498 lines
17 KiB
TypeScript
498 lines
17 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 { SessionPage } from "../../pages/session-page";
|
|
import { attachGatewayTrafficCapture } from "../../helpers/ws-traffic";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Overview
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Covers the plan checkpointing feature (task plan revisions + rewind/revert).
|
|
//
|
|
// Coalesce window is set to 2000ms by the backend fixture
|
|
// (KANDEV_PLAN_COALESCE_WINDOW_MS=2000). Tests that cross the boundary use
|
|
// e2e:delay(2500) inside the mock-agent script; tests that stay within use
|
|
// e2e:delay(200).
|
|
//
|
|
// Agent-authored revisions are seeded through the MCP tool
|
|
// (create_task_plan_kandev). User-authored revisions are produced by typing
|
|
// into the plan editor and waiting for the 1500ms autosave debounce.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function expectedAgentCompletionText(description: string): string | null {
|
|
return description.includes("Revisions seeded.") ? "Revisions seeded." : null;
|
|
}
|
|
|
|
async function waitForAgentOutput(session: SessionPage, expectedText: string) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const matchingMessages = session.chat.getByText(expectedText, { exact: false });
|
|
const messageCount = await matchingMessages.count();
|
|
for (let index = 0; index < messageCount; index++) {
|
|
if (await matchingMessages.nth(index).isVisible()) return true;
|
|
}
|
|
return session.planPanel.isVisible();
|
|
},
|
|
{ timeout: 45_000, message: `agent output "${expectedText}" not visible` },
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
/** Create a task with agent, navigate to session, wait for agent output. */
|
|
async function seedTaskAndWaitForIdle(
|
|
testPage: Page,
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
description = "/e2e:simple-message",
|
|
) {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description,
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
const sessionId = task.session_id;
|
|
if (!sessionId) throw new Error("createTaskWithAgent did not return a session_id");
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
const expectedCompletionText = expectedAgentCompletionText(description);
|
|
if (expectedCompletionText) {
|
|
await waitForAgentOutput(session, expectedCompletionText);
|
|
// The mock agent can render its final message before the SPA receives the
|
|
// websocket state transition. Reload once after visible output so the page
|
|
// rehydrates the finished session state instead of waiting on a stale
|
|
// "Starting" footer.
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
} else {
|
|
await session.waitForChatIdle({ timeout: 45_000 });
|
|
}
|
|
|
|
return { session, taskId: task.id, sessionId };
|
|
}
|
|
|
|
/** Ensure the plan panel is visible. When an agent write auto-opens it,
|
|
* the toggle is unnecessary (and would close it). After a page reload the
|
|
* panel may not auto-open, in which case togglePlanMode adds it; we poll
|
|
* a few times because hydration can race the click. */
|
|
async function openPlanPanel(session: SessionPage) {
|
|
if (await session.planPanel.isVisible()) return;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
await session.togglePlanMode();
|
|
} catch {
|
|
// toggle button not yet ready — retry after a short wait
|
|
}
|
|
try {
|
|
await session.planPanel.waitFor({ state: "visible", timeout: 5_000 });
|
|
return;
|
|
} catch {
|
|
// Not visible yet; loop and try toggling again.
|
|
}
|
|
}
|
|
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
/** Poll for the number of revision rows in the popover. */
|
|
async function expectRevisionCount(session: SessionPage, count: number, timeout = 10_000) {
|
|
await expect(session.revisionRows()).toHaveCount(count, { timeout });
|
|
}
|
|
|
|
// MCP script building blocks — each emits one agent-authored plan revision.
|
|
function mcpWrite(content: string): string {
|
|
// JSON-string-escape content: handles backslashes, quotes, and control chars.
|
|
const escaped = JSON.stringify(content).slice(1, -1);
|
|
return `e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"${escaped}"})`;
|
|
}
|
|
|
|
/** Build a mock-agent script that chains plan writes with inter-write delays. */
|
|
function planWriteScript(writes: Array<{ content: string; delayMsAfter?: number }>): string {
|
|
const lines: string[] = ['e2e:thinking("Seeding plan revisions...")'];
|
|
for (const w of writes) {
|
|
lines.push(mcpWrite(w.content));
|
|
if (w.delayMsAfter) lines.push(`e2e:delay(${w.delayMsAfter})`);
|
|
}
|
|
lines.push('e2e:message("Revisions seeded.")');
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Plan checkpointing — rewind UI", () => {
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("empty plan: rewind button is disabled when no revisions exist", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint empty",
|
|
);
|
|
await openPlanPanel(session);
|
|
|
|
await expect(session.rewindButton()).toBeVisible({ timeout: 5_000 });
|
|
// No plan yet → no revisions → button disabled.
|
|
await expect(session.rewindButton()).toBeDisabled();
|
|
});
|
|
|
|
test("single agent write: rewind exposes one revision with current badge", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const script = planWriteScript([{ content: "Initial plan" }]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint single",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await expect(session.planPanel.getByText("Initial plan", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 1);
|
|
await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toBeVisible();
|
|
});
|
|
|
|
test("two agent writes within coalesce window: remain one revision", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "Draft v1", delayMsAfter: 200 },
|
|
{ content: "Draft v2 (merged)" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint coalesce",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await expect(session.planPanel.getByText("Draft v2", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 1);
|
|
});
|
|
|
|
test("two agent writes across coalesce window: produce two revisions", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "First pass", delayMsAfter: 2500 },
|
|
{ content: "Second pass" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint cross-window",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await expect(session.planPanel.getByText("Second pass", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
// Newest first: v2 has current badge; v1 has revert button instead.
|
|
await expect(session.revisionRow(2).getByTestId("plan-revision-current-badge")).toBeVisible();
|
|
await expect(session.revisionRow(1).getByTestId("plan-revision-current-badge")).toHaveCount(0);
|
|
await expect(session.revisionRow(1).getByTestId("plan-revision-revert-button")).toBeVisible();
|
|
});
|
|
|
|
test("revert to earlier revision: HEAD updates and new revision appears", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "Agent draft A", delayMsAfter: 2500 },
|
|
{ content: "Agent draft B" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint revert",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await expect(session.planPanel.getByText("Agent draft B", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
|
|
// Revert to v1.
|
|
await session.revertToRevision(1);
|
|
|
|
// HEAD content must now be the old v1 content.
|
|
await expect(session.planPanel.getByText("Agent draft A", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Reopen popover; expect 3 revisions with restore marker on v3.
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 3);
|
|
await expect(session.revisionRow(3).getByTestId("plan-revision-current-badge")).toBeVisible();
|
|
await expect(session.revisionRow(3).getByTestId("plan-revision-revert-marker")).toBeVisible();
|
|
});
|
|
|
|
test("revert confirmation: cancel leaves revisions untouched", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "First", delayMsAfter: 2500 },
|
|
{ content: "Second" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint cancel",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
|
|
// Click revert on v1 then cancel the row-local confirmation.
|
|
await session.revertButton(session.revisionRow(1)).click();
|
|
await expect(session.revertConfirmPopover()).toBeVisible({ timeout: 5_000 });
|
|
await session.revertConfirmPopoverCancel().click();
|
|
await expect(session.revertConfirmPopover()).toBeHidden({ timeout: 5_000 });
|
|
|
|
// HEAD is unchanged and the two revisions are still there.
|
|
await expect(session.planPanel.getByText("Second", { exact: false })).toBeVisible();
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
});
|
|
|
|
test("author switch breaks coalesce: user edit creates a second revision", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
// Attach before the first navigation so the capture sees the websocket
|
|
// from the moment it opens.
|
|
const traffic = attachGatewayTrafficCapture(testPage);
|
|
|
|
const script = planWriteScript([{ content: "Agent wrote this" }]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint author switch",
|
|
script,
|
|
);
|
|
const planUpdatesBeforeEdit = () =>
|
|
traffic.frames.filter((frame) => frame.action === "task.plan.update").length;
|
|
const planUpdatesAtStart = planUpdatesBeforeEdit();
|
|
|
|
await openPlanPanel(session);
|
|
await expect(session.planPanel.getByText("Agent wrote this", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// User types into the editor; autosave fires after 1500ms.
|
|
const editor = session.planEditor();
|
|
await editor.click();
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await testPage.keyboard.press(`${modifier}+a`);
|
|
await testPage.keyboard.type("User overwrote it");
|
|
// Autosave is debounced, but the debounce firing is observable: the save
|
|
// travels as a `task.plan.update` frame on the gateway socket. Wait for
|
|
// that frame rather than for a budget chosen to outlast the debounce.
|
|
await expect
|
|
.poll(planUpdatesBeforeEdit, {
|
|
timeout: 15_000,
|
|
message: "plan autosave never sent a task.plan.update frame",
|
|
})
|
|
.toBeGreaterThan(planUpdatesAtStart);
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
// v2 (newest) is the user write — UI displays "You" for any user-authored
|
|
// revision regardless of stored author_name. v1 stays as the agent label,
|
|
// resolved from the active session's profile snapshot — exact value varies
|
|
// per E2E setup ("Claude", "mock-default", …) so we just ensure it's a
|
|
// non-empty agent name (i.e., not the user sentinel).
|
|
const v2Author = session.revisionRow(2).getByTestId("plan-revision-author");
|
|
const v1Author = session.revisionRow(1).getByTestId("plan-revision-author");
|
|
await expect(v1Author).not.toHaveText("You");
|
|
await expect(v1Author).not.toBeEmpty();
|
|
await expect(v2Author).toHaveText("You");
|
|
});
|
|
|
|
test("preview dialog: row click opens content, restore goes through confirm", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "Older draft", delayMsAfter: 2500 },
|
|
{ content: "Newer draft" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint preview",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
|
|
// Open the older revision via row click.
|
|
await session.openRevisionPreview(1);
|
|
await expect(session.previewBody()).toContainText("Older draft", { timeout: 5_000 });
|
|
await expect(session.previewRestoreButton()).toBeVisible();
|
|
|
|
// Restore from preview routes through the confirm dialog.
|
|
await session.previewRestoreButton().click();
|
|
await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
|
|
await session.revertConfirmOk().click();
|
|
await expect(session.revertConfirmDialog()).toBeHidden({ timeout: 5_000 });
|
|
// HEAD is now the older content again.
|
|
await expect(session.planPanel.getByText("Older draft", { exact: false })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 3);
|
|
});
|
|
|
|
test("compare diff: open from preview, view unified + split, restore older", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const script = planWriteScript([
|
|
{ content: "alpha\nbeta\ngamma", delayMsAfter: 2500 },
|
|
{ content: "alpha\nBETA\ngamma\ndelta" },
|
|
]);
|
|
const { session } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint compare",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
|
|
// Open the older revision in preview, then "Compare with current" — this
|
|
// is the only entry point now; there is no per-row compare toggle.
|
|
await session.openRevisionPreview(1);
|
|
await session.previewCompareWithCurrentButton().click();
|
|
await expect(session.diffDialog()).toBeVisible({ timeout: 5_000 });
|
|
await expect(session.diffDialog()).toContainText("Compare v1 → v2");
|
|
|
|
// Summary + unified diff lines.
|
|
await expect(session.diffSummary()).toContainText("added", { timeout: 5_000 });
|
|
await expect(session.diffSummary()).toContainText("removed");
|
|
await expect(session.diffLines("add").first()).toBeVisible();
|
|
await expect(session.diffLines("remove").first()).toBeVisible();
|
|
|
|
// Switch to split mode: side-by-side cells render with kinds.
|
|
await session.diffModeToggle("split").click();
|
|
await expect(session.diffSplitCells("remove").first()).toBeVisible({ timeout: 5_000 });
|
|
await expect(session.diffSplitCells("add").first()).toBeVisible();
|
|
|
|
// Back to unified.
|
|
await session.diffModeToggle("unified").click();
|
|
await expect(session.diffLines("add").first()).toBeVisible();
|
|
|
|
// Restore from diff targets the older revision (v1).
|
|
await session.diffRestoreButton().click();
|
|
await expect(session.revertConfirmDialog()).toBeVisible({ timeout: 5_000 });
|
|
await expect(session.revertConfirmDialog()).toContainText("version 1");
|
|
await session.revertConfirmCancel().click();
|
|
});
|
|
|
|
test("persistence across reload: revisions survive page refresh", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const script = planWriteScript([{ content: "One", delayMsAfter: 2500 }, { content: "Two" }]);
|
|
const { session, taskId } = await seedTaskAndWaitForIdle(
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
"Checkpoint reload",
|
|
script,
|
|
);
|
|
await openPlanPanel(session);
|
|
await session.openRewind();
|
|
await expectRevisionCount(session, 2);
|
|
|
|
// Reload and reopen the panel.
|
|
await testPage.goto(`/t/${taskId}`);
|
|
const freshSession = new SessionPage(testPage);
|
|
await freshSession.waitForLoad();
|
|
await openPlanPanel(freshSession);
|
|
await freshSession.openRewind();
|
|
await expectRevisionCount(freshSession, 2);
|
|
});
|
|
});
|