417 lines
18 KiB
TypeScript
417 lines
18 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import { test, type SeedData } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { LayoutSettingsPage } from "../../pages/layout-settings-page";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
|
|
|
type TodosLayout = {
|
|
todosExists: boolean;
|
|
todosGroupId: string | null;
|
|
filesGroupId: string | null;
|
|
rightTopOrder: string[];
|
|
};
|
|
|
|
async function createTaskWithSession(apiClient: ApiClient, seedData: 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],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error(`${title} did not create a session`);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{ timeout: 45_000, message: `Waiting for ${title} session to settle` },
|
|
)
|
|
.toBe(true);
|
|
return task;
|
|
}
|
|
|
|
/** Seeds a task whose session already emitted todo entries via the mock
|
|
* agent's `e2e:plan(...)` scripting command, then settles — so the entries
|
|
* exist only in persisted message history by the time a fresh page opens
|
|
* the task, exactly like reopening an already-completed real task. */
|
|
async function createTaskWithTodos(apiClient: ApiClient, seedData: SeedData, title: string) {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: [
|
|
'e2e:plan([{"content":"Write tests","status":"completed"},{"content":"Implement feature","status":"in_progress"}])',
|
|
'e2e:message("done")',
|
|
].join("\n"),
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error(`${title} did not create a session`);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{ timeout: 45_000, message: `Waiting for ${title} session to settle` },
|
|
)
|
|
.toBe(true);
|
|
return task;
|
|
}
|
|
|
|
async function openTask(page: Page, taskId: string): Promise<SessionPage> {
|
|
await page.goto(`/t/${taskId}`);
|
|
const session = new SessionPage(page);
|
|
await session.waitForLoad();
|
|
await session.waitForDockviewReady();
|
|
return session;
|
|
}
|
|
|
|
/** Dockview tab content for the Todos panel (plain default tab, no icon). */
|
|
function todosTabContent(page: Page) {
|
|
return page.locator(".dv-default-tab").filter({ hasText: "Todos" });
|
|
}
|
|
|
|
function todosTabWrapper(page: Page) {
|
|
return page.locator(".dv-tab", { has: todosTabContent(page) });
|
|
}
|
|
|
|
async function readTodosLayout(page: Page): Promise<TodosLayout | null> {
|
|
return page.evaluate(() => {
|
|
type Panel = { id: string; group?: { id?: string; panels?: Panel[] } };
|
|
type Api = { getPanel: (id: string) => Panel | undefined };
|
|
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
|
|
if (!api) return null;
|
|
const todos = api.getPanel("todos");
|
|
const files = api.getPanel("files");
|
|
return {
|
|
todosExists: !!todos,
|
|
todosGroupId: todos?.group?.id ?? null,
|
|
filesGroupId: files?.group?.id ?? null,
|
|
rightTopOrder: files?.group?.panels?.map((panel) => panel.id) ?? [],
|
|
};
|
|
});
|
|
}
|
|
|
|
async function setTodoListPanelPreference(apiClient: ApiClient, enabled: boolean): Promise<void> {
|
|
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
show_todo_list_panel: enabled,
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
}
|
|
|
|
async function setOnlyPinWhenNotEmptyPreference(
|
|
apiClient: ApiClient,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
show_todo_list_panel_only_when_not_empty: enabled,
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
}
|
|
|
|
test.describe("Todo list panel preference", () => {
|
|
let baseline: boolean;
|
|
let baselineOnlyWhenNotEmpty: boolean;
|
|
|
|
test.beforeEach(async ({ apiClient }) => {
|
|
const settings = await apiClient.getUserSettings();
|
|
baseline = Boolean(settings.settings.show_todo_list_panel);
|
|
baselineOnlyWhenNotEmpty = Boolean(settings.settings.show_todo_list_panel_only_when_not_empty);
|
|
if (baseline) await setTodoListPanelPreference(apiClient, false);
|
|
if (baselineOnlyWhenNotEmpty) await setOnlyPinWhenNotEmptyPreference(apiClient, false);
|
|
});
|
|
|
|
test.afterEach(async ({ apiClient }) => {
|
|
await setTodoListPanelPreference(apiClient, baseline);
|
|
await setOnlyPinWhenNotEmptyPreference(apiClient, baselineOnlyWhenNotEmpty);
|
|
});
|
|
|
|
test("hides the Todos tab by default", async ({ testPage, apiClient, seedData }) => {
|
|
test.setTimeout(120_000);
|
|
const task = await createTaskWithSession(apiClient, seedData, "Todos default hidden");
|
|
await openTask(testPage, task.id);
|
|
|
|
await expect(todosTabWrapper(testPage)).toHaveCount(0);
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({ todosExists: false, rightTopOrder: ["files", "changes"] });
|
|
});
|
|
|
|
test("adds then removes the tab live from a second tab, without navigating away from the task or changing the selected tab", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const task = await createTaskWithSession(apiClient, seedData, "Todos enabled live");
|
|
await openTask(testPage, task.id);
|
|
await expect(todosTabWrapper(testPage)).toHaveCount(0);
|
|
|
|
const activeRightTab = testPage
|
|
.locator(".dv-tab.dv-active-tab")
|
|
.filter({ hasText: /Files|Changes/ });
|
|
await expect(activeRightTab).toHaveCount(1);
|
|
const activeLabelBefore = await activeRightTab.textContent();
|
|
|
|
// Toggle the preference from a second tab; `testPage` never navigates
|
|
// away from the task — proves the change is live, not reload-dependent.
|
|
const settingsPage = await testPage.context().newPage();
|
|
await settingsPage.goto("/settings/preferences/task-behavior?tab=conversation");
|
|
const toggle = settingsPage.getByRole("switch", { name: "Show agent todo list panel" });
|
|
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
await toggle.click();
|
|
let floatingSave = settingsPage.getByTestId("settings-floating-save");
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible();
|
|
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await expect(todosTabWrapper(testPage)).not.toHaveClass(/dv-active-tab/);
|
|
await expect(activeRightTab).toHaveClass(/dv-active-tab/);
|
|
expect(await activeRightTab.textContent()).toBe(activeLabelBefore);
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({ todosExists: true, rightTopOrder: ["files", "changes", "todos"] });
|
|
|
|
// Disable it again from the settings tab; the task tab still never navigates.
|
|
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
|
await toggle.click();
|
|
floatingSave = settingsPage.getByTestId("settings-floating-save");
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible();
|
|
await settingsPage.close();
|
|
|
|
await expect(todosTabWrapper(testPage)).toHaveCount(0);
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({ todosExists: false, rightTopOrder: ["files", "changes"] });
|
|
});
|
|
|
|
test("places the tab in a custom Default's configured group instead of the Files/Changes fallback", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await setTodoListPanelPreference(apiClient, true);
|
|
|
|
const layouts = new LayoutSettingsPage(testPage);
|
|
await layouts.open();
|
|
await expect(layouts.editor.locator(".dv-tab", { hasText: "Todos" })).toHaveCount(0);
|
|
// Select Terminal first so the newly added Todos panel joins its group
|
|
// (group-right-bottom) rather than the default Files/Changes fallback.
|
|
await layouts.selectPanel("Terminal");
|
|
await layouts.addPanel("Todos");
|
|
await expect(layouts.editor.locator(".dv-tab", { hasText: "Todos" })).toBeVisible();
|
|
await layouts.save();
|
|
|
|
const saved = (await apiClient.getUserSettings()).settings.saved_layouts?.[0];
|
|
expect(JSON.stringify(saved?.layout ?? {})).toContain("todos");
|
|
|
|
const task = await createTaskWithSession(apiClient, seedData, "Configured Todos placement");
|
|
await openTask(testPage, task.id);
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({
|
|
todosExists: true,
|
|
todosGroupId: "group-right-bottom",
|
|
rightTopOrder: ["files", "changes"],
|
|
});
|
|
});
|
|
|
|
test("shows the checklist for a session whose todos were already persisted before this page ever loaded it — not just live updates", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
// Todos are emitted and the session settles entirely before the
|
|
// preference is even turned on or the task is opened, so the only way
|
|
// the panel can show them is by deriving from persisted message
|
|
// history (`sessionTodos.bySessionId` is populated exclusively by a
|
|
// live WS event and is never backfilled).
|
|
const task = await createTaskWithTodos(apiClient, seedData, "Todos from history");
|
|
await setTodoListPanelPreference(apiClient, true);
|
|
await openTask(testPage, task.id);
|
|
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await todosTabWrapper(testPage).click();
|
|
const panel = testPage.getByTestId("todos-panel");
|
|
await expect(panel).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.getByTestId("todos-panel-empty-state")).toHaveCount(0);
|
|
await expect(panel.getByText("Write tests")).toBeVisible();
|
|
await expect(panel.getByText("Implement feature")).toBeVisible();
|
|
await expect(panel.getByText("1/2 completed")).toBeVisible();
|
|
});
|
|
|
|
test("checklist fills the height of its hosting panel instead of the popover cap", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
// Deterministic viewport so the geometry assertions below are stable.
|
|
await testPage.setViewportSize({ width: 1440, height: 900 });
|
|
const task = await createTaskWithTodos(apiClient, seedData, "Todos fills panel height");
|
|
await setTodoListPanelPreference(apiClient, true);
|
|
await openTask(testPage, task.id);
|
|
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await todosTabWrapper(testPage).click();
|
|
const panel = testPage.getByTestId("todos-panel");
|
|
await expect(panel).toBeVisible({ timeout: 15_000 });
|
|
|
|
// The panel root must stretch to its layout parent, the portal slot
|
|
// (`h-full w-full overflow-hidden`). `xpath=../..` skips the
|
|
// `display: contents` portal element, which carries no box of its own.
|
|
const slot = panel.locator("xpath=../..");
|
|
const [panelBox, slotBox] = await Promise.all([panel.boundingBox(), slot.boundingBox()]);
|
|
expect(slotBox).not.toBeNull();
|
|
expect(Math.abs((panelBox?.height ?? 0) - (slotBox?.height ?? 0))).toBeLessThanOrEqual(4);
|
|
|
|
// The checklist scroll container is no longer a fixed-size popover list:
|
|
// its bottom edge must track the panel's bottom content edge (only the
|
|
// p-3 padding remains below). A capped 192px (max-h-48) list can never
|
|
// reach the bottom of a taller hosting panel, so this measured invariant
|
|
// is layout-independent — it holds in short split groups and any
|
|
// viewport, with no arbitrary pixel allowance. Two-sided (Math.abs) so
|
|
// content spilling below the panel also fails the guard (round 5).
|
|
const listBox = await panel.locator(".overflow-y-auto").boundingBox();
|
|
expect(listBox).not.toBeNull();
|
|
const panelBottom = (panelBox?.y ?? 0) + (panelBox?.height ?? 0);
|
|
const listBottom = (listBox?.y ?? 0) + listBox!.height;
|
|
expect(Math.abs(panelBottom - listBottom)).toBeLessThanOrEqual(24);
|
|
});
|
|
|
|
test("is manually addable from the task workbench's own + menu, independent of the preference", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
// Preference stays off for this test (see beforeEach) — the + menu's
|
|
// Todos row is a manual, always-available add action, not gated by the
|
|
// preference the way the automatic visibility-sync hook is.
|
|
const task = await createTaskWithTodos(apiClient, seedData, "Todos via plus menu");
|
|
const session = await openTask(testPage, task.id);
|
|
await expect(todosTabWrapper(testPage)).toHaveCount(0);
|
|
|
|
// `addPanelButton()` targets the chat/center group's own "+" (the
|
|
// first header action in DOM order) — deliberately not the Files/
|
|
// Changes group where the settings-driven sync hook would default to
|
|
// placing Todos, so this proves the manual action honors the invoking
|
|
// group rather than always landing beside Files/Changes.
|
|
await session.addPanelButton().click();
|
|
const todosItem = testPage.getByRole("menuitem", { name: "Todos" });
|
|
await expect(todosItem).toBeVisible();
|
|
await todosItem.click();
|
|
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await expect(todosTabWrapper(testPage)).toHaveClass(/dv-active-tab/);
|
|
await expect(testPage.getByTestId("todos-panel")).toBeVisible();
|
|
const layout = await readTodosLayout(testPage);
|
|
expect(layout?.todosExists).toBe(true);
|
|
expect(layout?.todosGroupId).not.toBe(layout?.filesGroupId);
|
|
});
|
|
|
|
test("inhibits the only-pin-when-not-empty sub-option while the main preference is off, preserving its value", async ({
|
|
testPage,
|
|
apiClient,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const subSwitch = () =>
|
|
testPage.getByRole("switch", { name: "Only pin when todo list is not empty" });
|
|
|
|
await testPage.goto("/settings/general/task-actions");
|
|
await testPage.getByRole("tab", { name: "Conversation", exact: true }).click();
|
|
const toggle = testPage.getByRole("switch", { name: "Show agent todo list panel" });
|
|
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
await expect(subSwitch()).toHaveCount(0);
|
|
|
|
// Enabling the main preference reveals the sub-option.
|
|
await toggle.click();
|
|
await expect(subSwitch()).toBeVisible();
|
|
await subSwitch().click();
|
|
await expect(subSwitch()).toHaveAttribute("aria-checked", "true");
|
|
let floatingSave = testPage.getByTestId("settings-floating-save");
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible();
|
|
|
|
let settings = await apiClient.getUserSettings();
|
|
expect(settings.settings.show_todo_list_panel_only_when_not_empty).toBe(true);
|
|
|
|
// Disabling the main preference inhibits (hides, never disables) the
|
|
// sub-option, but its saved value survives the save.
|
|
await toggle.click();
|
|
await expect(subSwitch()).toHaveCount(0);
|
|
floatingSave = testPage.getByTestId("settings-floating-save");
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible();
|
|
|
|
settings = await apiClient.getUserSettings();
|
|
expect(settings.settings.show_todo_list_panel).toBe(false);
|
|
expect(settings.settings.show_todo_list_panel_only_when_not_empty).toBe(true);
|
|
|
|
// Reload: main preference off, sub-option still hidden but preserved.
|
|
await testPage.reload();
|
|
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
await expect(subSwitch()).toHaveCount(0);
|
|
|
|
// Re-enabling the main preference restores the sub-option checked.
|
|
await toggle.click();
|
|
await expect(subSwitch()).toHaveAttribute("aria-checked", "true");
|
|
});
|
|
|
|
test("does not auto-pin the Todos tab for an empty todo list when only-pin-when-not-empty is on", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await setOnlyPinWhenNotEmptyPreference(apiClient, true);
|
|
await setTodoListPanelPreference(apiClient, true);
|
|
|
|
// This session never emits todo entries, so the automatic pin must stay
|
|
// suppressed even though the master preference is on.
|
|
const task = await createTaskWithSession(apiClient, seedData, "Todos empty not pinned");
|
|
await openTask(testPage, task.id);
|
|
|
|
await expect(todosTabWrapper(testPage)).toHaveCount(0);
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({ todosExists: false, rightTopOrder: ["files", "changes"] });
|
|
});
|
|
|
|
test("auto-pins the Todos tab when the todo list is not empty and only-pin-when-not-empty is on", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await setOnlyPinWhenNotEmptyPreference(apiClient, true);
|
|
await setTodoListPanelPreference(apiClient, true);
|
|
|
|
// Todos are persisted in message history before the task opens, so the
|
|
// sync's two-source fallback must see them and pin the tab.
|
|
const task = await createTaskWithTodos(apiClient, seedData, "Todos pinned when present");
|
|
await openTask(testPage, task.id);
|
|
|
|
await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 });
|
|
await expect
|
|
.poll(() => readTodosLayout(testPage), { timeout: 15_000 })
|
|
.toMatchObject({ todosExists: true, rightTopOrder: ["files", "changes", "todos"] });
|
|
});
|
|
});
|