Files
kandev/apps/web/e2e/tests/session/multi-session-ux.spec.ts
T

393 lines
13 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
/**
* Helpers to reduce boilerplate: create a task with a completed session
* and navigate to it.
*/
async function createTaskAndNavigate(
testPage: import("@playwright/test").Page,
apiClient: import("../../helpers/api-client").ApiClient,
seedData: import("../../fixtures/test-base").SeedData,
title: string,
) {
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 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,
});
return { task, session };
}
test.describe("Multi-session UX", () => {
test("session tab shows numbered label", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(120_000);
const { task, session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Tab Naming Task",
);
// Create a second session
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 });
// Wait for second session to appear
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.length;
},
{ timeout: 30_000, message: "Waiting for second session" },
)
.toBe(2);
// Verify tabs show index badge and agent label
const tab1 = session.sessionTabByText("1");
const tab2 = session.sessionTabByText("2");
await expect(tab1).toBeVisible({ timeout: 10_000 });
await expect(tab2).toBeVisible({ timeout: 10_000 });
});
test("+ dropdown shows sessions with correct numbering", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Dropdown Numbering Task",
);
// Create second session via API to have two completed sessions
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 second session to complete" },
)
.toBe(2);
// Open the + dropdown and verify both sessions are listed with #1 and #2
await session.addPanelButton().click();
const items = session.sessionReopenItems();
await expect(items).toHaveCount(2, { timeout: 5_000 });
await expect(items.first()).toContainText("#1");
await expect(items.last()).toContainText("#2");
});
test("completed sessions show state icon, active sessions do not", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"State Icon Task",
);
// Open the + dropdown — the single completed session should have a state icon
await session.addPanelButton().click();
const items = session.sessionReopenItems();
await expect(items.first()).toBeVisible({ timeout: 5_000 });
// A completed session has a state icon (svg element inside the item)
const stateIcon = items.first().locator("svg").last();
await expect(stateIcon).toBeVisible();
});
test("delete session via context menu shows confirmation", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { task, session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Delete Confirm Task",
);
// Create second session so we have two
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.length;
},
{ timeout: 30_000 },
)
.toBe(2);
// Right-click on session #1 tab and click Delete
const tab1 = session.sessionTabByText("1");
await expect(tab1).toBeVisible({ timeout: 10_000 });
await tab1.click({ button: "right" });
const deleteItem = session.contextMenuItem("Delete");
// Wait for context menu — the session must be in a deletable state
await expect(deleteItem).toBeVisible({ timeout: 5_000 });
await deleteItem.click();
// Confirmation dialog should appear
const dialog = session.alertDialog();
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText("Delete session?");
// Cancel the deletion
const cancelBtn = dialog.getByRole("button", { name: "Cancel" });
await cancelBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Verify session still exists
const { sessions } = await apiClient.listTaskSessions(task.id);
expect(sessions).toHaveLength(2);
});
test("delete session removes it from backend", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(120_000);
const { task, session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Delete Session Task",
);
// Create second session
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 },
)
.toBe(2);
// Capture session IDs now so we can identify tabs by ID (display numbers
// get renumbered after deletion, making text-based locators unreliable).
const { sessions: sessionsBeforeDelete } = await apiClient.listTaskSessions(task.id);
const sorted = sessionsBeforeDelete.sort(
(a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
);
const session1Id = sorted[0].id;
// Right-click on session #1 tab → Delete → Confirm
const tab1 = session.sessionTabByText("1");
await expect(tab1).toBeVisible({ timeout: 10_000 });
await tab1.click({ button: "right" });
await session.contextMenuItem("Delete").click();
const dialog = session.alertDialog();
await expect(dialog).toBeVisible({ timeout: 5_000 });
const confirmBtn = dialog.getByRole("button", { name: "Delete" });
await confirmBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Wait for the deleted session's tab to disappear (identified by session ID,
// not display number, because the remaining session gets renumbered to #1).
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
// Verify backend only has 1 session
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.length;
},
{ timeout: 10_000, message: "Waiting for session to be deleted" },
)
.toBe(1);
});
test("new session dialog context mode selector works", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { session } = await createTaskAndNavigate(
testPage,
apiClient,
seedData,
"Context Mode Task",
);
// Open new session dialog
await session.openNewSessionDialog();
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
// Verify default context mode is "Blank"
const contextTrigger = session
.newSessionDialog()
.locator("button")
.filter({ hasText: "Blank" });
await expect(contextTrigger).toBeVisible();
// Open context mode dropdown and check "Copy initial prompt" option exists
await contextTrigger.click();
const copyOption = testPage.getByRole("option", { name: "Copy initial prompt" });
await expect(copyOption).toBeVisible({ timeout: 3_000 });
// Close dialog
const cancelBtn = session.newSessionDialog().getByRole("button", { name: "Cancel" });
// Press Escape to close the select first
await testPage.keyboard.press("Escape");
await cancelBtn.click();
});
test("switching between tasks preserves correct session context", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
// Create two tasks with sessions
const task1 = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Task Switch A",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const task2 = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Task Switch B",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// Wait for both to finish
for (const task of [task1, task2]) {
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: `Waiting for ${task.id} to finish` },
)
.toBe(true);
}
// Navigate to task 1
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card1 = kanban.taskCardByTitle("Task Switch A");
await expect(card1).toBeVisible({ timeout: 10_000 });
await card1.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,
});
// Verify task 1 title is in the sidebar
await expect(session.taskInSidebar("Task Switch A")).toBeVisible();
await expect(session.taskInSidebar("Task Switch B")).toBeVisible();
// Switch to task 2 via sidebar
await session.clickTaskInSidebar("Task Switch B");
// Wait for URL to change to task 2's session
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
// Verify chat loads for task 2
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 15_000,
});
// Switch back to task 1
await session.clickTaskInSidebar("Task Switch A");
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 15_000,
});
});
});