← Files DataARCHIVED FILE

tests/mobile-touch-browser.smoke.mjs

21.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 } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromium, webkit } from 'playwright-core';
import { forceLocalDataAppMount, resolveChromiumExecutable, runDataAppFixtureBuild } from './browser-helpers.mjs';

const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const workspace = mkdtempSync(join(tmpdir(), 'data-mobile-touch-'));
const hostedPages = {};
function buildApp(surface) {
  const project = join(workspace, surface);
  cpSync(join(pluginRoot, 'templates/data-app/base'), project, { recursive: true,
    filter: path => !path.includes('/node_modules') && !path.includes('/dist') });
  if (surface === 'report') {
    const path = join(project, 'src/data.json');
    writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), surface }));
  }
  const result = runDataAppFixtureBuild(project, { pluginRoot });
  assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
  const path = join(project, 'dist/index.html');
  hostedPages[surface] = readFileSync(path, 'utf8');
  writeFileSync(path, forceLocalDataAppMount(hostedPages[surface]));
  return pathToFileURL(path).href;
}
const targets = { dashboard: buildApp('dashboard'), report: buildApp('report') };
async function chooseTopAction(page, label) {
  await page.locator('.dashboard-topbar-title').waitFor();
  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 more = page.locator('.dashboard-native-overflow select');
  await more.selectOption(value);
  assert.equal(await page.evaluate(() => document.querySelector('.dashboard-native-overflow select')?.selectedIndex ?? -1), -1,
    `${label}: selecting a command must leave no checkmarked option`);
}
try {
for (const [name, engine] of process.env.DATA_TOUCH_WEBKIT === '1'
  ? [['webkit', webkit]] : [['chromium', chromium]]) {
  const browser = await engine.launch(name === 'chromium'
    ? { executablePath: resolveChromiumExecutable(), headless: true } : { headless: true });
  try {
    for (const surface of ['dashboard', 'report']) {
      const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
      const errors = [];
      page.on('pageerror', error => errors.push(error.message));
      await page.goto(targets[surface], { waitUntil: 'load' });
      const more = page.getByRole('combobox', { name: 'More' });
      await more.waitFor();
      assert.ok((await more.boundingBox()).width >= 44,
        `${name} ${surface}: native actions need a usable hit target`);
      assert.equal(await more.evaluate(element => element.selectedIndex), -1,
        `${name} ${surface}: native action menu starts without a selection`);
      assert.equal(await more.locator('option').filter({ hasText: 'Choose action' }).count(), 0,
        `${name} ${surface}: native action menu has no placeholder option`);
      assert.ok((await more.locator('option').allTextContents()).every(text => /^[\p{L}\p{N}]/u.test(text)),
        `${name} ${surface}: native commands should have text labels instead of decorative glyphs`);
      const chartActions = page.locator('.dashboard-component[data-component-kind="chart"] .component-native-actions select').first();
      await chartActions.waitFor();
      const chartBox = await chartActions.boundingBox();
      assert.ok(chartBox?.width >= 44,
        `${name} ${surface}: card actions need a usable native picker target: ${JSON.stringify(chartBox)}`);
      const chartOptions = await chartActions.locator('option').allTextContents();
      assert.equal(await chartActions.evaluate(element => element.selectedIndex), -1,
        `${name} ${surface}: chart actions should start without a selected checkmark`);
      assert.ok(!chartOptions.includes('Choose action'), `${name} ${surface}: chart actions should have no placeholder`);
      assert.ok(chartOptions.includes('View data source')
        && (surface === 'report' || chartOptions.includes('Export chart')),
        `${name} ${surface}: chart actions should appear in the native picker: ${chartOptions}`);
      await chartActions.selectOption({ label: 'View data source' });
      assert.equal(await chartActions.evaluate(element => element.selectedIndex), -1,
        `${name} ${surface}: chart actions should reset after selection`);
      await page.locator('.source-sidebar').waitFor({ state: 'visible' });
      await page.locator('.source-sidebar').getByRole('button', { name: 'Close data source' }).click();
      await page.locator('.source-sidebar').waitFor({ state: 'hidden' });
      await chartActions.selectOption({ label: 'View data source' });
      await page.locator('.source-sidebar').waitFor({ state: 'visible' });
      await page.locator('.source-sidebar').getByRole('button', { name: 'Close data source' }).click();
      await page.locator('.source-sidebar').waitFor({ state: 'hidden' });
      if (surface === 'dashboard') {
        const date = page.getByRole('combobox', { name: 'Date range' });
        assert.ok((await date.boundingBox()).height >= 44, `${name}: date presets use a native hit region`);
        await date.selectOption({ index: 2 });
        assert.equal(await date.inputValue(), '', `${name}: the same date preset can be selected again`);
        await date.selectOption('custom');
        const calendar = page.locator('.date-range-calendar');
        await calendar.waitFor();
        if (process.env.DATA_TOUCH_CUSTOM_SCREENSHOT_PATH)
          await page.screenshot({ path: process.env.DATA_TOUCH_CUSTOM_SCREENSHOT_PATH });
        assert.equal(await calendar.locator('input[type="date"]').count(), 0,
          `${name}: custom dates should use the existing calendar`);
        await calendar.getByRole('button', { name: 'Monday, July 6, 2026' }).click();
        await calendar.getByRole('button', { name: 'Monday, July 20, 2026' }).click();
        await calendar.waitFor({ state: 'hidden' });
        const nativeSegment = page.locator('.native-filter-control[aria-label="Product segment"]');
        await nativeSegment.selectOption('Studio');
        assert.equal(await nativeSegment.inputValue(), 'Studio');
        assert.match(await page.locator('.native-filter-shell').filter({ has: nativeSegment }).textContent(), /Studio/);
        if (process.env.DATA_TOUCH_FILTER_SCREENSHOT_PATH)
          await page.screenshot({ path: process.env.DATA_TOUCH_FILTER_SCREENSHOT_PATH });
        await chooseTopAction(page, 'Schedule refresh');
        const scheduleDialog = page.getByRole('dialog', { name: 'Schedule refresh' });
        const repeat = page.locator('.native-filter-control[aria-label="Repeat schedule"]');
        assert.equal(await scheduleDialog.evaluate(element => element.contains(document.activeElement)
          && document.activeElement.classList.contains('dialog-header')), true,
        `${name}: opening Schedule refresh focuses its heading, not the native Repeat picker`);
        assert.equal(await repeat.evaluate(element => element === document.activeElement), false,
          `${name}: Repeat should wait for an intentional tap`);
        await repeat.selectOption('weekly');
        assert.equal(await repeat.inputValue(), 'weekly');
        const time = page.locator('.native-filter-control[aria-label="Refresh time"]');
        await time.selectOption({ index: 1 });
        assert.notEqual(await time.inputValue(), '');
        await page.getByRole('dialog', { name: 'Schedule refresh' }).getByRole('button', { name: 'Close' }).click();
        await chooseTopAction(page, 'Switch theme');
        await page.locator('.theme-drawer.is-open').waitFor();
        await page.getByRole('button', { name: 'Close theme picker' }).click();
        await chooseTopAction(page, 'Switch theme');
        await page.locator('.theme-drawer.is-open').waitFor();
        await page.getByRole('button', { name: 'Close theme picker' }).click();
      }
      const info = page.locator('.dashboard-component .info-wrap > button.info').first();
      await info.scrollIntoViewIfNeeded();
      const infoBox = await info.boundingBox();
      await page.touchscreen.tap(infoBox.x + infoBox.width / 2, infoBox.y + infoBox.height / 2);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
      assert.equal(await page.locator('.info-tooltip[data-tooltip-portal="true"]').count(), 1,
        `${name} ${surface}: tap should show one tooltip`);
      await page.touchscreen.tap(infoBox.x + infoBox.width / 2, infoBox.y + infoBox.height / 2);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'hidden' });
      await page.touchscreen.tap(infoBox.x + infoBox.width / 2, infoBox.y + infoBox.height / 2);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
      await page.touchscreen.tap(20, 310);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'hidden' });
      await page.mouse.move(infoBox.x + infoBox.width / 2, infoBox.y + infoBox.height / 2);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
      await page.mouse.move(20, 310);
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'hidden' });
      await info.focus();
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
      await page.keyboard.press('Tab');
      await page.locator('.info-tooltip[data-tooltip-portal="true"]').waitFor({ state: 'hidden' });
      assert.deepEqual(errors, [], `${name} ${surface} page errors`);
      await page.close();
    }
    const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
    const pageErrors = [];
    page.on('pageerror', error => pageErrors.push(error.message));
    await page.goto(targets.dashboard, { waitUntil: 'load' });
    await chooseTopAction(page, 'Ask ChatGPT');
    const dialog = page.getByRole('dialog', { name: 'Ask ChatGPT about this dashboard' });
    await dialog.waitFor();
    if (process.env.DATA_TOUCH_SCREENSHOT_PATH)
      await page.screenshot({ path: process.env.DATA_TOUCH_SCREENSHOT_PATH });
    const shape = await dialog.evaluate(element => {
      const box = element.getBoundingClientRect();
      return { bottom: box.bottom, width: box.width, height: box.height, viewport: window.innerHeight };
    });
    assert.ok(shape.height >= 700 && shape.width === 390 && Math.abs(shape.bottom - shape.viewport) < 1,
      `${name}: Ask composer should fill the phone viewport: ${JSON.stringify(shape)}`);
    assert.equal(await dialog.getAttribute('aria-modal'), 'true', `${name}: phone composer traps focus`);
    await page.setViewportSize({ width: 844, height: 390 });
    assert.equal(await dialog.getAttribute('aria-modal'), null, `${name}: landscape composer releases modal semantics`);
    assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden',
      `${name}: rotating to landscape releases page scroll`);
    await page.setViewportSize({ width: 390, height: 844 });
    assert.equal(await dialog.getAttribute('aria-modal'), 'true', `${name}: portrait composer restores modal semantics`);
    assert.equal(await page.evaluate(() => document.body.style.overflow), 'hidden',
      `${name}: rotating back to portrait locks page scroll`);
    assert.equal(await dialog.evaluate(element => getComputedStyle(element).boxShadow), 'none',
      `${name}: the full-screen Ask view should have no bottom edge above the keyboard`);
    const askHeader = await dialog.evaluate(element => {
      const header = element.querySelector('.dashboard-ask-mobile-heading');
      const title = header.querySelector('span');
      const close = header.querySelector('button');
      return { height: header.getBoundingClientRect().height, fontSize: getComputedStyle(title).fontSize,
        fontWeight: getComputedStyle(title).fontWeight, icon: close.querySelector('[data-dashboard-icon="cross"]')?.getBoundingClientRect().width,
        closeTop: close.getBoundingClientRect().top - header.getBoundingClientRect().top,
        closeBottom: header.getBoundingClientRect().bottom - close.getBoundingClientRect().bottom };
    });
    assert.ok(askHeader.height === 50 && askHeader.fontSize === '16px' && askHeader.fontWeight === '500'
      && askHeader.icon === 20 && Math.abs(askHeader.closeTop - askHeader.closeBottom) <= 1,
    `${name}: Ask header should match the balanced Export chart mobile header: ${JSON.stringify(askHeader)}`);
    const suggestions = dialog.getByRole('group', { name: 'Suggested actions' });
    const initialSuggestions = await suggestions.locator('a, button').count();
    assert.ok(initialSuggestions > 0, `${name}: Ask composer should offer suggestions`);
    await dialog.getByRole('textbox', { name: 'Question for ChatGPT' }).fill('What changed?');
    assert.equal(await suggestions.locator('a, button').count(), initialSuggestions,
      `${name}: typing on a phone should keep suggestions available`);
    const icons = await dialog.evaluate(element => ({
      close: element.querySelector('.dashboard-ask-mobile-heading button [data-dashboard-icon="cross"]')?.getBoundingClientRect().width,
      send: element.querySelector('.dashboard-ask-submit [data-dashboard-icon="sendUp"]')?.getBoundingClientRect().width,
    }));
    assert.ok(icons.close >= 20 && icons.send >= 20,
      `${name}: close and send icons should be legible on a phone: ${JSON.stringify(icons)}`);
    const assertSendInside = async () => {
      const field = await dialog.getByRole('textbox', { name: 'Question for ChatGPT' }).boundingBox();
      const send = await dialog.locator('.dashboard-ask-submit').boundingBox();
      assert.ok(send.x >= field.x && send.y >= field.y
        && send.x + send.width <= field.x + field.width
        && send.y + send.height <= field.y + field.height,
        `Send must remain inside the filled field: ${JSON.stringify({ field, send })}`);
    };
    await assertSendInside();
    await page.setViewportSize({ width: 390, height: 420 });
    await assertSendInside();
    await page.setViewportSize({ width: 390, height: 844 });
    await dialog.getByRole('button', { name: 'Close Ask ChatGPT' }).click();
    await dialog.waitFor({ state: 'hidden' });
    if (name === 'chromium') {
      await chooseTopAction(page, 'Edit text and layout');
      const handles = page.locator('.block-drag-keyboard');
      const firstCard = await page.locator('.sortable-item > .dashboard-component').nth(0).boundingBox();
      const nextCard = await page.locator('.sortable-item > .dashboard-component').nth(1).boundingBox();
      assert.ok(nextCard.y - (firstCard.y + firstCard.height) <= 20,
        'Mobile edit cards keep their normal spacing without visible Move controls');
      assert.ok((await handles.first().boundingBox()).width <= 1,
        'The keyboard move control remains available without taking up layout space');
      const saveStyle = await page.getByRole('button', { name: 'Save', exact: true }).evaluate(element => {
        const style = getComputedStyle(element);
        return { background: style.backgroundColor, text: style.color };
      });
      assert.notEqual(saveStyle.background, 'rgba(0, 0, 0, 0)', 'Save stays a primary action on phones');
      assert.notEqual(saveStyle.background, saveStyle.text, 'Save keeps contrasting text');
      if (process.env.DATA_MOBILE_EDIT_SCREENSHOT_PATH)
        await page.screenshot({ path: process.env.DATA_MOBILE_EDIT_SCREENSHOT_PATH });
      const order = () => page.locator('.sortable-item[data-sortable-item-id]')
        .evaluateAll(elements => elements.map(element => element.dataset.sortableItemId));
      const originalOrder = await order();
      const client = await page.context().newCDPSession(page);
      let x = firstCard.x + 70, y = firstCard.y + 26;
      await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
      await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y + 55, id: 1 }] });
      await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
      assert.deepEqual(await order(), originalOrder, 'A swipe before the hold threshold scrolls without moving a card');
      await page.locator('.sortable-item[data-sortable-item-id="active-users"]')
        .scrollIntoViewIfNeeded();
      const heldCard = await page.locator('.sortable-item > .dashboard-component').nth(0).boundingBox();
      const destination = await page.locator('.sortable-item > .dashboard-component').nth(1).boundingBox();
      x = heldCard.x + 70; y = heldCard.y + 72;
      await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] });
      await page.waitForTimeout(420);
      for (let step = 1; step <= 12; step++) {
        await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
          x, y: y + (destination.y + destination.height / 2 - y) * step / 12, id: 1,
        }] });
        await page.evaluate(() => new Promise(requestAnimationFrame));
      }
      await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
      await page.evaluate(() => new Promise(requestAnimationFrame));
      assert.notDeepEqual(await order(), originalOrder, 'A touch drag reorders the stacked phone cards');
      await page.getByRole('button', { name: 'Cancel', exact: true }).click();
      assert.deepEqual(await order(), originalOrder, 'Cancel restores the original order after a touch drag');
      await page.locator('.block-drag-preview').waitFor({ state: 'hidden' });
      const chartActions = page.locator('[data-component-id="usage-trend"] .component-native-actions select');
      await chartActions.selectOption({ label: 'Edit chart' });
      const chartEditor = page.locator('.chart-editor-dialog');
      await chartEditor.waitFor({ state: 'visible' });
      const split = chartEditor.getByRole('combobox', { name: 'Split series by' });
      await split.waitFor();
      assert.ok((await split.locator('option').allTextContents()).includes('No series'),
        'The native Split by picker labels its empty series choice');
      assert.equal(await split.evaluate(element => element.parentElement?.querySelector('.select-value')?.textContent), 'No series',
        'The collapsed Split by control also labels the empty selection');
      await split.selectOption({ label: 'Segment' });
      assert.equal(await split.inputValue(), 'segment');
      const editorSave = chartEditor.getByRole('button', { name: 'Save', exact: true });
      assert.equal(await editorSave.isEnabled(), true, 'Save becomes available after editing the chart');
      const editorSaveStyle = await editorSave.evaluate(element => {
        const style = getComputedStyle(element);
        return { background: style.backgroundColor, text: style.color };
      });
      assert.notEqual(editorSaveStyle.background, 'rgba(0, 0, 0, 0)', 'Chart editor Save has a filled primary style');
      assert.notEqual(editorSaveStyle.background, editorSaveStyle.text, 'Chart editor Save has contrasting text');
      if (process.env.DATA_MOBILE_CHART_EDITOR_SCREENSHOT_PATH)
        await page.screenshot({ path: process.env.DATA_MOBILE_CHART_EDITOR_SCREENSHOT_PATH });
      await chartEditor.getByRole('button', { name: 'Cancel', exact: true }).click();
    }
    assert.deepEqual(pageErrors, [], `${name} mobile editing page errors`);
    await page.close();
    if (name === 'chromium') {
      const hosted = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
      await hosted.route('https://qa.example.chatgpt.site/**', route => {
        const path = new URL(route.request().url()).pathname;
        const payload = path === '/api/snapshot'
          ? JSON.parse(readFileSync(join(pluginRoot, 'templates/data-app/base/src/data.json'), 'utf8'))
          : path === '/api/presentation' ? { canEdit: true, presentation: {}, revision: 0 } : null;
        return route.fulfill(payload
          ? { contentType: 'application/json', body: JSON.stringify(payload) }
          : { contentType: 'text/html', body: hostedPages.dashboard });
      });
      await hosted.goto('https://qa.example.chatgpt.site/dashboard', { waitUntil: 'load' });
      await chooseTopAction(hosted, 'Create a copy');
      const handoff = hosted.getByRole('dialog', { name: 'Open in ChatGPT', exact: true });
      await handoff.waitFor();
      const openOnWeb = handoff.getByRole('link', { name: 'Open on web' });
      assert.match(await openOnWeb.getAttribute('href'), /^https:\/\/chatgpt\.com\//,
        `${name}: the native picker exposes a real ChatGPT destination link`);
      const popupPromise = hosted.waitForEvent('popup', { timeout: 5000 });
      await openOnWeb.click();
      const popup = await popupPromise;
      await popup.waitForURL(/^https:\/\/chatgpt\.com\//, { timeout: 10000 });
      assert.match(popup.url(), /^https:\/\/chatgpt\.com\//,
        `${name}: a menu command must preserve the hosted ChatGPT handoff`);
      await popup.close();
      await hosted.close();
    }
  } finally { await browser.close(); }
}
console.log(`PASS: touch release, drag cancellation, tooltip tap and dismissal, mobile Ask in ${process.env.DATA_TOUCH_WEBKIT === '1' ? 'WebKit' : 'Chromium'}`);
} finally { rmSync(workspace, { recursive: true, force: true }); }

SHA-256: e43875eea1ff169385f344f05df7e2ad2b7111206da8a6bbddb4d79a279c77ca