ktg-plugin-marketplace/plugins/graceful-handoff/tests/scripts/handoff-pipeline.test.mjs
Kjell Tore Guttormsen 81aba9a5f5 feat(graceful-handoff): 2.0 — Stop hook auto-execute + pipeline staging fix [skip-docs]
Step 5 of v2.0 plan + critical pipeline fix.

Stop hook (hooks/scripts/stop-context-monitor.mjs):
- Estimates context usage from transcript size (chars/3.5 / window_size)
- At ≥70%, spawns handoff-pipeline.mjs --auto --no-push synchronously
- Reads context_window_size from payload (supports 1M windows)
- Lock file at <transcript_dir>/.handoff-lock-<session_id>
- Gracefully handles missing CLAUDE_PLUGIN_ROOT, missing transcript

Pipeline fix (scripts/handoff-pipeline.mjs):
- REMOVED `git add -A` (CLAUDE.md anti-pattern: scoops up unrelated WIP)
- Now stages ONLY artifact + REMEMBER.md/TODO.md if present
- New regression test 'pipeline never stages unrelated dirty files'

Tests: 7 stop-hook tests use stub pipeline (no real git operations);
11 pipeline tests including new regression for explicit staging.
2026-05-01 06:06:25 +02:00

184 lines
9.2 KiB
JavaScript

// 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 });
});