MiniCPM5-2B-WebGPU-Pi-HTTP / app /tests /cat-interactions.mjs
Mike0021's picture
Enable browser HTTP commands with CORS-aware errors and bounded requests
39371ea verified
Raw History Blame Contribute Delete
8.56 kB
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));