File size: 9,800 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 | 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 }));
|