156 lines
9.5 KiB
TypeScript
156 lines
9.5 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Smoke test of the npm bundle (dist/storebound.mjs) with the real `node` binary: the CLI must run on Node 20+.
|
|
// bun tools/node-smoke.ts [--bundle dist/storebound.mjs] [--node <path>] [--only prompt]
|
|
// --only prompt: just the stdin prompt steps (the Windows release job runs these on the runner).
|
|
// Offline only: --version, help, check on examples/demo-game (same exit code and problems as under Bun), scan on the
|
|
// teaser fixture, release plan --help, init + check in an empty folder (the TypeScript config the template writes),
|
|
// and an MCP initialize handshake over stdio with tools/list and one tools/call (a child run through the bundle).
|
|
// Every step has a timeout; on a failure the step's output is printed.
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { ROOT } from './build-sidecars';
|
|
import { NODE_BUNDLE } from './build-node';
|
|
|
|
const arg = (n: string) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : undefined; };
|
|
const bundle = resolve(arg('--bundle') ?? NODE_BUNDLE);
|
|
const node = arg('--node') ?? Bun.which('node');
|
|
if (!node) { console.error('node-smoke: no `node` on PATH'); process.exit(1); }
|
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string; engines?: { node?: string } };
|
|
const env: Record<string, string | undefined> = { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', STOREBOUND_NO_UPDATE_CHECK: '1' };
|
|
|
|
interface Out { code: number | null; out: string; err: string }
|
|
function run(cmd: string[], cwd = ROOT, stdin?: string, timeout = 60_000): Out {
|
|
const r = Bun.spawnSync(cmd, { cwd, env, stdout: 'pipe', stderr: 'pipe', timeout, ...(stdin !== undefined ? { stdin: new TextEncoder().encode(stdin) } : {}) });
|
|
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
}
|
|
const sb = (args: string[], cwd = ROOT, stdin?: string) => run([node, bundle, ...args], cwd, stdin);
|
|
|
|
let failed = 0;
|
|
function step(name: string, f: () => string | void): void {
|
|
const t = Date.now();
|
|
try {
|
|
const note = f();
|
|
console.log(`ok ${name}${note ? `: ${note}` : ''} (${Date.now() - t} ms)`);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log(`FAIL ${name}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
function expectOk(r: Out, what: string, code = 0): void {
|
|
if (r.code !== code) throw new Error(`${what}: exit ${r.code} (want ${code})\n--- stdout\n${r.out.slice(-2000)}\n--- stderr\n${r.err.slice(-2000)}`);
|
|
}
|
|
/** Output without timestamps and absolute paths, to compare a Node and a Bun run. */
|
|
const norm = (s: string) => s.replace(/^\d\d:\d\d:\d\d /gm, '').replace(/\/\S*\/storebound\.config\.ts/g, '<config>').trim();
|
|
|
|
const nodeVersion = run([node, '--version']).out.trim();
|
|
console.log(`node-smoke: ${bundle.replace(ROOT + '/', '')} with node ${nodeVersion} (package engines: node ${pkg.engines?.node ?? '?'})`);
|
|
|
|
// ---- stdin prompts: `for await (const line of console)` is Bun-only and crashed `npx storebound desktop` at its
|
|
// [Y/n] prompt on Node ("console is not async iterable", then a libuv assertion on Windows). The bundle must not
|
|
// contain it, and rt.readLine (what every prompt uses) must read one piped line, see EOF, and exit cleanly.
|
|
step('prompt: the bundle has no Bun-only stdin iteration', () => {
|
|
const src = readFileSync(bundle, 'utf8');
|
|
if (/\bof\s+console\b/.test(src)) throw new Error('dist bundle iterates `console` (Bun-only): use rt.readLine');
|
|
if (!/readline/.test(src)) throw new Error('dist bundle has no node:readline (rt.readLine missing?)');
|
|
});
|
|
step('prompt: rt.readLine under node reads "n", EOF and CRLF, exits 0 without a crash', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'storebound-prompt-smoke-'));
|
|
try {
|
|
const entry = join(tmp, 'entry.ts');
|
|
writeFileSync(entry, `import { readLine } from ${JSON.stringify(join(ROOT, 'cli/lib/rt.ts'))};\nprocess.stdout.write('Install? [Y/n] ');\nconst a = await readLine();\nconst b = await readLine();\nconsole.log(JSON.stringify([a, b]));\n`);
|
|
const built = Bun.spawnSync(['bun', 'build', entry, '--target', 'node', '--format', 'esm', '--outfile', join(tmp, 'p.mjs')], { stdout: 'pipe', stderr: 'pipe' });
|
|
if (built.exitCode !== 0) throw new Error(`bun build: ${built.stderr.toString()}`);
|
|
const notes: string[] = [];
|
|
for (const [input, want] of [['n\n', '["n",null]'], ['', '[null,null]'], ['y\r\n', '["y",null]']] as const) {
|
|
const r = run([node, join(tmp, 'p.mjs')], tmp, input, 20_000);
|
|
const got = r.out.replace('Install? [Y/n] ', '').trim();
|
|
if (r.code !== 0 || got !== want || /assert|not async iterable/i.test(r.err)) throw new Error(`stdin ${JSON.stringify(input)}: exit ${r.code}, printed ${JSON.stringify(got)} (want ${want})\n${r.err.slice(-1500)}`);
|
|
notes.push(`${JSON.stringify(input)} -> ${got}`);
|
|
}
|
|
return notes.join(', ');
|
|
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
});
|
|
if (arg('--only') === 'prompt') {
|
|
if (failed) { console.error(`node-smoke: ${failed} step(s) failed`); process.exit(1); }
|
|
console.log('node-smoke: prompt steps passed');
|
|
process.exit(0);
|
|
}
|
|
|
|
step('--version', () => {
|
|
const r = sb(['--version']);
|
|
expectOk(r, '--version');
|
|
if (r.out.trim() !== pkg.version) throw new Error(`printed ${JSON.stringify(r.out.trim())}, package.json says ${pkg.version}`);
|
|
return r.out.trim();
|
|
});
|
|
|
|
step('help', () => {
|
|
const r = sb(['help']);
|
|
expectOk(r, 'help');
|
|
if (!r.out.includes('usage: storebound <command>')) throw new Error('no usage line');
|
|
return `${r.out.split('\n').length} lines`;
|
|
});
|
|
|
|
step('check (examples/demo-game, same result as Bun)', () => {
|
|
const cwd = join(ROOT, 'examples/demo-game');
|
|
const n = sb(['check'], cwd);
|
|
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'check'], cwd);
|
|
if (n.code !== b.code) throw new Error(`node exit ${n.code}, bun exit ${b.code}\n${n.out}\n${n.err}`);
|
|
if (norm(n.out + n.err) !== norm(b.out + b.err)) throw new Error(`output differs from Bun's\n--- node\n${norm(n.out + n.err)}\n--- bun\n${norm(b.out + b.err)}`);
|
|
return `exit ${n.code}, ${(n.out + n.err).match(/error: .*/)?.[0] ?? 'no problems'}`;
|
|
});
|
|
|
|
step('scan (tests/fixtures/scan/teaser)', () => {
|
|
const cwd = join(ROOT, 'tests/fixtures/scan/teaser');
|
|
const r = sb(['scan', '--json'], cwd);
|
|
const b = run(['bun', join(ROOT, 'cli/index.ts'), 'scan', '--json'], cwd);
|
|
if (r.code !== b.code) throw new Error(`node exit ${r.code}, bun exit ${b.code}\n${r.err}`);
|
|
const j = JSON.parse(r.out) as { findings?: unknown[] };
|
|
if (JSON.stringify(j) !== JSON.stringify(JSON.parse(b.out))) throw new Error('scan --json differs from Bun\'s');
|
|
return `exit ${r.code}, ${Array.isArray(j.findings) ? `${j.findings.length} findings` : 'json ok'}`;
|
|
});
|
|
|
|
step('release plan --help', () => {
|
|
const r = sb(['release', 'plan', '--help']);
|
|
expectOk(r, 'release plan --help');
|
|
if (!/storebound release/.test(r.out + r.err)) throw new Error('no usage');
|
|
});
|
|
|
|
step('init + inspect in an empty folder (TypeScript config)', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-node-init-'));
|
|
try {
|
|
const i = sb(['init'], dir);
|
|
expectOk(i, 'init');
|
|
if (!readFileSync(join(dir, 'storebound.config.ts'), 'utf8').includes('ShipConfig')) throw new Error('no storebound.config.ts written');
|
|
const c = sb(['inspect', '--json'], dir);
|
|
expectOk(c, 'inspect --json after init');
|
|
return `config loads: ${(JSON.parse(c.out) as { name?: string }).name}`;
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
step('mcp: initialize, tools/list, tools/call over stdio', () => {
|
|
const lines = [
|
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'node-smoke', version: '1' } } },
|
|
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
|
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } },
|
|
].map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
const prev = env.STOREBOUND_PROJECT;
|
|
env.STOREBOUND_PROJECT = join(ROOT, 'examples/demo-game');
|
|
let r: Out;
|
|
try { r = sb(['mcp'], ROOT, lines); } finally { if (prev === undefined) delete env.STOREBOUND_PROJECT; else env.STOREBOUND_PROJECT = prev; }
|
|
expectOk(r, 'mcp');
|
|
const res = new Map(r.out.trim().split('\n').filter(Boolean).map(l => JSON.parse(l) as { id: number; result?: Record<string, unknown>; error?: unknown }).map(m => [m.id, m]));
|
|
const init = res.get(1)?.result as { protocolVersion?: string; serverInfo?: { name: string; version: string } } | undefined;
|
|
if (init?.serverInfo?.name !== 'storebound' || init.serverInfo.version !== pkg.version) throw new Error(`initialize: ${JSON.stringify(res.get(1))}`);
|
|
const tools = (res.get(2)?.result as { tools?: unknown[] } | undefined)?.tools ?? [];
|
|
if (tools.length < 10) throw new Error(`tools/list: ${tools.length} tools`);
|
|
const call = res.get(3)?.result as { isError?: boolean; content?: { text: string }[] } | undefined;
|
|
// demo-game's check reports 2 problems (exit 1): what counts is that the child ran through the bundle and loaded the config
|
|
const text = call?.content?.[0]?.text ?? '';
|
|
if (!/config ok/.test(text) || !/\[exit [01]\]/.test(text)) throw new Error(`tools/call check: ${JSON.stringify(res.get(3)).slice(0, 1500)}`);
|
|
return `protocol ${init.protocolVersion}, ${tools.length} tools, check ran as a child of the bundle (${text.match(/\[exit \d+\]/)![0]})`;
|
|
});
|
|
|
|
if (failed) { console.error(`node-smoke: ${failed} step(s) failed`); process.exit(1); }
|
|
console.log('node-smoke: all steps passed');
|