Mercurial
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; +});