← Files DataARCHIVED FILE

tests/mobile-responsive-browser.smoke.mjs

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

↓ Download file

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromium, webkit } from 'playwright-core';
import { resolveChromiumExecutable, runDataAppFixtureBuild } from './browser-helpers.mjs';

const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const template = join(pluginRoot, 'templates/data-app/base');
const workspace = mkdtempSync(join(tmpdir(), 'data-mobile-responsive-'));
const viewports = [
  { name: 'small-phone', width: 320, height: 700 },
  { name: 'phone', width: 390, height: 844 },
  { name: 'large-phone', width: 430, height: 932 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'landscape', width: 844, height: 390 },
  { name: 'desktop', width: 1440, height: 900 },
];

function buildApp(surface) {
  const project = join(workspace, surface);
  cpSync(template, project, { recursive: true, filter: path => !path.includes('/node_modules') && !path.includes('/dist') });
  if (surface === 'dashboard') {
    const contentPath = join(project, 'src/content/dashboard/DashboardContent.jsx');
    const content = readFileSync(contentPath, 'utf8');
    const table = '<DataComponent variant="card" id="usage-details" title="Reviewed account-level evidence" kind="table"';
    assert.ok(content.includes(table), 'The responsive fixture needs its account-level table');
    writeFileSync(contentPath, content.replace(table,
      `${table.replace('Reviewed account-level evidence', 'Reviewed account-level evidence and conversion by channel')}
        description="Reviewed account-level source rows."`));
  }
  if (surface === 'report') {
    const dataPath = join(project, 'src/data.json');
    writeFileSync(dataPath, JSON.stringify({ ...JSON.parse(readFileSync(dataPath, 'utf8')), surface }));
  }
  const build = runDataAppFixtureBuild(project, { pluginRoot });
  assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
  return join(project, 'dist/index.html');
}

function buildInline(kind) {
  const output = join(workspace, `${kind}.html`);
  const script = join(pluginRoot, `skills/visualize-data/scripts/render-inline-${kind}.mjs`);
  const input = join(pluginRoot, `skills/visualize-data/assets/inline-${kind}-example.json`);
  const fixture = kind === 'chart' ? join(workspace, 'described-inline-chart.json') : input;
  if (kind === 'chart') writeFileSync(fixture, JSON.stringify({ ...JSON.parse(readFileSync(input, 'utf8')),
    title: 'Weekly active users by plan and signup channel',
    description: 'Illustrative weekly active users by plan.' }));
  execFileSync(process.execPath, [script, '--input', fixture, '--output', output], { stdio: 'pipe' });
  const hosted = join(workspace, `${kind}-host.html`);
  writeFileSync(hosted, `<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><body style="margin:16px">${readFileSync(output, 'utf8')}</body>`);
  return hosted;
}

async function checkWidth(page, label) {
  const width = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, content: document.documentElement.scrollWidth }));
  assert.ok(width.content <= width.viewport + 1, `${label} overflows horizontally: ${JSON.stringify(width)}`);
}

async function checkPhoneChrome(page, label) {
  const touch = await page.evaluate(() => {
    const rect = element => element.getBoundingClientRect().toJSON();
    const topbar = document.querySelector('.dashboard-topbar');
    const title = rect(topbar.querySelector('.dashboard-topbar-title'));
    const context = topbar.querySelector('.dashboard-topbar-copy .freshness');
    const controls = [...topbar.querySelectorAll('.dashboard-topbar-inner button, .dashboard-topbar-inner select')]
      .filter(element => element.getBoundingClientRect().width && element.getBoundingClientRect().height)
      .map(element => ({ name: element.getAttribute('aria-label') || element.textContent.trim(), box: rect(element) }));
    const filters = [...document.querySelectorAll('.filter-bar .filter-trigger')]
      .filter(element => element.getBoundingClientRect().width).map(rect);
    const menus = [...document.querySelectorAll('.dashboard-component .component-native-actions')].map(rect);
    const info = [...document.querySelectorAll('.dashboard-component .info-wrap > button.info')].map(rect);
    const overlaps = [...document.querySelectorAll('.dashboard-component')].flatMap(component => {
      const information = component.querySelector('.info-wrap > button.info');
      const menu = component.querySelector('.component-native-actions');
      if (!information || !menu) return [];
      const a = information.getBoundingClientRect();
      const b = menu.getBoundingClientRect();
      return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
        ? [component.dataset.componentId] : [];
    });
    const edition = document.querySelector('.dashboard-mobile-report-date');
    return { title, context: context && rect(context), edition: edition && rect(edition),
      header: rect(topbar), controls, filters, menus, info, overlaps };
  });
  const targets = [...touch.controls.map(({ box }) => box), ...touch.filters, ...touch.menus, ...touch.info];
  assert.ok(targets.every(({ width, height }) => width >= 44 && height >= 44),
    `${label}: primary phone controls need 44px hit regions: ${JSON.stringify(touch)}`);
  assert.deepEqual(touch.overlaps, [], `${label}: information and menu hit regions must not overlap`);
  assert.equal(touch.header.height, 56, `${label}: reading mode uses a single row app bar`);
  assert.equal(touch.controls.length, 1, `${label}: reading mode exposes one overflow control`);
  assert.equal(touch.controls[0].name, 'More');
  if (touch.edition) assert.ok(touch.edition.top >= touch.header.bottom - 1,
    `${label}: the prepared date should be in report content, below the bar`);
}

async function checkTitleInfoSpacing(page, viewport, label, { inline = false } = {}) {
  const headings = await page.locator('.component-title:has(.info-wrap)')
    .evaluateAll(elements => elements.map(heading => {
      const tail = heading.querySelector('.component-title-tail');
      const label = tail || heading.querySelector('.component-title-text');
      const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
      let text;
      for (let node = walker.nextNode(); node; node = walker.nextNode())
        if (node.textContent.trim()) text = node;
      const range = document.createRange();
      const lastCharacter = text.textContent.trimEnd().length;
      range.setStart(text, lastCharacter - 1);
      range.setEnd(text, lastCharacter);
      const word = range.getBoundingClientRect();
      const icon = heading.querySelector('.info .dashboard-icon').getBoundingClientRect();
      const component = heading.closest('.dashboard-component');
      return { kind: component?.classList.contains('data-inline-chart-content') ? 'inline-chart' : component?.dataset.componentKind,
        title: text.textContent, gap: icon.left - word.right,
        centerOffset: (icon.top + icon.bottom - word.top - word.bottom) / 2 };
    }));
  assert.ok(headings.length > 0, `${label}: expected at least one information icon`);
  if (inline) assert.ok(headings.some(({ kind }) => kind === 'inline-chart'), `${label}: inline chart information icon`);
  else if (label.includes('dashboard'))
    for (const kind of ['chart', 'metric', 'table'])
      assert.ok(headings.some(heading => heading.kind === kind), `${label}: missing ${kind} information icon`);
  const misaligned = headings.filter(({ kind, gap, centerOffset }) => {
    return gap < 5 || gap > 7 || Math.abs(centerOffset) > 2;
  });
  assert.deepEqual(misaligned, [], `${label}: information glyph should follow its final word with its intended visual gap and alignment`);
}

async function checkPhoneActionMenu(page, viewport, label) {
  const picker = await page.getByRole('combobox', { name: 'More' }).evaluate(element => {
    const box = element.getBoundingClientRect();
    return { left: box.left, right: box.right, width: box.width, height: box.height,
      selectedIndex: element.selectedIndex,
      groups: [...element.querySelectorAll('optgroup')].map(group => ({ label: group.label,
        items: [...group.querySelectorAll('option')].map(option => ({
          label: option.textContent.trim(), text: option.textContent.trim(), value: option.value,
        })) })) };
  });
  assert.ok(picker.width >= 44 && picker.height >= 44 && picker.right <= viewport.width - 12,
    `${label}: native action picker needs a full phone tap target: ${JSON.stringify(picker)}`);
  assert.equal(picker.selectedIndex, -1, `${label}: actions must not have a selected checkmark`);
  assert.ok(picker.groups.every(group => group.items.every(item => item.text !== 'Choose action'
    && item.text === item.label)),
    `${label}: native options should be plain text commands without a placeholder: ${JSON.stringify(picker)}`);
  if (label.includes('dashboard')) assert.deepEqual(picker.groups.find(group => group.label === 'Refresh')?.items.map(item => item.label),
    ['Refresh now', 'Schedule refresh'], `${label}: refresh commands should have their own group`);
  assert.ok(picker.groups.some(group => group.label === 'Export' && group.items.some(item => item.label === 'PDF')),
    `${label}: export actions must remain available`);
}

async function chooseTopAction(page, label) {
  const value = await page.locator('.dashboard-native-overflow option').evaluateAll((options, name) =>
    options.find(option => option.textContent.trim() === name)?.value, label);
  assert.ok(value, `Missing native action: ${label}`);
  const picker = page.locator('.dashboard-native-overflow select');
  await picker.selectOption(value);
  assert.equal(await page.evaluate(() => document.querySelector('.dashboard-native-overflow select')?.selectedIndex ?? -1), -1,
    `${label}: native command should reset selection for repeat use`);
}

async function checkDashboard(page, viewport, browserName) {
  await page.locator('.forecast-progress-labels').waitFor();
  await checkWidth(page, `${browserName} dashboard ${viewport.name}`);
  await checkTitleInfoSpacing(page, viewport, `${browserName} dashboard ${viewport.name}`);
  if (browserName === 'chromium' && viewport.name === 'landscape') {
    const picker = page.locator('.dashboard-native-overflow select');
    await picker.waitFor({ state: 'visible' });
    await chooseTopAction(page, 'Ask ChatGPT');
    const dialog = page.getByRole('dialog', { name: 'Ask ChatGPT about this dashboard' });
    await dialog.waitFor();
    const location = await dialog.boundingBox();
    const trigger = await picker.boundingBox();
    assert.ok(location.x + location.width > trigger.x - 60,
      `Landscape Ask should anchor beside the visible native More control: ${JSON.stringify({ location, trigger })}`);
    await page.keyboard.press('Escape');
    await dialog.waitFor({ state: 'hidden' });
  }
  if (viewport.width <= 430) {
    await checkPhoneChrome(page, `${browserName} dashboard ${viewport.name}`);
    const chrome = await page.evaluate(() => {
      const rect = element => element.getBoundingClientRect().toJSON();
      const filters = [...document.querySelectorAll('.filters.filter-bar .filter-trigger')]
        .filter(element => element.getBoundingClientRect().width);
      const actions = [...document.querySelectorAll('.dashboard-topbar-actions > *')]
        .filter(element => element.getBoundingClientRect().width);
      const cardMenus = [...document.querySelectorAll('.dashboard-component[data-component-variant="card"] > .component-header .component-native-actions')];
      return { filters: filters.map(rect), actions: actions.map(rect), menus: cardMenus.map(menu => ({
        button: rect(menu), card: rect(menu.closest('.dashboard-component')),
        metric: menu.closest('.dashboard-component').dataset.componentKind === 'metric',
      })) };
    });
    assert.ok(chrome.filters[0].width < viewport.width - 80 && chrome.filters[1].left < viewport.width - 45,
      `Filter chips should show the next filter on a phone: ${JSON.stringify(chrome.filters)}`);
    assert.ok(chrome.actions[0].right <= viewport.width - 12,
      `Phone overflow button should fit against the trailing inset: ${JSON.stringify(chrome.actions)}`);
    assert.ok(chrome.menus.every(({ button, card, metric }) => metric
      ? button.top >= card.top + 12 && button.top <= card.top + 36 && card.right - button.right >= 10
      : Math.abs(button.top - card.top - (card.right - button.right)) <= 2),
    `Card actions should align with metric labels or have even chart insets: ${JSON.stringify(chrome.menus)}`);
    const accountFilters = await page.locator('.evidence-section .data-section-filters').evaluate(element =>
      [...element.querySelectorAll('.native-filter-shell')].map(control => control.getBoundingClientRect().toJSON()));
    assert.equal(accountFilters.length, 2);
    assert.ok(Math.abs(accountFilters[0].top - accountFilters[1].top) <= 1 &&
      accountFilters[0].right + 6 <= accountFilters[1].left &&
      accountFilters[1].right <= viewport.width - 12,
    `${browserName} ${viewport.name}: Account health filters should share one row: ${JSON.stringify(accountFilters)}`);
    const heading = await page.locator('[data-component-id="engagement-heatmap"]').evaluate(card => {
      const text = card.querySelector('.component-title-tail').firstChild;
      const lastWord = text.textContent.lastIndexOf('active');
      const range = document.createRange();
      range.setStart(text, lastWord);
      range.setEnd(text, lastWord + 'active'.length);
      const word = range.getBoundingClientRect();
      const icon = card.querySelector('.info-wrap').getBoundingClientRect();
      const infoTarget = card.querySelector('.info-wrap > button').getBoundingClientRect();
      const actionTarget = card.querySelector('.component-native-actions select').getBoundingClientRect();
      return { word: word.toJSON(), icon: icon.toJSON(), infoTarget: infoTarget.toJSON(),
        actionTarget: actionTarget.toJSON() };
    });
    assert.ok(Math.abs((heading.word.top + heading.word.bottom - heading.icon.top - heading.icon.bottom) / 2) <= 10,
      `Chart info icon should follow the title's last line: ${JSON.stringify(heading)}`);
    assert.ok(heading.infoTarget.right + 4 <= heading.actionTarget.left,
      `Chart info and action hit targets need separate space: ${JSON.stringify(heading)}`);
    if (viewport.name === 'phone') {
      const flow = await page.locator('[data-component-id="engagement-heatmap"] .component-title-text').evaluate(element => {
        const prefix = element.firstChild;
        const range = document.createRange();
        range.setStart(prefix, prefix.textContent.indexOf('most'));
        range.setEnd(prefix, prefix.textContent.indexOf('most') + 4);
        return { most: range.getBoundingClientRect().top, first: element.getBoundingClientRect().top };
      });
      assert.ok(Math.abs(flow.most - flow.first) <= 6,
        `Phone chart title should use available space on its first line: ${JSON.stringify(flow)}`);
    }
  }
  const labels = await page.locator('.forecast-progress-labels').evaluate(element => {
    const target = element.querySelector('.forecast-progress-target-label').getBoundingClientRect();
    const projected = element.lastElementChild.getBoundingClientRect();
    const track = element.previousElementSibling.getBoundingClientRect();
    return { target: target.toJSON(), projected: projected.toJSON(), markerX: track.left + track.width * .8 };
  });
  const separated = labels.target.bottom <= labels.projected.top + 1 || labels.projected.bottom <= labels.target.top + 1
    || labels.target.right <= labels.projected.left + 1 || labels.projected.right <= labels.target.left + 1;
  assert.ok(separated, `${browserName} ${viewport.name}: target and projected labels overlap: ${JSON.stringify(labels)}`);
  assert.ok(Math.abs((labels.target.left + labels.target.right) / 2 - labels.markerX) <= 2,
    `${browserName} ${viewport.name}: target label must point to its bar marker`);
  if (browserName !== 'chromium' || !['small-phone', 'phone'].includes(viewport.name)) return;
  await checkPhoneActionMenu(page, viewport, `${browserName} dashboard ${viewport.name}`);
  await chooseTopAction(page, 'Schedule refresh');
  const schedule = page.getByRole('dialog', { name: 'Schedule refresh' });
  await schedule.waitFor();
  const scheduleIcon = await schedule.locator('.dialog-title-group').evaluate(group => {
    const label = group.querySelector('[role="heading"]')?.getBoundingClientRect()
      || group.querySelector('h2')?.getBoundingClientRect();
    const icon = group.querySelector('.info .dashboard-icon').getBoundingClientRect();
    return { gap: icon.left - label.right,
      centerOffset: (icon.top + icon.bottom - label.top - label.bottom) / 2 };
  });
  assert.ok(scheduleIcon.gap >= 5 && scheduleIcon.gap <= 7 && Math.abs(scheduleIcon.centerOffset) <= 2,
    `Schedule dialog information icon should align with its heading: ${JSON.stringify(scheduleIcon)}`);
  await page.getByRole('button', { name: 'Close', exact: true }).click();
  await chooseTopAction(page, 'Edit text and layout');
  await page.getByRole('button', { name: 'Cancel', exact: true }).click();
  const table = page.locator('.table-wrap').first();
  const scroll = await table.evaluate(element => ({ width: element.clientWidth, content: element.scrollWidth }));
  assert.ok(scroll.content > scroll.width, 'Dense mobile table should scroll within its card');
  const chartActions = page.getByRole('combobox', { name: /Active accounts over time actions/ });
  assert.equal(await chartActions.locator('optgroup').count(), 0,
    'The phone chart picker should list actions without section headings');
  const chartSections = await chartActions.evaluate(element => [...element.children].map(child =>
    child.tagName === 'HR' ? 'separator' : child.textContent));
  assert.deepEqual(chartSections.slice(0, 3),
    ['Edit chart', 'View data source', 'separator'],
    `Phone chart actions should retain a divider without a heading: ${chartSections}`);
  assert.ok(chartSections[3]?.startsWith('Copy '),
    `The second group should contain copy and export actions: ${chartSections}`);
  const options = await chartActions.locator('option').allTextContents();
  assert.ok(options.includes('Edit chart') && options.includes('View data source')
    && options.includes('Export chart'), `Chart actions should remain in the phone picker: ${options}`);
  await chartActions.selectOption({ label: 'Edit chart' });
  const editor = page.locator('.chart-editor-dialog');
  const header = await editor.locator('.dialog-header').evaluate(element => ({
    title: element.querySelector('h2').getBoundingClientRect().toJSON(),
    actions: element.querySelector('.dialog-header-actions').getBoundingClientRect().toJSON(),
    save: element.querySelector('.chart-editor-save').getBoundingClientRect().toJSON(),
  }));
  assert.ok(header.title.bottom <= header.actions.top + 1,
    `Dashboard chart editor title must have its own mobile row: ${JSON.stringify(header)}`);
  assert.ok(header.save.right <= viewport.width + 1,
    `Dashboard chart editor Save must fit within the viewport: ${JSON.stringify(header)}`);
  const editorButtons = await editor.locator('.dialog-header-actions').evaluate(element => ({
    cancel: element.querySelector('.chart-editor-cancel')?.getBoundingClientRect().toJSON(),
    save: element.querySelector('.chart-editor-save')?.getBoundingClientRect().toJSON(),
  }));
  assert.ok(editorButtons.cancel && editorButtons.save.left - editorButtons.cancel.right <= 12,
    `Dashboard editor Cancel and Save should be grouped: ${JSON.stringify(editorButtons)}`);
  const settings = await editor.locator('[aria-label="Reviewed data settings"]').evaluate(element => {
    const fields = [...element.querySelectorAll(':scope > label')];
    const value = fields[1].querySelector('.select-value');
    return { first: fields[0].getBoundingClientRect().toJSON(), second: fields[1].getBoundingClientRect().toJSON(),
      valueWidth: value.clientWidth, valueContent: value.scrollWidth };
  });
  assert.ok(settings.first.bottom <= settings.second.top + 1 && settings.valueContent <= settings.valueWidth + 1,
    `Dashboard editor fields and selected values must be readable on phones: ${JSON.stringify(settings)}`);
  const panes = await editor.evaluate(element => {
    const preview = element.querySelector('.explorer-preview');
    const controls = element.querySelector('.explorer-controls');
    return { preview: preview.getBoundingClientRect().toJSON(), controls: controls.getBoundingClientRect().toJSON(),
      previewHeight: preview.clientHeight, chartHeight: preview.scrollHeight, overflow: getComputedStyle(preview).overflowY };
  });
  assert.ok(panes.preview.bottom <= panes.controls.top + 1 && panes.chartHeight >= panes.previewHeight
    && panes.overflow === 'auto', `Dashboard chart preview must scroll within its pane: ${JSON.stringify(panes)}`);
  await editor.getByRole('button', { name: 'Cancel' }).click();
  await chartActions.selectOption({ label: 'Export chart' });
  await page.waitForFunction(() => {
    const card = document.querySelector('.chart-export-preview')?.getBoundingClientRect();
    return card && card.right <= innerWidth - 15;
  });
  const exportHeader = await page.locator('.chart-export-dialog .dialog-header').evaluate(element => ({
    title: element.querySelector('h2').getBoundingClientRect().toJSON(),
    close: element.querySelector('button').getBoundingClientRect().toJSON(),
    height: element.getBoundingClientRect().height,
    top: element.querySelector('button').getBoundingClientRect().top - element.getBoundingClientRect().top,
    bottom: element.getBoundingClientRect().bottom - element.querySelector('button').getBoundingClientRect().bottom,
  }));
  assert.ok(Math.abs((exportHeader.title.top + exportHeader.title.bottom - exportHeader.close.top - exportHeader.close.bottom) / 2) <= 2
    && exportHeader.close.right <= viewport.width - 12
    && Math.abs(exportHeader.top - exportHeader.bottom) <= 1,
  `Export title and Close should share one row: ${JSON.stringify(exportHeader)}`);
  assert.equal(await page.locator('.chart-export-footer > button, .chart-export-footer > a').count(), 2,
    'The Close icon already provides a way to dismiss Export chart');
  const exportLayout = await page.locator('.chart-export-dialog').evaluate(element => {
    const rect = selector => element.querySelector(selector).getBoundingClientRect();
    const preview = rect('.chart-export-preview');
    const artwork = element.querySelector('.chart-export-artwork');
    const presets = [...element.querySelectorAll('.chart-export-presets button')].map(button => button.getBoundingClientRect());
    const footer = [...element.querySelectorAll('.chart-export-footer > button, .chart-export-footer > a')]
      .map(button => button.getBoundingClientRect());
    return { titleX: rect('.dialog-header h2').left, previewLeft: preview.left, previewRight: preview.right,
      artworkWidth: artwork.style.width, previewWidth: preview.width, presets: presets.map(box => ({ top: box.top, height: box.height })),
      footer: footer.map(box => ({ left: box.left, top: box.top, width: box.width, height: box.height })) };
  });
  assert.ok(Math.abs(exportLayout.titleX - exportLayout.previewLeft) <= 1
    && Math.abs(viewport.width - exportLayout.previewRight - exportLayout.previewLeft) <= 1,
  `Export title and preview should share even phone gutters: ${JSON.stringify(exportLayout)}`);
  assert.ok(exportLayout.footer.every(box => box.height >= 44) && exportLayout.presets.every(box => box.height >= 44),
    `Export actions must retain phone tap targets: ${JSON.stringify(exportLayout)}`);
  if (viewport.name === 'phone') {
    assert.ok(exportLayout.presets.every(box => Math.abs(box.top - exportLayout.presets[0].top) < 1)
      && exportLayout.footer.every(box => Math.abs(box.width - exportLayout.footer[0].width) < 1),
    `390px export presets and footer should fit one balanced row: ${JSON.stringify(exportLayout)}`);
  }
  await page.getByRole('button', { name: 'Slide', exact: true }).click();
  await page.waitForFunction(() => document.querySelector('.chart-export-preview')?.getBoundingClientRect().right <= innerWidth - 15);
  await page.getByRole('button', { name: 'Original', exact: true }).click();
  await page.locator('.chart-export-dialog .dialog-header button').click();
  await chooseTopAction(page, 'Switch theme');
  await page.locator('.theme-drawer.is-open').waitFor();
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.waitForTimeout(300);
  const drawer = await page.evaluate(() => {
    const topbar = document.querySelector('.dashboard-topbar').getBoundingClientRect();
    const picker = document.querySelector('.theme-drawer').getBoundingClientRect();
    const filter = document.querySelector('.filter-bar').getBoundingClientRect();
    return { headerBottom: topbar.bottom, pickerTop: picker.top, pickerBottom: picker.bottom, filterTop: filter.top };
  });
  assert.ok(drawer.pickerTop >= drawer.headerBottom - 1 && drawer.filterTop >= drawer.pickerBottom - 1,
    `Theme picker should sit below the topbar and above filters: ${JSON.stringify(drawer)}`);
  await page.getByRole('button', { name: 'Close theme picker' }).click();
}

async function checkReport(page, viewport, browserName) {
  await page.locator('.report-page').waitFor();
  await checkWidth(page, `${browserName} report ${viewport.name}`);
  await checkTitleInfoSpacing(page, viewport, `${browserName} report ${viewport.name}`);
  if (viewport.width <= 430) {
    await checkPhoneChrome(page, `${browserName} report ${viewport.name}`);
    const type = await page.evaluate(() => {
      const style = selector => {
        const element = document.querySelector(selector);
        const css = getComputedStyle(element);
        return { size: css.fontSize, weight: css.fontWeight, spacing: css.letterSpacing };
      };
      return { title: style('.report-hero h1'), lead: style('.report-summary-lead h2'), section: style('.report-section .report-analysis h2') };
    });
    assert.equal(type.title.size, '32px');
    assert.equal(type.title.weight, '600');
    assert.equal(type.lead.weight, '500');
    assert.equal(type.lead.spacing, '-0.4px');
    assert.deepEqual(type.section, type.lead, 'Peer report section headings should use one mobile type style');
  }
  if (browserName !== 'chromium' || viewport.width > 430) return;
  await checkPhoneActionMenu(page, viewport, `${browserName} report ${viewport.name}`);
  if (viewport.name !== 'phone') return;
  await chooseTopAction(page, 'Publish');
  await page.getByRole('dialog', { name: /Publish report/ }).waitFor();
  await page.getByRole('button', { name: 'Close', exact: true }).click();
  await chooseTopAction(page, 'Edit text and layout');
  await page.locator('.dashboard-topbar').getByRole('button', { name: 'Cancel', exact: true }).click();
}

async function checkInline(page, viewport, browserName) {
  const actions = page.locator('.component-native-actions select');
  await page.locator('.data-inline-chart-content').waitFor();
  const native = await actions.isVisible();
  await checkWidth(page, `${browserName} inline ${viewport.name}`);
  await checkTitleInfoSpacing(page, viewport, `${browserName} inline ${viewport.name}`, { inline: true });
  if (!['small-phone', 'phone'].includes(viewport.name)) return;
  assert.ok(native, `${browserName} ${viewport.name}: inline actions use the native mobile picker`);
  if (process.env.DATA_CAPTURE_PR && browserName === 'chromium' && viewport.name === 'phone')
    await page.locator('.data-inline-chart-content').screenshot({ path: '/tmp/data-mobile-inline-chart-pr.png' });
  if (native) await actions.selectOption('edit');
  else {
    await page.locator('.component-custom-actions button.menu-trigger').click();
    await page.getByRole('menuitem', { name: 'Edit chart' }).click();
  }
  const editor = page.locator('.chart-editor-dialog--contained');
  await editor.waitFor();
  const positions = await editor.evaluate(element => Object.fromEntries(
    ['history', 'reset', 'cancel', 'save'].map(name => [name,
      element.querySelector(`.chart-editor-${name}`).getBoundingClientRect().toJSON()])));
  assert.ok(positions.history.bottom <= positions.cancel.top + 1 && positions.reset.bottom <= positions.save.top + 1,
    `${browserName} ${viewport.name}: editor actions must form two balanced rows: ${JSON.stringify(positions)}`);
  assert.ok(positions.cancel.right <= positions.save.left + 1,
    `${browserName} ${viewport.name}: Cancel and Apply must sit together`);
  await checkWidth(page, `${browserName} inline editor ${viewport.name}`);
  await editor.getByRole('button', { name: 'Cancel' }).click();
  await editor.waitFor({ state: 'hidden' });
}

async function checkSources(page, viewport, browserName) {
  const disclosure = page.locator('.receipt-disclosure');
  await disclosure.waitFor();
  await checkWidth(page, `${browserName} sources ${viewport.name}`);
  if (!['small-phone', 'phone'].includes(viewport.name)) return;
  const disclosureHeight = await disclosure.evaluate(element => element.getBoundingClientRect().height);
  assert.ok(disclosureHeight >= 44, `${browserName} ${viewport.name}: Sources disclosure needs a 44px touch region`);
  await disclosure.click();
  await page.locator('.receipt-card').waitFor();
  const expander = page.locator('.receipt-expander');
  assert.equal(await expander.getAttribute('data-open'), 'true', `${browserName} ${viewport.name}: Sources must expand on tap`);
  await expander.evaluate(element => Promise.all(element.getAnimations().map(animation => animation.finished.catch(() => {}))));
  assert.ok(await expander.evaluate(element => Number(getComputedStyle(element).opacity) > .99),
    `${browserName} ${viewport.name}: expanded Sources must be visible`);
  const targets = await page.locator('.receipt-card').first().evaluate(card => ({
    toggle: card.querySelector('.receipt-card-toggle')?.getBoundingClientRect().height,
    tabs: [...card.querySelectorAll('[role="tab"]')].map(tab => ({
      height: tab.getBoundingClientRect().height,
      fontSize: parseFloat(getComputedStyle(tab).fontSize),
      padding: parseFloat(getComputedStyle(tab).paddingInlineStart),
    })),
  }));
  assert.ok((targets.toggle === undefined || targets.toggle >= 44) && targets.tabs.every(tab => tab.height >= 44),
    `${browserName} ${viewport.name}: Sources card actions need 44px touch regions: ${JSON.stringify(targets)}`);
  assert.ok(targets.tabs.every(tab => tab.fontSize >= 15 && tab.padding >= 12),
    `${browserName} ${viewport.name}: Sources tabs need readable labels and room around them: ${JSON.stringify(targets)}`);
  const readingSizes = await page.locator('.sources-receipt').evaluate(receipt =>
    ['.receipt-disclosure', '.receipt-qualification-list li', '.source-row-title']
      .map(selector => receipt.querySelector(selector))
      .filter(Boolean)
      .map(element => parseFloat(getComputedStyle(element).fontSize)));
  assert.ok(readingSizes.length >= 2 && readingSizes.every(size => size >= 14),
    `${browserName} ${viewport.name}: Sources labels and body must remain legible: ${readingSizes}`);
  if (process.env.DATA_CAPTURE_PR && browserName === 'chromium' && viewport.name === 'phone')
    await page.locator('.sources-receipt').screenshot({ path: '/tmp/data-mobile-sources-card-pr.png' });
  const lastTab = page.getByRole('tab', { name: 'Evidence flow' });
  await lastTab.click();
  assert.equal(await lastTab.getAttribute('aria-selected'), 'true');
  await checkWidth(page, `${browserName} expanded sources ${viewport.name}`);
}

try {
  const targets = { dashboard: buildApp('dashboard'), report: buildApp('report'), inline: buildInline('chart'), sources: buildInline('sources') };
  for (const [browserName, engine] of [['chromium', chromium], ['webkit', webkit]]) {
    let browser = null;
    try {
      for (const [surface, path] of Object.entries(targets)) {
        for (const viewport of viewports) {
          // WebKit's inspector can close after a single-file app interaction on this host.
          // Keep static app checks isolated; inline interactions run in both engines.
          if (!browser || !browser.isConnected()) browser = await engine.launch(browserName === 'chromium'
            ? { executablePath: resolveChromiumExecutable(), headless: true } : { headless: true });
          const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height },
            hasTouch: true, isMobile: viewport.name !== 'desktop', colorScheme: 'light' });
          const errors = [];
          page.on('pageerror', error => errors.push(error.message));
          await page.goto(pathToFileURL(path).href, { waitUntil: 'load' });
          if (surface === 'dashboard') await checkDashboard(page, viewport, browserName);
          else if (surface === 'report') await checkReport(page, viewport, browserName);
          else if (surface === 'inline') await checkInline(page, viewport, browserName);
          else await checkSources(page, viewport, browserName);
          assert.deepEqual(errors, [], `${browserName} ${surface} ${viewport.name} page errors`);
          await page.close().catch(() => {});
          if (browserName === 'webkit' && ['dashboard', 'report'].includes(surface)) {
            await browser.close().catch(() => {});
            browser = null;
          }
        }
      }
    } finally { await browser?.close().catch(() => {}); }
  }
  const desktopBrowser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
  try {
    for (const width of [390, 760]) {
      const page = await desktopBrowser.newPage({ viewport: { width, height: 844 } });
      await page.goto(pathToFileURL(targets.dashboard).href);
      const controls = await page.locator('.filters.filter-bar .custom-select-host > .filter-trigger')
        .evaluateAll(elements => elements.map(element => ({
          label: element.querySelector('.filter-label')?.getBoundingClientRect().height,
          width: element.getBoundingClientRect().width,
          height: element.getBoundingClientRect().height,
        })));
      assert.ok(controls[0].width >= 150 && controls[0].label <= 22 && controls[0].height <= 44 &&
        controls[1].width >= 100,
      `Narrow desktop filter chips should keep full labels without shrinking: ${width} ${JSON.stringify(controls)}`);
      if (width === 390) {
        assert.ok(await page.locator('.dashboard-custom-overflow button.dashboard-header-overflow-button').isVisible(),
          'Narrow mouse layouts retain the custom More menu');
        assert.equal(await page.locator('.dashboard-native-overflow').isVisible(), false,
          'The native picker appears only with a coarse touch pointer');
      }
      await checkWidth(page, `narrow desktop dashboard ${width}`);
      await page.close();
    }
  } finally { await desktopBrowser.close(); }
  console.log('PASS: touch-enabled mobile, tablet, landscape, and desktop geometry in Chromium and WebKit; app actions in Chromium; inline editing and Sources tabs in both engines.');
} finally { rmSync(workspace, { recursive: true, force: true }); }

SHA-256: d29d31c5a51c6b3f6e0507836d42188d946bf5cbf55d77867a80d76d14648544