Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 250:745fd127b2a1 | 251:117c4d53c9a4 |
|---|---|
| 1 const assert = require('node:assert/strict'); | |
| 2 const http = require('node:http'); | |
| 3 const net = require('node:net'); | |
| 4 const path = require('node:path'); | |
| 5 const { spawn } = require('node:child_process'); | |
| 6 | |
| 7 const RUNFILES = process.env.JS_BINARY__RUNFILES; | |
| 8 const WORKSPACE = process.env.JS_BINARY__WORKSPACE; | |
| 9 const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); | |
| 10 const playwrightPath = path.join( | |
| 11 runfilesWorkspace, | |
| 12 'hg-web/e2e/node_modules/playwright-core', | |
| 13 ); | |
| 14 const { chromium } = require(playwrightPath); | |
| 15 | |
| 16 function findFreePort() { | |
| 17 return new Promise((resolve, reject) => { | |
| 18 const server = net.createServer(); | |
| 19 server.once('error', reject); | |
| 20 server.listen(0, '127.0.0.1', () => { | |
| 21 const address = server.address(); | |
| 22 server.close(error => { | |
| 23 if (error) reject(error); | |
| 24 else resolve(String(address.port)); | |
| 25 }); | |
| 26 }); | |
| 27 }); | |
| 28 } | |
| 29 | |
| 30 async function stopProcess(child) { | |
| 31 if (!child || child.exitCode !== null) return; | |
| 32 child.kill('SIGTERM'); | |
| 33 await new Promise(resolve => { | |
| 34 const timer = setTimeout(() => { | |
| 35 if (child.exitCode === null) child.kill('SIGKILL'); | |
| 36 }, 3000); | |
| 37 child.once('exit', () => { | |
| 38 clearTimeout(timer); | |
| 39 resolve(); | |
| 40 }); | |
| 41 }); | |
| 42 } | |
| 43 | |
| 44 async function waitForServer(server, baseUrl, logs) { | |
| 45 const deadline = Date.now() + 15000; | |
| 46 while (Date.now() < deadline) { | |
| 47 if (server.exitCode !== null) { | |
| 48 throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); | |
| 49 } | |
| 50 try { | |
| 51 const response = await fetch(baseUrl); | |
| 52 if (response.ok) return; | |
| 53 } catch { | |
| 54 // Keep waiting. | |
| 55 } | |
| 56 await new Promise(resolve => setTimeout(resolve, 100)); | |
| 57 } | |
| 58 throw new Error(`Server startup timed out\n${logs.join('')}`); | |
| 59 } | |
| 60 | |
| 61 function requestRawPath(port, requestPath) { | |
| 62 return new Promise((resolve, reject) => { | |
| 63 const request = http.request({ | |
| 64 host: '127.0.0.1', | |
| 65 port, | |
| 66 method: 'GET', | |
| 67 path: requestPath, | |
| 68 agent: false, | |
| 69 }, response => { | |
| 70 response.resume(); | |
| 71 response.on('end', () => resolve(response.statusCode)); | |
| 72 }); | |
| 73 request.on('error', reject); | |
| 74 request.end(); | |
| 75 }); | |
| 76 } | |
| 77 | |
| 78 (async () => { | |
| 79 assert.ok(RUNFILES); | |
| 80 assert.ok(WORKSPACE); | |
| 81 const serverBinary = path.join( | |
| 82 runfilesWorkspace, | |
| 83 'design_system/design_system_server', | |
| 84 ); | |
| 85 const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); | |
| 86 const port = await findFreePort(); | |
| 87 const baseUrl = `http://127.0.0.1:${port}`; | |
| 88 const logs = []; | |
| 89 let server; | |
| 90 let browser; | |
| 91 | |
| 92 try { | |
| 93 const invalidServer = spawn(serverBinary, [], { | |
| 94 cwd: runfilesWorkspace, | |
| 95 env: { | |
| 96 ...process.env, | |
| 97 DESIGN_SYSTEM_PORT: 'not-a-port', | |
| 98 }, | |
| 99 stdio: ['ignore', 'pipe', 'pipe'], | |
| 100 }); | |
| 101 const invalidExit = await new Promise(resolve => { | |
| 102 invalidServer.once('exit', (code, signal) => resolve({ code, signal })); | |
| 103 }); | |
| 104 assert.equal(invalidExit.code, 1); | |
| 105 assert.equal(invalidExit.signal, null); | |
| 106 | |
| 107 server = spawn(serverBinary, [], { | |
| 108 cwd: runfilesWorkspace, | |
| 109 env: { | |
| 110 ...process.env, | |
| 111 DESIGN_SYSTEM_PORT: port, | |
| 112 }, | |
| 113 stdio: ['ignore', 'pipe', 'pipe'], | |
| 114 }); | |
| 115 server.stdout.on('data', chunk => logs.push(chunk.toString())); | |
| 116 server.stderr.on('data', chunk => logs.push(chunk.toString())); | |
| 117 await waitForServer(server, baseUrl, logs); | |
| 118 assert.equal(await requestRawPath(port, '/../../MODULE.bazel'), 400); | |
| 119 | |
| 120 for (const route of [ | |
| 121 '/', | |
| 122 '/tokens', | |
| 123 '/components', | |
| 124 '/components/button', | |
| 125 '/components/card', | |
| 126 '/components/alert', | |
| 127 '/components/field', | |
| 128 '/components/stack', | |
| 129 ]) { | |
| 130 const response = await fetch(`${baseUrl}${route}`); | |
| 131 assert.equal(response.status, 200, route); | |
| 132 assert.match( | |
| 133 response.headers.get('content-type') || '', | |
| 134 /^text\/html/, | |
| 135 ); | |
| 136 } | |
| 137 for (const asset of [ | |
| 138 '/styles/tokens.css', | |
| 139 '/styles/components.css', | |
| 140 '/components/index.js', | |
| 141 '/storybook.js', | |
| 142 ]) { | |
| 143 const response = await fetch(`${baseUrl}${asset}`); | |
| 144 assert.equal(response.status, 200, asset); | |
| 145 assert.ok((await response.text()).length > 100, asset); | |
| 146 } | |
| 147 | |
| 148 browser = await chromium.launch({ | |
| 149 executablePath: chromiumPath, | |
| 150 headless: true, | |
| 151 args: ['--no-sandbox'], | |
| 152 }); | |
| 153 const context = await browser.newContext({ colorScheme: 'light' }); | |
| 154 const page = await context.newPage(); | |
| 155 const errors = []; | |
| 156 page.on('pageerror', error => errors.push(error.message)); | |
| 157 page.on('console', message => { | |
| 158 if (message.type() === 'error') errors.push(message.text()); | |
| 159 }); | |
| 160 page.on('response', response => { | |
| 161 if (response.status() >= 400) { | |
| 162 errors.push(`${response.status()} ${response.url()}`); | |
| 163 } | |
| 164 }); | |
| 165 | |
| 166 await page.goto(`${baseUrl}/components/button`, { | |
| 167 waitUntil: 'networkidle', | |
| 168 }); | |
| 169 await page.waitForFunction(() => | |
| 170 customElements.get('zen-button') && | |
| 171 customElements.get('zen-story') | |
| 172 ); | |
| 173 assert.equal( | |
| 174 await page.locator('[data-catalog-page="button"]').isVisible(), | |
| 175 true, | |
| 176 ); | |
| 177 assert.equal( | |
| 178 await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'), | |
| 179 'page', | |
| 180 ); | |
| 181 assert.match( | |
| 182 await page.locator('zen-story pre').first().textContent(), | |
| 183 /<zen-button>/, | |
| 184 ); | |
| 185 assert.equal( | |
| 186 await page.locator('zen-button[loading]').getAttribute('inert'), | |
| 187 null, | |
| 188 ); | |
| 189 assert.equal( | |
| 190 await page.locator('zen-button[loading] button').getAttribute('aria-disabled'), | |
| 191 'true', | |
| 192 ); | |
| 193 assert.equal( | |
| 194 await page.locator('zen-button[loading] button').getAttribute('aria-busy'), | |
| 195 'true', | |
| 196 ); | |
| 197 await page.evaluate(() => { | |
| 198 window.__loadingClicks = 0; | |
| 199 document.querySelector('zen-button[loading] button') | |
| 200 .addEventListener('click', () => window.__loadingClicks++); | |
| 201 }); | |
| 202 await page.locator('zen-button[loading] button').evaluate( | |
| 203 button => button.click(), | |
| 204 ); | |
| 205 assert.equal(await page.evaluate(() => window.__loadingClicks), 0); | |
| 206 await page.locator('zen-button[loading] button').focus(); | |
| 207 assert.equal( | |
| 208 await page.locator('zen-button[loading] button').evaluate( | |
| 209 button => document.activeElement === button, | |
| 210 ), | |
| 211 true, | |
| 212 ); | |
| 213 await page.keyboard.press('Tab'); | |
| 214 assert.equal( | |
| 215 await page.locator('zen-button[loading] button').evaluate( | |
| 216 button => document.activeElement === button, | |
| 217 ), | |
| 218 false, | |
| 219 ); | |
| 220 const nativeDisabledPreserved = await page.evaluate(async () => { | |
| 221 const wrapper = document.createElement('zen-button'); | |
| 222 const button = document.createElement('button'); | |
| 223 button.disabled = true; | |
| 224 button.textContent = 'Native disabled'; | |
| 225 wrapper.append(button); | |
| 226 document.body.append(wrapper); | |
| 227 await customElements.whenDefined('zen-button'); | |
| 228 wrapper.setAttribute('disabled', ''); | |
| 229 wrapper.removeAttribute('disabled'); | |
| 230 return button.disabled; | |
| 231 }); | |
| 232 assert.equal(nativeDisabledPreserved, true); | |
| 233 const replacementButtonState = await page.evaluate(async () => { | |
| 234 const wrapper = document.createElement('zen-button'); | |
| 235 wrapper.setAttribute('disabled', ''); | |
| 236 const first = document.createElement('button'); | |
| 237 first.textContent = 'First'; | |
| 238 wrapper.append(first); | |
| 239 document.body.append(wrapper); | |
| 240 await new Promise(resolve => setTimeout(resolve)); | |
| 241 const second = document.createElement('button'); | |
| 242 second.textContent = 'Second'; | |
| 243 wrapper.replaceChildren(second); | |
| 244 await new Promise(resolve => setTimeout(resolve)); | |
| 245 return { | |
| 246 firstDisabled: first.disabled, | |
| 247 secondAriaDisabled: second.getAttribute('aria-disabled'), | |
| 248 wrapperInert: wrapper.hasAttribute('inert'), | |
| 249 }; | |
| 250 }); | |
| 251 assert.equal(replacementButtonState.firstDisabled, false); | |
| 252 assert.equal(replacementButtonState.secondAriaDisabled, 'true'); | |
| 253 assert.equal(replacementButtonState.wrapperInert, false); | |
| 254 const modifiedClickAllowed = await page.evaluate(() => { | |
| 255 const link = document.querySelector( | |
| 256 '.catalog-sidebar a[href="/components/card"]', | |
| 257 ); | |
| 258 return link.dispatchEvent(new MouseEvent('click', { | |
| 259 bubbles: true, | |
| 260 cancelable: true, | |
| 261 button: 0, | |
| 262 ctrlKey: true, | |
| 263 })); | |
| 264 }); | |
| 265 assert.equal(modifiedClickAllowed, true); | |
| 266 | |
| 267 await page.goto(`${baseUrl}/components/field`, { | |
| 268 waitUntil: 'networkidle', | |
| 269 }); | |
| 270 const fieldWiring = await page.locator('zen-field').first().evaluate(field => { | |
| 271 const label = field.querySelector('label'); | |
| 272 const input = field.querySelector('input'); | |
| 273 const help = field.querySelector('small'); | |
| 274 input.checkValidity(); | |
| 275 return { | |
| 276 describedBy: input.getAttribute('aria-describedby'), | |
| 277 helpId: help.id, | |
| 278 inputId: input.id, | |
| 279 invalid: field.hasAttribute('data-invalid'), | |
| 280 labelFor: label.htmlFor, | |
| 281 }; | |
| 282 }); | |
| 283 assert.ok(fieldWiring.inputId); | |
| 284 assert.equal(fieldWiring.labelFor, fieldWiring.inputId); | |
| 285 assert.equal(fieldWiring.describedBy, fieldWiring.helpId); | |
| 286 assert.equal(fieldWiring.invalid, true); | |
| 287 const replacementField = await page.locator('zen-field').first().evaluate( | |
| 288 async field => { | |
| 289 const label = field.querySelector('label'); | |
| 290 const oldInput = field.querySelector('input'); | |
| 291 const blocker = document.createElement('div'); | |
| 292 blocker.id = 'zen-field-3'; | |
| 293 document.body.append(blocker); | |
| 294 const nextInput = document.createElement('input'); | |
| 295 nextInput.required = true; | |
| 296 oldInput.replaceWith(nextInput); | |
| 297 await new Promise(resolve => setTimeout(resolve)); | |
| 298 oldInput.dispatchEvent(new Event('invalid')); | |
| 299 const help = field.querySelector('small'); | |
| 300 nextInput.id = 'replacement-email'; | |
| 301 help.id = 'replacement-help'; | |
| 302 await new Promise(resolve => setTimeout(resolve)); | |
| 303 return { | |
| 304 describedBy: nextInput.getAttribute('aria-describedby'), | |
| 305 helpId: help.id, | |
| 306 labelFor: label.htmlFor, | |
| 307 nextId: nextInput.id, | |
| 308 blockerId: blocker.id, | |
| 309 }; | |
| 310 }, | |
| 311 ); | |
| 312 assert.ok(replacementField.nextId); | |
| 313 assert.notEqual(replacementField.nextId, replacementField.blockerId); | |
| 314 assert.equal(replacementField.labelFor, replacementField.nextId); | |
| 315 assert.equal(replacementField.describedBy, replacementField.helpId); | |
| 316 | |
| 317 await page.goto(`${baseUrl}/components/alert`, { | |
| 318 waitUntil: 'networkidle', | |
| 319 }); | |
| 320 await page.evaluate(() => { | |
| 321 window.__dismissed = 0; | |
| 322 document.addEventListener('zen-dismiss', () => { | |
| 323 window.__dismissed++; | |
| 324 }); | |
| 325 }); | |
| 326 const dismissible = page.locator('zen-alert[dismissible]'); | |
| 327 assert.equal(await dismissible.getAttribute('role'), 'alert'); | |
| 328 await dismissible.locator('[data-zen-dismiss]').click(); | |
| 329 assert.equal(await dismissible.count(), 0); | |
| 330 assert.equal(await page.evaluate(() => window.__dismissed), 1); | |
| 331 const authoredDismissState = await page.evaluate(async () => { | |
| 332 const alert = document.createElement('zen-alert'); | |
| 333 alert.setAttribute('dismissible', ''); | |
| 334 const message = document.createElement('p'); | |
| 335 message.textContent = 'Authored dismiss control'; | |
| 336 const dismiss = document.createElement('button'); | |
| 337 dismiss.dataset.zenDismiss = ''; | |
| 338 alert.append(message, dismiss); | |
| 339 document.body.append(alert); | |
| 340 await new Promise(resolve => setTimeout(resolve)); | |
| 341 let events = 0; | |
| 342 alert.addEventListener('zen-dismiss', () => events++); | |
| 343 alert.removeAttribute('dismissible'); | |
| 344 dismiss.click(); | |
| 345 return { | |
| 346 connected: alert.isConnected, | |
| 347 events, | |
| 348 }; | |
| 349 }); | |
| 350 assert.equal(authoredDismissState.connected, true); | |
| 351 assert.equal(authoredDismissState.events, 0); | |
| 352 | |
| 353 const lightCanvas = await page.evaluate(() => | |
| 354 getComputedStyle(document.documentElement) | |
| 355 .getPropertyValue('--zen-color-canvas') | |
| 356 .trim() | |
| 357 ); | |
| 358 await page.locator('#themeToggle').click(); | |
| 359 const darkCanvas = await page.evaluate(() => | |
| 360 getComputedStyle(document.documentElement) | |
| 361 .getPropertyValue('--zen-color-canvas') | |
| 362 .trim() | |
| 363 ); | |
| 364 assert.notEqual(lightCanvas, darkCanvas); | |
| 365 assert.equal( | |
| 366 await page.evaluate(() => localStorage.getItem('zen-theme')), | |
| 367 'dark', | |
| 368 ); | |
| 369 | |
| 370 assert.deepEqual(errors, []); | |
| 371 await context.close(); | |
| 372 | |
| 373 const darkContext = await browser.newContext({ colorScheme: 'dark' }); | |
| 374 const darkPage = await darkContext.newPage(); | |
| 375 await darkPage.goto(baseUrl, { waitUntil: 'networkidle' }); | |
| 376 assert.equal( | |
| 377 await darkPage.locator('#themeToggle').getAttribute('aria-pressed'), | |
| 378 'true', | |
| 379 ); | |
| 380 const systemDarkCanvas = await darkPage.evaluate(() => | |
| 381 getComputedStyle(document.documentElement) | |
| 382 .getPropertyValue('--zen-color-canvas') | |
| 383 .trim() | |
| 384 ); | |
| 385 await darkPage.locator('#themeToggle').click(); | |
| 386 const explicitLightCanvas = await darkPage.evaluate(() => | |
| 387 getComputedStyle(document.documentElement) | |
| 388 .getPropertyValue('--zen-color-canvas') | |
| 389 .trim() | |
| 390 ); | |
| 391 assert.notEqual(systemDarkCanvas, explicitLightCanvas); | |
| 392 await darkContext.close(); | |
| 393 } finally { | |
| 394 if (browser) await browser.close(); | |
| 395 await stopProcess(server); | |
| 396 } | |
| 397 })().catch(error => { | |
| 398 console.error(error.stack || error); | |
| 399 process.exitCode = 1; | |
| 400 }); |