← Files DataARCHIVED FILE
tests/data-report-composition-browser.smoke.mjs
31.3 KB · Sep 30, 2026 · 23:19 UTC
import assert from "node:assert/strict";
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { chromium } from "playwright-core";
import { resolveChromiumExecutable, runDataAppFixtureBuild } from "./browser-helpers.mjs";
const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(pluginRoot, "templates/data-app/base");
const project = mkdtempSync(join(tmpdir(), "data-report-composition-"));
const source = (name, date, rows) => ({
label: `Ledger ${name}`, reportingField: "date", rows,
source: {
label: `Ledger ${name}`, sql: `SELECT value FROM ledger_${name.toLowerCase()}`,
tables: [`ledger_${name.toLowerCase()}`], executedAt: `${date}T12:00:00Z`,
metricDefinitions: [{ label: "Value", definition: `Definition ${name}` }],
evidenceFlow: [{ title: `Evidence ${name}`, detail: `Reviewed ledger ${name}` }],
},
});
const alphaRows = Array.from({ length: 9 }, (_, index) => ({ date: "2026-08-10", record: `A-${index + 1}`, value: index + 101 }));
const betaRows = [{ date: "2026-08-03", record: "B-included", value: 222 }];
const snapshot = {
id: "report-composition-contract", surface: "report", title: "Evidence brief",
generatedAt: "2026-08-11T12:00:00Z", status: "fixture", filters: [], includeLead: true,
queries: {
alpha: source("A", "2026-08-10", [...alphaRows, { date: "2026-08-11", record: "A-omitted", value: 999 }]),
beta: source("B", "2026-08-03", [...betaRows, { date: "2026-08-04", record: "B-omitted", value: 888 }]),
},
};
const checklistMarkdown = "- [ ] Pending **review**\n- [x] Done\n - [ ] Nested\n- Ordinary bullet\n- \\[x] Escaped marker\n- `[x]` Code marker";
const authoredReport = `import React, { useState } from "react";
import { DataComponent, ReportSection, RichNarrative, useDataApp } from "../../data-app-public.jsx";
export function ReportContent() {
const { snapshot, reviewedRows, visible, appTitle } = useDataApp();
const [includeSecondary, setIncludeSecondary] = useState(true);
const [sourceRevision, setSourceRevision] = useState(0);
const alpha = reviewedRows("alpha").filter((row) => row.record !== "A-omitted");
const beta = reviewedRows("beta").filter((row) => row.record !== "B-omitted");
return <article className="report-content" aria-label="Composition fixture">
<header className="report-hero"><h1>{appTitle}</h1></header>
{snapshot.includeLead && visible("report-executive-summary") &&
<ReportSection id="report-executive-summary" title="The finding" queryId="alpha" queryIds={["beta"]}
sourceRowsByQuery={{ alpha, beta }}>
<RichNarrative id="finding:body" value="The **reviewed evidence** supports this finding." label="Edit finding" />
</ReportSection>}
{visible("single-evidence") && <ReportSection id="single-evidence" title="A closer look" queryId="beta"
sourceRows={beta} showHeading={false}>
<RichNarrative id="detail:body" value={"## A closer look\\n\\nA single-source [explanation](https://example.com/evidence)."} />
</ReportSection>}
{visible("prose-only") && <ReportSection id="prose-only" title="One finding" queryId="beta"
sourceRows={beta} showHeading={false}>
<RichNarrative id="prose-only:body" label="Edit prose-only finding"
value="A plain paragraph can answer the question without adding a heading. Its reviewed evidence and editable text must remain usable when this sentence wraps on a narrow screen." />
</ReportSection>}
<DataComponent id="legacy-plain" title="Legacy finding" kind="custom" queryId="beta">
<p>Legacy paragraph content.</p>
</DataComponent>
<DataComponent id="legacy-merged" title="Merged finding" kind="custom" queryId="beta">
<RichNarrative id="legacy:body" value="Editable legacy content." />
</DataComponent>
<DataComponent id="legacy-no-heading" title="Hidden heading" kind="custom" queryId="beta" showHeading={false}>
<RichNarrative id="hidden:body" value="No forced heading." />
</DataComponent>
<ReportSection id="checklist" title="Review tasks" queryId="beta">
<RichNarrative id="checklist:body" value={${JSON.stringify(checklistMarkdown)}} />
</ReportSection>
<ReportSection id="block-styles" title="Editable list" queryId="beta">
<RichNarrative id="styles:body" label="Edit list" value={"- First **item**\\n- Second item"} />
</ReportSection>
<ReportSection id="clear-text" title="Editable text" queryId="beta">
<RichNarrative id="clear:body" label="Clear finding" value="Text that can be cleared." />
</ReportSection>
<ReportSection id="partial-list" title="Partial list selection" queryId="beta">
<RichNarrative id="partial:body" label="Edit one list item" value={"- Before\\n- Selected **item**\\n- After"} />
</ReportSection>
<ReportSection id="nested-list" title="Nested list selection" queryId="beta">
<RichNarrative id="nested:body" label="Edit nested list" value={"- Parent\\n - Nested first\\n - Nested second\\n- After"} />
</ReportSection>
<ReportSection id="nested-middle" title="Nested middle selection" queryId="beta">
<RichNarrative id="nested-middle:body" label="Edit nested middle" value={"- Parent\\n - Nested first\\n - Nested second\\n - Nested third\\n- After"} />
</ReportSection>
<ReportSection id="nested-parent" title="Parent list selection" queryId="beta">
<RichNarrative id="nested-parent:body" label="Edit parent item" value={"- Parent\\n - Child one\\n - Child two\\n- After"} />
</ReportSection>
<section aria-label="Live source controls">
<button type="button" onClick={() => setIncludeSecondary((value) => !value)}>Toggle secondary source</button>
<button type="button" onClick={() => setSourceRevision((value) => value + 1)}>Refresh source component</button>
<ReportSection id="live-source-evidence" title={"Live source " + sourceRevision} queryId="alpha"
queryIds={includeSecondary ? ["beta"] : []}
sourceRowsByQuery={includeSecondary ? { alpha, beta } : { alpha }}>
<RichNarrative id="live-source:body" value="Reviewed sources can change while their drawer is open." />
</ReportSection>
</section>
</article>;
}`;
let browser;
const errors = [];
const block = (page, id) => page.locator(`[data-component-id="${id}"]`);
async function saveReportEdits(page) {
await page.locator(".dashboard-topbar").getByRole("button", { name: "Save", exact: true }).click();
await page.locator('.dashboard-topbar[data-mode="view"]').waitFor();
}
async function action(page, id, name) {
await block(page, id).getByRole("button", { name: / actions$/u }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
}
async function openSource(page, id) {
await action(page, id, "View data source");
const drawer = page.getByRole("complementary", { name: /Data source for/u });
await drawer.getByRole("tab", { name: "Overview", exact: true }).waitFor();
return drawer;
}
async function selectSource(page, drawer, label) {
await drawer.getByRole("button", { name: "Choose reviewed data source" }).click();
await page.getByRole("menuitemradio", { name: label, exact: true }).click();
}
async function closeSource(drawer) {
await drawer.getByRole("button", { name: "Close data source", exact: true }).click();
await drawer.waitFor({ state: "hidden" });
}
async function captureClipboard(page) {
await page.addInitScript(() => {
window.__compositionClipboard = [];
Object.defineProperty(navigator, "clipboard", { configurable: true,
value: { writeText: async (value) => window.__compositionClipboard.push(value) } });
});
}
async function assertProseMenuDoesNotOverlapText(component) {
await component.page().waitForFunction((id) => {
const menu = document.querySelector(`[data-component-id="${id}"] .menu-trigger`);
return menu && getComputedStyle(menu).opacity === "1";
}, await component.getAttribute("data-component-id"));
const geometry = await component.evaluate((element) => {
const menu = element.querySelector(".menu-trigger");
const bounds = menu.getBoundingClientRect();
const narrative = element.querySelector(".report-rich-editable, .rich-narrative-content");
const walker = document.createTreeWalker(narrative, NodeFilter.SHOW_TEXT);
let overlappingText = "";
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (let index = 0; index < node.textContent.length; index += 1) {
if (!node.textContent[index].trim()) continue;
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + 1);
if ([...range.getClientRects()].some((rect) => rect.width > 0
&& Math.min(rect.right, bounds.right) - Math.max(rect.left, bounds.left) > 0.5
&& Math.min(rect.bottom, bounds.bottom) - Math.max(rect.top, bounds.top) > 0.5)) {
overlappingText += node.textContent[index];
}
}
}
return { overlappingText, menuOpacity: getComputedStyle(menu).opacity,
menu: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom } };
});
assert.equal(geometry.menuOpacity, "1", "Measure the visible source-action hit target");
assert.equal(geometry.overlappingText, "", `Source actions must not overlap selectable prose: ${JSON.stringify(geometry)}`);
}
try {
cpSync(template, project, { recursive: true, filter: (path) => !path.includes("/node_modules") && !path.includes("/dist") });
writeFileSync(join(project, "src/data.json"), `${JSON.stringify(snapshot, null, 2)}\n`);
writeFileSync(join(project, "src/content/report/ReportContent.jsx"), authoredReport);
const build = runDataAppFixtureBuild(project, { pluginRoot });
assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
const compiledHtml = readFileSync(join(project, "dist/index.html"), "utf8");
browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
const page = await browser.newPage({ viewport: { width: 1100, height: 850 }, locale: "en-US" });
page.on("pageerror", (error) => errors.push(error.message));
await captureClipboard(page);
await page.goto(pathToFileURL(join(project, "dist/index.html")).href, { waitUntil: "load" });
await page.getByRole("heading", { name: "The finding", exact: true }).waitFor();
assert.equal(await block(page, "legacy-plain").getByRole("heading", { name: "Legacy finding" }).count(), 1);
assert.equal(await block(page, "legacy-merged").getByRole("heading", { name: "Merged finding" }).count(), 1);
assert.equal(await block(page, "legacy-no-heading").getByRole("heading").count(), 0);
await closeSource(await openSource(page, "legacy-plain"));
async function assertChecklists(target) {
for (const selector of [".rich-narrative-content"]) {
const content = block(target, "checklist").locator(selector);
assert.deepEqual(await content.locator('input[type="checkbox"]').evaluateAll((inputs) =>
inputs.map((input) => ({ checked: input.checked, disabled: input.disabled }))), [
{ checked: false, disabled: true }, { checked: true, disabled: true }, { checked: false, disabled: true },
]);
assert.match(await content.innerText(), /\[x\] Escaped marker/u);
assert.match(await content.innerText(), /\[x\] Code marker/u);
assert.equal(await content.locator("strong").innerText(), "review");
assert.equal(await content.locator("li.task-list-item").first().evaluate((node) =>
getComputedStyle(node).listStyleType), "none");
}
}
await assertChecklists(page);
assert.equal(await page.getByRole("heading", { name: "Executive summary", exact: true }).count(), 0);
const geometry = await block(page, "single-evidence").evaluate((element) => {
const frame = element.getBoundingClientRect();
const title = element.querySelector("h2").getBoundingClientRect();
const menu = element.querySelector(".menu-trigger").getBoundingClientRect();
return { titleGap: title.top - frame.top, menuRight: frame.right - menu.right };
});
assert.ok(Math.abs(geometry.titleGap) <= 2 && Math.abs(geometry.menuRight) <= 8,
`Markdown-owned headings must have right-aligned actions without an empty header row: ${JSON.stringify(geometry)}`);
async function assertEditorialLink() {
const evidenceLink = block(page, "single-evidence").getByRole("link", { name: "explanation", exact: true });
const styles = await evidenceLink.evaluate((element) => ({
color: getComputedStyle(element).color,
surroundingColor: getComputedStyle(element.parentElement).color,
decoration: getComputedStyle(element).textDecorationLine,
style: getComputedStyle(element).textDecorationStyle,
}));
assert.equal(styles.color, styles.surroundingColor);
assert.equal(styles.decoration, "underline");
assert.equal(styles.style, "dotted");
await evidenceLink.hover();
assert.equal(await evidenceLink.evaluate((element) => getComputedStyle(element).textDecorationStyle), "solid");
await page.mouse.move(0, 0);
}
await assertEditorialLink();
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
await assertEditorialLink();
const narrative = block(page, "report-executive-summary").getByRole("textbox", { name: "Edit finding" });
await narrative.evaluate((element) => {
element.focus();
const selection = window.getSelection();
selection.selectAllChildren(element);
selection.collapseToEnd();
});
await narrative.pressSequentially(" Revised locally.");
await narrative.blur();
await saveReportEdits(page);
await page.waitForFunction(() => Object.values(localStorage).some((value) => value.includes("Revised")));
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
await action(page, "report-executive-summary", "Hide");
await block(page, "report-executive-summary").waitFor({ state: "hidden" });
await saveReportEdits(page);
await page.waitForFunction(() => Object.values(localStorage).some((value) =>
JSON.parse(value).presentation?.hiddenBlocks?.includes("report-executive-summary")));
await page.reload({ waitUntil: "load" });
await block(page, "single-evidence").waitFor();
assert.equal(await block(page, "report-executive-summary").count(), 0, "A custom-titled summary must stay hidden after reload");
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
await page.getByRole("button", { name: "Restore hidden (1)", exact: true }).click();
await block(page, "report-executive-summary").waitFor();
assert.match(await block(page, "report-executive-summary").innerText(), /Revised/u);
await saveReportEdits(page);
assert.equal(await block(page, "report-executive-summary").locator("strong").innerText(), "reviewed evidence");
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
const detail = block(page, "single-evidence");
const detailEditor = detail.locator('.report-rich-editable[contenteditable="true"]');
await detailEditor.locator("h2").fill("A revised look");
await detailEditor.locator("p").fill("A revised source explanation.");
await detailEditor.locator("p").selectText();
const formatToolbar = page.getByRole("toolbar", { name: "Format selected text" });
await formatToolbar.waitFor();
await formatToolbar.getByRole("button", { name: "Bold", exact: true }).click();
await page.keyboard.press("Escape");
await detailEditor.blur();
await saveReportEdits(page);
await page.waitForFunction(() => Object.values(localStorage).some((value) =>
value.includes("## A revised look") && value.includes("**A revised source explanation.**")));
await page.reload({ waitUntil: "load" });
await detail.getByRole("heading", { name: "A revised look", exact: true }).waitFor();
assert.equal(await detail.getByRole("heading").count(), 1, "Markdown-owned headings must not duplicate after editing");
assert.equal(await detail.locator(".component-title-text").count(), 0);
assert.equal(await detail.locator("strong").innerText(), "A revised source explanation.");
await closeSource(await openSource(page, "single-evidence"));
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "More", exact: true }).click();
await page.getByRole("menuitem", { name: "Edit text and layout", exact: true }).click();
const proseOnly = block(page, "prose-only");
await proseOnly.scrollIntoViewIfNeeded();
await assertProseMenuDoesNotOverlapText(proseOnly);
const proseEditor = proseOnly.getByRole("textbox", { name: "Edit prose-only finding" });
const proseText = await proseEditor.innerText();
await proseEditor.locator("p").selectText();
await formatToolbar.waitFor();
await formatToolbar.getByRole("button", { name: "Bold", exact: true }).click();
await page.keyboard.press("Escape");
await proseEditor.blur();
await assertProseMenuDoesNotOverlapText(proseOnly);
await saveReportEdits(page);
await page.waitForFunction(() => Object.values(localStorage).some((value) =>
value.includes("**A plain paragraph") && value.includes("narrow screen.**")));
await closeSource(await openSource(page, "prose-only"));
await page.reload({ waitUntil: "load" });
await proseOnly.locator("strong").waitFor();
assert.equal(await proseOnly.locator("strong").innerText(), proseText);
assert.equal(await proseOnly.getByRole("heading").count(), 0, "Prose-only evidence must not acquire a forced heading");
await proseOnly.hover();
await assertProseMenuDoesNotOverlapText(proseOnly);
await closeSource(await openSource(page, "prose-only"));
await page.setViewportSize({ width: 1100, height: 850 });
let drawer = await openSource(page, "report-executive-summary");
assert.match(await drawer.innerText(), /Definition A/u);
assert.match(await drawer.innerText(), /Aug 10, 2026/u);
await drawer.getByRole("tab", { name: "Data preview", exact: true }).click();
assert.doesNotMatch(await drawer.innerText(), /A-omitted/u);
await drawer.getByRole("button", { name: "Next page", exact: true }).click();
assert.match(await drawer.innerText(), /A-9/u);
await drawer.getByRole("textbox", { name: "Search data" }).fill("A-9");
await selectSource(page, drawer, "Ledger B");
assert.equal(await drawer.getByRole("textbox", { name: "Search data" }).inputValue(), "");
assert.match(await drawer.innerText(), /B-included/u);
assert.doesNotMatch(await drawer.innerText(), /A-9|B-omitted/u);
await drawer.getByRole("tab", { name: "Overview", exact: true }).click();
assert.match(await drawer.innerText(), /Definition B/u);
assert.doesNotMatch(await drawer.innerText(), /Definition A/u);
assert.match(await drawer.innerText(), /Aug 3, 2026/u);
await drawer.getByRole("tab", { name: "Evidence flow", exact: true }).click();
assert.match(await drawer.innerText(), /Evidence B/u);
assert.doesNotMatch(await drawer.innerText(), /Evidence A/u);
await drawer.getByRole("tab", { name: "SQL query", exact: true }).click();
await drawer.getByRole("button", { name: "Copy", exact: true }).click();
assert.equal(await page.evaluate(() => window.__compositionClipboard.at(-1)), snapshot.queries.beta.source.sql);
await selectSource(page, drawer, "Ledger A");
await drawer.getByRole("button", { name: "Copy", exact: true }).waitFor();
assert.match(await drawer.locator(".sql").innerText(), /ledger_a/u);
assert.doesNotMatch(await drawer.locator(".sql").innerText(), /ledger_b/u);
await closeSource(drawer);
await action(page, "report-executive-summary", "Copy data");
const copied = await page.evaluate(() => window.__compositionClipboard.at(-1));
assert.match(copied, /^Ledger A\n/u);
assert.match(copied, /A-9\t109/u);
assert.match(copied, /\n\nLedger B\n/u);
assert.match(copied, /B-included\t222/u);
assert.doesNotMatch(copied, /A-omitted|B-omitted/u);
drawer = await openSource(page, "single-evidence");
assert.equal(await drawer.getByRole("button", { name: "Choose reviewed data source" }).count(), 0);
await closeSource(drawer);
drawer = await openSource(page, "live-source-evidence");
await selectSource(page, drawer, "Ledger B");
await drawer.getByRole("tab", { name: "Data preview", exact: true }).click();
await drawer.getByRole("cell", { name: "B-included", exact: true }).waitFor();
const liveDrawer = await drawer.elementHandle();
// Trigger external React state updates without dismissing the open drawer's backdrop.
await page.getByRole("button", { name: "Refresh source component", exact: true }).evaluate(button => button.click());
await drawer.getByRole("heading", { name: "Live source 1", exact: true }).waitFor();
assert.match(await drawer.innerText(), /B-included/u,
"Updating the open component must retain its still-valid secondary-source selection");
assert.doesNotMatch(await drawer.innerText(), /A-1|B-omitted/u);
await page.getByRole("button", { name: "Toggle secondary source", exact: true }).evaluate(button => button.click());
await drawer.getByRole("cell", { name: "A-1", exact: true }).waitFor();
assert.equal(await drawer.getByRole("button", { name: "Choose reviewed data source" }).count(), 0);
assert.doesNotMatch(await drawer.innerText(), /B-included|A-omitted/u,
"Removing the selected secondary source must resolve the primary source before reading scoped rows");
await page.getByRole("button", { name: "Refresh source component", exact: true }).evaluate(button => button.click());
await drawer.getByRole("heading", { name: "Live source 2", exact: true }).waitFor();
assert.match(await drawer.innerText(), /A-1/u, "The primary-source fallback survives subsequent component updates");
assert.equal(await liveDrawer.evaluate(element => element.isConnected), true,
"The regression must update the existing SourceSidebarHost drawer, not close and reopen it");
await closeSource(drawer);
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
const listEditor = block(page, "block-styles").getByRole("textbox", { name: "Edit list" });
async function chooseStyle(name, target = listEditor, viewport) {
if (target) await target.selectText();
await formatToolbar.getByRole("button", { name: "Text styles" }).click();
const menu = page.getByRole("menu", { name: "Text styles" });
if (viewport) {
await page.setViewportSize(viewport);
await menu.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}
const geometry = await menu.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const trigger = document.querySelector('[data-rich-format-toolbar] [aria-label="Text styles"]').getBoundingClientRect();
return { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom,
height: bounds.height, triggerTop: trigger.top, triggerBottom: trigger.bottom,
width: innerWidth, viewportHeight: innerHeight };
});
assert.ok(geometry.left >= 11 && geometry.right <= geometry.width - 11
&& geometry.top >= 11 && geometry.bottom <= geometry.viewportHeight - 11,
`Every text-style option must be reachable inside the viewport: ${JSON.stringify(geometry)}`);
if (geometry.triggerBottom + 8 + geometry.height > geometry.viewportHeight - 12
&& geometry.triggerTop - 8 - geometry.height >= 12) {
assert.ok(geometry.bottom <= geometry.triggerTop - 7,
"A submenu without space below opens above its trigger");
}
await menu.getByRole("menuitemradio", { name: new RegExp(`^${name}`) }).click();
}
for (const listStyle of ["Bulleted list", "Numbered list", "Checklist"]) {
await chooseStyle(listStyle);
assert.equal(await listEditor.locator("li").count(), 2);
await chooseStyle("Heading");
assert.equal(await listEditor.locator("h2").count(), 2, `${listStyle} converts to the requested heading, not a paragraph`);
assert.equal(await listEditor.locator("li").count(), 0);
assert.equal(await listEditor.locator("strong").innerText(), "item");
await chooseStyle("Text");
assert.equal(await listEditor.locator("p").count(), 2);
}
const partialEditor = block(page, "partial-list").getByRole("textbox", { name: "Edit one list item" });
await chooseStyle("Heading", partialEditor.locator("li").nth(1));
assert.deepEqual(await partialEditor.locator("li").allTextContents(), ["Before", "After"]);
assert.equal(await partialEditor.locator("h2").innerText(), "Selected item");
assert.equal(await partialEditor.locator("h2 strong").innerText(), "item");
const nestedEditor = block(page, "nested-list").getByRole("textbox", { name: "Edit nested list" });
await nestedEditor.scrollIntoViewIfNeeded();
await nestedEditor.evaluate((element) => {
element.focus();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const nodes = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node);
const first = nodes.find((node) => node.textContent === "Nested first");
const last = nodes.find((node) => node.textContent === "After");
const range = document.createRange();
range.setStart(first, 0);
range.setEnd(last, last.textContent.length);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
});
await chooseStyle("Heading", null);
assert.deepEqual(await nestedEditor.locator("h2").allTextContents(), ["Nested first", "Nested second", "After"]);
assert.deepEqual(await nestedEditor.locator("li").allTextContents(), ["Parent"]);
const nestedMiddle = block(page, "nested-middle");
await chooseStyle("Heading", nestedMiddle.getByText("Nested second", { exact: true }));
assert.equal(await nestedMiddle.locator(".report-rich-editable h2").innerText(), "Nested second");
const nestedParent = block(page, "nested-parent");
await chooseStyle("Heading", nestedParent.getByText("Parent", { exact: true }), { width: 360, height: 500 });
await page.setViewportSize({ width: 1100, height: 850 });
assert.equal(await nestedParent.locator(".report-rich-editable h2").innerText(), "Parent");
assert.equal(await nestedParent.locator("h2 ul,h2 ol").count(), 0,
"Converting a parent must not put its child list inside the heading");
const clearEditor = block(page, "clear-text").getByRole("textbox", { name: "Clear finding" });
await clearEditor.fill("");
const waitForCleared = () => page.waitForFunction(() =>
document.querySelector('[data-editable-id="clear:body"] [contenteditable="true"]')?.textContent === "");
await waitForCleared();
await clearEditor.press("ControlOrMeta+z");
await page.waitForFunction(() => document.querySelector('[data-editable-id="clear:body"]').textContent.includes("Text that can be cleared."));
await clearEditor.press("ControlOrMeta+Shift+z");
await waitForCleared();
await saveReportEdits(page);
await page.waitForFunction(() => Object.values(localStorage).some((value) =>
JSON.parse(value).presentation?.textEdits?.["clear:body"] === ""));
await page.reload({ waitUntil: "load" });
assert.equal(await block(page, "clear-text").locator(".rich-narrative-content").innerText(), "");
assert.equal(await nestedMiddle.locator("code, pre").count(), 0, "An unselected nested remainder must stay a list after save/reload");
assert.equal(await nestedMiddle.getByText("Nested third", { exact: true }).evaluate((node) => node.tagName), "LI");
assert.equal(await nestedParent.getByRole("heading", { name: "Parent", exact: true }).count(), 1);
assert.deepEqual(await nestedParent.locator(".rich-narrative-content li").allTextContents(),
["Child one", "Child two", "After"], "Child list structure survives parent conversion and save/reload");
await assertChecklists(page);
const hosted = await browser.newPage({ viewport: { width: 1100, height: 850 } });
hosted.on("pageerror", (error) => errors.push(error.message));
let hostedSnapshot = { ...snapshot, includeLead: false };
const record = { canEdit: false, revision: 0, presentation: {} };
const writes = [];
await hosted.route("https://report.chatgpt.site/**", async (route) => {
const { pathname } = new URL(route.request().url());
if (pathname === "/api/presentation" && route.request().method() === "PUT") {
const change = JSON.parse(route.request().postData());
writes.push(change);
record.presentation = change.presentation;
record.revision = change.revision + 1;
}
const value = pathname === "/api/snapshot" ? hostedSnapshot : pathname === "/api/presentation" ? record : undefined;
await route.fulfill(value ? { contentType: "application/json", body: JSON.stringify(value) }
: { contentType: "text/html", body: compiledHtml });
});
await hosted.goto("https://report.chatgpt.site/", { waitUntil: "load" });
await block(hosted, "single-evidence").waitFor();
assert.equal(await block(hosted, "report-executive-summary").count(), 0, "A report may omit a summary entirely");
assert.equal(await hosted.getByRole("button", { name: "Edit text and layout", exact: true }).count(), 0);
assert.equal(await hosted.locator('[contenteditable="true"]').count(), 0);
await assertChecklists(hosted);
drawer = await openSource(hosted, "single-evidence");
await closeSource(drawer);
assert.deepEqual(writes, [], "Viewer source inspection must not write shared presentation");
record.canEdit = true;
hostedSnapshot = { ...snapshot };
await hosted.reload({ waitUntil: "load" });
await hosted.getByRole("button", { name: "Edit text and layout", exact: true }).click();
const saved = hosted.waitForResponse((response) => response.url().endsWith("/api/presentation")
&& response.request().method() === "PUT" && response.ok());
await block(hosted, "clear-text").getByRole("textbox", { name: "Clear finding" }).fill("");
await saveReportEdits(hosted);
await saved;
assert.equal(record.presentation.textEdits["clear:body"], "");
await hosted.getByRole("button", { name: "Edit text and layout", exact: true }).click();
const hiddenSaved = hosted.waitForResponse((response) => response.url().endsWith("/api/presentation")
&& response.request().method() === "PUT" && response.ok());
await action(hosted, "report-executive-summary", "Hide");
await saveReportEdits(hosted);
await hiddenSaved;
assert.ok(record.presentation.hiddenBlocks.includes("report-executive-summary"));
record.canEdit = false;
await hosted.reload({ waitUntil: "load" });
await block(hosted, "single-evidence").waitFor();
assert.equal(await block(hosted, "report-executive-summary").count(), 0, "Viewers receive the owner's hidden-section state");
assert.equal(await hosted.getByRole("button", { name: "Edit text and layout", exact: true }).count(), 0);
assert.equal(await block(hosted, "clear-text").locator(".rich-narrative-content").innerText(), "");
assert.deepEqual(errors, [], "Generic report composition must not emit browser errors");
console.log(JSON.stringify({ status: "passed", checks: ["optional summary", "custom summary title", "hide/reload/restore",
"rich narrative persistence", "clear/undo/redo/reload", "read-only checklists", "list-to-heading conversion", "legacy custom headings",
"editorial links in read/edit modes", "Markdown heading geometry and editing", "prose-only mobile source actions",
"scoped multi-source inspection", "source state reset",
"grouped clipboard", "single-source inspection", "hosted owner/viewer"] }, null, 2));
} finally {
await browser?.close();
rmSync(project, { recursive: true, force: true });
}
SHA-256: 9bcf9909d41810a76678eb6b5b44bcd8ba0673b730fcab78c4e0623d09c628ed