Files
kandev/apps/web/e2e/tests/task/add-workspace-sources.spec.ts
T

396 lines
17 KiB
TypeScript

import { expect, test } from "../../fixtures/test-base";
import type { Locator, Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { ApiClient } from "../../helpers/api-client";
import { waitForHttp } from "../../helpers/causal-waits";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import { mockFolderAvailability } from "../../helpers/open-task-folder";
const WORKSPACE_SOURCES_PATH = /^\/api\/v1\/tasks\/[^/]+\/workspace-sources$/;
async function chooseDirectory(
page: Page,
trigger: Locator,
pickerRoot: string,
directory: string,
) {
const relativeDirectory = path.relative(pickerRoot, directory);
if (relativeDirectory === ".." || relativeDirectory.startsWith(`..${path.sep}`)) {
throw new Error(`Directory ${directory} is outside picker root ${pickerRoot}`);
}
await trigger.click();
const picker = page.locator('[data-testid="folder-picker-popover"][data-state="open"]');
await expect(picker).toBeVisible();
for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
await picker.getByRole("button", { name: segment, exact: true }).click();
}
await picker.getByTestId("folder-picker-choose").click();
}
function createSourceDirectories(root: string) {
const gitEnv = makeGitEnv(root);
const repositoryPath = path.join(root, "sources", "second-local-repository");
const originPath = path.join(root, "sources", "second-local-repository-origin.git");
const folderPath = path.join(root, "sources", "plain-local-folder");
fs.mkdirSync(repositoryPath, { recursive: true });
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path.join(repositoryPath, "second-source.txt"), "repository source\n");
fs.writeFileSync(path.join(folderPath, "folder-source.txt"), "folder source\n");
execFileSync("git", ["init", "-b", "main"], { cwd: repositoryPath, env: gitEnv });
execFileSync("git", ["add", "."], { cwd: repositoryPath, env: gitEnv });
execFileSync("git", ["commit", "-m", "initial source"], { cwd: repositoryPath, env: gitEnv });
// Workspace-source materialization refreshes required branches before it
// creates the attached worktree. Give this local fixture a real offline
// origin so that refresh succeeds deterministically.
execFileSync("git", ["init", "--bare", "-b", "main", originPath], { env: gitEnv });
execFileSync("git", ["remote", "add", "origin", originPath], {
cwd: repositoryPath,
env: gitEnv,
});
execFileSync("git", ["push", "--set-upstream", "origin", "main"], {
cwd: repositoryPath,
env: gitEnv,
});
return { repositoryPath, folderPath };
}
function activeFileEditor(page: Page) {
return page.locator(".monaco-editor:visible").first();
}
function activeFileTab(page: Page, filename: string) {
return page.locator(".dv-tab.dv-active-tab", { hasText: filename }).first();
}
async function submitWorkspaceSources(page: Page, submit: Locator) {
const responsePromise = waitForHttp(page, "POST", WORKSPACE_SOURCES_PATH);
await submit.click();
const response = await responsePromise;
expect(response.ok()).toBe(true);
await response.finished();
}
async function waitForWorkspaceReady(
apiClient: ApiClient,
taskId: string,
sessionId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
return sessions.find((session) => session.id === sessionId)?.workspace_path ?? "";
},
{
timeout: 60_000,
message: "task workspace should be ready before opening the session",
},
)
.toMatch(/\S/);
}
test.describe("Attach local workspace sources", () => {
test.beforeEach(async ({ testPage }) => {
await mockFolderAvailability(testPage, true);
await testPage.route("**/api/v1/task-sessions/*/open-folder", (route) =>
route.fulfill({ json: { success: true } }),
);
});
test("adds a local repository and folder successively, scopes Changes to Git, and persists", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
test.setTimeout(120_000);
const { repositoryPath, folderPath } = createSourceDirectories(backend.tmpDir);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Attach mixed local sources",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
if (!task.session_id) throw new Error("task creation did not return a session id");
await waitForWorkspaceReady(apiClient, task.id, task.session_id);
// The "Add folder" control is gated on the task's primary executor
// binding. Poll the backend directly for it rather than relying on the
// later "Add folder" click landing after it incidentally.
// `primary_executor_type` is `omitempty`, so an unbound task omits the key
// entirely: assert truthiness, never `not.toBeNull()`, which `undefined`
// satisfies on the first poll.
await expect
.poll(async () => (await apiClient.getTask(task.id)).primary_executor_type, {
timeout: 30_000,
})
.toBeTruthy();
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 60_000 });
await session.clickTab("Files");
const workspaceActions = testPage.getByTestId("files-workspace-actions");
await expect(workspaceActions).toBeEnabled();
await expect(workspaceActions).toHaveAccessibleName("Workspace actions");
const workspaceActionsBox = await workspaceActions.boundingBox();
expect(workspaceActionsBox).not.toBeNull();
expect(workspaceActionsBox!.width).toBeLessThanOrEqual(44);
await workspaceActions.click();
const addSources = testPage.getByRole("menuitem", {
name: "Add Repositories to workspace",
});
const openFolder = testPage.getByRole("menuitem", { name: "Open workspace folder" });
await expect(addSources).toBeEnabled();
await expect(openFolder).toBeEnabled();
await Promise.all([
testPage.waitForRequest(
(request) =>
request.method() === "POST" &&
request.url().endsWith(`/api/v1/task-sessions/${task.session_id}/open-folder`),
),
openFolder.click(),
]);
await expect(openFolder).not.toBeVisible();
await workspaceActions.click();
await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click();
const dialog = testPage.getByTestId("add-workspace-sources-dialog");
await expect(dialog).toBeVisible();
const [dialogBox, viewport] = await Promise.all([
dialog.boundingBox(),
testPage.evaluate(() => ({ width: innerWidth, height: innerHeight })),
]);
expect(dialogBox).not.toBeNull();
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewport.height);
await expect(dialog.getByTestId("add-workspace-sources-dialog-scroll")).toHaveCSS(
"overflow-y",
"auto",
);
const consequences = dialog.getByTestId("workspace-change-consequences");
await expect(consequences).toBeVisible();
await expect(consequences).toContainText("This restarts the task workspace");
await expect(consequences).toContainText("Cancel leaves the workspace unchanged");
await expect(consequences).toContainText(
"terminals, dev servers, and other workspace processes stop",
);
const fullImpactDetails = consequences.getByText("Full impact details");
await fullImpactDetails.click();
await expect(
consequences.getByText(/The task root becomes the agent's working directory/),
).toBeVisible();
await fullImpactDetails.click();
await expect(dialog.getByTestId("source-mode-local")).toHaveCount(0);
const addRepository = dialog.getByRole("button", { name: "Add repository" });
const submit = dialog.getByTestId("add-workspace-sources-submit");
await expect(submit).toBeDisabled();
await addRepository.click();
await expect(testPage.getByRole("menuitem", { name: "Local Git repository" })).toBeVisible();
await expect(testPage.getByRole("menuitem", { name: "Remote repository" })).toBeVisible();
await testPage.getByRole("menuitem", { name: "Workspace repository" }).click();
const savedRepositoryRow = dialog.getByTestId("workspace-source-row").first();
await expect(savedRepositoryRow.getByRole("alert")).toHaveCount(0);
await expect(savedRepositoryRow.getByRole("textbox", { name: "Checkout branch" })).toHaveCount(
0,
);
await savedRepositoryRow.getByTestId("repo-chip-trigger").click();
await expect(testPage.getByTestId("repo-refresh-button")).toBeVisible();
await expect(testPage.getByTestId("create-local-repository-button")).toBeVisible();
await testPage.keyboard.press("Escape");
await submit.click();
const savedRepositoryError = savedRepositoryRow.getByRole("alert");
await expect(savedRepositoryError).toHaveText("Choose a repository and base branch.");
await expect(savedRepositoryError).toHaveCSS("font-size", "12px");
await savedRepositoryRow.getByRole("button", { name: "Remove source" }).click();
await addRepository.click();
await testPage.getByRole("menuitem", { name: "Local Git repository" }).click();
const repositoryRow = dialog.getByTestId("workspace-source-row");
await chooseDirectory(
testPage,
repositoryRow.getByTestId("folder-picker-trigger"),
backend.tmpDir,
repositoryPath,
);
await repositoryRow.getByRole("textbox", { name: "Base branch" }).fill("main");
await submitWorkspaceSources(testPage, submit);
await expect(dialog).not.toBeVisible();
await expect(
session.files
.getByTestId("file-tree-node")
.filter({ hasText: "second-local-repository-main" }),
).toBeVisible({ timeout: 30_000 });
if (!task.session_id) throw new Error("task creation did not return a session id");
const turnsAfterFirstAttachment = await apiClient.listSessionTurns(task.session_id);
expect(turnsAfterFirstAttachment.turns.filter((turn) => !turn.completed_at)).toEqual([]);
await workspaceActions.click();
await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Add folder" }).click();
const folderRow = dialog.getByTestId("workspace-source-row");
await chooseDirectory(
testPage,
folderRow.getByTestId("folder-picker-trigger"),
backend.tmpDir,
folderPath,
);
await prCapture.screenshot("workspace-actions-mixed-sources", {
caption: "Desktop Add to workspace dialog with a local folder configured",
});
await submitWorkspaceSources(testPage, submit);
await expect(dialog).not.toBeVisible();
await expect(
session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }),
).toBeVisible();
const sessionData = (await apiClient.listTaskSessions(task.id)) as {
sessions: Array<{ worktrees?: Array<{ worktree_path?: string }> }>;
};
const repoPaths = (sessionData.sessions[0]?.worktrees ?? []).flatMap((worktree) =>
worktree.worktree_path ? [worktree.worktree_path] : [],
);
expect(repoPaths).toHaveLength(2);
for (const [index, repoPath] of repoPaths.entries()) {
new GitHelper(repoPath, makeGitEnv(backend.tmpDir)).createFile(
`changes/repository-${index}.txt`,
`repository ${index}\n`,
);
}
await session.clickTab("Changes");
const changes = session.changes;
await expect(changes.getByTestId("changes-repo-group")).toHaveCount(2, { timeout: 30_000 });
await expect(
changes.getByTestId("changes-repo-header").filter({ hasText: "second-local-repository" }),
).toBeVisible();
await expect(changes.getByText("plain-local-folder", { exact: true })).not.toBeVisible();
await testPage.reload();
await session.waitForLoad();
const refreshedSessions = await apiClient.listTaskSessions(task.id);
const refreshedPrimarySession = refreshedSessions.sessions.find(
({ id }) => id === task.session_id,
);
expect(refreshedPrimarySession).toMatchObject({
workspace_path: path.dirname(repoPaths[0]),
worktree_path: repoPaths[0],
});
await session.clickTab("Files");
await expect(
session.files
.getByTestId("file-tree-node")
.filter({ hasText: "second-local-repository-main" }),
).toBeVisible({ timeout: 30_000 });
await expect(
session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }),
).toBeVisible();
// Chat links in a multi-repository workspace are absolute on the host but
// must resolve relative to the task root (not the primary repository).
const taskState = await apiClient.getTask(task.id);
const activeSessionId = taskState.primary_session_id;
if (!activeSessionId) throw new Error("task has no primary session after source attachment");
const linkedRepoPath = repoPaths.find((repoPath) =>
repoPath.endsWith("second-local-repository-main"),
);
if (!linkedRepoPath) throw new Error("attached repository worktree path was not returned");
const primaryFilePath = path.join(repoPaths[0], "changes/repository-0.txt");
const linkedFilePath = path.join(linkedRepoPath, "second-source.txt");
const registeredSourceFilePath = path.join(repositoryPath, "second-source.txt");
fs.writeFileSync(linkedFilePath, "active worktree source\n");
await apiClient.seedSessionMessage(activeSessionId, {
type: "message",
content: `[registered source](${registeredSourceFilePath}:1) [primary source](${primaryFilePath}) [second source](${linkedFilePath})`,
});
await testPage.reload();
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
await session.showSessionContext();
const primaryChatLink = session.activeChat().getByRole("link", { name: "primary source" });
const registeredChatLink = session
.activeChat()
.getByRole("link", { name: "registered source" });
const siblingChatLink = session.activeChat().getByRole("link", { name: "second source" });
await expect(registeredChatLink).toBeVisible({ timeout: 15_000 });
await expect(primaryChatLink).toBeVisible({ timeout: 15_000 });
await expect(siblingChatLink).toBeVisible({ timeout: 15_000 });
const taskUrlBeforeSourceLink = testPage.url();
await registeredChatLink.click();
const fileEditor = activeFileEditor(testPage);
await expect(fileEditor).toBeVisible({ timeout: 15_000 });
await expect(fileEditor.locator(".view-lines")).toContainText("active worktree source");
await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 });
expect(testPage.url()).toBe(taskUrlBeforeSourceLink);
await prCapture.screenshot("registered-source-file-opened-desktop", {
caption: "Desktop file tab showing content from the active task worktree",
});
await session.showSessionContext();
await prCapture.screenshot("registered-source-link-opened-desktop", {
caption: "Desktop task transcript link with the matching active-worktree file tab open",
});
await primaryChatLink.click();
await expect(fileEditor).toBeVisible({ timeout: 15_000 });
await expect(fileEditor.locator(".view-lines")).toContainText("repository 0");
await expect(activeFileTab(testPage, "repository-0.txt")).toBeVisible({ timeout: 15_000 });
await session.showSessionContext();
await session.activeChat().getByRole("link", { name: "second source" }).click();
await expect(fileEditor.locator(".view-lines")).toContainText("active worktree source");
await expect(activeFileTab(testPage, "second-source.txt")).toBeVisible({ timeout: 15_000 });
});
test("explains the disabled action while an active turn is running", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Busy source attachment gate",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
await session.sendMessage("/slow 8s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await session.clickTab("Files");
const workspaceActions = testPage.getByTestId("files-workspace-actions");
await expect(workspaceActions).toBeEnabled();
await workspaceActions.click();
const action = testPage.getByRole("menuitem", {
name: "Add Repositories to workspace",
});
await expect(action).toBeDisabled();
await expect(action).toContainText(
"Wait for the active turn or tool call to finish before adding sources.",
);
await expect(testPage.getByRole("menuitem", { name: "Open workspace folder" })).toBeEnabled();
});
});