/** * P6 (T-172…T-175) — the AI knowledge-assistant dialog, driven through the * user surface (topbar ⌘K command palette → the assistant-dialog). * * The three palette-reachable capabilities are proven end-to-end: * 1. extract-actions — notes → REAL tasks (the dialog lists them; the * server persists them, verified via the task list). * 2. find-stale — backdated pages (Prisma) → the dialog lists them. * 3. find-duplicates — pages → the dialog lists the duplicate groups. * * DETERMINISM: the capability output is the deterministic mock assistant * (packages/ai MockProvider, `metadata.mode="assistant"`), so this spec MUST * run with the API started `AI_PROVIDER=mock` — the same recorded CI/dev * decision the other AI E2E suites use (`ai.spec.ts`, `ai-generate.spec.ts`, * `ai-database.spec.ts`; see ci.yml e2e job). The internal mock-assistant * SEAM is NOT needed here: it only injects hostile/forged outputs (those * negative paths are proven at the API layer by * `assistant.integration.test.ts`), and it only wraps a mock provider. * * generate-faqs / suggest-metadata are NOT covered by an E2E: they have no * web entry point (only the three modes above are wired to the palette; the * AssistantDialog header's "page-scoped block context-menu items" claim is * stale — recorded as a rule-29 conflict). Their web view (the Apply/Dismiss * suggest flow) is proven by the `assistant-dialog.test.tsx` unit tests and * the API by `assistant.integration.test.ts`. * * Selector convention: the dialog's own data-testids (assistant-dialog, * assistant-input, assistant-run, assistant-result) + the palette's * command-palette-button. */ import { test, expect } from "../fixtures"; import type { APIRequestContext, Page } from "@playwright/test"; import type { PrismaClient } from "@prisma/client"; const DSN = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory"; async function newPrisma(): Promise { const { PrismaClient: PC } = await import("@prisma/client"); return new PC({ datasources: { db: { url: DSN } } }); } /** Create a top-level page via the API (bare 201 row). */ async function createPage(api: APIRequestContext, wsId: string, title: string): Promise { const res = await api.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title } }); expect(res.status(), `createPage(${title})`).toBe(201); return ((await res.json()) as { id: string }).id; } /** Backdate a page's updatedAt (the find-stale candidate pool is a * server-side `updatedAt < now - days` cutoff). */ async function backdatePage(pageId: string, days: number): Promise { const prisma = await newPrisma(); try { await prisma.page.update({ where: { id: pageId }, data: { updatedAt: new Date(Date.now() - days * 86_400_000) }, }); } finally { await prisma.$disconnect(); } } /** Open the command palette (topbar ⌘K button) and pick an action by label. */ async function pickFromPalette(page: Page, query: string, itemLabel: string): Promise { await page.getByTestId("command-palette-button").click(); const palette = page.getByRole("dialog", { name: "Command palette" }); await expect(palette).toBeVisible({ timeout: 10_000 }); const input = palette.getByRole("combobox"); await input.fill(query); // The option's accessible name is label + hint ("…(AI) Turn notes into // tasks"), so match the label as a substring (the house pattern in // ai.spec.ts types + Enter; here we click the uniquely-named option). const option = palette.getByRole("option", { name: itemLabel }); await expect(option).toBeVisible({ timeout: 10_000 }); await option.click(); } /** Navigate to a page in the workspace (so the workspace shell — and thus * the topbar palette + the mounted assistant dialog — is booted). */ async function gotoPage(authPage: Page, ws: { id: string; slug: string }, pageId: string): Promise { await authPage.goto(`/w/${ws.slug}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); } test.describe("AI knowledge assistant (P6, T-172…T-175) — palette → assistant-dialog", () => { test("extract-actions: notes → real tasks (dialog lists them, server persists them)", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("assistant-extract"); const probe = await createPage(api, ws.id, "probe"); await gotoPage(authPage, ws, probe); await pickFromPalette(authPage, "extract action", "Extract action items (AI)"); const dialog = authPage.getByTestId("assistant-dialog"); await expect(dialog).toBeVisible({ timeout: 10_000 }); await expect(dialog.getByRole("heading", { name: "Extract action items" })).toBeVisible(); // The textarea is empty → Run is disabled until there is content. const input = dialog.getByTestId("assistant-input"); await expect(input).toBeVisible(); await expect(dialog.getByTestId("assistant-run")).toBeDisabled(); await input.fill("- Ship the whiteboard export\n- Review the Q3 budget"); await expect(dialog.getByTestId("assistant-run")).toBeEnabled(); await dialog.getByTestId("assistant-run").click(); // The dialog lists the two extracted actions. await expect(dialog.getByTestId("assistant-result")).toContainText("Ship the whiteboard export", { timeout: 20_000 }); await expect(dialog.getByTestId("assistant-result")).toContainText("Review the Q3 budget"); // The server persisted them as REAL tasks (scope=all — the dialog creates // them as the requesting user). await expect .poll( async () => { const res = await api.get(`/api/v1/workspaces/${ws.id}/tasks?scope=all`); expect(res.status()).toBe(200); const body = (await res.json()) as { data: Array<{ title: string }> }; const titles = body.data.map((t) => t.title); return titles.includes("Ship the whiteboard export") && titles.includes("Review the Q3 budget"); }, { timeout: 15_000, message: "extract-actions created both tasks" } ) .toBeTruthy(); }); test("find-stale: backdated pages are listed (90-day window)", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("assistant-stale"); const a = "Stale Alpha Page"; const b = "Stale Beta Page"; const pa = await createPage(api, ws.id, a); const pb = await createPage(api, ws.id, b); await gotoPage(authPage, ws, pa); // Backdate both past the 90-day window (the pool is `updatedAt < cutoff`). await backdatePage(pa, 120); await backdatePage(pb, 100); await pickFromPalette(authPage, "find stale", "Find stale pages (AI)"); const dialog = authPage.getByTestId("assistant-dialog"); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Default window is 90 days. await expect(dialog.getByLabel("Not updated in")).toHaveValue("90"); await dialog.getByTestId("assistant-run").click(); await expect(dialog.getByTestId("assistant-result")).toContainText("Stale Alpha Page", { timeout: 20_000 }); await expect(dialog.getByTestId("assistant-result")).toContainText("Stale Beta Page"); // The group label carries the window. await expect(dialog.getByTestId("assistant-result")).toContainText("Not updated in 90+ days"); }); test("find-duplicates: pages are grouped as likely duplicates", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("assistant-dup"); const titles = ["Dup Page One", "Dup Page Two", "Dup Page Three", "Dup Page Four"]; const p0 = await createPage(api, ws.id, titles[0]); for (let i = 1; i < titles.length; i++) await createPage(api, ws.id, titles[i]); await gotoPage(authPage, ws, p0); await pickFromPalette(authPage, "find duplicate", "Find duplicate pages (AI)"); const dialog = authPage.getByTestId("assistant-dialog"); await expect(dialog).toBeVisible({ timeout: 10_000 }); await dialog.getByTestId("assistant-run").click(); // The deterministic mock pairs the four candidates into two groups // (4 page links, "Overlapping content (mock)" reason). const result = dialog.getByTestId("assistant-result"); await expect(result).toContainText("Dup Page One", { timeout: 20_000 }); await expect(result).toContainText("Dup Page Four"); await expect(result.getByText(/Overlapping content \(mock\)/)).toHaveCount(2); }); });