6597f8b9d1
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
/**
|
|
* Mobile coverage for the parked-on-background-work affordance in the session
|
|
* switcher (spec docs/specs/disambiguate-waiting/spec.md, task-09, AC-51/AC-34).
|
|
*
|
|
* The desktop chat toolbar's session switcher (components/task/sessions-dropdown.tsx)
|
|
* is unreachable from the primary Dockview session view: every caller of the
|
|
* shared chat toolbar (dockview-panel-content.tsx, dockview-shared.tsx,
|
|
* quick-chat-content.tsx, preview-session-tabs.tsx, passthrough-chat-composer.tsx)
|
|
* sets `hideSessionsDropdown`, and the desktop chat toolbar itself also hides it
|
|
* below the `md` breakpoint. The session switcher row this AC describes is the
|
|
* mobile picker sheet (components/task/mobile/mobile-sessions-section.tsx),
|
|
* reached via `mobile-sessions-pill` — hence this spec lives in its own
|
|
* `mobile-*.spec.ts` file so Playwright's `mobile-.*\.spec\.ts` project match
|
|
* runs it under the Pixel 5 device profile (see e2e/playwright.config.ts).
|
|
*
|
|
* See parked-background-work.spec.ts for the desktop sidebar/state-transition
|
|
* coverage and the shared mechanics comment (probe scripting, shell-kind
|
|
* detached-launch fixture, KANDEV_MOCK_PROVIDERS=claude-acp).
|
|
*/
|
|
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";
|
|
|
|
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 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, sessionId: 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, sessionId);
|
|
}
|
|
|
|
await waitForSessionState(
|
|
apiClient,
|
|
task.id,
|
|
"WAITING_FOR_INPUT",
|
|
`${title}'s foreground turn should settle`,
|
|
);
|
|
|
|
return { taskId: task.id, sessionId };
|
|
}
|
|
|
|
test.describe("mobile: parked on background work", () => {
|
|
let claudeAcpProfileId: string;
|
|
|
|
test.beforeAll(async ({ backend, apiClient }) => {
|
|
await backend.restart({
|
|
KANDEV_PARKED_PROBE_INTERVAL: PARKED_PROBE_INTERVAL,
|
|
KANDEV_MOCK_PROVIDERS: "claude-acp",
|
|
});
|
|
const { agents } = await apiClient.listAgents();
|
|
const claudeAgent = agents.find((agent) => agent.name === "claude-acp");
|
|
const profileId = claudeAgent?.profiles[0]?.id;
|
|
if (!profileId) {
|
|
throw new Error(
|
|
"E2E seed has no claude-acp mock profile after KANDEV_MOCK_PROVIDERS restart",
|
|
);
|
|
}
|
|
claudeAcpProfileId = profileId;
|
|
});
|
|
|
|
test.afterAll(async ({ backend }) => {
|
|
await backend.restart();
|
|
});
|
|
|
|
test("AC-51/AC-34: session switcher row reflects the parked reading, pending input still wins", async ({
|
|
apiClient,
|
|
seedData,
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
let parkedSession: SessionPage | undefined;
|
|
const { sessionId } = await createParkedTask(
|
|
apiClient,
|
|
seedData,
|
|
claudeAcpProfileId,
|
|
"Parked Switcher Turn",
|
|
async (parkedTaskId) => {
|
|
await testPage.goto(`/t/${parkedTaskId}`);
|
|
parkedSession = new SessionPage(testPage);
|
|
await parkedSession.waitForLoad();
|
|
},
|
|
);
|
|
|
|
if (!parkedSession) {
|
|
throw new Error("parked session page should be initialized before settling");
|
|
}
|
|
|
|
const layout = testPage.locator("[data-testid='mobile-task-layout']:visible");
|
|
const pill = layout.getByTestId("mobile-sessions-pill");
|
|
await expect(pill).toBeVisible({ timeout: 30_000 });
|
|
await pill.tap();
|
|
|
|
const sheet = testPage.getByRole("dialog", { name: "Sessions" });
|
|
await expect(sheet).toBeVisible({ timeout: 5_000 });
|
|
const row = sheet.getByTestId(`mobile-session-row-${sessionId}`);
|
|
await expect(row).toBeVisible({ timeout: 10_000 });
|
|
await expect(row.locator(".tabler-icon-loader-2")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// AC-34: a concurrent pending clarification outranks the parked reading
|
|
// — the row must switch to the message-question icon, not stay on the
|
|
// background spinner, even though the session is still scripted "live".
|
|
await apiClient.seedSessionMessage(sessionId, {
|
|
type: "clarification_request",
|
|
metadata: { status: "pending" },
|
|
});
|
|
|
|
// The harness creates a new turn when the foreground turn has already
|
|
// settled. Reload once so the turn list and message list are hydrated from
|
|
// the same snapshot before checking the current-turn precedence rule.
|
|
await testPage.reload();
|
|
const refreshedSession = new SessionPage(testPage);
|
|
await refreshedSession.waitForLoad();
|
|
const refreshedLayout = testPage.locator("[data-testid='mobile-task-layout']:visible");
|
|
const refreshedPill = refreshedLayout.getByTestId("mobile-sessions-pill");
|
|
await expect(refreshedPill).toBeVisible({ timeout: 30_000 });
|
|
await refreshedPill.tap();
|
|
const refreshedSheet = testPage.getByRole("dialog", { name: "Sessions" });
|
|
await expect(refreshedSheet).toBeVisible({ timeout: 5_000 });
|
|
const refreshedRow = refreshedSheet.getByTestId(`mobile-session-row-${sessionId}`);
|
|
await expect(refreshedRow.locator(".tabler-icon-message-question")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(refreshedRow.locator(".tabler-icon-loader-2")).toHaveCount(0);
|
|
});
|
|
});
|