Files
kandev/apps/web/e2e/tests/plugins/plugin-task-panel.spec.ts
2026-08-15 17:00:02 +01:00

367 lines
14 KiB
TypeScript

/**
* E2E: the plugin task panel / kanban Edit submenu / card indicator hooks
* added by this task (docs/plans/plugins/PLUGIN-API.md's registerTaskPanel /
* registerTaskMenuAction / the "task-card-indicators" slot).
*
* Uses the same real `plugin-fixture` gRPC plugin package as
* `plugins.spec.ts` — see that file's header for how
* apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz is built
* (`make -C apps/backend e2e-plugin-package`). The fixture's `ui/bundle.js`
* registers a "Notes" task panel (mobile-enabled), a `task-card-indicators`
* slot component, and an "edit"-group kanban menu action — see
* apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js.
*/
import { expect, test } from "../../fixtures/test-base";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
import { SessionPage } from "../../pages/session-page";
import { KanbanPage } from "../../pages/kanban-page";
import type { ApiClient } from "../../helpers/api-client";
const PANEL_ID = "notes";
async function uninstallViaApi(apiClient: ApiClient): Promise<void> {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
}
test.describe("Plugins — task panel / kanban Edit submenu / card indicator", () => {
test.afterEach(async ({ apiClient }) => {
await uninstallViaApi(apiClient);
});
test("registerTaskPanel: opens from the + menu, round-trips through host.storage, and survives a reload (AC1, AC3, AC19)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
// A repo-backed task with an agent (not a bare createTask) so the task
// gets a real environment id — the dockview per-env layout persistence
// this test exercises (AC3) is keyed by envId and is a no-op without one,
// same as every other layout-persistence-across-reload spec.
const seedTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Plugin panel seed task",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${seedTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// --- AC1: the "+" menu shows a "Notes" row; opening it renders the
// plugin's Component as a real dockview panel. ---
await session.addPanelButton().click();
const addPanelRow = session.addPanelPluginItem(PLUGIN_ID, PANEL_ID);
await expect(addPanelRow).toBeVisible();
await expect(addPanelRow).toHaveText(/Notes/);
await addPanelRow.click();
const notesEditor = testPage.getByTestId("e2e-notes-panel");
await expect(notesEditor).toBeVisible({ timeout: 10_000 });
await expect(notesEditor).toHaveValue("");
// --- AC19: host.storage.set/get round-trip through the real backend
// (the fixture debounces ~150ms before writing). ---
await notesEditor.fill("hello from e2e");
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/plugins/${PLUGIN_ID}/user-state/task/${seedTask.id}/note`,
);
if (res.status !== 200) return null;
const body = (await res.json()) as { value: string };
return body.value;
},
{ timeout: 10_000, intervals: [250, 500, 1000] },
)
.toBe("hello from e2e");
// --- AC3: reloading restores the panel at the same id/title/position —
// the layout was saved with the open plugin panel. Wait on the restored
// panel directly rather than session.waitForLoad() (which gates on the
// chat panel specifically) — the agent-backed environment can take a
// moment to reconnect post-reload, and that reconnection isn't what
// this assertion is about. ---
await testPage.reload();
await expect(testPage.getByTestId("e2e-notes-panel")).toBeVisible({ timeout: 20_000 });
await expect(testPage.getByTestId("e2e-notes-panel")).toHaveValue("hello from e2e", {
timeout: 10_000,
});
});
test("disabling the plugin closes its open panel and removes the + menu row (AC4)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
const seedTask = await apiClient.createTask(seedData.workspaceId, "Plugin panel disable task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${seedTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.addPanelButton().click();
await session.addPanelPluginItem(PLUGIN_ID, PANEL_ID).click();
await expect(testPage.getByTestId("e2e-notes-panel")).toBeVisible({ timeout: 10_000 });
const consoleErrors: string[] = [];
testPage.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
await testPage.goto("/settings/plugins");
await pluginRow.getByRole("button", { name: "Disable" }).click();
await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 10_000 });
await testPage.goto(`/t/${seedTask.id}`);
await session.waitForLoad();
await expect(testPage.getByTestId("e2e-notes-panel")).toHaveCount(0);
await session.addPanelButton().click();
await expect(session.addPanelPluginItem(PLUGIN_ID, PANEL_ID)).toHaveCount(0);
expect(consoleErrors).toEqual([]);
});
test("registerTaskMenuAction: the kanban card Edit item becomes a submenu, and the plugin action runs (AC9)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
const seedTask = await apiClient.createTask(seedData.workspaceId, "Plugin edit menu task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto("/");
const kanban = new KanbanPage(testPage);
await expect(kanban.board).toBeVisible({ timeout: 15_000 });
await kanban.taskCard(seedTask.id).click({ button: "right" });
const editSubmenu = testPage.getByTestId("kanban-edit-submenu");
await expect(editSubmenu).toBeVisible();
await editSubmenu.click();
await expect(testPage.getByRole("menuitem", { name: "Edit task" })).toBeVisible();
const pluginAction = testPage.getByRole("menuitem", { name: "Enhance notes" });
await expect(pluginAction).toBeVisible();
await pluginAction.click();
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/plugins/${PLUGIN_ID}/user-state/task/${seedTask.id}/note`,
);
if (res.status !== 200) return null;
const body = (await res.json()) as { value: string };
return body.value;
},
{ timeout: 10_000, intervals: [250, 500, 1000] },
)
.toBe("Enhanced via plugin action");
await expect
.poll(
async () => {
const res = await apiClient.rawRequest(
"GET",
`/api/plugins/${PLUGIN_ID}/user-state/task/${seedTask.id}/menu-presentation`,
);
if (res.status !== 200) return null;
const body = (await res.json()) as { value: string };
return body.value;
},
{ timeout: 10_000, intervals: [250, 500, 1000] },
)
.toBe("desktop");
});
test("a sibling surface's write (the kanban action's default writerId) still reaches an open task panel in the same tab", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Regression test for review feedback: host.storage.subscribe's echo
// suppression used to compare against one writer id shared by every
// surface in the tab, so the task panel's own subscription treated the
// kanban "Enhance notes" action's write (a different surface, same tab)
// as its own echo and silently dropped it. The panel now subscribes
// under its own panelId (see the fixture's useNotesValue), so a write
// carrying any other writerId — exactly what the kanban action's
// fire-and-forget default write looks like — must still come through.
await installFixturePlugin(testPage);
const seedTask = await apiClient.createTask(seedData.workspaceId, "Sibling surface sync task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${seedTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.addPanelButton().click();
await session.addPanelPluginItem(PLUGIN_ID, PANEL_ID).click();
const notesEditor = testPage.getByTestId("e2e-notes-panel");
await expect(notesEditor).toBeVisible({ timeout: 10_000 });
await expect(notesEditor).toHaveValue("");
// Simulates the kanban "Enhance notes" action's write — same endpoint,
// same request shape (host.storage.set with no writerId override) — but
// triggered directly rather than via the kanban board UI, since the
// board and an open task panel are different routes and can't be
// on-screen at the same time in this app's navigation model. What
// matters for this regression is that the write's writerId does NOT
// match the open panel's own panelId, exactly as a sibling surface's
// write would look from the panel's point of view.
const res = await apiClient.rawRequest(
"PUT",
`/api/plugins/${PLUGIN_ID}/user-state/task/${seedTask.id}/note`,
{ value: "Enhanced via plugin action", writerId: "e2e-sibling-surface" },
);
expect(res.status).toBe(200);
await expect(notesEditor).toHaveValue("Enhanced via plugin action", { timeout: 10_000 });
});
test("useNotesValue discards a stale storage read that resolves after a newer one", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
// Regression test for review feedback: the fixture's useNotesValue only
// guarded against unmount (`cancelled`), not against two overlapping
// host.storage.get calls resolving out of order — the mount's initial
// read and a subscribe-triggered refresh from another surface's write.
// If the mount's read (fetched first, before the other write) resolves
// *after* the refresh triggered by that write, it must not overwrite the
// newer value with its own stale one.
await installFixturePlugin(testPage);
const seedTask = await apiClient.createTask(seedData.workspaceId, "Stale read task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const notePath = `/api/plugins/${PLUGIN_ID}/user-state/task/${seedTask.id}/note`;
const notePathPattern = `**${notePath}`;
// Hold only the first mount read. A production E2E bundle does not replay
// effects through React Strict Mode, while a development bundle may issue
// a second read. Letting later reads continue keeps the causal stale-read
// setup valid in both modes without waiting for a dev-only request.
let firstReadHeld = false;
let releaseInitialReads: () => void = () => {};
const initialReadsHeld = new Promise<void>((resolve) => {
releaseInitialReads = resolve;
});
let signalFirstRequestStarted: () => void = () => {};
const firstRequestStarted = new Promise<void>((resolve) => {
signalFirstRequestStarted = resolve;
});
const initialRequestHandlers: Promise<void>[] = [];
await testPage.route(notePathPattern, async (route) => {
if (route.request().method() !== "GET" || firstReadHeld) {
return route.continue();
}
firstReadHeld = true;
const handler = (async () => {
signalFirstRequestStarted();
await initialReadsHeld;
// Fulfilled with canned stale content instead of passed through, so
// its body is deterministically the pre-update value even though the
// real store has since moved on.
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ value: "first note", updatedAt: new Date(0).toISOString() }),
});
})();
initialRequestHandlers.push(handler);
return handler;
});
try {
await testPage.goto(`/t/${seedTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.addPanelButton().click();
await session.addPanelPluginItem(PLUGIN_ID, PANEL_ID).click();
// Wait until the first mount GET is held before issuing the newer write.
await firstRequestStarted;
// Subsequent reads are the real subscription refreshes. The sibling
// write now triggers one that resolves with the newer store value.
const res = await apiClient.rawRequest("PUT", notePath, {
value: "second note",
writerId: "e2e-sibling-surface",
});
expect(res.status).toBe(200);
const notesEditor = testPage.getByTestId("e2e-notes-panel");
await expect(notesEditor).toHaveValue("second note", { timeout: 10_000 });
// Now let the stale mount reads land — neither may revert the panel.
// Awaiting the handlers themselves (rather than a fixed dwell) is the
// real causal signal that the stale responses have been processed.
releaseInitialReads();
await Promise.all(initialRequestHandlers);
await expect(notesEditor).toHaveValue("second note");
} finally {
releaseInitialReads();
await Promise.all(initialRequestHandlers);
await testPage.unroute(notePathPattern);
}
});
test("task-card-indicators slot renders the plugin's indicator on the kanban card (AC13)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
await installFixturePlugin(testPage);
const seedTask = await apiClient.createTask(
seedData.workspaceId,
"Plugin card indicator task",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
},
);
await testPage.goto("/");
const kanban = new KanbanPage(testPage);
await expect(kanban.board).toBeVisible({ timeout: 15_000 });
const indicator = kanban.taskCard(seedTask.id).getByTestId("e2e-card-indicator");
await expect(indicator).toBeVisible({ timeout: 15_000 });
await expect(indicator).toHaveAttribute("data-task-id", seedTask.id);
});
});