// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, spawn } from 'node:child_process'; import { existsSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, '..', '..', 'scripts', 'handoff-pipeline.mjs'); function makeTempRepo() { const dir = mkdtempSync(join(tmpdir(), 'gh-pipeline-')); execFileSync('git', ['init', '-q'], { cwd: dir }); execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir }); execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir }); // Initial commit so HEAD exists writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8'); execFileSync('git', ['add', '.'], { cwd: dir }); execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); return dir; } function runPipeline(repo, args = [], { stdin = '' } = {}) { return new Promise((resolveP) => { const child = spawn('node', [SCRIPT, ...args], { cwd: repo, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); child.stderr.on('data', (d) => (stderr += d.toString())); child.on('close', (code) => resolveP({ code, stdout, stderr })); if (stdin) child.stdin.write(stdin); child.stdin.end(); }); } test('--dry-run returns valid JSON with required keys', async () => { const repo = makeTempRepo(); const result = await runPipeline(repo, ['--dry-run']); assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`); const json = JSON.parse(result.stdout); assert.ok(json.handoff_type, 'handoff_type missing'); assert.ok(json.write_dir, 'write_dir missing'); assert.ok(Array.isArray(json.next_steps), 'next_steps missing'); assert.ok(Array.isArray(json.actions_taken), 'actions_taken missing'); assert.ok(Array.isArray(json.errors), 'errors missing'); assert.ok(json.git_status, 'git_status missing'); rmSync(repo, { recursive: true, force: true }); }); test('--dry-run is idempotent (two runs produce same JSON shape)', async () => { const repo = makeTempRepo(); const a = await runPipeline(repo, ['--dry-run']); const b = await runPipeline(repo, ['--dry-run']); const aJson = JSON.parse(a.stdout); const bJson = JSON.parse(b.stdout); assert.equal(aJson.handoff_type, bJson.handoff_type); assert.equal(aJson.write_dir, bJson.write_dir); assert.deepEqual(aJson.next_steps, bJson.next_steps); rmSync(repo, { recursive: true, force: true }); }); test('--non-interactive without --auto is invalid', async () => { const repo = makeTempRepo(); // Add dirty state so commit phase would activate writeFileSync(join(repo, 'foo.txt'), 'change\n'); const result = await runPipeline(repo, ['--non-interactive']); assert.equal(result.code, 0); // pipeline always exits 0 on logical errors const json = JSON.parse(result.stdout); assert.ok(json.errors.some(e => /non-interactive/i.test(e)), `expected non-interactive error, got: ${JSON.stringify(json.errors)}`); rmSync(repo, { recursive: true, force: true }); }); test('--auto on dirty repo writes artifact and commits without prompting', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); // No upstream — push will be skipped via no-upstream error, but commit should succeed const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); assert.equal(result.code, 0); const json = JSON.parse(result.stdout); assert.ok(json.actions_taken.some(a => a.startsWith('wrote-artifact')), `expected wrote-artifact, got: ${JSON.stringify(json.actions_taken)}`); assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); // Verify artifact file actually exists on disk assert.ok(existsSync(json.artifact_path), `artifact path ${json.artifact_path} should exist`); rmSync(repo, { recursive: true, force: true }); }); test('--no-commit skips git operations even when dirty', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); const result = await runPipeline(repo, ['--no-commit', '--auto']); const json = JSON.parse(result.stdout); assert.ok(!json.actions_taken.includes('committed'), 'should not commit with --no-commit'); assert.ok(!json.actions_taken.includes('pushed'), 'should not push without commit'); rmSync(repo, { recursive: true, force: true }); }); test('idempotency: second --auto run on clean tree with recent artifact is no-op', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); // First run: dirty, writes artifact and commits ONLY the artifact (not foo.txt) await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); // Clean up the unrelated dirty file so second run sees a CLEAN tree. // The pipeline must NEVER auto-stage user's other dirty files (CLAUDE.md // anti-pattern) — the test explicitly removes it to isolate idempotency. rmSync(join(repo, 'foo.txt')); // Second run: clean tree, recent artifact exists → idempotent no-op const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); const json = JSON.parse(result.stdout); assert.ok( json.actions_taken.some(a => a.includes('idempotent')), `expected idempotent no-op, got: ${JSON.stringify(json.actions_taken)}` ); rmSync(repo, { recursive: true, force: true }); }); test('pipeline never stages unrelated dirty files (no git add -A regression)', async () => { const repo = makeTempRepo(); // Two unrelated dirty files — pipeline should NOT commit them writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n'); writeFileSync(join(repo, 'unrelated-2.md'), '# user notes\n'); await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); // After commit, unrelated files must STILL be in working tree (not committed) const { execFileSync } = await import('node:child_process'); const lastCommit = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8', }).trim().split('\n').filter(Boolean); assert.ok(!lastCommit.includes('unrelated-1.txt'), `unrelated-1.txt should NOT be in HEAD commit, got: ${lastCommit}`); assert.ok(!lastCommit.includes('unrelated-2.md'), `unrelated-2.md should NOT be in HEAD commit, got: ${lastCommit}`); // The artifact SHOULD be in HEAD assert.ok(lastCommit.some(f => f.includes('NEXT-SESSION')), `artifact should be in HEAD, got: ${lastCommit}`); // unrelated files still untracked const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' }); assert.match(status, /unrelated-1\.txt/); assert.match(status, /unrelated-2\.md/); rmSync(repo, { recursive: true, force: true }); }); test('detached HEAD is detected and reported (no commit attempted)', async () => { const repo = makeTempRepo(); // Detach HEAD const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); execFileSync('git', ['checkout', '-q', sha], { cwd: repo }); writeFileSync(join(repo, 'foo.txt'), 'change\n'); const result = await runPipeline(repo, ['--auto', '--non-interactive', '--no-push']); const json = JSON.parse(result.stdout); assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got: ${JSON.stringify(json.errors)}`); assert.ok(!json.actions_taken.includes('committed'), 'should not commit on detached HEAD'); rmSync(repo, { recursive: true, force: true }); }); test('no-upstream branch is detected on push attempt', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); // No remote/upstream — pipeline tries to push, gets no-upstream error const result = await runPipeline(repo, ['--auto', '--non-interactive']); const json = JSON.parse(result.stdout); assert.ok(json.errors.some(e => /upstream/i.test(e)), `expected upstream error, got: ${JSON.stringify(json.errors)}`); rmSync(repo, { recursive: true, force: true }); }); test('interactive: stdin "n" cancels commit', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); const result = await runPipeline(repo, [], { stdin: 'n\n' }); const json = JSON.parse(result.stdout); assert.ok( json.actions_taken.some(a => /cancelled/i.test(a)), `expected commit-cancelled-by-user, got: ${JSON.stringify(json.actions_taken)}` ); assert.ok(!json.actions_taken.includes('committed'), 'should not commit when user says n'); rmSync(repo, { recursive: true, force: true }); }); test('interactive: stdin "y" confirms commit', async () => { const repo = makeTempRepo(); writeFileSync(join(repo, 'foo.txt'), 'change\n'); const result = await runPipeline(repo, ['--no-push'], { stdin: 'y\n' }); const json = JSON.parse(result.stdout); assert.ok(json.actions_taken.includes('committed'), `expected committed, got: ${JSON.stringify(json.actions_taken)}`); rmSync(repo, { recursive: true, force: true }); });