487 lines
16 KiB
TypeScript
487 lines
16 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 { SessionPage } from "../../pages/session-page";
|
|
|
|
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
|
const CREATE_PLAN_SCRIPT = [
|
|
'e2e:thinking("creating plan")',
|
|
"e2e:delay(100)",
|
|
'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Focus regression\\n\\nKeep Agent selected","title":"Focus plan"})',
|
|
"e2e:delay(100)",
|
|
'e2e:message("focus regression agent")',
|
|
].join("\n");
|
|
|
|
async function createFinishedTaskWithSession(
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
description: string,
|
|
) {
|
|
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(`${title} did not return a session_id`);
|
|
|
|
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 finish` },
|
|
)
|
|
.toBe(true);
|
|
|
|
return { task, sessionId: task.session_id };
|
|
}
|
|
|
|
function simpleLayoutForSession(sessionId: string) {
|
|
return {
|
|
columns: [
|
|
{
|
|
id: "center",
|
|
groups: [
|
|
{
|
|
id: "group-center",
|
|
panels: [
|
|
{
|
|
id: `session:${sessionId}`,
|
|
component: "chat",
|
|
title: "Agent",
|
|
tabComponent: "sessionTab",
|
|
params: { sessionId },
|
|
},
|
|
],
|
|
activePanel: `session:${sessionId}`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function dockviewPanelIds(page: Page) {
|
|
return page.evaluate(() => {
|
|
type DockviewWindow = Window & {
|
|
__dockviewApi__?: { panels: Array<{ id: string }> };
|
|
};
|
|
return ((window as DockviewWindow).__dockviewApi__?.panels ?? []).map((p) => p.id);
|
|
});
|
|
}
|
|
|
|
async function dockviewDefaultTree(page: Page, sessionId: string) {
|
|
return page.evaluate((currentSessionId) => {
|
|
type GridNode =
|
|
| { type: "branch"; data: GridNode[] }
|
|
| { type: "leaf"; data: { views: string[] } };
|
|
type Api = {
|
|
getPanel: (id: string) => { group: { id: string } } | null;
|
|
toJSON: () => { grid: { orientation: string; root: GridNode } };
|
|
};
|
|
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
|
|
if (!api) throw new Error("dockview api not exposed");
|
|
const layout = api.toJSON();
|
|
const root = layout.grid.root;
|
|
const rootColumns = root.type === "branch" ? root.data : [];
|
|
const rightColumnPreserved = rootColumns.some(
|
|
(column) =>
|
|
column.type === "branch" &&
|
|
column.data.some(
|
|
(group) =>
|
|
group.type === "leaf" &&
|
|
(group.data.views.includes("files") || group.data.views.includes("changes")),
|
|
) &&
|
|
column.data.some(
|
|
(group) => group.type === "leaf" && group.data.views.includes("terminal-default"),
|
|
),
|
|
);
|
|
|
|
return {
|
|
orientation: layout.grid.orientation,
|
|
centerGroupId: api.getPanel(`session:${currentSessionId}`)?.group.id ?? null,
|
|
rootColumnCount: rootColumns.length,
|
|
rightColumnPreserved,
|
|
};
|
|
}, sessionId);
|
|
}
|
|
|
|
function focusSplitLayout() {
|
|
return {
|
|
grid: {
|
|
root: {
|
|
type: "branch",
|
|
size: 700,
|
|
data: [
|
|
{
|
|
type: "leaf",
|
|
size: 900,
|
|
data: {
|
|
id: "group-center",
|
|
views: ["chat", "plan"],
|
|
activeView: "chat",
|
|
},
|
|
},
|
|
{
|
|
type: "branch",
|
|
size: 360,
|
|
data: [
|
|
{
|
|
type: "leaf",
|
|
size: 420,
|
|
data: {
|
|
id: "group-right-top",
|
|
views: ["files", "changes"],
|
|
activeView: "files",
|
|
},
|
|
},
|
|
{
|
|
type: "leaf",
|
|
size: 280,
|
|
data: {
|
|
id: "group-right-bottom",
|
|
views: ["terminal-default"],
|
|
activeView: "terminal-default",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
height: 700,
|
|
width: 1260,
|
|
orientation: "HORIZONTAL",
|
|
},
|
|
panels: {
|
|
chat: { id: "chat", contentComponent: "chat", title: "Agent" },
|
|
plan: {
|
|
id: "plan",
|
|
contentComponent: "plan",
|
|
tabComponent: "planTab",
|
|
title: "Plan",
|
|
},
|
|
files: { id: "files", contentComponent: "files", title: "Files" },
|
|
changes: {
|
|
id: "changes",
|
|
contentComponent: "changes",
|
|
tabComponent: "changesTab",
|
|
title: "Changes",
|
|
},
|
|
"terminal-default": {
|
|
id: "terminal-default",
|
|
contentComponent: "terminal",
|
|
tabComponent: "terminalTab",
|
|
title: "Terminal",
|
|
},
|
|
},
|
|
activeGroup: "group-right-top",
|
|
};
|
|
}
|
|
|
|
async function triggerFocusSplitReconciliation(page: Page, taskId: string, sessionId: string) {
|
|
await page.evaluate(
|
|
({ currentTaskId, currentSessionId }) => {
|
|
type Panel = {
|
|
id: string;
|
|
group: { id: string };
|
|
api: { close: () => void; setActive: () => void };
|
|
};
|
|
type StoreState = {
|
|
taskSessionsByTask: { itemsByTaskId: Record<string, unknown[]> };
|
|
};
|
|
type TestWindow = Window & {
|
|
__KANDEV_E2E_STORE__?: {
|
|
getState: () => StoreState;
|
|
setState: (updater: (state: StoreState) => void) => void;
|
|
};
|
|
__dockviewApi__?: {
|
|
addPanel: (options: object) => Panel;
|
|
getPanel: (id: string) => Panel | null;
|
|
};
|
|
__focusTestSessions__?: unknown[];
|
|
};
|
|
const win = window as TestWindow;
|
|
const api = win.__dockviewApi__;
|
|
const store = win.__KANDEV_E2E_STORE__;
|
|
if (!api || !store) throw new Error("Dockview or E2E store bridge missing");
|
|
|
|
const sessionPanel = api.getPanel(`session:${currentSessionId}`);
|
|
const centerGroupId = sessionPanel?.group.id ?? api.getPanel("plan")?.group.id;
|
|
if (!centerGroupId) throw new Error("Center group missing");
|
|
const chatPanel =
|
|
api.getPanel("chat") ??
|
|
api.addPanel({
|
|
id: "chat",
|
|
component: "chat",
|
|
title: "Agent",
|
|
position: { referenceGroup: centerGroupId, index: 0 },
|
|
inactive: true,
|
|
});
|
|
const filesPanel = api.getPanel("files");
|
|
if (!filesPanel) throw new Error("Files panel missing");
|
|
|
|
chatPanel.api.setActive();
|
|
filesPanel.api.setActive();
|
|
sessionPanel?.api.close();
|
|
|
|
const sessions = store.getState().taskSessionsByTask.itemsByTaskId[currentTaskId];
|
|
if (!sessions?.length) throw new Error("Task session list missing");
|
|
win.__focusTestSessions__ = sessions;
|
|
store.setState((state) => {
|
|
state.taskSessionsByTask.itemsByTaskId[currentTaskId] = [];
|
|
});
|
|
},
|
|
{ currentTaskId: taskId, currentSessionId: sessionId },
|
|
);
|
|
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
}),
|
|
);
|
|
|
|
await page.evaluate((currentTaskId) => {
|
|
type StoreState = {
|
|
taskSessionsByTask: { itemsByTaskId: Record<string, unknown[]> };
|
|
};
|
|
type TestWindow = Window & {
|
|
__KANDEV_E2E_STORE__?: {
|
|
setState: (updater: (state: StoreState) => void) => void;
|
|
};
|
|
__focusTestSessions__?: unknown[];
|
|
};
|
|
const win = window as TestWindow;
|
|
const store = win.__KANDEV_E2E_STORE__;
|
|
const sessions = win.__focusTestSessions__;
|
|
if (!store || !sessions) throw new Error("Focus test session snapshot missing");
|
|
store.setState((state) => {
|
|
state.taskSessionsByTask.itemsByTaskId[currentTaskId] = sessions;
|
|
});
|
|
}, taskId);
|
|
}
|
|
|
|
test.describe("saved Dockview layouts", () => {
|
|
test("keeps the desktop split tree when switching tasks in one environment", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const parent = await createFinishedTaskWithSession(
|
|
apiClient,
|
|
seedData,
|
|
"Shared environment layout parent",
|
|
'/e2e:message("parent session")',
|
|
);
|
|
const child = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Shared environment layout child",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: '/e2e:message("child session")',
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
parent_id: parent.task.id,
|
|
workspace_mode: "inherit_parent",
|
|
},
|
|
);
|
|
if (!child.session_id) throw new Error("child task did not return a session_id");
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [{ sessions: parentSessions }, { sessions: childSessions }] = await Promise.all([
|
|
apiClient.listTaskSessions(parent.task.id),
|
|
apiClient.listTaskSessions(child.id),
|
|
]);
|
|
const parentEnvironmentId = parentSessions[0]?.task_environment_id;
|
|
const childEnvironmentId = childSessions[0]?.task_environment_id;
|
|
return parentEnvironmentId && childEnvironmentId === parentEnvironmentId;
|
|
})
|
|
.toBe(true);
|
|
|
|
await testPage.goto(`/t/${parent.task.id}`);
|
|
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.getByText("parent session")).toBeVisible({ timeout: 30_000 });
|
|
|
|
const sidebar = testPage.getByTestId("app-sidebar");
|
|
await sidebar.getByRole("button", { name: /Shared environment layout child/ }).click();
|
|
await expect(testPage).toHaveURL(new RegExp(`/t/${child.id}(?:\\?|$)`), { timeout: 10_000 });
|
|
|
|
await expect
|
|
.poll(() => dockviewPanelIds(testPage), {
|
|
timeout: 10_000,
|
|
message: "Waiting for the child session panel after the same-environment switch",
|
|
})
|
|
.toContain(`session:${child.session_id}`);
|
|
expect(await dockviewPanelIds(testPage)).not.toContain(`session:${parent.sessionId}`);
|
|
await expect(testPage.getByTestId(`session-tab-${child.session_id}`)).toBeVisible();
|
|
await expect(testPage.getByTestId("watermark-add-panel-btn")).toHaveCount(0);
|
|
|
|
await expect
|
|
.poll(() => dockviewDefaultTree(testPage, child.session_id!))
|
|
.toEqual({
|
|
orientation: "HORIZONTAL",
|
|
centerGroupId: "group-center",
|
|
rootColumnCount: 2,
|
|
rightColumnPreserved: true,
|
|
});
|
|
|
|
await testPage.reload();
|
|
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
|
await expect
|
|
.poll(() => dockviewDefaultTree(testPage, child.session_id!))
|
|
.toEqual({
|
|
orientation: "HORIZONTAL",
|
|
centerGroupId: "group-center",
|
|
rootColumnCount: 2,
|
|
rightColumnPreserved: true,
|
|
});
|
|
await expect(testPage.getByTestId(`session-tab-${child.session_id}`)).toBeVisible();
|
|
await expect(testPage.getByTestId("watermark-add-panel-btn")).toHaveCount(0);
|
|
});
|
|
|
|
test("saved chat-only layouts keep the current task session", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const taskA = await createFinishedTaskWithSession(
|
|
apiClient,
|
|
seedData,
|
|
"Saved Layout Source Task",
|
|
'/e2e:message("source task only")',
|
|
);
|
|
const taskB = await createFinishedTaskWithSession(
|
|
apiClient,
|
|
seedData,
|
|
"Saved Layout Target Task",
|
|
'/e2e:message("target task current")',
|
|
);
|
|
|
|
await apiClient.saveUserSettings({
|
|
workspace_id: seedData.workspaceId,
|
|
workflow_filter_id: seedData.workflowId,
|
|
saved_layouts: [
|
|
{
|
|
id: "layout-default-stale-session",
|
|
name: "Default Chat",
|
|
is_default: true,
|
|
layout: simpleLayoutForSession(taskA.sessionId),
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: "layout-simple-stale-session",
|
|
name: "Simple",
|
|
is_default: false,
|
|
layout: simpleLayoutForSession(taskA.sessionId),
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
],
|
|
});
|
|
|
|
await testPage.goto(`/t/${taskB.task.id}`);
|
|
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.getByText("target task current")).toBeVisible({ timeout: 30_000 });
|
|
|
|
await expect
|
|
.poll(() => dockviewPanelIds(testPage), {
|
|
timeout: 10_000,
|
|
message: "Waiting for default layout to settle on current session",
|
|
})
|
|
.toContain(`session:${taskB.sessionId}`);
|
|
expect(await dockviewPanelIds(testPage)).not.toContain(`session:${taskA.sessionId}`);
|
|
|
|
await testPage.getByTestId("layout-preset-trigger").click();
|
|
await testPage.getByRole("menuitem", { name: "Simple", exact: true }).click();
|
|
|
|
await expect
|
|
.poll(() => dockviewPanelIds(testPage), {
|
|
timeout: 10_000,
|
|
message: "Waiting for saved layout to settle on current session",
|
|
})
|
|
.toContain(`session:${taskB.sessionId}`);
|
|
|
|
const panelIds = await dockviewPanelIds(testPage);
|
|
expect(panelIds).not.toContain(`session:${taskA.sessionId}`);
|
|
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.task.id));
|
|
await expect(testPage.getByText("target task current")).toBeVisible();
|
|
await expect(testPage.getByText("source task only")).not.toBeVisible();
|
|
});
|
|
|
|
test("preserves Agent selection while Files owns global focus", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const { task, sessionId } = await createFinishedTaskWithSession(
|
|
apiClient,
|
|
seedData,
|
|
"Dockview focus preservation",
|
|
'/e2e:message("focus regression agent")',
|
|
);
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
const environmentId = sessions[0]?.task_environment_id;
|
|
if (!environmentId) throw new Error("Task environment id missing");
|
|
|
|
await testPage.addInitScript(
|
|
({ envId, layout }) => {
|
|
window.sessionStorage.setItem(
|
|
`kandev.dockview.env-layout-v3.${envId}`,
|
|
JSON.stringify(layout),
|
|
);
|
|
},
|
|
{ envId: environmentId, layout: focusSplitLayout() },
|
|
);
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.getByTestId(`session-tab-${sessionId}`)).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await session.clickSessionChatTab();
|
|
await session.waitForLoad();
|
|
await session.sendMessage(CREATE_PLAN_SCRIPT);
|
|
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
|
|
await expect.poll(async () => (await apiClient.getTaskPlan(task.id))?.created_by).toBe("agent");
|
|
|
|
const planTab = testPage.locator(".dv-tab", {
|
|
has: testPage.locator(".dv-default-tab:has-text('Plan')"),
|
|
});
|
|
await expect(planTab).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.getByTestId("plan-tab-indicator")).toBeVisible({ timeout: 15_000 });
|
|
|
|
await triggerFocusSplitReconciliation(testPage, task.id, sessionId);
|
|
|
|
const sessionTab = testPage.locator(".dv-tab", {
|
|
has: testPage.getByTestId(`session-tab-${sessionId}`),
|
|
});
|
|
await expect(sessionTab).toHaveClass(/dv-active-tab/);
|
|
await expect(planTab).not.toHaveClass(/dv-active-tab/);
|
|
await expect(testPage.getByTestId("plan-tab-indicator")).toBeVisible();
|
|
await expect
|
|
.poll(() =>
|
|
testPage.evaluate(
|
|
() =>
|
|
(
|
|
window as unknown as {
|
|
__dockviewApi__?: { activePanel?: { id: string }; panels: Array<{ id: string }> };
|
|
}
|
|
).__dockviewApi__?.activePanel?.id ?? null,
|
|
),
|
|
)
|
|
.toBe("files");
|
|
expect(
|
|
(await dockviewPanelIds(testPage)).filter((id) => id === `session:${sessionId}`),
|
|
).toHaveLength(1);
|
|
});
|
|
});
|