Files
kandev/apps/web/e2e/tests/session/session-flicker.spec.ts
T
João Salavisa bfd632bafa test(e2e): label or convert every remaining unconditional sleep (#2650)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:55:11 +01:00

295 lines
11 KiB
TypeScript

import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { waitForStableActiveSession } from "../../helpers/session-store";
import { dwell } from "../../helpers/causal-waits";
/**
* Regression: opening / switching to a second agent session in a task that
* already has one open must NOT make the UI flicker uncontrollably between the
* old and new session.
*
* Root cause of the original bug: every session of a task is supposed to share
* one `task_environment_id` (the backend reuses the task's environment), but a
* launch race can hand a second session a *different* env id. Dockview layouts
* are keyed by env, so a same-task env change triggered a full env-layout
* switch that strips the sibling session's chat panel (keepSessionId). With the
* active session bouncing between the two envs, the tabs were repeatedly
* removed and re-added — the "flicker between the old and new session" users
* reported. Closing one tab stopped it; reopening it restarted it.
*
* These tests assert the active session settles and, critically, that two
* same-task sessions whose env ids differ keep BOTH tabs and never tear the
* chat panel down.
*/
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
type E2EStore = {
getState: () => {
tasks: { activeSessionId: string | null };
};
setState: (updater: (s: { environmentIdBySessionId: Record<string, string> }) => void) => void;
subscribe: (cb: () => void) => () => void;
};
type DockApi = {
panels: Array<{ id: string }>;
onDidRemovePanel: (cb: (p: { id: string }) => void) => void;
};
type FlickerWindow = {
__KANDEV_E2E_STORE__?: E2EStore;
__dockviewApi__?: DockApi;
__activeLog__?: Array<string | null>;
__panelRemovals__?: string[];
__divergeIntervalId__?: number;
};
type SetupResult = {
task: { id: string };
session: SessionPage;
session1Id: string;
session2Id: string;
};
async function createTaskWithTwoSessions(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<SetupResult> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for first session to finish" },
)
.toBe(true);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardByTitle(title);
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 15_000,
});
await session.openNewSessionDialog();
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
await session.newSessionPromptInput().fill("/e2e:simple-message");
await session.newSessionStartButton().click();
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.filter((s) => DONE_STATES.includes(s.state)).length;
},
{ timeout: 60_000, message: "Waiting for both sessions to finish" },
)
.toBe(2);
const { sessions } = await apiClient.listTaskSessions(task.id);
const sorted = sessions.sort(
(a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
);
return { task, session, session1Id: sorted[0].id, session2Id: sorted[1].id };
}
/** Longest run of strict A,B,A,B alternation in a value sequence. */
function longestAlternationRun(values: Array<string | null>): number {
let best = 0;
let run = 0;
for (let i = 1; i < values.length; i++) {
if (values[i] !== values[i - 1] && (i < 2 || values[i] === values[i - 2])) {
run = run === 0 ? 2 : run + 1;
} else {
run = values[i] !== values[i - 1] ? 2 : 0;
}
best = Math.max(best, run);
}
return best;
}
test.describe("Session flicker", () => {
test("opening a second session does not flicker the active session", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Flicker Second Session",
);
await session.sessionTabBySessionId(session1Id).click();
await waitForStableActiveSession(testPage, session1Id);
// Observe every change of the active session over the settle window.
await testPage.evaluate(() => {
const w = window as unknown as FlickerWindow;
const store = w.__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
const log: Array<string | null> = [];
w.__activeLog__ = log;
let last = store.getState().tasks.activeSessionId;
log.push(last);
store.subscribe(() => {
const v = store.getState().tasks.activeSessionId;
if (v !== last) {
last = v;
log.push(v);
}
});
});
await session.sessionTabBySessionId(session2Id).click();
await dwell(
testPage,
2_500,
"negative-assertion",
"the observation window this test exists for: it asserts the absence of sustained oscillation after a deliberate switch, so it has to sample real elapsed time rather than await any single event",
);
const activeLog = await testPage.evaluate(
() => (window as unknown as FlickerWindow).__activeLog__ ?? [],
);
// One deliberate switch (A -> B), no sustained oscillation.
expect(longestAlternationRun(activeLog)).toBeLessThan(4);
expect(activeLog.length).toBeLessThan(5);
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible();
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
});
test("two same-task sessions with diverged env ids keep both tabs and the chat", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Flicker Diverged Env",
);
await session.sessionTabBySessionId(session1Id).click();
await waitForStableActiveSession(testPage, session1Id);
// Simulate the backend launch race: session #2 ends up with a DIFFERENT
// task_environment_id than session #1. Keep re-forcing it so trailing WS
// env-sync events can't reset it during the test window. Also record any
// session chat-panel teardown.
await testPage.evaluate((sid2) => {
const w = window as unknown as FlickerWindow;
const store = w.__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
// Fail fast if the dockview bridge is absent — otherwise the removal
// listener below would never register and the teardown assertion would
// pass trivially (false green).
const dockviewApi = w.__dockviewApi__;
if (!dockviewApi) throw new Error("Dockview API bridge missing");
w.__divergeIntervalId__ = window.setInterval(() => {
store.setState((s) => {
s.environmentIdBySessionId[sid2] = "diverged-env-for-session-2";
});
}, 30);
const removals: string[] = [];
w.__panelRemovals__ = removals;
dockviewApi.onDidRemovePanel((p: { id: string }) => {
if (p.id.startsWith("session:")) removals.push(p.id);
});
}, session2Id);
// The diverge interval above fires every 30ms; wait for its first write to
// land in the store rather than assuming one tick has happened.
await testPage.waitForFunction(
(sid2) =>
(
window as unknown as {
__KANDEV_E2E_STORE__?: {
getState: () => { environmentIdBySessionId: Record<string, string> };
};
}
).__KANDEV_E2E_STORE__?.getState().environmentIdBySessionId[sid2] ===
"diverged-env-for-session-2",
session2Id,
{ timeout: 5_000 },
);
// Switch to session #2 (now on a diverged env) and back — this is what used
// to strip the sibling tab and tear the chat down.
await session.sessionTabBySessionId(session2Id).click();
await waitForStableActiveSession(testPage, session2Id);
await session.sessionTabBySessionId(session1Id).click();
await waitForStableActiveSession(testPage, session1Id);
await session.sessionTabBySessionId(session2Id).click();
await dwell(
testPage,
1_500,
"negative-assertion",
"final observation window; the assertions below are that neither tab was stripped and no session panel was torn down, which only mean something after the regression has had real time to occur",
);
// Both tabs must remain present throughout, and the active session's chat
// must still be visible (dockview keeps inactive panels mounted-but-hidden,
// so assert on a *visible* session-chat rather than the first match).
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible();
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
await expect(testPage.locator('[data-testid="session-chat"]:visible').first()).toBeVisible();
// Both session chat panels must still be mounted, and the layout must not
// have torn any of them down as part of a (spurious) env switch — that
// teardown is the flicker.
const panels = await testPage.evaluate(() => {
const api = (window as unknown as { __dockviewApi__?: { panels: Array<{ id: string }> } })
.__dockviewApi__;
return (api?.panels ?? []).map((p) => p.id).filter((id) => id.startsWith("session:"));
});
expect(panels).toContain(`session:${session1Id}`);
expect(panels).toContain(`session:${session2Id}`);
const removals = await testPage.evaluate(() => {
const w = window as unknown as FlickerWindow;
// Stop forcing the diverged env so the interval can't outlive the test.
if (w.__divergeIntervalId__ !== undefined) {
clearInterval(w.__divergeIntervalId__);
w.__divergeIntervalId__ = undefined;
}
return w.__panelRemovals__ ?? [];
});
expect(removals).toEqual([]);
});
});