Files
kandev/apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts
T
2026-09-12 16:29:16 +01:00

285 lines
11 KiB
TypeScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { useRegularMode } from "../../helpers/regular-mode";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
import { exerciseMultiRowCreation } from "./local-repository-multi-row-helpers";
useRegularMode();
type RepositoryRecord = { id: string; local_path: string; default_branch: string };
async function persistedRepository(
apiClient: ApiClient,
workspaceId: string,
repositoryPath: string,
): Promise<RepositoryRecord | undefined> {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${workspaceId}/repositories`,
);
expect(response.ok).toBe(true);
const body = (await response.json()) as { repositories: RepositoryRecord[] };
return body.repositories.find((repository) => repository.local_path === repositoryPath);
}
async function openCreateTask(page: Page): Promise<void> {
const mobile = new MobileKanbanPage(page);
await mobile.goto();
await mobile.mobileFab.click();
await expect(page.getByTestId("create-task-dialog")).toBeVisible();
}
async function openCreationDrawer(page: Page): Promise<Locator> {
const trigger = page.getByTestId("repo-chip-trigger");
await trigger.click();
const search = page.getByPlaceholder("Search repositories...");
const refresh = page.getByTestId("repo-refresh-button");
const action = page.getByTestId("create-local-repository-button");
await expect(search).toBeVisible();
await expect(refresh).toBeVisible();
await expect(action).toBeVisible();
const searchControl = search.locator("..");
let previousLayoutKey: string | null = null;
let stableLayoutReads = 0;
await expect
.poll(
async () => {
const [searchControlBox, refreshBox, actionBox] = await Promise.all([
searchControl.boundingBox(),
refresh.boundingBox(),
action.boundingBox(),
]);
if (!searchControlBox || !refreshBox || !actionBox) return false;
const refreshGap = refreshBox.x - (searchControlBox.x + searchControlBox.width);
const validLayout =
searchControlBox.width > refreshBox.width + actionBox.width &&
refreshGap >= 0 &&
refreshGap <= 8 &&
refreshBox.height >= 44 &&
actionBox.height >= 44;
if (!validLayout) {
previousLayoutKey = null;
stableLayoutReads = 0;
return false;
}
const layoutKey = [
searchControlBox.x,
searchControlBox.y,
searchControlBox.width,
searchControlBox.height,
refreshBox.x,
refreshBox.y,
refreshBox.width,
refreshBox.height,
actionBox.x,
actionBox.y,
actionBox.width,
actionBox.height,
].join(":");
stableLayoutReads = layoutKey === previousLayoutKey ? stableLayoutReads + 1 : 1;
previousLayoutKey = layoutKey;
return stableLayoutReads >= 2;
},
{
timeout: 15_000,
message: "repository picker toolbar did not settle into its mobile layout",
},
)
.toBe(true);
await action.click();
const drawer = page.getByTestId("create-local-repository-drawer");
await expect(drawer).toBeVisible();
return drawer;
}
async function expectDrawerGeometry(page: Page, drawer: Locator): Promise<void> {
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
await expect
.poll(async () => {
const box = await drawer.boundingBox();
return !!box && box.y >= 0 && box.y + box.height <= viewport!.height;
})
.toBe(true);
const drawerBox = await drawer.boundingBox();
expect(drawerBox).not.toBeNull();
expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(drawerBox!.y).toBeGreaterThanOrEqual(0);
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(viewport!.height);
const scrollOwners = await drawer.locator("*").evaluateAll((elements) =>
elements
.filter((element) => ["auto", "scroll"].includes(getComputedStyle(element).overflowY))
.map((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
})),
);
expect(scrollOwners).toHaveLength(1);
expect(scrollOwners[0].scrollHeight).toBeGreaterThan(scrollOwners[0].clientHeight);
const entryBox = await drawer.getByTestId("folder-picker-entry").first().boundingBox();
const createButton = drawer.getByRole("button", { name: "Create repository" });
const [createBox, footerBox] = await Promise.all([
createButton.boundingBox(),
createButton.locator("..").boundingBox(),
]);
expect(entryBox).not.toBeNull();
expect(createBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(entryBox!.height).toBeGreaterThanOrEqual(44);
expect(createBox!.height).toBeGreaterThanOrEqual(44);
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
),
).toBe(false);
}
function expectMainRepository(repositoryPath: string): void {
const symbolicRef = spawnSync("git", ["symbolic-ref", "--short", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(symbolicRef.error).toBeUndefined();
expect(symbolicRef.status).toBe(0);
expect(String(symbolicRef.stdout).trim()).toBe("main");
const branchRef = spawnSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: repositoryPath,
});
expect(branchRef.error).toBeUndefined();
expect(branchRef.status).toBe(0);
const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
expect(head.error).toBeUndefined();
expect(head.status).toBe(0);
const commitCount = spawnSync("git", ["rev-list", "--count", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(commitCount.error).toBeUndefined();
expect(commitCount.status).toBe(0);
expect(String(commitCount.stdout).trim()).toBe("1");
const tree = spawnSync("git", ["ls-tree", "-r", "--name-only", "HEAD"], {
cwd: repositoryPath,
encoding: "utf8",
});
expect(tree.error).toBeUndefined();
expect(tree.status).toBe(0);
expect(String(tree.stdout).trim()).toBe("");
}
test.describe("Create task with a new local repository on mobile", () => {
test("refreshes and creates from a second repository row", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
await openCreateTask(testPage);
await exerciseMultiRowCreation(testPage, apiClient, {
workspaceId: seedData.workspaceId,
parentPath: backend.tmpDir,
mobile: true,
});
});
test("creates and selects the repository in a contained, scrollable drawer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repositoryName = "mobile-real-main";
const parentName = "mobile-created-parent";
const parentPath = path.join(backend.tmpDir, parentName);
const repositoryPath = path.join(parentPath, repositoryName);
for (let index = 0; index < 18; index += 1) {
fs.mkdirSync(path.join(backend.tmpDir, `mobile-parent-${String(index).padStart(2, "0")}`));
}
const { executors } = await apiClient.listExecutors();
const directExecutor = executors.find((executor) =>
["local", "local_pc"].includes(executor.type),
);
const directProfile = directExecutor?.profiles?.[0];
expect(
directProfile,
"a direct local executor profile is required by the fixture",
).toBeDefined();
await openCreateTask(testPage);
await testPage.getByTestId("task-title-input").fill("Mobile task on a new repository");
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
let drawer = await openCreationDrawer(testPage);
await expect(testPage.getByRole("textbox", { name: "Repository name" })).toBeFocused();
await testPage.getByRole("textbox", { name: "Repository name" }).fill("dismissed-name");
await testPage.keyboard.press("Escape");
await expect(drawer).not.toBeVisible();
await expect(testPage.getByTestId("repo-chip-trigger")).toBeFocused();
expect(fs.existsSync(path.join(backend.tmpDir, "dismissed-name"))).toBe(false);
await expect(testPage.getByTestId("task-title-input")).toHaveValue(
"Mobile task on a new repository",
);
drawer = await openCreationDrawer(testPage);
await expectDrawerGeometry(testPage, drawer);
const parentInput = drawer.getByRole("textbox", { name: "Parent directory" });
await parentInput.fill(backend.tmpDir);
await parentInput.press("Enter");
await drawer.getByRole("button", { name: "New folder" }).click();
await drawer.getByRole("textbox", { name: "New folder name" }).fill(parentName);
await drawer.getByRole("button", { name: "Create folder" }).click();
await expect(parentInput).toHaveValue(parentPath);
const nameInput = testPage.getByRole("textbox", { name: "Repository name" });
await nameInput.fill(repositoryName);
await expect(testPage.getByTitle(repositoryPath)).toBeVisible();
await drawer.getByRole("button", { name: "Create repository" }).click();
await expect(drawer).not.toBeVisible();
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
const branchSelector = testPage.getByTestId("branch-chip-trigger").first();
await expect(branchSelector).toBeEnabled({ timeout: 10_000 });
await branchSelector.tap();
const mainOption = testPage.getByRole("option", { name: /^main\b/ }).first();
await expect(mainOption).toBeVisible();
await mainOption.tap();
await expect(branchSelector).toContainText("main");
await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
directExecutor!.name,
);
await expect(testPage.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
const taskResponsePromise = testPage.waitForResponse(
(response) =>
response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
);
await testPage.getByTestId("submit-start-agent").click();
const taskResponse = await taskResponsePromise;
expect(taskResponse.ok()).toBe(true);
const createdTask = (await taskResponse.json()) as { id: string };
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible();
const repository = await persistedRepository(apiClient, seedData.workspaceId, repositoryPath);
expect(repository).toMatchObject({ local_path: repositoryPath, default_branch: "main" });
const taskId = createdTask.id;
expect((await apiClient.getTask(taskId)).repositories).toEqual(
expect.arrayContaining([expect.objectContaining({ repository_id: repository!.id })]),
);
await expect
.poll(async () => await apiClient.getTaskEnvironment(taskId), { timeout: 20_000 })
.toMatchObject({
executor_type: expect.stringMatching(/^(local|local_pc)$/),
executor_profile_id: directProfile!.id,
});
expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
expectMainRepository(repositoryPath);
});
});