Files
kandev/apps/web/e2e/tests/task/parked-background-work.spec.ts
T
2026-09-16 10:38:31 +01:00

280 lines
11 KiB
TypeScript

/**
* E2E coverage for the parked-on-background-work affordance (spec
* docs/specs/disambiguate-waiting/spec.md, task-09). The projection itself
* (probe port, sampling loop, revision ordering) is covered by Go unit tests
* in internal/orchestrator/parked_projection_test.go; these specs prove the
* whole pipeline end-to-end through a real backend + WebSocket + browser:
* BackgroundProbe answers -> parked projection -> session.activity_changed /
* task.updated -> store -> rendered icon, using the mock agent's
* `/parked-fixture` command plus the KANDEV_E2E_MOCK-gated
* POST /api/v1/_test/background-probe test harness route to script the
* probe's answers deterministically.
*
* `/parked-fixture [settleDelay]` registers a shell-kind detached background
* launch (run_in_background:true — the one condition the parked projection's
* attestation term actually recognises, see cmd/mock-agent/handler.go's
* emitParkedFixture) and delays the foreground turn by settleDelay first,
* giving the test a deterministic window to read the freshly-created
* session's ID and script its probe sequence *before* the turn settles and
* triggers the synchronous first probe sample (spec D2). The attestation
* persists until the session's next turn starts (spec D3), so every test
* below scripts the probe to hold "live" indefinitely (ScriptedBackgroundProbe
* holds at a sequence's last entry once exhausted) rather than racing a
* fixed-length sequence against its own setup latency — a settle-to-clear
* transition is driven by explicitly re-scripting the probe afterward.
*
* The shell-kind recogniser is registered only for agent ID "claude-acp"
* (internal/agentctl/server/adapter/transport/acp/background_launch_recognizer.go)
* — the default e2e mock agent's ACP session reports agent_type "mock-agent",
* which has no registered recogniser at all, so the default seedData agent
* profile can never attest. The suite opts into KANDEV_MOCK_PROVIDERS=claude-acp
* (same mechanism the office-routing-* specs use) so the mock binary is also
* registered under a "claude-acp" profile, then uses that profile for every
* task created below.
*/
import type { Locator } from "@playwright/test";
import { dwell } from "../../helpers/causal-waits";
import { expect, test } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
const PARKED_PROBE_INTERVAL = "1s";
const PARKED_FIXTURE_SCRIPT = "/parked-fixture 3s";
const AC73_MIN_LIVE_SAMPLES = 3;
/** Polls the scripted probe's call count for sessionId up to minSamples,
* asserting at every sample the affordance is visible and neither
* waiting-for-input nor turn-finished ever renders (AC-73: "at every sample
* point", not just once at the start or end). */
async function expectAffordanceHoldsAcrossLiveSamples(
apiClient: ApiClient,
parkedRow: Locator,
sessionId: string,
minSamples: number,
): Promise<void> {
const deadline = Date.now() + 20_000;
let calls = 0;
while (calls < minSamples) {
calls = await apiClient.backgroundProbeCallCount(sessionId);
await expect(parkedRow.getByTestId("task-state-background-running")).toBeVisible({
timeout: 2_000,
});
await expect(parkedRow.getByTestId("task-state-waiting-for-input")).toHaveCount(0);
await expect(parkedRow.getByTestId("task-state-turn-finished")).toHaveCount(0);
if (calls >= minSamples) break;
if (Date.now() > deadline) {
throw new Error(
`expected at least ${minSamples} live probe samples for ${sessionId}, got ${calls}`,
);
}
await dwell(200, "poll-interval", "polling the scripted probe's call count between samples");
}
}
async function waitForSessionState(
apiClient: ApiClient,
taskId: string,
state: string,
message: string,
): Promise<void> {
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
return sessions[0]?.state ?? "";
},
{ message, timeout: 30_000 },
)
.toBe(state);
}
/** First session row to appear for the task, regardless of state — used to
* script the probe before the foreground turn settles. */
async function waitForFirstSessionId(apiClient: ApiClient, taskId: string): Promise<string> {
let sessionId = "";
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
sessionId = sessions[0]?.id ?? "";
return sessionId;
},
{ message: "session row should be created", timeout: 30_000 },
)
.not.toBe("");
return sessionId;
}
/** Creates a fresh profile after the per-test cleanup has run. The suite
* restart registers the provider, while testPage cleanup removes every
* non-seed profile, so a profile captured during beforeAll may be gone before
* the task is created. */
async function createClaudeAcpProfile(apiClient: ApiClient): Promise<string> {
let agentId = "";
await expect
.poll(
async () => {
const { agents } = await apiClient.listAgents();
agentId = agents.find((agent) => agent.name === "claude-acp")?.id ?? "";
return agentId;
},
{ message: "claude-acp agent should be registered", timeout: 30_000 },
)
.not.toBe("");
const profile = await apiClient.createAgentProfile(agentId, `Parked E2E ${Date.now()}`, {
model: "mock-fast",
});
return profile.id;
}
/** Creates a task whose foreground turn registers a shell-kind detached
* launch after `settleDelay`, scripts its probe to hold "live" indefinitely
* before that settle can happen, then waits for the settle itself. */
async function createParkedTask(
apiClient: ApiClient,
seedData: { workspaceId: string; repositoryId: string; workflowId: string; startStepId: string },
agentProfileId: string,
title: string,
beforeSettle?: (taskId: string) => Promise<void>,
): Promise<{ taskId: string; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, agentProfileId, {
description: PARKED_FIXTURE_SCRIPT,
repository_ids: [seedData.repositoryId],
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
// The create response already carries the session id in the normal launch
// path. Use it immediately so slow session-list hydration cannot consume
// the fixture's settle window; retain polling for older API responses.
const sessionId = task.session_id ?? (await waitForFirstSessionId(apiClient, task.id));
await apiClient.scriptBackgroundProbe(sessionId, ["live"]);
if (beforeSettle) await beforeSettle(task.id);
await waitForSessionState(
apiClient,
task.id,
"WAITING_FOR_INPUT",
`${title}'s foreground turn should settle`,
);
return { taskId: task.id, sessionId };
}
test.describe("Parked on background work", () => {
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_PARKED_PROBE_INTERVAL: PARKED_PROBE_INTERVAL,
KANDEV_MOCK_PROVIDERS: "claude-acp",
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("AC-73/AC-62: live samples show the affordance in the sidebar, settle clears it via WS", async ({
apiClient,
seedData,
testPage,
}) => {
test.setTimeout(120_000);
const claudeAcpProfileId = await createClaudeAcpProfile(apiClient);
// Mount the sidebar on a different task first so the client is live
// before the parked task's own WS traffic starts (see
// sidebar-settled-spinner.spec.ts for why this ordering matters).
const navTask = await apiClient.createTask(seedData.workspaceId, "Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${navTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
const { sessionId } = await createParkedTask(
apiClient,
seedData,
claudeAcpProfileId,
"Parked Turn",
);
const parkedRow = session.sidebarTaskItem("Parked Turn");
await expect(parkedRow).toBeVisible({ timeout: 15_000 });
// Core regression (AC-73): the probe returns "live" for at least three
// consecutive samples (the synchronous settle sample plus periodic
// KANDEV_PARKED_PROBE_INTERVAL=1s samples) — the affordance must be
// visible, and neither task-state-waiting-for-input nor
// task-state-turn-finished may ever render, at every one of those
// samples, not just once at the start.
await expectAffordanceHoldsAcrossLiveSamples(
apiClient,
parkedRow,
sessionId,
AC73_MIN_LIVE_SAMPLES,
);
// AC-62: the next periodic sample (KANDEV_PARKED_PROBE_INTERVAL=1s) reads
// "settled" and clears the affordance; the row updates from the live
// task.updated stream — no reload anywhere above.
await apiClient.scriptBackgroundProbe(sessionId, ["settled"]);
await expect(parkedRow.getByTestId("task-state-background-running")).toHaveCount(0, {
timeout: 10_000,
});
});
test("AC-68: leaving WAITING_FOR_INPUT clears the affordance immediately, without a new probe sample", async ({
apiClient,
seedData,
testPage,
}) => {
test.setTimeout(120_000);
const claudeAcpProfileId = await createClaudeAcpProfile(apiClient);
// Keep the sidebar mounted before the parked task settles. This lets the
// test observe the task.updated projection instead of depending on a
// boot payload assembled during the settle race.
const navTask = await apiClient.createTask(seedData.workspaceId, "Resume Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${navTask.id}`);
const navSession = new SessionPage(testPage);
await navSession.waitForLoad();
await expect(navSession.sidebar).toBeVisible({ timeout: 10_000 });
let parkedSession: SessionPage | undefined;
await createParkedTask(
apiClient,
seedData,
claudeAcpProfileId,
"Parked Resume Turn",
async (parkedTaskId) => {
await testPage.goto(`/t/${parkedTaskId}`);
parkedSession = new SessionPage(testPage);
await parkedSession.waitForLoad();
await expect(parkedSession.sidebar).toBeVisible({ timeout: 10_000 });
},
);
const session = parkedSession;
if (!session) throw new Error("parked task page was not mounted before settle");
const parkedRow = session.sidebarTaskItem("Parked Resume Turn");
await expect(parkedRow.getByTestId("task-state-background-running")).toBeVisible({
timeout: 20_000,
});
// The probe is still scripted to hold "live" forever — AC-68 must clear
// on the state transition alone, never by waiting for a different probe
// answer.
await session.sendMessage("keep going");
await expect(parkedRow.getByTestId("task-state-background-running")).toHaveCount(0, {
timeout: 10_000,
});
});
});