/** Browser coverage for prose navigation around hidden discarded cards. */ import { content, expect, test } from "./room"; import { storedQuestion } from "../apps/server/src/testing/plan"; import type { Page } from "@playwright/test"; const WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; const OPEN_DEFINITION = { questions: [{ id: QUESTION, header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ id: OPTION, label: "Canary", description: "" }], }], }; const DISCARDED_CARD = ` `; const DISCARDED_RECORD = { id: WIDGET, definition: OPEN_DEFINITION, status: "discarded", answers: { [QUESTION]: "Canary" }, choices: [OPTION], resolver: "ana", owner: "ana", decidedAt: 1_790_000_000, history: [], optionOrigins: {}, editors: [], }; const OPEN_CARD = ` `; function discardedHost(page: Page) { return content(page).locator(`[data-plan-questionnaire="${WIDGET}"]`); } async function placeCaretAtEnd(page: Page, sentence: string) { return content(page).evaluate((root, before) => { let paragraph = [...root.querySelectorAll("p")].find(item => item.textContent === before); let text = paragraph?.querySelector("[data-lexical-text]")?.firstChild; if (!(text instanceof Text)) throw new Error("the target paragraph has no text node"); let selection = getSelection(); if (!selection) throw new Error("selection unavailable"); (root as HTMLElement).focus(); let range = document.createRange(); range.setStart(text, text.data.length); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return { active: document.activeElement === root, anchor: selection.anchorNode === text, paragraph: paragraph?.textContent ?? null, contentEditable: (root as HTMLElement).contentEditable, }; }, sentence); } async function placeCaretAtStart(page: Page, sentence: string) { return content(page).evaluate((root, textContent) => { let paragraph = [...root.querySelectorAll("p")].find(item => item.textContent === textContent); let text = paragraph?.querySelector("[data-lexical-text]")?.firstChild; if (!(text instanceof Text)) throw new Error("the target paragraph has no text node"); let selection = getSelection(); if (!selection) throw new Error("selection unavailable"); (root as HTMLElement).focus(); let range = document.createRange(); range.setStart(text, 0); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return { active: document.activeElement === root, anchor: selection.anchorNode === text, paragraph: paragraph?.textContent ?? null, contentEditable: (root as HTMLElement).contentEditable, }; }, sentence); } async function caretState(page: Page) { return page.evaluate(() => { let selection = getSelection(); let node = selection?.anchorNode; let element = node instanceof Element ? node : node?.parentElement; let active = document.activeElement; return { active: active?.tagName ?? null, activeContentEditable: active instanceof HTMLElement ? active.contentEditable : null, anchorNode: node?.nodeName ?? null, anchorText: node?.textContent ?? null, offset: selection?.anchorOffset ?? null, paragraph: element?.closest("p")?.textContent ?? null, }; }); } test("arrowing between ordinary paragraphs moves to the next paragraph", async ({ join, seed }) => { await seed("Before hidden decision.\n\nAfter hidden decision."); let page = await join("ana"); let editor = content(page); let originalParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); let initial = await placeCaretAtEnd(page, "Before hidden decision."); expect(initial).toEqual({ active: true, anchor: true, paragraph: "Before hidden decision.", contentEditable: "true", }); await page.keyboard.press("ArrowRight"); let caret = await caretState(page); let afterRightParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("X"); let afterRightTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); initial = await placeCaretAtStart(page, "After hidden decision."); expect(initial.paragraph).toBe("After hidden decision."); await page.keyboard.press("ArrowLeft"); let afterLeftParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("Y"); let afterLeftTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); let restoredParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); if ( caret.paragraph !== "After hidden decision." || JSON.stringify(afterRightParagraphs) !== JSON.stringify(originalParagraphs) || !afterRightTyping.includes("XAfter hidden decision.") || JSON.stringify(afterLeftParagraphs) !== JSON.stringify(originalParagraphs) || !afterLeftTyping.includes("Before hidden decision.Y") || JSON.stringify(restoredParagraphs) !== JSON.stringify(originalParagraphs) ) { throw new Error(`Ordinary paragraph traversal: ${ JSON.stringify({ caret, afterRightParagraphs, afterRightTyping, afterLeftParagraphs, afterLeftTyping, restoredParagraphs, }) }`); } }); test("a hidden discarded node has no layout footprint or keyboard stop", async ({ join, seed }) => { await seed(`Before hidden decision.\n\n${DISCARDED_CARD}\n\nAfter hidden decision.`, { questions: [DISCARDED_RECORD], }); let page = await join("ana"); let editor = content(page); let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); await expect(host).toBeAttached(); await expect(host).toBeHidden(); let documentOrder = await editor.evaluate((root, widgetId) => { let paragraphs = [...root.querySelectorAll("p")]; let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); return { hiddenBetweenProse: Boolean( paragraphs[0] && hidden && paragraphs.at(-1) && paragraphs[0]!.compareDocumentPosition(hidden) & Node.DOCUMENT_POSITION_FOLLOWING && hidden.compareDocumentPosition(paragraphs.at(-1)!) & Node.DOCUMENT_POSITION_FOLLOWING, ), }; }, WIDGET); expect(documentOrder.hiddenBetweenProse).toBe(true); let layout = await host.evaluate(element => { let style = getComputedStyle(element); let bounds = element.getBoundingClientRect(); return { display: style.display, marginBottom: style.marginBottom, marginTop: style.marginTop, width: bounds.width, height: bounds.height, }; }); expect(layout).toEqual({ display: "none", marginBottom: "0px", marginTop: "0px", width: 0, height: 0, }); await expect(host.locator("button, input, select, textarea, [tabindex]:not([tabindex='-1'])")) .toHaveCount(0); let initial = await placeCaretAtStart(page, "After hidden decision."); expect(initial).toEqual({ active: true, anchor: true, paragraph: "After hidden decision.", contentEditable: "true", }); let originalParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); initial = await placeCaretAtEnd(page, "Before hidden decision."); await page.keyboard.press("ArrowRight"); let afterNaturalRight = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("ArrowLeft"); let afterNaturalLeft = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("R"); let afterNaturalLeftTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); let afterNaturalRestore = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); initial = await placeCaretAtStart(page, "After hidden decision."); await page.keyboard.press("ArrowLeft"); let afterLeft = await caretState(page); let afterLeftParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("Y"); let afterLeftTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); let afterLeftRestore = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); initial = await placeCaretAtEnd(page, "Before hidden decision."); expect(initial.paragraph).toBe("Before hidden decision."); await page.keyboard.press("ArrowRight"); let afterRight = await caretState(page); let afterRightParagraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("X"); let paragraphs = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); if ( JSON.stringify(afterNaturalRight) !== JSON.stringify(originalParagraphs) || JSON.stringify(afterNaturalLeft) !== JSON.stringify(originalParagraphs) || !afterNaturalLeftTyping.includes("Before hidden decision.R") || JSON.stringify(afterNaturalRestore) !== JSON.stringify(originalParagraphs) || JSON.stringify(afterLeftParagraphs) !== JSON.stringify(originalParagraphs) || JSON.stringify(afterRightParagraphs) !== JSON.stringify(originalParagraphs) || !afterLeftTyping.includes("Before hidden decision.Y") || JSON.stringify(afterLeftRestore) !== JSON.stringify(originalParagraphs) || !paragraphs.includes("XAfter hidden decision.") ) { throw new Error(`Discarded traversal state: ${ JSON.stringify({ afterNaturalRight, afterNaturalLeft, afterNaturalLeftTyping, afterNaturalRestore, afterLeft, afterLeftParagraphs, afterLeftTyping, afterLeftRestore, afterRight, afterRightParagraphs, paragraphs, }) }`); } }); test("a discarded first block does not create a prose gap before the first paragraph", async ({ join, seed }) => { await seed(`${DISCARDED_CARD}\n\nOnly visible paragraph.`, { questions: [DISCARDED_RECORD], }); let page = await join("ana"); let editor = content(page); let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); await expect(host).toBeHidden(); let order = await editor.evaluate((root, widgetId) => { let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); let visible = [...root.querySelectorAll("p")].find( paragraph => paragraph.textContent === "Only visible paragraph.", ); return Boolean( hidden && visible && hidden.compareDocumentPosition(visible) & Node.DOCUMENT_POSITION_FOLLOWING, ); }, WIDGET); expect(order).toBe(true); let original = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await placeCaretAtStart(page, "Only visible paragraph."); await page.keyboard.press("ArrowLeft"); let afterArrow = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("X"); let afterTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); let restored = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); if ( JSON.stringify(afterArrow) !== JSON.stringify(original) || !afterTyping.includes("XOnly visible paragraph.") || JSON.stringify(restored) !== JSON.stringify(original) ) { throw new Error(`Discarded leading block traversal: ${ JSON.stringify({ original, afterArrow, afterTyping, restored, }) }`); } }); test("a discarded final block routes Right into the existing blank continuation", async ({ join, seed }) => { await seed(`Only visible paragraph.\n\n${DISCARDED_CARD}`, { questions: [{ ...DISCARDED_RECORD, status: "discarded", resolver: "ana" }], }); let page = await join("ana"); let editor = content(page); let host = editor.locator(`[data-plan-questionnaire="${WIDGET}"]`); await expect(host).toBeHidden(); let original = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); let order = await editor.evaluate((root, widgetId) => { let paragraphs = [...root.querySelectorAll("p")]; let hidden = root.querySelector(`[data-plan-questionnaire="${widgetId}"]`); return Boolean( paragraphs[0] && paragraphs.at(-1) && hidden && paragraphs[0]!.compareDocumentPosition(hidden) & Node.DOCUMENT_POSITION_FOLLOWING && hidden.compareDocumentPosition(paragraphs.at(-1)!) & Node.DOCUMENT_POSITION_FOLLOWING && paragraphs.at(-1)!.textContent === "", ); }, WIDGET); expect(order).toBe(true); let initial = await placeCaretAtEnd(page, "Only visible paragraph."); await page.keyboard.press("ArrowRight"); let afterArrowState = await caretState(page); let afterArrow = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.type("X"); let afterTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await page.keyboard.press("Backspace"); let restored = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); if ( JSON.stringify(afterArrow) !== JSON.stringify(original) || afterTyping.length !== original.length || afterTyping.at(-1) !== "X" || JSON.stringify(restored) !== JSON.stringify(original) ) { throw new Error(`Discarded trailing block traversal: ${ JSON.stringify({ initial, afterArrowState, original, afterArrow, afterTyping, restored, }) }`); } }); test("metadata-only discard hides a stale node and routes Right into the next prose block", async ({ join, page, seed }) => { await seed(`Before metadata discard.\n\n${OPEN_CARD}\n\nAfter metadata discard.`, { revision: 1, questions: [{ id: WIDGET, definition: OPEN_DEFINITION, status: "open", origin: "planner", history: [], optionOrigins: {}, editors: [], }], openQuestions: [{ id: WIDGET, definition: OPEN_DEFINITION, model: storedQuestion(OPEN_DEFINITION), revision: 0, widget: WIDGET, }], }); let holdPlanUpdates = false; let heldUpdates: string[] = []; let didHoldUpdate!: () => void; let updateHeld = new Promise(resolve => didHoldUpdate = resolve); let didDiscard!: () => void; let discarded = new Promise(resolve => didDiscard = resolve); let serverSender: { send: (message: string) => void } | undefined; 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 === WIDGET && frame.kind === "question:meta" && frame.meta?.status === "discarded" ) { didDiscard(); } route.send(message); }); }); let ben = await join("ben"); let ana = await join("ana"); let host = discardedHost(ben); await expect(host).toBeVisible(); holdPlanUpdates = true; await ana.getByRole("button", { name: /^Decisions/ }).click(); let anaCard = ana.locator( `[data-document-view="decisions"] article[data-plan-sidecar-questionnaire="${WIDGET}"]`, ); await anaCard.getByRole("button", { name: "Discard", exact: true }).click(); await expect(anaCard.getByText("Discard this decision?", { exact: true })).toBeVisible(); await anaCard.getByRole("button", { name: "Discard", exact: true }).click(); await Promise.all([updateHeld, discarded]); try { await expect(host).toBeHidden(); let editor = content(ben); let original = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); let initial = await placeCaretAtEnd(ben, "Before metadata discard."); await ben.keyboard.press("ArrowRight"); let afterArrow = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await ben.keyboard.type("X"); let afterTyping = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); await ben.keyboard.press("Backspace"); let restored = await editor.evaluate(root => [...root.querySelectorAll("p")].map(paragraph => paragraph.textContent) ); if ( !initial.anchor || JSON.stringify(afterArrow) !== JSON.stringify(original) || !afterTyping.includes("XAfter metadata discard.") || JSON.stringify(restored) !== JSON.stringify(original) || heldUpdates.length === 0 ) { throw new Error(`Metadata-only discarded navigation: ${ JSON.stringify({ original, afterArrow, afterTyping, restored, heldUpdateCount: heldUpdates.length, }) }`); } } finally { if (serverSender) { for (let update of heldUpdates) serverSender.send(update); } heldUpdates = []; } });