mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
176 lines
7.8 KiB
JavaScript
176 lines
7.8 KiB
JavaScript
// Attach to a running packaged OpenSwarm over CDP and measure what the renderer actually painted.
|
|
//
|
|
// This exists because every other check in the packaged smoke is about the installer. Signature,
|
|
// install path, bundle contents, backend port: all can pass while the window shows a blank route
|
|
// or text nobody can read. Those are the failures users report and the smoke could not see.
|
|
//
|
|
// Node 22's global WebSocket, so the runner installs nothing.
|
|
//
|
|
// Exit 0 = every check passed. Exit 1 = at least one failed, with the numbers printed.
|
|
|
|
const HOST = process.env.SMOKE_CDP || '127.0.0.1:9222';
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function pickTarget() {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const targets = await (await fetch(`http://${HOST}/json`)).json();
|
|
const page = targets.find((t) => t.type === 'page' && !t.url.startsWith('devtools://'));
|
|
if (page && page.webSocketDebuggerUrl) return page;
|
|
await wait(3000);
|
|
}
|
|
throw new Error('no page target exposed by the app');
|
|
}
|
|
|
|
function connect(url) {
|
|
const ws = new WebSocket(url);
|
|
let id = 0;
|
|
const pending = new Map();
|
|
ws.addEventListener('message', (e) => {
|
|
const msg = JSON.parse(e.data);
|
|
const resolve = pending.get(msg.id);
|
|
if (resolve) { pending.delete(msg.id); resolve(msg); }
|
|
});
|
|
const ready = new Promise((r) => ws.addEventListener('open', r));
|
|
const send = (method, params) => new Promise((resolve) => {
|
|
id += 1;
|
|
pending.set(id, resolve);
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
return { ws, ready, send };
|
|
}
|
|
|
|
// One expression, evaluated in the page: everything we want to know about the painted result.
|
|
const PROBE = `(() => {
|
|
const lum = (c) => {
|
|
const [r, g, b] = c.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
const parse = (s) => {
|
|
const m = (s || '').match(/rgba?\\(([^)]+)\\)/);
|
|
if (!m) return null;
|
|
const a = m[1].split(',').map(Number);
|
|
return { rgb: [a[0], a[1], a[2]], a: a.length > 3 ? a[3] : 1 };
|
|
};
|
|
const bgOf = (el) => {
|
|
let n = el;
|
|
while (n && n !== document.documentElement) {
|
|
const c = parse(getComputedStyle(n).backgroundColor);
|
|
if (c && c.a > 0.5) return c.rgb;
|
|
n = n.parentElement;
|
|
}
|
|
const b = parse(getComputedStyle(document.body).backgroundColor);
|
|
return b ? b.rgb : [255, 255, 255];
|
|
};
|
|
const fields = [];
|
|
document.querySelectorAll('input:not([type=checkbox]):not([type=radio]),textarea').forEach((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 8 || r.height < 6) return;
|
|
const cs = getComputedStyle(el);
|
|
// MUI parks an invisible native <input> behind every Select for form submission and a11y. It has
|
|
// a real box and white-on-white text, so a naive contrast sweep fails a perfectly good build on
|
|
// something no human can see. Judge only what is actually painted.
|
|
if (cs.opacity === '0' || cs.visibility === 'hidden' || cs.display === 'none') return;
|
|
if (el.getAttribute('aria-hidden') === 'true' || cs.pointerEvents === 'none') return;
|
|
const fg = parse(cs.color);
|
|
if (!fg) return;
|
|
const bg = bgOf(el);
|
|
const l1 = lum(fg.rgb);
|
|
const l2 = lum(bg);
|
|
fields.push({
|
|
name: (el.placeholder || el.getAttribute('aria-label') || el.name || el.type || '?').slice(0, 30),
|
|
ratio: Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100,
|
|
});
|
|
});
|
|
return JSON.stringify({
|
|
route: location.hash.slice(0, 60),
|
|
// A blank render is the ENG-207 boot wedge: chrome-less, on an empty route, one reload heals it.
|
|
painted: document.body ? document.body.innerText.trim().length : 0,
|
|
nodes: document.querySelectorAll('*').length,
|
|
fields,
|
|
});
|
|
})()`;
|
|
|
|
const main = async () => {
|
|
const target = await pickTarget();
|
|
const { ws, ready, send } = connect(target.webSocketDebuggerUrl);
|
|
await ready;
|
|
|
|
const evaluate = async (expression) => {
|
|
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
|
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description || 'eval threw');
|
|
return r.result?.result?.value;
|
|
};
|
|
|
|
// Open Settings first. Without it the dashboard has no form fields, the contrast check runs over
|
|
// an empty list, and the step reports PASS having measured nothing. A check that cannot fail is
|
|
// worse than no check, so the field count is asserted below.
|
|
const key = async (k, code, modifiers) => {
|
|
for (const type of ['keyDown', 'keyUp']) {
|
|
await send('Input.dispatchKeyEvent', { type, key: k, code, modifiers, windowsVirtualKeyCode: 75 });
|
|
}
|
|
};
|
|
const fieldCount = async () => JSON.parse(await evaluate(PROBE)).fields.length;
|
|
await evaluate('window.focus()');
|
|
for (const mod of [2, 4]) { // Ctrl on Windows, Meta on macOS
|
|
await key('k', 'KeyK', mod);
|
|
await wait(1000);
|
|
await send('Input.insertText', { text: 'settings' });
|
|
await wait(1000);
|
|
for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Enter', code: 'Enter', modifiers: 0 });
|
|
await wait(3000);
|
|
// Settings remembers its last tab, and several tabs have no inputs at all, so opening it is
|
|
// not enough: land on General, which owns the system prompt, working directory and max turns.
|
|
await evaluate(`(() => {
|
|
const hit = [...document.querySelectorAll('button,[role=button],[role=tab],li')]
|
|
.filter((e) => (e.textContent || '').trim() === 'General' && e.getBoundingClientRect().width > 0);
|
|
if (hit.length) hit[0].click();
|
|
return hit.length;
|
|
})()`);
|
|
await wait(2000);
|
|
// Whether the palette opened is not something to detect with a guessed selector; the only
|
|
// question that matters is whether form fields are now on screen, so just look.
|
|
if (await fieldCount() > 0) break;
|
|
}
|
|
|
|
const data = JSON.parse(await evaluate(PROBE));
|
|
const failures = [];
|
|
|
|
console.log(` route ${data.route || '(none)'}`);
|
|
console.log(` DOM nodes ${data.nodes}`);
|
|
console.log(` visible text ${data.painted} chars`);
|
|
console.log(` fields measured ${data.fields.length}`);
|
|
|
|
// A booted app paints a real tree. The boot wedge lands at a handful of nodes and no text.
|
|
if (data.nodes < 120) failures.push(`only ${data.nodes} DOM nodes: renderer never painted a real tree`);
|
|
if (data.painted < 20) failures.push(`only ${data.painted} chars of visible text: blank render`);
|
|
|
|
// ENG-281 on Windows. Black-on-black scores 1.0; WCAG AA wants 4.5.
|
|
// The count assertion is the point: zero fields means the contrast loop measured nothing and
|
|
// "no field failed" was true only because no field existed.
|
|
const MIN_FIELDS = 3;
|
|
if (data.fields.length < MIN_FIELDS) {
|
|
failures.push(`only ${data.fields.length} field(s) rendered, need >= ${MIN_FIELDS}: the contrast check would pass vacuously`);
|
|
}
|
|
const unreadable = data.fields.filter((f) => f.ratio < 4.5);
|
|
for (const f of data.fields) console.log(` ${String(f.ratio).padStart(7)}:1 ${f.name}`);
|
|
if (unreadable.length) {
|
|
failures.push(`${unreadable.length} field(s) below AA contrast: ${unreadable.map((f) => `${f.name}=${f.ratio}:1`).join(', ')}`);
|
|
}
|
|
|
|
// An exception during boot is not visible in any other step of this smoke.
|
|
const errors = await evaluate(`JSON.stringify((window.__smokeErrors || []).slice(0, 5))`);
|
|
const parsedErrors = JSON.parse(errors || '[]');
|
|
if (parsedErrors.length) failures.push(`${parsedErrors.length} renderer error(s): ${parsedErrors.join(' | ')}`);
|
|
|
|
ws.close();
|
|
|
|
if (failures.length) {
|
|
console.log('\nFAIL');
|
|
for (const f of failures) console.log(` - ${f}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('\nPASS renderer painted a usable window and every field is readable');
|
|
};
|
|
|
|
main().catch((e) => { console.log(`FAIL ${e.message}`); process.exit(1); });
|