import { chatInput, expectChatValue } from "./chat-input"; /** * 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, ready, roomPath, test } from "./room"; import { storedQuestion } from "../apps/server/src/testing/plan"; import type { Locator, Page } from "@playwright/test"; /** 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 TALL_PASSAGE = `${ Array.from({ length: 160 }, () => "A selected passage keeps going.").join(" ") }\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 SECOND_QUESTION = "01K0N4W3B7P27CBAEC7A8C8WEB"; const SECOND_OPTION = "01K0N4W3B7P27CBAEC7A8C8WEC"; const ANCHORED_DEFINITION = { questions: [{ id: QUESTION, header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ id: OPTION, label: "Canary", description: "" }], }], }; function editorIsModalBackground(page: Page): Promise { return page.locator('[aria-label="editable markdown"]').evaluate(element => !!element.closest('[inert], [aria-hidden="true"]') ); } const ANCHORED = `Anchored paragraph. `; const ANCHORED_DIGEST = "sha256:3ccc3e648811df8180799f8b012c6934bcf44a306f5eb8b8c9d2676b0493ccf4"; const MULTI_QUESTIONNAIRE = `Multi-step decision. `; /** 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 documentEditor(page: Page) { return page.locator('[data-document-view="plan"]') .getByRole("textbox", { includeHidden: true, name: "editable markdown" }); } function commentButton(page: import("@playwright/test").Page) { return page.getByRole("button", { name: /Comment on “/ }); } test("the desktop document view switches through its segmented control", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let control = page.getByRole("group", { name: "Document view" }); let plan = control.getByRole("button", { name: "Document", exact: true }); let decisions = control.getByRole("button", { name: /^Decisions, 2 unanswered$/ }); await expect(control).toBeVisible(); await expect(plan).toHaveAttribute("aria-pressed", "true"); await expect(decisions).toHaveAttribute("aria-pressed", "false"); await decisions.click(); await expect(page.locator('[data-document-view="decisions"]')).toBeVisible(); await expect(decisions).toHaveAttribute("aria-pressed", "true"); await plan.click(); await expect(page.locator('[data-document-view="plan"]')).toBeVisible(); await expect(plan).toHaveAttribute("aria-pressed", "true"); }); test("question step swaps overlap only for pointer input", async ({ join, seed }) => { await seed(MULTI_QUESTIONNAIRE); let page = await join("ana"); let card = page.locator( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ); let stack = card.locator("[data-question-step-swap]"); let visible = stack.locator(":scope > [data-content-swap-state]:not([hidden])"); let outgoing = stack.locator( ':scope > [data-content-swap-state="outgoing"]:not([hidden])', ); let next = card.getByRole("button", { name: "Next question" }); let previous = card.getByRole("button", { name: "Previous question" }); let count = card.getByText("2/2"); await next.click(); await expect(visible).toHaveCount(2); await expect(stack.locator(":scope > [data-content-swap-state]:not([hidden]):not([inert])")) .toHaveCount(1); await expect(outgoing).toHaveCount(1); await expect(outgoing).toHaveAttribute("aria-hidden", "true"); await expect(outgoing).toHaveAttribute("inert", ""); let accessibility = await card.evaluate(root => { let ids = [...root.querySelectorAll("[id]")].map(element => element.id); let counts = new Map(); for (let id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); let duplicateIds = [...counts].filter(([, count]) => count > 1).map(([id]) => id).sort(); let invalidReferences = [...root.querySelectorAll( "[aria-controls], [aria-labelledby]", )].flatMap(element => ["aria-controls", "aria-labelledby"].flatMap(attribute => (element.getAttribute(attribute)?.split(/\s+/) ?? []).flatMap(id => document.querySelectorAll(`#${CSS.escape(id)}`).length === 1 ? [] : [`${attribute}:${id}`] ) ) ).sort(); return { duplicateIds, invalidReferences }; }); expect(accessibility).toEqual({ duplicateIds: [], invalidReferences: [] }); await expect(visible).toHaveCount(1); await expect(previous).toBeFocused(); await previous.click(); await expect(visible).toHaveCount(1); await expect(outgoing).toHaveCount(0); await expect(card.getByText("1/2")).toBeVisible(); await expect(card.getByRole("heading", { name: "How should we deploy?" })).toBeVisible(); await page.emulateMedia({ reducedMotion: "reduce" }); await stack.evaluate(root => { let counts: number[] = []; let recordActiveCount = () => { counts.push( root.querySelectorAll(":scope > [data-content-swap-state]:not([hidden]):not([inert])") .length, ); }; let observer = new MutationObserver(recordActiveCount); observer.observe(root, { attributes: true, childList: true, subtree: true }); Reflect.set(window, "__questionStepActiveCounts", counts); Reflect.set(window, "__questionStepObserver", observer); recordActiveCount(); }); await next.click(); await expect(visible).toHaveCount(1); let activeCounts = await page.evaluate(() => { let observer = Reflect.get(window, "__questionStepObserver") as MutationObserver; observer.disconnect(); return Reflect.get(window, "__questionStepActiveCounts") as number[]; }); expect(activeCounts).not.toContain(0); await expect(count).toBeVisible(); await expect(card.getByRole("heading", { name: "What belongs in the first cut?" })).toBeVisible(); }); 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|Post 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("a wide desktop comment sits in the gutter beside its passage", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.setViewportSize({ width: 1_440, height: 900 }); await page.getByRole("button", { name: "Hide chat" }).click(); await page.getByRole("button", { name: "Hide sidebar" }).click(); let card = await thread(page); let paragraph = content(page).locator("p").first(); let document = page.locator(".plan-document"); await expect(card).toHaveAttribute("data-side", "right"); await expect.poll(async () => { let cardBox = await card.boundingBox(); let paragraphBox = await paragraph.boundingBox(); let documentBox = await document.boundingBox(); if (!cardBox || !paragraphBox || !documentBox) return false; return cardBox.x >= paragraphBox.x + paragraphBox.width && cardBox.x + cardBox.width <= documentBox.x + documentBox.width && Math.abs(cardBox.y - paragraphBox.y) <= 4; }).toBe(true); }); test("a narrow split document keeps the desktop comment popover", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.setViewportSize({ width: 1_024, height: 800 }); let card = await thread(page); let paragraph = content(page).locator("p").first(); await expect(card).not.toHaveAttribute("aria-modal", "true"); await expect(page.getByRole("button", { name: "Resize comment sheet" })).toHaveCount(0); // No gutter is wide enough here, so the card sits below the passage inside its column. await expect.poll(async () => { let cardBox = await card.boundingBox(); let paragraphBox = await paragraph.boundingBox(); if (!cardBox || !paragraphBox) return false; return cardBox.width <= 320 && cardBox.y >= paragraphBox.y + paragraphBox.height && cardBox.x >= paragraphBox.x - 1 && cardBox.x + cardBox.width <= paragraphBox.x + paragraphBox.width + 1; }).toBe(true); }); 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: "Document", exact: true }); let decisions = page.getByRole("button", { name: /^Decisions/ }); let scroller = page.locator("[data-plan-scroll]"); let selected = content(page).getByText("Paragraph 9.", { exact: true }); 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: "Where should room state live?" }), ).toBeVisible(); await expect(page.locator('[data-document-view="decisions"] [data-plan-sidecar-thread]')) .toHaveCount(0); }, ); test( "an unseeded document keeps linked questions inline and reveals them in Decisions", async ({ baseURL, page, room }) => { await authenticate(page, "ana", baseURL!); await page.goto(roomPath(room)); await ready(page); let decisions = page.getByRole("button", { name: /^Decisions/ }); await expect(decisions).toHaveAttribute("aria-pressed", "false"); await expect(page.getByRole("button", { name: "Document", exact: true })) .toHaveAttribute("aria-pressed", "true"); await expect(content(page).locator("article[data-plan-sidecar-questionnaire]")) .toHaveCount(2); await decisions.click(); await expect(decisions).toHaveAttribute("aria-pressed", "true"); let card = questionnaire(page); await expect(card).toHaveCount(2); await expect(card.getByRole("heading", { name: "Where should room state live?" })) .toBeVisible(); await expect(card.getByRole("heading", { name: "Which of these belong in the first cut?" })) .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.getByRole("complementary", { includeHidden: true, name: "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("[data-plan-scroll]"); await scroller.evaluate(element => { element.scrollTop = 160; element.dispatchEvent(new Event("scroll")); }); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "Document", exact: true }).click(); await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(160); }); async function expectCompactDestinationStatePreserved(page: Page): Promise { let nav = page.getByRole("navigation", { name: "Workspace view" }); let planScroller = page.locator("[data-plan-scroll]"); let draft = chatInput(page); await planScroller.evaluate(element => { element.scrollTop = 160; element.dispatchEvent(new Event("scroll")); }); await draft.evaluate(textarea => { let plan = document.querySelector("[data-plan-scroll]")!; let tracker = { plan, textarea, removed: false, observer: undefined as MutationObserver | undefined, }; tracker.observer = new MutationObserver(records => { for (let record of records) { for (let node of record.removedNodes) { if (node === plan || node === textarea) tracker.removed = true; if (node instanceof Element && (node.contains(plan) || node.contains(textarea))) { tracker.removed = true; } } } }); tracker.observer.observe(document.body, { childList: true, subtree: true }); (window as typeof window & { __workspaceTracker?: typeof tracker }).__workspaceTracker = tracker; }); await nav.getByRole("button", { name: /Chat/ }).click(); await draft.fill("unfinished compact thought"); await nav.getByRole("button", { name: /^Decisions/ }).click(); let decisionScroller = page.locator("[data-plan-decisions-scroll]"); await decisionScroller.evaluate(element => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); let decisionScroll = await decisionScroller.evaluate(element => element.scrollTop); await nav.getByRole("button", { name: "Document" }).click(); await expectChatValue(draft, "unfinished compact thought"); await expect.poll(() => planScroller.evaluate(element => element.scrollTop)).toBe(160); await nav.getByRole("button", { name: /^Decisions/ }).click(); await expect.poll(() => decisionScroller.evaluate(element => element.scrollTop)).toBe( decisionScroll, ); await nav.getByRole("button", { name: "Document" }).click(); expect(decisionScroll).toBeGreaterThan(0); let identity = await draft.evaluate(textarea => { let tracker = (window as typeof window & { __workspaceTracker?: { plan: Element; textarea: Element; removed: boolean; observer: MutationObserver; }; }).__workspaceTracker!; tracker.observer.disconnect(); return { plan: tracker.plan === document.querySelector("[data-plan-scroll]"), textarea: tracker.textarea === textarea, removed: tracker.removed, }; }); expect(identity).toEqual({ plan: true, textarea: true, removed: false }); } for ( let viewport of [ { height: 568, width: 320 }, { height: 844, width: 390 }, ] ) { test(`${viewport.width}×${viewport.height} compact destinations preserve draft, scroll, and mounted identity`, async ({ join, seed }) => { await seed(LONG_PLAN); let page = await join("ana", { hasTouch: true, viewport }); await expectCompactDestinationStatePreserved(page); }); } test("compact Chat returns to the document after a collaborator adds prose", async ({ baseURL, browser, page: ana, room }) => { await ana.setViewportSize({ width: 390, height: 844 }); await authenticate(ana, "ana", baseURL!); await ana.goto(roomPath(room)); await expect(ana.locator('[aria-label="editable markdown"]')).toHaveAttribute( "contenteditable", "true", { timeout: 20_000 }, ); let nav = ana.getByRole("navigation", { name: "Workspace view" }); await expect(nav.getByRole("button", { name: "Document", exact: true })) .toHaveAttribute("aria-current", "page"); await expect(nav.getByRole("button", { name: "Decisions, 2 unanswered", exact: true })) .toBeVisible(); await nav.getByRole("button", { name: /Chat/ }).click(); let boContext = await browser.newContext({ baseURL, viewport: { width: 1280, height: 800 } }); try { let bo = await boContext.newPage(); await authenticate(bo, "bo", baseURL!); await bo.goto(roomPath(room)); await expect(bo.locator('[aria-label="editable markdown"]')).toHaveAttribute( "contenteditable", "true", { timeout: 20_000 }, ); await bo.getByRole("group", { name: "Document view" }) .getByRole("button", { name: "Document", exact: true }) .click(); await expect(documentEditor(bo)).toBeVisible(); let paragraph = documentEditor(bo).locator(":scope > p").first(); await expect(paragraph).toBeEmpty(); await documentEditor(bo).press("Home"); await bo.keyboard.type("Collaborative prose arrived."); await expect(documentEditor(ana)).toContainText( "Collaborative prose arrived.", ); } finally { await boContext.close(); } await ana.getByRole("heading", { name: "Chat", exact: true }).press("Escape"); await expect(ana.locator('[data-document-view="plan"]')).toBeVisible(); await expect(nav.getByRole("button", { name: "Decisions, 2 unanswered", exact: true })) .toBeVisible(); await expect(nav.getByRole("button", { name: "Document", exact: true })).toHaveAttribute( "aria-current", "page", ); }); test("an edit received while compact Plan is hidden appears when it returns", async ({ join, seed }) => { await seed(TWO_BLOCKS); let ana = await join("ana", { viewport: { width: 390, height: 844 } }); let bo = await join("bo", { viewport: { width: 1280, height: 800 } }); let nav = ana.getByRole("navigation", { name: "Workspace view" }); await nav.getByRole("button", { name: /Chat/ }).click(); await rewriteFirstBlock(bo, "The hidden plan still receives collaborative edits."); await nav.getByRole("button", { name: "Document" }).click(); await expect(content(ana)).toContainText("The hidden plan still receives collaborative edits."); }); for ( let example of [ { compact: true, name: "compact", width: 390 }, { compact: false, name: "desktop", width: 1280 }, ] ) { test(`${example.name} Decisions navigation follows the active presentation`, async ({ join, page: browser, seed }) => { await browser.setViewportSize({ width: example.width, height: 360 }); await seed(LONG_PLAN); let page = await join("ana"); let decisions = page.getByRole("button", { name: /^Decisions/ }); let plan = page.getByRole("button", { name: "Document", exact: true }); let stack = page.locator("[data-plan-decisions-scroll]"); let first = questionnaire(page).first(); await decisions.click(); if (example.compact) { await expect(page.getByRole("heading", { name: "Decisions", exact: true })).toBeFocused(); } else 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(); if (example.compact) { await expect.poll(() => stack.evaluate(element => element.scrollTop)).toBe(scrolled); await expect(page.getByRole("heading", { name: "Decisions", exact: true })).toBeFocused(); } else { await expect.poll(() => stack.evaluate(element => element.scrollTop)).toBeLessThan(scrolled); await expect(first).toBeFocused(); } }); } 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: /show in document/i }).click(); await expect(page.getByRole("button", { name: "Document", exact: true })) .toHaveAttribute("aria-pressed", "true"); await expect( page.locator( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ), ).toBeFocused(); }); test("decision cards save independently with progressive custom answers", 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: "Where should room state live?" }), }); let scope = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Which of these belong in the first cut?" }), }); let saveStorage = storage.getByRole("button", { name: "Save", exact: true }); await expect(storage.getByRole("textbox", { name: "New option" })).toHaveCount(0); await expect(scope.getByRole("textbox", { name: "New option" })).toHaveCount(0); await storage.getByRole("radio", { name: /On disk as MDX/ }).check(); await saveStorage.click(); await expect(scope).toBeVisible(); await expect(scope).toBeFocused(); await expect(scope.getByRole("button", { name: "Save", exact: true })).toBeVisible(); await expect(scope).not.toContainText("Answered by"); await expect(scope.getByRole("checkbox", { name: "Anchors" })).not.toBeChecked(); let history = page.getByRole("button", { name: "1 resolved" }); let historyIcon = history.locator("[data-feedback-icon]"); await history.hover(); await page.evaluate(() => { let record = { starts: 0 }; Reflect.set(window, "__feedbackIconTransitions", record); document.addEventListener("transitionstart", event => { if ( event.target instanceof Element && event.target.matches('[data-motion-feedback="icon"]') ) record.starts++; }, true); }); await history.click(); await expect(historyIcon).toHaveAttribute("data-feedback-icon", "open"); expect( await historyIcon.evaluate(element => getComputedStyle(element).transitionDuration.split(",").some(duration => parseFloat(duration) > 0 ) ), ).toBe(true); await expect.poll(() => page.evaluate(() => { let record = Reflect.get(window, "__feedbackIconTransitions") as { starts: number }; return record.starts > 0; }) ).toBe(true); let historyContent = page.locator('[data-motion-disclosure="decision-history"]'); await expect(historyContent).toBeVisible(); let historyId = await historyContent.getAttribute("id"); expect(historyId).toBeTruthy(); await expect(history).toHaveAttribute("aria-controls", historyId!); await expect(historyContent).not.toHaveClass(/is-closing/); 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", exact: true })).toHaveCount(0); let iconStarts = await page.evaluate(() => { let record = Reflect.get(window, "__feedbackIconTransitions") as { starts: number }; return record.starts; }); await history.focus(); await page.keyboard.press("Space"); await expect(historyIcon).toHaveAttribute("data-feedback-icon", "closed"); await historyIcon.evaluate(() => new Promise(resolve => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }) ); await expect(historyIcon).toHaveCSS("transition-duration", "0s"); expect( await page.evaluate(() => { let record = Reflect.get(window, "__feedbackIconTransitions") as { starts: number }; return record.starts; }), ).toBe(iconStarts); await historyIcon.hover(); await historyIcon.evaluate(() => new Promise(resolve => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }) ); expect( await page.evaluate(() => { let record = Reflect.get(window, "__feedbackIconTransitions") as { starts: number }; return record.starts; }), ).toBe(iconStarts); await expect(history).not.toHaveAttribute("aria-controls"); await expect(historyContent).toHaveCount(0); let addRow = scope.getByRole("button", { name: "Add an option" }); await addRow.focus(); await page.keyboard.press("Enter"); let field = scope.getByRole("textbox", { name: "New option" }); await expect(field).toBeFocused(); let saveScope = scope.getByRole("button", { name: "Save", exact: true }); await expect(saveScope).toBeDisabled(); await field.press("Escape"); await expect(field).toHaveCount(0); await expect(addRow).toBeFocused(); await addRow.click(); field = scope.getByRole("textbox", { name: "New option" }); await field.fill("Only collaborative anchors"); await field.press("Enter"); // It joins everyone's list as the next lettered row and is not chosen for anyone. let added = scope.getByRole("checkbox", { name: "Only collaborative anchors" }); await expect(added).toBeVisible(); await expect(added).not.toBeChecked(); await expect(scope.locator("label", { hasText: "Only collaborative anchors" })).toContainText( "D", ); await expect(addRow).toBeFocused(); await expect(saveScope).toBeDisabled(); await added.check(); await scope.getByRole("checkbox", { name: /^Anchors/ }).check(); await saveScope.click(); let allResolved = page.getByRole("button", { name: "2 resolved" }); await expect(allResolved).toBeVisible(); await allResolved.click(); let resolvedScope = questionnaire(page).filter({ hasText: "Which of these belong in the first cut?", }); await expect(resolvedScope).toContainText("Only collaborative anchors"); await expect(resolvedScope).toContainText("Answered by @ana"); }); test("Save and Next wait for a chosen answer", 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: "Which of these belong in the first cut?" }), }); let save = card.getByRole("button", { name: "Save", exact: true }); await expect(save).toBeDisabled(); await card.getByRole("checkbox", { name: "Anchors" }).check(); await expect(save).toBeEnabled(); await card.getByRole("checkbox", { name: "Anchors" }).uncheck(); await expect(save).toBeDisabled(); await expect(card).not.toContainText("Answered by"); }); test("a rejected save is announced as an alert with motion feedback", async ({ join, page, seed }) => { await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); route.onMessage(message => { let frame = typeof message === "string" ? JSON.parse(message) as Record : {}; // The server stays authoritative for everything but this one refusal. if (frame.kind !== "question:submit") return server.send(message); route.send(JSON.stringify({ kind: "question:submit", rid: frame.rid, id: frame.id, ok: false, reason: "invalid", message: "Scope could not be saved.", })); }); server.onMessage(message => route.send(message)); }); await seed(PROSE); await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Which of these belong in the first cut?" }), }); await card.getByRole("checkbox", { name: "Anchors" }).check(); let save = card.getByRole("button", { name: "Save", exact: true }); await save.hover(); await page.evaluate(() => { let record = { starts: 0 }; Reflect.set(window, "__feedbackAlertTransitions", record); document.addEventListener("transitionstart", event => { if ( event.target instanceof Element && event.target.matches('[data-motion-feedback="alert"]') ) record.starts++; }, true); }); await save.click(); let alert = card.getByRole("alert"); await expect(alert).toBeVisible(); await expect(alert).toContainText("Couldn’t save"); await expect(alert).toContainText("Scope could not be saved."); expect( await alert.evaluate(element => getComputedStyle(element).transitionDuration.split(",").some(duration => parseFloat(duration) > 0 ) ), ).toBe(true); await expect.poll(() => page.evaluate(() => { let record = Reflect.get(window, "__feedbackAlertTransitions") as { starts: number }; return record.starts > 0; }) ).toBe(true); await expect(card.getByRole("button", { name: "Try again" })).toBeEnabled(); await expect(card).not.toContainText("Answered by"); }); test("people on a decision are faces with verbatim handle tooltips", async ({ join, seed }) => { await seed(PROSE); let title = "Where should room state live?"; let open = async (handle: string) => { let page = await join(handle); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: title }) }); await expect(card).toBeVisible(); return { page, card }; }; let ana = await open("ana"); let people = ana.card.getByRole("group", { name: /^In this decision/ }); await expect(people).toHaveCount(0); // Four real peers work on the same question, one after another: three faces and a "+1". let peers = []; for (let handle of ["Bo", "cy", "Di", "ed"]) { let peer = await open(handle); await peer.card.getByRole("radio").first().focus(); peers.push(peer); await expect( ana.card.getByRole("group", { name: new RegExp(`^In this decision:.*\\b${handle}\\b`) }), ) .toBeVisible(); } await expect(people.getByRole("img")).toHaveCount(3); let more = people.getByText("+1"); await expect(more).toBeVisible(); await expect(ana.card).not.toContainText("@Bo"); // The tooltip hides on any scroll, and scroll events arrive a frame after the // scroll itself. Settle the page first, then arrive with real pointer movement. let tooltip = ana.page.locator("[data-icon-tooltip]"); let settleThenHover = async (target: Locator) => { await target.scrollIntoViewIfNeeded(); await ana.page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done()))) ); let box = (await target.boundingBox())!; await ana.page.mouse.move(0, 0); await ana.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 }); }; await settleThenHover(people.getByRole("img").first()); await expect(tooltip).toHaveAttribute("data-visible", ""); await expect(tooltip).toHaveText("Bo"); await settleThenHover(more); await expect(tooltip).toHaveText("ed"); expect( await people.getByRole("img").evaluateAll(nodes => nodes.map(node => node.getAttribute("title")) ), ).toEqual([null, null, null]); // Leaving the question clears the face; choosing is the same as being there. await peers[3]!.card.getByRole("radio").first().blur(); await expect(more).toHaveCount(0); await peers[0]!.page.close(); await expect(people.getByRole("img")).toHaveCount(2); await expect(people).toHaveAttribute("aria-label", "In this decision: cy, Di"); }); test("an option one member adds is shared, durable, and choosable by another", async ({ join, seed }) => { await seed(PROSE); let ana = await join("ana"); let bo = await join("bo"); let storage = (page: Page) => questionnaire(page).filter({ has: page.getByRole("heading", { name: "Where should room state live?" }), }); for (let page of [ana, bo]) await page.getByRole("button", { name: /^Decisions/ }).click(); await expect(storage(bo).getByRole("radio")).toHaveCount(2); await storage(ana).getByRole("button", { name: "Add an option" }).click(); let field = storage(ana).getByRole("textbox", { name: "New option" }); await expect(field).toBeFocused(); await field.fill("In PostgreSQL"); await field.press("Enter"); // Everyone sees it as the next lettered row, chosen by nobody. for (let page of [ana, bo]) { let row = storage(page).getByRole("radio", { name: "In PostgreSQL" }); await expect(row).toBeVisible(); await expect(row).not.toBeChecked(); await expect(storage(page).locator("label", { hasText: "In PostgreSQL" })).toContainText("C"); } await expect(storage(ana).getByRole("textbox", { name: "New option" })).toHaveCount(0); // The plan carries it too, which is what the Planner reads. await ana.getByRole("button", { name: "Document", exact: true }).click(); await expect(ana.locator(`[data-document-view="plan"]`).getByText("In PostgreSQL")) .toBeVisible(); await ana.getByRole("button", { name: /^Decisions/ }).click(); // It survives a reload, and the second member can choose and save it. await bo.reload(); await bo.getByRole("button", { name: /^Decisions/ }).click(); let chosen = storage(bo).getByRole("radio", { name: "In PostgreSQL" }); await expect(chosen).toBeVisible(); await chosen.check(); await storage(bo).getByRole("button", { name: "Save", exact: true }).click(); for (let page of [ana, bo]) { await page.getByRole("button", { name: "1 resolved" }).click(); await expect(questionnaire(page).filter({ hasText: "Where should room state live?" })) .toContainText("In PostgreSQL"); } }); test("adding a duplicate option is flagged inline and sends no request", async ({ join, page, seed }) => { await seed(PROSE); let sent = 0; await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); route.onMessage(message => { if (typeof message === "string" && message.includes('"question:option"')) sent++; server.send(message); }); server.onMessage(message => route.send(message)); }); let ana = await join("ana"); await ana.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(ana).filter({ has: ana.getByRole("heading", { name: "Where should room state live?" }), }); await card.getByRole("button", { name: "Add an option" }).click(); let field = card.getByRole("textbox", { name: "New option" }); await field.fill("in sqlite"); await field.press("Enter"); await expect(card.getByText("Already an option: In SQLite")).toBeVisible(); await expect(card.getByRole("alert")).toHaveCount(0); await expect(field).toHaveValue("in sqlite"); await expect(card.getByRole("radio")).toHaveCount(2); expect(sent).toBe(0); }); test("a duplicate the client could not see is still refused by the server", async ({ join, page, seed }) => { await seed(PROSE); // Ana never hears about added options, so only the server can catch the repeat. await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); route.onMessage(message => server.send(message)); server.onMessage(message => { if (typeof message === "string" && message.includes('"question:option-added"')) return; route.send(message); }); }); let ana = await join("ana"); let bo = await join("bo"); let storage = (p: Page) => questionnaire(p).filter({ has: p.getByRole("heading", { name: "Where should room state live?" }), }); for (let p of [ana, bo]) await p.getByRole("button", { name: /^Decisions/ }).click(); await storage(bo).getByRole("button", { name: "Add an option" }).click(); let boField = storage(bo).getByRole("textbox", { name: "New option" }); await boField.fill("In PostgreSQL"); await boField.press("Enter"); await expect(storage(bo).getByRole("radio", { name: "In PostgreSQL" })).toBeVisible(); await storage(ana).getByRole("button", { name: "Add an option" }).click(); let field = storage(ana).getByRole("textbox", { name: "New option" }); await field.fill("in postgresql"); await field.press("Enter"); let alert = storage(ana).getByRole("alert"); await expect(alert).toContainText("Couldn’t add option"); await expect(alert).toContainText("already exists"); await expect(field).toHaveValue("in postgresql"); }); test("discarding 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: "Which of these belong in the first cut?" }), }); await card.getByRole("button", { name: "Discard", exact: true }).click(); await expect(card.getByText("Discard this decision?")).toBeVisible(); let keep = card.getByRole("button", { name: "Keep it" }); await expect(keep).toBeVisible(); await keep.click(); await expect(card.getByRole("button", { name: "Save", exact: true })).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("Is this still right?"); await expect(preview.locator("blockquote")).toHaveCount(0); 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(); await page.keyboard.press("Escape"); await expect(page.getByRole("tooltip")).toHaveCount(0); }); test("a compact new-comment sheet blocks navigation and restores editor focus", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); let editor = content(page); let plan = page.getByRole("button", { name: "Document", exact: true, includeHidden: true, }); let decisions = page.getByRole("button", { name: /^Decisions/, includeHidden: true }); let openDraft = async () => { await editor.locator("p").nth(1).selectText(); await page.getByRole("button", { name: "Comment on this passage", exact: true }).click(); let sheet = page.getByRole("dialog", { name: "New comment" }); await expect(sheet).toHaveAttribute("aria-modal", "true"); await expect(sheet.getByPlaceholder("Comment on this passage…")).toBeFocused(); return sheet; }; let sheet = await openDraft(); let destination = await decisions.boundingBox(); expect(destination).not.toBeNull(); let destinationPoint = { x: destination!.x + destination!.width / 2, y: destination!.y + destination!.height / 2, }; await expect.poll(() => page.evaluate( ({ x, y }) => !!document.elementFromPoint(x, y)?.closest("[data-plan-comment-sheet-backdrop]"), destinationPoint, ) ).toBe(true); await page.mouse.click(destinationPoint.x, destinationPoint.y); await expect(sheet).toHaveCount(0); await expect(plan).toHaveAttribute("aria-pressed", "true"); await expect(editor).toBeFocused(); sheet = await openDraft(); await page.locator("[data-plan-comment-sheet-backdrop]").click({ position: { x: 10, y: 10 }, }); await expect(sheet).toHaveCount(0); await expect(editor).toBeFocused(); sheet = await openDraft(); await page.keyboard.press("Escape"); await expect(sheet).toHaveCount(0); await expect(editor).toBeFocused(); }); test("submitting a compact new comment restores editor focus", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); let editor = content(page); await editor.locator("p").nth(1).selectText(); await page.getByRole("button", { name: "Comment on this passage", exact: true }).click(); let sheet = page.getByRole("dialog", { name: "New comment" }); await sheet.getByPlaceholder("Comment on this passage…").fill("Keep this block as well."); await sheet.getByRole("button", { name: "Post comment", exact: true }).click(); await expect(sheet).toHaveCount(0); await expect(editor).toBeFocused(); }); for ( let resolution of [ { action: "Apply feedback", confirmation: "Apply feedback", verb: "applying" }, { action: "Dismiss", confirmation: "Dismiss", verb: "dismissing" }, ] as const ) { test(`${resolution.verb} a compact comment restores editor focus`, async ({ join, seed }) => { await seed(PROSE); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); let editor = content(page); let sheet = await thread(page); await sheet.getByRole("button", { name: resolution.action }).click(); await sheet.getByRole("button", { name: resolution.confirmation }).click(); await expect(sheet).toHaveCount(0); await expect(editor).toBeFocused(); }); } test("an unavailable comment position keeps its compact sheet mounted until geometry recovers", async ({ join, seed }) => { await seed(TALL_PASSAGE); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 300 }, }); let editor = content(page); await expect(commentButton(page)).toHaveCount(1); await editor.locator("p").first().evaluate(paragraph => { let phrase = "A selected passage keeps going."; let text = paragraph.querySelector("[data-lexical-text]")?.firstChild; if (!(text instanceof Text) || !text.data.startsWith(phrase)) { throw new Error("expected the tall passage fixture"); } let range = document.createRange(); range.setStart(text, 0); range.setEnd(text, phrase.length); let selection = getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }); 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 the whole passage."); await draft.getByRole("button", { name: "Post comment", exact: true }).click(); // Both threads mark the one block, so they share its marker. // A modal sheet hides the document from the accessibility tree, so find the marker by its data. let marker = page.locator("[data-plan-comment-button][data-plan-comment-count='2']"); await expect(marker).toHaveAttribute("aria-label", /^2 comments on “A selected passage/); await expect(page.locator("[data-plan-comment-button]")).toHaveCount(1); await marker.click(); await page.getByRole("dialog", { name: "Comments" }) .locator("[data-plan-comment-group-item]") .filter({ hasText: "Keep the whole passage." }) .click(); let sheet = page.getByRole("dialog", { name: "Comment thread" }); await expect(sheet).toContainText("Keep the whole passage."); let back = sheet.getByRole("button", { name: "All 2 comments" }); await expect(back).toBeFocused(); // The open sheet and its focus must not be unmounted while geometry changes, // even in a host too narrow to hold a marker beside the prose. await page.setViewportSize({ width: 32, height: 300 }); await expect(marker).toBeAttached(); await expect(sheet).toBeVisible(); await expect(back).toBeFocused(); let host = page.locator(".plan-document"); await page.setViewportSize({ width: 430, height: 3_200 }); await expect(sheet).toBeVisible(); await expect(back).toBeFocused(); await expect(marker).toBeAttached(); await expect.poll(async () => { let markerBox = await marker.boundingBox(); let documentBox = await host.boundingBox(); return !!markerBox && !!documentBox && markerBox.y < documentBox.y + documentBox.height && markerBox.y + markerBox.height > documentBox.y; }).toBe(true); }); test("a touch comment opens as a modal sheet and restores its marker", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); await secondThread(page); let marker = commentButton(page).first(); let markerBox = await marker.boundingBox(); expect(markerBox).not.toBeNull(); expect(markerBox!.width).toBeGreaterThanOrEqual(44); // As tall as the first line, never more than 44px, so the next line keeps its taps. expect(markerBox!.height).toBeGreaterThanOrEqual(24); expect(markerBox!.height).toBeLessThanOrEqual(44); let markerBoxes = await commentButton(page).evaluateAll(buttons => buttons.map(button => { let box = button.getBoundingClientRect(); return { bottom: box.bottom, left: box.left, right: box.right, top: box.top }; }) ); expect( markerBoxes.every((box, index) => markerBoxes.slice(index + 1).every(other => box.right <= other.left || other.right <= box.left || box.bottom <= other.top || other.bottom <= box.top ) ), ).toBe(true); let passages = await page.locator("[data-plan-comment-hit]").evaluateAll(hits => hits.map(hit => { let box = hit.getBoundingClientRect(); return { bottom: box.bottom, left: box.left, right: box.right, top: box.top }; }) ); // The visible chip never covers prose; only its invisible touch area may reach the line end. let chip = await marker.locator(".plan-comment-chip").boundingBox(); expect(chip).not.toBeNull(); expect(passages.every(passage => chip!.x >= passage.right || chip!.x + chip!.width <= passage.left || chip!.y >= passage.bottom || chip!.y + chip!.height <= passage.top )).toBe(true); await marker.tap(); let sheet = page.getByRole("dialog", { name: "Comment thread" }); await expect(sheet).toBeVisible(); await expect(sheet.getByRole("button", { name: "Resize comment sheet" })).toBeFocused(); await expect.poll(() => editorIsModalBackground(page)).toBe(true); await page.keyboard.press("Escape"); await expect(sheet).toHaveCount(0); await expect(marker).toBeFocused(); await expect.poll(() => editorIsModalBackground(page)).toBe(false); }); test("a wrapped passage opens its comment without intercepting text selection", async ({ join, page: browser, seed }) => { // The compact document forces the injected quote across two lines. await browser.setViewportSize({ width: 390, 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("Is this still right?"); await expect(preview.locator("blockquote")).toHaveCount(0); let pageBox = await page.locator("[data-plan-scroll]").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"); await page.setViewportSize({ width: 1_440, height: 900 }); await page.getByRole("button", { name: "Hide chat" }).click(); await page.getByRole("button", { name: "Hide sidebar" }).click(); 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); }); test("a compact orphan sheet owns focus and restores its opener", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); let first = content(page).locator("p").first(); await first.selectText(); await page.keyboard.press("Backspace"); await page.keyboard.press("Backspace"); let opener = page.getByRole("button", { name: "1 orphaned comments" }); await opener.tap(); let sheet = page.getByRole("dialog", { name: "Orphaned comments" }); await expect(sheet).toHaveAttribute("aria-modal", "true"); await expect(sheet.getByRole("button", { name: "Resize comment sheet" })).toBeFocused(); await expect.poll(() => editorIsModalBackground(page)).toBe(true); await page.keyboard.press("Shift+Tab"); await expect(sheet.getByRole("button", { name: "Apply feedback" })).toBeFocused(); await page.keyboard.press("Escape"); await expect(sheet).toHaveCount(0); await expect(opener).toBeFocused(); await expect.poll(() => editorIsModalBackground(page)).toBe(false); }); test("a remotely orphaned compact comment closes its sheet and restores editor focus", async ({ join, seed }) => { await seed(TWO_BLOCKS); let page = await join("ana", { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 }, }); let editor = content(page); let sheet = await thread(page); await expect(sheet.getByRole("button", { name: "Resize comment sheet" })).toBeFocused(); let collaborator = await join("bo"); let subject = content(collaborator).locator("p").first(); await subject.selectText(); await collaborator.keyboard.press("Backspace"); await collaborator.keyboard.press("Backspace"); await expect(sheet).toHaveCount(0); await expect(editor).toBeFocused(); await expect(editor).not.toHaveAttribute("inert", ""); await expect(page.getByRole("button", { name: "1 orphaned comments" })).toBeVisible(); }); function washed(page: import("@playwright/test").Page): Promise { return page.evaluate(() => CSS.highlights.get("plan-related")?.size ?? 0); } test("the reply composer grows and keeps one inset send action", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); let composer = card.getByPlaceholder("Reply…"); let initial = await composer.evaluate(element => ({ height: element.clientHeight, resize: getComputedStyle(element).resize, })); expect(initial.resize).toBe("none"); await composer.fill("First line\nSecond line\nThird line\nFourth line\nFifth line"); await expect.poll(() => composer.evaluate(element => element.clientHeight)).toBeGreaterThan( initial.height, ); let send = card.getByRole("button", { name: "Send reply" }); await expect(send).toBeVisible(); let geometry = await Promise.all([composer.boundingBox(), send.boundingBox()]).then( ([field, button]) => field && button ? { bottom: field.y + field.height - button.y - button.height, contained: button.x >= field.x && button.y >= field.y && button.x + button.width <= field.x + field.width && button.y + button.height <= field.y + field.height, right: field.x + field.width - button.x - button.width, } : undefined, ); expect(geometry?.contained).toBe(true); expect(Math.abs((geometry?.right ?? 0) - (geometry?.bottom ?? 0))).toBeLessThan(0.5); let apply = card.getByRole("button", { name: "Apply feedback" }); let dismiss = card.getByRole("button", { name: "Dismiss" }); let actionStyles = await Promise.all([apply, dismiss].map(button => button.evaluate(element => { let style = getComputedStyle(element); return { background: style.backgroundColor, paddingLeft: style.paddingLeft, paddingRight: style.paddingRight, }; }) )); expect(actionStyles[0].background).not.toBe(actionStyles[1].background); expect(actionStyles[0].paddingLeft).toBe("12px"); expect(actionStyles[0].paddingRight).toBe("12px"); }); test("a reply joins the thread without a duplicate reply count", 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: "Send reply" }).click(); await expect(card).toContainText("Still right, but say why."); await expect(card).toContainText("@ana"); await expect(card.getByText("1 reply")).toHaveCount(0); await expect(commentButton(page)).toHaveAccessibleDescription("1 reply waiting."); }); test("a comment confirmation remains until the reader chooses", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await card.getByRole("button", { name: "Dismiss" }).click(); await expect(card).toContainText("This closes the thread without changing the document."); await page.waitForTimeout(4_100); await expect(card.getByRole("button", { name: "Dismiss" })).toBeVisible(); await expect(card.getByRole("button", { name: "Cancel" })).toBeVisible(); }); test("accepting explains its consequence before changing the document", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); let card = await thread(page); await card.getByRole("button", { name: "Apply feedback" }).click(); await expect(card).toContainText("Planner will use this feedback to update the document."); await card.getByRole("button", { name: "Apply feedback" }).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 expect(card).toContainText("This closes the thread without changing the document."); await card.getByRole("button", { name: "Dismiss" }).click(); await expect(commentButton(page)).toHaveCount(0); });