// cli-wrapper.test.mjs — Tests for bin/llm-security.mjs CLI dispatcher import { describe, it } from 'node:test'; import { execFile, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { strict as assert } from 'node:assert'; import { readFileSync, unlinkSync, existsSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI = resolve(__dirname, '../../bin/llm-security.mjs'); const PKG = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')); // For --help/--version/unknown: CLI writes directly to its own stdout function run(args, opts = {}) { return new Promise((resolve) => { execFile('node', [CLI, ...args], { timeout: 30000, ...opts }, (err, stdout, stderr) => { resolve({ code: err ? err.code ?? 1 : 0, stdout: stdout || '', stderr: stderr || '', }); }); }); } // For scanner subcommands: capture output via spawn with piped stdio function runCapture(args) { return new Promise((resolve) => { const chunks = []; const errChunks = []; const child = spawn('node', [CLI, ...args], { timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (c) => chunks.push(c)); child.stderr.on('data', (c) => errChunks.push(c)); child.on('close', (code) => { resolve({ code: code ?? 1, stdout: Buffer.concat(chunks).toString(), stderr: Buffer.concat(errChunks).toString(), }); }); }); } describe('CLI wrapper: bin/llm-security.mjs', () => { it('--help prints usage with subcommands', async () => { const { code, stdout } = await run(['--help']); assert.equal(code, 0, 'exit code should be 0'); assert.ok(stdout.includes('scan'), 'should list scan subcommand'); assert.ok(stdout.includes('posture'), 'should list posture subcommand'); assert.ok(stdout.includes('audit-bom'), 'should list audit-bom subcommand'); assert.ok(stdout.includes('benchmark'), 'should list benchmark subcommand'); assert.ok(stdout.includes('deep-scan'), 'should list deep-scan subcommand'); }); it('--version prints version from package.json', async () => { const { code, stdout } = await run(['--version']); assert.equal(code, 0, 'exit code should be 0'); assert.ok(stdout.trim().includes(PKG.version), `should print ${PKG.version}`); }); it('no arguments prints help and exits 0', async () => { const { code, stdout } = await run([]); assert.equal(code, 0, 'exit code should be 0'); assert.ok(stdout.includes('Usage'), 'should print usage'); }); it('unknown subcommand prints help and exits 1', async () => { const { code, stderr } = await run(['nonexistent']); assert.equal(code, 1, 'exit code should be 1'); assert.ok(stderr.includes('Unknown'), 'should mention unknown command'); }); it('scan dispatches to scan-orchestrator and produces JSON', async () => { const cwd = resolve(__dirname, '../..'); const { code, stdout } = await runCapture(['scan', cwd]); // scan-orchestrator produces JSON; non-zero exit (findings exist) is expected const parsed = JSON.parse(stdout); assert.ok(parsed.scanners || parsed.aggregate, 'should produce scanner result JSON'); }); it('posture dispatches to posture-scanner and produces JSON', async () => { const cwd = resolve(__dirname, '../..'); const { code, stdout } = await runCapture(['posture', cwd]); const parsed = JSON.parse(stdout); assert.ok(parsed.grade || parsed.categories, 'should produce posture JSON'); }); it('scan --format sarif via --output-file produces complete SARIF', async () => { // Note: scan-orchestrator calls process.exit() immediately after // process.stdout.write(), which can truncate piped output >64KB on macOS. // Using --output-file bypasses this by writing to file instead. const cwd = resolve(__dirname, '../..'); const tmpFile = resolve(__dirname, '../../.sarif-test-output.json'); try { await runCapture(['scan', cwd, '--format', 'sarif', '--output-file', tmpFile]); assert.ok(existsSync(tmpFile), 'output file should exist'); const content = readFileSync(tmpFile, 'utf8'); const parsed = JSON.parse(content); assert.equal(parsed.version, '2.1.0', 'should produce SARIF 2.1.0'); assert.ok(parsed.$schema, 'should have $schema field'); } finally { if (existsSync(tmpFile)) unlinkSync(tmpFile); } }); });