The tester-cli test makes about 20 Console reads and chat calls in a row. Each one spawns TESTER_CLI, which re-executes itself so the nonce is in its initial environment; both processes statically imported the client and the broker module graph behind it, though the parent only spawns the child. The child now imports Client and readProcess after the re-exec branch, so the parent is a bare node start. Interleaved over 20 calls at load 19: 258 ms per call before, 192 ms after. The proof, nonce and argv are unchanged. What remains is legitimate serial work: about 20 round trips of two node processes each, about 3 s idle and 10 s at load 20 on 16 cores (before this change). 30 s left under 3x room on a busy machine, so the test's hang guard is now 60 s.
85 lines
6.6 KiB
JavaScript
85 lines
6.6 KiB
JavaScript
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
|
import { after as laneAfter } from 'node:test';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer } from 'node:net';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
|
|
import { TESTER_CLI, HUMAN_CLI } from '../../cli/src/transport.mjs';
|
|
import { testerFixture } from './tester-fixture.mjs';
|
|
import { request } from 'node:http';
|
|
import { testerSocketPath, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
|
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
|
|
const get = (port, path) => new Promise((resolve, reject) => {
|
|
const q = request({ socketPath: port, path, headers: { host: new URL(TESTER_ORIGIN).host } }, r => {
|
|
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
|
|
}); q.on('error', reject); q.end();
|
|
});
|
|
async function freePort() { return leasePort({after:laneAfter}); }
|
|
|
|
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
|
|
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
|
|
for (const flags of [['--tester-port', '7332'], ['--port', '7444', '--tester-port', '7444'], ['--tester-port', '7444', '--port', '7444'], ...['1023', '65536', '1.5', '-1', 'nope'].map(p => ['--tester-port', p])]) {
|
|
const r = spawnSync(process.execPath, [CLI, 'serve', ...flags], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
|
|
assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*retired/i);
|
|
}
|
|
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 });
|
|
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
|
|
});
|
|
|
|
// P19 #876: about 20 Console reads and chat calls in a row, each one TESTER_CLI round trip of two
|
|
// node processes (the shim re-executes itself to carry its nonce). That is about 3 s idle and
|
|
// 10 s at load 20 on 16 cores; 30 s left too little room on a busy machine, so the guard is 60 s.
|
|
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 60000 }, async t => {
|
|
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
|
|
await new Promise(r=>f.web.close(r));
|
|
const hook = join(f.root, 'spawn-hook.mjs');
|
|
writeFileSync(log, '');
|
|
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
|
|
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let output = '', error = '';
|
|
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
|
|
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(Error('startup did not name tester: ' + output + error)), 5000);
|
|
const check = () => { if (output.includes(`Tester Console: unix:${port}`)) { clearTimeout(timer); resolve(); } };
|
|
child.stdout.on('data', check); child.once('exit', code => { clearTimeout(timer); reject(Error('CLI exit ' + code + ': ' + output + error)); }); check();
|
|
});
|
|
assert.match(output, /binds (the )?tester/);
|
|
const paths = ['inbox', 'tasks', 'agents', 'trail?subject=coder', 'decisions?list=roles', 'credentials', 'replies?id=absent', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'].map(p => '/api/bus/' + p);
|
|
paths.push('/api/project-files?project=stack', '/api/project-file?project=stack&path=spec%2Fnorth-star.md');
|
|
for (const path of paths) {
|
|
const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length;
|
|
const r = await get(port, path);
|
|
const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
|
assert.equal(calls.length, before + 1, path);
|
|
assert.equal(calls.at(-1).cli, TESTER_CLI, path);
|
|
assert.equal(calls.some(c => c.cli === HUMAN_CLI), false);
|
|
assert.ok(![401, 403, 500, 502, 503].includes(r.status), path + ': ' + r.body);
|
|
}
|
|
// Row 177 (#1675): the tester port's chat reaches the bus through TESTER_CLI as well.
|
|
const { revision, plugins } = await f.call('plugins');
|
|
await f.call('plugins.set', { revision, plugins: { ...plugins, channel: ['cli', 'webui'] } });
|
|
const send = (path, value) => new Promise((resolve, reject) => {
|
|
const q = request({ socketPath: port, path, method: 'POST', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => {
|
|
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
|
|
}); q.on('error', reject); q.end(JSON.stringify(value));
|
|
});
|
|
const spawns = async (label, go) => {
|
|
const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length;
|
|
const r = await go();
|
|
const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse).slice(before);
|
|
assert.ok(calls.length > 0, label); assert.ok(calls.every(c => c.cli === TESTER_CLI), label);
|
|
return r;
|
|
};
|
|
for (const path of ['/api/chat/projects', '/api/chat/threads']) assert.equal((await spawns(path, () => get(port, path))).status, 200, path);
|
|
const project = await spawns('create project', () => send('/api/chat/projects', { name: 'tester work' }));
|
|
assert.equal(project.status, 200, project.body);
|
|
// The fixture business has no assistant instance, so the thread's refusal comes from the bus.
|
|
const thread = await spawns('create thread', () => send('/api/chat/threads', { project: JSON.parse(project.body).id, name: 'probe' }));
|
|
assert.equal(JSON.parse(thread.body).error, 'thread-instance-missing', thread.body);
|
|
});
|