mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-28 13:13:26 +08:00
225 lines
8.1 KiB
JavaScript
225 lines
8.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { rmSync, readFileSync } from 'node:fs';
|
|
import { test } from 'node:test';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { fakeBinaries } from './fake-bin.mjs';
|
|
import { parseOpenCodeArgs } from '../../scripts/cloud-session-opencode.mjs';
|
|
|
|
const script = fileURLToPath(new URL('../../scripts/cloud-session.mjs', import.meta.url));
|
|
const secret = 'a'.repeat(64);
|
|
|
|
function fixture(t, env = {}) {
|
|
const { root, bin, calls, env: binEnv } = fakeBinaries('opencode-client-');
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
bin(
|
|
'gh',
|
|
`
|
|
log({ command: 'gh', args });
|
|
if (args[0] === 'codespace' && args[1] === 'list') {
|
|
console.log(JSON.stringify([
|
|
{ name: 'dev-box', state: 'Available' },
|
|
]));
|
|
} else if (args.includes('-N')) {
|
|
if (process.env.TEST_TUNNEL_FAIL) process.exit(1);
|
|
const mapping = args[args.indexOf('-L') + 1];
|
|
const port = +mapping.split(':')[1];
|
|
const ssh = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
log({ event: 'ssh-child', pid: ssh.pid });
|
|
const server = require('node:http').createServer((req, res) => {
|
|
const expected = 'Basic ' + Buffer.from('opencode:' + '${secret}').toString('base64');
|
|
if (process.env.TEST_UNAUTHORIZED || req.headers.authorization !== expected) { res.writeHead(401).end(); return; }
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify({ healthy: true, version: process.env.TEST_SERVER_VERSION || '1.2.3' }));
|
|
});
|
|
server.listen(port, '127.0.0.1');
|
|
if (process.env.TEST_TUNNEL_DROP) setTimeout(() => process.exit(1), 500);
|
|
process.on('SIGTERM', () => { log({ event: 'tunnel-stopped' }); server.closeAllConnections(); server.close(() => process.exit(0)); });
|
|
} else if (args.at(-1).includes('node --input-type=module')) {
|
|
let source = '';
|
|
process.stdin.on('data', chunk => source += chunk);
|
|
process.stdin.on('end', () => {
|
|
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
|
|
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
|
|
const parts = args.at(-1).split(' ');
|
|
const name = parts.at(-3);
|
|
const fresh = parts.at(-2) === 'true';
|
|
const web = parts.at(-1) === 'true';
|
|
console.log(JSON.stringify({ port: 4242, password: '${secret}',
|
|
...(fresh && !web ? {} : { sessionID: 'ses_saved' }),
|
|
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
|
|
});
|
|
}
|
|
`,
|
|
);
|
|
bin(
|
|
'opencode',
|
|
`
|
|
if (args[0] === '--version') {
|
|
if (process.env.TEST_NO_CLIENT) process.exit(1);
|
|
console.log(process.env.TEST_CLIENT_VERSION || '1.2.3');
|
|
} else {
|
|
log({ command: 'opencode', args, password: process.env.OPENCODE_SERVER_PASSWORD });
|
|
if (process.env.TEST_CLIENT_WAIT) setInterval(() => {}, 1000);
|
|
}
|
|
`,
|
|
);
|
|
for (const opener of ['open', 'xdg-open']) bin(opener, `log({ command: 'browser', args });`);
|
|
return {
|
|
calls,
|
|
start(args) {
|
|
const child = spawn(process.execPath, [script, '--opencode', ...args], {
|
|
env: { ...process.env, ...binEnv, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let output = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
output += chunk;
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
output += chunk;
|
|
});
|
|
const done = new Promise((resolve) => child.on('exit', (code) => resolve({ code, output })));
|
|
t.after(() => child.kill('SIGKILL'));
|
|
return { child, done };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitFor(check) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const result = check();
|
|
if (result) return result;
|
|
await delay(50);
|
|
}
|
|
throw new Error('Fixture did not become ready.');
|
|
}
|
|
|
|
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
|
|
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
|
|
// kernel exposes it; without /proc only the signal error can tell.
|
|
function stopped(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
return true;
|
|
}
|
|
try {
|
|
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
test('parses options before or after the workspace and rejects unsupported flags', () => {
|
|
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
|
|
assert.equal(parseOpenCodeArgs([]).port, 0);
|
|
assert.equal(parseOpenCodeArgs(['--port', '4100', '--web']).port, 4100);
|
|
assert.deepEqual(parseOpenCodeArgs(['--web', 'fix-flaky', '--new', '--port', '4100']), {
|
|
name: 'fix-flaky',
|
|
web: true,
|
|
fresh: true,
|
|
port: 4100,
|
|
});
|
|
for (const args of [
|
|
['--port'],
|
|
['--port', '0'],
|
|
['--port', '65536'],
|
|
['--model', 'test'],
|
|
['a/b'],
|
|
['one', 'two'],
|
|
]) {
|
|
assert.throws(() => parseOpenCodeArgs(args));
|
|
}
|
|
});
|
|
|
|
test(
|
|
'attaches the local TUI to the selected workspace and closes the whole tunnel',
|
|
{ timeout: 15000 },
|
|
async (t) => {
|
|
const f = fixture(t);
|
|
const result = await f.start(['fix-flaky']).done;
|
|
assert.equal(result.code, 0, result.output);
|
|
const calls = f.calls();
|
|
assert.ok(calls.find((call) => call.event === 'bootstrap').hasSource);
|
|
assert.ok(
|
|
calls
|
|
.filter((call) => call.command === 'gh' && call.args[1] === 'ssh')
|
|
.every((call) => call.args[3] === 'dev-box'),
|
|
);
|
|
const tunnel = calls.find((call) => call.command === 'gh' && call.args.includes('-N'));
|
|
assert.match(tunnel.args.at(-1), /^127\.0\.0\.1:\d+:127\.0\.0\.1:4242$/);
|
|
const client = calls.find((call) => call.command === 'opencode');
|
|
assert.deepEqual(client.args.slice(2), [
|
|
'--dir',
|
|
'/workspaces/wt-fix-flaky',
|
|
'--session',
|
|
'ses_saved',
|
|
]);
|
|
assert.equal(client.password, secret);
|
|
assert.ok(!result.output.includes(secret));
|
|
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
|
|
const pid = calls.find((call) => call.event === 'ssh-child').pid;
|
|
await waitFor(() => stopped(pid));
|
|
},
|
|
);
|
|
|
|
test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => {
|
|
const f = fixture(t);
|
|
const result = await f.start(['fix-flaky', '--new']).done;
|
|
assert.equal(result.code, 0, result.output);
|
|
const client = f.calls().find((call) => call.command === 'opencode');
|
|
assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']);
|
|
});
|
|
|
|
for (const signal of ['SIGINT', 'SIGHUP']) {
|
|
test(
|
|
`opens the newest web conversation and cleans up on ${signal}`,
|
|
{ timeout: 15000 },
|
|
async (t) => {
|
|
const f = fixture(t, { TEST_NO_CLIENT: '1' });
|
|
const run = f.start(['--web', 'fix-flaky']);
|
|
const browser = await waitFor(() => f.calls().find((call) => call.command === 'browser'));
|
|
const url = new URL(browser.args[0]);
|
|
assert.equal(
|
|
url.pathname,
|
|
`/${Buffer.from('/workspaces/wt-fix-flaky').toString('base64url')}/session/ses_saved`,
|
|
);
|
|
assert.equal(url.password, '');
|
|
const response = await fetch(`${url.origin}/global/health`);
|
|
assert.equal(response.status, 200);
|
|
await response.text();
|
|
run.child.kill(signal);
|
|
const result = await run.done;
|
|
assert.equal(result.code, 0, result.output);
|
|
assert.ok(f.calls().some((call) => call.event === 'tunnel-stopped'));
|
|
await assert.rejects(fetch(url.origin));
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const [label, env, message] of [
|
|
[
|
|
'a version mismatch',
|
|
{ TEST_SERVER_VERSION: '1.14.22' },
|
|
/versions differ.*npm install -g opencode-ai@1\.14\.22/,
|
|
],
|
|
['an authentication failure', { TEST_UNAUTHORIZED: '1' }, /health check failed \(401\)/],
|
|
['a tunnel startup failure', { TEST_TUNNEL_FAIL: '1' }, /SSH tunnel closed/],
|
|
['a remote preparation failure', { TEST_BOOTSTRAP_FAIL: '1' }, /Could not prepare/],
|
|
['a missing local client', { TEST_NO_CLIENT: '1' }, /Install OpenCode locally/],
|
|
['a connection loss', { TEST_CLIENT_WAIT: '1', TEST_TUNNEL_DROP: '1' }, /SSH connection closed/],
|
|
]) {
|
|
test(`reports ${label} without printing credentials`, { timeout: 15000 }, async (t) => {
|
|
const f = fixture(t, env);
|
|
const result = await f.start([]).done;
|
|
assert.equal(result.code, 1, result.output);
|
|
assert.match(result.output, message);
|
|
assert.ok(!result.output.includes(secret));
|
|
if (label !== 'a connection loss')
|
|
assert.ok(!f.calls().some((call) => call.command === 'opencode'));
|
|
});
|
|
}
|