Mike0021's picture
Enable browser HTTP commands with CORS-aware errors and bounded requests
39371ea verified
Raw History Blame Contribute Delete
9.8 kB
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 }));