Files
msd-core/tests/bug-1974-context-exhaustion-record.test.cjs
Tom Boucher 32dea2034b fix(3597): bug-1974 windows EPERM — retry rmSync on tmpDir teardown
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.
2026-05-16 13:18:34 -04:00

183 lines
6.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.
/**
* 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'
);
});
});