ktg-plugin-marketplace/plugins/graceful-handoff/tests/hooks/stop-context-monitor.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

140 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// stop-context-monitor.test.mjs — Tests for Stop hook auto-execute logic.
// Uses runHook to spawn the script as a subprocess and inspect its behavior
// via temporary fixture files (real fs) — simpler than mocking imports.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, existsSync, rmSync, statSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { runHookWithEnv } from './hook-helper.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOOK = join(__dirname, '..', '..', 'hooks', 'scripts', 'stop-context-monitor.mjs');
const PLUGIN_ROOT = join(__dirname, '..', '..');
function setup(transcriptSize) {
const dir = mkdtempSync(join(tmpdir(), 'stop-hook-'));
const transcriptPath = join(dir, 'transcript.jsonl');
// Generate transcript content of exact size (chars)
writeFileSync(transcriptPath, 'a'.repeat(transcriptSize), 'utf-8');
return { dir, transcriptPath };
}
// Build a stub plugin root with a fake handoff-pipeline.mjs that returns
// canned JSON. This prevents tests from invoking the real pipeline (which
// does git operations against whatever repo the test process happens to be in).
function makeStubPluginRoot() {
const dir = mkdtempSync(join(tmpdir(), 'stub-plugin-root-'));
const scriptsDir = join(dir, 'scripts');
mkdirSync(scriptsDir);
const stub = `#!/usr/bin/env node
process.stdout.write(JSON.stringify({
handoff_type: 'plugin-arbeid',
write_dir: '/tmp/stub',
artifact_path: '/tmp/stub/NEXT-SESSION-PROMPT.local.md',
next_steps: [],
git_status: { branch: 'main', dirty: false, ahead: 0 },
commit_message: '',
actions_taken: ['stub-no-op'],
errors: [],
}));
process.exit(0);
`;
writeFileSync(join(scriptsDir, 'handoff-pipeline.mjs'), stub, 'utf-8');
return dir;
}
function cleanup(dir) {
rmSync(dir, { recursive: true, force: true });
}
test('estimated < 70%: no spawn, no lock file', async () => {
// 200k window × 70% threshold = 140k tokens × 3.5 chars = 490k chars
// Use 400k chars (~57%) — well under threshold
const { dir, transcriptPath } = setup(400_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-1',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-1')), 'no lock should be written below threshold');
cleanup(dir);
});
test('estimated ≥ 70% + no lock: lock created, stub pipeline spawned', async () => {
// 600k chars / 3.5 = 171k tokens / 200k = 86% — well above threshold
const { dir, transcriptPath } = setup(600_000);
const stubRoot = makeStubPluginRoot();
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-2',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: stubRoot });
assert.equal(res.code, 0);
// Lock file must exist
assert.ok(existsSync(join(dir, '.handoff-lock-test-2')), 'lock file should be created');
// additionalContext should mention auto-handoff (stub returns no errors → success path)
assert.match(res.stdout, /Auto-handoff utført/i);
cleanup(dir);
cleanup(stubRoot);
});
test('estimated ≥ 70% + lock exists: no spawn, no output', async () => {
const { dir, transcriptPath } = setup(600_000);
// Pre-create the lock file
writeFileSync(join(dir, '.handoff-lock-test-3'), 'pre-existing', 'utf-8');
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-3',
context_window: { context_window_size: 200_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent (lock exists), got: ${res.stdout}`);
cleanup(dir);
});
test('missing transcript_path: silent exit 0', async () => {
const res = await runHookWithEnv(HOOK, { session_id: 'test-4' }, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('non-existent transcript file: silent exit 0', async () => {
const res = await runHookWithEnv(HOOK, {
transcript_path: '/tmp/does-not-exist-' + Date.now() + '.jsonl',
session_id: 'test-5',
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '');
});
test('uses context_window_size from payload (not hardcoded 200k)', async () => {
// 1M context window × 70% = 700k tokens × 3.5 = 2.45M chars to trigger
// 600k chars on a 1M window is only ~17% — should NOT trigger
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-6',
context_window: { context_window_size: 1_000_000 },
}, { CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT });
assert.equal(res.code, 0);
assert.equal(res.stdout.trim(), '', `expected silent on 1M window, got: ${res.stdout}`);
assert.ok(!existsSync(join(dir, '.handoff-lock-test-6')));
cleanup(dir);
});
test('CLAUDE_PLUGIN_ROOT missing: graceful error message', async () => {
const { dir, transcriptPath } = setup(600_000);
const res = await runHookWithEnv(HOOK, {
transcript_path: transcriptPath,
session_id: 'test-7',
context_window: { context_window_size: 200_000 },
}, {}); // no CLAUDE_PLUGIN_ROOT
assert.equal(res.code, 0);
assert.match(res.stdout, /CLAUDE_PLUGIN_ROOT not set/);
cleanup(dir);
});