leasePort({closed:true}) ignored its option: it probed an ephemeral port and
closed it, so a parallel test file could bind it and answer a request the
test expected to fail (preview.test.mjs:209 saw 200 instead of 502).
leaseClosedPort holds a bound, non-listening socket in a perl child until the
test's after hook. Connects still get ECONNREFUSED, bind() from another
process gets EADDRINUSE, and the kernel's ephemeral allocation skips the port.
Lane mode keeps its band and per-port lock, and releases the socket before
the lock. leasePort now refuses a closed option, so a stale call cannot get
the unheld port again.
Unanswerable callers (webui preview, serve, service) move to leaseClosedPort(t).
Callers that listen on the port themselves (stack-release rehearse, control-board
serve, tester-cli) keep leasePort and drop the ignored option.
82 lines
6.3 KiB
JavaScript
82 lines
6.3 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/);
|
|
});
|
|
|
|
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, 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);
|
|
});
|