Files
kandev/apps/web/e2e/tests/session/session-tab-management.spec.ts
T
2026-09-07 13:45:08 +01:00

800 lines
30 KiB
TypeScript

import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
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 { attachGatewayTrafficCapture } from "../../helpers/ws-traffic";
import { dwell } from "../../helpers/causal-waits";
/** Wire action the kanban WS handler consumes when a task moves step. */
const TASK_UPDATED_ACTION = "task.updated";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
/**
* Regression suite for tab session management bugs:
* - Bug A: kanban.update wiped primarySessionId, dropping the primary star.
* - Bug B: handleDelete fired removeTaskSession before removePanel, so
* useAutoSessionTab re-created the deleted session's panel.
* - Bug C: setupChatPanelSafetyNet recreated panels for sessions that no
* longer existed in the store.
*/
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],
},
);
// First session must reach a done state before we open the second.
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,
});
// Launch the second session via the new-session dialog.
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 };
}
function starInTab(session: SessionPage, sessionId: string) {
return session.sessionTabBySessionId(sessionId).locator(".tabler-icon-star").first();
}
test.describe("Session tab management — close behavior", () => {
test("tab close button shows delete confirmation and removes session on confirm", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Tab Close Deletes Session",
);
await session.sessionTabBySessionId(session1Id).click();
await expect(session.sessionTabCloseButton(session1Id)).toBeVisible({ timeout: 5_000 });
await session.sessionTabCloseButton(session1Id).click();
const dialog = session.alertDialog();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText("Delete session?");
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(
testPage.getByTestId("toast-message").filter({ hasText: "Deleting session" }),
).toHaveCount(0);
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
await expect(
testPage.getByText("Deleting session successful", { exact: false }),
).not.toBeVisible();
const { sessions } = await apiClient.listTaskSessions(task.id);
expect(sessions.map((s) => s.id)).toEqual([session2Id]);
});
test("tab close button delete confirmation can be cancelled", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session, session1Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Tab Close Cancel Delete",
);
await session.sessionTabBySessionId(session1Id).click();
await expect(session.sessionTabCloseButton(session1Id)).toBeVisible({ timeout: 5_000 });
await session.sessionTabCloseButton(session1Id).click();
const dialog = session.alertDialog();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible();
const { sessions } = await apiClient.listTaskSessions(task.id);
expect(sessions).toHaveLength(2);
});
test("deleting a non-active session removes its tab and stays gone", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Tab Stays Gone",
);
// Make session #2 active so #1 is the non-active deletion target.
await session.sessionTabBySessionId(session2Id).click();
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
await session.contextMenuItem("Delete").click();
const confirmation = testPage.getByTestId("session-delete-confirm-popover");
await expect(confirmation).toBeVisible({ timeout: 5_000 });
await expect(session.alertDialog()).toHaveCount(0);
await confirmation.getByTestId("session-delete-confirm").click();
// Tab disappears…
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
// …and stays gone — useAutoSessionTab must not recreate it.
await dwell(
testPage,
800,
"negative-assertion",
"the regression is a tab being recreated after removal; a recreation that must never happen has no event, so the check needs real elapsed time to mean anything",
);
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
const { sessions } = await apiClient.listTaskSessions(task.id);
expect(sessions).toHaveLength(1);
});
test("deleting the active session switches focus to the remaining session", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Delete Active Session",
);
// Make session #1 active so the deletion target IS the active one.
await session.sessionTabBySessionId(session1Id).click();
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
await session.contextMenuItem("Delete").click();
const confirmation = testPage.getByTestId("session-delete-confirm-popover");
await expect(confirmation).toBeVisible({ timeout: 5_000 });
await expect(session.alertDialog()).toHaveCount(0);
await confirmation.getByTestId("session-delete-confirm").click();
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 5_000 });
// URL must not have switched to a different task.
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`));
// Backend must reflect the deletion — exactly one session remains.
const { sessions } = await apiClient.listTaskSessions(task.id);
expect(sessions.map((s) => s.id)).toEqual([session2Id]);
});
test("deleting the active shared-environment session keeps Changes data visible", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Delete Active Session Keeps Changes",
);
const worktreePath = seedData.repositoryPath;
const worktreeBranch = execFileSync("git", ["branch", "--show-current"], {
cwd: worktreePath,
encoding: "utf8",
}).trim();
expect(worktreeBranch).toBeTruthy();
const localChange = "session-delete-change.txt";
const remoteChange = "remote-session-delete-change.ts";
const localChangePath = path.join(worktreePath, localChange);
fs.writeFileSync(localChangePath, "keep this change visible\n");
try {
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
await apiClient.mockGitHubAddPRs([
{
number: 3157,
title: "Keep Changes visible after session deletion",
state: "open",
head_branch: worktreeBranch,
base_branch: "main",
author_login: "test-user",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 1,
deletions: 0,
},
]);
await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 3157, [
{
filename: remoteChange,
status: "added",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+export const kept = true;",
},
]);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
workspace_id: seedData.workspaceId,
repository_id: seedData.repositoryId,
owner: "testorg",
repo: "testrepo",
pr_number: 3157,
pr_url: "https://github.com/testorg/testrepo/pull/3157",
pr_title: "Keep Changes visible after session deletion",
head_branch: worktreeBranch,
base_branch: "main",
author_login: "test-user",
additions: 1,
deletions: 0,
});
await testPage.reload();
await session.waitForLoad();
await session.sessionTabBySessionId(session1Id).click();
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).toBeVisible({ timeout: 20_000 });
await session.expandPRChangesSection();
await expect(
session.prFilesSection().locator(`[data-changes-file="${remoteChange}"]`),
).toBeVisible({ timeout: 20_000 });
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
await session.contextMenuItem("Delete").click();
const confirmation = testPage.getByTestId("session-delete-confirm-popover");
await expect(confirmation).toBeVisible({ timeout: 5_000 });
await confirmation.getByTestId("session-delete-confirm").click();
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 5_000 });
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).toBeVisible({ timeout: 20_000 });
await session.expandPRChangesSection();
await expect(
session.prFilesSection().locator(`[data-changes-file="${remoteChange}"]`),
).toBeVisible({ timeout: 20_000 });
} finally {
fs.rmSync(localChangePath, { force: true });
}
});
test("keeps dirty Changes status after sibling hydration", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
// Attach before setup so the capture also observes the reload-driven
// subscriptions used by this regression.
const traffic = attachGatewayTrafficCapture(testPage);
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Sibling Hydration Keeps Changes",
);
const localChange = "sibling-hydration-change.txt";
const localChangePath = path.join(seedData.repositoryPath, localChange);
fs.writeFileSync(localChangePath, "keep this change visible\n");
const receivedGitEvent = (sessionId: string, fromIndex: number) =>
traffic.frames
.slice(fromIndex)
.some(
(frame) =>
frame.direction === "received" &&
frame.action === "session.git.event" &&
frame.sessionId === sessionId,
);
try {
traffic.frames.length = 0;
const beforeReload = traffic.frames.length;
await testPage.reload();
await session.waitForLoad();
await session.sessionTabBySessionId(session1Id).click();
await expect
.poll(() => receivedGitEvent(session1Id, beforeReload), {
timeout: 20_000,
message: "waiting for the first sibling git-status hydration",
})
.toBe(true);
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).toBeVisible({ timeout: 20_000 });
await session.sessionTabBySessionId(session2Id).click();
await expect
.poll(() => receivedGitEvent(session2Id, beforeReload), {
timeout: 20_000,
message: "waiting for the second sibling git-status hydration",
})
.toBe(true);
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).toBeVisible({ timeout: 20_000 });
} finally {
fs.rmSync(localChangePath, { force: true });
}
});
test("does not restore removed Changes files after sibling hydration", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const traffic = attachGatewayTrafficCapture(testPage);
const { session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Sibling Hydration Drops Removed Changes",
);
const localChange = "sibling-hydration-removed-change.txt";
const localChangePath = path.join(seedData.repositoryPath, localChange);
fs.writeFileSync(localChangePath, "remove this change\n");
const environmentFiles = (sessionId: string) =>
testPage.evaluate((sid) => {
type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
environmentIdBySessionId: Record<string, string>;
gitStatus: {
byEnvironmentId: Record<string, { files?: Record<string, unknown> } | undefined>;
};
};
};
};
const state = (window as E2EStoreWindow).__KANDEV_E2E_STORE__?.getState();
const environmentId = state?.environmentIdBySessionId[sid];
if (!environmentId) return null;
return state.gitStatus.byEnvironmentId[environmentId]?.files ?? {};
}, sessionId);
try {
await session.sessionTabBySessionId(session1Id).click();
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).toBeVisible({ timeout: 20_000 });
await expect
.poll(() => environmentFiles(session1Id), {
timeout: 20_000,
message: "waiting for the added Changes file to enter the environment store",
})
.toEqual(expect.objectContaining({ [localChange]: expect.anything() }));
fs.rmSync(localChangePath);
await expect
.poll(
async () => {
const files = await environmentFiles(session1Id);
return files ? Object.hasOwn(files, localChange) : false;
},
{
timeout: 20_000,
message: "waiting for the removed Changes file to leave the environment store",
},
)
.toBe(false);
await expect(session.changesFileRow(localChange)).not.toBeVisible({ timeout: 10_000 });
traffic.frames.length = 0;
await testPage.reload();
await session.waitForLoad();
const receivedGitEvent = (sessionId: string) =>
traffic.frames.some(
(frame) =>
frame.direction === "received" &&
frame.action === "session.git.event" &&
frame.sessionId === sessionId,
);
await session.sessionTabBySessionId(session1Id).click();
await expect
.poll(() => receivedGitEvent(session1Id), {
timeout: 20_000,
message: "waiting for the first sibling git-status hydration after removal",
})
.toBe(true);
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).not.toBeVisible({ timeout: 10_000 });
await session.sessionTabBySessionId(session2Id).click();
await expect
.poll(() => receivedGitEvent(session2Id), {
timeout: 20_000,
message: "waiting for the second sibling git-status hydration after removal",
})
.toBe(true);
await session.clickTab("Changes");
await expect(session.changesFileRow(localChange)).not.toBeVisible({ timeout: 10_000 });
} finally {
fs.rmSync(localChangePath, { force: true });
}
});
test("deleting then immediately switching tasks does not resurrect the tab", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const {
task: task1,
session,
session1Id,
session2Id,
} = await createTaskWithTwoSessions(testPage, apiClient, seedData, "Delete Then Switch A");
// Second task to switch into mid-delete.
const task2 = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Delete Then Switch B",
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(task2.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for task2 session" },
)
.toBe(true);
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
await session.contextMenuItem("Delete").click();
const confirmation = testPage.getByTestId("session-delete-confirm-popover");
await expect(confirmation).toBeVisible({ timeout: 5_000 });
await expect(session.alertDialog()).toHaveCount(0);
await confirmation.getByTestId("session-delete-confirm").click();
// Wait for backend to confirm deletion (don't wait for tab to disappear).
await expect
.poll(async () => (await apiClient.listTaskSessions(task1.id)).sessions.length, {
timeout: 10_000,
})
.toBe(1);
// Switch to task2 then back to task1 — the deleted tab must not return.
await session.clickTaskInSidebar("Delete Then Switch B");
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Delete Then Switch A");
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// After a delete-then-switch round-trip the restored env layout can land the
// chat panel as a non-active background tab in the right-column group, so the
// chat-visible `waitForLoad()` gate isn't reliable here. The test only cares
// that the remaining session tab is present (and the deleted one didn't come
// back), so gate on the surviving session tab instead.
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 15_000 });
await dwell(
testPage,
800,
"negative-assertion",
"the deleted tab must not come back after a task round-trip; nothing is rendered to wait for when the expected outcome is that no tab ever appears",
);
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
});
test("session tabs from one task do not leak into another task's view", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const {
session,
session1Id: sessionA1Id,
session2Id: sessionA2Id,
} = await createTaskWithTwoSessions(testPage, apiClient, seedData, "Tab Leak Source A");
// Task B has only one session — never visited from the UI yet.
const taskB = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Tab Leak Target B",
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(taskB.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for taskB session" },
)
.toBe(true);
const { sessions: bSessions } = await apiClient.listTaskSessions(taskB.id);
const sessionB1Id = bSessions[0].id;
// Sanity: while on task A, both A sessions are visible.
await expect(session.sessionTabBySessionId(sessionA1Id)).toBeVisible({ timeout: 10_000 });
await expect(session.sessionTabBySessionId(sessionA2Id)).toBeVisible({ timeout: 5_000 });
// Switch from A → B via the sidebar.
await session.clickTaskInSidebar("Tab Leak Target B");
await expect(testPage).toHaveURL(new RegExp(`/t/${taskB.id}`), { timeout: 15_000 });
await session.waitForLoad();
// Task B's only session must be visible…
await expect(session.sessionTabBySessionId(sessionB1Id)).toBeVisible({ timeout: 10_000 });
// …and neither of task A's session tabs should have followed us in.
await dwell(
testPage,
800,
"negative-assertion",
"asserts that tabs from another task never leak in, which has no arrival event to wait on",
);
await expect(session.sessionTabBySessionId(sessionA1Id)).not.toBeVisible();
await expect(session.sessionTabBySessionId(sessionA2Id)).not.toBeVisible();
// Reload to make sure the persisted layout for task B doesn't bring them back either.
await testPage.reload();
await session.waitForLoad();
await expect(session.sessionTabBySessionId(sessionB1Id)).toBeVisible({ timeout: 10_000 });
await expect(session.sessionTabBySessionId(sessionA1Id)).not.toBeVisible();
await expect(session.sessionTabBySessionId(sessionA2Id)).not.toBeVisible();
});
});
test.describe("Session tab management — primary session persistence", () => {
test("primary star survives a kanban.update broadcast", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
// Attach before the first navigation so the capture sees the websocket from
// the moment it opens.
const traffic = attachGatewayTrafficCapture(testPage);
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
testPage,
apiClient,
seedData,
"Primary Through Kanban Update",
);
// Session #1 is auto-primary; promote session #2 via the context menu so we
// can verify the new primary survives the next kanban.update broadcast.
await session.sessionTabBySessionId(session2Id).click({ button: "right" });
await expect(session.contextMenuItem("Set as Primary")).toBeVisible({ timeout: 5_000 });
await session.contextMenuItem("Set as Primary").click();
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.find((s) => s.id === session2Id)?.is_primary ?? false;
},
{ timeout: 10_000, message: "Waiting for primary set in backend" },
)
.toBe(true);
await expect(
testPage.getByTestId("toast-message").filter({ hasText: "Set primary" }),
).toHaveCount(0);
await expect(starInTab(session, session2Id)).toBeVisible({ timeout: 5_000 });
await expect(starInTab(session, session1Id)).not.toBeVisible({ timeout: 5_000 });
// Trigger kanban.update by moving the task to a non-start step.
const otherStep = seedData.steps.find((s) => s.id !== seedData.startStepId);
if (!otherStep) throw new Error("Workflow needs at least 2 steps to trigger kanban.update");
const taskUpdatesBeforeMove = () =>
traffic.frames.filter(
(frame) => frame.direction === "received" && frame.action === TASK_UPDATED_ACTION,
).length;
const taskUpdatesAtMove = taskUpdatesBeforeMove();
await apiClient.moveTask(task.id, seedData.workflowId, otherStep.id);
// The kanban.update broadcast is what could wrongly move the star, so wait
// for the gateway to actually deliver it instead of budgeting for it.
// The move is delivered as `task.updated`, which `lib/ws/handlers/kanban.ts`
// consumes -- that handler is what this test is named after, and its
// regression was moving the star back to session #1. There is no
// `kanban.update` frame on the wire; waiting for one times out. Verified by
// dumping every received action after moveTask.
await expect
.poll(taskUpdatesBeforeMove, {
timeout: 15_000,
message: `no ${TASK_UPDATED_ACTION} frame was delivered after moveTask`,
})
.toBeGreaterThan(taskUpdatesAtMove);
// Observing the frame only proves the gateway delivered it, not that the
// handler under test ran. Both assertions below are already true before the
// move, so asserting in that gap would pass against the pre-move DOM and
// miss the regression entirely. Wait for the handler's own effect on the
// store -- the task's step in `kanban.tasks` -- before asserting.
await testPage.waitForFunction(
({ taskId, stepId }) => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => { kanban: { tasks: Array<{ id: string; workflowStepId: string }> } };
};
}
).__KANDEV_E2E_STORE__;
if (!store) throw new Error("E2E store bridge missing");
return (
store.getState().kanban.tasks.find((t) => t.id === taskId)?.workflowStepId === stepId
);
},
{ taskId: task.id, stepId: otherStep.id },
{ timeout: 15_000 },
);
// Star must still be on session #2 (would jump back to #1 before the kanban.ts fix).
await expect(starInTab(session, session2Id)).toBeVisible({ timeout: 5_000 });
await expect(starInTab(session, session1Id)).not.toBeVisible();
});
test("primary star survives switching tasks and returning", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(150_000);
const {
task: task1,
session,
session1Id,
session2Id,
} = await createTaskWithTwoSessions(testPage, apiClient, seedData, "Primary Round Trip A");
const task2 = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Primary Round Trip B",
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(task2.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000 },
)
.toBe(true);
// Session #1 is auto-primary; promote session #2 (matches the user's bug
// scenario: setting the second session as primary).
await session.sessionTabBySessionId(session2Id).click({ button: "right" });
await session.contextMenuItem("Set as Primary").click();
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task1.id);
return sessions.find((s) => s.id === session2Id)?.is_primary ?? false;
},
{ timeout: 10_000 },
)
.toBe(true);
await expect(starInTab(session, session2Id)).toBeVisible({ timeout: 5_000 });
// A → B → A round trip.
await session.clickTaskInSidebar("Primary Round Trip B");
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Primary Round Trip A");
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await session.waitForLoad();
await expect(session.sessionTabBySessionId(session1Id)).toBeVisible({ timeout: 10_000 });
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 5_000 });
await expect(starInTab(session, session2Id)).toBeVisible({ timeout: 5_000 });
await expect(starInTab(session, session1Id)).not.toBeVisible();
});
});