Files
kandev/apps/web/e2e/tests/session/setup-script-progress.spec.ts
T
2026-08-26 23:00:36 +00:00

385 lines
16 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Verifies the UX for the inline prepare-progress card that renders below the
* initial user prompt:
* - While running: card is expanded and streams the setup script output.
* - On clean success: card auto-collapses (still visible as a summary row).
* - On step failure: card stays expanded so the error is immediately visible;
* user can click to collapse.
* - Without a setup script: card still renders, auto-collapses on success.
*
* Each test creates a fresh executor profile carrying the desired prepare
* script — this hits the same `runSetupScript` path users exercise via the
* repository setup_script placeholder in profile templates, without mutating
* the worker-scoped seed profile (avoids cross-test contamination).
*/
test.describe("Setup script progress UX", () => {
async function createWorktreeProfileWithScript(
apiClient: import("../../helpers/api-client").ApiClient,
script: string,
name: string,
): Promise<{ id: string }> {
const { executors } = await apiClient.listExecutors();
const worktreeExec = executors.find((e) => e.type === "worktree");
if (!worktreeExec) throw new Error("No worktree executor found");
return apiClient.createExecutorProfile(worktreeExec.id, name, { prepare_script: script });
}
test("streams setup script output while running, collapses after success", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// Gate the preceding fetch until the browser subscribes, then hold the
// setup script so its preparing state and streamed output stay observable.
const gateID = Date.now();
const gitGateFile = path.join(backend.tmpDir, "git-delay-ms");
const gitStartedFile = path.join(backend.tmpDir, `git-started-${gateID}`);
const gitReleaseFile = path.join(backend.tmpDir, `git-release-${gateID}`);
const startedFile = path.join(backend.tmpDir, `setup-started-${gateID}`);
const releaseFile = path.join(backend.tmpDir, `setup-release-${gateID}`);
let profile: { id: string } | null = null;
try {
fs.writeFileSync(
gitGateFile,
JSON.stringify({ startedFile: gitStartedFile, releaseFile: gitReleaseFile }),
);
const setupScript = [
"echo '[setup] installing deps'",
`touch '${startedFile}'`,
`while [ ! -f '${releaseFile}' ]; do sleep 0.1; done`,
"echo '[setup] ready'",
].join("; ");
profile = await createWorktreeProfileWithScript(
apiClient,
setupScript,
`E2E Streaming ${Date.now()}`,
);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Setup Script Streaming",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: profile.id,
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect
.poll(() => fs.existsSync(gitStartedFile), {
message: "repository preparation should reach its deterministic git gate",
timeout: 45_000,
})
.toBe(true);
fs.writeFileSync(gitReleaseFile, "release");
await expect
.poll(() => fs.existsSync(startedFile), {
message: "setup script should reach its deterministic test gate",
timeout: 45_000,
})
.toBe(true);
// Panel appears inline in the chat, expanded while running.
const panel = testPage.getByTestId("prepare-progress-panel");
await expect(panel).toBeVisible({ timeout: 15_000 });
await expect(panel).toHaveAttribute("data-status", "preparing");
await expect(panel).toHaveAttribute("data-expanded", "true");
await expect(panel.getByTestId("prepare-progress-header-spinner")).toBeVisible();
// Setup script output reaches the expanded step list — either streamed
// in real time or captured from the final `prepare.completed` payload.
await expect(panel).toContainText("[setup] installing deps", { timeout: 30_000 });
fs.writeFileSync(releaseFile, "release");
// On clean success the panel stays visible but auto-collapses.
await expect(panel).toHaveAttribute("data-status", "completed", { timeout: 30_000 });
await expect(panel).toHaveAttribute("data-expanded", "false");
} finally {
// Always release a setup process that is still waiting before teardown.
if (!fs.existsSync(gitReleaseFile)) fs.writeFileSync(gitReleaseFile, "release");
if (!fs.existsSync(releaseFile)) fs.writeFileSync(releaseFile, "release");
if (profile) {
await apiClient.deleteExecutorProfile(profile.id).catch(() => {
// Profile may already be deleted if the test tore down mid-run.
});
}
if (fs.existsSync(gitGateFile)) fs.unlinkSync(gitGateFile);
if (fs.existsSync(gitStartedFile)) fs.unlinkSync(gitStartedFile);
if (fs.existsSync(gitReleaseFile)) fs.unlinkSync(gitReleaseFile);
if (fs.existsSync(startedFile)) fs.unlinkSync(startedFile);
if (fs.existsSync(releaseFile)) fs.unlinkSync(releaseFile);
}
});
test("keeps setup script failure details collapsed until requested", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Exit non-zero with output on both streams — the panel must surface
// both so the user can diagnose without diving into backend logs. The
// upfront sleep gives the page + WS time to subscribe before the script
// terminates; without it the failure fires faster than the frontend can
// connect and we miss every event.
const failingScript = [
"echo '[setup] starting install'",
"sleep 4",
"echo 'ENOENT: package-lock.json missing' 1>&2",
"exit 1",
].join("; ");
const profile = await createWorktreeProfileWithScript(
apiClient,
failingScript,
`E2E Failing ${Date.now()}`,
);
try {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Setup Script Failure",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: profile.id,
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const panel = testPage.getByTestId("prepare-progress-panel");
// Setup script failures are non-fatal (agent still starts), so the panel
// transitions preparing → completed_with_error rather than the fatal
// `failed` state. The concise error state is visible immediately while
// command output remains collapsed until the user requests it.
await expect(panel).toBeVisible({ timeout: 15_000 });
await expect(panel).toHaveAttribute("data-status", "completed_with_error", {
timeout: 30_000,
});
await expect(panel).toHaveAttribute("data-expanded", "false");
const output = panel.locator("pre").filter({ hasText: /^\[setup\] starting install/ });
await expect(panel.getByRole("button", { name: "Show preparation details" })).toBeVisible();
await expect(output).toBeHidden();
// Both streams remain available after the user opens the disclosure.
await panel.getByRole("button", { name: "Show preparation details" }).click();
await expect(panel).toHaveAttribute("data-expanded", "true");
await expect(output).toBeVisible();
await expect(output).toContainText("[setup] starting install");
await expect(output).toContainText("ENOENT: package-lock.json missing");
} finally {
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
}
});
test("prepare panel persists after page refresh", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
const delayFile = path.join(backend.tmpDir, "git-delay-ms");
const setupScript = "echo '[setup] persistence check'";
const profile = await createWorktreeProfileWithScript(
apiClient,
setupScript,
`E2E Persist ${Date.now()}`,
);
try {
fs.writeFileSync(delayFile, "5000");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Prepare Panel Persist",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: profile.id,
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const panel = testPage.getByTestId("prepare-progress-panel");
// Wait for preparation to complete.
await expect(panel).toBeVisible({ timeout: 15_000 });
await expect(panel).toHaveAttribute("data-status", "completed", { timeout: 30_000 });
// Expand the panel (auto-collapsed on success) and verify step data.
await panel.getByTestId("prepare-progress-toggle").click();
await expect(panel).toContainText("Validate repository", { timeout: 5_000 });
await expect(panel).toContainText("Run setup script", { timeout: 5_000 });
// Reload the page — the panel AND step data must survive the round-trip through SSR.
await testPage.reload();
await session.waitForLoad();
const panelAfterReload = testPage.getByTestId("prepare-progress-panel");
await expect(panelAfterReload).toBeVisible({ timeout: 10_000 });
// Status should still be "completed" (not "preparing").
await expect(panelAfterReload).toHaveAttribute("data-status", "completed");
// Step details must persist — not just the panel header.
// Expand the panel to see steps (auto-collapsed on success).
await panelAfterReload.getByTestId("prepare-progress-toggle").click();
await expect(panelAfterReload).toContainText("Validate repository");
await expect(panelAfterReload).toContainText("Run setup script");
} finally {
if (fs.existsSync(delayFile)) fs.unlinkSync(delayFile);
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
}
});
test("repository setup script failure is non-fatal — agent still launches", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
// This exercises the REPOSITORY-level setup_script (run inside
// worktree.Manager.Create via the script handler), which is a different
// path from the profile prepare_script the tests above cover. Before the
// fix, a non-zero exit aborted environment preparation and stopped the
// agent ("Environment setup failed"); the Resume / Start-fresh buttons
// then couldn't recover because each relaunch re-ran the same failing
// script. The script must now be non-fatal: the worktree is kept, the
// failure shows as a warning, and the agent launches normally.
const failingScript = [
"echo '[repo-setup] installing deps'",
"sleep 4",
"echo 'make: *** No rule to make target `i2nstall`. Stop.' 1>&2",
"exit 2",
].join("; ");
await apiClient.updateRepository(seedData.repositoryId, { setup_script: failingScript });
try {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Repo Setup Script Failure",
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();
const panel = testPage.getByTestId("prepare-progress-panel");
await expect(panel).toBeVisible({ timeout: 15_000 });
// The failure is surfaced as a non-fatal warning, NOT the fatal `failed`
// state — preparation completes and the worktree survives.
await expect(panel).toHaveAttribute("data-status", "completed_with_warnings", {
timeout: 45_000,
});
// The failure is surfaced as a warning banner on the worktree step so
// the user knows the setup script didn't run — without it blocking the
// launch. (The full script output also streams to a separate
// script_execution chat message.)
await expect(panel.getByTestId("prepare-warning-banner")).toContainText(
"Repository setup script failed",
);
// The core regression: the agent must launch and run to completion
// instead of stopping. Reaching chat-idle proves the turn finished and
// the session is awaiting input — preparation did not abort the launch.
await session.waitForChatIdle();
// The "agent stopped" recovery affordances must not appear — the task
// continued normally.
await expect(session.recoveryResumeButton()).toBeHidden();
await expect(session.recoveryFreshButton()).toBeHidden();
} finally {
// Reset the worker-scoped seed repo so sibling specs aren't contaminated.
await apiClient.updateRepository(seedData.repositoryId, { setup_script: "" }).catch(() => {});
}
});
test("renders collapsed on success when no setup script is configured", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(60_000);
// Slow the git fetch so prepare takes long enough for the page+WS to
// connect before the completed event fires — without this the events
// broadcast before the client is subscribed, and the panel never shows.
const delayFile = path.join(backend.tmpDir, "git-delay-ms");
try {
fs.writeFileSync(delayFile, "5000");
// Default seeded worktree profile — empty prepare_script. The worktree
// validate/sync/create steps still run (card appears), and with no
// error/warning the card auto-collapses once done.
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"No Setup Script",
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();
// Panel stays visible but auto-collapses on a clean run. This replaces
// the pre-redesign "disappears entirely" behavior — users want to be
// able to expand it and read what ran even on a happy path.
const panel = testPage.getByTestId("prepare-progress-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel).toHaveAttribute("data-status", "completed", { timeout: 30_000 });
await expect(panel).toHaveAttribute("data-expanded", "false");
} finally {
if (fs.existsSync(delayFile)) fs.unlinkSync(delayFile);
}
});
});