← Files DataARCHIVED FILE

tests/data-app-browser-chart-interactions.mjs

14.6 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 { open } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

import { installDashboardBrowserMocks, runDataAppFixtureBuild } from "./browser-helpers.mjs";

async function numericAxisTicks(chart) {
  return chart.locator('svg text[text-anchor="end"]').allTextContents();
}

export async function verifyChartOverridePrecedence({ browser, pluginRoot }) {
  const project = mkdtempSync(join(tmpdir(), "data-chart-overrides-"));
  const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
  const page = await context.newPage();
  const errors = [];
  page.on("pageerror", (error) => errors.push(error.message));
  try {
    cpSync(join(pluginRoot, "templates/data-app/base"), project, {
      recursive: true,
      filter: (path) => !path.includes("/node_modules") && !path.includes("/dist"),
    });
    const snapshotPath = join(project, "src/data.json");
    const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8"));
    snapshot.id = "chart-override-regression";
    snapshot.title = "Chart override regression";
    snapshot.filters = [];
    snapshot.queries = {
      edited: {
        rows: [
          { week: "2026-07-06", segment: "A", activeUsers: 100, activeAccounts: 10 },
          { week: "2026-07-13", segment: "A", activeUsers: 200, activeAccounts: 30 },
        ],
        source: { label: "Synthetic chart-edit regression fixture" },
      },
    };
    writeFileSync(snapshotPath, `${JSON.stringify(snapshot)}\n`);
    // Reproduce the generated app's mistake at both the plot and metadata:
    // reading saved settings, then forcing the original design back over them.
    writeFileSync(join(project, "src/content/dashboard/DashboardContent.jsx"), `
import React from "react";
import { Chart, ChartRenderer, DataComponent, useDataApp } from "../../data-app-public.jsx";
const defaults = { type: "line", x: "week", y: "activeUsers", fields: ["activeUsers"],
  series: "segment", xLabel: "Original week", yLabel: "Original users", showLegend: true,
  colors: { A: "#112233" } };
export function DashboardContent() {
  const { chartOverrides, chartProps, reviewedRows } = useDataApp();
  const rows = reviewedRows("edited");
  return <section>{[["renderer", ChartRenderer], ["alias", Chart]].map(([id, Plot]) => {
    const chart = { ...(chartOverrides[id] ?? defaults), ...defaults };
    return <DataComponent key={id} id={id} queryId="edited" kind="chart" title={id}
      chart={chart} displayRows={rows}>
      <Plot {...chartProps(id)} spec={chart} rows={rows} height={240} />
    </DataComponent>;
  })}</section>;
}
`);
    const build = runDataAppFixtureBuild(project, { pluginRoot });
    assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
    await installDashboardBrowserMocks(page);
    await page.goto(pathToFileURL(join(project, "dist/index.html")).href, { waitUntil: "load" });

    async function openEditor(id) {
      await page.getByRole("button", { name: `${id} actions`, exact: true }).click();
      await page.getByRole("menuitem", { name: "Edit chart", exact: true }).click();
      const editor = page.getByRole("dialog", { name: id, exact: true });
      await editor.locator('.explorer-chart[data-ready="true"]').waitFor();
      return editor;
    }
    async function select(editor, control, choice) {
      await editor.getByRole("button", { name: control, exact: true }).click();
      await page.getByRole("menuitemradio", { name: choice, exact: true }).click();
    }
    async function closeEditor(editor, action) {
      await editor.getByRole("button", { name: action, exact: true }).click();
      await editor.waitFor({ state: "hidden" });
    }
    async function assertSavedChart(id) {
      const chart = page.locator(`[data-component-id="${id}"]`);
      await chart.locator(".recharts-bar-rectangle").first().waitFor();
      assert.equal(await chart.locator(".recharts-line-curve").count(), 0);
      assert.equal(await chart.locator(".chart-legend").count(), 0);
      await chart.getByText("Edited accounts", { exact: true }).waitFor();
      for (const [index, value] of ["10", "30"].entries()) {
        await chart.locator(".recharts-bar-rectangle").nth(index).hover();
        const tooltip = chart.locator(".chart-tooltip");
        await tooltip.getByText(value, { exact: true }).waitFor();
        assert.deepEqual(await tooltip.locator("b").allTextContents(), [value],
          "The saved plot must show activeAccounts values, not the authored activeUsers values");
      }
      const editor = await openEditor(id);
      assert.match(await editor.getByRole("button", { name: "Chart type", exact: true }).innerText(), /Bar/);
      assert.match(await editor.getByRole("button", { name: "Y axis", exact: true }).innerText(), /Active accounts/);
      assert.equal(await editor.getByRole("textbox", { name: "Y axis title" }).inputValue(), "Edited accounts");
      assert.equal(await editor.getByRole("switch", { name: "Show legend" }).isChecked(), false);
      return editor;
    }

    for (const id of ["renderer", "alias"]) {
      const chart = page.locator(`[data-component-id="${id}"]`);
      await chart.locator(".recharts-line-curve").first().waitFor();
      let editor = await openEditor(id);
      await select(editor, "Chart type", "Bar");
      await select(editor, "Y axis", "Active accounts");
      await editor.getByRole("textbox", { name: "Y axis title" }).fill("Edited accounts");
      await editor.getByRole("switch", { name: "Show legend" }).click();
      await editor.locator(".recharts-bar-rectangle").first().waitFor();
      assert.equal(await chart.locator(".recharts-bar-rectangle").count(), 0,
        "Unsaved drafts must not change the dashboard");
      await closeEditor(editor, "Save");
      editor = await assertSavedChart(id);
      await select(editor, "Chart type", "Line");
      await editor.locator(".recharts-line-curve").first().waitFor();
      await editor.getByRole("button", { name: "Undo chart change" }).click();
      await editor.locator(".recharts-bar-rectangle").first().waitFor();
      await editor.getByRole("button", { name: "Redo chart change" }).click();
      await editor.locator(".recharts-line-curve").first().waitFor();
      await closeEditor(editor, "Cancel");
      assert.equal(await chart.locator(".recharts-line-curve").count(), 0,
        "Cancel must leave the saved chart in place");
    }
    await page.reload({ waitUntil: "load" });
    for (const id of ["renderer", "alias"]) {
      await closeEditor(await assertSavedChart(id), "Cancel");
    }
    assert.deepEqual(errors, [], "Chart edits must survive authored defaults without browser errors");
  } finally {
    await context.close();
    rmSync(project, { recursive: true, force: true });
  }
}

export async function verifyChartPngDownload({ page, chart, actionsLabel, expectedFilename }) {
  await chart.getByRole("button", { name: actionsLabel }).click();
  const menu = page.getByRole("menu", { name: actionsLabel });
  assert.equal(await menu.getByRole("menuitem", { name: "Export chart" }).count(), 1,
    "Renderable charts should offer one export dialog");
  assert.equal(await menu.getByRole("menuitem", { name: "Download PNG" }).count(), 0,
    "Chart menus should not duplicate the download action inside the export dialog");
  assert.equal(
    await menu.getByRole("menuitem", { name: "Copy as image" }).count(),
    1,
    "Chart menus should retain quick clipboard image copying",
  );
  await menu.getByRole("menuitem", { name: "Export chart" }).click();
  const dialog = page.getByRole("dialog", { name: "Export chart", exact: true });
  assert.equal(await dialog.getByRole("button", { name: "Cancel", exact: true }).count(), 0,
    "Export chart should have one Close action at every viewport size");
  const downloadLink = dialog.getByRole("link", { name: "Download PNG", exact: true });
  await downloadLink.waitFor();
  const [download] = await Promise.all([
    page.waitForEvent("download"),
    downloadLink.click(),
  ]);
  await dialog.waitFor({ state: "hidden" });
  assert.equal(
    download.suggestedFilename(),
    expectedFilename,
    "Downloaded chart images should use a readable PNG filename",
  );
  const downloadPath = await download.path();
  assert.ok(downloadPath, "The PNG chart download should produce a browser-owned file");
  const downloadHeader = Buffer.alloc(8);
  const handle = await open(downloadPath, "r");
  try {
    await handle.read(downloadHeader, 0, downloadHeader.length, 0);
  } finally {
    await handle.close();
  }
  assert.deepEqual(
    [...downloadHeader],
    [137, 80, 78, 71, 13, 10, 26, 10],
    "Downloaded chart images should be encoded as PNG files",
  );
}

export async function verifyDashboardChartInteractions({ page, trend }) {
  const driverChart = page.locator('[data-component-id="growth-drivers"]');
  await verifyChartPngDownload({
    page,
    chart: driverChart,
    actionsLabel: "Weekly change in active accounts actions",
    expectedFilename: "Weekly-change-in-active-accounts.png",
  });
  const previousImageCount = await page.evaluate(
    () => window.__dashboardClipboard.filter((item) => item?.type === "image/png").length,
  );
  await driverChart.getByRole("button", { name: "Weekly change in active accounts actions" }).click();
  await page.getByRole("menuitem", { name: "Copy as image" }).click();
  await page.waitForFunction(
    (count) => window.__dashboardClipboard.filter((item) => item?.type === "image/png").length > count,
    previousImageCount,
  );
  const describedChart = (await page.evaluate(() => window.__dashboardClipboard))
    .filter((item) => item?.type === "image/png")
    .at(-1);
  assert.ok(
    describedChart.text.includes("Weekly change in active accounts"),
    "The copied chart should include its title",
  );
  assert.ok(
    describedChart.text.some((line) => /Reviewed before and after totals/.test(line)),
    "The copied chart should include its available source-backed description",
  );

  async function selectEditorType(editor, name) {
    await editor.getByRole("button", { name: "Chart type", exact: true }).click();
    await page.getByRole("menuitemradio", { name, exact: true }).click();
  }
  async function closeEditor(editor, action = "Cancel") {
    await editor.getByRole("button", { name: action, exact: true }).click();
    await editor.waitFor({ state: "hidden" });
  }
  await driverChart.getByRole("button", { name: "Weekly change in active accounts actions" }).click();
  await page.getByRole("menuitem", { name: "Edit chart" }).click();
  const waterfallEditor = page.getByRole("dialog", { name: "Weekly change in active accounts" });
  await waterfallEditor.locator(".explorer-chart svg").waitFor();
  const originalWaterfall = await numericAxisTicks(driverChart);
  const waterfallZero = waterfallEditor.getByRole("switch", { name: "Start axis at zero" });
  await waterfallZero.click();
  await page.waitForFunction(
    () =>
      ![...document.querySelectorAll('.chart-editor-dialog svg text[text-anchor="end"]')].some(
        (node) => node.textContent === "0",
      ),
  );
  assert.deepEqual(
    await numericAxisTicks(driverChart),
    originalWaterfall,
    "Drafts must not mutate the dashboard before Save",
  );
  await closeEditor(waterfallEditor, "Save");
  assert.ok(!(await numericAxisTicks(driverChart)).includes("0"));
  await driverChart.getByRole("button", { name: "Weekly change in active accounts actions" }).click();
  await page.getByRole("menuitem", { name: "Edit chart" }).click();
  await waterfallEditor.locator(".explorer-chart svg").waitFor();
  assert.equal(await waterfallZero.isChecked(), false);
  for (const type of ["Stacked bar", "Horizontal stacked bar"]) {
    await selectEditorType(waterfallEditor, type);
    const negative = waterfallEditor.locator('g[data-stack-sign="negative"] path[clip-path]');
    await negative.first().waitFor();
    const bounds = await negative.first().boundingBox();
    assert.ok(bounds.width > 0 && bounds.height > 0, `${type} must preserve negative values`);
  }
  await closeEditor(waterfallEditor);

  await trend.getByRole("button", { name: "Active accounts over time actions" }).click();
  await page.getByRole("menuitem", { name: "Edit chart" }).click();
  const explorer = page.getByRole("dialog", { name: "Active accounts over time" });
  const preview = explorer.locator(".explorer-chart");
  await preview.locator("svg").waitFor();
  assert.equal(await explorer.locator("table").count(), 0);
  assert.equal(await explorer.getByRole("button", { name: "Close", exact: true }).count(), 0);
  assert.equal(await explorer.getByRole("button", { name: "Save", exact: true }).isDisabled(), true);
  assert.match(await explorer.getByRole("button", { name: "X axis", exact: true }).innerText(), /Week/);
  assert.match(await explorer.getByRole("button", { name: "Y axis", exact: true }).innerText(), /Active users/);
  const showLegend = explorer.getByRole("switch", { name: "Show legend" });
  assert.equal(await explorer.getByRole("switch", { name: "Show annotations" }).count(), 0,
    "Charts without authored annotations do not offer an annotation visibility control");
  await showLegend.click();
  await preview.locator(".chart-legend").waitFor({ state: "hidden" });
  assert.ok(await trend.locator(".chart-legend").count());
  await explorer.getByRole("button", { name: "Undo chart change" }).click();
  await preview.locator(".chart-legend").waitFor();
  await explorer.getByRole("button", { name: "Redo chart change" }).click();
  await preview.locator(".chart-legend").waitFor({ state: "hidden" });
  await explorer.getByRole("button", { name: "Undo chart change" }).click();
  await selectEditorType(explorer, "Heatmap");
  await preview.locator(".chart-heatmap-cell").first().waitFor();
  const xTitle = explorer.getByRole("textbox", { name: "X axis title" });
  const yTitle = explorer.getByRole("textbox", { name: "Y axis title" });
  await xTitle.fill("");
  await yTitle.fill("Accounts");
  await page.waitForFunction(
    () => document.querySelector(".chart-editor-dialog svg text.recharts-label")?.textContent === "Accounts",
  );
  assert.equal(await preview.locator(".chart-axis-label").count(), 0, "An absent X title reserves no footer");
  await xTitle.fill("Week");
  await preview.getByText("Week", { exact: true }).last().waitFor();
  await yTitle.fill("");
  await page.waitForFunction(() => !document.querySelector(".chart-editor-dialog svg text.recharts-label"));
  assert.equal(
    await preview.locator(".chart-axis-label").innerText(),
    "Week",
    "Heatmap X and Y titles are independent",
  );
  await closeEditor(explorer);
  assert.equal(await trend.locator(".chart-heatmap-cell").count(), 0, "Cancel discards chart-type edits");
}

SHA-256: 9842579d4d5f03cd20529bc3913b39c80568775c1303073e1fbfd28c8be64cad