← Files DataARCHIVED FILE
tests/data-report-visual-defaults-browser.smoke.mjs
21.2 KB · Sep 30, 2026 · 23:19 UTC
import assert from "node:assert/strict";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { chromium } from "playwright-core";
import { resolveChromiumExecutable, runDataAppFixtureBuild } from "./browser-helpers.mjs";
import { buildDeliveryDiagnostic } from "../templates/data-app/base/examples/reports/delivery-diagnostic/build.mjs";
const example = buildDeliveryDiagnostic({ buildProject(project, options) {
const reportPath = join(project, "src/content/report/ReportContent.jsx");
const report = readFileSync(reportPath, "utf8");
const closingArticle = " </article>;";
assert.equal(report.split(closingArticle).length, 2, "The explicit-card fixture is inserted once into the temporary report");
const explicitCards = `
<DataComponent id="shared-card-chart" title="Shared card" kind="chart" variant="card"
queryId="weekly" sourceRows={reviewedRows("weekly")}>
<div className="shared-card-content">Shared card fixture</div>
</DataComponent>
<DataComponent id="shared-card-reference" title="Shared card" kind="custom" variant="card"
queryId="weekly" sourceRows={reviewedRows("weekly")}>
<div className="shared-card-content">Shared card fixture</div>
</DataComponent>
`;
writeFileSync(reportPath, report.replace(closingArticle, explicitCards + closingArticle));
return runDataAppFixtureBuild(project, options);
} });
const browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
const errors = [];
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1, `${message}: ${actual} vs ${expected}`);
async function assertChartMappingContract() {
// Exercise real rendered marks, runtime validation after a local revision,
// and recovery. These are synthetic rows, not customer operational data.
const fields = ["Site A", "Site B"];
const stages = ["Stage A", "Stage B", "Stage C", "Stage D", "Stage E"];
const rows = [10, 18, 14].map((value, index) => ({
period: `Period ${index + 1}`,
...Object.fromEntries(stages.map((field, fieldIndex) => [field, value + fieldIndex * 3])),
"Site A": value, "Site B": value + 5,
}));
const cases = [
{ id: "wide-line", spec: { type: "line", x: "period", y: fields[0], fields }, series: 2 },
{ id: "wide-bar", spec: { type: "bar", x: "period", y: fields[0], fields }, bars: 6 },
{ id: "wide-stack", spec: { type: "stackedBar", x: "period", y: stages[0], fields: stages }, bars: 15 },
{ id: "scalar-line", spec: { type: "line", x: "period", y: fields[0] }, series: 1 },
{ id: "scalar-waterfall", spec: { type: "waterfall", x: "period", y: fields[0] }, bars: 3 },
];
const mappingExample = buildDeliveryDiagnostic({ buildProject(project, options) {
const dataPath = join(project, "src/data.json");
const data = JSON.parse(readFileSync(dataPath, "utf8"));
data.id = "synthetic-chart-mapping-contract";
data.title = "Synthetic chart mapping contract";
data.queries = { synthetic: { label: "Synthetic evidence", rows } };
writeFileSync(dataPath, JSON.stringify(data));
writeFileSync(join(project, "src/content/report/ReportContent.jsx"), `
import React, { useState } from "react";
import { ChartRenderer, DataComponent, useDataApp } from "../../data-app-public.jsx";
const cases = ${JSON.stringify(cases)};
export function ReportContent() {
const { reviewedRows } = useDataApp();
const [mapping, setMapping] = useState("valid");
const rows = reviewedRows("synthetic");
return <article className="report-content">
<h1>Synthetic chart mapping contract</h1>
<label>Chart mapping <select aria-label="Chart mapping" value={mapping} onChange={event => setMapping(event.target.value)}>
<option value="valid">Valid mapping</option>
<option value="array">Array y</option>
<option value="nested">Nested fields</option>
</select></label>
{cases.map(fixture => {
const fields = fixture.spec.fields ?? [fixture.spec.y];
const spec = mapping === "array" ? { ...fixture.spec, fields: undefined, y: fields }
: mapping === "nested" ? { ...fixture.spec, fields: [fields] }
: fixture.spec;
return <DataComponent key={fixture.id} id={fixture.id} title={fixture.id} kind="chart"
queryId="synthetic" sourceRows={rows} chart={spec}>
<ChartRenderer chartId={fixture.id} spec={spec} rows={rows} height={300} />
</DataComponent>;
})}
</article>;
}
`);
return runDataAppFixtureBuild(project, options);
} });
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
page.on("pageerror", (error) => errors.push(error.message));
try {
await page.goto(pathToFileURL(mappingExample.html).href);
async function assertPaintedMarks() {
for (const fixture of cases) {
const chart = page.locator(`[data-component-id="${fixture.id}"]`);
await chart.scrollIntoViewIfNeeded();
await page.waitForFunction(({ id, series, bars }) => {
const chart = document.querySelector(`[data-component-id="${id}"]`);
const painted = selector => [...chart.querySelectorAll(selector)].filter(mark => {
const box = mark.getBoundingClientRect();
return box.width > 0 && box.height > 0 && getComputedStyle(mark).visibility !== "hidden";
}).length;
return series ? painted(".recharts-line-curve") === series
: painted(".recharts-bar-rectangle .recharts-rectangle") >= bars;
}, fixture);
assert.equal(await chart.getByRole("alert").count(), 0, `${fixture.id}: valid evidence is not a configuration error`);
}
}
await assertPaintedMarks();
const line = page.locator('[data-component-id="wide-line"]');
await line.scrollIntoViewIfNeeded();
const midpoint = await line.locator(".recharts-line-curve").first().evaluate(path => {
const point = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM());
return { x: point.x, y: point.y };
});
await page.mouse.move(midpoint.x, midpoint.y);
const tooltip = line.locator(".chart-tooltip");
await tooltip.waitFor({ state: "visible" });
assert.match(await tooltip.innerText(), /Site A\s*18/u, "The first line displays its reviewed value");
assert.match(await tooltip.innerText(), /Site B\s*23/u, "The second line displays its own reviewed value");
await page.mouse.move(0, 0);
for (const mapping of ["array", "nested"]) {
await page.getByLabel("Chart mapping", { exact: true }).selectOption(mapping);
for (const fixture of cases) {
const chart = page.locator(`[data-component-id="${fixture.id}"]`);
const diagnostic = chart.locator('[role="alert"][data-chart-config-error="true"]');
await diagnostic.waitFor();
assert.match(await diagnostic.innerText(), new RegExp(fixture.id), "The diagnostic identifies the affected chart");
assert.match(await diagnostic.innerText(), mapping === "array" ? /single field|fields/iu : /chart\.fields|field names/iu,
"A malformed mapping explains its correction instead of silently painting a blank chart");
assert.equal(await chart.locator(".recharts-line-curve, .recharts-bar-rectangle .recharts-rectangle").count(), 0,
"Invalid mappings cannot leave misleading marks from the previous render");
}
await page.getByLabel("Chart mapping", { exact: true }).selectOption("valid");
await assertPaintedMarks();
}
} finally {
await page.close();
rmSync(mappingExample.project, { recursive: true, force: true });
}
}
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(pathToFileURL(example.html).href);
await page.locator(".recharts-surface").first().waitFor();
for (const width of [1280, 748, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme });
await page.waitForFunction(() => [...document.querySelectorAll(".chart-frame")].every((frame) => {
const svg = frame.querySelector("svg.recharts-surface");
return svg && Math.abs(svg.getBoundingClientRect().width - frame.getBoundingClientRect().width) < 1;
}));
const geometry = await page.evaluate(() => {
const rect = (element) => {
const { x, y, width, height, right, bottom } = element.getBoundingClientRect();
return { x, y, width, height, right, bottom };
};
const article = document.querySelector(".report-content");
const heading = article.querySelector("h1");
const paragraph = article.querySelector(".rich-narrative-content p");
const titleStyle = (node) => {
const style = getComputedStyle(node);
return { size: style.fontSize, line: style.lineHeight, weight: style.fontWeight, tracking: style.letterSpacing };
};
const sharedTitleProbe = document.createElement("span");
sharedTitleProbe.style.cssText = "position:absolute;visibility:hidden;font-size:var(--text-md-size);"
+ "line-height:var(--text-md-line);font-weight:500;letter-spacing:var(--text-title-3-tracking)";
article.append(sharedTitleProbe);
const sharedTitle = titleStyle(sharedTitleProbe);
sharedTitleProbe.remove();
return {
sharedTitle,
pageWidth: rect(article.closest(".dashboard-root")).width,
width: rect(article).width, headingSize: getComputedStyle(heading).fontSize,
dateTicks: [...article.querySelector('[data-component-kind="chart"]')
.querySelectorAll('.recharts-xAxis-tick-labels text')].map((node) => ({ text: node.textContent, ...rect(node) })),
headingWeight: getComputedStyle(heading).fontWeight, bodyLine: getComputedStyle(paragraph).lineHeight,
overflow: document.documentElement.scrollWidth > innerWidth,
narratives: [...article.querySelectorAll('[data-component-kind="narrative"]')].map((node) => ({
border: getComputedStyle(node).borderTopWidth, shadow: getComputedStyle(node).boxShadow,
})),
cards: [...article.querySelectorAll('[data-component-kind="chart"]:not([data-component-variant="card"])')].map((node) => {
const style = getComputedStyle(node);
const frame = node.querySelector(".chart-frame");
const header = node.querySelector(".component-header");
const menu = [...header.querySelectorAll(".menu-trigger")]
.find(control => control.getBoundingClientRect().width > 0);
const svg = frame.querySelector("svg.recharts-surface");
const bounds = rect(svg);
return {
rect: rect(node), article: rect(article), frame: rect(frame), header: rect(header), menu: rect(menu),
menuPosition: getComputedStyle(menu).position,
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
radius: style.borderTopLeftRadius, border: style.borderTopWidth, shadow: style.boxShadow,
clippedText: [...svg.querySelectorAll(".chart-annotation-label text, .recharts-cartesian-axis-tick-label text")]
.filter((text) => { const box = rect(text); return box.x < bounds.x - 1 || box.right > bounds.right + 1
|| box.y < bounds.y - 1 || box.bottom > bounds.bottom + 1; }).map((text) => text.textContent),
marks: [...svg.querySelectorAll(".recharts-bar-rectangle path, .recharts-line-curve")].length,
};
}),
sharedCards: [...article.querySelectorAll('[data-component-id^="shared-card-"][data-component-variant="card"]')]
.map((node) => {
const style = getComputedStyle(node);
const bounds = rect(node);
const content = rect(node.querySelector(".shared-card-content"));
const header = node.querySelector(".component-header");
const menu = node.querySelector(".menu-trigger");
return {
width: bounds.width, contentWidth: content.width, contentLeft: content.x - bounds.x,
height: bounds.height, contentTop: content.y - bounds.y,
headerPadding: getComputedStyle(header).paddingInlineEnd,
headerMargin: getComputedStyle(header).marginBottom,
title: titleStyle(node.querySelector(".component-title")),
menuTop: rect(menu).y - rect(header).y, menuRight: bounds.right - rect(menu).right,
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
radius: style.borderTopLeftRadius, border: style.borderTopWidth,
borderColor: style.borderTopColor, background: style.backgroundColor, shadow: style.boxShadow,
};
}),
links: [...article.querySelectorAll(".report-task-link")].map((node) => {
const bullet = node.previousElementSibling.querySelector("li");
const range = document.createRange();
range.selectNodeContents(bullet);
return { left: rect(node).x, textLeft: range.getBoundingClientRect().left,
size: getComputedStyle(node).fontSize, arrow: rect(node.querySelector(".dashboard-icon")),
gap: rect(node).y - rect(node.previousElementSibling).bottom };
}),
};
});
close(geometry.width, Math.min(748, geometry.pageWidth - (width <= 640 ? 40 : 72)), "Actual usable report width");
assert.equal(geometry.headingSize, width <= 640 ? "32px" : "44px");
assert.equal(geometry.headingWeight, "600");
close(parseFloat(geometry.bodyLine), 25.6, "Readable body leading");
assert.equal(geometry.overflow, false, `No page overflow at ${width}/${scheme}`);
assert.equal(geometry.cards.length, 3);
assert.equal(geometry.sharedCards.length, 2);
assert.deepEqual(geometry.sharedCards[0], geometry.sharedCards[1],
`Explicit chart cards retain the shared card geometry and theme instead of report-default chart CSS at ${width}/${scheme}`);
for (const card of geometry.sharedCards) assert.deepEqual(card.title, geometry.sharedTitle,
"Explicit cards use shared title tokens and weight, not report-specific heading typography");
const weeks = ["Jun 22", "Jun 29", "Jul 6", "Jul 13", "Jul 20", "Jul 27", "Aug 3", "Aug 10"];
const positions = geometry.dateTicks.map(({ text }) => weeks.indexOf(text));
assert.ok(positions.length >= 2 && positions.every((index) => index >= 0), "Date labels use actual reviewed weeks");
const stride = positions[1] - positions[0];
assert.ok(stride > 0 && positions.slice(1).every((index, i) => index - positions[i] === stride),
`Date labels keep a consistent week interval at ${width}/${scheme}: ${JSON.stringify(geometry.dateTicks)}`);
if (width >= 748) assert.deepEqual(geometry.dateTicks.map(({ text }) => text), weeks,
"Roomy report charts show all eight weeks rather than skipping arbitrary dates");
for (let index = 1; index < geometry.dateTicks.length; index++) {
assert.ok(geometry.dateTicks[index].x - geometry.dateTicks[index - 1].right >= 8,
"Responsive date labels retain breathing room instead of overlapping");
}
assert.ok(geometry.narratives.every((node) => node.border === "0px" && node.shadow === "none"));
for (const card of geometry.cards) {
close(card.rect.x, card.article.x, "Chart and narrative left edges");
close(card.rect.width, card.article.width, "Chart and narrative widths");
assert.deepEqual(card.padding, width <= 640 ? ["16px", "12px", "16px", "12px"] : Array(4).fill("24px"));
assert.equal(card.radius, "24px");
assert.equal(card.border, "1px");
assert.notEqual(card.shadow, "none");
close(card.frame.height, 300, "Chart content, not full card, is 300px");
assert.ok(card.rect.height > 300);
close(card.menu.right, card.header.right, `Menu stays on inner padding edge ${JSON.stringify({ width, scheme, card })}`);
close(card.menu.y - card.rect.y, card.rect.right - card.menu.right, "Menu top and right clearances match");
close(card.rect.right - card.menu.right, parseFloat(card.padding[1]) + 1, "Menu right clearance matches card padding");
assert.deepEqual(card.clippedText, [], `No clipped labels at ${width}/${scheme}`);
assert.ok(card.marks > 0, `Chart data renders at ${width}/${scheme}`);
}
assert.equal(geometry.links.length, 2);
for (const link of geometry.links) {
close(link.left, link.textLeft, "Task aligns with bullet text");
close(link.gap, 12, "Prose to task spacing");
assert.equal(link.size, "16px");
close(link.arrow.width, 20, "Bundled arrow width");
close(link.arrow.height, 20, "Bundled arrow height");
}
}
}
await page.setViewportSize({ width: 1280, height: 900 });
const disclosure = page.getByRole("button", { name: "Evidence and alternative explanations", exact: true });
assert.equal(await disclosure.getAttribute("aria-expanded"), "false");
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
assert.equal(await page.locator(".report-task-link").count(), 0);
assert.equal(await disclosure.getAttribute("aria-expanded"), "false", "Edit mode must not open evidence automatically");
await disclosure.click();
assert.equal(await disclosure.getAttribute("aria-expanded"), "true");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assert.equal(await disclosure.getAttribute("aria-expanded"), "true");
await disclosure.click();
const chart = page.locator('[data-component-id="delivery-bridge"]');
await chart.getByRole("button", { name: / actions$/u }).click();
await page.getByRole("menuitem", { name: "View data source", exact: true }).click();
const drawer = page.getByRole("complementary", { name: /Data source for/u });
await drawer.waitFor();
await drawer.getByRole("button", { name: "Close data source", exact: true }).click();
// The chart editor is portaled outside .report-page and must inherit the same
// contextual bar color, including in dark mode.
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme });
await page.waitForFunction((expected) => getComputedStyle(document.querySelector(
'[data-component-id="delivery-bridge"] .recharts-bar-rectangle path')).fill === expected,
scheme === "dark" ? "rgb(146, 146, 146)" : "rgb(212, 212, 212)");
const comparisonFill = await chart.locator(".recharts-bar-rectangle path").first()
.evaluate((node) => getComputedStyle(node).fill);
await chart.getByRole("button", { name: / actions$/u }).click();
await page.getByRole("menuitem", { name: "Edit chart", exact: true }).click();
const dialog = page.getByRole("dialog");
// Recharts can replace the initial mark while the preview settles. Read the
// current mark and its color together instead of retaining a detached node.
await page.waitForFunction((expected) => {
const preview = document.querySelector('[role="dialog"] .explorer-chart[aria-busy="false"]');
const mark = preview?.querySelector(".recharts-bar-rectangle path");
return mark && getComputedStyle(mark).fill === expected;
}, comparisonFill);
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden" });
}
// Editing a recommendation out of task bounds must never blank the report.
const recommendation = page.locator('[data-component-id="delivery-packing-test"]');
for (const text of ["A".repeat(4001)]) {
await page.getByRole("button", { name: "Edit text and layout", exact: true }).click();
const editor = recommendation.locator('.report-rich-editable[contenteditable="true"]');
await editor.fill(text);
await editor.blur();
await page.getByRole("button", { name: "Save", exact: true }).click();
await page.waitForFunction((length) => {
const narrative = document.querySelector('[data-component-id="delivery-packing-test"] .rich-narrative-content');
return narrative && narrative.textContent.trim().length === length;
}, text.length);
assert.equal(await recommendation.locator(".report-task-link").count(), 0, `Invalid saved recommendation (${text.length} characters) has no task link`);
assert.equal(await page.locator(".report-content h1").isVisible(), true);
assert.equal(await page.locator('[data-component-id="delivery-pickup-comparison"] .report-task-link').count(), 1);
}
await page.emulateMedia({ media: "print" });
assert.equal(await page.locator(".report-task-link").first().isVisible(), false);
assert.equal(await page.locator('[id="delivery-evidence:details"]').isVisible(), true);
assert.equal(await page.locator('[id="delivery-scope:details"]').isVisible(), true);
await assertChartMappingContract();
assert.deepEqual(errors, []);
console.log("Report visual defaults passed: 748px, chart cards, 300px content, light/dark/mobile, task spacing, edit/disclosure/source/print, chart mapping diagnostics and rendered-mark recovery.");
} finally {
await browser.close();
rmSync(example.project, { recursive: true, force: true });
}
SHA-256: 8555f8af6ddf0a7965dea7275c799e48d39a32b828ae72baa35cbb4a8378ad8f