Test spawns a fire-and-forget subprocess; on Windows the child may still hold a handle on tmpDir when afterEach runs, producing EPERM from fs.rmSync. Match helpers.cleanup() retry budget (20 × 250ms = 5s) to absorb the deferred-handle window without pulling in the helper (this suite predates it). Closes the Windows test (windows-latest, 24/26) lane failure on #3649.
183 lines
6.6 KiB
JavaScript
183 lines
6.6 KiB
JavaScript
/**
|
||
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
|
||
*
|
||
* Verifies:
|
||
* 1. On CRITICAL + active GSD project, subprocess is spawned and STATE.md
|
||
* receives the "Stopped At" field.
|
||
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
|
||
* the subprocess (sentinel guard prevents repeated overwrites).
|
||
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
|
||
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const { spawnSync } = require('node:child_process');
|
||
|
||
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||
|
||
/**
|
||
* Run the hook with a given session id and context percentage.
|
||
* Writes a bridge metrics file first, then pipes the hook input via stdin.
|
||
* Returns after the hook exits.
|
||
*/
|
||
function runHook(sessionId, remainingPct, cwd) {
|
||
// Write the bridge metrics file the hook reads
|
||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||
fs.writeFileSync(bridgePath, JSON.stringify({
|
||
session_id: sessionId,
|
||
remaining_percentage: remainingPct,
|
||
used_pct: 100 - remainingPct,
|
||
timestamp: Math.floor(Date.now() / 1000),
|
||
}));
|
||
|
||
const input = JSON.stringify({
|
||
session_id: sessionId,
|
||
cwd,
|
||
});
|
||
|
||
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
||
input,
|
||
encoding: 'utf-8',
|
||
timeout: 10000,
|
||
env: { ...process.env, HOME: process.env.HOME },
|
||
});
|
||
|
||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
||
}
|
||
|
||
/**
|
||
* Wait up to `ms` for a file to exist (the subprocess is fire-and-forget).
|
||
*/
|
||
function waitForStoppedAt(statePath, ms = 2000) {
|
||
const deadline = Date.now() + ms;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const content = fs.readFileSync(statePath, 'utf-8');
|
||
if (/Stopped [Aa]t.*context exhaustion/.test(content)) return content;
|
||
} catch { /* file may briefly not exist during atomic write */ }
|
||
// Tight poll loop — subprocess should complete in <100ms
|
||
const start = Date.now();
|
||
while (Date.now() - start < 50) { /* spin */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
describe('#1974 context exhaustion auto-record', () => {
|
||
let tmpDir;
|
||
let statePath;
|
||
let sessionId;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
|
||
const planningDir = path.join(tmpDir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
|
||
// Minimal STATE.md with Stopped At field
|
||
statePath = path.join(planningDir, 'STATE.md');
|
||
fs.writeFileSync(statePath, [
|
||
'# Session State',
|
||
'',
|
||
'**Current Phase:** 1',
|
||
'**Status:** executing',
|
||
'**Last session:** unset',
|
||
'**Last Date:** unset',
|
||
'**Stopped At:** None',
|
||
'**Resume File:** None',
|
||
'',
|
||
].join('\n'));
|
||
|
||
// Minimal config.json required by gsd-tools
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
|
||
|
||
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
});
|
||
|
||
afterEach(() => {
|
||
// Windows: AV/file-indexer/not-yet-exited fire-and-forget subprocess may
|
||
// still hold a handle on tmpDir at teardown. Match the helpers.cleanup()
|
||
// retry budget (20 × 250ms = 5s) to absorb the deferred-handle window.
|
||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
|
||
// Clean up bridge files
|
||
try {
|
||
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
||
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
|
||
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
||
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
|
||
} catch { /* noop */ }
|
||
});
|
||
|
||
test('spawns subprocess and writes Stopped At field on CRITICAL with active GSD', () => {
|
||
// Trigger CRITICAL — remaining <= 25
|
||
const result = runHook(sessionId, 20, tmpDir);
|
||
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
||
|
||
// Wait for fire-and-forget subprocess to write STATE.md
|
||
const content = waitForStoppedAt(statePath);
|
||
assert.ok(content, `STATE.md should contain "context exhaustion" after CRITICAL fire`);
|
||
assert.match(content, /context exhaustion at \d+%/);
|
||
});
|
||
|
||
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
|
||
// Delete STATE.md to simulate non-GSD project
|
||
fs.unlinkSync(statePath);
|
||
const originalMtime = Date.now();
|
||
|
||
const result = runHook(sessionId, 20, tmpDir);
|
||
assert.strictEqual(result.exitCode, 0);
|
||
|
||
// Wait a bit then verify STATE.md was NOT recreated
|
||
const start = Date.now();
|
||
while (Date.now() - start < 500) { /* spin */ }
|
||
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
|
||
});
|
||
|
||
test('sentinel prevents repeated firing within same session', () => {
|
||
// First CRITICAL fire — should record
|
||
runHook(sessionId, 20, tmpDir);
|
||
const content1 = waitForStoppedAt(statePath);
|
||
assert.ok(content1, 'first fire should record Stopped At');
|
||
|
||
// Extract the timestamp from first fire
|
||
const firstMatch = content1.match(/context exhaustion at (\d+)%/);
|
||
assert.ok(firstMatch, 'first fire should have numeric usedPct');
|
||
|
||
// Manually set Stopped At to a sentinel value to detect second fire
|
||
const modified = content1.replace(/(\*\*Stopped At:\*\*) .+/, '$1 SENTINEL_SHOULD_NOT_CHANGE');
|
||
fs.writeFileSync(statePath, modified);
|
||
|
||
// Second CRITICAL fire — should NOT re-fire the subprocess
|
||
runHook(sessionId, 18, tmpDir);
|
||
|
||
// Wait and verify the sentinel is preserved
|
||
const start = Date.now();
|
||
while (Date.now() - start < 500) { /* spin */ }
|
||
const content2 = fs.readFileSync(statePath, 'utf-8');
|
||
assert.match(
|
||
content2,
|
||
/SENTINEL_SHOULD_NOT_CHANGE/,
|
||
'second CRITICAL fire should not re-record (sentinel guard)'
|
||
);
|
||
});
|
||
|
||
test('hook uses __dirname-based path (runtime-agnostic)', () => {
|
||
// Verify the hook source references __dirname, not ~/.claude/
|
||
const hookSource = fs.readFileSync(HOOK_PATH, 'utf-8');
|
||
assert.match(
|
||
hookSource,
|
||
/path\.join\(__dirname,\s*'\.\.',\s*'get-shit-done'/,
|
||
'hook must use __dirname-based path resolution for gsd-tools.cjs'
|
||
);
|
||
assert.doesNotMatch(
|
||
hookSource,
|
||
/process\.env\.HOME.*\.claude.*get-shit-done.*gsd-tools\.cjs/,
|
||
'hook must not hardcode ~/.claude/ path'
|
||
);
|
||
});
|
||
});
|