/** * AI Software Factory — E2E: Writing tools (TASK-0123). * * Drives the REAL stack (Next web + Nest API + the deterministic mock * provider's writing mode) against the acceptance criteria in * docs/tasks/phase-12-ai.md. The mock's writing mode is deterministic: * summarize → `Summarized: ` (per block) * rewrite → `Rewritten: ` * improve → `Improved: ` * translate → `Translated to : ` * so the E2E can assert the exact applied text. * * Covered (the spec's E2E AC): * 1. select 3 paragraphs → selection toolbar "AI" → Summarize → the diff * appears → Accept → all 3 blocks updated (positions unchanged), realtime * syncs to a second tab. * 2. Translate with a language pick (Spanish) → Accept → text updated. * 3. Discard → blocks byte-identical (no write). * 4. a concurrent edit (version bump) between suggest and apply → the apply * surfaces a per-block 409 → the rebase prompt appears. * * Send budget: the per-user AI cap is 30 calls/min and the demo user is shared * across the 2 CI workers; each test issues ONE writing generate, so this file * stays well under the cap (tests are serial within a file). * * Selector convention (tests/README.md): this task's own data-testids — * selection-toolbar, selection-ai-menu, writing-action-*, writing-language-*, * writing-diff, writing-diff-block, writing-accept, writing-accept-one-*, * writing-discard, writing-rebase-prompt. */ import { API_BASE, DEMO_EMAIL, DEMO_PASSWORD, expect, test } from "../fixtures"; import { createBlock, createPage, createWorkspace } from "../utils/api"; import type { BlockDto } from "../fixtures"; import type { APIRequestContext, Browser, Page } from "@playwright/test"; /** The deterministic mock output for `improve`/`rewrite`/`translate` of `t`. */ const IMPROVED = (t: string) => `Improved: ${t.trim()}`; const SUMMARIZED = (t: string) => `Summarized: ${t.trim().slice(0, 80)}`; const TRANSLATED = (lang: string, t: string) => `Translated to ${lang}: ${t.trim()}`; /** A demo-user tab with its OWN session (fresh login) — the realtime second * tab (mirrors collab-two-tab.spec.ts; a shared cookie would rotate + trip * reuse-detection). */ async function authedTab(browser: Browser): Promise<{ page: Page; close: () => Promise }> { // baseURL is REQUIRED: `plain.request.post("/api/v1/auth/login", …)` is a // relative URL and throws `Invalid URL` without one (the `authPage` fixture // pins the same API_BASE). const plain = await browser.newContext({ baseURL: API_BASE }); const res = await plain.request.post("/api/v1/auth/login", { data: { email: DEMO_EMAIL, password: DEMO_PASSWORD }, }); if (res.status() !== 200) throw new Error(`authedTab: login failed (${res.status()})`); const jar = await plain.cookies(); await plain.close(); const context = await browser.newContext({ storageState: { cookies: jar.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), origins: [], }, }); const page = await context.newPage(); return { page, close: () => context.close() }; } /** A workspace + page with 3 seeded paragraph blocks (positions 1/2/3). */ async function seedPage(api: APIRequestContext): Promise<{ wsId: string; pageId: string; blocks: BlockDto[] }> { const ws = await createWorkspace(api, `Writing ${Date.now()}`); const page = await createPage(api, ws.id, { title: "Writing tools" }); const blocks: BlockDto[] = []; const texts = ["alpha one", "beta two", "gamma three"]; for (let i = 0; i < texts.length; i++) { blocks.push(await createBlock(api, page.id, { type: "paragraph", content: { text: texts[i] }, position: `${i + 1}.0`, })); } return { wsId: ws.id, pageId: page.id, blocks }; } /** The block's rendered text (the `{text}` content model). */ const textOf = (b: BlockDto): string => typeof b.content === "string" ? b.content : String((b.content as { text?: unknown }).text ?? ""); /** Ctrl+click (toggle) a block into the multi-selection, then the toolbar "AI" * menu → a writing action. Returns the opened writing dialog. */ async function pickWriting( page: Page, blocks: BlockDto[], action: "summarize" | "rewrite" | "improve" | "translate", language?: string, ) { for (const b of blocks) { await page.locator(`.block-row[data-block-id="${b.id}"]`).click({ modifiers: ["Control"] }); } await expect(page.getByTestId("selection-toolbar")).toBeVisible(); await page.getByTestId("selection-ai-menu").click(); await page.getByTestId(`writing-action-${action}`).click(); if (language) await page.getByTestId(`writing-language-${language}`).click(); const diff = page.getByTestId("writing-diff"); await expect(diff).toBeVisible({ timeout: 20_000 }); return diff; } test.describe("Writing tools (TASK-0123)", () => { test("select 3 → AI → Summarize → diff → Accept → all updated, realtime to a 2nd tab, positions unchanged", async ({ authPage, api, browser, }) => { const { wsId, pageId, blocks } = await seedPage(api); const url = `/w/${wsId}/page/${pageId}`; await authPage.goto(url); await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 }); const positionsBefore = blocks.map((b) => b.position); await pickWriting(authPage, blocks, "summarize"); // The diff lists all 3 blocks, each showing its (mock) summary. const diffBlocks = authPage.getByTestId("writing-diff-block"); await expect(diffBlocks).toHaveCount(3, { timeout: 20_000 }); for (const [i, b] of blocks.entries()) { await expect(diffBlocks.nth(i)).toContainText(SUMMARIZED(textOf(b))); } await authPage.getByTestId("writing-accept").click(); // All 3 blocks updated to their summaries; the dialog closed (all applied). await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 }); const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data; for (const [i, b] of blocks.entries()) { const updated = all.find((x) => x.id === b.id)!; expect(textOf(updated), `block ${i} text`).toBe(SUMMARIZED(textOf(b))); } // Positions (fractional ordering) are unchanged by the apply. for (const [i, b] of blocks.entries()) { const updated = all.find((x) => x.id === b.id)!; expect(updated.position, `block ${i} position`).toBe(positionsBefore[i]); } // A second tab (realtime) converges on the same new text within the window. const tab2 = await authedTab(browser); try { await tab2.page.goto(url); for (const [i, b] of blocks.entries()) { await expect( tab2.page.locator(`.block-row[data-block-id="${b.id}"]`), `tab2 block ${i}`, ).toContainText(SUMMARIZED(textOf(b)), { timeout: 15_000 }); } } finally { await tab2.close(); } }); test("Translate with a language pick → Accept → text updated", async ({ authPage, api }) => { const { wsId, pageId, blocks } = await seedPage(api); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 }); await pickWriting(authPage, blocks.slice(0, 1), "translate", "es"); const diffBlocks = authPage.getByTestId("writing-diff-block"); await expect(diffBlocks).toHaveCount(1, { timeout: 20_000 }); await expect(diffBlocks.first()).toContainText(TRANSLATED("es", textOf(blocks[0]!))); await authPage.getByTestId("writing-accept").click(); await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 }); const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data; const updated = all.find((x) => x.id === blocks[0]!.id)!; expect(textOf(updated)).toBe(TRANSLATED("es", textOf(blocks[0]!))); }); test("Discard → blocks byte-identical (no write)", async ({ authPage, api }) => { const { wsId, pageId, blocks } = await seedPage(api); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 }); // Snapshot the blocks' content + positions before the suggestion. const before = new Map( blocks.map((b) => [b.id, JSON.stringify({ content: b.content, position: b.position })]), ); await pickWriting(authPage, blocks, "improve"); await expect(authPage.getByTestId("writing-accept")).toBeEnabled(); await authPage.getByTestId("writing-discard").click(); await expect(authPage.getByTestId("writing-diff")).toHaveCount(0, { timeout: 20_000 }); // Nothing was written — every block's content + position is byte-identical. const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data; for (const b of blocks) { const now = all.find((x) => x.id === b.id)!; expect(JSON.stringify({ content: now.content, position: now.position })).toBe(before.get(b.id)); } }); test("concurrent edit between suggest and apply → 409 → rebase prompt", async ({ authPage, api }) => { const { wsId, pageId, blocks } = await seedPage(api); await authPage.goto(`/w/${wsId}/page/${pageId}`); await expect(authPage.locator(`.block-row[data-block-id="${blocks[0]!.id}"]`)).toBeVisible({ timeout: 20_000 }); // Generate the suggestion (locks the blocks' versions in the suggestion row). await pickWriting(authPage, blocks.slice(0, 1), "improve"); const diffBlocks = authPage.getByTestId("writing-diff-block"); await expect(diffBlocks).toHaveCount(1, { timeout: 20_000 }); const target = blocks[0]!; // A concurrent edit bumps the block's version (another user / API write). await api.patch(`/api/v1/blocks/${target.id}`, { data: { content: { text: `${textOf(target)} — concurrently edited` } }, }); // Apply now hits a stale version → per-block 409 → the rebase prompt. await authPage.getByTestId("writing-accept").click(); await expect(authPage.getByTestId("writing-rebase-prompt")).toBeVisible({ timeout: 20_000 }); // The block keeps the concurrent edit (it was NOT overwritten). const all = ((await (await api.get(`/api/v1/pages/${pageId}/blocks`)).json()) as { data: BlockDto[] }).data; const now = all.find((x) => x.id === target.id)!; expect(textOf(now)).toContain("concurrently edited"); }); });