Mercurial
view design_system/test/storybook_test.js @ 253:fdf3816959cb
[ui] Add Sonner-like notification stack
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 04 Aug 2026 12:21:19 -0700 |
| parents | 7a7581f040e8 |
| children | 2b6e732087ff |
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', '/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/components.css', '/components/index.js', '/storybook.js', ]) { const response = await fetch(`${baseUrl}${asset}`); assert.equal(response.status, 200, asset); assert.ok((await response.text()).length > 100, asset); } browser = await chromium.launch({ executablePath: chromiumPath, headless: true, args: ['--no-sandbox'], }); const context = await browser.newContext({ colorScheme: 'light' }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('response', response => { if (response.status() >= 400) { errors.push(`${response.status()} ${response.url()}`); } }); await page.goto(`${baseUrl}/components/button`, { waitUntil: 'networkidle', }); await page.waitForFunction(() => customElements.get('zen-button') && customElements.get('zen-story') ); assert.equal( await page.locator('[data-catalog-page="button"]').isVisible(), true, ); assert.equal( await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'), 'page', ); assert.match( await page.locator('zen-story pre').first().textContent(), /<zen-button>/, ); assert.equal( await page.locator('zen-button[loading]').getAttribute('inert'), null, ); assert.equal( await page.locator('zen-button[loading] button').getAttribute('aria-disabled'), 'true', ); assert.equal( await page.locator('zen-button[loading] button').getAttribute('aria-busy'), 'true', ); await page.evaluate(() => { window.__loadingClicks = 0; document.querySelector('zen-button[loading] button') .addEventListener('click', () => window.__loadingClicks++); }); await page.locator('zen-button[loading] button').evaluate( button => button.click(), ); assert.equal(await page.evaluate(() => window.__loadingClicks), 0); await page.locator('zen-button[loading] button').focus(); assert.equal( await page.locator('zen-button[loading] button').evaluate( button => document.activeElement === button, ), true, ); await page.keyboard.press('Tab'); assert.equal( await page.locator('zen-button[loading] button').evaluate( button => document.activeElement === button, ), false, ); const nativeDisabledPreserved = await page.evaluate(async () => { const wrapper = document.createElement('zen-button'); const button = document.createElement('button'); button.disabled = true; button.textContent = 'Native disabled'; wrapper.append(button); document.body.append(wrapper); await customElements.whenDefined('zen-button'); wrapper.setAttribute('disabled', ''); wrapper.removeAttribute('disabled'); return button.disabled; }); assert.equal(nativeDisabledPreserved, true); const replacementButtonState = await page.evaluate(async () => { const wrapper = document.createElement('zen-button'); wrapper.setAttribute('disabled', ''); const first = document.createElement('button'); first.textContent = 'First'; wrapper.append(first); document.body.append(wrapper); await new Promise(resolve => setTimeout(resolve)); const second = document.createElement('button'); second.textContent = 'Second'; wrapper.replaceChildren(second); await new Promise(resolve => setTimeout(resolve)); return { firstDisabled: first.disabled, secondAriaDisabled: second.getAttribute('aria-disabled'), wrapperInert: wrapper.hasAttribute('inert'), }; }); assert.equal(replacementButtonState.firstDisabled, false); assert.equal(replacementButtonState.secondAriaDisabled, 'true'); assert.equal(replacementButtonState.wrapperInert, false); const modifiedClickAllowed = await page.evaluate(() => { const link = document.querySelector( '.catalog-sidebar a[href="/components/card"]', ); return link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ctrlKey: true, })); }); assert.equal(modifiedClickAllowed, true); await page.goto(`${baseUrl}/components/field`, { waitUntil: 'networkidle', }); const fieldWiring = await page.locator('zen-field').first().evaluate(field => { const label = field.querySelector('label'); const input = field.querySelector('input'); const help = field.querySelector('small'); input.checkValidity(); return { describedBy: input.getAttribute('aria-describedby'), helpId: help.id, inputId: input.id, invalid: field.hasAttribute('data-invalid'), labelFor: label.htmlFor, }; }); assert.ok(fieldWiring.inputId); assert.equal(fieldWiring.labelFor, fieldWiring.inputId); assert.equal(fieldWiring.describedBy, fieldWiring.helpId); assert.equal(fieldWiring.invalid, true); const replacementField = await page.locator('zen-field').first().evaluate( async field => { const label = field.querySelector('label'); const oldInput = field.querySelector('input'); const blocker = document.createElement('div'); blocker.id = 'zen-field-3'; document.body.append(blocker); const nextInput = document.createElement('input'); nextInput.required = true; oldInput.replaceWith(nextInput); await new Promise(resolve => setTimeout(resolve)); oldInput.dispatchEvent(new Event('invalid')); const help = field.querySelector('small'); nextInput.id = 'replacement-email'; help.id = 'replacement-help'; await new Promise(resolve => setTimeout(resolve)); return { describedBy: nextInput.getAttribute('aria-describedby'), helpId: help.id, labelFor: label.htmlFor, nextId: nextInput.id, blockerId: blocker.id, }; }, ); assert.ok(replacementField.nextId); assert.notEqual(replacementField.nextId, replacementField.blockerId); assert.equal(replacementField.labelFor, replacementField.nextId); assert.equal(replacementField.describedBy, replacementField.helpId); await page.goto(`${baseUrl}/components/alert`, { waitUntil: 'networkidle', }); await page.evaluate(() => { window.__dismissed = 0; document.addEventListener('zen-dismiss', () => { window.__dismissed++; }); }); const dismissible = page.locator('zen-alert[dismissible]'); assert.equal(await dismissible.getAttribute('role'), 'alert'); await dismissible.locator('[data-zen-dismiss]').click(); assert.equal(await dismissible.count(), 0); assert.equal(await page.evaluate(() => window.__dismissed), 1); const authoredDismissState = await page.evaluate(async () => { const alert = document.createElement('zen-alert'); alert.setAttribute('dismissible', ''); const message = document.createElement('p'); message.textContent = 'Authored dismiss control'; const dismiss = document.createElement('button'); dismiss.dataset.zenDismiss = ''; alert.append(message, dismiss); document.body.append(alert); await new Promise(resolve => setTimeout(resolve)); let events = 0; alert.addEventListener('zen-dismiss', () => events++); alert.removeAttribute('dismissible'); dismiss.click(); return { connected: alert.isConnected, events, }; }); assert.equal(authoredDismissState.connected, true); assert.equal(authoredDismissState.events, 0); 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('--zen-color-canvas') .trim() ); await page.locator('#themeToggle').click(); const darkCanvas = await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zen-color-canvas') .trim() ); assert.notEqual(lightCanvas, darkCanvas); assert.equal( await page.evaluate(() => localStorage.getItem('zen-theme')), 'dark', ); assert.deepEqual(errors, []); await context.close(); const darkContext = await browser.newContext({ colorScheme: 'dark' }); const darkPage = await darkContext.newPage(); await darkPage.goto(baseUrl, { waitUntil: 'networkidle' }); assert.equal( await darkPage.locator('#themeToggle').getAttribute('aria-pressed'), 'true', ); const systemDarkCanvas = await darkPage.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zen-color-canvas') .trim() ); await darkPage.locator('#themeToggle').click(); const explicitLightCanvas = await darkPage.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--zen-color-canvas') .trim() ); assert.notEqual(systemDarkCanvas, explicitLightCanvas); await darkContext.close(); } finally { if (browser) await browser.close(); await stopProcess(server); } })().catch(error => { console.error(error.stack || error); process.exitCode = 1; });