comparison design_system/test/catalog_test.js @ 258:60a876c4587a

[ui] Add semantic primitive ownership Build a layered Zenbu token and sizing system, make authored controls use native-underneath primitives, migrate mrjunejune without imposing visual surfaces, and document/enforce HTML ownership in the catalog and wiki. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Wed, 05 Aug 2026 05:25:40 -0700
parents
children 667156fcd3e3
comparison
equal deleted inserted replaced
257:609d3c6aff4e 258:60a876c4587a
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 '/icons',
124 '/components',
125 '/components/button',
126 '/components/card',
127 '/components/alert',
128 '/components/field',
129 '/components/notifications',
130 '/components/stack',
131 ]) {
132 const response = await fetch(`${baseUrl}${route}`);
133 assert.equal(response.status, 200, route);
134 assert.match(
135 response.headers.get('content-type') || '',
136 /^text\/html/,
137 );
138 }
139 for (const asset of [
140 '/styles/tokens.css',
141 '/styles/reference.css',
142 '/styles/semantic.css',
143 '/styles/density.css',
144 '/styles/themes.css',
145 '/styles/components.css',
146 '/styles/elements.css',
147 '/components/index.js',
148 '/components/icon.js',
149 '/catalog.js',
150 '/catalog.js',
151 ]) {
152 const response = await fetch(`${baseUrl}${asset}`);
153 assert.equal(response.status, 200, asset);
154 assert.ok((await response.text()).length > 100, asset);
155 }
156
157 browser = await chromium.launch({
158 executablePath: chromiumPath,
159 headless: true,
160 args: ['--no-sandbox'],
161 });
162 const context = await browser.newContext({ colorScheme: 'light' });
163 const page = await context.newPage();
164 const errors = [];
165 const devtools = await context.newCDPSession(page);
166 await devtools.send('Runtime.enable');
167 devtools.on('Runtime.exceptionThrown', event => {
168 const exception = event.exceptionDetails;
169 errors.push(
170 `${exception.text}: ${exception.exception?.description || ''} ` +
171 `${exception.url || ''}:${exception.lineNumber + 1}`,
172 );
173 });
174 page.on('pageerror', error => errors.push(error.stack || error.message));
175 page.on('console', message => {
176 if (message.type() === 'error') {
177 const location = message.location();
178 errors.push(
179 `${message.text()} ${location.url || ''}:${location.lineNumber + 1}`,
180 );
181 }
182 });
183 page.on('response', response => {
184 if (response.status() >= 400) {
185 errors.push(`${response.status()} ${response.url()}`);
186 }
187 });
188
189 await page.goto(`${baseUrl}/components/button`, {
190 waitUntil: 'networkidle',
191 });
192 try {
193 await page.waitForFunction(
194 () => customElements.get('zen-button') &&
195 customElements.get('zen-story'),
196 undefined,
197 { timeout: 5000 },
198 );
199 } catch (error) {
200 throw new Error(`${error.message}\n${errors.join('\n')}`);
201 }
202 const catalogState = await page.evaluate(async () => {
203 const links = [
204 ...document.querySelectorAll(
205 '.catalog-nav[aria-label="Components"] a',
206 ),
207 ];
208 const elements = [...new Set(
209 links.map(link => link.dataset.zenElement).filter(Boolean),
210 )];
211 const statuses = await Promise.all(
212 links.map(async link => (await fetch(link.href)).status),
213 );
214 return {
215 cards: document.querySelectorAll('.component-grid > a').length,
216 elements,
217 links: links.length,
218 missing: elements.filter(name => !customElements.get(name)),
219 routes: links.map(link => new URL(link.href).pathname),
220 statuses,
221 };
222 });
223 assert.equal(catalogState.cards, 70);
224 assert.equal(catalogState.links, 70);
225 assert.equal(catalogState.elements.length, 69);
226 assert.deepEqual(catalogState.missing, []);
227 assert.deepEqual(
228 [...new Set(catalogState.statuses)],
229 [200],
230 );
231 const primitiveOwnership = await page.evaluate(() => {
232 const buttonOwners = [
233 'zen-alert',
234 'zen-attachment',
235 'zen-button',
236 'zen-button-group',
237 'zen-calendar',
238 'zen-carousel',
239 'zen-combobox',
240 'zen-command',
241 'zen-context-menu',
242 'zen-data-table',
243 'zen-date-picker',
244 'zen-dropdown-menu',
245 'zen-input-group',
246 'zen-menubar',
247 'zen-message-scroller',
248 'zen-navigation-menu',
249 'zen-notifications',
250 'zen-sidebar',
251 'zen-tabs',
252 'zen-toggle',
253 'zen-toggle-group',
254 ].join(',');
255 const controlOwners = [
256 'zen-calendar',
257 'zen-checkbox',
258 'zen-combobox',
259 'zen-command',
260 'zen-date-picker',
261 'zen-field',
262 'zen-form',
263 'zen-input',
264 'zen-input-group',
265 'zen-input-otp',
266 'zen-native-select',
267 'zen-radio-group',
268 'zen-select',
269 'zen-slider',
270 'zen-switch',
271 'zen-textarea',
272 ].join(',');
273 return {
274 buttons: [...document.querySelectorAll('button')].filter(button =>
275 !button.closest(buttonOwners) &&
276 !button.matches('zen-story > header > button')
277 ).map(button => button.outerHTML),
278 controls: [
279 ...document.querySelectorAll('input, select, textarea'),
280 ].filter(control => !control.closest(controlOwners))
281 .map(control => control.outerHTML),
282 };
283 });
284 assert.deepEqual(primitiveOwnership, {
285 buttons: [],
286 controls: [],
287 });
288 const brokenCatalogPages = await page.evaluate(routes => {
289 const failures = [];
290 const buttonOwners = [
291 'zen-alert',
292 'zen-attachment',
293 'zen-button',
294 'zen-button-group',
295 'zen-calendar',
296 'zen-carousel',
297 'zen-combobox',
298 'zen-command',
299 'zen-context-menu',
300 'zen-data-table',
301 'zen-date-picker',
302 'zen-dropdown-menu',
303 'zen-input-group',
304 'zen-menubar',
305 'zen-message-scroller',
306 'zen-navigation-menu',
307 'zen-notifications',
308 'zen-sidebar',
309 'zen-tabs',
310 'zen-toggle',
311 'zen-toggle-group',
312 ].join(',');
313 for (const route of ['/', '/tokens', '/icons', '/components', ...routes]) {
314 history.replaceState({}, '', route);
315 window.dispatchEvent(new PopStateEvent('popstate'));
316 const visible = [
317 ...document.querySelectorAll('[data-catalog-page]:not([hidden])'),
318 ];
319 const expected = route === '/' || route === '/components'
320 ? 'overview'
321 : route.slice(route.lastIndexOf('/') + 1);
322 if (visible.length !== 1 ||
323 visible[0].dataset.catalogPage !== expected ||
324 !visible[0].querySelector('h1')?.textContent.trim()) {
325 failures.push(route);
326 continue;
327 }
328 if (route.startsWith('/components/')) {
329 const story = visible[0].querySelector('zen-story');
330 const canvas = story?.querySelector('.story-canvas');
331 const source = story?.querySelector('pre code');
332 if (!story || !canvas?.children.length ||
333 !source?.textContent.trim()) {
334 failures.push(`${route}:empty-story`);
335 }
336 const unownedButtons = [...canvas?.querySelectorAll('button') || []]
337 .filter(button => !button.closest(buttonOwners));
338 if (unownedButtons.length) {
339 failures.push(`${route}:unowned-button`);
340 }
341 }
342 }
343 history.replaceState({}, '', '/components/button');
344 window.dispatchEvent(new PopStateEvent('popstate'));
345 return failures;
346 }, catalogState.routes);
347 assert.deepEqual(brokenCatalogPages, []);
348 await page.keyboard.press('/');
349 assert.equal(
350 await page.locator('#componentSearch').evaluate(
351 input => document.activeElement === input,
352 ),
353 true,
354 );
355 await page.locator('#componentSearch').fill('date');
356 assert.equal(
357 await page.locator(
358 '.catalog-nav[aria-label="Components"] a:not([hidden])',
359 ).count(),
360 2,
361 );
362 assert.equal(
363 await page.locator('.component-grid > a:not([hidden])').count(),
364 2,
365 );
366 assert.match(
367 await page.locator('#componentSearchStatus').textContent(),
368 /^2 components found$/,
369 );
370 await page.locator('#componentSearch').press('Escape');
371 assert.equal(await page.locator('#componentSearch').inputValue(), '');
372 assert.equal(
373 await page.locator(
374 '.catalog-nav[aria-label="Components"] a:not([hidden])',
375 ).count(),
376 70,
377 );
378 await page.locator('#componentSearch').fill('stack');
379 assert.equal(
380 await page.locator(
381 '.catalog-nav[aria-label="Components"] a[href="/components/stack"]',
382 ).getAttribute('hidden'),
383 null,
384 );
385 assert.equal(
386 await page.locator(
387 '.component-grid > a[href="/components/stack"]',
388 ).getAttribute('hidden'),
389 null,
390 );
391 await page.locator('#componentSearch').press('Escape');
392 assert.equal(
393 await page.locator('[data-catalog-page="button"]').isVisible(),
394 true,
395 );
396 assert.equal(
397 await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'),
398 'page',
399 );
400 assert.match(
401 await page.locator('zen-story pre').first().textContent(),
402 /<zen-button>/,
403 );
404 const plainPresentation = await page.evaluate(() => {
405 const style = document.createElement('style');
406 style.textContent = `
407 .application-button {
408 padding: 7px;
409 border: 3px solid currentColor;
410 box-shadow: none;
411 background: transparent;
412 }
413 .application-input {
414 appearance: auto;
415 padding: 9px;
416 border: 4px solid currentColor;
417 background: transparent;
418 }
419 `;
420 document.head.append(style);
421 const buttonHost = document.createElement('zen-button');
422 buttonHost.setAttribute('appearance', 'plain');
423 buttonHost.setAttribute('loading', '');
424 const button = document.createElement('button');
425 button.className = 'application-button';
426 button.textContent = 'Plain';
427 buttonHost.append(button);
428
429 const card = document.createElement('zen-card');
430 card.setAttribute('appearance', 'plain');
431 const article = document.createElement('article');
432 card.append(article);
433
434 const field = document.createElement('zen-field');
435 field.setAttribute('appearance', 'plain');
436 const label = document.createElement('label');
437 label.textContent = 'Plain field';
438 const input = document.createElement('input');
439 field.append(label, input);
440 const inputHost = document.createElement('zen-input');
441 inputHost.setAttribute('appearance', 'plain');
442 inputHost.setAttribute('size', 'lg');
443 const applicationInput = document.createElement('input');
444 applicationInput.className = 'application-input';
445 inputHost.append(applicationInput);
446 document.body.append(buttonHost, card, field, inputHost);
447
448 const buttonStyle = getComputedStyle(button);
449 const cardStyle = getComputedStyle(article);
450 return {
451 buttonBackground: buttonStyle.backgroundColor,
452 buttonBorder: buttonStyle.borderTopWidth,
453 buttonPadding: buttonStyle.paddingTop,
454 buttonShadow: buttonStyle.boxShadow,
455 buttonOpacity: buttonStyle.opacity,
456 buttonSpinner: getComputedStyle(button, '::before').content,
457 cardBackground: cardStyle.backgroundColor,
458 cardBorder: cardStyle.borderTopWidth,
459 cardShadow: cardStyle.boxShadow,
460 fieldDisplay: getComputedStyle(field).display,
461 inputAppearance: getComputedStyle(input).appearance,
462 plainInputAppearance: getComputedStyle(applicationInput).appearance,
463 plainInputBorder: getComputedStyle(applicationInput).borderTopWidth,
464 plainInputPadding: getComputedStyle(applicationInput).paddingTop,
465 labelFor: label.htmlFor,
466 inputId: input.id,
467 };
468 });
469 assert.deepEqual(plainPresentation, {
470 buttonBackground: 'rgba(0, 0, 0, 0)',
471 buttonBorder: '3px',
472 buttonPadding: '7px',
473 buttonShadow: 'none',
474 buttonOpacity: '1',
475 buttonSpinner: 'none',
476 cardBackground: 'rgba(0, 0, 0, 0)',
477 cardBorder: '0px',
478 cardShadow: 'none',
479 fieldDisplay: 'contents',
480 inputAppearance: 'auto',
481 plainInputAppearance: 'auto',
482 plainInputBorder: '4px',
483 plainInputPadding: '9px',
484 labelFor: plainPresentation.inputId,
485 inputId: plainPresentation.inputId,
486 });
487 const buttonScale = await page.locator(
488 '[data-catalog-page="button"] zen-button[size] > button',
489 ).evaluateAll(buttons => buttons.map(button => {
490 const host = button.parentElement;
491 const style = getComputedStyle(button);
492 const hostStyle = getComputedStyle(host);
493 return {
494 fontSize: Number.parseFloat(style.fontSize),
495 height: button.getBoundingClientRect().height,
496 paddingInline: Number.parseFloat(style.paddingInlineStart),
497 size: host.getAttribute('size'),
498 variableHeight: Number.parseFloat(
499 hostStyle.getPropertyValue('--zenbu-control-height'),
500 ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
501 };
502 }));
503 assert.deepEqual(
504 buttonScale.map(item => item.size),
505 ['xs', 'sm', 'md', 'lg', 'xl'],
506 );
507 for (let index = 1; index < buttonScale.length; index++) {
508 assert.ok(buttonScale[index].height > buttonScale[index - 1].height);
509 assert.ok(
510 buttonScale[index].paddingInline >
511 buttonScale[index - 1].paddingInline,
512 );
513 assert.ok(
514 buttonScale[index].fontSize > buttonScale[index - 1].fontSize,
515 );
516 }
517 for (const item of buttonScale) {
518 assert.ok(
519 Math.abs(item.height - item.variableHeight) < 1,
520 JSON.stringify(buttonScale),
521 );
522 }
523 const compactScale = await page.evaluate(() => {
524 document.documentElement.dataset.zenDensity = 'compact';
525 return [...document.querySelectorAll(
526 '[data-catalog-page="button"] zen-button[size] > button',
527 )]
528 .map(button => button.getBoundingClientRect().height);
529 });
530 assert.equal(compactScale.length, buttonScale.length);
531 for (let index = 0; index < compactScale.length; index++) {
532 assert.ok(compactScale[index] < buttonScale[index].height);
533 }
534 await page.evaluate(() => {
535 delete document.documentElement.dataset.zenDensity;
536 });
537
538 await page.emulateMedia({ reducedMotion: 'reduce' });
539 assert.equal(
540 await page.evaluate(() =>
541 getComputedStyle(document.documentElement)
542 .getPropertyValue('--zenbu-sys-motion-duration-state')
543 .trim()
544 ),
545 '1ms',
546 );
547 await page.emulateMedia({ reducedMotion: 'no-preference' });
548
549 assert.equal(
550 await page.locator('zen-button[loading]:not([appearance="plain"])').getAttribute('inert'),
551 null,
552 );
553 assert.equal(
554 await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-disabled'),
555 'true',
556 );
557 assert.equal(
558 await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-busy'),
559 'true',
560 );
561 await page.evaluate(() => {
562 window.__loadingClicks = 0;
563 document.querySelector('zen-button[loading]:not([appearance="plain"]) button')
564 .addEventListener('click', () => window.__loadingClicks++);
565 });
566 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate(
567 button => button.click(),
568 );
569 assert.equal(await page.evaluate(() => window.__loadingClicks), 0);
570 await page.locator('zen-button[loading]:not([appearance="plain"]) button').focus();
571 assert.equal(
572 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate(
573 button => document.activeElement === button,
574 ),
575 true,
576 );
577 await page.keyboard.press('Tab');
578 assert.equal(
579 await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate(
580 button => document.activeElement === button,
581 ),
582 false,
583 );
584 const nativeDisabledPreserved = await page.evaluate(async () => {
585 const wrapper = document.createElement('zen-button');
586 const button = document.createElement('button');
587 button.disabled = true;
588 button.textContent = 'Native disabled';
589 wrapper.append(button);
590 document.body.append(wrapper);
591 await customElements.whenDefined('zen-button');
592 wrapper.setAttribute('disabled', '');
593 wrapper.removeAttribute('disabled');
594 return button.disabled;
595 });
596 assert.equal(nativeDisabledPreserved, true);
597 const replacementButtonState = await page.evaluate(async () => {
598 const wrapper = document.createElement('zen-button');
599 wrapper.setAttribute('disabled', '');
600 const first = document.createElement('button');
601 first.textContent = 'First';
602 wrapper.append(first);
603 document.body.append(wrapper);
604 await new Promise(resolve => setTimeout(resolve));
605 const second = document.createElement('button');
606 second.textContent = 'Second';
607 wrapper.replaceChildren(second);
608 await new Promise(resolve => setTimeout(resolve));
609 return {
610 firstDisabled: first.disabled,
611 secondAriaDisabled: second.getAttribute('aria-disabled'),
612 wrapperInert: wrapper.hasAttribute('inert'),
613 };
614 });
615 assert.equal(replacementButtonState.firstDisabled, false);
616 assert.equal(replacementButtonState.secondAriaDisabled, 'true');
617 assert.equal(replacementButtonState.wrapperInert, false);
618 const modifiedClickAllowed = await page.evaluate(() => {
619 const link = document.querySelector(
620 '.catalog-sidebar a[href="/components/card"]',
621 );
622 return link.dispatchEvent(new MouseEvent('click', {
623 bubbles: true,
624 cancelable: true,
625 button: 0,
626 ctrlKey: true,
627 }));
628 });
629 assert.equal(modifiedClickAllowed, true);
630
631 await page.goto(`${baseUrl}/icons`, { waitUntil: 'networkidle' });
632 const iconCount = await page.locator('#iconGrid figure').count();
633 assert.ok(iconCount >= 30);
634 assert.equal(
635 await page.locator('#iconGrid figure zen-icon > svg').count(),
636 iconCount,
637 );
638 const iconFallback = await page.evaluate(() => {
639 const icon = document.createElement('zen-icon');
640 icon.setAttribute('name', 'not-a-real-icon');
641 icon.setAttribute('label', 'Missing icon fallback');
642 document.body.append(icon);
643 return {
644 fallback: icon.dataset.zenIcon,
645 hidden: icon.getAttribute('aria-hidden'),
646 label: icon.getAttribute('aria-label'),
647 role: icon.getAttribute('role'),
648 };
649 });
650 assert.deepEqual(iconFallback, {
651 fallback: 'alert',
652 hidden: null,
653 label: 'Missing icon fallback',
654 role: 'img',
655 });
656
657 await page.goto(`${baseUrl}/components/link`, {
658 waitUntil: 'networkidle',
659 });
660 const linkPrimitive = page.locator('zen-link[effect="paw"]').first();
661 assert.equal(
662 await linkPrimitive.locator(
663 ':scope > a > zen-icon[name="paw"][data-zen-link-decoration]',
664 ).count(),
665 1,
666 );
667 const pawMotion = await linkPrimitive.locator('zen-icon').evaluate(icon => {
668 const style = getComputedStyle(icon);
669 const keyframes = icon.getAnimations().some(animation =>
670 animation.animationName === 'zen-paw-step'
671 );
672 return {
673 animationDuration: style.animationDuration,
674 color: style.color,
675 keyframes,
676 };
677 });
678 assert.notEqual(pawMotion.animationDuration, '0s');
679 assert.notEqual(pawMotion.color, 'rgba(0, 0, 0, 0)');
680 assert.equal(pawMotion.keyframes, true);
681 await page.emulateMedia({ reducedMotion: 'reduce' });
682 assert.equal(
683 await linkPrimitive.locator('zen-icon').evaluate(
684 icon => getComputedStyle(icon).animationName,
685 ),
686 'none',
687 );
688 await page.emulateMedia({ reducedMotion: 'no-preference' });
689
690 await page.goto(`${baseUrl}/components/text`, {
691 waitUntil: 'networkidle',
692 });
693 const textScale = await page.locator('zen-text[size] > p').evaluateAll(
694 texts => texts.map(text => Number.parseFloat(
695 getComputedStyle(text).fontSize,
696 )),
697 );
698 assert.equal(textScale.length, 5);
699 for (let index = 1; index < textScale.length; index++) {
700 assert.ok(textScale[index] > textScale[index - 1]);
701 }
702
703 await page.goto(`${baseUrl}/components/heading`, {
704 waitUntil: 'networkidle',
705 });
706 const headingScale = await page.locator(
707 'zen-heading[size] > h3',
708 ).evaluateAll(headings => headings.map(heading => ({
709 fontSize: Number.parseFloat(getComputedStyle(heading).fontSize),
710 weight: Number(getComputedStyle(heading).fontWeight),
711 })));
712 assert.equal(headingScale.length, 5);
713 assert.ok(headingScale.every(heading => heading.weight >= 700));
714 for (let index = 1; index < headingScale.length; index++) {
715 assert.ok(
716 headingScale[index].fontSize > headingScale[index - 1].fontSize,
717 );
718 }
719
720 await page.goto(`${baseUrl}/components/box`, {
721 waitUntil: 'networkidle',
722 });
723 const boxScale = await page.locator('zen-box[padding]').evaluateAll(
724 boxes => boxes.map(box => Number.parseFloat(
725 getComputedStyle(box).paddingTop,
726 )),
727 );
728 assert.equal(boxScale.length, 5);
729 for (let index = 1; index < boxScale.length; index++) {
730 assert.ok(boxScale[index] > boxScale[index - 1]);
731 }
732
733 await page.goto(`${baseUrl}/components/skeleton`, {
734 waitUntil: 'networkidle',
735 });
736 assert.equal(
737 await page.locator('zen-skeleton[width="60%"]').evaluate(
738 skeleton => skeleton.style.getPropertyValue(
739 '--zenbu-skeleton-width',
740 ),
741 ),
742 '60%',
743 );
744
745 await page.goto(`${baseUrl}/tokens`, { waitUntil: 'networkidle' });
746 const colorTokenState = await page.evaluate(() => {
747 const root = document.documentElement;
748 const value = name => getComputedStyle(root)
749 .getPropertyValue(`--zen-color-${name}`)
750 .trim();
751 const systemValue = name => getComputedStyle(root)
752 .getPropertyValue(`--zenbu-sys-${name}`)
753 .trim();
754 const families = [
755 'neutral',
756 'red',
757 'orange',
758 'amber',
759 'green',
760 'teal',
761 'blue',
762 'violet',
763 'rose',
764 'brown',
765 ];
766 const steps = [
767 '50',
768 '100',
769 '200',
770 '300',
771 '400',
772 '500',
773 '600',
774 '700',
775 '800',
776 '900',
777 '950',
778 ];
779 const rampsComplete = families.every(family =>
780 steps.every(step => value(`${family}-${step}`))
781 );
782 const data = Array.from(
783 { length: 10 },
784 (_, index) => value(`data-${index + 1}`),
785 );
786 const materials = [
787 'paper',
788 'washi',
789 'linen',
790 'sumi',
791 'brick',
792 'persimmon',
793 'ochre',
794 'moss',
795 'patina',
796 'indigo',
797 'plum',
798 'clay',
799 'wood',
800 ].map(value);
801 const lightMuted = systemValue('color-surface-subtle');
802 root.dataset.zenTheme = 'dark';
803 const darkMuted = systemValue('color-surface-subtle');
804 const darkData = Array.from(
805 { length: 10 },
806 (_, index) => value(`data-${index + 1}`),
807 );
808 const themes = ['paper', 'ink', 'playful'].map(theme => {
809 root.dataset.zenTheme = theme;
810 const channels = value => value.match(/\d+(?:\.\d+)?/g)
811 .slice(0, 3)
812 .map(Number);
813 const luminance = value => {
814 const converted = channels(value).map(channel => {
815 const normalized = channel / 255;
816 return normalized <= 0.04045
817 ? normalized / 12.92
818 : ((normalized + 0.055) / 1.055) ** 2.4;
819 });
820 return converted[0] * 0.2126 +
821 converted[1] * 0.7152 +
822 converted[2] * 0.0722;
823 };
824 const contrasts = [
825 'info',
826 'success',
827 'warning',
828 'danger',
829 ].map(tone => {
830 const probe = document.createElement('span');
831 probe.style.background =
832 `var(--zenbu-sys-color-${tone}-background)`;
833 probe.style.color =
834 `var(--zenbu-sys-color-${tone}-foreground)`;
835 document.body.append(probe);
836 const probeStyle = getComputedStyle(probe);
837 const foreground = luminance(probeStyle.color);
838 const background = luminance(probeStyle.backgroundColor);
839 const contrast = (Math.max(foreground, background) + 0.05) /
840 (Math.min(foreground, background) + 0.05);
841 probe.remove();
842 return contrast;
843 });
844 return {
845 canvas: systemValue('color-surface-page'),
846 contrast: Math.min(...contrasts),
847 font: getComputedStyle(root)
848 .getPropertyValue('--zenbu-sys-font-family-ui')
849 .trim(),
850 name: theme,
851 radius: getComputedStyle(root)
852 .getPropertyValue('--zenbu-sys-radius-container')
853 .trim(),
854 };
855 });
856 delete root.dataset.zenTheme;
857 return {
858 darkData,
859 darkMuted,
860 data,
861 lightMuted,
862 materials,
863 rampsComplete,
864 themes,
865 };
866 });
867 assert.equal(colorTokenState.rampsComplete, true);
868 assert.equal(new Set(colorTokenState.materials).size, 13);
869 assert.equal(new Set(colorTokenState.data).size, 10);
870 assert.equal(new Set(colorTokenState.darkData).size, 10);
871 assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData);
872 assert.equal(
873 new Set(colorTokenState.themes.map(theme => theme.canvas)).size,
874 3,
875 );
876 assert.notEqual(
877 colorTokenState.themes.find(theme => theme.name === 'paper').radius,
878 colorTokenState.themes.find(theme => theme.name === 'playful').radius,
879 );
880 assert.ok(
881 colorTokenState.themes.every(theme => theme.contrast >= 4.5),
882 JSON.stringify(colorTokenState.themes),
883 );
884 assert.notEqual(
885 colorTokenState.lightMuted,
886 colorTokenState.darkMuted,
887 );
888 assert.equal(await page.locator('.palette-grid > div').count(), 13);
889 assert.equal(await page.locator('.data-palette > span').count(), 10);
890 await page.evaluate(() => {
891 document.documentElement.dataset.zenTheme = 'playful';
892 });
893 await page.emulateMedia({ forcedColors: 'active' });
894 assert.equal(
895 await page.evaluate(() =>
896 getComputedStyle(document.documentElement)
897 .getPropertyValue('--zenbu-sys-color-surface-page')
898 .trim()
899 .toLowerCase()
900 ),
901 'canvas',
902 );
903 await page.emulateMedia({ forcedColors: 'none' });
904 await page.evaluate(() => {
905 delete document.documentElement.dataset.zenTheme;
906 });
907
908 await page.goto(`${baseUrl}/components/field`, {
909 waitUntil: 'networkidle',
910 });
911 const fieldWiring = await page.locator('zen-field').first().evaluate(field => {
912 const label = field.querySelector('label');
913 const input = field.querySelector('input');
914 const help = field.querySelector('small');
915 input.checkValidity();
916 return {
917 describedBy: input.getAttribute('aria-describedby'),
918 helpId: help.id,
919 inputId: input.id,
920 invalid: field.hasAttribute('data-invalid'),
921 labelFor: label.htmlFor,
922 };
923 });
924 assert.ok(fieldWiring.inputId);
925 assert.equal(fieldWiring.labelFor, fieldWiring.inputId);
926 assert.equal(fieldWiring.describedBy, fieldWiring.helpId);
927 assert.equal(fieldWiring.invalid, true);
928 await page.locator('zen-field input').first().focus();
929 assert.equal(
930 await page.locator('zen-field input').first().evaluate(
931 input => getComputedStyle(input).outlineStyle,
932 ),
933 'none',
934 );
935 const replacementField = await page.locator('zen-field').first().evaluate(
936 async field => {
937 const label = field.querySelector('label');
938 const oldInput = field.querySelector('input');
939 const blocker = document.createElement('div');
940 blocker.id = 'zen-field-3';
941 document.body.append(blocker);
942 const nextInput = document.createElement('input');
943 nextInput.required = true;
944 oldInput.replaceWith(nextInput);
945 await new Promise(resolve => setTimeout(resolve));
946 oldInput.dispatchEvent(new Event('invalid'));
947 const help = field.querySelector('small');
948 nextInput.id = 'replacement-email';
949 help.id = 'replacement-help';
950 await new Promise(resolve => setTimeout(resolve));
951 return {
952 describedBy: nextInput.getAttribute('aria-describedby'),
953 helpId: help.id,
954 labelFor: label.htmlFor,
955 nextId: nextInput.id,
956 blockerId: blocker.id,
957 };
958 },
959 );
960 assert.ok(replacementField.nextId);
961 assert.notEqual(replacementField.nextId, replacementField.blockerId);
962 assert.equal(replacementField.labelFor, replacementField.nextId);
963 assert.equal(replacementField.describedBy, replacementField.helpId);
964
965 await page.goto(`${baseUrl}/components/alert`, {
966 waitUntil: 'networkidle',
967 });
968 await page.evaluate(() => {
969 window.__dismissed = 0;
970 document.addEventListener('zen-dismiss', () => {
971 window.__dismissed++;
972 });
973 });
974 const dismissible = page.locator('zen-alert[dismissible]');
975 assert.equal(await dismissible.getAttribute('role'), 'alert');
976 await dismissible.locator('[data-zen-dismiss]').click();
977 assert.equal(await dismissible.count(), 0);
978 assert.equal(await page.evaluate(() => window.__dismissed), 1);
979 const authoredDismissState = await page.evaluate(async () => {
980 const alert = document.createElement('zen-alert');
981 alert.setAttribute('dismissible', '');
982 const message = document.createElement('p');
983 message.textContent = 'Authored dismiss control';
984 const dismiss = document.createElement('button');
985 dismiss.dataset.zenDismiss = '';
986 alert.append(message, dismiss);
987 document.body.append(alert);
988 await new Promise(resolve => setTimeout(resolve));
989 let events = 0;
990 alert.addEventListener('zen-dismiss', () => events++);
991 alert.removeAttribute('dismissible');
992 dismiss.click();
993 return {
994 connected: alert.isConnected,
995 events,
996 };
997 });
998 assert.equal(authoredDismissState.connected, true);
999 assert.equal(authoredDismissState.events, 0);
1000
1001 await page.goto(`${baseUrl}/components/accordion`, {
1002 waitUntil: 'networkidle',
1003 });
1004 const accordion = page.locator('zen-accordion').first();
1005 const disclosureIcon = accordion.locator(
1006 'summary > zen-icon[data-zen-disclosure-icon]',
1007 ).nth(1);
1008 const collapsedDisclosure = await disclosureIcon.evaluate(icon => {
1009 const style = getComputedStyle(icon);
1010 return {
1011 duration: style.transitionDuration,
1012 transform: style.transform,
1013 };
1014 });
1015 assert.notEqual(collapsedDisclosure.duration, '0s');
1016 await accordion.locator('summary').nth(1).click();
1017 await page.waitForTimeout(250);
1018 assert.notEqual(
1019 await disclosureIcon.evaluate(icon => getComputedStyle(icon).transform),
1020 collapsedDisclosure.transform,
1021 );
1022 assert.equal(
1023 await accordion.locator('details').first().getAttribute('open'),
1024 null,
1025 );
1026 assert.equal(
1027 await accordion.locator('details').nth(1).getAttribute('open'),
1028 '',
1029 );
1030 await accordion.locator('summary').nth(1).press('ArrowUp');
1031 assert.equal(
1032 await accordion.locator('summary').first().evaluate(
1033 summary => document.activeElement === summary,
1034 ),
1035 true,
1036 );
1037
1038 await page.goto(`${baseUrl}/components/aspect-ratio`, {
1039 waitUntil: 'networkidle',
1040 });
1041 const aspectColors = await page.locator('.demo-media').evaluate(media => {
1042 const style = getComputedStyle(media);
1043 const probe = document.createElement('span');
1044 probe.style.background = 'var(--zenbu-sys-color-surface-subtle)';
1045 document.body.append(probe);
1046 const token = getComputedStyle(probe).backgroundColor;
1047 probe.remove();
1048 return {
1049 background: style.backgroundColor,
1050 image: style.backgroundImage,
1051 token,
1052 };
1053 });
1054 assert.equal(aspectColors.image, 'none');
1055 assert.equal(aspectColors.background, aspectColors.token);
1056
1057 await page.goto(`${baseUrl}/components/tabs`, {
1058 waitUntil: 'networkidle',
1059 });
1060 await page.evaluate(() => {
1061 window.__tabValue = null;
1062 document.querySelector('zen-tabs').addEventListener(
1063 'zen-change',
1064 event => {
1065 window.__tabValue = event.detail.value;
1066 },
1067 );
1068 });
1069 await page.locator('zen-tabs [role="tab"]').nth(1).click();
1070 assert.equal(
1071 await page.locator('zen-tabs [role="tab"]').nth(1)
1072 .getAttribute('aria-selected'),
1073 'true',
1074 );
1075 assert.equal(
1076 await page.locator('zen-tabs [role="tabpanel"]').nth(1)
1077 .getAttribute('hidden'),
1078 null,
1079 );
1080 assert.equal(await page.evaluate(() => window.__tabValue), 'history');
1081
1082 await page.goto(`${baseUrl}/components/dialog`, {
1083 waitUntil: 'networkidle',
1084 });
1085 await page.locator('zen-dialog [data-zen-trigger]').click();
1086 assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), '');
1087 await page.locator('zen-dialog [data-zen-close]').click();
1088 assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), null);
1089 const dynamicOverlays = await page.evaluate(async () => {
1090 const ownAction = button => {
1091 const owner = document.createElement('zen-button');
1092 owner.setAttribute('size', 'md');
1093 owner.append(button);
1094 return owner;
1095 };
1096 const dialogHost = document.createElement('zen-dialog');
1097 const popoverHost = document.createElement('zen-popover');
1098 const tooltipHost = document.createElement('zen-tooltip');
1099 const menuHost = document.createElement('zen-dropdown-menu');
1100 document.body.append(dialogHost, popoverHost, tooltipHost, menuHost);
1101
1102 const dialogTrigger = document.createElement('button');
1103 dialogTrigger.type = 'button';
1104 dialogTrigger.dataset.zenTrigger = '';
1105 const dialog = document.createElement('dialog');
1106 dialogHost.append(ownAction(dialogTrigger), dialog);
1107
1108 const popoverTrigger = document.createElement('button');
1109 popoverTrigger.type = 'button';
1110 popoverTrigger.dataset.zenTrigger = '';
1111 const popover = document.createElement('div');
1112 popover.dataset.zenContent = '';
1113 popoverHost.append(ownAction(popoverTrigger), popover);
1114
1115 const tooltipTrigger = document.createElement('button');
1116 tooltipTrigger.type = 'button';
1117 tooltipTrigger.dataset.zenTrigger = '';
1118 const tooltip = document.createElement('span');
1119 tooltip.dataset.zenContent = '';
1120 tooltipHost.append(ownAction(tooltipTrigger), tooltip);
1121
1122 const menuTrigger = document.createElement('button');
1123 menuTrigger.type = 'button';
1124 menuTrigger.dataset.zenTrigger = '';
1125 const menu = document.createElement('div');
1126 menu.setAttribute('role', 'menu');
1127 const item = document.createElement('button');
1128 item.type = 'button';
1129 item.setAttribute('role', 'menuitem');
1130 menu.append(item);
1131 menuHost.append(menuTrigger, menu);
1132
1133 await new Promise(resolve => setTimeout(resolve));
1134 dialogTrigger.click();
1135 const dialogOpen = dialog.open;
1136 dialog.close();
1137 popoverTrigger.click();
1138 menuTrigger.click();
1139 tooltipTrigger.focus();
1140 await new Promise(resolve => setTimeout(resolve, 375));
1141 return {
1142 dialogControlled: Boolean(dialogTrigger.getAttribute('aria-controls')),
1143 dialogOpen,
1144 menuOpen: !menu.hidden,
1145 popoverExpanded: popoverTrigger.getAttribute('aria-expanded'),
1146 tooltipOpen: !tooltip.hidden,
1147 };
1148 });
1149 assert.deepEqual(dynamicOverlays, {
1150 dialogControlled: true,
1151 dialogOpen: true,
1152 menuOpen: true,
1153 popoverExpanded: 'true',
1154 tooltipOpen: true,
1155 });
1156
1157 await page.goto(`${baseUrl}/components/dropdown-menu`, {
1158 waitUntil: 'networkidle',
1159 });
1160 const dropdownTrigger = page.locator(
1161 'zen-dropdown-menu [data-zen-trigger]',
1162 );
1163 await dropdownTrigger.click();
1164 assert.equal(
1165 await page.locator('zen-dropdown-menu [role="menu"]')
1166 .getAttribute('hidden'),
1167 null,
1168 );
1169 await page.keyboard.press('Escape');
1170 assert.equal(
1171 await page.locator('zen-dropdown-menu [role="menu"]')
1172 .getAttribute('hidden'),
1173 '',
1174 );
1175 assert.equal(
1176 await dropdownTrigger.evaluate(
1177 trigger => document.activeElement === trigger,
1178 ),
1179 true,
1180 );
1181 await dropdownTrigger.click();
1182 await page.locator('zen-dropdown-menu [role="menuitem"]').first().click();
1183 assert.equal(
1184 await dropdownTrigger.evaluate(
1185 trigger => document.activeElement === trigger,
1186 ),
1187 true,
1188 );
1189
1190 await page.goto(`${baseUrl}/components/combobox`, {
1191 waitUntil: 'networkidle',
1192 });
1193 const comboboxInput = page.locator('zen-combobox input');
1194 await comboboxInput.fill('seo');
1195 assert.equal(
1196 await page.locator('zen-combobox [role="option"]:not([hidden])').count(),
1197 1,
1198 );
1199 await comboboxInput.press('ArrowDown');
1200 await comboboxInput.press('Enter');
1201 assert.equal(await comboboxInput.inputValue(), 'seobeo');
1202 assert.equal(
1203 await page.locator('zen-combobox [role="listbox"]')
1204 .getAttribute('hidden'),
1205 '',
1206 );
1207
1208 await page.goto(`${baseUrl}/components/date-picker`, {
1209 waitUntil: 'networkidle',
1210 });
1211 const dateInput = page.locator('zen-date-picker input[type="date"]');
1212 assert.equal(await dateInput.getAttribute('aria-hidden'), 'true');
1213 assert.equal(
1214 await dateInput.evaluate(input => getComputedStyle(input).position),
1215 'absolute',
1216 );
1217 await page.evaluate(() => {
1218 window.__dateValue = null;
1219 document.querySelector('zen-date-picker').addEventListener(
1220 'zen-change',
1221 event => {
1222 window.__dateValue = event.detail.value;
1223 },
1224 );
1225 });
1226 const dateTrigger = page.locator(
1227 'zen-date-picker [data-zen-date-trigger]',
1228 );
1229 await dateTrigger.click();
1230 assert.equal(
1231 await page.locator('zen-date-picker [data-zen-calendar-panel]')
1232 .getAttribute('hidden'),
1233 null,
1234 );
1235 assert.equal(
1236 await page.locator(
1237 'zen-date-picker [data-zen-calendar-day]',
1238 ).count(),
1239 42,
1240 );
1241 const chosenDate = await page.locator(
1242 'zen-date-picker [data-zen-calendar-day]:not([data-outside]):not(:disabled)',
1243 ).nth(14).getAttribute('data-zen-calendar-day');
1244 await page.locator(
1245 `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`,
1246 ).click();
1247 assert.equal(await dateInput.inputValue(), chosenDate);
1248 assert.equal(await page.evaluate(() => window.__dateValue), chosenDate);
1249 assert.equal(await dateTrigger.getAttribute('aria-expanded'), 'false');
1250 const triggerName = await dateTrigger.evaluate(trigger =>
1251 (trigger.getAttribute('aria-labelledby') || '')
1252 .split(/\s+/)
1253 .map(id => document.getElementById(id)?.textContent || '')
1254 .join(' ')
1255 );
1256 assert.match(triggerName, /Deploy on/);
1257 assert.equal(
1258 await page.locator('zen-date-picker > label').getAttribute('for'),
1259 await dateTrigger.getAttribute('id'),
1260 );
1261 const minimumDate = `${chosenDate.slice(0, 8)}10`;
1262 await dateInput.evaluate((input, minimum) => {
1263 input.min = minimum;
1264 }, minimumDate);
1265 await page.waitForTimeout(0);
1266 await dateTrigger.click();
1267 const focusedBoundary = page.locator(
1268 `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`,
1269 );
1270 await focusedBoundary.press('PageUp');
1271 assert.equal(
1272 await page.evaluate(() =>
1273 document.activeElement?.dataset.zenCalendarDay
1274 ),
1275 minimumDate,
1276 );
1277 assert.equal(
1278 await page.locator(
1279 'zen-date-picker [data-zen-calendar-day][tabindex="0"]:not(:disabled)',
1280 ).count(),
1281 1,
1282 );
1283 await page.keyboard.press('Escape');
1284 await dateInput.evaluate(input => {
1285 input.disabled = true;
1286 });
1287 await page.waitForTimeout(0);
1288 assert.equal(await dateTrigger.isDisabled(), true);
1289 await dateInput.evaluate(input => {
1290 input.disabled = false;
1291 input.readOnly = true;
1292 });
1293 await page.waitForTimeout(0);
1294 assert.equal(await dateTrigger.isDisabled(), true);
1295 await dateInput.evaluate(input => {
1296 input.readOnly = false;
1297 input.required = true;
1298 input.value = '';
1299 input.dispatchEvent(new Event('input', { bubbles: true }));
1300 input.reportValidity();
1301 });
1302 await page.waitForTimeout(0);
1303 assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'true');
1304 assert.equal(
1305 await dateTrigger.evaluate(
1306 trigger => document.activeElement === trigger,
1307 ),
1308 true,
1309 );
1310 assert.equal(
1311 await page.locator('zen-date-picker').getAttribute('data-invalid'),
1312 '',
1313 );
1314 await dateInput.evaluate((input, value) => {
1315 input.value = value;
1316 input.dispatchEvent(new Event('input', { bubbles: true }));
1317 }, chosenDate);
1318 assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'false');
1319 const earlyYear = await page.evaluate(async () => {
1320 const calendar = document.createElement('zen-calendar');
1321 const input = document.createElement('input');
1322 input.type = 'date';
1323 input.value = '0001-01-01';
1324 input.setAttribute('aria-label', 'Early date');
1325 calendar.append(input);
1326 document.body.append(calendar);
1327 await new Promise(resolve => setTimeout(resolve));
1328 return {
1329 selected: calendar.querySelector(
1330 '[data-zen-calendar-day][aria-selected="true"]',
1331 )?.dataset.zenCalendarDay,
1332 value: input.value,
1333 };
1334 });
1335 assert.deepEqual(earlyYear, {
1336 selected: '0001-01-01',
1337 value: '0001-01-01',
1338 });
1339 const resetDateState = await page.evaluate(async () => {
1340 const label = document.createElement('span');
1341 label.id = 'reset-date-label';
1342 label.textContent = 'Reset date';
1343 const form = document.createElement('form');
1344 const picker = document.createElement('zen-date-picker');
1345 const input = document.createElement('input');
1346 input.type = 'date';
1347 input.defaultValue = '2026-08-04';
1348 input.setAttribute('aria-labelledby', label.id);
1349 picker.append(input);
1350 form.append(picker);
1351 document.body.append(label, form);
1352 await new Promise(resolve => setTimeout(resolve));
1353 input.value = '2026-08-12';
1354 input.dispatchEvent(new Event('input', { bubbles: true }));
1355 form.reset();
1356 await new Promise(resolve => setTimeout(resolve));
1357 const trigger = picker.querySelector('[data-zen-date-trigger]');
1358 return {
1359 labelledBy: trigger.getAttribute('aria-labelledby'),
1360 selected: picker.querySelector(
1361 '[data-zen-calendar-day][aria-selected="true"]',
1362 )?.dataset.zenCalendarDay,
1363 value: input.value,
1364 };
1365 });
1366 assert.match(resetDateState.labelledBy, /^reset-date-label /);
1367 assert.deepEqual(
1368 {
1369 selected: resetDateState.selected,
1370 value: resetDateState.value,
1371 },
1372 {
1373 selected: '2026-08-04',
1374 value: '2026-08-04',
1375 },
1376 );
1377
1378 await page.goto(`${baseUrl}/components/input-otp`, {
1379 waitUntil: 'networkidle',
1380 });
1381 await page.evaluate(() => {
1382 window.__otpValue = null;
1383 document.querySelector('zen-input-otp').addEventListener(
1384 'zen-complete',
1385 event => {
1386 window.__otpValue = event.detail.value;
1387 },
1388 );
1389 });
1390 await page.locator('zen-input-otp input').evaluate(input => {
1391 input.value = '12a3456';
1392 input.dispatchEvent(new Event('input', { bubbles: true }));
1393 });
1394 assert.equal(await page.locator('zen-input-otp input').inputValue(), '123456');
1395 assert.equal(
1396 await page.locator('zen-input-otp [data-zen-otp-slots] > span').count(),
1397 6,
1398 );
1399 const otp = page.locator('zen-input-otp');
1400 await otp.evaluate(host => host.setAttribute('size', 'xs'));
1401 const compactOtpSize = await otp.locator(
1402 '[data-zen-otp-slots] > span',
1403 ).first().evaluate(slot => slot.getBoundingClientRect().width);
1404 await otp.evaluate(host => host.setAttribute('size', 'xl'));
1405 const largeOtpSize = await otp.locator(
1406 '[data-zen-otp-slots] > span',
1407 ).first().evaluate(slot => slot.getBoundingClientRect().width);
1408 assert.ok(largeOtpSize > compactOtpSize);
1409 assert.equal(await page.evaluate(() => window.__otpValue), '123456');
1410
1411 await page.goto(`${baseUrl}/components/data-table`, {
1412 waitUntil: 'networkidle',
1413 });
1414 await page.locator('zen-data-table [data-zen-sort="commits"]').click();
1415 assert.equal(
1416 await page.locator('zen-data-table th').nth(1).getAttribute('aria-sort'),
1417 'ascending',
1418 );
1419 assert.equal(
1420 await page.locator('zen-data-table tbody tr').first().locator(
1421 '[data-key="commits"]',
1422 ).textContent(),
1423 '12',
1424 );
1425
1426 await page.goto(`${baseUrl}/components/carousel`, {
1427 waitUntil: 'networkidle',
1428 });
1429 await page.evaluate(() => {
1430 window.__carouselIndex = null;
1431 document.querySelector('zen-carousel').addEventListener(
1432 'zen-change',
1433 event => {
1434 window.__carouselIndex = event.detail.index;
1435 },
1436 );
1437 });
1438 await page.locator('zen-carousel [data-zen-next]').click();
1439 assert.equal(await page.evaluate(() => window.__carouselIndex), 1);
1440 assert.equal(
1441 await page.locator('zen-carousel [data-zen-prev]').isDisabled(),
1442 false,
1443 );
1444
1445 await page.goto(`${baseUrl}/components/resizable`, {
1446 waitUntil: 'networkidle',
1447 });
1448 const resizeHandle = page.locator('zen-resizable [data-zen-handle]');
1449 const initialPercent = Number(
1450 await resizeHandle.getAttribute('aria-valuenow'),
1451 );
1452 await resizeHandle.focus();
1453 await resizeHandle.press('ArrowRight');
1454 assert.ok(
1455 Number(await resizeHandle.getAttribute('aria-valuenow')) > initialPercent,
1456 );
1457
1458 await page.goto(`${baseUrl}/components/switch`, {
1459 waitUntil: 'networkidle',
1460 });
1461 const switchInput = page.locator('zen-switch input');
1462 assert.equal(await switchInput.getAttribute('role'), 'switch');
1463 assert.equal(await switchInput.getAttribute('aria-checked'), 'true');
1464 await switchInput.uncheck();
1465 assert.equal(await switchInput.getAttribute('aria-checked'), 'false');
1466
1467 await page.goto(`${baseUrl}/components/checkbox`, {
1468 waitUntil: 'networkidle',
1469 });
1470 assert.equal(
1471 await page.locator('zen-checkbox input').evaluate(
1472 input => getComputedStyle(input).appearance,
1473 ),
1474 'none',
1475 );
1476 await page.goto(`${baseUrl}/components/native-select`, {
1477 waitUntil: 'networkidle',
1478 });
1479 assert.equal(
1480 await page.locator('zen-native-select select').evaluate(
1481 select => getComputedStyle(select).appearance,
1482 ),
1483 'none',
1484 );
1485 assert.equal(
1486 await page.locator(
1487 'zen-native-select > zen-icon[data-zen-select-icon]',
1488 ).count(),
1489 1,
1490 );
1491 await page.goto(`${baseUrl}/components/slider`, {
1492 waitUntil: 'networkidle',
1493 });
1494 assert.equal(
1495 await page.locator('zen-slider input').evaluate(
1496 input => getComputedStyle(input).appearance,
1497 ),
1498 'none',
1499 );
1500
1501 await page.goto(`${baseUrl}/components/toggle-group`, {
1502 waitUntil: 'networkidle',
1503 });
1504 await page.locator('zen-toggle-group button[value="right"]').click();
1505 assert.equal(
1506 await page.locator('zen-toggle-group button[value="right"]')
1507 .getAttribute('aria-pressed'),
1508 'true',
1509 );
1510 assert.equal(
1511 await page.locator('zen-toggle-group').getAttribute('value'),
1512 'right',
1513 );
1514
1515 await page.goto(`${baseUrl}/components/sidebar`, {
1516 waitUntil: 'networkidle',
1517 });
1518 const sidebarTrigger = page.locator(
1519 'zen-sidebar [data-zen-sidebar-trigger]',
1520 );
1521 const sidebarPanel = page.locator(
1522 'zen-sidebar [data-zen-sidebar-panel]',
1523 );
1524 assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'true');
1525 assert.equal(await sidebarPanel.getAttribute('hidden'), null);
1526 await sidebarTrigger.click();
1527 assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'false');
1528 assert.equal(await sidebarPanel.getAttribute('hidden'), '');
1529
1530 await page.goto(`${baseUrl}/components/notifications`, {
1531 waitUntil: 'networkidle',
1532 });
1533 const notificationScope = page.locator('zen-notifications').first();
1534 const notificationPosition = await notificationScope.locator(
1535 '[data-zen-notification-stack]',
1536 ).evaluate(stack => {
1537 const style = getComputedStyle(stack);
1538 return {
1539 bottom: style.bottom,
1540 position: style.position,
1541 right: style.right,
1542 };
1543 });
1544 assert.equal(notificationPosition.position, 'fixed');
1545 assert.notEqual(notificationPosition.bottom, 'auto');
1546 assert.notEqual(notificationPosition.right, 'auto');
1547
1548 const dedupe = await notificationScope.evaluate(scope => {
1549 const source = scope.querySelector('[data-notification-demo]');
1550 const send = detail => source.dispatchEvent(
1551 new CustomEvent('zen-notify', {
1552 bubbles: true,
1553 composed: false,
1554 detail,
1555 }),
1556 );
1557 send({
1558 version: 1,
1559 id: 'dedupe',
1560 tone: 'info',
1561 message: 'First announcement',
1562 announcement: 'polite',
1563 persistent: true,
1564 });
1565 send({
1566 version: 1,
1567 id: 'dedupe',
1568 tone: 'success',
1569 message: 'Updated without duplication',
1570 announcement: 'polite',
1571 persistent: true,
1572 });
1573 send({ version: 99, id: 'invalid' });
1574 return {
1575 size: scope.size,
1576 visible: scope.visibleCount,
1577 };
1578 });
1579 assert.deepEqual(dedupe, { size: 1, visible: 1 });
1580 assert.equal(
1581 await notificationScope.locator('article').count(),
1582 1,
1583 );
1584 assert.match(
1585 await notificationScope.locator('article').textContent(),
1586 /Updated without duplication/,
1587 );
1588 await page.waitForFunction(() =>
1589 document.querySelector(
1590 'zen-notifications [data-zen-live="polite"]',
1591 )?.textContent === 'First announcement'
1592 );
1593
1594 const bounded = await notificationScope.evaluate(scope => {
1595 let accepted = 0;
1596 for (let index = 0; index < 25; index++) {
1597 if (scope.notify({
1598 version: 1,
1599 id: `bounded-${index}`,
1600 tone: 'info',
1601 message: `Bounded ${index}`,
1602 announcement: 'none',
1603 durationMs: 120000,
1604 })) accepted++;
1605 }
1606 return {
1607 accepted,
1608 size: scope.size,
1609 visible: scope.visibleCount,
1610 };
1611 });
1612 assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 });
1613 assert.equal(
1614 await notificationScope.locator('article').count(),
1615 3,
1616 );
1617 await page.waitForFunction(() =>
1618 [...document.querySelectorAll(
1619 'zen-notifications article',
1620 )].some(article => article.dataset.depth === '2')
1621 );
1622 const collapsedStack = await notificationScope.locator(
1623 '[data-zen-notification-stack]',
1624 ).evaluate(stack => {
1625 const articles = [...stack.querySelectorAll('article')];
1626 return {
1627 depths: articles.map(article => article.dataset.depth),
1628 height: stack.getBoundingClientRect().height,
1629 positions: articles.map(article =>
1630 getComputedStyle(article).position
1631 ),
1632 transforms: articles.map(article =>
1633 getComputedStyle(article).transform
1634 ),
1635 };
1636 });
1637 assert.deepEqual(collapsedStack.depths, ['2', '1', '0']);
1638 assert.deepEqual(
1639 collapsedStack.positions,
1640 ['absolute', 'absolute', 'absolute'],
1641 );
1642 await notificationScope.locator('article').last().hover();
1643 await page.waitForTimeout(300);
1644 const expandedStack = await notificationScope.locator(
1645 '[data-zen-notification-stack]',
1646 ).evaluate(stack => ({
1647 height: stack.getBoundingClientRect().height,
1648 rects: [...stack.querySelectorAll('article')].map(article => {
1649 const bounds = article.getBoundingClientRect();
1650 return {
1651 bottom: bounds.bottom,
1652 left: bounds.left,
1653 right: bounds.right,
1654 top: bounds.top,
1655 };
1656 }),
1657 tops: [...stack.querySelectorAll('article')].map(
1658 article => Math.round(article.getBoundingClientRect().top),
1659 ),
1660 transforms: [...stack.querySelectorAll('article')].map(
1661 article => getComputedStyle(article).transform,
1662 ),
1663 }));
1664 assert.ok(expandedStack.height > collapsedStack.height);
1665 assert.equal(new Set(expandedStack.tops).size, 3);
1666 const expandedGaps = [
1667 expandedStack.rects[1].top - expandedStack.rects[0].bottom,
1668 expandedStack.rects[2].top - expandedStack.rects[1].bottom,
1669 ];
1670 for (const gap of expandedGaps) {
1671 assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps));
1672 }
1673 assert.notDeepEqual(
1674 expandedStack.transforms,
1675 collapsedStack.transforms,
1676 );
1677 const gapX =
1678 (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2;
1679 const gapY =
1680 (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2;
1681 await page.mouse.move(gapX, gapY);
1682 await page.waitForTimeout(100);
1683 const gapState = await notificationScope.locator(
1684 '[data-zen-notification-stack]',
1685 ).evaluate(stack => ({
1686 height: stack.getBoundingClientRect().height,
1687 hovered: stack.matches(':hover'),
1688 }));
1689 assert.equal(gapState.hovered, true);
1690 assert.ok(gapState.height >= expandedStack.height - 1);
1691 await page.mouse.move(0, 0);
1692
1693 const actionScope = await page.evaluate(() => {
1694 const scope = document.createElement('zen-notifications');
1695 scope.id = 'action-scope';
1696 const source = document.createElement('button');
1697 scope.append(source);
1698 document.body.append(scope);
1699 window.__notificationAction = null;
1700 scope.addEventListener('zen-notification-action', event => {
1701 window.__notificationAction = event.detail;
1702 });
1703 source.dispatchEvent(new CustomEvent('zen-notify', {
1704 bubbles: true,
1705 composed: false,
1706 detail: {
1707 version: 1,
1708 id: 'action',
1709 tone: 'error',
1710 message: 'Action required',
1711 announcement: 'assertive',
1712 persistent: true,
1713 action: {
1714 token: 'opaque-secret-token',
1715 label: 'Retry',
1716 },
1717 },
1718 }));
1719 return scope.id;
1720 });
1721 assert.equal(
1722 await page.locator(`#${actionScope}`).evaluate(
1723 scope => scope.records,
1724 ),
1725 undefined,
1726 );
1727 const actionArticle = page.locator(
1728 `#${actionScope} [data-zen-notification-id="action"]`,
1729 );
1730 assert.doesNotMatch(
1731 await actionArticle.evaluate(article => article.outerHTML),
1732 /opaque-secret-token/,
1733 );
1734 await actionArticle.locator('.zen-notification-action').focus();
1735 await page.locator(`#${actionScope}`).evaluate(scope => {
1736 const source = scope.querySelector('button');
1737 source.dispatchEvent(new CustomEvent('zen-notify', {
1738 bubbles: true,
1739 composed: false,
1740 detail: {
1741 version: 1,
1742 id: 'action-neighbor',
1743 tone: 'info',
1744 message: 'Neighbor',
1745 announcement: 'none',
1746 persistent: true,
1747 },
1748 }));
1749 });
1750 assert.equal(
1751 await actionArticle.locator('.zen-notification-action').evaluate(
1752 action => document.activeElement === action,
1753 ),
1754 true,
1755 );
1756 await actionArticle.locator('.zen-notification-action').click();
1757 assert.deepEqual(
1758 await page.evaluate(() => window.__notificationAction),
1759 {
1760 version: 1,
1761 id: 'action',
1762 token: 'opaque-secret-token',
1763 },
1764 );
1765 await page.locator(`#${actionScope}`).evaluate(scope => {
1766 const source = scope.querySelector('button');
1767 source.dispatchEvent(new CustomEvent(
1768 'zen-dismiss-notification',
1769 {
1770 bubbles: true,
1771 composed: false,
1772 detail: { version: 1, id: 'action' },
1773 },
1774 ));
1775 });
1776 await actionArticle.waitFor({ state: 'detached' });
1777
1778 await page.evaluate(() => {
1779 const scope = document.createElement('zen-notifications');
1780 scope.id = 'announcement-scope';
1781 const source = document.createElement('button');
1782 scope.append(source);
1783 document.body.append(scope);
1784 window.__announcements = [];
1785 const region = scope.querySelector('[data-zen-live="polite"]');
1786 new MutationObserver(() => {
1787 if (region.textContent) {
1788 window.__announcements.push(region.textContent);
1789 }
1790 }).observe(region, { childList: true });
1791 for (let index = 0; index < 3; index++) {
1792 source.dispatchEvent(new CustomEvent('zen-notify', {
1793 bubbles: true,
1794 composed: false,
1795 detail: {
1796 version: 1,
1797 id: `announcement-${index}`,
1798 tone: 'info',
1799 message: `Announcement ${index}`,
1800 announcement: 'polite',
1801 persistent: true,
1802 },
1803 }));
1804 }
1805 });
1806 await page.waitForFunction(() =>
1807 window.__announcements?.length === 3
1808 );
1809 assert.deepEqual(
1810 await page.evaluate(() => window.__announcements),
1811 ['Announcement 0', 'Announcement 1', 'Announcement 2'],
1812 );
1813
1814 const composedRejected = await page.evaluate(() => {
1815 const scope = document.createElement('zen-notifications');
1816 const source = document.createElement('button');
1817 scope.append(source);
1818 document.body.append(scope);
1819 source.dispatchEvent(new CustomEvent('zen-notify', {
1820 bubbles: true,
1821 composed: true,
1822 detail: {
1823 version: 1,
1824 id: 'composed',
1825 tone: 'info',
1826 message: 'Must be rejected',
1827 announcement: 'none',
1828 persistent: true,
1829 },
1830 }));
1831 return scope.size;
1832 });
1833 assert.equal(composedRejected, 0);
1834
1835 await page.evaluate(() => {
1836 const scope = document.createElement('zen-notifications');
1837 scope.id = 'focus-timer-scope';
1838 const source = document.createElement('button');
1839 scope.append(source);
1840 document.body.append(scope);
1841 source.dispatchEvent(new CustomEvent('zen-notify', {
1842 bubbles: true,
1843 composed: false,
1844 detail: {
1845 version: 1,
1846 id: 'focus-timer',
1847 tone: 'warning',
1848 message: 'Focus timer',
1849 announcement: 'none',
1850 durationMs: 300,
1851 action: { token: 'focus-token', label: 'Keep focused' },
1852 },
1853 }));
1854 });
1855 const focusTimer = page.locator(
1856 '#focus-timer-scope [data-zen-notification-id="focus-timer"]',
1857 );
1858 await focusTimer.locator('.zen-notification-action').focus();
1859 await page.locator('#focus-timer-scope').evaluate(scope => {
1860 const source = scope.querySelector(':scope > button');
1861 source.dispatchEvent(new CustomEvent('zen-notify', {
1862 bubbles: true,
1863 composed: false,
1864 detail: {
1865 version: 1,
1866 id: 'focus-neighbor',
1867 tone: 'info',
1868 message: 'Focus neighbor',
1869 announcement: 'none',
1870 persistent: true,
1871 },
1872 }));
1873 });
1874 await page.waitForTimeout(500);
1875 assert.equal(await focusTimer.count(), 1);
1876 assert.equal(
1877 await focusTimer.locator('.zen-notification-action').evaluate(
1878 action => document.activeElement === action,
1879 ),
1880 true,
1881 );
1882 await page.mouse.move(0, 0);
1883 await page.locator('#catalogMain').focus();
1884 await page.waitForFunction(() =>
1885 !document.querySelector(
1886 '#focus-timer-scope [data-zen-notification-id="focus-timer"]',
1887 )
1888 );
1889
1890 await page.evaluate(() => {
1891 const scope = document.createElement('zen-notifications');
1892 scope.id = 'timer-scope';
1893 const source = document.createElement('button');
1894 scope.append(source);
1895 document.body.append(scope);
1896 source.dispatchEvent(new CustomEvent('zen-notify', {
1897 bubbles: true,
1898 composed: false,
1899 detail: {
1900 version: 1,
1901 id: 'timer',
1902 tone: 'info',
1903 message: 'Paused timer',
1904 announcement: 'none',
1905 durationMs: 300,
1906 },
1907 }));
1908 });
1909 const timerArticle = page.locator('#timer-scope article');
1910 await timerArticle.hover();
1911 await page.waitForTimeout(500);
1912 assert.equal(await timerArticle.count(), 1);
1913 await page.mouse.move(0, 0);
1914 await page.waitForFunction(() =>
1915 !document.querySelector('#timer-scope article')
1916 );
1917
1918 await page.evaluate(() => {
1919 const scope = document.createElement('zen-notifications');
1920 scope.id = 'hidden-timer-scope';
1921 const source = document.createElement('button');
1922 scope.append(source);
1923 document.body.append(scope);
1924 source.dispatchEvent(new CustomEvent('zen-notify', {
1925 bubbles: true,
1926 composed: false,
1927 detail: {
1928 version: 1,
1929 id: 'hidden-timer',
1930 tone: 'info',
1931 message: 'Hidden timer',
1932 announcement: 'none',
1933 durationMs: 300,
1934 },
1935 }));
1936 Object.defineProperty(document, 'hidden', {
1937 configurable: true,
1938 value: true,
1939 });
1940 document.dispatchEvent(new Event('visibilitychange'));
1941 });
1942 await page.waitForTimeout(500);
1943 assert.equal(
1944 await page.locator('#hidden-timer-scope article').count(),
1945 1,
1946 );
1947 await page.evaluate(() => {
1948 Object.defineProperty(document, 'hidden', {
1949 configurable: true,
1950 value: false,
1951 });
1952 document.dispatchEvent(new Event('visibilitychange'));
1953 });
1954 await page.waitForFunction(() =>
1955 !document.querySelector('#hidden-timer-scope article')
1956 );
1957
1958 const isolated = await page.evaluate(() => {
1959 const makeScope = id => {
1960 const scope = document.createElement('zen-notifications');
1961 scope.id = id;
1962 const source = document.createElement('button');
1963 scope.append(source);
1964 document.body.append(scope);
1965 return { scope, source };
1966 };
1967 const first = makeScope('scope-one');
1968 const second = makeScope('scope-two');
1969 first.source.dispatchEvent(new CustomEvent('zen-notify', {
1970 bubbles: true,
1971 composed: false,
1972 detail: {
1973 version: 1,
1974 id: 'isolated',
1975 tone: 'success',
1976 message: 'Only first scope',
1977 announcement: 'none',
1978 persistent: true,
1979 },
1980 }));
1981 return [first.scope.size, second.scope.size];
1982 });
1983 assert.deepEqual(isolated, [1, 0]);
1984
1985 const lightCanvas = await page.evaluate(() =>
1986 getComputedStyle(document.documentElement)
1987 .getPropertyValue('--zenbu-sys-color-surface-page')
1988 .trim()
1989 );
1990 await page.locator('#themeToggle').click();
1991 const darkCanvas = await page.evaluate(() =>
1992 getComputedStyle(document.documentElement)
1993 .getPropertyValue('--zenbu-sys-color-surface-page')
1994 .trim()
1995 );
1996 assert.notEqual(lightCanvas, darkCanvas);
1997 assert.equal(
1998 await page.evaluate(() => localStorage.getItem('zen-theme')),
1999 'dark',
2000 );
2001
2002 assert.deepEqual(errors, []);
2003 await context.close();
2004
2005 const darkContext = await browser.newContext({ colorScheme: 'dark' });
2006 const darkPage = await darkContext.newPage();
2007 await darkPage.goto(baseUrl, { waitUntil: 'networkidle' });
2008 assert.equal(
2009 await darkPage.locator('#themeToggle').getAttribute('aria-pressed'),
2010 'true',
2011 );
2012 const systemDarkCanvas = await darkPage.evaluate(() =>
2013 getComputedStyle(document.documentElement)
2014 .getPropertyValue('--zenbu-sys-color-surface-page')
2015 .trim()
2016 );
2017 await darkPage.locator('#themeToggle').click();
2018 const explicitLightCanvas = await darkPage.evaluate(() =>
2019 getComputedStyle(document.documentElement)
2020 .getPropertyValue('--zenbu-sys-color-surface-page')
2021 .trim()
2022 );
2023 assert.notEqual(systemDarkCanvas, explicitLightCanvas);
2024 await darkContext.close();
2025
2026 const mobileContext = await browser.newContext({
2027 colorScheme: 'light',
2028 viewport: { width: 390, height: 844 },
2029 });
2030 const mobilePage = await mobileContext.newPage();
2031 const mobileErrors = [];
2032 mobilePage.on('pageerror', error => {
2033 mobileErrors.push(error.stack || error.message);
2034 });
2035 mobilePage.on('console', message => {
2036 if (message.type() === 'error') mobileErrors.push(message.text());
2037 });
2038 await mobilePage.goto(baseUrl, { waitUntil: 'networkidle' });
2039 await mobilePage.waitForFunction(() =>
2040 customElements.get('zen-date-picker') &&
2041 document.querySelectorAll(
2042 '.catalog-nav[aria-label="Components"] a',
2043 ).length === 70
2044 );
2045 const mobileFailures = await mobilePage.evaluate(() => {
2046 const routes = [
2047 '/',
2048 '/tokens',
2049 '/icons',
2050 ...[...document.querySelectorAll(
2051 '.catalog-nav[aria-label="Components"] a',
2052 )].map(link => new URL(link.href).pathname),
2053 ];
2054 const failures = [];
2055 for (const route of routes) {
2056 history.replaceState({}, '', route);
2057 window.dispatchEvent(new PopStateEvent('popstate'));
2058 const page = document.querySelector(
2059 '[data-catalog-page]:not([hidden])',
2060 );
2061 if (!page ||
2062 document.documentElement.scrollWidth > innerWidth + 1 ||
2063 page.getBoundingClientRect().right > innerWidth + 1) {
2064 failures.push(route);
2065 }
2066 }
2067 return failures;
2068 });
2069 assert.deepEqual(mobileFailures, []);
2070 await mobilePage.goto(`${baseUrl}/components/date-picker`, {
2071 waitUntil: 'networkidle',
2072 });
2073 await mobilePage.locator(
2074 'zen-date-picker [data-zen-date-trigger]',
2075 ).click();
2076 const mobileCalendarBounds = await mobilePage.locator(
2077 'zen-date-picker [data-zen-calendar-panel]',
2078 ).evaluate(panel => {
2079 const bounds = panel.getBoundingClientRect();
2080 return {
2081 bottom: bounds.bottom,
2082 left: bounds.left,
2083 right: bounds.right,
2084 top: bounds.top,
2085 };
2086 });
2087 assert.ok(mobileCalendarBounds.left >= 0);
2088 assert.ok(mobileCalendarBounds.right <= 390);
2089 assert.ok(mobileCalendarBounds.top >= 0);
2090 assert.ok(mobileCalendarBounds.bottom <= 844);
2091 assert.deepEqual(mobileErrors, []);
2092 await mobileContext.close();
2093 } finally {
2094 if (browser) await browser.close();
2095 await stopProcess(server);
2096 }
2097 })().catch(error => {
2098 console.error(error.stack || error);
2099 process.exitCode = 1;
2100 });