webui: serve the Console first screen from the control board (#1507)

This commit is contained in:
2026-09-12 19:37:35 -05:00
parent 7c8e530add
commit ea00ec66d9
29 changed files with 1639 additions and 0 deletions
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { startServer } from '../src/serve.mjs';
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
let resolveBoardRequest;
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
const server = createServer(async (req, res) => {
for await (const chunk of req) { void chunk; }
res.setHeader('content-type', 'application/json');
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
sends++; held = res;
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
const b = await browser();
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
await boardRequested;
holdBoard = false; boardResponse.end(JSON.stringify(index));
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
const agent = 'agent"[\\<script>', project = '__proto__';
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
index = { sessions: [rec], counts: { waiting: 1 } };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".reply-form button").disabled');
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// A stale registration removes the form, but does not destroy its draft.
index.sessions[0].registered.alive = false;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
index.sessions[0].registered.alive = true;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
// Malformed data must not erase a valid snapshot.
index = { sessions: [null] };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
assert.deepEqual(await b.evaluate('window.errors'), []);
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
});
+78
View File
@@ -0,0 +1,78 @@
// 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';
export async function browser() {
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}`,
], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buffer = '';
const pending = new Map();
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 (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) {
return new Promise((resolve, reject) => {
const key = ++id;
const timeout = setTimeout(() => { pending.delete(key); reject(new Error(`CDP timeout: ${method}`)); }, 25000);
pending.set(key, { resolve, reject, timeout });
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');
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,
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, ArrowRight: 39, ' ': 32 }[key];
await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: key === 'Enter' ? '\r' : key.length === 1 ? key : '' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual });
},
async close() {
await send('Browser.close').catch(() => {});
if (child.exitCode === null) await new Promise(resolve => child.once('exit', resolve));
await rm(profile, { recursive: true, force: true });
},
};
}
+107
View File
@@ -0,0 +1,107 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { fixture } from './fixture.mjs';
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
const f = await fixture(), b = await browser();
const out = process.env.WEBUI_EVIDENCE;
if (out) mkdirSync(out, { recursive: true });
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out')})()`);
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 2);
assert.equal(await b.evaluate('!!window.injected'), false);
// Project filtering does not hide another project's waiting requests.
await b.evaluate('document.querySelector("[data-project=proj]").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
await b.evaluate('[...document.querySelectorAll("[data-project]")].find(e=>e.dataset.project==="").click()');
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
const first = await b.evaluate('document.activeElement.dataset.open');
await b.key('ArrowDown'); assert.notEqual(await b.evaluate('document.activeElement.dataset.open'), first);
// Use the registered row regardless of scanner ordering.
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()');
await b.key('Enter');
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
assert.equal(await b.evaluate('!!window.injected'), false);
await b.evaluate('document.querySelector("#reply").focus()');
await b.call('Input.insertText', { text: 'keep this draft' });
await b.evaluate('document.querySelector("#reply").setSelectionRange(3,7);document.querySelector("#refresh").click()');
await wait('!document.querySelector("#refresh").disabled');
assert.deepEqual(await b.evaluate('({value:document.querySelector("#reply").value,start:document.querySelector("#reply").selectionStart,end:document.querySelector("#reply").selectionEnd,focus:document.activeElement.id})'), { value: 'keep this draft', start: 3, end: 7, focus: 'reply' });
// Real proxy to real board, with only its transport stubbed.
await b.evaluate('document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
assert.equal(f.captures.length, 1); assert.equal(await b.evaluate('document.querySelector("#reply").value'), '');
f.failReply();
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'failed draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit()');
await wait('document.querySelector(".receipt")?.textContent.includes("fixture refusal")');
assert.equal(await b.evaluate('document.querySelector("#reply").value'), 'failed draft');
assert.match(await b.evaluate('document.querySelector(".receipt").textContent'), /failed \(exit 4\): fixture refusal <unsafe>/);
await b.evaluate('document.querySelector("#inspection [data-seen]").click()');
await wait('document.querySelector("#seen-count").textContent==="1"');
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '1');
await b.evaluate('document.querySelector("#inspection [data-seen]").click()');
await wait('document.querySelector("#seen-count").textContent==="0"');
await b.evaluate('document.querySelector("#close").focus()'); await b.key('Escape');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.dataset.open'), 'proj/agent1');
if (out) {
await b.screenshot(join(out, 'console-harbor-light-1440.png'));
await b.viewport(320, 1000);
await b.screenshot(join(out, 'console-harbor-light-320.png'));
await b.viewport(1440);
}
// All supported palette/mode tokens and rendered badge/text backgrounds.
const contrast = await b.evaluate(`(() => {
const failures=[]; let count=0,lowest=100;
const rgb = c => { const a=c.match(/[\\d.]+/g).map(Number);return c.startsWith('color(')?[a[0]*255,a[1]*255,a[2]*255,a[3]??1]:[a[0],a[1],a[2],a[3]??1] };
const blend=(f,g)=>[...f.slice(0,3).map((v,i)=>v*f[3]+g[i]*(1-f[3])),1];
const lum=c=>c.slice(0,3).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4}).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
for(const p of Brand.palettes)for(const m of ['light','dim','dark']){
document.querySelector('#palette').value=p.id;document.querySelector('#palette').dispatchEvent(new Event('change'));
document.querySelector('#mode').value=m;document.querySelector('#mode').dispatchEvent(new Event('change'));
for(const s of ['h1','.muted','.source','.badge-ok','.badge-attn','.badge-danger','.badge-muted','.count','th','.session-open','.btn']){
const e=document.querySelector(s);if(!e)continue; const layers=[];for(let n=e;n;n=n.parentElement)layers.push(rgb(getComputedStyle(n).backgroundColor));
let bg=[255,255,255,1];while(layers.length)bg=blend(layers.pop(),bg);
const style=getComputedStyle(e), fg=blend(rgb(style.color),bg),a=lum(fg),b=lum(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);
const large=parseFloat(style.fontSize)>=24 || (parseFloat(style.fontSize)>=18.66&&parseInt(style.fontWeight)>=700);
count++;lowest=Math.min(lowest,ratio);if(ratio<(large?3:4.5))failures.push(p.id+'/'+m+' '+s+' '+ratio.toFixed(2));
}
}return {failures,count,lowest};
})()`);
assert.deepEqual(contrast.failures, []); assert.ok(contrast.count >= 300); console.log('Rendered contrast:', JSON.stringify(contrast));
for (const width of [320, 390, 768, 1440, 2560]) {
await b.viewport(width, 1000);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${width} page overflow`);
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").click()');
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, `${width} inspector overflow`);
assert.equal(await b.evaluate('document.querySelector("#reply").getBoundingClientRect().right<=innerWidth'), true);
if (out && [320, 1440].includes(width)) await b.screenshot(join(out, `console-inspector-${width}.png`));
await b.evaluate('document.querySelector("#close").click()');
}
// Empty and unreachable pages are tested as browser responses, never live data claims.
// Stop only the fixture board. The page keeps the last data and reports its URL.
await new Promise(r => f.board.close(r));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('!document.querySelector("#error").hidden');
const error = await b.evaluate('document.querySelector("#error").textContent');
assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
assert.deepEqual(await b.evaluate('window.errors'), []);
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
} finally { await b.close(); await f.close(); }
});
+36
View File
@@ -0,0 +1,36 @@
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
// Run the real board scanner/server against temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
export async function fixture() {
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
const seatsDir = join(root, 'seats'), specs = [];
let exitCode = 0;
const captures = [];
for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) {
const sessionsDir = join(root, agent); mkdirSync(sessionsDir);
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? <script>window.injected=true</script>' }] };
const lines = [
{ type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' },
{ type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } },
{ type: 'message', timestamp: '2026-09-01T00:00:02Z', message: assistant },
];
if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } });
writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
specs.push({ agent, project, sessionsDir, tmux: { session: agent } });
if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
}
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
});
const boardURL = `http://127.0.0.1:${board.address().port}`;
const web = await startServer({ port: 0, board: boardURL });
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, get } from 'node:http';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
import { fixture, close } from './fixture.mjs';
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
test('loopback host and board origin fail closed', async () => {
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
assert.equal(isLoopback(h), false);
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
}
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
});
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
const f = await fixture();
try {
assert.equal(f.web.address().address, '127.0.0.1');
const data = await (await fetch(f.base + '/api/board')).json();
assert.equal(data.sessions.length, 4);
assert.equal(data.sessions.filter(r => r.waitingOnYou).length, 2);
assert.deepEqual(data.counts, { working: 1, waiting: 1, error: 1, offline: 1, idle: 0, unknown: 0 });
const r = data.sessions.find(r => r.agent === 'agent1');
const seen = await post(f.base, '/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: true });
assert.equal(seen.status, 200); assert.equal((await seen.json()).seen.length, 1);
const reply = await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'fixture reply' });
assert.equal((await reply.json()).delivered, true); assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^fixture reply\n\(control-board:/);
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
}
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
assert.equal((await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'blocked' }, { origin: 'https://evil.example' })).status, 403);
assert.equal(f.captures.length, 1);
const hostileHost = await new Promise((resolve, reject) => {
get(f.base + '/api/board', { headers: { host: 'evil.example' } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject);
});
assert.equal(hostileHost, 403);
} finally { await f.close(); }
});
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
const requests = [];
const upstream = createServer(async (req, res) => {
let body = ''; for await (const c of req) body += c;
requests.push({ url: req.url, method: req.method, body });
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
});
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
const base = `http://127.0.0.1:${web.address().port}`;
try {
for (const path of ['/api/reply', '/api/seen']) {
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
const res = await post(base, path, raw);
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
}
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
}
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
assert.equal(requests.length, 2);
assert.equal((await fetch(base + '/api/board')).status, 502);
assert.equal(requests.length, 3);
} finally { await close(web); await close(upstream); }
});
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
const web = await startServer({ port: 0, board: url, timeout: 100 });
try {
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
} finally { await close(web); }
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
}
});