File size: 8,559 Bytes
39371ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | 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));
|