import { chromium, webkit } from 'playwright'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; const url = process.argv[2] ?? 'http://127.0.0.1:4173/'; const result = { url, checks: [], errors: [] }; const webkitRoot = process.env.WEBKIT_MINIBROWSER_ROOT; const cases = [ { name: 'chromium-desktop', engine: chromium, mobile: false }, { name: 'chromium-touch', engine: chromium, mobile: true }, ...(process.env.TEST_WEBKIT ? [{ name: 'webkit-touch', engine: webkit, mobile: true }] : []), ]; for (const { name, engine, mobile } of cases.filter(item => !process.env.CAT_TEST_CASE || item.name === process.env.CAT_TEST_CASE)) { console.log(`Checking ${name}`); const browser = await engine.launch({ headless: true, ...(engine === chromium ? { executablePath: '/usr/bin/chromium' } : 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: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, isMobile: mobile, hasTouch: mobile }); page.setDefaultTimeout(20000); page.on('pageerror', error => result.errors.push(`${name}: ${error.message}`)); // Observe real HTMLAudioElement playback; do not mock play() or decoding. await page.addInitScript(() => { const NativeAudio = window.Audio; window.catAudioInstances = []; window.Audio = function (...args) { const audio = new NativeAudio(...args); if (String(args[0]).includes('cat-meow')) window.catAudioInstances.push(audio); return audio; }; window.Audio.prototype = NativeAudio.prototype; }); const activate = selector => mobile ? page.locator(selector).tap() : page.locator(selector).click(); const heard = () => page.waitForFunction(() => { const audio = window.catAudioInstances[0]; return !audio.paused && audio.currentTime > .025 && !audio.muted && audio.volume > 0; }); const ended = () => page.waitForFunction(() => window.catAudioInstances[0].ended); async function unfocusable(selector) { const states = await page.locator(selector).evaluate(figure => { const focused = document.activeElement; const nodes = [figure, ...figure.querySelectorAll('*')]; const states = nodes.map(node => { node.focus(); const style = getComputedStyle(node); return { keepsFocus: document.activeElement === focused, tabIndex: node.tabIndex, selection: style.userSelect, drag: style.webkitUserDrag, outline: style.outlineStyle }; }); for (const type of ['pointerdown', 'mousedown']) { const event = type === 'pointerdown' ? new PointerEvent(type, { bubbles: true, cancelable: true, isPrimary: true }) : new MouseEvent(type, { bubbles: true, cancelable: true }); figure.querySelector('img').dispatchEvent(event); states.push({ type, prevented: event.defaultPrevented }); } return states; }); for (const state of states) { if (state.type) assert(state.prevented, `${selector}: ${state.type} must preserve focus`); else { assert(state.keepsFocus && state.tabIndex < 0, `${selector}: cat layer took focus`); assert.equal(state.selection, 'none'); assert.equal(state.drag, 'none'); assert.equal(state.outline, 'none'); } } } async function tabPastCat(start) { await page.locator(start).focus(); for (let i = 0; i < 14; i++) { await page.keyboard.press('Tab'); assert(!await page.evaluate(() => document.activeElement.closest('.mascot-cat, .setup-cat'))); } await page.locator(start).focus(); } try { console.log(`${name}: opening app`); await page.goto(url); await page.waitForFunction(() => window.browserPi?.terminal); await page.evaluate(() => window.browserPi.ready); await page.locator('.mascot-cat').waitFor({ state: 'visible' }); console.log(`${name}: preloading audio`); await page.waitForFunction(() => window.catAudioInstances[0]?.readyState >= 2); await page.waitForTimeout(2200); // Includes the automatic greeting hop. assert.deepEqual(await page.evaluate(() => window.catAudioInstances.map(a => ({ paused: a.paused, time: a.currentTime }))), [{ paused: true, time: 0 }]); await page.locator('.xterm-helper-textarea').focus(); await unfocusable('.mascot-cat'); await tabPastCat('.xterm-helper-textarea'); console.log(`${name}: playing empty-state meow`); await activate('.mascot-cat'); await heard(); assert(await page.locator('.xterm-helper-textarea').evaluate(el => el === document.activeElement)); await ended(); await page.emulateMedia({ reducedMotion: 'reduce' }); await activate('.mascot-cat'); await heard(); assert.equal(await page.locator('.mascot-cat').evaluate(el => el.getAnimations().length), 0); await ended(); await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.locator('.xterm-helper-textarea').focus(); await page.keyboard.type('Hello cat'); await page.locator('#send').click(); await page.locator('#model-dialog[open]').waitFor(); await page.locator('.setup-cat.is-ready').waitFor(); await unfocusable('.setup-cat'); await tabPastCat('#model-confirm'); console.log(`${name}: playing modal meow and swish`); await page.locator('.setup-cat').evaluate(el => el.focus()); assert(await page.locator('#model-confirm').evaluate(el => el === document.activeElement)); const bodyPose = await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).transform); await activate('.setup-cat'); await heard(); assert(await page.locator('#model-confirm').evaluate(el => el === document.activeElement)); assert(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().some(a => a.id === 'cat-tail-reaction'))); const pose = await page.locator('.setup-cat-tail').evaluate(el => getComputedStyle(el).transform); await page.waitForTimeout(250); assert.notEqual(await page.locator('.setup-cat-tail').evaluate(el => getComputedStyle(el).transform), pose); assert.equal(await page.locator('.setup-cat img').evaluate(el => getComputedStyle(el).transform), bodyPose); await activate('.setup-cat'); await activate('.setup-cat'); await heard(); assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().filter(a => a.id === 'cat-tail-reaction').length), 1); assert.equal(await page.evaluate(() => window.catAudioInstances.length), 1); await ended(); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.waitForFunction(() => document.querySelector('.setup-cat-tail').getAnimations().length === 0); await activate('.setup-cat'); await heard(); await ended(); assert.equal(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().length), 0); await page.emulateMedia({ reducedMotion: 'no-preference' }); await activate('.setup-cat'); await heard(); 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), 'Hello cat'); await ended(); // A blocked playback promise is swallowed; the UI reaction still works. await page.evaluate(() => { window.catAudioInstances[0].play = () => Promise.reject(new DOMException('Blocked', 'NotAllowedError')); }); await page.locator('#send').click(); await activate('.setup-cat'); assert(await page.locator('.setup-cat-tail').evaluate(el => el.getAnimations().some(a => a.id === 'cat-tail-reaction'))); await page.screenshot({ path: `../validation/cat-interactions-${name}.png` }); result.checks.push(`${name}: both cats and their layers reject focus, Tab skips cats, pointer/mouse preserve focus, no selection/drag/outline; real meow playback, repeated taps, silent idle, one shared clip, tail-only motion, reduced motion, close cleanup, blocked-audio fallback`); } finally { await browser.close(); } } assert.deepEqual(result.errors, []); result.passed = true; await fs.writeFile('../validation/cat-interactions.json', JSON.stringify(result, null, 2) + '\n'); console.log(JSON.stringify(result));