import { chromium, webkit } from 'playwright'; import fs from 'node:fs/promises'; import assert from 'node:assert/strict'; const url = process.argv[2] ?? 'http://127.0.0.1:4173/'; const label = process.argv[3] ?? 'agent-modal-cat'; const result = { url, checks: [], layouts: [], errors: [] }; const webkitRoot = process.env.WEBKIT_MINIBROWSER_ROOT; for (const engine of ['chromium', ...(process.env.TEST_WEBKIT ? ['webkit'] : [])]) { const browser = engine === 'chromium' ? await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true, args: ['--disable-gpu', '--disable-software-rasterizer'] }) : await webkit.launch({ headless: true, ...(webkitRoot ? { executablePath: webkitRoot + '/bin/MiniBrowser', env: { ...process.env, WEBKIT_EXEC_PATH: webkitRoot + '/bin', WEBKIT_INJECTED_BUNDLE_PATH: webkitRoot + '/lib', WEBKIT_INSPECTOR_RESOURCES_PATH: webkitRoot + '/share', LD_LIBRARY_PATH: webkitRoot + '/lib:' + webkitRoot + '/sys/lib:' + (process.env.WEBKIT_EXTRA_LIBS ?? '') } } : {}) }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const requests = []; const catRequests = []; page.on('request', r => { requests.push(r.url()); if (r.url().includes('modal-cat') && !r.redirectedFrom()) catRequests.push(r.url()); }); page.on('pageerror', e => result.errors.push(e.message)); let releaseImage; try { const imageGate = new Promise(resolve => { releaseImage = resolve; }); await page.route('**/*modal-cat*.png', async route => { await imageGate; await route.continue(); }); await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.browserPi?.terminal); await page.evaluate(() => window.browserPi.ready); assert(requests.some(u => u.includes('modal-cat')), 'The sprite must start loading before any interaction'); assert(await page.locator('link[rel="preload"][as="image"]').evaluate(link => link.href === document.querySelector('.setup-cat img').src)); const prompt = "What's our total sales revenue?"; await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.type(prompt); await page.locator('#send').click(); await page.locator('#model-dialog[open]').waitFor(); assert.equal(await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).opacity), '0'); assert(await page.locator('#model-confirm').evaluate(el => document.activeElement === el)); await page.locator('#model-cancel').focus(); await page.keyboard.press('Enter'); assert.equal(await page.evaluate(() => window.browserPi.terminal.text), prompt); releaseImage(); await page.waitForFunction(() => document.querySelector('.setup-cat').classList.contains('is-ready')); assert(await page.locator('#model-dialog').isHidden()); assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0); await page.evaluate(() => { const dialog = document.querySelector('#model-dialog'); const observer = new MutationObserver(() => { if (!dialog.open) return; const img = dialog.querySelector('.setup-cat img'); window.catAtModalOpen = img.complete && img.naturalWidth > 0 && getComputedStyle(img).opacity === '1'; observer.disconnect(); }); observer.observe(dialog, { attributes: true, attributeFilter: ['open'] }); }); await page.locator('#send').click(); await page.locator('#model-dialog[open]').waitFor(); assert.equal(await page.evaluate(() => window.catAtModalOpen), true, 'The decoded cat must be visible at modal opening'); assert.equal(await page.locator('#model-cancel').getAttribute('aria-label'), 'Close model setup'); assert.equal(await page.locator('#model-cancel svg').count(), 1); await page.keyboard.press('Shift+Tab'); assert(await page.locator('.setup-credits a:last-child').evaluate(el => document.activeElement === el)); await page.keyboard.press('Tab'); assert(await page.locator('#model-confirm').evaluate(el => document.activeElement === el)); assert.equal(catRequests.length, 1, 'Preload and image must reuse the same request'); assert.equal(await page.locator('.setup-cat img').getAttribute('alt'), ''); assert.equal(await page.locator('.setup-cat').getAttribute('aria-hidden'), 'true'); // Spaces may redirect assets to a CDN, which taints a DOM image's canvas. // Inspect the same response bytes without changing the displayed sprite. const sprite = await page.request.get(await page.locator('.setup-cat img').evaluate(img => img.currentSrc)); assert(sprite.ok()); const alpha = await page.evaluate(async source => { const img = new Image(); img.src = source; await img.decode(); const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); return [[0,0],[1253,0],[0,1253],[1253,1253]].map(([x,y]) => ctx.getImageData(x,y,1,1).data[3]); }, 'data:image/png;base64,' + (await sprite.body()).toString('base64')); assert.deepEqual(alpha, [0,0,0,0]); result.checks.push(engine + ': sprite preloads once at page startup and decodes while closed; visible at modal opening; slow image does not block Cancel; transparent PNG adds no focus stop'); const tailFrames = await page.locator('.setup-cat-tail').evaluate(el => { const animation = el.getAnimations()[0]; animation.pause(); return [0, 2970, 3690, 4500].map(time => { animation.currentTime = time; return { transform: getComputedStyle(el).transform, body: getComputedStyle(el.nextElementSibling).transform }; }); }); assert(tailFrames.every(frame => frame.body === 'none')); assert.notEqual(tailFrames[0].transform, tailFrames[1].transform); assert.notEqual(tailFrames[1].transform, tailFrames[2].transform); assert.equal(tailFrames[0].transform, tailFrames[3].transform); await page.emulateMedia({ reducedMotion: 'reduce' }); assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0); await page.emulateMedia({ reducedMotion: 'no-preference' }); result.checks.push(engine + ': only the tail swishes and settles; animation is absent while closed or with reduced motion'); for (const [width, height] of [[1440,1000],[1024,768],[850,640],[390,844],[375,667],[320,568],[390,420],[390,320]]) { await page.setViewportSize({ width, height }); const layout = await page.evaluate(() => { const rect = s => document.querySelector(s).getBoundingClientRect().toJSON(); return { dialog: rect('#model-dialog'), cat: rect('.setup-cat'), title: rect('#model-title'), actions: rect('.setup-actions'), confirm: rect('#model-confirm'), close: rect('#model-cancel'), body: rect('.setup-body'), facts: rect('.setup-facts'), overflow: document.documentElement.scrollWidth > innerWidth, blend: getComputedStyle(document.querySelector('.setup-cat img')).mixBlendMode }; }); assert(!layout.overflow); assert(layout.cat.top >= 8 && layout.cat.right <= width - 8, JSON.stringify(layout)); assert(layout.dialog.bottom <= height - 8 && layout.dialog.left >= 8, JSON.stringify(layout)); assert(layout.actions.bottom <= height - 8 && layout.actions.top >= layout.dialog.top, JSON.stringify(layout)); assert(layout.cat.bottom <= layout.title.top + 1, JSON.stringify(layout)); assert(Math.abs(layout.confirm.width - layout.body.width) < 1, 'Primary action should fill the modal content width'); assert(layout.close.right <= layout.dialog.right && layout.close.bottom <= layout.body.top && layout.close.left >= layout.title.right, JSON.stringify(layout)); if (height >= 568) assert(layout.facts.bottom <= layout.body.bottom + 1, 'The full download note should fit: ' + JSON.stringify(layout)); assert.equal(layout.blend, 'normal'); await page.screenshot({ path: `../validation/${label}-${engine}-${width}x${height}.png` }); result.layouts.push({ engine, width, height, ...layout }); } await page.setViewportSize({ width: 390, height: 420 }); await page.locator('#model-confirm').click(); await page.locator('#model-error').waitFor({ state: 'visible' }); assert.match(await page.locator('#model-error').textContent(), /WebGPU/); assert.equal(await page.locator('#model-confirm').textContent(), 'Try again'); assert.equal(await page.locator('#model-cancel svg').count(), 1); assert(await page.locator('#model-cancel').isVisible()); assert(await page.locator('.setup-cat.is-ready').isVisible()); await page.screenshot({ path: `../validation/${label}-${engine}-error.png` }); await page.locator('#model-cancel').click(); assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0); assert.equal(await page.evaluate(() => window.browserPi.terminal.text), prompt); assert(!requests.some(u => /\/resolve\/|\/models\//.test(u))); result.checks.push(engine + ': eight viewport sizes keep the cat and actions in bounds; real GPU failure keeps retry and cancel usable'); } catch (e) { result.failure = e.stack; process.exitCode = 1; await page.screenshot({ path: `../validation/${label}-${engine}-failure.png` }).catch(() => {}); } finally { releaseImage?.(); await browser.close(); await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result, null, 2)); } } result.passed = !result.failure && !result.errors.length; await fs.writeFile(`../validation/${label}.json`, JSON.stringify(result, null, 2)); console.log(JSON.stringify({ passed: result.passed, checks: result.checks, failure: result.failure }));