// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs (v3.0 STATE helper). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { execFileSync, spawn } from 'node:child_process'; import { existsSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } 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({ remote = null, gitignoreState = false } = {}) { // realpath: on macOS tmpdir() is a /var → /private/var symlink, but // `git rev-parse --show-toplevel` returns the canonical path. Canonicalise // here so derived expected paths match the script's git-resolved output. const dir = realpathSync(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 }); if (remote) execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: dir }); writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8'); if (gitignoreState) writeFileSync(join(dir, '.gitignore'), 'STATE.md\n', 'utf-8'); execFileSync('git', ['add', '.'], { cwd: dir }); execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir }); return dir; } function runPipeline(cwd, args = []) { return new Promise((resolveP) => { const child = spawn('node', [SCRIPT, ...args], { cwd, stdio: ['ignore', '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 })); }); } // ---------- classifyRemote (unit) ---------- test('classifyRemote: none / public / private', async () => { const { classifyRemote } = await import(pathToFileURL(SCRIPT).href); assert.equal(classifyRemote(null), 'none'); assert.equal(classifyRemote(''), 'none'); assert.equal(classifyRemote('https://github.com/foo/bar.git'), 'public'); assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/open/graceful-handoff.git'), 'public'); assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/ktg/secret.git'), 'private'); }); // ---------- --plan ---------- test('--plan returns JSON with required keys', async () => { const repo = makeTempRepo(); const result = await runPipeline(repo, ['--plan']); assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`); const json = JSON.parse(result.stdout); for (const k of ['state_path', 'state_exists', 'state_gitignored', 'remote_class', 'should_be_local_only', 'should_commit_state', 'git_status', 'dirty_files', 'recent_commits', 'line_budget', 'errors']) { assert.ok(k in json, `missing key: ${k}`); } assert.ok(json.git_status.branch, 'branch missing'); rmSync(repo, { recursive: true, force: true }); }); test('--plan (default mode) targets /STATE.md when none exists', async () => { const repo = makeTempRepo(); const result = await runPipeline(repo, []); // default = plan const json = JSON.parse(result.stdout); assert.equal(json.state_exists, false); assert.equal(json.state_path, join(repo, 'STATE.md')); rmSync(repo, { recursive: true, force: true }); }); test('--plan resolves nearest existing STATE.md (subdir wins over root)', async () => { const repo = makeTempRepo(); const sub = join(repo, 'plugins', 'x'); execFileSync('mkdir', ['-p', sub]); writeFileSync(join(repo, 'STATE.md'), '# root\n'); writeFileSync(join(sub, 'STATE.md'), '# sub\n'); const result = await runPipeline(sub, ['--plan']); const json = JSON.parse(result.stdout); assert.equal(json.state_path, join(sub, 'STATE.md')); assert.equal(json.state_exists, true); rmSync(repo, { recursive: true, force: true }); }); test('--plan: public open/ remote → should_be_local_only, leak_warning when STATE not gitignored', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' }); const result = await runPipeline(repo, ['--plan']); const json = JSON.parse(result.stdout); assert.equal(json.remote_class, 'public'); assert.equal(json.should_be_local_only, true); assert.ok(json.leak_warning, 'expected leak_warning when public remote and STATE not gitignored'); rmSync(repo, { recursive: true, force: true }); }); test('--plan: public remote + STATE gitignored → no leak_warning, should_commit_state false', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true }); const result = await runPipeline(repo, ['--plan']); const json = JSON.parse(result.stdout); assert.equal(json.remote_class, 'public'); assert.equal(json.state_gitignored, true); assert.equal(json.should_commit_state, false); assert.equal(json.leak_warning, null); rmSync(repo, { recursive: true, force: true }); }); test('--plan: private remote → should_commit_state true, not local-only', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); const result = await runPipeline(repo, ['--plan']); const json = JSON.parse(result.stdout); assert.equal(json.remote_class, 'private'); assert.equal(json.should_be_local_only, false); assert.equal(json.should_commit_state, true); rmSync(repo, { recursive: true, force: true }); }); // ---------- --commit ---------- test('--commit on private repo stages and commits ONLY STATE.md', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); writeFileSync(join(repo, 'unrelated.txt'), 'user work\n'); const result = await runPipeline(repo, ['--commit']); const json = JSON.parse(result.stdout); assert.ok(json.actions_taken.includes('committed'), `expected committed, got ${JSON.stringify(json.actions_taken)}`); const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' }) .trim().split('\n').filter(Boolean); assert.deepEqual(head, ['STATE.md'], `HEAD should contain only STATE.md, got ${JSON.stringify(head)}`); rmSync(repo, { recursive: true, force: true }); }); test('--commit never stages unrelated dirty files (no git add -A regression)', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n'); writeFileSync(join(repo, 'unrelated-2.md'), '# notes\n'); await runPipeline(repo, ['--commit']); 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('--commit with --also includes the explicit related path', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n'); writeFileSync(join(repo, 'untouched.txt'), 'leave me\n'); await runPipeline(repo, ['--commit', '--also', 'code.mjs']); const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' }) .trim().split('\n').filter(Boolean).sort(); assert.deepEqual(head, ['STATE.md', 'code.mjs']); const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' }); assert.match(status, /untouched\.txt/); rmSync(repo, { recursive: true, force: true }); }); test('--commit on public repo does NOT commit gitignored STATE.md', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true }); writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n'); const result = await runPipeline(repo, ['--commit']); const json = JSON.parse(result.stdout); assert.ok( json.actions_taken.some(a => /local-only-skipped|intet-å-committe/.test(a)), `expected local-only skip, got ${JSON.stringify(json.actions_taken)}` ); assert.ok(!json.actions_taken.includes('committed'), 'should not commit gitignored STATE.md'); // STATE.md stays untracked/ignored, working tree otherwise clean const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim(); assert.equal(tracked, '', 'STATE.md must not become tracked on a public repo'); rmSync(repo, { recursive: true, force: true }); }); test('--commit on detached HEAD is detected (no commit attempted)', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); execFileSync('git', ['checkout', '-q', sha], { cwd: repo }); writeFileSync(join(repo, 'STATE.md'), '# STATE\n'); const result = await runPipeline(repo, ['--commit']); 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 }); }); // ---------- --dry-run ---------- test('--dry-run writes nothing and creates no commit', async () => { const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' }); const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); const result = await runPipeline(repo, ['--dry-run']); const json = JSON.parse(result.stdout); assert.equal(json.mode, 'dry-run'); assert.ok(!existsSync(join(repo, 'STATE.md')), 'dry-run must not write STATE.md'); const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim(); assert.equal(before, after, 'dry-run must not create a commit'); rmSync(repo, { recursive: true, force: true }); });