diff design_system/test/storybook_test.js @ 251:117c4d53c9a4

[ui] Add HTML-first Web Component system Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 09:14:57 -0700
parents
children 7a7581f040e8
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/design_system/test/storybook_test.js	Tue Aug 04 09:14:57 2026 -0700
@@ -0,0 +1,400 @@
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const net = require('node:net');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+
+const RUNFILES = process.env.JS_BINARY__RUNFILES;
+const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
+const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
+const playwrightPath = path.join(
+  runfilesWorkspace,
+  'hg-web/e2e/node_modules/playwright-core',
+);
+const { chromium } = require(playwrightPath);
+
+function findFreePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      server.close(error => {
+        if (error) reject(error);
+        else resolve(String(address.port));
+      });
+    });
+  });
+}
+
+async function stopProcess(child) {
+  if (!child || child.exitCode !== null) return;
+  child.kill('SIGTERM');
+  await new Promise(resolve => {
+    const timer = setTimeout(() => {
+      if (child.exitCode === null) child.kill('SIGKILL');
+    }, 3000);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve();
+    });
+  });
+}
+
+async function waitForServer(server, baseUrl, logs) {
+  const deadline = Date.now() + 15000;
+  while (Date.now() < deadline) {
+    if (server.exitCode !== null) {
+      throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`);
+    }
+    try {
+      const response = await fetch(baseUrl);
+      if (response.ok) return;
+    } catch {
+      // Keep waiting.
+    }
+    await new Promise(resolve => setTimeout(resolve, 100));
+  }
+  throw new Error(`Server startup timed out\n${logs.join('')}`);
+}
+
+function requestRawPath(port, requestPath) {
+  return new Promise((resolve, reject) => {
+    const request = http.request({
+      host: '127.0.0.1',
+      port,
+      method: 'GET',
+      path: requestPath,
+      agent: false,
+    }, response => {
+      response.resume();
+      response.on('end', () => resolve(response.statusCode));
+    });
+    request.on('error', reject);
+    request.end();
+  });
+}
+
+(async () => {
+  assert.ok(RUNFILES);
+  assert.ok(WORKSPACE);
+  const serverBinary = path.join(
+    runfilesWorkspace,
+    'design_system/design_system_server',
+  );
+  const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
+  const port = await findFreePort();
+  const baseUrl = `http://127.0.0.1:${port}`;
+  const logs = [];
+  let server;
+  let browser;
+
+  try {
+    const invalidServer = spawn(serverBinary, [], {
+      cwd: runfilesWorkspace,
+      env: {
+        ...process.env,
+        DESIGN_SYSTEM_PORT: 'not-a-port',
+      },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    const invalidExit = await new Promise(resolve => {
+      invalidServer.once('exit', (code, signal) => resolve({ code, signal }));
+    });
+    assert.equal(invalidExit.code, 1);
+    assert.equal(invalidExit.signal, null);
+
+    server = spawn(serverBinary, [], {
+      cwd: runfilesWorkspace,
+      env: {
+        ...process.env,
+        DESIGN_SYSTEM_PORT: port,
+      },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    server.stdout.on('data', chunk => logs.push(chunk.toString()));
+    server.stderr.on('data', chunk => logs.push(chunk.toString()));
+    await waitForServer(server, baseUrl, logs);
+    assert.equal(await requestRawPath(port, '/../../MODULE.bazel'), 400);
+
+    for (const route of [
+      '/',
+      '/tokens',
+      '/components',
+      '/components/button',
+      '/components/card',
+      '/components/alert',
+      '/components/field',
+      '/components/stack',
+    ]) {
+      const response = await fetch(`${baseUrl}${route}`);
+      assert.equal(response.status, 200, route);
+      assert.match(
+        response.headers.get('content-type') || '',
+        /^text\/html/,
+      );
+    }
+    for (const asset of [
+      '/styles/tokens.css',
+      '/styles/components.css',
+      '/components/index.js',
+      '/storybook.js',
+    ]) {
+      const response = await fetch(`${baseUrl}${asset}`);
+      assert.equal(response.status, 200, asset);
+      assert.ok((await response.text()).length > 100, asset);
+    }
+
+    browser = await chromium.launch({
+      executablePath: chromiumPath,
+      headless: true,
+      args: ['--no-sandbox'],
+    });
+    const context = await browser.newContext({ colorScheme: 'light' });
+    const page = await context.newPage();
+    const errors = [];
+    page.on('pageerror', error => errors.push(error.message));
+    page.on('console', message => {
+      if (message.type() === 'error') errors.push(message.text());
+    });
+    page.on('response', response => {
+      if (response.status() >= 400) {
+        errors.push(`${response.status()} ${response.url()}`);
+      }
+    });
+
+    await page.goto(`${baseUrl}/components/button`, {
+      waitUntil: 'networkidle',
+    });
+    await page.waitForFunction(() =>
+      customElements.get('zen-button') &&
+      customElements.get('zen-story')
+    );
+    assert.equal(
+      await page.locator('[data-catalog-page="button"]').isVisible(),
+      true,
+    );
+    assert.equal(
+      await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'),
+      'page',
+    );
+    assert.match(
+      await page.locator('zen-story pre').first().textContent(),
+      /<zen-button>/,
+    );
+    assert.equal(
+      await page.locator('zen-button[loading]').getAttribute('inert'),
+      null,
+    );
+    assert.equal(
+      await page.locator('zen-button[loading] button').getAttribute('aria-disabled'),
+      'true',
+    );
+    assert.equal(
+      await page.locator('zen-button[loading] button').getAttribute('aria-busy'),
+      'true',
+    );
+    await page.evaluate(() => {
+      window.__loadingClicks = 0;
+      document.querySelector('zen-button[loading] button')
+        .addEventListener('click', () => window.__loadingClicks++);
+    });
+    await page.locator('zen-button[loading] button').evaluate(
+      button => button.click(),
+    );
+    assert.equal(await page.evaluate(() => window.__loadingClicks), 0);
+    await page.locator('zen-button[loading] button').focus();
+    assert.equal(
+      await page.locator('zen-button[loading] button').evaluate(
+        button => document.activeElement === button,
+      ),
+      true,
+    );
+    await page.keyboard.press('Tab');
+    assert.equal(
+      await page.locator('zen-button[loading] button').evaluate(
+        button => document.activeElement === button,
+      ),
+      false,
+    );
+    const nativeDisabledPreserved = await page.evaluate(async () => {
+      const wrapper = document.createElement('zen-button');
+      const button = document.createElement('button');
+      button.disabled = true;
+      button.textContent = 'Native disabled';
+      wrapper.append(button);
+      document.body.append(wrapper);
+      await customElements.whenDefined('zen-button');
+      wrapper.setAttribute('disabled', '');
+      wrapper.removeAttribute('disabled');
+      return button.disabled;
+    });
+    assert.equal(nativeDisabledPreserved, true);
+    const replacementButtonState = await page.evaluate(async () => {
+      const wrapper = document.createElement('zen-button');
+      wrapper.setAttribute('disabled', '');
+      const first = document.createElement('button');
+      first.textContent = 'First';
+      wrapper.append(first);
+      document.body.append(wrapper);
+      await new Promise(resolve => setTimeout(resolve));
+      const second = document.createElement('button');
+      second.textContent = 'Second';
+      wrapper.replaceChildren(second);
+      await new Promise(resolve => setTimeout(resolve));
+      return {
+        firstDisabled: first.disabled,
+        secondAriaDisabled: second.getAttribute('aria-disabled'),
+        wrapperInert: wrapper.hasAttribute('inert'),
+      };
+    });
+    assert.equal(replacementButtonState.firstDisabled, false);
+    assert.equal(replacementButtonState.secondAriaDisabled, 'true');
+    assert.equal(replacementButtonState.wrapperInert, false);
+    const modifiedClickAllowed = await page.evaluate(() => {
+      const link = document.querySelector(
+        '.catalog-sidebar a[href="/components/card"]',
+      );
+      return link.dispatchEvent(new MouseEvent('click', {
+        bubbles: true,
+        cancelable: true,
+        button: 0,
+        ctrlKey: true,
+      }));
+    });
+    assert.equal(modifiedClickAllowed, true);
+
+    await page.goto(`${baseUrl}/components/field`, {
+      waitUntil: 'networkidle',
+    });
+    const fieldWiring = await page.locator('zen-field').first().evaluate(field => {
+      const label = field.querySelector('label');
+      const input = field.querySelector('input');
+      const help = field.querySelector('small');
+      input.checkValidity();
+      return {
+        describedBy: input.getAttribute('aria-describedby'),
+        helpId: help.id,
+        inputId: input.id,
+        invalid: field.hasAttribute('data-invalid'),
+        labelFor: label.htmlFor,
+      };
+    });
+    assert.ok(fieldWiring.inputId);
+    assert.equal(fieldWiring.labelFor, fieldWiring.inputId);
+    assert.equal(fieldWiring.describedBy, fieldWiring.helpId);
+    assert.equal(fieldWiring.invalid, true);
+    const replacementField = await page.locator('zen-field').first().evaluate(
+      async field => {
+        const label = field.querySelector('label');
+        const oldInput = field.querySelector('input');
+        const blocker = document.createElement('div');
+        blocker.id = 'zen-field-3';
+        document.body.append(blocker);
+        const nextInput = document.createElement('input');
+        nextInput.required = true;
+        oldInput.replaceWith(nextInput);
+        await new Promise(resolve => setTimeout(resolve));
+        oldInput.dispatchEvent(new Event('invalid'));
+        const help = field.querySelector('small');
+        nextInput.id = 'replacement-email';
+        help.id = 'replacement-help';
+        await new Promise(resolve => setTimeout(resolve));
+        return {
+          describedBy: nextInput.getAttribute('aria-describedby'),
+          helpId: help.id,
+          labelFor: label.htmlFor,
+          nextId: nextInput.id,
+          blockerId: blocker.id,
+        };
+      },
+    );
+    assert.ok(replacementField.nextId);
+    assert.notEqual(replacementField.nextId, replacementField.blockerId);
+    assert.equal(replacementField.labelFor, replacementField.nextId);
+    assert.equal(replacementField.describedBy, replacementField.helpId);
+
+    await page.goto(`${baseUrl}/components/alert`, {
+      waitUntil: 'networkidle',
+    });
+    await page.evaluate(() => {
+      window.__dismissed = 0;
+      document.addEventListener('zen-dismiss', () => {
+        window.__dismissed++;
+      });
+    });
+    const dismissible = page.locator('zen-alert[dismissible]');
+    assert.equal(await dismissible.getAttribute('role'), 'alert');
+    await dismissible.locator('[data-zen-dismiss]').click();
+    assert.equal(await dismissible.count(), 0);
+    assert.equal(await page.evaluate(() => window.__dismissed), 1);
+    const authoredDismissState = await page.evaluate(async () => {
+      const alert = document.createElement('zen-alert');
+      alert.setAttribute('dismissible', '');
+      const message = document.createElement('p');
+      message.textContent = 'Authored dismiss control';
+      const dismiss = document.createElement('button');
+      dismiss.dataset.zenDismiss = '';
+      alert.append(message, dismiss);
+      document.body.append(alert);
+      await new Promise(resolve => setTimeout(resolve));
+      let events = 0;
+      alert.addEventListener('zen-dismiss', () => events++);
+      alert.removeAttribute('dismissible');
+      dismiss.click();
+      return {
+        connected: alert.isConnected,
+        events,
+      };
+    });
+    assert.equal(authoredDismissState.connected, true);
+    assert.equal(authoredDismissState.events, 0);
+
+    const lightCanvas = await page.evaluate(() =>
+      getComputedStyle(document.documentElement)
+        .getPropertyValue('--zen-color-canvas')
+        .trim()
+    );
+    await page.locator('#themeToggle').click();
+    const darkCanvas = await page.evaluate(() =>
+      getComputedStyle(document.documentElement)
+        .getPropertyValue('--zen-color-canvas')
+        .trim()
+    );
+    assert.notEqual(lightCanvas, darkCanvas);
+    assert.equal(
+      await page.evaluate(() => localStorage.getItem('zen-theme')),
+      'dark',
+    );
+
+    assert.deepEqual(errors, []);
+    await context.close();
+
+    const darkContext = await browser.newContext({ colorScheme: 'dark' });
+    const darkPage = await darkContext.newPage();
+    await darkPage.goto(baseUrl, { waitUntil: 'networkidle' });
+    assert.equal(
+      await darkPage.locator('#themeToggle').getAttribute('aria-pressed'),
+      'true',
+    );
+    const systemDarkCanvas = await darkPage.evaluate(() =>
+      getComputedStyle(document.documentElement)
+        .getPropertyValue('--zen-color-canvas')
+        .trim()
+    );
+    await darkPage.locator('#themeToggle').click();
+    const explicitLightCanvas = await darkPage.evaluate(() =>
+      getComputedStyle(document.documentElement)
+        .getPropertyValue('--zen-color-canvas')
+        .trim()
+    );
+    assert.notEqual(systemDarkCanvas, explicitLightCanvas);
+    await darkContext.close();
+  } finally {
+    if (browser) await browser.close();
+    await stopProcess(server);
+  }
+})().catch(error => {
+  console.error(error.stack || error);
+  process.exitCode = 1;
+});