Files
kandev/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts
T

269 lines
10 KiB
TypeScript

import path from "node:path";
import { expect, 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 { SessionPage } from "../../pages/session-page";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { KanbanPage } from "../../pages/kanban-page";
const FILE_A = "alpha.ts";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
async function openFileInPreview(page: Page, session: SessionPage, filename: string) {
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 10_000 });
const fileRow = session.files.getByText(filename);
await expect(fileRow).toBeVisible({ timeout: 15_000 });
await fileRow.click();
// Retry click if preview tab didn't appear (executor may need a moment)
const previewTab = page.getByTestId("preview-tab-file-editor");
try {
await expect(previewTab).toBeVisible({ timeout: 5_000 });
} catch {
await fileRow.click();
}
}
async function seedFinishedTask(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ id: 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 ${title} session to finish` },
)
.toBe(true);
return task;
}
/** `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on. */
function tabWrapperByText(page: Page, text: string) {
return page.locator(".dv-tab", { has: page.locator(".dv-default-tab", { hasText: text }) });
}
test.describe("Preview tab survives session switch", () => {
test("preview tab persists after switching tasks and back", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// Seed a file in the repo
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(FILE_A, "// alpha content");
git.stageAll();
git.commit("seed alpha");
// Create Task A and wait for it to complete
const taskA = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Preview Switch Task A",
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(taskA.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for Task A session to finish" },
)
.toBe(true);
// Create Task B and wait for it to complete
const taskB = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Preview Switch Task 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 Task B session to finish" },
)
.toBe(true);
// Navigate to Task A via kanban
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Preview Switch Task A").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open a file in preview mode
await openFileInPreview(testPage, session, FILE_A);
const previewTab = testPage.getByTestId("preview-tab-file-editor");
await expect(previewTab).toBeVisible({ timeout: 15_000 });
// Switch to Task B via sidebar
await session.clickTaskInSidebar("Preview Switch Task B");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), {
timeout: 15_000,
});
await session.waitForLoad();
// Preview tab should not be visible on Task B
await expect(previewTab).not.toBeVisible({ timeout: 5_000 });
// Switch back to Task A — slow path restores layout via fromJSON
await session.clickTaskInSidebar("Preview Switch Task A");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), {
timeout: 15_000,
});
// Wait for layout to stabilize after fromJSON restore
await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 });
await testPage.waitForTimeout(1_000);
// File tab should be restored (preview or pinned — the file was open)
const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_A });
await expect(fileTab).toHaveCount(1, { timeout: 15_000 });
});
test("promoted file tab does not duplicate after task switch round-trip", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
// Worker-scoped backend reuses the same repo across tests in the file, so each
// test seeds its own filename to avoid "nothing to commit" collisions.
const FILE_PROMOTED = "promoted.ts";
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(FILE_PROMOTED, "// promoted content");
git.stageAll();
git.commit("seed promoted");
const taskA = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip A");
const taskB = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip B");
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Promote Round-Trip A").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open file in preview, then promote via double-click. The panel keeps id
// `preview:file-editor` with `params.promoted=true` until another file is
// opened, so we sync on the italic class disappearing rather than the testid.
await openFileInPreview(testPage, session, FILE_PROMOTED);
const previewTab = testPage.getByTestId("preview-tab-file-editor");
await expect(previewTab).toBeVisible({ timeout: 15_000 });
await previewTab.dblclick();
await expect(previewTab).not.toHaveClass(/italic/, { timeout: 10_000 });
// Round-trip: switch to Task B, then back to Task A (forces fromJSON restore).
await session.clickTaskInSidebar("Promote Round-Trip B");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Promote Round-Trip A");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 });
await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 });
// Regression: previously `loadAndRestoreTabs` re-added `file:promoted.ts` on
// top of the snapshot's restored `preview:file-editor` (which was persisted
// as `pinned: true` because it was a promoted preview), producing two tabs
// with the same filename. Strict count of 1 catches the duplicate.
const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_PROMOTED });
await expect(fileTab).toHaveCount(1, { timeout: 15_000 });
});
test("active center tab is preserved across task switch round-trip", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
const FILE_ACTIVE = "active-file.ts";
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(FILE_ACTIVE, "// active content");
git.stageAll();
git.commit("seed active");
const taskA = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip A");
const taskB = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip B");
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Active Tab Round-Trip A").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Open file in preview — it auto-activates, but we click it explicitly to
// make the intent clear and to be robust against any default-active changes.
await openFileInPreview(testPage, session, FILE_ACTIVE);
const fileTabWrapper = tabWrapperByText(testPage, FILE_ACTIVE);
await fileTabWrapper.click();
await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 10_000 });
// Round-trip
await session.clickTaskInSidebar("Active Tab Round-Trip B");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 });
await session.waitForLoad();
await session.clickTaskInSidebar("Active Tab Round-Trip A");
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 });
await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 });
// Regression: previously `useAutoSessionTab` always called `setActive()` on
// the session panel after fromJSON, overriding the restored active state.
// The file tab the user left active must remain active across the round-trip.
// (Only one tab per group can be `dv-active-tab` so this implicitly asserts
// the session panel is not active.)
await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 15_000 });
});
});