Files
kandev/apps/web/e2e/tests/task/file-tree-create.spec.ts
T
2026-09-16 10:38:31 +01:00

260 lines
8.8 KiB
TypeScript

import { type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../../helpers/git-helper";
// File creation lives in file-browser-toolbar.tsx ("New file" button) +
// inline-file-input.tsx (InlineFileInput) + file-browser.tsx
// (handleStartCreate / handleCreateFileSubmit).
// Behaviour to lock in:
// - "New file" at the tree root creates a file at root
// - After expanding a folder, "New file" creates inside that folder
// - Typing a path-like name (subdir/file.ts) creates implicit parent folders
// - Escape cancels the input
// There is no separate "create folder" affordance today; users create folders
// implicitly by including a "/" in the new-file name.
async function setupTask(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
options: { profileName: string; taskTitle: string; requiredPath?: string },
) {
const profile = await createStandardProfile(apiClient, options.profileName);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
options.taskTitle,
profile.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (options.requiredPath) {
let workspacePath = "";
await expect
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.status ?? null, {
timeout: 30_000,
message: `Waiting for ${options.taskTitle} task environment to be ready`,
})
.toBe("ready");
await expect
.poll(
async () => {
const environment = await apiClient.getTaskEnvironment(task.id);
workspacePath =
environment?.workspace_path ?? environment?.repos?.[0]?.worktree_path ?? "";
return Boolean(
workspacePath && fs.existsSync(path.join(workspacePath, options.requiredPath!)),
);
},
{
timeout: 60_000,
message: `Waiting for ${options.requiredPath} in the ${options.taskTitle} worktree`,
},
)
.toBe(true);
}
const session = await openTaskSession(testPage, options.taskTitle);
await session.clickTab("Files");
return session;
}
async function startCreateAtRoot(testPage: Page) {
const btn = testPage.getByRole("button", { name: "New file" });
if (await btn.isVisible().catch(() => false)) {
await btn.click();
} else {
const createMenu = testPage.getByTestId("files-create-menu");
await expect(createMenu).toBeVisible({ timeout: 15_000 });
await createMenu.click();
const menuItem = testPage.getByRole("menuitem", { name: "New file" });
await expect(menuItem).toBeVisible({ timeout: 5_000 });
await menuItem.click();
await expect(menuItem).toHaveCount(0);
}
const input = testPage.getByPlaceholder("filename...");
await expect(input).toBeVisible({ timeout: 5_000 });
await expect(input).toBeFocused({ timeout: 2_000 });
return input;
}
test.describe("File tree create file", () => {
test("New file at root creates a file on disk and in the tree", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
// Seed at least one file so the tree loads. Without any files the tree
// shows "No files found" instead of the toolbar.
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(testPage, apiClient, seedData, {
profileName: "ft-create-root",
taskTitle: "FT Create Root",
requiredPath: "seed.ts",
});
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("brand-new.ts");
await input.press("Enter");
await expect(session.fileTreeNode("brand-new.ts")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fs.existsSync(path.join(repoDir, "brand-new.ts")), { timeout: 10_000 })
.toBe(true);
});
test("select-all stays in the new-file name input", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("select-all-alpha.ts", "alpha");
git.createFile("select-all-beta.ts", "beta");
git.stageAll();
git.commit("seed select-all files");
const session = await setupTask(testPage, apiClient, seedData, {
profileName: "ft-create-select-all",
taskTitle: "FT Create Select All",
requiredPath: "select-all-alpha.ts",
});
await expect(session.fileTreeNode("select-all-alpha.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
const draftName = "draft-name.ts";
await input.fill(draftName);
await input.press("ControlOrMeta+a");
await expect
.poll(async () => ({
selection: await input.evaluate((element) => ({
start: element.selectionStart,
end: element.selectionEnd,
})),
selectedRows: await session.fileTreeSelectedNodes().count(),
}))
.toEqual({
selection: { start: 0, end: draftName.length },
selectedRows: 0,
});
});
test("New file inside expanded folder creates the file in that folder", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("scope/existing.ts", "x");
git.stageAll();
git.commit("seed scope");
const session = await setupTask(testPage, apiClient, seedData, {
profileName: "ft-create-folder",
taskTitle: "FT Create In Folder",
requiredPath: "scope/existing.ts",
});
// Expand the folder so it becomes the "active folder" for handleStartCreate.
const folder = session.fileTreeNode("scope");
await expect(folder).toBeVisible({ timeout: 15_000 });
await folder.click();
await expect(session.fileTreeNode("scope/existing.ts")).toBeVisible({ timeout: 10_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("inside.ts");
await input.press("Enter");
await expect(session.fileTreeNode("scope/inside.ts")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fs.existsSync(path.join(repoDir, "scope", "inside.ts")), { timeout: 10_000 })
.toBe(true);
});
test("typing path-like name creates implicit parent folder", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(testPage, apiClient, seedData, {
profileName: "ft-create-implicit",
taskTitle: "FT Create Implicit Folder",
requiredPath: "seed.ts",
});
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("newdir/leaf.ts");
await input.press("Enter");
// The file appears under the new folder on disk.
await expect
.poll(() => fs.existsSync(path.join(repoDir, "newdir", "leaf.ts")), { timeout: 10_000 })
.toBe(true);
});
test("Escape cancels the inline input without creating a file", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("seed.ts", "seed");
git.stageAll();
git.commit("seed");
const session = await setupTask(testPage, apiClient, seedData, {
profileName: "ft-create-cancel",
taskTitle: "FT Create Cancel",
requiredPath: "seed.ts",
});
await expect(session.fileTreeNode("seed.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
await input.fill("ghost.ts");
await input.press("Escape");
await expect(testPage.getByPlaceholder("filename...")).toHaveCount(0, { timeout: 5_000 });
await expect(session.fileTreeNode("ghost.ts")).toHaveCount(0);
expect(fs.existsSync(path.join(repoDir, "ghost.ts"))).toBe(false);
});
});