Mercurial
view design_system/test/catalog_test.js @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | 667156fcd3e3 |
| children |
line wrap: on
line source
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', '/icons', '/components', '/components/button', '/components/card', '/components/alert', '/components/field', '/components/notifications', '/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/reference.css', '/styles/semantic.css', '/styles/density.css', '/styles/themes.css', '/styles/components.css', '/styles/elements.css', '/components/index.js', '/components/icon.js', '/catalog.js', '/catalog.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 = []; const devtools = await context.newCDPSession(page); await devtools.send('Runtime.enable'); devtools.on('Runtime.exceptionThrown', event => { const exception = event.exceptionDetails; errors.push( `${exception.text}: ${exception.exception?.description || ''} ` + `${exception.url || ''}:${exception.lineNumber + 1}`, ); }); page.on('pageerror', error => errors.push(error.stack || error.message)); page.on('console', message => { if (message.type() === 'error') { const location = message.location(); errors.push( `${message.text()} ${location.url || ''}:${location.lineNumber + 1}`, ); } }); page.on('response', response => { if (response.status() >= 400) { errors.push(`${response.status()} ${response.url()}`); } }); await page.goto(`${baseUrl}/components/button`, { waitUntil: 'networkidle', }); try { await page.waitForFunction( () => customElements.get('zen-button') && customElements.get('zen-story'), undefined, { timeout: 5000 }, ); } catch (error) { throw new Error(`${error.message}\n${errors.join('\n')}`); } const catalogState = await page.evaluate(async () => { const links = [ ...document.querySelectorAll( '.catalog-nav[aria-label="Components"] a', ), ]; const elements = [...new Set( links.map(link => link.dataset.zenElement).filter(Boolean), )]; const statuses = await Promise.all( links.map(async link => (await fetch(link.href)).status), ); return { cards: document.querySelectorAll('.component-grid > a').length, elements, links: links.length, missing: elements.filter(name => !customElements.get(name)), routes: links.map(link => new URL(link.href).pathname), statuses, }; }); assert.equal(catalogState.cards, 70); assert.equal(catalogState.links, 70); assert.equal(catalogState.elements.length, 69); assert.deepEqual(catalogState.missing, []); assert.deepEqual( [...new Set(catalogState.statuses)], [200], ); const primitiveOwnership = await page.evaluate(() => { const buttonOwners = [ 'zen-alert', 'zen-attachment', 'zen-button', 'zen-button-group', 'zen-calendar', 'zen-carousel', 'zen-combobox', 'zen-command', 'zen-context-menu', 'zen-data-table', 'zen-date-picker', 'zen-dropdown-menu', 'zen-input-group', 'zen-menubar', 'zen-message-scroller', 'zen-navigation-menu', 'zen-notifications', 'zen-sidebar', 'zen-tabs', 'zen-toggle', 'zen-toggle-group', ].join(','); const controlOwners = [ 'zen-calendar', 'zen-checkbox', 'zen-combobox', 'zen-command', 'zen-date-picker', 'zen-field', 'zen-form', 'zen-input', 'zen-input-group', 'zen-input-otp', 'zen-native-select', 'zen-radio-group', 'zen-select', 'zen-slider', 'zen-switch', 'zen-textarea', ].join(','); return { buttons: [...document.querySelectorAll('button')].filter(button => !button.closest(buttonOwners) && !button.matches('zen-story > header > button') ).map(button => button.outerHTML), controls: [ ...document.querySelectorAll('input, select, textarea'), ].filter(control => !control.closest(controlOwners)) .map(control => control.outerHTML), }; }); assert.deepEqual(primitiveOwnership, { buttons: [], controls: [], }); const brokenCatalogPages = await page.evaluate(routes => { const failures = []; const buttonOwners = [ 'zen-alert', 'zen-attachment', 'zen-button', 'zen-button-group', 'zen-calendar', 'zen-carousel', 'zen-combobox', 'zen-command', 'zen-context-menu', 'zen-data-table', 'zen-date-picker', 'zen-dropdown-menu', 'zen-input-group', 'zen-menubar', 'zen-message-scroller', 'zen-navigation-menu', 'zen-notifications', 'zen-sidebar', 'zen-tabs', 'zen-toggle', 'zen-toggle-group', ].join(','); for (const route of ['/', '/tokens', '/icons', '/components', ...routes]) { history.replaceState({}, '', route); window.dispatchEvent(new PopStateEvent('popstate')); const visible = [ ...document.querySelectorAll('[data-catalog-page]:not([hidden])'), ]; const expected = route === '/' || route === '/components' ? 'overview' : route.slice(route.lastIndexOf('/') + 1); if (visible.length !== 1 || visible[0].dataset.catalogPage !== expected || !visible[0].querySelector('h1')?.textContent.trim()) { failures.push(route); continue; } if (route.startsWith('/components/')) { const story = visible[0].querySelector('zen-story'); const canvas = story?.querySelector('.story-canvas'); const source = story?.querySelector('pre code'); if (!story || !canvas?.children.length || !source?.textContent.trim()) { failures.push(`${route}:empty-story`); } const unownedButtons = [...canvas?.querySelectorAll('button') || []] .filter(button => !button.closest(buttonOwners)); if (unownedButtons.length) { failures.push(`${route}:unowned-button`); } } } history.replaceState({}, '', '/components/button'); window.dispatchEvent(new PopStateEvent('popstate')); return failures; }, catalogState.routes); assert.deepEqual(brokenCatalogPages, []); await page.keyboard.press('/'); assert.equal( await page.locator('#componentSearch').evaluate( input => document.activeElement === input, ), true, ); await page.locator('#componentSearch').fill('date'); assert.equal( await page.locator( '.catalog-nav[aria-label="Components"] a:not([hidden])', ).count(), 2, ); assert.equal( await page.locator('.component-grid > a:not([hidden])').count(), 2, ); assert.match( await page.locator('#componentSearchStatus').textContent(), /^2 components found$/, ); await page.locator('#componentSearch').press('Escape'); assert.equal(await page.locator('#componentSearch').inputValue(), ''); assert.equal( await page.locator( '.catalog-nav[aria-label="Components"] a:not([hidden])', ).count(), 70, ); await page.locator('#componentSearch').fill('stack'); assert.equal( await page.locator( '.catalog-nav[aria-label="Components"] a[href="/components/stack"]', ).getAttribute('hidden'), null, ); assert.equal( await page.locator( '.component-grid > a[href="/components/stack"]', ).getAttribute('hidden'), null, ); await page.locator('#componentSearch').press('Escape'); 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>/, ); const plainPresentation = await page.evaluate(() => { const style = document.createElement('style'); style.textContent = ` .application-button { padding: 7px; border: 3px solid currentColor; box-shadow: none; background: transparent; } .application-input { appearance: auto; padding: 9px; border: 4px solid currentColor; background: transparent; } `; document.head.append(style); const buttonHost = document.createElement('zen-button'); buttonHost.setAttribute('appearance', 'plain'); buttonHost.setAttribute('loading', ''); const button = document.createElement('button'); button.className = 'application-button'; button.textContent = 'Plain'; buttonHost.append(button); const card = document.createElement('zen-card'); card.setAttribute('appearance', 'plain'); const article = document.createElement('article'); card.append(article); const field = document.createElement('zen-field'); field.setAttribute('appearance', 'plain'); const label = document.createElement('label'); label.textContent = 'Plain field'; const input = document.createElement('input'); field.append(label, input); const inputHost = document.createElement('zen-input'); inputHost.setAttribute('appearance', 'plain'); inputHost.setAttribute('size', 'lg'); const applicationInput = document.createElement('input'); applicationInput.className = 'application-input'; inputHost.append(applicationInput); document.body.append(buttonHost, card, field, inputHost); const buttonStyle = getComputedStyle(button); const cardStyle = getComputedStyle(article); return { buttonBackground: buttonStyle.backgroundColor, buttonBorder: buttonStyle.borderTopWidth, buttonPadding: buttonStyle.paddingTop, buttonShadow: buttonStyle.boxShadow, buttonOpacity: buttonStyle.opacity, buttonSpinner: getComputedStyle(button, '::before').content, cardBackground: cardStyle.backgroundColor, cardBorder: cardStyle.borderTopWidth, cardShadow: cardStyle.boxShadow, fieldDisplay: getComputedStyle(field).display, inputAppearance: getComputedStyle(input).appearance, plainInputAppearance: getComputedStyle(applicationInput).appearance, plainInputBorder: getComputedStyle(applicationInput).borderTopWidth, plainInputPadding: getComputedStyle(applicationInput).paddingTop, labelFor: label.htmlFor, inputId: input.id, }; }); assert.deepEqual(plainPresentation, { buttonBackground: 'rgba(0, 0, 0, 0)', buttonBorder: '3px', buttonPadding: '7px', buttonShadow: 'none', buttonOpacity: '1', buttonSpinner: 'none', cardBackground: 'rgba(0, 0, 0, 0)', cardBorder: '0px', cardShadow: 'none', fieldDisplay: 'contents', inputAppearance: 'auto', plainInputAppearance: 'auto', plainInputBorder: '4px', plainInputPadding: '9px', labelFor: plainPresentation.inputId, inputId: plainPresentation.inputId, }); const buttonScale = await page.locator( '[data-catalog-page="button"] zen-button[size] > button', ).evaluateAll(buttons => buttons.map(button => { const host = button.parentElement; const style = getComputedStyle(button); const hostStyle = getComputedStyle(host); return { fontSize: Number.parseFloat(style.fontSize), height: button.getBoundingClientRect().height, paddingInline: Number.parseFloat(style.paddingInlineStart), size: host.getAttribute('size'), variableHeight: Number.parseFloat( hostStyle.getPropertyValue('--zenbu-control-height'), ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), }; })); assert.deepEqual( buttonScale.map(item => item.size), ['xs', 'sm', 'md', 'lg', 'xl'], ); for (let index = 1; index < buttonScale.length; index++) { assert.ok(buttonScale[index].height > buttonScale[index - 1].height); assert.ok( buttonScale[index].paddingInline > buttonScale[index - 1].paddingInline, ); assert.ok( buttonScale[index].fontSize > buttonScale[index - 1].fontSize, ); } for (const item of buttonScale) { assert.ok( Math.abs(item.height - item.variableHeight) < 1, JSON.stringify(buttonScale), ); } const compactScale = await page.evaluate(() => { document.documentElement.dataset.zenDensity = 'compact'; return [...document.querySelectorAll( '[data-catalog-page="button"] zen-button[size] > button', )] .map(button => button.getBoundingClientRect().height); }); assert.equal(compactScale.length, buttonScale.length); for (let index = 0; index < compactScale.length; index++) { assert.ok(compactScale[index] < buttonScale[index].height); } await page.evaluate(() => { delete document.documentElement.dataset.zenDensity; }); await page.emulateMedia({ reducedMotion: 'reduce' }); assert.equal( await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zenbu-sys-motion-duration-state') .trim() ), '1ms', ); await page.emulateMedia({ reducedMotion: 'no-preference' }); assert.equal( await page.locator('zen-button[loading]:not([appearance="plain"])').getAttribute('inert'), null, ); assert.equal( await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-disabled'), 'true', ); assert.equal( await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-busy'), 'true', ); await page.evaluate(() => { window.__loadingClicks = 0; document.querySelector('zen-button[loading]:not([appearance="plain"]) button') .addEventListener('click', () => window.__loadingClicks++); }); await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( button => button.click(), ); assert.equal(await page.evaluate(() => window.__loadingClicks), 0); await page.locator('zen-button[loading]:not([appearance="plain"]) button').focus(); assert.equal( await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( button => document.activeElement === button, ), true, ); await page.keyboard.press('Tab'); assert.equal( await page.locator('zen-button[loading]:not([appearance="plain"]) 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}/icons`, { waitUntil: 'networkidle' }); const iconCount = await page.locator('#iconGrid figure').count(); assert.ok(iconCount >= 30); assert.equal( await page.locator('#iconGrid figure zen-icon > svg').count(), iconCount, ); const iconFallback = await page.evaluate(() => { const icon = document.createElement('zen-icon'); icon.setAttribute('name', 'not-a-real-icon'); icon.setAttribute('label', 'Missing icon fallback'); document.body.append(icon); return { fallback: icon.dataset.zenIcon, hidden: icon.getAttribute('aria-hidden'), label: icon.getAttribute('aria-label'), role: icon.getAttribute('role'), }; }); assert.deepEqual(iconFallback, { fallback: 'alert', hidden: null, label: 'Missing icon fallback', role: 'img', }); await page.goto(`${baseUrl}/components/link`, { waitUntil: 'networkidle', }); const linkPrimitive = page.locator('zen-link[effect="paw"]').first(); assert.equal( await linkPrimitive.locator( ':scope > a > zen-icon[name="paw"][data-zen-link-decoration]', ).count(), 1, ); const pawMotion = await linkPrimitive.locator('zen-icon').evaluate(icon => { const style = getComputedStyle(icon); const keyframes = icon.getAnimations().some(animation => animation.animationName === 'zen-paw-step' ); return { animationDuration: style.animationDuration, color: style.color, keyframes, }; }); assert.notEqual(pawMotion.animationDuration, '0s'); assert.notEqual(pawMotion.color, 'rgba(0, 0, 0, 0)'); assert.equal(pawMotion.keyframes, true); await page.emulateMedia({ reducedMotion: 'reduce' }); assert.equal( await linkPrimitive.locator('zen-icon').evaluate( icon => getComputedStyle(icon).animationName, ), 'none', ); await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.goto(`${baseUrl}/components/text`, { waitUntil: 'networkidle', }); const textScale = await page.locator('zen-text[size] > p').evaluateAll( texts => texts.map(text => Number.parseFloat( getComputedStyle(text).fontSize, )), ); assert.equal(textScale.length, 5); for (let index = 1; index < textScale.length; index++) { assert.ok(textScale[index] > textScale[index - 1]); } await page.goto(`${baseUrl}/components/heading`, { waitUntil: 'networkidle', }); const headingScale = await page.locator( 'zen-heading[size] > h3', ).evaluateAll(headings => headings.map(heading => ({ fontSize: Number.parseFloat(getComputedStyle(heading).fontSize), weight: Number(getComputedStyle(heading).fontWeight), }))); assert.equal(headingScale.length, 5); assert.ok(headingScale.every(heading => heading.weight >= 700)); for (let index = 1; index < headingScale.length; index++) { assert.ok( headingScale[index].fontSize > headingScale[index - 1].fontSize, ); } await page.goto(`${baseUrl}/components/box`, { waitUntil: 'networkidle', }); const boxScale = await page.locator('zen-box[padding]').evaluateAll( boxes => boxes.map(box => Number.parseFloat( getComputedStyle(box).paddingTop, )), ); assert.equal(boxScale.length, 5); for (let index = 1; index < boxScale.length; index++) { assert.ok(boxScale[index] > boxScale[index - 1]); } await page.goto(`${baseUrl}/components/skeleton`, { waitUntil: 'networkidle', }); assert.equal( await page.locator('zen-skeleton[width="60%"]').evaluate( skeleton => skeleton.style.getPropertyValue( '--zenbu-skeleton-width', ), ), '60%', ); await page.goto(`${baseUrl}/tokens`, { waitUntil: 'networkidle' }); const colorTokenState = await page.evaluate(() => { const root = document.documentElement; const value = name => getComputedStyle(root) .getPropertyValue(`--zen-color-${name}`) .trim(); const systemValue = name => getComputedStyle(root) .getPropertyValue(`--zenbu-sys-${name}`) .trim(); const families = [ 'neutral', 'red', 'orange', 'amber', 'green', 'teal', 'blue', 'violet', 'rose', 'brown', ]; const steps = [ '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950', ]; const rampsComplete = families.every(family => steps.every(step => value(`${family}-${step}`)) ); const data = Array.from( { length: 10 }, (_, index) => value(`data-${index + 1}`), ); const materials = [ 'paper', 'washi', 'linen', 'sumi', 'brick', 'persimmon', 'ochre', 'moss', 'patina', 'indigo', 'plum', 'clay', 'wood', ].map(value); const lightMuted = systemValue('color-surface-subtle'); root.dataset.zenTheme = 'dark'; const darkMuted = systemValue('color-surface-subtle'); const darkData = Array.from( { length: 10 }, (_, index) => value(`data-${index + 1}`), ); const themes = ['paper', 'ink', 'playful', 'cyberpunk'].map(theme => { root.dataset.zenTheme = theme; const channels = value => value.match(/\d+(?:\.\d+)?/g) .slice(0, 3) .map(Number); const luminance = value => { const converted = channels(value).map(channel => { const normalized = channel / 255; return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return converted[0] * 0.2126 + converted[1] * 0.7152 + converted[2] * 0.0722; }; const contrasts = [ 'info', 'success', 'warning', 'danger', ].map(tone => { const probe = document.createElement('span'); probe.style.background = `var(--zenbu-sys-color-${tone}-background)`; probe.style.color = `var(--zenbu-sys-color-${tone}-foreground)`; document.body.append(probe); const probeStyle = getComputedStyle(probe); const foreground = luminance(probeStyle.color); const background = luminance(probeStyle.backgroundColor); const contrast = (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); probe.remove(); return contrast; }); return { canvas: systemValue('color-surface-page'), contrast: Math.min(...contrasts), font: getComputedStyle(root) .getPropertyValue('--zenbu-sys-font-family-ui') .trim(), name: theme, radius: getComputedStyle(root) .getPropertyValue('--zenbu-sys-radius-container') .trim(), }; }); delete root.dataset.zenTheme; return { darkData, darkMuted, data, lightMuted, materials, rampsComplete, themes, }; }); assert.equal(colorTokenState.rampsComplete, true); assert.equal(new Set(colorTokenState.materials).size, 13); assert.equal(new Set(colorTokenState.data).size, 10); assert.equal(new Set(colorTokenState.darkData).size, 10); assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData); assert.equal( new Set(colorTokenState.themes.map(theme => theme.canvas)).size, 4, ); assert.notEqual( colorTokenState.themes.find(theme => theme.name === 'paper').radius, colorTokenState.themes.find(theme => theme.name === 'playful').radius, ); assert.ok( colorTokenState.themes.every(theme => theme.contrast >= 4.5), JSON.stringify(colorTokenState.themes), ); assert.notEqual( colorTokenState.lightMuted, colorTokenState.darkMuted, ); assert.equal(await page.locator('.palette-grid > div').count(), 13); assert.equal(await page.locator('.data-palette > span').count(), 10); await page.evaluate(() => { document.documentElement.dataset.zenTheme = 'playful'; }); await page.emulateMedia({ forcedColors: 'active' }); assert.equal( await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zenbu-sys-color-surface-page') .trim() .toLowerCase() ), 'canvas', ); await page.emulateMedia({ forcedColors: 'none' }); await page.evaluate(() => { delete document.documentElement.dataset.zenTheme; }); 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); await page.locator('zen-field input').first().focus(); assert.equal( await page.locator('zen-field input').first().evaluate( input => getComputedStyle(input).outlineStyle, ), 'none', ); 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); await page.goto(`${baseUrl}/components/accordion`, { waitUntil: 'networkidle', }); const accordion = page.locator('zen-accordion').first(); const disclosureIcon = accordion.locator( 'summary > zen-icon[data-zen-disclosure-icon]', ).nth(1); const collapsedDisclosure = await disclosureIcon.evaluate(icon => { const style = getComputedStyle(icon); return { duration: style.transitionDuration, transform: style.transform, }; }); assert.notEqual(collapsedDisclosure.duration, '0s'); await accordion.locator('summary').nth(1).click(); await page.waitForTimeout(250); assert.notEqual( await disclosureIcon.evaluate(icon => getComputedStyle(icon).transform), collapsedDisclosure.transform, ); assert.equal( await accordion.locator('details').first().getAttribute('open'), null, ); assert.equal( await accordion.locator('details').nth(1).getAttribute('open'), '', ); await accordion.locator('summary').nth(1).press('ArrowUp'); assert.equal( await accordion.locator('summary').first().evaluate( summary => document.activeElement === summary, ), true, ); await page.goto(`${baseUrl}/components/aspect-ratio`, { waitUntil: 'networkidle', }); const aspectColors = await page.locator('.demo-media').evaluate(media => { const style = getComputedStyle(media); const probe = document.createElement('span'); probe.style.background = 'var(--zenbu-sys-color-surface-subtle)'; document.body.append(probe); const token = getComputedStyle(probe).backgroundColor; probe.remove(); return { background: style.backgroundColor, image: style.backgroundImage, token, }; }); assert.equal(aspectColors.image, 'none'); assert.equal(aspectColors.background, aspectColors.token); await page.goto(`${baseUrl}/components/tabs`, { waitUntil: 'networkidle', }); await page.evaluate(() => { window.__tabValue = null; document.querySelector('zen-tabs').addEventListener( 'zen-change', event => { window.__tabValue = event.detail.value; }, ); }); await page.locator('zen-tabs [role="tab"]').nth(1).click(); assert.equal( await page.locator('zen-tabs [role="tab"]').nth(1) .getAttribute('aria-selected'), 'true', ); assert.equal( await page.locator('zen-tabs [role="tabpanel"]').nth(1) .getAttribute('hidden'), null, ); assert.equal(await page.evaluate(() => window.__tabValue), 'history'); await page.goto(`${baseUrl}/components/dialog`, { waitUntil: 'networkidle', }); await page.locator('zen-dialog [data-zen-trigger]').click(); assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), ''); await page.locator('zen-dialog [data-zen-close]').click(); assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), null); const dynamicOverlays = await page.evaluate(async () => { const ownAction = button => { const owner = document.createElement('zen-button'); owner.setAttribute('size', 'md'); owner.append(button); return owner; }; const dialogHost = document.createElement('zen-dialog'); const popoverHost = document.createElement('zen-popover'); const tooltipHost = document.createElement('zen-tooltip'); const menuHost = document.createElement('zen-dropdown-menu'); document.body.append(dialogHost, popoverHost, tooltipHost, menuHost); const dialogTrigger = document.createElement('button'); dialogTrigger.type = 'button'; dialogTrigger.dataset.zenTrigger = ''; const dialog = document.createElement('dialog'); dialogHost.append(ownAction(dialogTrigger), dialog); const popoverTrigger = document.createElement('button'); popoverTrigger.type = 'button'; popoverTrigger.dataset.zenTrigger = ''; const popover = document.createElement('div'); popover.dataset.zenContent = ''; popoverHost.append(ownAction(popoverTrigger), popover); const tooltipTrigger = document.createElement('button'); tooltipTrigger.type = 'button'; tooltipTrigger.dataset.zenTrigger = ''; const tooltip = document.createElement('span'); tooltip.dataset.zenContent = ''; tooltipHost.append(ownAction(tooltipTrigger), tooltip); const menuTrigger = document.createElement('button'); menuTrigger.type = 'button'; menuTrigger.dataset.zenTrigger = ''; const menu = document.createElement('div'); menu.setAttribute('role', 'menu'); const item = document.createElement('button'); item.type = 'button'; item.setAttribute('role', 'menuitem'); menu.append(item); menuHost.append(menuTrigger, menu); await new Promise(resolve => setTimeout(resolve)); dialogTrigger.click(); const dialogOpen = dialog.open; dialog.close(); popoverTrigger.click(); menuTrigger.click(); tooltipTrigger.focus(); await new Promise(resolve => setTimeout(resolve, 375)); return { dialogControlled: Boolean(dialogTrigger.getAttribute('aria-controls')), dialogOpen, menuOpen: !menu.hidden, popoverExpanded: popoverTrigger.getAttribute('aria-expanded'), tooltipOpen: !tooltip.hidden, }; }); assert.deepEqual(dynamicOverlays, { dialogControlled: true, dialogOpen: true, menuOpen: true, popoverExpanded: 'true', tooltipOpen: true, }); await page.goto(`${baseUrl}/components/dropdown-menu`, { waitUntil: 'networkidle', }); const dropdownTrigger = page.locator( 'zen-dropdown-menu [data-zen-trigger]', ); await dropdownTrigger.click(); assert.equal( await page.locator('zen-dropdown-menu [role="menu"]') .getAttribute('hidden'), null, ); await page.keyboard.press('Escape'); assert.equal( await page.locator('zen-dropdown-menu [role="menu"]') .getAttribute('hidden'), '', ); assert.equal( await dropdownTrigger.evaluate( trigger => document.activeElement === trigger, ), true, ); await dropdownTrigger.click(); await page.locator('zen-dropdown-menu [role="menuitem"]').first().click(); assert.equal( await dropdownTrigger.evaluate( trigger => document.activeElement === trigger, ), true, ); await page.goto(`${baseUrl}/components/combobox`, { waitUntil: 'networkidle', }); const comboboxInput = page.locator('zen-combobox input'); await comboboxInput.fill('seo'); assert.equal( await page.locator('zen-combobox [role="option"]:not([hidden])').count(), 1, ); await comboboxInput.press('ArrowDown'); await comboboxInput.press('Enter'); assert.equal(await comboboxInput.inputValue(), 'seobeo'); assert.equal( await page.locator('zen-combobox [role="listbox"]') .getAttribute('hidden'), '', ); await page.goto(`${baseUrl}/components/date-picker`, { waitUntil: 'networkidle', }); const dateInput = page.locator('zen-date-picker input[type="date"]'); assert.equal(await dateInput.getAttribute('aria-hidden'), 'true'); assert.equal( await dateInput.evaluate(input => getComputedStyle(input).position), 'absolute', ); await page.evaluate(() => { window.__dateValue = null; document.querySelector('zen-date-picker').addEventListener( 'zen-change', event => { window.__dateValue = event.detail.value; }, ); }); const dateTrigger = page.locator( 'zen-date-picker [data-zen-date-trigger]', ); await dateTrigger.click(); assert.equal( await page.locator('zen-date-picker [data-zen-calendar-panel]') .getAttribute('hidden'), null, ); assert.equal( await page.locator( 'zen-date-picker [data-zen-calendar-day]', ).count(), 42, ); const chosenDate = await page.locator( 'zen-date-picker [data-zen-calendar-day]:not([data-outside]):not(:disabled)', ).nth(14).getAttribute('data-zen-calendar-day'); await page.locator( `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, ).click(); assert.equal(await dateInput.inputValue(), chosenDate); assert.equal(await page.evaluate(() => window.__dateValue), chosenDate); assert.equal(await dateTrigger.getAttribute('aria-expanded'), 'false'); const triggerName = await dateTrigger.evaluate(trigger => (trigger.getAttribute('aria-labelledby') || '') .split(/\s+/) .map(id => document.getElementById(id)?.textContent || '') .join(' ') ); assert.match(triggerName, /Deploy on/); assert.equal( await page.locator('zen-date-picker > label').getAttribute('for'), await dateTrigger.getAttribute('id'), ); const minimumDate = `${chosenDate.slice(0, 8)}10`; await dateInput.evaluate((input, minimum) => { input.min = minimum; }, minimumDate); await page.waitForTimeout(0); await dateTrigger.click(); const focusedBoundary = page.locator( `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, ); await focusedBoundary.press('PageUp'); assert.equal( await page.evaluate(() => document.activeElement?.dataset.zenCalendarDay ), minimumDate, ); assert.equal( await page.locator( 'zen-date-picker [data-zen-calendar-day][tabindex="0"]:not(:disabled)', ).count(), 1, ); await page.keyboard.press('Escape'); await dateInput.evaluate(input => { input.disabled = true; }); await page.waitForTimeout(0); assert.equal(await dateTrigger.isDisabled(), true); await dateInput.evaluate(input => { input.disabled = false; input.readOnly = true; }); await page.waitForTimeout(0); assert.equal(await dateTrigger.isDisabled(), true); await dateInput.evaluate(input => { input.readOnly = false; input.required = true; input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.reportValidity(); }); await page.waitForTimeout(0); assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'true'); assert.equal( await dateTrigger.evaluate( trigger => document.activeElement === trigger, ), true, ); assert.equal( await page.locator('zen-date-picker').getAttribute('data-invalid'), '', ); await dateInput.evaluate((input, value) => { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); }, chosenDate); assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'false'); const earlyYear = await page.evaluate(async () => { const calendar = document.createElement('zen-calendar'); const input = document.createElement('input'); input.type = 'date'; input.value = '0001-01-01'; input.setAttribute('aria-label', 'Early date'); calendar.append(input); document.body.append(calendar); await new Promise(resolve => setTimeout(resolve)); return { selected: calendar.querySelector( '[data-zen-calendar-day][aria-selected="true"]', )?.dataset.zenCalendarDay, value: input.value, }; }); assert.deepEqual(earlyYear, { selected: '0001-01-01', value: '0001-01-01', }); const resetDateState = await page.evaluate(async () => { const label = document.createElement('span'); label.id = 'reset-date-label'; label.textContent = 'Reset date'; const form = document.createElement('form'); const picker = document.createElement('zen-date-picker'); const input = document.createElement('input'); input.type = 'date'; input.defaultValue = '2026-08-04'; input.setAttribute('aria-labelledby', label.id); picker.append(input); form.append(picker); document.body.append(label, form); await new Promise(resolve => setTimeout(resolve)); input.value = '2026-08-12'; input.dispatchEvent(new Event('input', { bubbles: true })); form.reset(); await new Promise(resolve => setTimeout(resolve)); const trigger = picker.querySelector('[data-zen-date-trigger]'); return { labelledBy: trigger.getAttribute('aria-labelledby'), selected: picker.querySelector( '[data-zen-calendar-day][aria-selected="true"]', )?.dataset.zenCalendarDay, value: input.value, }; }); assert.match(resetDateState.labelledBy, /^reset-date-label /); assert.deepEqual( { selected: resetDateState.selected, value: resetDateState.value, }, { selected: '2026-08-04', value: '2026-08-04', }, ); await page.goto(`${baseUrl}/components/input-otp`, { waitUntil: 'networkidle', }); await page.evaluate(() => { window.__otpValue = null; document.querySelector('zen-input-otp').addEventListener( 'zen-complete', event => { window.__otpValue = event.detail.value; }, ); }); await page.locator('zen-input-otp input').evaluate(input => { input.value = '12a3456'; input.dispatchEvent(new Event('input', { bubbles: true })); }); assert.equal(await page.locator('zen-input-otp input').inputValue(), '123456'); assert.equal( await page.locator('zen-input-otp [data-zen-otp-slots] > span').count(), 6, ); const otp = page.locator('zen-input-otp'); await otp.evaluate(host => host.setAttribute('size', 'xs')); const compactOtpSize = await otp.locator( '[data-zen-otp-slots] > span', ).first().evaluate(slot => slot.getBoundingClientRect().width); await otp.evaluate(host => host.setAttribute('size', 'xl')); const largeOtpSize = await otp.locator( '[data-zen-otp-slots] > span', ).first().evaluate(slot => slot.getBoundingClientRect().width); assert.ok(largeOtpSize > compactOtpSize); assert.equal(await page.evaluate(() => window.__otpValue), '123456'); await page.goto(`${baseUrl}/components/data-table`, { waitUntil: 'networkidle', }); await page.locator('zen-data-table [data-zen-sort="commits"]').click(); assert.equal( await page.locator('zen-data-table th').nth(1).getAttribute('aria-sort'), 'ascending', ); assert.equal( await page.locator('zen-data-table tbody tr').first().locator( '[data-key="commits"]', ).textContent(), '12', ); await page.goto(`${baseUrl}/components/carousel`, { waitUntil: 'networkidle', }); await page.evaluate(() => { window.__carouselIndex = null; document.querySelector('zen-carousel').addEventListener( 'zen-change', event => { window.__carouselIndex = event.detail.index; }, ); }); await page.locator('zen-carousel [data-zen-next]').click(); assert.equal(await page.evaluate(() => window.__carouselIndex), 1); assert.equal( await page.locator('zen-carousel [data-zen-prev]').isDisabled(), false, ); await page.goto(`${baseUrl}/components/resizable`, { waitUntil: 'networkidle', }); const resizeHandle = page.locator('zen-resizable [data-zen-handle]'); const initialPercent = Number( await resizeHandle.getAttribute('aria-valuenow'), ); await resizeHandle.focus(); await resizeHandle.press('ArrowRight'); assert.ok( Number(await resizeHandle.getAttribute('aria-valuenow')) > initialPercent, ); await page.goto(`${baseUrl}/components/switch`, { waitUntil: 'networkidle', }); const switchInput = page.locator('zen-switch input'); assert.equal(await switchInput.getAttribute('role'), 'switch'); assert.equal(await switchInput.getAttribute('aria-checked'), 'true'); await switchInput.uncheck(); assert.equal(await switchInput.getAttribute('aria-checked'), 'false'); await page.goto(`${baseUrl}/components/checkbox`, { waitUntil: 'networkidle', }); assert.equal( await page.locator('zen-checkbox input').evaluate( input => getComputedStyle(input).appearance, ), 'none', ); await page.goto(`${baseUrl}/components/native-select`, { waitUntil: 'networkidle', }); assert.equal( await page.locator('zen-native-select select').evaluate( select => getComputedStyle(select).appearance, ), 'none', ); assert.equal( await page.locator( 'zen-native-select > zen-icon[data-zen-select-icon]', ).count(), 1, ); await page.goto(`${baseUrl}/components/slider`, { waitUntil: 'networkidle', }); assert.equal( await page.locator('zen-slider input').evaluate( input => getComputedStyle(input).appearance, ), 'none', ); await page.goto(`${baseUrl}/components/toggle-group`, { waitUntil: 'networkidle', }); await page.locator('zen-toggle-group button[value="right"]').click(); assert.equal( await page.locator('zen-toggle-group button[value="right"]') .getAttribute('aria-pressed'), 'true', ); assert.equal( await page.locator('zen-toggle-group').getAttribute('value'), 'right', ); await page.goto(`${baseUrl}/components/sidebar`, { waitUntil: 'networkidle', }); const sidebarTrigger = page.locator( 'zen-sidebar [data-zen-sidebar-trigger]', ); const sidebarPanel = page.locator( 'zen-sidebar [data-zen-sidebar-panel]', ); assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'true'); assert.equal(await sidebarPanel.getAttribute('hidden'), null); await sidebarTrigger.click(); assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'false'); assert.equal(await sidebarPanel.getAttribute('hidden'), ''); await page.goto(`${baseUrl}/components/notifications`, { waitUntil: 'networkidle', }); const notificationScope = page.locator('zen-notifications').first(); const notificationPosition = await notificationScope.locator( '[data-zen-notification-stack]', ).evaluate(stack => { const style = getComputedStyle(stack); return { bottom: style.bottom, position: style.position, right: style.right, }; }); assert.equal(notificationPosition.position, 'fixed'); assert.notEqual(notificationPosition.bottom, 'auto'); assert.notEqual(notificationPosition.right, 'auto'); const dedupe = await notificationScope.evaluate(scope => { const source = scope.querySelector('[data-notification-demo]'); const send = detail => source.dispatchEvent( new CustomEvent('zen-notify', { bubbles: true, composed: false, detail, }), ); send({ version: 1, id: 'dedupe', tone: 'info', message: 'First announcement', announcement: 'polite', persistent: true, }); send({ version: 1, id: 'dedupe', tone: 'success', message: 'Updated without duplication', announcement: 'polite', persistent: true, }); send({ version: 99, id: 'invalid' }); return { size: scope.size, visible: scope.visibleCount, }; }); assert.deepEqual(dedupe, { size: 1, visible: 1 }); assert.equal( await notificationScope.locator('article').count(), 1, ); assert.match( await notificationScope.locator('article').textContent(), /Updated without duplication/, ); await page.waitForFunction(() => document.querySelector( 'zen-notifications [data-zen-live="polite"]', )?.textContent === 'First announcement' ); const bounded = await notificationScope.evaluate(scope => { let accepted = 0; for (let index = 0; index < 25; index++) { if (scope.notify({ version: 1, id: `bounded-${index}`, tone: 'info', message: `Bounded ${index}`, announcement: 'none', durationMs: 120000, })) accepted++; } return { accepted, size: scope.size, visible: scope.visibleCount, }; }); assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 }); assert.equal( await notificationScope.locator('article').count(), 3, ); await page.waitForFunction(() => [...document.querySelectorAll( 'zen-notifications article', )].some(article => article.dataset.depth === '2') ); const collapsedStack = await notificationScope.locator( '[data-zen-notification-stack]', ).evaluate(stack => { const articles = [...stack.querySelectorAll('article')]; return { depths: articles.map(article => article.dataset.depth), height: stack.getBoundingClientRect().height, positions: articles.map(article => getComputedStyle(article).position ), transforms: articles.map(article => getComputedStyle(article).transform ), }; }); assert.deepEqual(collapsedStack.depths, ['2', '1', '0']); assert.deepEqual( collapsedStack.positions, ['absolute', 'absolute', 'absolute'], ); await notificationScope.locator('article').last().hover(); await page.waitForTimeout(300); const expandedStack = await notificationScope.locator( '[data-zen-notification-stack]', ).evaluate(stack => ({ height: stack.getBoundingClientRect().height, rects: [...stack.querySelectorAll('article')].map(article => { const bounds = article.getBoundingClientRect(); return { bottom: bounds.bottom, left: bounds.left, right: bounds.right, top: bounds.top, }; }), tops: [...stack.querySelectorAll('article')].map( article => Math.round(article.getBoundingClientRect().top), ), transforms: [...stack.querySelectorAll('article')].map( article => getComputedStyle(article).transform, ), })); assert.ok(expandedStack.height > collapsedStack.height); assert.equal(new Set(expandedStack.tops).size, 3); const expandedGaps = [ expandedStack.rects[1].top - expandedStack.rects[0].bottom, expandedStack.rects[2].top - expandedStack.rects[1].bottom, ]; for (const gap of expandedGaps) { assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps)); } assert.notDeepEqual( expandedStack.transforms, collapsedStack.transforms, ); const gapX = (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2; const gapY = (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2; await page.mouse.move(gapX, gapY); await page.waitForTimeout(100); const gapState = await notificationScope.locator( '[data-zen-notification-stack]', ).evaluate(stack => ({ height: stack.getBoundingClientRect().height, hovered: stack.matches(':hover'), })); assert.equal(gapState.hovered, true); assert.ok(gapState.height >= expandedStack.height - 1); await page.mouse.move(0, 0); const actionScope = await page.evaluate(() => { const scope = document.createElement('zen-notifications'); scope.id = 'action-scope'; const source = document.createElement('button'); scope.append(source); document.body.append(scope); window.__notificationAction = null; scope.addEventListener('zen-notification-action', event => { window.__notificationAction = event.detail; }); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'action', tone: 'error', message: 'Action required', announcement: 'assertive', persistent: true, action: { token: 'opaque-secret-token', label: 'Retry', }, }, })); return scope.id; }); assert.equal( await page.locator(`#${actionScope}`).evaluate( scope => scope.records, ), undefined, ); const actionArticle = page.locator( `#${actionScope} [data-zen-notification-id="action"]`, ); assert.doesNotMatch( await actionArticle.evaluate(article => article.outerHTML), /opaque-secret-token/, ); await actionArticle.locator('.zen-notification-action').focus(); await page.locator(`#${actionScope}`).evaluate(scope => { const source = scope.querySelector('button'); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'action-neighbor', tone: 'info', message: 'Neighbor', announcement: 'none', persistent: true, }, })); }); assert.equal( await actionArticle.locator('.zen-notification-action').evaluate( action => document.activeElement === action, ), true, ); await actionArticle.locator('.zen-notification-action').click(); assert.deepEqual( await page.evaluate(() => window.__notificationAction), { version: 1, id: 'action', token: 'opaque-secret-token', }, ); await page.locator(`#${actionScope}`).evaluate(scope => { const source = scope.querySelector('button'); source.dispatchEvent(new CustomEvent( 'zen-dismiss-notification', { bubbles: true, composed: false, detail: { version: 1, id: 'action' }, }, )); }); await actionArticle.waitFor({ state: 'detached' }); await page.evaluate(() => { const scope = document.createElement('zen-notifications'); scope.id = 'announcement-scope'; const source = document.createElement('button'); scope.append(source); document.body.append(scope); window.__announcements = []; const region = scope.querySelector('[data-zen-live="polite"]'); new MutationObserver(() => { if (region.textContent) { window.__announcements.push(region.textContent); } }).observe(region, { childList: true }); for (let index = 0; index < 3; index++) { source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: `announcement-${index}`, tone: 'info', message: `Announcement ${index}`, announcement: 'polite', persistent: true, }, })); } }); await page.waitForFunction(() => window.__announcements?.length === 3 ); assert.deepEqual( await page.evaluate(() => window.__announcements), ['Announcement 0', 'Announcement 1', 'Announcement 2'], ); const composedRejected = await page.evaluate(() => { const scope = document.createElement('zen-notifications'); const source = document.createElement('button'); scope.append(source); document.body.append(scope); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: true, detail: { version: 1, id: 'composed', tone: 'info', message: 'Must be rejected', announcement: 'none', persistent: true, }, })); return scope.size; }); assert.equal(composedRejected, 0); await page.evaluate(() => { const scope = document.createElement('zen-notifications'); scope.id = 'focus-timer-scope'; const source = document.createElement('button'); scope.append(source); document.body.append(scope); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'focus-timer', tone: 'warning', message: 'Focus timer', announcement: 'none', durationMs: 300, action: { token: 'focus-token', label: 'Keep focused' }, }, })); }); const focusTimer = page.locator( '#focus-timer-scope [data-zen-notification-id="focus-timer"]', ); await focusTimer.locator('.zen-notification-action').focus(); await page.locator('#focus-timer-scope').evaluate(scope => { const source = scope.querySelector(':scope > button'); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'focus-neighbor', tone: 'info', message: 'Focus neighbor', announcement: 'none', persistent: true, }, })); }); await page.waitForTimeout(500); assert.equal(await focusTimer.count(), 1); assert.equal( await focusTimer.locator('.zen-notification-action').evaluate( action => document.activeElement === action, ), true, ); await page.mouse.move(0, 0); await page.locator('#catalogMain').focus(); await page.waitForFunction(() => !document.querySelector( '#focus-timer-scope [data-zen-notification-id="focus-timer"]', ) ); await page.evaluate(() => { const scope = document.createElement('zen-notifications'); scope.id = 'timer-scope'; const source = document.createElement('button'); scope.append(source); document.body.append(scope); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'timer', tone: 'info', message: 'Paused timer', announcement: 'none', durationMs: 300, }, })); }); const timerArticle = page.locator('#timer-scope article'); await timerArticle.hover(); await page.waitForTimeout(500); assert.equal(await timerArticle.count(), 1); await page.mouse.move(0, 0); await page.waitForFunction(() => !document.querySelector('#timer-scope article') ); await page.evaluate(() => { const scope = document.createElement('zen-notifications'); scope.id = 'hidden-timer-scope'; const source = document.createElement('button'); scope.append(source); document.body.append(scope); source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'hidden-timer', tone: 'info', message: 'Hidden timer', announcement: 'none', durationMs: 300, }, })); Object.defineProperty(document, 'hidden', { configurable: true, value: true, }); document.dispatchEvent(new Event('visibilitychange')); }); await page.waitForTimeout(500); assert.equal( await page.locator('#hidden-timer-scope article').count(), 1, ); await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, value: false, }); document.dispatchEvent(new Event('visibilitychange')); }); await page.waitForFunction(() => !document.querySelector('#hidden-timer-scope article') ); const isolated = await page.evaluate(() => { const makeScope = id => { const scope = document.createElement('zen-notifications'); scope.id = id; const source = document.createElement('button'); scope.append(source); document.body.append(scope); return { scope, source }; }; const first = makeScope('scope-one'); const second = makeScope('scope-two'); first.source.dispatchEvent(new CustomEvent('zen-notify', { bubbles: true, composed: false, detail: { version: 1, id: 'isolated', tone: 'success', message: 'Only first scope', announcement: 'none', persistent: true, }, })); return [first.scope.size, second.scope.size]; }); assert.deepEqual(isolated, [1, 0]); const lightCanvas = await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zenbu-sys-color-surface-page') .trim() ); await page.locator('#themeToggle').click(); const darkCanvas = await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zenbu-sys-color-surface-page') .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('--zenbu-sys-color-surface-page') .trim() ); await darkPage.locator('#themeToggle').click(); const explicitLightCanvas = await darkPage.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zenbu-sys-color-surface-page') .trim() ); assert.notEqual(systemDarkCanvas, explicitLightCanvas); await darkContext.close(); const mobileContext = await browser.newContext({ colorScheme: 'light', viewport: { width: 390, height: 844 }, }); const mobilePage = await mobileContext.newPage(); const mobileErrors = []; mobilePage.on('pageerror', error => { mobileErrors.push(error.stack || error.message); }); mobilePage.on('console', message => { if (message.type() === 'error') mobileErrors.push(message.text()); }); await mobilePage.goto(baseUrl, { waitUntil: 'networkidle' }); await mobilePage.waitForFunction(() => customElements.get('zen-date-picker') && document.querySelectorAll( '.catalog-nav[aria-label="Components"] a', ).length === 70 ); const mobileFailures = await mobilePage.evaluate(() => { const routes = [ '/', '/tokens', '/icons', ...[...document.querySelectorAll( '.catalog-nav[aria-label="Components"] a', )].map(link => new URL(link.href).pathname), ]; const failures = []; for (const route of routes) { history.replaceState({}, '', route); window.dispatchEvent(new PopStateEvent('popstate')); const page = document.querySelector( '[data-catalog-page]:not([hidden])', ); if (!page || document.documentElement.scrollWidth > innerWidth + 1 || page.getBoundingClientRect().right > innerWidth + 1) { failures.push(route); } } return failures; }); assert.deepEqual(mobileFailures, []); await mobilePage.goto(`${baseUrl}/components/date-picker`, { waitUntil: 'networkidle', }); await mobilePage.locator( 'zen-date-picker [data-zen-date-trigger]', ).click(); const mobileCalendarBounds = await mobilePage.locator( 'zen-date-picker [data-zen-calendar-panel]', ).evaluate(panel => { const bounds = panel.getBoundingClientRect(); return { bottom: bounds.bottom, left: bounds.left, right: bounds.right, top: bounds.top, }; }); assert.ok(mobileCalendarBounds.left >= 0); assert.ok(mobileCalendarBounds.right <= 390); assert.ok(mobileCalendarBounds.top >= 0); assert.ok(mobileCalendarBounds.bottom <= 844); assert.deepEqual(mobileErrors, []); await mobileContext.close(); } finally { if (browser) await browser.close(); await stopProcess(server); } })().catch(error => { console.error(error.stack || error); process.exitCode = 1; });