import assert from 'node:assert/strict'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import test, { type TestContext } from 'node:test'; import { DockerRunner } from '../src/adapters/testing/docker-runner.js'; import { testFile } from '../src/adapters/testing/test-results.js'; import { describeTestEnvironment, runnerSchema } from '../src/domain/runner-config.js'; import { testId } from '../src/domain/test-evidence.js'; import type { Snapshot } from '../src/domain/types.js'; import type { ProcessOptions, ProcessResult } from '../src/shared/process.js'; const ok = (stdout = ''): ProcessResult => ({ code: 0, stdout, stderr: '', timedOut: false }); const report = (status: 'passed' | 'failed' = 'passed', empty = false): ProcessResult => ({ ...ok(JSON.stringify({ format: 'repopilot-node-v1', infrastructureErrors: [], cases: empty ? [] : [ { file: 'test.js', name: 'works', status, durationMs: 1, failure: status === 'failed' ? 'expected 1, got 2' : undefined } ] })), code: status === 'failed' ? 1 : 0 }); const service = { name: 'default', image: 'redis:7-alpine', tmpfs: ['/data'], readiness: { command: ['redis-cli', 'ping'] } }; const command = ['node', '--test']; const files: Snapshot = new Map([['packages/api/test.js', 'test'], ['test.js', 'test']]); type Call = { args: string[]; options: ProcessOptions }; async function fixture(t: TestContext, raw: unknown, respond: (call: Call) => ProcessResult | Promise = () => report()) { await mkdir('.cache/tests', { recursive: true }); const root = await mkdtemp(resolve('.cache/tests/runner-')); t.after(() => rm(root, { recursive: true, force: true })); const reporter = resolve(root, 'reporter.mjs'); await writeFile(reporter, '// trusted reporter'); const calls: Call[] = []; const runner = new DockerRunner(runnerSchema.parse(raw), root, { reporterSource: pathToFileURL(reporter), execute: async (binary, args, options = {}) => { assert.equal(binary, 'docker'); const call = { args, options }; calls.push(call); return respond(call); } }); return { runner, calls }; } const isTest = (args: string[]) => args[0] === 'run' && !args.includes('--detach'); const defaultResponse = ({ args }: Call) => isTest(args) ? report() : ok(args[0] === 'inspect' ? 'true' : ''); test('legacy runner defaults and case identities remain compatible', async t => { assert.deepEqual(runnerSchema.parse({ command }), { image: 'node:22-bookworm-slim', command, reporter: 'node', timeoutSeconds: 300, memory: '1g', cpus: 2 }); const { runner, calls } = await fixture(t, { command }, defaultResponse); const result = await runner.run(files, 'head'); assert.equal(result.status, 'passed'); assert.equal(result.cases[0]?.id, testId('test.js', 'works')); const args = calls.find(c => isTest(c.args))!.args; assert.equal(args[args.indexOf('--network') + 1], 'none'); assert.ok(args.includes('--pull=never')); assert.ok(!calls.some(c => c.args[0] === 'network')); }); test('multiple commands preserve failures and namespace identical cases', async t => { let count = 0; const { runner } = await fixture(t, { commands: [{ name: 'one', command }, { name: 'two', command }] }, c => isTest(c.args) ? report(count++ === 0 ? 'failed' : 'passed') : ok()); const result = await runner.run(files, 'head'); assert.equal(result.status, 'failed'); assert.equal(result.commands?.length, 2); assert.notEqual(result.cases[0]?.id, result.cases[1]?.id); assert.deepEqual(result.cases.map(c => c.command), ['one', 'two']); }); test('empty results in any command block a passing aggregate', async t => { let count = 0; const { runner } = await fixture(t, { commands: [{ name: 'one', command }, { name: 'two', command }] }, c => isTest(c.args) ? report('passed', count++ === 0) : ok()); assert.equal((await runner.run(files, 'head')).status, 'not_run'); }); test('working directories normalize relative results and reject missing directories', async t => { const { runner, calls } = await fixture(t, { command, cwd: 'packages/api' }, defaultResponse); assert.equal((await runner.run(files, 'head')).cases[0]?.file, 'packages/api/test.js'); assert.ok(calls.find(c => isTest(c.args))!.args.includes('packages/api')); assert.equal(testFile('/tmp/work/packages/api/test.js', '/tmp/work', 'packages/api'), 'packages/api/test.js'); assert.throws(() => testFile('../../../outside', '/tmp/work', 'packages/api')); assert.throws(() => testFile('')); const missing = await fixture(t, { command, cwd: 'missing' }, defaultResponse); assert.equal((await missing.runner.run(files, 'head')).status, 'error'); assert.equal(missing.calls.length, 0); }); test('dependency runs use fresh internal networks, separate names and reverse cleanup', async t => { const { runner, calls } = await fixture(t, { command, services: [service], env: { REDIS_HOST: 'default' } }, defaultResponse); assert.equal((await runner.run(files, 'base')).status, 'passed'); assert.equal((await runner.run(files, 'head')).status, 'passed'); const networks = calls.filter(c => c.args[0] === 'network' && c.args[1] === 'create'); assert.equal(networks.length, 2); assert.notEqual(networks[0]!.args.at(-1), networks[1]!.args.at(-1)); assert.ok(networks.every(c => c.args.includes('--internal'))); const launches = calls.filter(c => c.args[0] === 'run'); assert.equal(new Set(launches.map(c => c.args[c.args.indexOf('--name') + 1])).size, 4); assert.ok(launches.every(c => c.args.includes('--read-only') && !c.args.includes('-p'))); assert.ok(launches.every(c => c.args.includes('io.repopilot.managed=true') && c.args.some(arg => /^io\.repopilot\.owner=[a-f0-9]{64}$/.test(arg)))); assert.ok(networks.every(c => c.args.includes('io.repopilot.managed=true'))); assert.ok(launches.filter(c => c.args.includes('--detach')).every(c => c.args.some(arg => arg.includes('uid=65534,gid=65534')))); const cleanup = calls.filter(c => c.args[0] === 'rm' || c.args[1] === 'rm'); assert.match(cleanup[0]!.args.at(-1)!, /-command-default$/); assert.match(cleanup[1]!.args.at(-1)!, /-service-default$/); assert.equal(cleanup[2]!.args[0], 'network'); assert.ok(cleanup.every(c => !c.options.signal)); assert.ok(cleanup.filter(c => c.args[0] === 'rm').every(c => c.args.includes('-v'))); }); test('partial service startup failure cleans resources before retry', async t => { const { runner, calls } = await fixture(t, { command, services: [service] }, c => c.args.includes('--detach') ? { ...ok(), code: 125 } : defaultResponse(c)); const result = await runner.run(files, 'head'); assert.equal(result.status, 'error'); assert.deepEqual(result.failure, { kind: 'environment', retryable: true }); assert.ok(calls.some(c => c.args[0] === 'rm')); assert.equal(calls.at(-1)?.args[1], 'rm'); }); test('dead services and failed cleanup invalidate passing tests', async t => { for (const failure of ['inspect', 'rm']) { const { runner } = await fixture(t, { command, services: [service] }, c => c.args[0] === failure ? { ...ok('false'), code: 1 } : defaultResponse(c)); assert.equal((await runner.run(files, 'head')).status, 'error'); } }); test('command timeout stops subsequent commands and cleans the environment', async t => { const { runner, calls } = await fixture(t, { commands: [{ name: 'one', command }, { name: 'two', command }], services: [service] }, c => isTest(c.args) ? { ...ok(), code: null, timedOut: true } : defaultResponse(c)); assert.equal((await runner.run(files, 'head')).status, 'error'); assert.equal(calls.filter(c => isTest(c.args)).length, 1); assert.equal(calls.at(-1)?.args[1], 'rm'); }); test('cancellation during readiness still cleans containers and network', async t => { const parent = new AbortController(); const { runner, calls } = await fixture(t, { command, services: [service] }, c => { if (c.args[0] === 'exec') { parent.abort(new Error('cancelled')); c.options.signal!.throwIfAborted(); } return defaultResponse(c); }); await assert.rejects(runner.run(files, 'head', parent.signal), /cancelled/); assert.equal(calls.at(-1)?.args[1], 'rm'); assert.ok(!calls.some(c => isTest(c.args))); }); test('configuration rejects ambiguous commands and unsafe environment settings', () => { for (const input of [{}, { command, commands: [{ name: 'one', command }] }, { command, cwd: '../escape' }, { command, env: { GITHUB_TOKEN: 'secret' } }, { command, services: [{ ...service, user: '0:0' }] }, { commands: [{ name: 'one', command }, { name: 'one', command }] }, { command, services: [{ ...service, ports: [6379] }] }, { command: ['node', '--test', '--test-reporter=tap'] }]) { assert.equal(runnerSchema.safeParse(input).success, false); } const description = describeTestEnvironment(runnerSchema.parse({ command, env: { DATABASE_URL: 'secret-value' }, services: [service] })); assert.deepEqual(description.commands[0]?.environmentVariables, ['DATABASE_URL']); assert.ok(!JSON.stringify(description).includes('secret-value')); }); test('readiness timeout and total environment budget stop before test execution', async t => { for (const readinessTimeout of [1, 30]) { const { runner, calls } = await fixture(t, { command, timeoutSeconds: readinessTimeout === 1 ? 5 : 1, services: [{ ...service, readiness: { command: ['redis-cli', 'ping'], timeoutSeconds: readinessTimeout } }] }, c => { if (c.args[0] !== 'exec') return defaultResponse(c); return new Promise((_resolve, reject) => { c.options.signal!.addEventListener('abort', () => reject(c.options.signal!.reason), { once: true }); }); }); assert.equal((await runner.run(files, 'head')).status, 'error'); assert.ok(!calls.some(c => isTest(c.args))); assert.equal(calls.at(-1)?.args[1], 'rm'); } });