Files
stack/packages/webui/tests/browser.mjs
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00

117 lines
6.6 KiB
JavaScript

// Small CDP client for the installed Chromium. No application dependencies.
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// P19 #865: teardown is bounded. A browser that does not answer Browser.close is killed, and a
// call to a browser that has exited fails at once instead of waiting for its CDP timeout. A test
// process that ends with a browser still open kills it, so no chromium outlives its test file.
const live = new Set();
// Chromium runs in its own process group: killing only the main process leaves its renderer and
// GPU children writing into the profile, so every kill takes the whole group. The group is
// killed again the moment the main process exits (its children serve nothing after that), and
// never later: once the group is gone its id can be reused, so nothing signals it afterwards.
const killGroup = child => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} };
process.once('exit', () => { for (const child of live) killGroup(child); });
// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
// so by default every page this browser opens starts with Show machine sessions on, as if the
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
export async function browser({ machine = true, args = [] } = {}) {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
live.add(child);
let id = 0, buffer = '', exited = false;
const pending = new Map(), events = new Map();
const gone = new Promise(resolve => child.once('exit', () => {
exited = true; killGroup(child); live.delete(child);
for (const [key, { reject, timeout, method }] of pending) { clearTimeout(timeout); pending.delete(key); reject(new Error(`CDP ${method}: browser exited`)); }
resolve();
}));
// The pipes break when chromium dies; the exit handler above reports that to every caller.
child.stdio[3].on('error', () => {});
child.stdio[4].on('error', () => {});
// A browser whose close() never ran (an earlier after hook failed) must not keep the test file
// alive: the child and its pipes do not hold the event loop, and the exit hook above kills it.
// Every wait on the browser has its own timer, so a live call still keeps the loop running.
child.unref(); child.stdio[3].unref(); child.stdio[4].unref();
child.stdio[4].on('data', chunk => {
buffer += chunk.toString();
let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const message = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
if (message.method) for (const fn of events.get(message.method) ?? []) fn(message.params, message.sessionId);
if (pending.has(message.id)) {
const { resolve, reject, timeout } = pending.get(message.id);
clearTimeout(timeout);
pending.delete(message.id);
message.error ? reject(new Error(JSON.stringify(message.error))) : resolve(message.result);
}
}
});
function send(method, params = {}, sessionId, ms = 25000) {
return new Promise((resolve, reject) => {
if (exited) return reject(new Error(`CDP ${method}: browser exited`));
const key = ++id;
const timeout = setTimeout(() => { pending.delete(key); reject(new Error(`CDP timeout: ${method}`)); }, ms);
pending.set(key, { resolve, reject, timeout, method });
child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? {sessionId} : {}) }) + '\0');
});
}
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const call = (method, params) => send(method, params, sessionId);
await call('Page.enable');
await call('Runtime.enable');
if (machine) await call('Page.addScriptToEvaluateOnNewDocument', { source: `try { localStorage.setItem('mosaic-console-board', '{"machine":true}'); } catch {}` });
const evaluate = async expression => {
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
return r.result.value;
};
return {
call, evaluate, pid: child.pid,
on(method, fn) { if (!events.has(method)) events.set(method, new Set()); events.get(method).add(fn); return () => events.get(method).delete(fn); },
async viewport(width, height = 1000) {
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
},
async navigate(url) {
const result = await call('Page.navigate', { url });
if (result.errorText) throw new Error(result.errorText);
// Bounded page-load check, not an agent wake/watch loop.
for (let n = 0; n < 50; n++) {
if (await evaluate('document.readyState === "complete"')) break;
await new Promise(r => setTimeout(r, 100));
}
await evaluate('document.fonts.ready.then(() => true)');
return result;
},
async screenshot(path) {
const { data } = await call('Page.captureScreenshot', { format: 'png' });
await writeFile(path, Buffer.from(data, 'base64'));
},
async key(key, code = key, modifiers = 0) {
const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowUp: 38, ArrowRight: 39, ' ': 32, k: 75 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: modifiers ? '' : key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
},
async close() {
if (!exited) {
await send('Browser.close', {}, undefined, 5000).catch(() => {});
let timer;
const late = new Promise(resolve => { timer = setTimeout(resolve, 5000, 'timeout'); });
const outcome = await Promise.race([gone, late]);
clearTimeout(timer);
if (outcome === 'timeout') { killGroup(child); await gone; }
}
await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
},
};
}