/** Browser coverage for decision card lifecycle states and metadata regrouping. */ import { authenticate, content, expect, roomPath, test } from "./room"; import { expectNoHorizontalOverflow } from "./responsive"; import type { Question } from "../packages/protocol/index"; import type { Page } from "@playwright/test"; const PROSE = "Room state lives on disk as MDX beside the transcript.\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: "" }], }], }; const ANCHORED_DIGEST = "sha256:3ccc3e648811df8180799f8b012c6934bcf44a306f5eb8b8c9d2676b0493ccf4"; const DECIDED_CARD = ` `; const DECIDED_RECORD = { id: WIDGET, definition: ANCHORED_DEFINITION, status: "answered", answers: { [QUESTION]: "Canary" }, choices: [OPTION], resolver: "ana", owner: "ana", decidedAt: 1_790_000_000, history: [], optionOrigins: {}, editors: [], }; const MULTI_DECIDED_DEFINITION = { questions: [ ...ANCHORED_DEFINITION.questions, { id: SECOND_QUESTION, header: "Scope", question: "What belongs in the first cut?", multiple: false, options: [{ id: SECOND_OPTION, label: "Anchors", description: "" }], }, ], }; const LEGACY_MULTI_DECIDED_CARD = ` `; function questionnaire(page: Page) { return page.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]'); } function cardByPrompt(page: Page, prompt: string) { return questionnaire(page).filter({ hasText: prompt }); } test("discarding an open card moves it into a collapsed, persistent resolved history", async ({ join, seed }) => { await seed(PROSE); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); let card = questionnaire(page).filter({ hasText: "Where should room state live?", }); let cardId = await card.getAttribute("data-plan-sidecar-questionnaire"); if (!cardId) throw new Error("questionnaire id missing"); await card.getByRole("button", { name: "Discard", exact: true }).click(); await expect(card.getByText("Discard this decision?", { exact: true })).toBeVisible(); await card.getByRole("button", { name: "Discard", exact: true }).click(); await expect( page.locator(`[data-document-view="decisions"] [data-plan-sidecar-questionnaire="${cardId}"]`), ) .toHaveCount(0); let discarded = page.getByRole("button", { name: "1 resolved" }); await expect(discarded).toHaveAttribute("aria-expanded", "false"); await discarded.click(); await expect(cardByPrompt(page, "Where should room state live?")) .toContainText("Discarded by @ana"); await page.reload(); await page.getByRole("button", { name: /^Decisions/ }).click(); await expect(page.getByRole("button", { name: "1 resolved" })).toHaveAttribute( "aria-expanded", "true", ); }); test("discarding a decided card records the person who discarded it", async ({ join, seed }) => { await seed(PROSE); let ana = await join("ana"); let ben = await join("ben"); for (let page of [ana, ben]) await page.getByRole("button", { name: /^Decisions/ }).click(); let card = (page: Page) => questionnaire(page).filter({ hasText: "Where should room state live?", }); await card(ana).getByText("In SQLite", { exact: true }).click(); await card(ana).getByRole("button", { name: "Save", exact: true }).click(); await expect(ben.getByRole("button", { name: "1 resolved" })).toBeVisible(); await ben.getByRole("button", { name: "1 resolved" }).click(); await card(ben).getByRole("button", { name: "Discard", exact: true }).click(); await card(ben).getByRole("button", { name: "Discard decision" }).click(); await expect(ben.getByRole("button", { name: "1 resolved" })) .toHaveAttribute("aria-expanded", "true"); let discardedCard = cardByPrompt(ben, "Where should room state live?"); await expect(discardedCard).toContainText("Discarded by @ben"); await expect(discardedCard).not.toContainText("Discarded by @ana"); await ben.reload(); await ben.getByRole("button", { name: /^Decisions/ }).click(); await expect(ben.getByRole("button", { name: "1 resolved" })).toHaveAttribute( "aria-expanded", "true", ); await expect(cardByPrompt(ben, "Where should room state live?")) .toContainText("Discarded by @ben"); }); test("reopening a decision shares a fresh draft and a later Save survives reload", async ({ join, seed }) => { await seed(PROSE); let ana = await join("ana"); await ana.setViewportSize({ width: 945, height: 850 }); let ben = await join("ben"); for (let page of [ana, ben]) await page.getByRole("button", { name: /^Decisions/ }).click(); let card = (page: Page) => questionnaire(page).filter({ hasText: "Where should room state live?", }); await card(ana).getByText("In SQLite", { exact: true }).click(); await card(ana).getByRole("button", { name: "Save", exact: true }).click(); await ana.getByRole("button", { name: "1 resolved" }).click(); let selected = card(ana).locator(".question-option[data-selected]"); await expect(selected).toHaveCount(1); await expect(selected).toContainText("In SQLite"); await expect(card(ana).getByRole("radio")).toHaveCount(0); await card(ana).getByRole("button", { name: "Reopen" }).click(); for (let page of [ana, ben]) { await expect(card(page).getByText("Previously: In SQLite · @ana")).toBeVisible(); await expect(card(page).getByRole("radio", { name: "In SQLite" })).not.toBeChecked(); } await card(ben).getByText("On disk as MDX", { exact: true }).click(); await expect(card(ana).getByRole("radio", { name: "On disk as MDX" })).toBeChecked(); await card(ben).getByRole("button", { name: "Save", exact: true }).click(); await expect(ana.getByRole("button", { name: "1 resolved" })).toBeVisible(); await ana.reload(); await ana.getByRole("button", { name: /^Decisions/ }).click(); await expect(ana.getByRole("button", { name: "1 resolved" })).toHaveAttribute( "aria-expanded", "true", ); await expect(cardByPrompt(ana, "Where should room state live?")) .toContainText("On disk as MDX"); }); test("card metadata regroups Decisions before the document update arrives", async ({ join, page, seed }) => { await seed(PROSE); let holdPlanUpdates = false; let heldUpdates: string[] = []; let didHoldUpdate!: () => void; let updateHeld = new Promise(resolve => didHoldUpdate = resolve); let didDecide!: () => void; let decided = new Promise(resolve => didDecide = resolve); let questionId: string | undefined; let serverSender: { send: (message: string) => void } | undefined; let releaseHeldUpdates = () => { if (!serverSender) throw new Error("WebSocket route is not ready"); for (let update of heldUpdates) serverSender.send(update); heldUpdates = []; }; await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); serverSender = { send: message => route.send(message) }; route.onMessage(message => server.send(message)); server.onMessage(message => { if (typeof message !== "string") return route.send(message); let frame = JSON.parse(message) as { kind?: string; id?: string; meta?: { status?: string }; }; if (holdPlanUpdates && frame.kind === "plan:update") { heldUpdates.push(message); didHoldUpdate(); return; } if ( frame.id === questionId && frame.kind === "question:meta" && frame.meta?.status === "decided" ) didDecide(); route.send(message); }); }); let ben = await join("ben"); let ana = await join("ana"); for (let page of [ana, ben]) await page.getByRole("button", { name: /^Decisions/ }).click(); await expect(ben.getByRole("button", { name: "Decisions, 2 unanswered" })).toBeVisible(); let benCard = questionnaire(ben).filter({ has: ben.getByRole("heading", { name: "Where should room state live?" }), }); questionId = await benCard.getAttribute("data-plan-sidecar-questionnaire") ?? undefined; if (!questionId) throw new Error("questionnaire id missing"); let benChoice = benCard.getByRole("radio", { name: "On disk as MDX" }); await benChoice.focus(); await expect(benChoice).toBeFocused(); holdPlanUpdates = true; let anaCard = questionnaire(ana).filter({ has: ana.getByRole("heading", { name: "Where should room state live?" }), }); await anaCard.getByText("On disk as MDX", { exact: true }).click(); await anaCard.getByRole("button", { name: "Save", exact: true }).click(); await Promise.all([updateHeld, decided]); await expect(ben.getByRole("button", { name: "Decisions, 1 unanswered" })).toBeVisible(); await expect( questionnaire(ben).filter({ has: ben.getByRole("heading", { name: "Which of these belong in the first cut?" }), }), ).toBeFocused(); await ben.getByRole("button", { name: "1 resolved" }).click(); let decidedCard = ben.locator( `[data-document-view="decisions"] article[data-plan-sidecar-questionnaire="${questionId}"]`, ); await expect(decidedCard).toBeVisible(); await expect(decidedCard.getByText("Saved decision", { exact: true })).toBeVisible(); await expect(decidedCard.getByRole("button", { name: "Reopen" })).toBeVisible(); await expect(decidedCard.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); expect(heldUpdates.length).toBeGreaterThan(0); releaseHeldUpdates(); await expect(decidedCard).toContainText("Where should room state live?"); await expect(decidedCard).toContainText("On disk as MDX"); }); test("resolved cards show no actions to a read-only viewer", async ({ baseURL, browser, join, room, seed }) => { await seed(`${PROSE}\n${DECIDED_CARD}`, { questions: [DECIDED_RECORD] }); await join("ana"); let context = await browser.newContext({ baseURL, viewport: { width: 945, height: 850 } }); try { let reader = await context.newPage(); await authenticate(reader, "readonly", baseURL!); await reader.goto(roomPath(room)); await reader.getByRole("button", { name: /^Decisions/ }).click(); await reader.getByRole("button", { name: "1 resolved" }).click(); let card = reader.locator( `[data-document-view="decisions"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ); await expect(card).toContainText("Answered by @ana"); await expect(card.locator(".question-option[data-selected]")).toContainText("Canary"); await expect(card.getByRole("radio")).toHaveCount(0); await expect(card.getByRole("checkbox")).toHaveCount(0); await expect(card.getByRole("button", { name: "Reopen" })).toHaveCount(0); await expect(card.getByRole("button", { name: "Discard", exact: true })).toHaveCount(0); } finally { await context.close(); } }); test("a legacy multi card reopens with its text-only previous answer", async ({ join, seed }) => { let record = { ...DECIDED_RECORD, definition: MULTI_DECIDED_DEFINITION, answers: { [QUESTION]: "Only collaborative anchors", [SECOND_QUESTION]: "Anchors", }, choices: [SECOND_OPTION], }; await seed(`${PROSE}\n${LEGACY_MULTI_DECIDED_CARD}`, { questions: [record] }); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "1 resolved" }).click(); let card = page.locator( `[data-document-view="decisions"] [data-plan-sidecar-questionnaire="${WIDGET}"]`, ); await expect(card.getByRole("heading", { name: "How should we deploy?" })).toBeVisible(); await expect(card.getByRole("heading", { name: "What belongs in the first cut?" })) .toBeVisible(); await expect(card.locator(".question-option[data-selected]")).toHaveCount(2); await expect(card.locator(".question-option[data-selected]").first()) .toContainText("Only collaborative anchors"); await card.getByRole("button", { name: "Reopen" }).click(); await expect(card).toContainText("Previously: Only collaborative anchors · @ana"); await expect(card.getByRole("radio", { name: "Canary" })).not.toBeChecked(); await card.getByRole("button", { name: "Next question", exact: true }).click(); await expect(card.getByRole("heading", { name: "What belongs in the first cut?" })) .toBeVisible(); await expect(card).toContainText("Previously: Anchors · @ana"); await expect(card.getByRole("radio", { name: "Anchors" })).not.toBeChecked(); }); test("a linked decision keeps a keyboard jump to its prose", async ({ join, seed }) => { await seed(`Anchored paragraph.\n\n${DECIDED_CARD}`, { questions: [{ ...DECIDED_RECORD, prose: [{ epoch: "stale", position: "AAAA", digest: ANCHORED_DIGEST }], }], }); let page = await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "1 resolved" }).click(); let card = cardByPrompt(page, "How should we deploy?"); let link = card.getByRole("button", { name: "Show in document" }); await expect(link).toHaveCount(1); await link.focus(); await link.press("Enter"); await expect(page.getByRole("button", { name: "Document", exact: true })) .toHaveAttribute("aria-pressed", "true"); await expect(content(page).getByText("Anchored paragraph.", { exact: true })).toBeVisible(); }); test("a long legacy answer wraps inside a 390px decided card", async ({ join, seed }) => { let answer = "Only collaborative anchors that preserve every surrounding edit across devices"; let source = ` `; await seed(`${PROSE}\n${source}`, { questions: [{ ...DECIDED_RECORD, answers: { [QUESTION]: answer }, choices: [] }], }); let page = await join("ana", { viewport: { width: 390, height: 844 } }); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "1 resolved" }).click(); let card = cardByPrompt(page, "How should we deploy?"); await expect(card.locator(".question-option[data-selected]")).toContainText(answer); await expectNoHorizontalOverflow(page); }); test("an orphaned prose anchor retains its authoritative state and resolved card", async ({ join, page, seed }) => { await seed(`${PROSE}\n${DECIDED_CARD}`, { questions: [{ ...DECIDED_RECORD, prose: [{ epoch: "old", position: "AAAA", digest: ANCHORED_DIGEST, orphaned: true }], }], }); let orphaned: boolean | undefined; await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); route.onMessage(message => server.send(message)); server.onMessage(message => { if (typeof message === "string") { let frame = JSON.parse(message) as { kind: string; id?: string; meta?: Question.CardMeta; cards?: Question.Metas["cards"]; }; if (frame.kind === "question:metas") { orphaned = frame.cards?.find(card => card.id === WIDGET)?.meta.proseOrphaned; } if (frame.kind === "question:meta" && frame.id === WIDGET) { orphaned = frame.meta?.proseOrphaned; } } route.send(message); }); }); await join("ana"); await page.getByRole("button", { name: /^Decisions/ }).click(); await page.getByRole("button", { name: "1 resolved" }).click(); await expect.poll(() => orphaned).toBe(true); await expect(cardByPrompt(page, "How should we deploy?")) .toContainText("Canary"); }); test("conversation decisions settle inline without claiming a write-up no job is doing", async ({ join, seed }) => { await seed(`${PROSE}\n${DECIDED_CARD}`, { questions: [{ ...DECIDED_RECORD, origin: "conversation" }], }); let page = await join("ana"); let settled = content(page).locator("[data-card-settled]"); await expect(settled).toContainText("Decided: Canary · @ana"); await expect(settled).not.toContainText("Writing up…"); });