/** * Questionnaires and comment threads, without an agent. * * `DEV_QUESTIONS` and `DEV_COMMENTS` exist so that the whole question and * passage path can be exercised by a room opening rather than by a model * deciding to ask something. They are read from the environment on every room * open, so this file runs against a second server — one with them on would put * a question and a thread into every other suite's room. * * A comment needs prose to mark, and a new room seeds from nothing, so every * test here writes the plan before anybody opens it. */ import { authenticate, content, expect, test } from "./room"; import { storedQuestion } from "../apps/server/src/testing/plan"; /** Long enough to be marked: the injector wants twenty characters. */ const PROSE = "Room state lives on disk as MDX beside the transcript.\n"; const TWO_BLOCKS = `${PROSE}\nA second block remains after the marked passage.\n`; const LONG_PLAN = Array.from({ length: 80 }, (_, index) => `Paragraph ${index + 1}.`).join("\n\n"); const WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; const ANCHORED_DEFINITION = { questions: [{ id: QUESTION, header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ id: OPTION, label: "Canary", description: "" }], }], }; const ANCHORED = `Anchored paragraph. `; const ANCHORED_DIGEST = "sha256:3ccc3e648811df8180799f8b012c6934bcf44a306f5eb8b8c9d2676b0493ccf4"; /** What the injector will quote: the first forty-eight characters. */ const QUOTED = "Room state lives on disk as MDX beside the trans"; function questionnaire(page: import("@playwright/test").Page) { return page.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]'); } function commentButton(page: import("@playwright/test").Page) { return page.getByRole("button", { name: /Comment on “/ }); } async function rewriteFirstBlock(page: import("@playwright/test").Page, value: string) { let block = content(page).locator("p").first(); await block.selectText(); await page.keyboard.type(value); } async function thread(page: import("@playwright/test").Page) { await commentButton(page).click(); return page.getByRole("dialog", { name: "Comment thread" }); } async function secondThread(page: import("@playwright/test").Page) { await content(page).locator("p").nth(1).selectText(); await page.getByRole("button", { name: "Comment on this passage", exact: true }).click(); let draft = page.getByRole("dialog", { name: "New comment" }); await draft.getByPlaceholder("Comment on this passage…").fill("Keep this block as well."); await draft.getByRole("button", { name: "Comment" }).click(); await expect.poll(() => commentButton(page).count()).toBe(2); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0); } test( "prose opens Plan while injected questions preserve its position and selection", async ({ join, seed }) => { await seed(LONG_PLAN); let page = await join("ana"); let plan = page.getByRole("button", { name: "Plan", exact: true }); let decisions = page.getByRole("button", { name: /^Decisions/ }); let scroller = page.locator(".plan-document > div.h-full.min-h-0.overflow-auto"); let selected = content(page).locator("p").nth(8); await selected.selectText(); await scroller.evaluate(element => { element.scrollTop = 160; element.dispatchEvent(new Event("scroll")); }); let selection = await page.evaluate(() => getSelection()?.toString()); let scrollTop = await scroller.evaluate(element => element.scrollTop); await expect(questionnaire(page)).toHaveCount(2); await expect(plan).toHaveAttribute("aria-pressed", "true"); await expect(decisions).toHaveAttribute("aria-pressed", "false"); await expect(page.locator('[data-document-view="decisions"]')).toBeHidden(); await expect(decisions).toContainText("2"); await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(scrollTop); expect(await page.evaluate(() => getSelection()?.toString())).toBe(selection); await decisions.click(); await expect(questionnaire(page)).toHaveCount(2); await expect(questionnaire(page).getByRole("heading", { name: "Storage" })).toBeVisible(); await expect(page.locator('[data-document-view="decisions"] [data-plan-sidecar-thread]')) .toHaveCount(0); }, ); test( "an unseeded room opens Decisions with the injected unanswered questions", async ({ baseURL, page, room }) => { await authenticate(page, "ana", baseURL!); await page.goto(`/channels/${room}`); await expect(page.getByRole("button", { name: /^Decisions/ })) .toHaveAttribute("aria-pressed", "true"); let card = questionnaire(page); await expect(card).toHaveCount(2); await expect(card.getByRole("heading", { name: "Storage" })).toBeVisible(); await expect(card.getByRole("heading", { name: "Scope" })).toBeVisible(); await expect(card.getByRole("tablist")).toHaveCount(0); }, ); test("questions leave the chat pane free of a waiting row", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await expect(questionnaire(page)).toHaveCount(2); await expect(page.locator("#pane-chat")).not.toContainText("questions are waiting"); await expect(page.locator("#pane-chat").getByRole("button", { name: "Answer" })).toHaveCount(0); await page.getByRole("button", { name: /^Decisions/ }).click(); await expect(page.getByRole("button", { name: /^Decisions/ })) .toHaveAttribute("aria-pressed", "true"); await expect(questionnaire(page).first()).toBeInViewport(); }); test("switching views restores the plan scroll position", async ({ join, seed }) => { await seed(LONG_PLAN); let page = await join("ana"); let scroller = page.locator(".plan-document > div.h-full.min-h-0.overflow-auto"); await scroller.evaluate(element => { element.scrollTop = 160; element.dispatchEvent(new Event("scroll")); }); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "Plan", exact: true }).click(); await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(160); }); test("selecting Decisions returns to the first unanswered card after its hidden stack was scrolled", async ({ join, page: browser, seed }) => { await browser.setViewportSize({ width: 1280, height: 360 }); await seed(LONG_PLAN); let page = await join("ana"); let decisions = page.getByRole("button", { name: /^Decisions/ }); let plan = page.getByRole("button", { name: "Plan", exact: true }); let stack = page.locator('[data-document-view="decisions"] .plan-decisions > .overflow-auto'); let first = questionnaire(page).first(); await decisions.click(); await expect(first).toBeFocused(); await stack.evaluate(element => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); let scrolled = await stack.evaluate(element => element.scrollTop); expect(scrolled).toBeGreaterThan(0); await plan.click(); await decisions.click(); await expect(first).toBeFocused(); await expect.poll(() => stack.evaluate(element => element.scrollTop)).toBeLessThan(scrolled); }); test("an unanswered inline decision is also shown in Decisions and can be focused in Plan", async ({ join, seed }) => { await seed(ANCHORED, { revision: 1, questions: [{ id: WIDGET, status: "open", definition: ANCHORED_DEFINITION, anchors: { widget: WIDGET, questions: { [QUESTION]: { anchors: [{ epoch: "stale", position: "", digest: ANCHORED_DIGEST }], pending: false, }, }, }, }], openQuestions: [{ id: WIDGET, definition: ANCHORED_DEFINITION, widget: WIDGET, model: storedQuestion(ANCHORED_DEFINITION), revision: 0, }], }); let page = await join("ana"); let inline = page.locator( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ); await expect(content(page).getByText("Anchored paragraph.", { exact: true })).toBeVisible(); await expect(inline).toHaveCount(1); await expect(inline).toBeVisible(); await expect(inline).toContainText("How should we deploy?"); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ hasText: "How should we deploy?" }); await expect(card).toHaveCount(1); await card.getByRole("button", { name: /How should we deploy.*show in plan/ }).click(); await expect(page.getByRole("button", { name: "Plan", exact: true })) .toHaveAttribute("aria-pressed", "true"); await expect( page.locator( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ), ).toBeFocused(); }); test("saving one decision leaves another unanswered", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); let storage = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Storage" }) }); let scope = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Scope" }) }); await storage.getByRole("radio", { name: /On disk as MDX/ }).check(); await storage.getByRole("button", { name: "Save" }).click(); await expect(scope).toBeVisible(); await expect(scope.getByRole("button", { name: "Save" })).toBeVisible(); await expect(scope).not.toContainText("Answered by"); await page.getByRole("button", { name: "1 resolved" }).click(); let resolved = questionnaire(page).filter({ hasText: "Where should room state live?" }); await expect(resolved).toContainText("On disk as MDX"); await expect(resolved).toContainText("Answered by @ana"); await expect(resolved.getByRole("button", { name: "Save" })).toHaveCount(0); }); test("an unanswered question refuses to submit and says which", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Scope" }) }); await card.getByRole("button", { name: "Save" }).click(); await expect(card.getByRole("alert")).toHaveText( "Every question needs an answer before submitting.", ); await expect(card).not.toContainText("Answered by"); }); test("cancelling asks first", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Scope" }) }); await card.getByRole("button", { name: "Cancel" }).click(); await expect(card).toContainText("Cancel without answering?"); await card.getByRole("button", { name: "Keep it" }).click(); await expect(card.getByRole("button", { name: "Save" })).toBeVisible(); }); test("a marked passage has document chrome with a hover preview", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let button = commentButton(page); await expect(button).toBeVisible(); await button.hover(); let preview = page.getByRole("tooltip"); await expect(preview).toContainText(QUOTED); let previewId = await preview.getAttribute("id"); expect(previewId).not.toBeNull(); await expect(button).toHaveAttribute("aria-describedby", previewId!); await button.focus(); await expect(page.getByRole("tooltip")).toBeVisible(); }); test("a wrapped passage opens its comment without intercepting text selection", async ({ join, page: browser, seed }) => { // The workspace leaves the document at its 400px minimum beside the // conversation rail, which forces the injected quote across two lines. await browser.setViewportSize({ width: 680, height: 600 }); await seed(PROSE); let page = await join("ana"); let hits = page.locator("[data-plan-comment-hit]"); await expect.poll(() => hits.count()).toBeGreaterThan(1); let hit = await hits.first().boundingBox(); expect(hit).not.toBeNull(); let point = { x: hit!.x + hit!.width / 2, y: hit!.y + hit!.height / 2 }; await page.mouse.move(point.x, point.y); let preview = page.getByRole("tooltip"); await expect(preview).toContainText(QUOTED); let pageBox = await page.locator(".plan-document").boundingBox(); let previewBox = await preview.boundingBox(); expect(pageBox).not.toBeNull(); expect(previewBox).not.toBeNull(); expect(previewBox!.x).toBeGreaterThanOrEqual(pageBox!.x); expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(pageBox!.x + pageBox!.width); await page.mouse.click(point.x, point.y); await expect(page.getByRole("dialog", { name: "Comment thread" })).toBeVisible(); await page.keyboard.press("Escape"); // The hit region observes clicks at the document host; it cannot become a // glass pane that eats the native drag Lexical uses to select prose. await page.mouse.move(hit!.x + 3, point.y); await page.mouse.down(); await page.mouse.move(hit!.x + hit!.width - 3, point.y, { steps: 8 }); await page.mouse.up(); expect(await page.evaluate(() => getSelection()?.toString().length ?? 0)).toBeGreaterThan(0); await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0); }); test("leaving a second comment gutter clears its preview", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana"); await secondThread(page); let hit = await page.locator("[data-plan-comment-hit]").first().boundingBox(); expect(hit).not.toBeNull(); await page.mouse.move(hit!.x + hit!.width / 2, hit!.y + hit!.height / 2); await expect(page.getByRole("tooltip")).toBeVisible(); await commentButton(page).nth(1).hover(); await expect(page.getByRole("tooltip")).toBeVisible(); let blank = await content(page).locator("p").nth(1).boundingBox(); expect(blank).not.toBeNull(); await page.mouse.move(blank!.x + blank!.width - 4, blank!.y + blank!.height / 2); await expect(page.getByRole("tooltip")).toHaveCount(0); }); test("clicking a comment button pins its document card and preserves the related wash", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await expect(card).toContainText("@dev"); await expect(card.getByPlaceholder("Reply…")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0); card = await thread(page); await expect.poll(() => washed(page)).toBeGreaterThan(0); await content(page).hover(); await expect.poll(() => washed(page)).toBeGreaterThan(0); }); test("a live text edit preserves its document comment", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana"); await rewriteFirstBlock(page, "The room has a new persistence rule."); await expect(content(page)).toContainText("The room has a new persistence rule."); await expect.poll(() => washed(page)).toBeGreaterThan(0); await expect(commentButton(page)).toBeVisible(); }); test("a removed subject block moves its comment to document orphan chrome", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana"); let first = content(page).locator("p").first(); await first.selectText(); await page.keyboard.press("Backspace"); await page.keyboard.press("Backspace"); let orphaned = page.getByRole("button", { name: "1 orphaned comments" }); await expect(orphaned).toBeVisible(); await orphaned.click(); await expect(page.getByRole("dialog", { name: "Orphaned comments" })).toContainText(QUOTED); }); function washed(page: import("@playwright/test").Page): Promise { return page.evaluate(() => CSS.highlights.get("plan-related")?.size ?? 0); } test("a reply joins the thread, and the quote counts it", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await expect(card.getByText(/repl(y|ies)$/)).toHaveCount(0); await card.getByPlaceholder("Reply…").fill("Still right, but say why."); await card.getByRole("button", { name: "Reply" }).click(); await expect(card).toContainText("Still right, but say why."); await expect(card).toContainText("@ana"); await expect(card.getByText("1 reply")).toBeVisible(); await expect(commentButton(page)).toHaveAccessibleDescription("1 reply waiting."); }); test("accepting asks twice, and says so in the transcript", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await card.getByRole("button", { name: "Accept" }).click(); await expect(card.getByRole("button", { name: "Sure?" })).toBeVisible(); await card.getByRole("button", { name: "Sure?" }).click(); await expect(page.getByText(/accepted a comment on/)).toBeVisible(); await expect( page.getByText("The agent is not running, so the plan has not been revised."), ).toBeVisible(); await expect(page.getByRole("dialog", { name: "Comment thread" })).toHaveCount(0); await expect(commentButton(page)).toHaveCount(0); await expect(content(page).locator("article").filter({ hasText: QUOTED })).toContainText(QUOTED); }); test("a dismissed thread removes its document button", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await card.getByRole("button", { name: "Dismiss" }).click(); await card.getByRole("button", { name: "Sure?" }).click(); await expect(commentButton(page)).toHaveCount(0); });