← Files DataARCHIVED FILE

tests/data-app-feature-coverage-browser.smoke.mjs

20.1 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

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 { installDashboardBrowserMocks, resolveChromiumExecutable, runDataAppFixtureBuild } from "./browser-helpers.mjs";
import { verifySeriesVisibility } from "./data-app-browser-series-visibility.mjs";
import { stackedGeometryCharts, verifyStackedGeometry } from "./data-app-browser-stacked-geometry.mjs";

const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(pluginRoot, "templates/data-app/base");
const project = mkdtempSync(join(tmpdir(), "data-feature-coverage-smoke-"));
const trendRows = [10, 30, 20, 40].map((amount, step) => ({ step, amount }));
const charts = [
  ...stackedGeometryCharts,
  {
    id: "coverage-area",
    title: "Area trend",
    spec: { type: "area", x: "step", y: "amount", showValues: true, showLegend: false },
    rows: trendRows,
  },
  {
    id: "coverage-horizontal-stack-100",
    title: "Normalized horizontal stacks",
    spec: {
      type: "horizontalStackedBar100",
      x: "cohort",
      y: "core",
      fields: ["core", "addon"],
      showXAxisLabel: false,
      showYAxisLabel: false,
    },
    rows: [
      { cohort: "North", core: 10, addon: 30 },
      { cohort: "South", core: 60, addon: 20 },
    ],
  },
  {
    id: "coverage-histogram",
    title: "Latency distribution",
    spec: { type: "histogram", y: "latencyMs", showValues: true, showYAxisLabel: false },
    rows: [101, 105, 109, 111, 119, 121].map((latencyMs) => ({ latencyMs })),
  },
  {
    id: "coverage-sparkline",
    title: "ChartRenderer sparkline",
    spec: { type: "sparkline", x: "step", y: "amount", showValues: true, showLegend: false },
    rows: trendRows,
  },
  {
    id: "coverage-funnel",
    title: "Activation funnel",
    spec: { type: "funnel", x: "stage", y: "accounts", showLegend: false },
    rows: [
      { stage: "Visited", accounts: 100 },
      { stage: "Signed up", accounts: 60 },
      { stage: "Activated", accounts: 30 },
    ],
  },
  {
    id: "coverage-sankey",
    title: "Aggregated account flows",
    spec: { type: "sankey", source: "origin", target: "destination", y: "users", showLegend: false },
    rows: [
      { origin: "Signup", destination: "Activation", users: 5 },
      { origin: "Signup", destination: "Activation", users: 3 },
      { origin: "Signup", destination: "Churn", users: 2 },
    ],
  },
];
const tableScores = [10, 2, 18, 4, 16, 6, 14, 8, 12, 1, 17, 3, 15, 5, 13, 7, 11, 9];
const tableRows = tableScores.map((score) => ({
  account: `Account ${score}`,
  region: score % 2 === 0 ? "East" : "West",
  score,
}));

function closeTo(actual, expected, message, tolerance = 0.01) {
  assert.ok(
    Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance,
    `${message}: expected ${expected}, received ${actual}`,
  );
}

async function geometry(marks) {
  return marks.evaluateAll((elements) =>
    elements.map((element) => {
      const bounds = element.getBBox();
      return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
    }),
  );
}

let browser;
try {
  cpSync(template, project, {
    recursive: true,
    filter: (path) => !path.includes("/node_modules") && !path.includes("/dist"),
  });
  const snapshot = JSON.parse(readFileSync(join(project, "src/data.json"), "utf8"));
  snapshot.title = "Synthetic feature-coverage dashboard";
  snapshot.filters = [];
  for (const { id, title, rows } of [...charts, { id: "coverage-table", title: "Account records", rows: tableRows }]) {
    snapshot.queries[id] = {
      rows,
      source: { label: `Synthetic browser fixture: ${title}`, sql: "-- deterministic regression fixture", tables: [] },
    };
  }
  writeFileSync(join(project, "src/data.json"), `${JSON.stringify(snapshot, null, 2)}\n`);
  writeFileSync(
    join(project, "src/content/dashboard/DashboardContent.jsx"),
    `
import React from "react";
import { ChartRenderer, DataComponent, DataTable, useDataApp } from "../../data-app-public.jsx";

const charts = ${JSON.stringify(charts.map(({ id, title, spec }) => ({ id, title, spec })))};

export function DashboardContent() {
  const { reviewedRows } = useDataApp();
  const tableRows = reviewedRows("coverage-table");
  return <article className="feature-coverage-page">
    <div className="feature-coverage-grid">
      {charts.map(({ id, title, spec }) => {
        const rows = reviewedRows(id);
        return <DataComponent key={id} id={id} title={title} queryId={id} kind="chart"
          chart={spec} displayRows={rows} sourceRows={rows} className="feature-coverage-card">
          <ChartRenderer spec={spec} rows={rows} height={290} />
        </DataComponent>;
      })}
    </div>
    <DataComponent id="coverage-table" title="Account records" queryId="coverage-table" kind="table"
      displayRows={tableRows} sourceRows={tableRows} className="feature-coverage-card">
      <DataTable rows={tableRows} compactNumbers={false} columns={[
        { field: "account", label: "Account" },
        { field: "region", label: "Region" },
        { field: "score", label: "Score" },
      ]} />
    </DataComponent>
  </article>;
}
`,
  );
  writeFileSync(
    join(project, "src/content/dashboard/dashboard.css"),
    `
.feature-coverage-page { display: grid; gap: 20px; padding: 24px; }
.feature-coverage-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.feature-coverage-card { min-width: 0; padding: 20px; border: 1px solid var(--border);
  border-radius: var(--card-radius); background: var(--surface); }
@media (max-width: 650px) { .feature-coverage-grid { grid-template-columns: 1fr; } }
`,
  );

  const build = runDataAppFixtureBuild(project, { pluginRoot });
  assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
  browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
  const page = await browser.newPage({
    viewport: { width: 1440, height: 1100 },
    locale: "en-US",
    colorScheme: "light",
  });
  const failures = [];
  const networkRequests = [];
  page.on("pageerror", (error) => failures.push(error.message));
  await page.route(/^https?:\/\//u, (route) => {
    networkRequests.push(route.request().url());
    return route.abort();
  });
  await installDashboardBrowserMocks(page);
  await page.goto(pathToFileURL(join(project, "dist/index.html")).href, { waitUntil: "load" });
  const component = (id) => page.locator(`[data-component-id="${id}"]`);

  const area = component("coverage-area");
  const areaFill = area.locator(".recharts-area-area");
  await areaFill.waitFor({ state: "visible" });
  assert.equal(await areaFill.count(), 1);
  assert.equal(await areaFill.getAttribute("fill-opacity"), "0.18");
  assert.equal(await area.locator(".recharts-area-curve").count(), 1);
  assert.equal(await area.locator(".recharts-area-curve").getAttribute("stroke-width"), "2");
  assert.equal(await area.locator(".recharts-line-curve").count(), 0, "Area must use its filled chart branch");
  const [areaBounds] = await geometry(areaFill);
  assert.ok(areaBounds.width > 0 && areaBounds.height > 0, "The area fill must have nonzero rendered geometry");
  assert.deepEqual(await area.locator(".recharts-label-list text").allTextContents(), ["10", "30", "20", "40"]);

  const normalized = component("coverage-horizontal-stack-100");
  const segments = await geometry(normalized.locator('g[data-stack-sign="positive"] > path[clip-path]'));
  assert.equal(segments.length, 4, "Each cohort must retain both positive stacked measures");
  const stackRows = [];
  for (const segment of segments.sort((left, right) => left.y - right.y || left.x - right.x)) {
    const row = stackRows.find((marks) => Math.abs(marks[0].y - segment.y) < 0.1);
    if (row) row.push(segment);
    else stackRows.push([segment]);
  }
  assert.equal(stackRows.length, 2);
  const totals = stackRows.map((row) => row.reduce((total, mark) => total + mark.width, 0));
  assert.ok(totals.every((total) => total > 0));
  closeTo(totals[0], totals[1], "Unequal raw cohort totals must occupy the same normalized width");
  for (const [index, row] of stackRows.entries()) {
    assert.equal(row.length, 2);
    closeTo(row[0].x + row[0].width, row[1].x, "Adjacent stack segments must meet");
    closeTo(row[0].width / totals[index], index === 0 ? 0.25 : 0.75, "Core share must be normalized per cohort");
    closeTo(row[1].width / totals[index], index === 0 ? 0.75 : 0.25, "Add-on share must be normalized per cohort");
  }
  const percentTicks = (
    await normalized.locator(".recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value").allTextContents()
  ).map((text) => text.replace(/\s+/gu, ""));
  assert.ok(
    percentTicks.includes("0%") && percentTicks.includes("100%"),
    `The normalized horizontal axis must display percentage endpoints: ${JSON.stringify(percentTicks)}`,
  );

  const histogram = component("coverage-histogram");
  const bins = (await geometry(histogram.locator(".recharts-bar-rectangle path"))).sort(
    (left, right) => left.x - right.x,
  );
  assert.equal(bins.length, 3);
  assert.deepEqual(
    await histogram.locator(".recharts-xAxis-tick-labels text[data-axis-layout] > title").allTextContents(),
    ["100–110", "110–120", "120–130"],
    "Raw latency rows must enter their expected automatic bins",
  );
  assert.deepEqual(await histogram.locator(".recharts-label-list text").allTextContents(), ["3", "2", "1"]);
  for (const [index, bin] of bins.entries()) {
    assert.ok(bin.width > 0 && bin.height > 0);
    closeTo(bin.width, bins[0].width, "Histogram bins must have equal widths");
    closeTo(bin.y + bin.height, bins[0].y + bins[0].height, "Histogram bars must share the zero baseline");
    closeTo(bin.height / bins[2].height, 3 - index, "Histogram height must encode the observed count");
  }

  const sparkline = component("coverage-sparkline");
  const sparklineCurve = sparkline.locator(".recharts-line-curve");
  assert.equal(await sparklineCurve.count(), 1);
  assert.equal(await sparklineCurve.getAttribute("stroke-width"), "2.25");
  const [sparklineBounds] = await geometry(sparklineCurve);
  assert.ok(sparklineBounds.width > 0 && sparklineBounds.height > 0);
  assert.deepEqual(await sparkline.locator(".recharts-label-list text").allTextContents(), ["10", "30", "20", "40"]);
  assert.equal(
    await sparkline
      .locator(".recharts-cartesian-axis, .recharts-cartesian-grid, .chart-axis-label, .recharts-area-area")
      .count(),
    0,
    "The ChartRenderer sparkline must omit axes, grids, and area fill",
  );

  const funnel = component("coverage-funnel");
  const funnelStages = funnel.getByRole("list", { name: "Funnel stages" }).getByRole("button");
  await funnelStages.first().waitFor({ state: "visible" });
  assert.deepEqual(
    await funnelStages.evaluateAll((stages) => stages.map((stage) => ({
      name: stage.querySelector(".chart-funnel-stage-name").textContent,
      value: stage.querySelector(".chart-funnel-stage-value").textContent,
      share: stage.querySelector(".chart-funnel-stage-share").textContent,
    }))),
    [
      { name: "Visited", value: "100", share: "100%" },
      { name: "Signed up", value: "60", share: "60%" },
      { name: "Activated", value: "30", share: "30%" },
    ],
    "Funnel stages must preserve reviewed order, counts, and share of the first stage",
  );
  const [ribbonBounds] = await geometry(funnel.locator(".chart-funnel-band"));
  assert.ok(ribbonBounds?.width > 0 && ribbonBounds.height > 0, "The funnel ribbon must render nonzero geometry");
  const stageBounds = await funnelStages.evaluateAll((stages) => stages.map((stage) => {
    const { x, width, height } = stage.getBoundingClientRect();
    return { x, width, height };
  }));
  for (const [index, bounds] of stageBounds.entries()) {
    assert.ok(bounds.width > 0 && bounds.height > 0);
    if (index) assert.ok(bounds.x >= stageBounds[index - 1].x + stageBounds[index - 1].width);
  }
  await funnelStages.nth(2).hover();
  const tooltip = funnel.locator(".chart-tooltip");
  await tooltip.waitFor({ state: "visible" });
  assert.equal(await tooltip.locator("strong").innerText(), "Activated");
  assert.match(await tooltip.innerText(), /Signed up → Activated/u);
  assert.match(await tooltip.innerText(), /Drop-off/u);
  assert.deepEqual(await tooltip.locator("b").allTextContents(), ["30", "50%", "−30"],
    "Hover must show the exact count, conversion from the previous stage (not 30% of total), and signed drop-off");
  await page.mouse.move(0, 0);

  const sankey = component("coverage-sankey");
  const nodes = await sankey.locator(".chart-sankey-node").evaluateAll((elements) =>
    elements.map((element) => {
      const rect = element.querySelector("rect").getBBox();
      return { label: element.getAttribute("aria-label"), x: rect.x, height: rect.height, width: rect.width };
    }),
  );
  assert.equal(nodes.length, 3);
  assert.deepEqual(
    nodes.map(({ label }) => label).sort(),
    ["Activation, 8", "Churn, 2", "Signup, 10"],
    "Sankey must aggregate duplicate edges without losing or duplicating reviewed values",
  );
  const node = (name) => nodes.find(({ label }) => label.startsWith(`${name},`));
  for (const entry of nodes) {
    assert.equal(entry.width, 12);
    assert.ok(entry.height > 0);
  }
  assert.ok(node("Signup").x < node("Activation").x && node("Signup").x < node("Churn").x);
  closeTo(node("Activation").height / node("Churn").height, 4, "Sankey terminal heights must encode the 8:2 flow");
  const links = await sankey.locator("path.chart-sankey-link-visible").evaluateAll((elements) =>
    elements.map((element) => ({
      length: element.getTotalLength(),
      width: Number(element.getAttribute("stroke-width")),
    })),
  );
  assert.equal(links.length, 2, "Duplicate reviewed edges must become one weighted Sankey link");
  assert.ok(links.every(({ length, width }) => length > 0 && width > 0));

  const table = component("coverage-table");
  const previous = table.getByRole("button", { name: "Previous page" });
  const next = table.getByRole("button", { name: "Next page" });
  const search = table.getByRole("textbox", { name: "Search data" });
  const results = table.locator('.table-pagination [aria-live="polite"]');
  async function assertRows(scores, label) {
    const rendered = await table
      .locator("tbody tr")
      .evaluateAll((rows) => rows.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent.trim())));
    assert.deepEqual(
      rendered,
      scores.map((score) => [`Account ${score}`, score % 2 === 0 ? "East" : "West", String(score)]),
      label,
    );
  }
  async function assertPage(number, count) {
    assert.equal(
      await table.locator(".table-pagination .source-value").filter({ hasText: /^Page /u }).innerText(),
      `Page ${number} of ${count}`,
    );
    assert.equal(await previous.isDisabled(), number === 1);
    assert.equal(await next.isDisabled(), number === count);
  }
  await assertRows(tableScores.slice(0, 8), "First page must preserve supplied row order");
  await assertPage(1, 3);
  await next.click();
  await assertRows(tableScores.slice(8, 16), "Next page must reveal the next eight exact records");
  await assertPage(2, 3);
  await next.click();
  await assertRows(tableScores.slice(16), "Final page must retain the two remaining records");
  await assertPage(3, 3);
  await previous.click();
  await assertRows(tableScores.slice(8, 16), "Previous page must return to the same records");
  await previous.click();
  await table.getByRole("button", { name: "Score", exact: true }).click();
  await assertRows([1, 2, 3, 4, 5, 6, 7, 8], "Numeric ascending sort must place 2 before 10");
  await next.click();
  await assertRows([9, 10, 11, 12, 13, 14, 15, 16], "Sorting must happen across all rows before pagination");
  await previous.click();
  await table.getByRole("button", { name: "Score ↑", exact: true }).click();
  await assertRows([18, 17, 16, 15, 14, 13, 12, 11], "The second header click must sort descending");
  assert.equal(await table.getByRole("button", { name: "Score ↓", exact: true }).count(), 1);
  await next.click();
  await search.fill("eAsT");
  await assertRows([18, 16, 14, 12, 10, 8, 6, 4], "Case-insensitive search must retain the active sort");
  await assertPage(1, 2);
  assert.equal(await results.innerText(), "1–8 of 9 results");
  await next.click();
  await assertRows([2], "Filtered pagination must include the final matching record");
  await assertPage(2, 2);
  await search.fill("aCcOuNt 1");
  await assertRows([18, 17, 16, 15, 14, 13, 12, 11], "Search must match the displayed account field");
  await assertPage(1, 2);
  assert.equal(await results.innerText(), "1–8 of 10 results");
  await next.click();
  await assertRows([10, 1], "Substring matches must not lose the exact Account 1 record");
  await search.fill("missing-synthetic-account");
  await assertRows([], "A search with no matches must not display stale rows");
  assert.equal(await results.innerText(), "No results");
  assert.equal(await previous.count(), 0);
  assert.equal(await next.count(), 0);
  await search.fill("");
  await assertRows([18, 17, 16, 15, 14, 13, 12, 11], "Clearing search must restore all rows and keep descending sort");
  await assertPage(1, 3);
  await table.getByRole("button", { name: "Account", exact: true }).click();
  await assertRows([1, 2, 3, 4, 5, 6, 7, 8], "Changing columns must begin with natural ascending text order");

  await page.getByRole("button", { name: "More", exact: true }).click();
  await page.getByRole("menuitem", { name: "Switch theme" }).click();
  const picker = page.getByRole("region", { name: "Theme picker" });
  await picker.getByRole("button", { name: "Appearance" }).click();
  await page.getByRole("menuitemradio", { name: "Light", exact: true }).click();
  await picker.getByRole("button", { name: "Apply Sticker pop", exact: true }).click();
  await page.waitForFunction(() => Object.values(localStorage).some((value) => {
    try {
      const presentation = JSON.parse(value)?.presentation;
      return presentation?.theme === "sticker-pop" && presentation.appearance === "light";
    } catch { return false; }
  }));
  async function assertStickerPop() {
    const state = await area.evaluate((card) => ({
      theme: document.documentElement.dataset.appTheme,
      background: getComputedStyle(document.body).backgroundColor,
      font: getComputedStyle(document.body).fontFamily,
      radius: getComputedStyle(card).borderTopLeftRadius,
      chartStroke: getComputedStyle(card.querySelector(".recharts-area-curve")).stroke,
      accent: getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(),
    }));
    assert.equal(state.theme, "sticker-pop");
    assert.equal(state.background, "rgb(255, 248, 252)");
    assert.match(state.font, /ui-rounded/u);
    assert.equal(state.radius, "24px");
    assert.equal(state.chartStroke, "rgb(255, 94, 168)");
    assert.equal(state.accent, "#7450ff");
  }
  await assertStickerPop();
  await page.reload({ waitUntil: "load" });
  await areaFill.waitFor({ state: "visible" });
  await assertStickerPop();
  assert.deepEqual(networkRequests, [], "The standalone feature fixture must not download browser dependencies");
  assert.deepEqual(failures, []);
  await verifySeriesVisibility({ browser, pluginRoot });
  await verifyStackedGeometry({ browser, url: pathToFileURL(join(project, "dist/index.html")).href });
  console.log(
    JSON.stringify({
      ok: true,
      chartTypes: charts.map(({ spec }) => spec.type),
      normalizedCohortTotals: [40, 80],
      histogramCounts: [3, 2, 1],
      sankeyAggregatedEdges: links.length,
      tableRows: tableRows.length,
      tablePages: 3,
      tableActions: [
        "next",
        "previous",
        "numeric ascending",
        "numeric descending",
        "text ascending",
        "case-insensitive search",
        "empty search",
        "clear search",
      ],
      theme: "sticker-pop",
      themePersistedAfterReload: true,
      customerBuild: "prebuilt-no-package-manager",
      networkRequests: networkRequests.length,
    }),
  );
} finally {
  await browser?.close();
  rmSync(project, { recursive: true, force: true });
}

SHA-256: eba0ae6d815853b2b2ce5011799e64170b10b8fff6c3dfd80c6a2292800c828f