← Files DataARCHIVED FILE

tests/mobile-chart-touch-browser.smoke.mjs

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

↓ Download file

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

const plugin = join(dirname(fileURLToPath(import.meta.url)), '..');
const project = mkdtempSync(join(tmpdir(), 'data-chart-touch-'));
try {
  cpSync(join(plugin, 'templates/data-app/base'), project, { recursive: true,
    filter: path => !path.includes('/node_modules') && !path.includes('/dist') });
  const build = runDataAppFixtureBuild(project, { pluginRoot: plugin });
  assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
  const html = forceLocalDataAppMount(readFileSync(join(project, 'dist/index.html'), 'utf8'));
  const browser = await chromium.launch({ executablePath: resolveChromiumExecutable(), headless: true });
  try {
    const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
    const errors = [];
    page.on('pageerror', error => errors.push(error.message));
    page.on('crash', () => errors.push('Page crashed'));
    await page.route('https://qa-static.chatgpt.site/**', route => route.fulfill({
      status: 200, contentType: 'text/html', body: html,
    }));
    await page.goto('https://qa-static.chatgpt.site/dashboard.html');
    const chart = page.locator('[data-component-id="engagement-heatmap"]');
    const cells = chart.locator('[data-heatmap-cell]');
    await cells.first().scrollIntoViewIfNeeded();
    await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
    const tap = async locator => {
      const box = await locator.boundingBox();
      const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
      await page.touchscreen.tap(point.x, point.y);
      return point;
    };
    const first = await tap(cells.nth(5));
    const tooltip = chart.locator('.recharts-tooltip-wrapper .chart-tooltip');
    await tooltip.waitFor({ state: 'visible' });
    await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
    let bounds = await tooltip.boundingBox();
    assert.ok(bounds.y + bounds.height <= first.y - 20,
      `First touch should reveal a tooltip clear of the finger: ${JSON.stringify({ first, bounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0);

    const second = await tap(cells.nth(6));
    await tooltip.waitFor({ state: 'visible' });
    bounds = await tooltip.boundingBox();
    assert.ok(bounds.y + bounds.height <= second.y - 20,
      `Moving to another cell should keep the tooltip above the finger: ${JSON.stringify({ second, bounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'A different cell starts a new preview instead of opening Ask');
    await page.touchscreen.tap(8, 80);
    await tooltip.waitFor({ state: 'hidden', timeout: 1500 });
    await tap(cells.nth(6));
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'After tapping away, the same mark needs a fresh preview');
    await tap(cells.nth(6));
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor();
    await page.touchscreen.tap(8, 80);
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor({ state: 'hidden' });

    const scatter = page.locator('[data-component-id="engagement-scatter"]');
    const dots = scatter.locator('.recharts-scatter-symbol');
    await dots.first().scrollIntoViewIfNeeded();
    assert.ok(await dots.count() > 1, 'The scatter fixture has distinct marks');
    const scatterPoint = await tap(dots.first());
    const scatterTip = scatter.locator('.chart-tooltip');
    await scatterTip.waitFor({ state: 'visible' });
    const scatterBounds = await scatterTip.boundingBox();
    assert.ok(scatterBounds.y + scatterBounds.height <= scatterPoint.y - 20 ||
      scatterBounds.y >= scatterPoint.y + 20,
    `Scatter tooltip should avoid the finger: ${JSON.stringify({ scatterPoint, scatterBounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'The first scatter tap previews its tooltip');
    await tap(dots.nth(1));
    await scatterTip.waitFor({ state: 'visible' });
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'A different scatter point previews instead of selecting');
    await tap(dots.nth(1));
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor();
    await page.touchscreen.tap(8, 80);
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor({ state: 'hidden' });

    // A finger scrub must resolve the point beneath the finger, rather than
    // retaining the payload from touchstart while merely moving its card.
    await dots.first().scrollIntoViewIfNeeded();
    const dotPositions = await dots.evaluateAll(elements => elements.map(element => {
      const box = element.getBoundingClientRect();
      return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
    }).filter(point => point.x > 0 && point.x < innerWidth && point.y > 0 && point.y < innerHeight));
    const [from, to] = dotPositions.reduce((best, first) => dotPositions.reduce((pair, second) =>
      Math.abs(second.x - first.x) > 70 && Math.abs(second.x - first.x) > Math.abs(second.y - first.y) * 1.25 &&
      Math.abs(second.x - first.x) > Math.abs(pair[1].x - pair[0].x) ? [first, second] : pair, best),
    [dotPositions[0], dotPositions[0]]);
    assert.ok(Math.abs(to.x - from.x) > 70, 'Scatter fixture has horizontally separated visible points');
    const scrubClient = await page.context().newCDPSession(page);
    await page.touchscreen.tap(from.x, from.y);
    await scatterTip.waitFor({ state: 'visible' });
    const initialScatterTip = await scatterTip.innerText();
    await scrubClient.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...from, id: 11 }] });
    for (let step = 1; step <= 12; step++) {
      const x = from.x + (to.x - from.x) * step / 12;
      const y = from.y + (to.y - from.y) * step / 12;
      await scrubClient.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y, id: 11 }] });
      await page.waitForTimeout(20);
    }
    await page.waitForTimeout(100);
    const endingScatterTip = await scatterTip.innerText();
    await scrubClient.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
    assert.notEqual(endingScatterTip, initialScatterTip,
      `Scatter scrub must change the active row: ${JSON.stringify({ initialScatterTip, endingScatterTip })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'Scrubbing scatter points must not open Ask');
    assert.deepEqual(errors, [], 'Scatter scrub must leave the page mounted');
    await page.touchscreen.tap(8, 80);
    await scatterTip.waitFor({ state: 'hidden' });
    await page.touchscreen.tap(to.x, to.y);
    assert.equal(await scatterTip.innerText(), endingScatterTip,
      'The scrub should end on the same data point as a direct tap');
    await page.touchscreen.tap(8, 80);
    await scatterTip.waitFor({ state: 'hidden' });
    await scatter.locator('.chart-frame').evaluate((frame, point) => frame.dispatchEvent(
      new CustomEvent('data-chart-scrub', { bubbles: true, detail: point })), to);
    await scatterTip.waitFor({ state: 'visible' });
    assert.equal(await scatterTip.innerText(), endingScatterTip,
      'A later gesture can reactivate the same point after dismissing the tooltip');
    await page.touchscreen.tap(8, 80);
    await scatterTip.waitFor({ state: 'hidden' });

    // WebKit can send a zero-detail compatibility click after touch release,
    // sometimes preceded by a mouse pointerdown on an inner SVG mark.
    for (const mark of [cells.nth(5), dots.first()]) {
      await mark.scrollIntoViewIfNeeded();
      await mark.evaluate(element => {
        const box = element.getBoundingClientRect();
        const options = { bubbles: true, clientX: box.left + box.width / 2,
          clientY: box.top + box.height / 2 };
        element.dispatchEvent(new PointerEvent('pointerdown', { ...options, pointerId: 77, pointerType: 'touch' }));
        element.dispatchEvent(new PointerEvent('pointerup', { ...options, pointerId: 77, pointerType: 'touch' }));
        element.dispatchEvent(new PointerEvent('pointerdown', { ...options, pointerId: 1, pointerType: 'mouse' }));
        element.dispatchEvent(new MouseEvent('click', { ...options, detail: 0 }));
      });
      assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
        'A zero-detail compatibility click after a touch previews instead of selecting');
      await page.touchscreen.tap(8, 80);
    }

    const frame = chart.locator('.chart-frame');
    const plot = await frame.boundingBox();
    await page.touchscreen.tap(plot.x + plot.width - 5, plot.y + 3);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'A touch on empty plot space cannot launch Ask');
    const start = await cells.nth(9).boundingBox();
    const client = await page.context().newCDPSession(page);
    const x = start.x + start.width / 2, y = start.y + start.height / 2;
    await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
    await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - 95, id: 1 }] });
    await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
      'A scroll gesture through a mark cannot select it');
    assert.equal(await page.locator('.dashboard-ask-panel[data-phase="compose"]').count(), 0,
      'A chart drag cannot open the Ask composer');
    await tooltip.waitFor({ state: 'hidden', timeout: 1500 });

    const line = page.locator('[data-component-id="usage-trend"] .recharts-line-curve').first();
    await page.locator('[data-component-id="usage-trend"]').evaluate(element => element.scrollIntoView({ block: 'center' }));
    for (let attempt = 0; attempt < 3; attempt++) {
      try { await line.scrollIntoViewIfNeeded({ timeout: 3000 }); break; }
      catch (error) { if (attempt === 2) throw error; }
    }
    const linePoint = await line.evaluate(element => {
      const point = element.getPointAtLength(element.getTotalLength() * .4)
        .matrixTransform(element.getScreenCTM());
      return { x: point.x, y: point.y };
    });
    await page.touchscreen.tap(linePoint.x, linePoint.y);
    const lineTooltip = page.locator('[data-component-id="usage-trend"] .chart-tooltip');
    await lineTooltip.waitFor({ state: 'visible' });
    const lineBounds = await lineTooltip.boundingBox();
    assert.ok(lineBounds.y + lineBounds.height <= linePoint.y - 20,
      `Line chart touch details should clear the finger: ${JSON.stringify({ linePoint, lineBounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0);
    await page.touchscreen.tap(8, 80);
    await lineTooltip.waitFor({ state: 'hidden' });
    await page.mouse.move(linePoint.x, linePoint.y);
    await lineTooltip.waitFor({ state: 'visible' });
    await page.mouse.move(8, 80);
    await lineTooltip.waitFor({ state: 'hidden' });

    const bars = page.locator('[data-component-id="growth-drivers"] .recharts-bar-rectangle');
    await bars.nth(1).scrollIntoViewIfNeeded();
    const barPoint = await tap(bars.nth(1));
    const barTooltip = page.locator('[data-component-id="growth-drivers"] .chart-tooltip');
    await barTooltip.waitFor({ state: 'visible' });
    const barBounds = await barTooltip.boundingBox();
    assert.ok(barBounds.y + barBounds.height <= barPoint.y - 20,
      `Standard bar chart tooltips should clear the finger: ${JSON.stringify({ barPoint, barBounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0);
    await tap(bars.nth(1));
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor();
    await page.touchscreen.tap(8, 80);
    await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor({ state: 'hidden' });

    // Repeated touch previews and scroll starts used to destabilize the phone
    // page. Exercise distinct marks so a second tap never intentionally pins.
    for (let index = 0; index < 24; index++) {
      await cells.nth(5).scrollIntoViewIfNeeded();
      await tap(cells.nth(index % 2 ? 5 : 6));
      if (index % 6 === 5) {
        const cell = await cells.nth(5).boundingBox();
        const dragX = cell.x + cell.width / 2, dragY = cell.y + cell.height / 2;
        await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: dragX, y: dragY, id: 2 }] });
        await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: dragX, y: dragY - 75, id: 2 }] });
        await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
      }
      assert.ok(await page.locator('[data-component-id="engagement-heatmap"]').count(),
        `Chart page should remain mounted after touch ${index + 1}`);
    }
    // Slow movement used to reach Recharts before the 12px scroll threshold.
    // Include pointer cancellation: iOS continues Touch Events after it cancels
    // the corresponding Pointer Events for native page scrolling.
    await chart.evaluate(element => {
      element.dataset.touchMoves = "0";
      element.addEventListener('touchmove', () => {
        element.dataset.touchMoves = String(Number(element.dataset.touchMoves) + 1);
      });
    });
    await cells.nth(5).scrollIntoViewIfNeeded();
    const slow = await cells.nth(5).boundingBox();
    const slowX = slow.x + slow.width / 2, slowY = slow.y + slow.height / 2;
    await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: slowX, y: slowY, id: 3 }] });
    for (const distance of [1, 3, 7, 11, 16, 32, 64]) {
      await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: slowX, y: slowY - distance, id: 3 }] });
    }
    await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
    assert.equal(await chart.getAttribute('data-touch-moves'), '0',
      'Scroll movement must never reach live chart touch handlers, even below activation threshold');
    assert.deepEqual(errors, [], 'Repeated chart touches should not crash the page');

    const stage = page.locator('[data-component-id="activation-funnel"] .chart-funnel-stage').nth(2);
    await stage.scrollIntoViewIfNeeded();
    // The stress gestures above can leave the document in motion. Center the
    // stage and wait for its hit target to settle before testing a tap.
    await stage.evaluate(element => element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }));
    await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() =>
      requestAnimationFrame(resolve))));
    const hit = await stage.evaluate(element => {
      const box = element.getBoundingClientRect();
      const target = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
      return { stage: element.contains(target), target: target?.className?.baseVal || target?.className || target?.tagName,
        box: { x: box.x, y: box.y, width: box.width, height: box.height } };
    });
    assert.ok(hit.stage, `Funnel stage must be the touch hit target: ${JSON.stringify(hit)}`);
    const stagePoint = await tap(stage);
    const stageTip = page.locator('[data-component-id="activation-funnel"] .chart-funnel-tooltip[data-pinned]');
    try { await stageTip.waitFor({ state: 'visible', timeout: 2000 }); }
    catch (error) {
      const state = await stage.evaluate(element => ({ active: element.dataset.active,
        focused: document.activeElement?.className,
        tooltip: element.closest('.chart-funnel')?.querySelector('.chart-funnel-tooltip')?.outerHTML.slice(0, 300) }));
      throw new Error(`Funnel touch did not pin its tooltip: ${JSON.stringify({ stagePoint, hit, state })}`, { cause: error });
    }
    const stageBounds = await stageTip.boundingBox();
    assert.ok(stageBounds.y + stageBounds.height <= stagePoint.y - 20 || stageBounds.y >= stagePoint.y + 20,
      `Funnel touch details must avoid the finger: ${JSON.stringify({ stagePoint, stageBounds })}`);
    assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0);
    await stageTip.getByRole('button', { name: 'Done' }).click();
    assert.deepEqual(errors, [], 'No chart touch runtime errors');
    await page.close();
  } finally { await browser.close(); }
  // Linux WPE MiniBrowser can abort in its EGL renderer on repeated SVG touches.
  // Keep the complete WebKit gesture coverage on macOS; CI still runs this
  // script's Chromium gestures and WebKit responsive coverage separately.
  if (process.env.DATA_APP_SKIP_WEBKIT_TOUCH !== '1') {
    const safari = await webkit.launch({ headless: true });
    try {
      const page = await safari.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
      const errors = [];
      page.on('pageerror', error => errors.push(error.message));
      page.on('crash', () => errors.push('Page crashed'));
      await page.route('https://qa-static.chatgpt.site/**', route => route.fulfill({
        status: 200, contentType: 'text/html', body: html,
      }));
      await page.goto('https://qa-static.chatgpt.site/dashboard.html');
      for (const [id, selector] of [
        ['engagement-heatmap', '[data-heatmap-cell]'],
        ['engagement-scatter', '.recharts-scatter-symbol'],
      ]) {
        const chart = page.locator(`[data-component-id="${id}"]`);
        const marks = chart.locator(selector);
        await marks.first().scrollIntoViewIfNeeded();
        const bounds = await marks.first().boundingBox();
        await page.touchscreen.tap(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
        await chart.locator('.chart-tooltip').waitFor({ state: 'visible' });
        assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
          `The first ${id} tap must show a tooltip without Ask in WebKit`);
        const other = await marks.nth(1).boundingBox();
        await page.touchscreen.tap(other.x + other.width / 2, other.y + other.height / 2);
        assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0,
          `A different ${id} mark must update the preview in WebKit`);
        await page.touchscreen.tap(other.x + other.width / 2, other.y + other.height / 2);
        await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor();
        await page.touchscreen.tap(8, 80);
        await page.getByRole('dialog', { name: 'Selected chart data' }).waitFor({ state: 'hidden' });
      }
      const scatter = page.locator('[data-component-id="engagement-scatter"]');
      const dots = scatter.locator('.recharts-scatter-symbol');
      await dots.first().scrollIntoViewIfNeeded();
      const points = await dots.evaluateAll(elements => elements.map(element => {
        const box = element.getBoundingClientRect();
        return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
      }).filter(point => point.x > 0 && point.x < innerWidth && point.y > 0 && point.y < innerHeight));
      const [start, end] = points.reduce((best, first) => points.reduce((pair, second) =>
        Math.abs(second.x - first.x) > 70 && Math.abs(second.x - first.x) > Math.abs(second.y - first.y) * 1.25 &&
        Math.abs(second.x - first.x) > Math.abs(pair[1].x - pair[0].x) ? [first, second] : pair, best),
      [points[0], points[0]]);
      assert.ok(Math.abs(end.x - start.x) > 70, 'WebKit has horizontally separated scatter points');
      await page.touchscreen.tap(start.x, start.y);
      const firstTip = await scatter.locator('.chart-tooltip').innerText();
      await page.evaluate(async ({ start, end }) => {
        const root = document.querySelector('[data-component-id="engagement-scatter"] .chart-frame');
        for (let step = 1; step <= 12; step++) {
          const x = start.x + (end.x - start.x) * step / 12;
          const y = start.y + (end.y - start.y) * step / 12;
          root.dispatchEvent(new CustomEvent('data-chart-scrub', { bubbles: true, detail: { x, y } }));
          await new Promise(resolve => requestAnimationFrame(resolve));
        }
      }, { start, end });
      await page.waitForTimeout(100);
      const endTip = await scatter.locator('.chart-tooltip').innerText();
      assert.notEqual(endTip, firstTip,
        'WebKit scatter scrub should move the active tooltip between distinct points');
      assert.equal(await page.getByRole('dialog', { name: 'Selected chart data' }).count(), 0);
      await page.touchscreen.tap(8, 80);
      await page.touchscreen.tap(end.x, end.y);
      assert.equal(await scatter.locator('.chart-tooltip').innerText(), endTip,
        'WebKit should resolve the same destination as a direct tap');
      assert.deepEqual(errors, [], 'WebKit first taps leave both chart types mounted');
      await page.close();
    } finally { await safari.close(); }
  }
  console.log(process.env.DATA_APP_SKIP_WEBKIT_TOUCH === '1'
    ? 'PASS: Chromium chart gestures; WebKit chart gestures skipped on Linux WPE CI.'
    : 'PASS: Chromium and WebKit scatter scrubs update the tooltip; heatmap/scatter first taps preview, repeated taps select, and drags do not open Ask.');
} finally { rmSync(project, { recursive: true, force: true }); }

SHA-256: 35c6f126a875b7c6f7303fc3c2abf8b3f030b7935ce510c2ac8132a94b2f9991