← Files DataARCHIVED FILE

tests/mobile-tab-overflow-browser.smoke.mjs

8.42 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 } 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-tab-overflow-'));
const pages = Array.from({ length: 9 }, (_, index) => ({ id: `page-${index}`, label: `Dashboard page ${index + 1}` }));
const generic = Array.from({ length: 8 }, (_, index) => ({ id: `section-${index}`, label: `Analysis section ${index + 1}` }));
const metrics = Array.from({ length: 6 }, (_, index) => ({ id: `metric-${index}`, title: `Metric ${index + 1}`, value: `${index + 1}K` }));

try {
  cpSync(join(plugin, 'templates/data-app/base'), project, { recursive: true,
    filter: path => !path.includes('/node_modules') && !path.includes('/dist') });
  writeFileSync(join(project, 'src/content/dashboard/DashboardContent.jsx'), `
import React, { useState } from 'react';
import { MetricCardTabs, Tabs, useDashboardTabs } from '../../data-app-public.jsx';
export function DashboardContent() {
  const { activeTabId } = useDashboardTabs(${JSON.stringify(pages)});
  const [section, setSection] = useState('section-0');
  const [metric, setMetric] = useState('metric-0');
  return <article data-active-page={activeTabId} style={{ width: 'min(100%, 420px)', padding: 8 }}>
    <Tabs label="Analysis sections" items={${JSON.stringify(generic)}} value={section} onChange={setSection} />
    <MetricCardTabs ariaLabel="Metrics" items={${JSON.stringify(metrics)}} selectedId={metric} onChange={setMetric} />
  </article>;
}`);
  const build = runDataAppFixtureBuild(project, { pluginRoot: plugin });
  assert.equal(build.status, 0, build.stdout + build.stderr);
  const html = forceLocalDataAppMount(readFileSync(join(project, 'dist/index.html'), 'utf8'));
  if (process.env.DATA_TAB_QA_OUTPUT) writeFileSync(process.env.DATA_TAB_QA_OUTPUT, html);

  for (const engine of [chromium, webkit]) {
    const browser = await engine.launch(engine === chromium ? { executablePath: resolveChromiumExecutable(), headless: true } : { headless: true });
    try {
      for (const width of [390, 1100]) {
        const page = await browser.newPage({ viewport: { width, height: 844 }, isMobile: width === 390, hasTouch: width === 390 });
        const errors = [];
        page.on('pageerror', error => errors.push(error.message));
        await page.route('https://tabs-qa.chatgpt.site/**', route => route.fulfill({ contentType: 'text/html', body: html }));
        await page.goto('https://tabs-qa.chatgpt.site/dashboard.html');
        assert.equal(await page.locator('.dashboard-tabs-collapse.is-open .dashboard-tabs-collapse-inner')
          .evaluate(node => getComputedStyle(node).opacity), '1',
        `${engine.name()} ${width}: the open dashboard tabs must be visible`);
        const underline = () => page.evaluate(() => {
          const bounds = selector => document.querySelector(selector).getBoundingClientRect();
          const mark = bounds('.dashboard-tabs .tab-active-indicator');
          const text = bounds('.dashboard-tab[aria-selected="true"] .dashboard-tab-label');
          const header = bounds('.dashboard-topbar');
          return { x: mark.x, width: mark.width, y: mark.y, textX: text.x, textWidth: text.width,
            borderY: header.bottom - parseFloat(getComputedStyle(document.querySelector('.dashboard-topbar')).borderBottomWidth) };
        });
        const assertUnderline = async context => {
          const result = await underline();
          assert.ok(Math.abs(result.y - result.borderY) <= 1,
            `${engine.name()} ${width} ${context}: underline must sit on the header divider: ${JSON.stringify(result)}`);
          assert.ok(Math.abs(result.x - result.textX) < 1 && Math.abs(result.width - result.textWidth) < 1,
            `${engine.name()} ${width} ${context}: underline must align with the label: ${JSON.stringify(result)}`);
        };
        await assertUnderline('initial tab');
        for (const selector of ['.dashboard-tabs-inner', '.tabs[aria-label="Analysis sections"]', '.data-metric-card-tabs-list']) {
          const strip = page.locator(selector);
          await strip.waitFor();
          const initial = await strip.evaluate(node => ({ width: node.clientWidth, total: node.scrollWidth,
            left: node.scrollLeft, fade: getComputedStyle(node).maskImage, right: node.dataset.scrollRight }));
          assert.ok(initial.total > initial.width + 30, `${engine.name()} ${width} ${selector} should overflow: ${JSON.stringify(initial)}; tabs=${await strip.locator('[role="tab"]').count()}`);
          assert.equal(initial.right, 'true', `${selector} should indicate more tabs to the right`);
          assert.ok(initial.fade.includes('gradient'), `${selector} needs a visible edge fade`);

          await strip.scrollIntoViewIfNeeded();
          if (width === 390 && engine === chromium) {
            const bounds = await strip.boundingBox();
            const client = await page.context().newCDPSession(page);
            const y = bounds.y + bounds.height / 2, x = bounds.x + bounds.width * .8;
            await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 21 }] });
            for (let step = 1; step <= 8; step++) {
              await client.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x - step * 26, y, id: 21 }] });
            }
            await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
            await page.waitForTimeout(200);
            assert.ok(await strip.evaluate(node => node.scrollLeft) > 20,
              `A mobile swipe beginning on a tab must scroll ${selector}`);
          } else if (width === 1100) {
            const bounds = await strip.boundingBox();
            await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
            await page.mouse.wheel(0, 145);
            assert.ok(await strip.evaluate(node => node.scrollLeft) > 20,
              `Desktop wheel should scroll ${selector} horizontally`);
          }
          await strip.evaluate(node => node.scrollLeft = node.scrollWidth);
          await page.waitForFunction(selector => document.querySelector(selector)?.dataset.scrollLeft === 'true' &&
            document.querySelector(selector)?.dataset.scrollRight === 'false', selector);
          assert.equal(await strip.locator('[role="tab"]').last().isVisible(), true);
        }
        await page.getByRole('tab', { name: 'Dashboard page 1' }).click();
        await page.getByRole('tab', { name: 'Dashboard page 9' }).focus();
        await page.keyboard.press('Enter');
        await page.locator('[data-active-page="page-8"]').waitFor();
        await page.waitForTimeout(240);
        await assertUnderline('selected tab after scrolling');
        assert.ok(await page.locator('.dashboard-tabs-inner').evaluate(node => node.scrollLeft > 0),
          'Keyboard selection must reveal a tab beyond the visible edge');
        assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
          'Tab overflow stays within its strip rather than widening the page');
        assert.deepEqual(errors, [], `${engine.name()} ${width} runtime errors`);
        await page.close();
      }
      const wide = await browser.newPage({ viewport: { width: 1920, height: 844 } });
      await wide.route('https://tabs-qa.chatgpt.site/**', route => route.fulfill({ contentType: 'text/html', body: html }));
      await wide.goto('https://tabs-qa.chatgpt.site/dashboard.html');
      const noOverflow = await wide.locator('.dashboard-tabs-inner').evaluate(node => ({
        fits: node.scrollWidth <= node.clientWidth + 1,
        left: node.dataset.scrollLeft,
        right: node.dataset.scrollRight,
        mask: getComputedStyle(node).maskImage,
      }));
      assert.deepEqual(noOverflow, { fits: true, left: 'false', right: 'false', mask: 'none' },
        `${engine.name()}: tabs that fit should not show a fade`);
      await wide.close();
    } finally { await browser.close(); }
  }
  console.log('PASS: horizontal tab overflow, fades, touch swipe, desktop wheel, and keyboard reveal in Chromium and WebKit.');
} finally { rmSync(project, { recursive: true, force: true }); }

SHA-256: e222e902bc6b1e70230d41f9059949aa31c367822b7abb436fced1409f79edf1