← Files DataARCHIVED FILE

tests/data-app-header-alignment.smoke.mjs

16.4 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 { resolveChromiumExecutable, runDataAppFixtureBuild } from "./browser-helpers.mjs";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const template = join(root, "templates/data-app/base");
const workspace = mkdtempSync(join(tmpdir(), "data-app-header-alignment-"));
const scenarios = [
  {
    id: "standard-dashboard",
    surface: "dashboard",
    contentWidth: 1440,
    styles: "",
  },
  {
    id: "wide-dashboard",
    surface: "dashboard",
    contentWidth: 1600,
    styles:
      ".page { --data-app-layout-intent: wide; " +
      "--data-app-content-width: var(--data-app-dashboard-wide-content-width); }",
  },
  {
    id: "full-bleed-dashboard",
    surface: "dashboard",
    contentWidth: 1440,
    fullBleed: true,
    styles:
      // Override the protected frame fallback explicitly; a bare .page loses
      // to main[data-data-app-content] in both the source and prebuilt CSS.
      'main.page[data-data-app-content="dashboard"] { ' +
      "--data-app-layout-intent: full-bleed; max-width: none; padding-inline: 0; } " +
      ".filters.filter-bar { margin-inline: 0; " +
      "padding-inline: max(var(--data-app-layout-gutter), " +
      "calc((100vw - var(--data-app-content-width)) / 2)); }",
  },
  {
    id: "inset-dashboard-filters",
    surface: "dashboard",
    contentWidth: 1440,
    styles: ".filter-bar { padding-inline: 36px; }",
  },
  {
    id: "standard-editorial-report",
    surface: "report",
    contentWidth: 720,
    styles: "",
  },
  {
    id: "explicit-custom-dashboard",
    surface: "dashboard",
    contentWidth: 1800,
    styles: ".page { --data-app-layout-intent: user-requested; --data-app-content-width: 1800px; }",
  },
  {
    id: "explicit-custom-report",
    surface: "report",
    contentWidth: 1200,
    styles: ".report-page { --data-app-layout-intent: user-requested; --data-app-content-width: 1200px; }",
  },
];

const browser = await chromium.launch({
  executablePath: resolveChromiumExecutable(),
  headless: true,
});
const results = [];

try {
  for (const scenario of scenarios) {
    const project = join(workspace, scenario.id);
    cpSync(template, 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"));
    writeFileSync(snapshotPath, `${JSON.stringify({ ...snapshot, surface: scenario.surface }, null, 2)}\n`);
    const stylesheet = join(project, `src/content/${scenario.surface}/${scenario.surface}.css`);
    writeFileSync(stylesheet, `${readFileSync(stylesheet, "utf8")}\n${scenario.styles}\n`);
    const build = runDataAppFixtureBuild(project, { pluginRoot: root });
    assert.equal(build.status, 0, `${scenario.id}: ${build.stdout}\n${build.stderr}`);

    const page = await browser.newPage({
      viewport: { width: 1920, height: 1050 },
    });
    await page.goto(pathToFileURL(join(project, "dist/index.html")).href, {
      waitUntil: "load",
    });
    await page.locator(scenario.surface === "report" ? "main h1" : "main .filter-bar > *").first().waitFor();
    for (const width of [1920, 1440, 901, 900, 760, 600, 599, 390, 320]) {
      await page.setViewportSize({ width, height: 1050 });
      await page.evaluate(
        () =>
          new Promise((next) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(next)))),
      );
      const alignment = await page.evaluate((surface) => {
        const main = document.querySelector("main");
        const mainBounds = main.getBoundingClientRect();
        const styles = getComputedStyle(main);
        const headingElement = main.querySelector(surface === "report" ? "h1" : ".filter-bar > *");
        const heading = headingElement.getBoundingClientRect();
        const headingStyles = getComputedStyle(headingElement);
        const title = document.querySelector(".dashboard-topbar-title").getBoundingClientRect();
        const actions = document.querySelector(".dashboard-topbar-actions").getBoundingClientRect();
        const fullFreshness = document.querySelector(".dashboard-freshness-full");
        const compactFreshness = document.querySelector(".dashboard-freshness-compact");
        const askButton = document.querySelector(".dashboard-ask-button");
        const publishButton = document.querySelector(".dashboard-publish-button");
        const refreshCaret = document.querySelector(".dashboard-refresh-trigger-chevron");
        const inset = Math.max(0, heading.left - mainBounds.left - Number.parseFloat(styles.paddingLeft));
        const narrative = main.querySelector(".report-summary-lead, .report-analysis");
        const chartTitle = main.querySelector('[data-component-kind="chart"] .component-header');
        return {
          heading: heading.left,
          headingText: heading.left + Number.parseFloat(headingStyles.paddingLeft),
          title: title.left,
          titleWidth: title.width,
          actions: innerWidth - actions.right,
          frame: mainBounds.width,
          paddingLeft: Number.parseFloat(styles.paddingLeft),
          paddingRight: Number.parseFloat(styles.paddingRight),
          contentWidth:
            mainBounds.width - Number.parseFloat(styles.paddingLeft) - Number.parseFloat(styles.paddingRight),
          proseWidth: narrative?.getBoundingClientRect().width,
          proseLeft: narrative?.getBoundingClientRect().left,
          chartTitleLeft: chartTitle?.getBoundingClientRect().left,
          contentEnd: innerWidth - mainBounds.right + Number.parseFloat(styles.paddingRight) + inset,
          overflow: document.documentElement.scrollWidth - innerWidth,
          askHit: askButton?.contains(document.elementFromPoint(
            askButton.getBoundingClientRect().x + askButton.getBoundingClientRect().width / 2,
            askButton.getBoundingClientRect().y + askButton.getBoundingClientRect().height / 2)),
          fullFreshnessDisplay: fullFreshness ? getComputedStyle(fullFreshness).display : null,
          compactFreshnessDisplay: compactFreshness ? getComputedStyle(compactFreshness).display : null,
          compactFreshnessText: compactFreshness?.textContent?.trim(),
          askButtonCount: document.querySelectorAll(".dashboard-ask-button").length,
          askButtonLabel: askButton?.getAttribute("aria-label"),
          askButtonText: askButton?.innerText?.trim(),
          publishButtonCount: document.querySelectorAll(".dashboard-publish-button").length,
          publishButtonText: publishButton?.innerText?.trim(),
          publishButtonIconCount: publishButton?.querySelectorAll(".dashboard-icon").length,
          refreshCaretDisplay: refreshCaret ? getComputedStyle(refreshCaret).display : null,
        };
      }, scenario.surface);
      const report = scenario.surface === "report";
      const evidenceStart = (width - alignment.contentWidth) / 2;
      const viewportGutter = scenario.fullBleed ? (width <= 650 ? 16 : 32) : alignment.paddingLeft;
      const expectedTitle = viewportGutter;
      const expectedActions = scenario.fullBleed ? viewportGutter : alignment.paddingRight;
      assert.ok(
        Math.abs(alignment.title - expectedTitle) <= 1,
        `${scenario.id} at ${width}px does not keep the protected title at the viewport gutter: ${JSON.stringify(alignment)}`,
      );
      assert.ok(
        Math.abs(alignment.actions - expectedActions) <= 1,
        `${scenario.id} at ${width}px does not keep protected actions at the viewport gutter: ${JSON.stringify(alignment)}`,
      );
      assert.ok(
        alignment.overflow <= 1,
        `${scenario.id} at ${width}px introduces horizontal overflow: ${JSON.stringify(alignment)}`,
      );
      assert.equal(alignment.publishButtonCount, 1, `${scenario.id} must retain one Publish control`);
      assert.equal(alignment.askButtonCount, 1, `${scenario.id} at ${width}px must retain the visible Ask control`);
      assert.equal(alignment.askHit, true, `${scenario.id} at ${width}px must keep Ask clickable`);
      assert.match(alignment.publishButtonText, /^Publish(?: changes)?$/u);
      assert.equal(alignment.publishButtonIconCount, 0, `${scenario.id} must keep Publish text-only`);
      if (width === 760) {
        assert.equal(alignment.fullFreshnessDisplay, "none", `${scenario.id} must hide the full date below 900px`);
        assert.notEqual(
          alignment.compactFreshnessDisplay,
          "none",
          `${scenario.id} must show the month-day date below 900px`,
        );
        assert.match(alignment.compactFreshnessText, /^[A-Z][a-z]{2} \d{1,2}$/u);
        assert.equal(alignment.askButtonCount, 1, `${scenario.id} must retain the Ask button from 600px to 899px`);
        assert.equal(alignment.askButtonLabel, "Ask ChatGPT");
        assert.equal(alignment.askButtonText, "Ask");
        if (alignment.refreshCaretDisplay != null) {
          assert.equal(alignment.refreshCaretDisplay, "none", `${scenario.id} must hide the refresh caret below 900px`);
        }
      }
      const gutter = scenario.surface === "report" ? (width <= 640 ? 20 : 36) : width <= 650 ? 16 : 32;
      const expectedWidth = Math.min(scenario.contentWidth, width - 2 * gutter);
      const actualWidth = scenario.fullBleed ? width - 2 * alignment.heading : alignment.contentWidth;
      assert.ok(
        Math.abs(actualWidth - expectedWidth) <= 1,
        `${scenario.id} at ${width}px uses an unexpected actual content width: ${JSON.stringify(alignment)}`,
      );
      if (!scenario.fullBleed) {
        assert.equal(alignment.paddingLeft, gutter, `${scenario.id} at ${width}px has an unexpected left gutter`);
        assert.equal(alignment.paddingRight, gutter, `${scenario.id} at ${width}px has an unexpected right gutter`);
      }
      if (report) {
        assert.ok(
          Math.abs(alignment.proseWidth - alignment.contentWidth) <= 1,
          `${scenario.id} at ${width}px gives report narrative and visual evidence different widths`,
        );
        assert.ok(
          Math.abs(alignment.headingText - evidenceStart) <= 1,
          `${scenario.id} at ${width}px misaligns the report title: ${JSON.stringify(alignment)}`,
        );
        assert.ok(
          Math.abs(alignment.proseLeft - evidenceStart) <= 1,
          `${scenario.id} at ${width}px misaligns report narrative: ${JSON.stringify(alignment)}`,
        );
        assert.ok(
          Math.abs(alignment.chartTitleLeft - evidenceStart) <= 1,
          `${scenario.id} at ${width}px separates chart headings from their wider visual evidence: ${JSON.stringify(alignment)}`,
        );
      }
      results.push({
        id: scenario.id,
        width,
        contentWidth: actualWidth,
        gutter,
      });
    }

    if (scenario.id === "standard-dashboard") {
      await page.setViewportSize({ width: 1440, height: 1050 });
      await page.evaluate(() => scrollTo({ top: 0, behavior: "instant" }));
      const ask = page.getByRole("button", { name: "Ask ChatGPT", exact: true });
      await ask.click();
      const composer = page.getByRole("dialog", { name: "Ask ChatGPT about this dashboard" });
      await composer.waitFor();
      await page.waitForFunction(() => {
        const panel = document.querySelector('.dashboard-ask-panel[data-header-morph="true"]');
        return panel?.getAnimations({ subtree: true }).every((animation) => animation.playState === "finished");
      });
      const entranceDuration = await composer.evaluate(
        (element) => Number.parseFloat(getComputedStyle(element).animationDuration) * 1000,
      );
      const compactBounds = await composer.boundingBox();
      await composer.getByRole("textbox", { name: "Question for ChatGPT" }).fill("What changed?");
      await page.waitForFunction(
        () =>
          document.querySelector('.dashboard-ask-panel[data-header-morph="true"]')?.getBoundingClientRect().height >=
          68,
      );
      const expandedBounds = await composer.boundingBox();
      assert.equal(Math.round(expandedBounds.height), 68, "Typing must expand the header composer");
      assert.ok(
        Math.abs(expandedBounds.y - compactBounds.y) <= 1,
        `The header composer must grow downward without moving its top edge: ${JSON.stringify({ compactBounds, expandedBounds })}`,
      );
      await page.keyboard.press("Escape");
      await page.waitForFunction(
        () => document.querySelector('.dashboard-ask-panel[data-header-morph="true"]')?.dataset.closing === "true",
      );
      const exitDuration = await composer.evaluate(
        (element) => Number.parseFloat(getComputedStyle(element).animationDuration) * 1000,
      );
      assert.equal(Math.round(exitDuration - entranceDuration), 50, "The header Ask exit must be 50ms longer");
      await composer.waitFor({ state: "detached" });
    }

    if (scenario.surface === "dashboard") {
      await page.setViewportSize({ width: 1440, height: 1050 });
      await page.evaluate(() => scrollTo({ top: 0, behavior: "instant" }));
      await page.evaluate(
        () => new Promise((next) => requestAnimationFrame(() => requestAnimationFrame(next))),
      );
      const tabCount = await page.getByRole("tab").count();
      if (tabCount > 1) {
        await page.getByRole("button", { name: "Edit mode" }).click();
        await page.waitForFunction(
          () => document.querySelector(".dashboard-tabs-collapse")?.getAttribute("aria-hidden") === "false",
        );
        const tabs = await page.evaluate(() => {
          const inner = document.querySelector(".dashboard-tabs-inner");
          const title = document.querySelector(".dashboard-topbar-title");
          return {
            gutter: inner.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(inner).paddingLeft),
            chrome: title.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(title).paddingLeft),
          };
        });
        assert.ok(
          Math.abs(tabs.gutter - tabs.chrome) <= 1,
          `${scenario.id} misaligns dashboard tabs with full-width protected chrome: ${JSON.stringify(tabs)}`,
        );
      }
    }
    if (scenario.id === "standard-dashboard") {
      await page.setViewportSize({ width: 390, height: 1050 });
      await page.getByRole("button", { name: "More", exact: true }).click();
      const menuItems = await page.locator(".dashboard-header-action-menu [role=menuitem]").allTextContents();
      assert.ok(!menuItems.includes("Ask ChatGPT"), "More must not duplicate the visible Ask action");
      await page.keyboard.press("Escape");
      const ask = page.getByRole("button", { name: "Ask ChatGPT", exact: true });
      const askBounds = await ask.boundingBox();
      await ask.click();
      const composer = page.getByRole("dialog", { name: "Ask ChatGPT about this dashboard" });
      await composer.waitFor();
      const composerBounds = await composer.boundingBox();
      assert.equal(Math.round(composerBounds.height), 36, "The empty Ask composer must remain a compact capsule");
      assert.equal(
        await composer.evaluate((element) => getComputedStyle(element).borderRadius),
        "18px",
        "The empty Ask composer must use a radius equal to half its height",
      );
      assert.ok(Math.abs(composerBounds.y + composerBounds.height / 2 - askBounds.y - askBounds.height / 2) <= 1,
        "The mobile composer must retain the direct button's anchored morph");
      assert.deepEqual(
        await composer.locator(".dashboard-ask-zero-state > *").allTextContents(),
        [
          "Draft a team updateShare the highlights in a message",
          "Refresh a documentUpdate a file with fresh data",
          "Set up an alertNotify you of important changes",
          "Remix this dashboard",
        ],
      );
      await composer.getByRole("textbox", { name: "Question for ChatGPT" }).fill("What changed?");
      assert.equal(await composer.locator(".dashboard-ask-zero-state").count(), 0);
      assert.equal(Math.round((await composer.boundingBox()).height), 68);
    }
    await page.close();
  }
} finally {
  await browser.close();
  rmSync(workspace, { recursive: true, force: true });
}

console.log(
  JSON.stringify(
    {
      status: "passed",
      scenarios: scenarios.length,
      viewportChecks: results.length,
      results,
    },
    null,
    2,
  ),
);

SHA-256: e72e1df22e6c44681537f3a988bab031335c14b79b2e783b700ea84d894158c1