Files
kandev/apps/web/e2e/helpers/session-store.ts
T

154 lines
5.0 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
taskSessions: { items: Record<string, Record<string, unknown>> };
tasks: { activeSessionId: string | null };
sessionAgentctl: { itemsBySessionId: Record<string, { status?: string }> };
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
};
setState: (
updater: (state: {
taskSessions: { items: Record<string, Record<string, unknown>> };
}) => void,
) => void;
};
};
type AvailableCommand = {
name: string;
description?: string;
input_hint?: string;
};
/** Wait until the session agentctl is ready for controls that require it. */
export async function waitForSessionAgentctlReady(
page: Page,
sessionId: string,
timeout = 60_000,
): Promise<void> {
await page.waitForFunction(
(sid) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
return store?.getState().sessionAgentctl.itemsBySessionId[sid]?.status === "ready";
},
sessionId,
{ timeout, message: `agentctl did not become ready for session ${sessionId}` },
);
}
export async function waitForActiveSessionForegroundActivity(
page: Page,
activity: "generating" | "background" | null,
): Promise<void> {
await page.waitForFunction(
(expected) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) return false;
const state = store.getState();
const sessionId = state.tasks.activeSessionId;
if (!sessionId) return false;
const current = state.taskSessions.items[sessionId]?.foreground_activity;
return expected === null ? current == null : current === expected;
},
activity,
{ timeout: 20_000 },
);
}
/** Wait for the backend-owned cancellation projection on the active session. */
export async function waitForActiveSessionCancellationPending(
page: Page,
pending: boolean,
): Promise<void> {
await page.waitForFunction(
(expected) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) return false;
const sessionId = store.getState().tasks.activeSessionId;
if (!sessionId) return false;
return store.getState().taskSessions.items[sessionId]?.cancellation_pending === expected;
},
pending,
{ timeout: 20_000 },
);
}
/**
* Simulate a lean session-list / partial WS update: preserve `is_passthrough`
* but drop `agent_profile_snapshot` from the client store.
*
* Uses `setState` directly so we bypass `mergeTaskSession`'s nullish-coalescing
* guard on `agent_profile_snapshot` (see session-slice.ts).
*/
export async function stripSessionProfileSnapshot(page: Page, sessionId: string): Promise<void> {
await page.evaluate((sid) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) {
throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?");
}
store.setState((state) => {
const session = state.taskSessions.items[sid];
if (!session) {
throw new Error(`Session ${sid} not found in store`);
}
state.taskSessions.items[sid] = {
...session,
agent_profile_snapshot: undefined,
};
});
const updated = store.getState().taskSessions.items[sid];
if (updated?.agent_profile_snapshot !== undefined) {
throw new Error("Failed to strip agent_profile_snapshot from session store");
}
}, sessionId);
}
export async function seedAvailableCommands(
page: Page,
sessionId: string,
commands: AvailableCommand[],
): Promise<void> {
await page.evaluate(
({ sid, commandList }) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) {
throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?");
}
store.getState().setAvailableCommands(sid, commandList);
},
{ sid: sessionId, commandList: commands },
);
}
/**
* Wait until `sessionId` is the active session AND has stopped changing.
*
* Clicking a session tab settles asynchronously, and the flicker specs install
* their observers straight afterwards: starting to observe mid-settle records
* the tail of the switch as if it were oscillation. Requiring two consecutive
* agreeing samples gives those specs the quiet baseline the fixed sleeps were
* approximating, while returning immediately once the switch is genuinely done.
*/
export async function waitForStableActiveSession(
page: Page,
sessionId: string,
timeout = 15_000,
): Promise<void> {
let previous: string | null = null;
await expect
.poll(
async () => {
const current = await page.evaluate(
() => (window as E2EStoreWindow).__KANDEV_E2E_STORE__?.getState().tasks.activeSessionId,
);
const stable = current === sessionId && previous === sessionId;
previous = current ?? null;
return stable;
},
{ timeout, message: `active session did not settle on ${sessionId}` },
)
.toBe(true);
}