308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { type Page } from "@playwright/test";
|
|
import { test, expect } 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";
|
|
|
|
/**
|
|
* Seed a task + session via the API and navigate directly to the session page.
|
|
* Waits for the mock agent to complete its turn (idle input visible).
|
|
*/
|
|
async function seedTaskWithSession(
|
|
testPage: Page,
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
description = "/e2e:simple-message",
|
|
): Promise<SessionPage> {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description,
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
return session;
|
|
}
|
|
|
|
const TERMINAL_MARKER = "KANDEV_E2E_MARKER_12345";
|
|
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
|
|
|
test.describe("Session layout", () => {
|
|
// The standalone executor can fail to allocate a port on cold start;
|
|
// retry once so a transient backend hiccup doesn't fail the suite.
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("maximize terminal hides other panels", async ({ testPage, apiClient, seedData }) => {
|
|
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Maximize Test");
|
|
|
|
// Default layout: all panels visible
|
|
await session.expectDefaultLayout();
|
|
|
|
// Type a command in the terminal
|
|
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
|
|
// Maximize the terminal group
|
|
await session.clickMaximize();
|
|
|
|
// Only terminal and sidebar should be visible, with our output
|
|
await session.expectMaximized();
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
});
|
|
|
|
test("maximize survives page refresh", async ({ testPage, apiClient, seedData }) => {
|
|
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Refresh Test");
|
|
|
|
// Type a command in the terminal, then maximize
|
|
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
await session.clickMaximize();
|
|
await session.expectMaximized();
|
|
|
|
// Refresh the page — maximize state is saved in sessionStorage
|
|
await testPage.reload();
|
|
|
|
// After refresh: terminal should still be maximized
|
|
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
|
|
await session.expectMaximized();
|
|
// Terminal reconnects to the same shell — our output should still be there
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
});
|
|
|
|
test("task switching preserves maximize per session", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
// Create Task A, type a command, and maximize terminal
|
|
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Task A Maximize");
|
|
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
await session.clickMaximize();
|
|
await session.expectMaximized();
|
|
|
|
// Remember Task A's URL so we can navigate back after visiting Task B
|
|
const taskAUrl = testPage.url();
|
|
|
|
// Create Task B via API and navigate directly
|
|
const taskB = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Task B Normal",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!taskB.session_id) throw new Error("Task B creation did not return session_id");
|
|
await testPage.goto(`/t/${taskB.id}`);
|
|
|
|
const sessionB = new SessionPage(testPage);
|
|
await sessionB.waitForLoad();
|
|
// Foreground the chat tab — after the layout restore it can land as a
|
|
// background tab in the right group, so the idle input isn't visible yet.
|
|
await sessionB.showSessionContext(30_000);
|
|
await sessionB.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
// Task B should have default (non-maximized) layout
|
|
await sessionB.expectDefaultLayout();
|
|
|
|
// Navigate back to Task A's session URL — this triggers a full page load,
|
|
// exercising the tryRestoreLayout path that restores maximize from sessionStorage.
|
|
await testPage.goto(taskAUrl);
|
|
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Task A should still be maximized with our output
|
|
await session.expectMaximized();
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
});
|
|
|
|
test("closing maximized panel exits maximize and restores layout", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Close Max Test");
|
|
|
|
// Type a command in the terminal, then maximize
|
|
await session.typeInTerminal(`echo ${TERMINAL_MARKER}`);
|
|
await session.expectTerminalHasText(TERMINAL_MARKER);
|
|
await session.clickMaximize();
|
|
await session.expectMaximized();
|
|
|
|
// Close the terminal tab via dockview's built-in tab close button.
|
|
const terminalTab = testPage.locator(".dv-tab:has(.dv-default-tab:has-text('Terminal'))");
|
|
const closeBtn = terminalTab.locator(".dv-default-tab-action");
|
|
await closeBtn.click();
|
|
|
|
// Closing a terminal that looks busy MAY pop a "Close terminal?" confirmation
|
|
// (CloseTerminalConfirmDialog) — the typed command can trip the busy heuristic,
|
|
// but whether it does is timing-dependent (the shell may have gone idle). Handle
|
|
// both: confirm the dialog if it appears, otherwise the close already proceeded.
|
|
const closeTerminalDialog = testPage.getByRole("alertdialog", { name: "Close terminal?" });
|
|
await closeTerminalDialog
|
|
.waitFor({ state: "visible", timeout: 2_000 })
|
|
.then(async () => {
|
|
await closeTerminalDialog.getByRole("button", { name: "Close terminal" }).click();
|
|
await expect(closeTerminalDialog).not.toBeVisible({ timeout: 5_000 });
|
|
})
|
|
.catch(() => {
|
|
/* no confirmation shown — close proceeded directly */
|
|
});
|
|
|
|
// Should exit maximize and restore default layout minus the closed terminal
|
|
await expect(session.chat).toBeVisible({ timeout: 10_000 });
|
|
await expect(session.files).toBeVisible({ timeout: 10_000 });
|
|
await expect(session.sidebar).toBeVisible();
|
|
// Terminal should be gone (it was closed)
|
|
await expect(session.terminal).not.toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("maximize chat panel preserves scroll position", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
// Seed a session whose script emits enough messages to overflow the chat list,
|
|
// so the user can be scrolled away from the bottom.
|
|
const script = Array.from(
|
|
{ length: 30 },
|
|
(_, i) =>
|
|
`e2e:message("Message number ${i + 1} - lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua")`,
|
|
).join("\n");
|
|
|
|
await seedTaskWithSession(testPage, apiClient, seedData, "Chat Maximize Scroll", script);
|
|
|
|
const chatList = testPage.locator(".chat-message-list:visible").first();
|
|
await chatList.waitFor({ state: "visible", timeout: 10_000 });
|
|
|
|
// Wait for the message list to overflow so we have room to scroll up.
|
|
await expect
|
|
.poll(async () => chatList.evaluate((el) => el.scrollHeight - el.clientHeight), {
|
|
timeout: 15_000,
|
|
message: "Waiting for chat to overflow",
|
|
})
|
|
.toBeGreaterThan(200);
|
|
|
|
// Scroll to roughly the middle so we are clearly away from the top.
|
|
// (Exact scrollTop won't match after maximize because the panel resizes
|
|
// and content reflows — we only assert the bug invariant: scroll did
|
|
// not snap back to 0/top.)
|
|
const targetScrollTop = await chatList.evaluate((el) => {
|
|
el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2);
|
|
return el.scrollTop;
|
|
});
|
|
expect(targetScrollTop).toBeGreaterThan(100);
|
|
|
|
// Click maximize on the dockview group whose tab is the chat session tab.
|
|
// Note: the chat panel content is rendered via a portal outside `.dv-groupview`,
|
|
// so we identify the group by its tab (`session-tab-<id>`) instead.
|
|
const chatMaxBtn = testPage
|
|
.locator(
|
|
`.dv-groupview:has([data-testid^="session-tab-"]) .dv-tabs-and-actions-container [data-testid="dockview-maximize-btn"]`,
|
|
)
|
|
.first();
|
|
await chatMaxBtn.click();
|
|
|
|
// Re-locate after layout change (panel content may be re-mounted).
|
|
const chatListAfter = testPage.locator(".chat-message-list:visible").first();
|
|
await chatListAfter.waitFor({ state: "visible", timeout: 5_000 });
|
|
|
|
// Bug invariant: after maximize, scrollTop must NOT snap back to 0.
|
|
// The fix preserves the user's vertical position so they remain anchored
|
|
// somewhere mid/bottom of the conversation, not jumped to the top.
|
|
// Poll instead of a fixed sleep — the rAF restore lands within ~1 frame
|
|
// after isRestoringLayout clears.
|
|
await expect
|
|
.poll(async () => chatListAfter.evaluate((el) => el.scrollTop), {
|
|
timeout: 2_000,
|
|
message: "scroll position should be restored after maximize",
|
|
})
|
|
.toBeGreaterThan(50);
|
|
});
|
|
});
|
|
|
|
test.describe("Session tab cleanup", () => {
|
|
test("single-session task shows only named agent tab without star or number", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Single Session Tab Task",
|
|
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("Single Session Tab Task");
|
|
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,
|
|
});
|
|
|
|
// Should have exactly one session tab (the named agent tab)
|
|
const sessionTabs = testPage.locator("[data-testid^='session-tab-']");
|
|
await expect(sessionTabs).toHaveCount(1, { timeout: 10_000 });
|
|
|
|
// The generic "Agent" permanent tab should NOT exist
|
|
// (it uses permanentTab tabComponent which has no data-testid, identified by text)
|
|
const permanentAgentTab = testPage.locator(
|
|
".dv-default-tab:not(:has([data-testid^='session-tab-'])):has-text('Agent')",
|
|
);
|
|
await expect(permanentAgentTab).toHaveCount(0);
|
|
|
|
// The single session tab should NOT show a star icon
|
|
const star = sessionTabs.first().locator(".tabler-icon-star");
|
|
await expect(star).toHaveCount(0);
|
|
|
|
// The single session tab should NOT show a number badge
|
|
// (number badges are small spans with bg-foreground/10 containing a digit)
|
|
const numberBadge = sessionTabs.first().locator("span.rounded");
|
|
await expect(numberBadge).toHaveCount(0);
|
|
});
|
|
});
|