Files
Codeman/test/setup.ts
T
Codeman maintainer 6310868696 fix(test): bound the temp-HOME removal by a deadline, not an attempt count
Merge-time fix for #660. afterAll runs under vitest's 10 s hookTimeout, and
each failed removal attempt costs rmSync's own retry sleeps (50 + 100 + 150
ms) plus the 250 ms wait, so 20 attempts overran it: with a writer still
filling $HOME/.claude/backups, the file failed with "Hook timed out in
10000ms" after its test passed, and the "leave it to the exit backstop" branch
was never reached. The loop now stops after about 5 s. Checked with a
temporary (uncommitted) test that leaves a child process writing there for
20 s: before, the file failed at 11 s with the hook timeout; after, it passes
in under 6 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 19:25:04 +02:00

151 lines
7.6 KiB
TypeScript

/**
* @fileoverview Global test setup for Codeman tests
*
* SAFETY: The suite gets a temporary HOME and explicitly enables runtime test
* mode before application modules load. Tests therefore cannot touch the real
* Codeman state/cases tree or launch external tmux-backed agent sessions.
*
* This setup file strips shell-level configuration that can leak from a running
* Codeman instance — auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`), the gesture
* flag, and the three INSTANCE-selection vars that would otherwise point the
* suite at a real data dir or tmux socket — then handles mock/timer cleanup
* between tests.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, vi } from 'vitest';
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalVitest = process.env.VITEST;
const originalPlaywrightBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
const testHome = mkdtempSync(join(tmpdir(), 'codeman-vitest-'));
if (originalPlaywrightBrowsersPath === undefined && originalHome) {
process.env.PLAYWRIGHT_BROWSERS_PATH =
process.platform === 'darwin'
? join(originalHome, 'Library', 'Caches', 'ms-playwright')
: process.platform === 'win32'
? join(process.env.LOCALAPPDATA || join(originalHome, 'AppData', 'Local'), 'ms-playwright')
: join(originalHome, '.cache', 'ms-playwright');
}
process.env.HOME = testHome;
process.env.USERPROFILE = testHome;
process.env.VITEST = 'true';
for (const key of Object.keys(process.env)) {
if (key.startsWith('CODEMAN_')) delete process.env[key];
}
// Explicitly document the most consequential inherited settings below. The
// loop above intentionally also catches future container/deployment variables.
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
// Gesture availability changes renderIndexHtml output (injects the
// __codemanGestureAvailable flag), breaking byte-identity assertions
// (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1.
delete process.env.CODEMAN_GESTURE;
// CODEMAN_BASE_URL (#381) is read by the WebServer constructor as a fallback; an
// operator who exports it (exactly who the feature is for) would otherwise see the
// root-install byte-identity assertions fail.
delete process.env.CODEMAN_BASE_URL;
// CLAUDE_CONFIG_DIR (#255) relocates Claude's whole tree, transcripts included, and
// `claudeProjectsDir()` reads it before it ever looks at `homedir()`. A developer who runs
// Codeman against a separate Claude account exports exactly this, and every test that writes
// a transcript fixture under the temp HOME's `~/.claude/projects` then reads "no transcript"
// (found at merge of #467: test/session-custom-model-restart.test.ts went red).
delete process.env.CLAUDE_CONFIG_DIR;
// Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives
// both the data dir and the tmux socket from, so a shell that exports any of these
// three reaches straight past the temp HOME above and undoes the isolation this
// file exists to provide:
//
// - CODEMAN_DATA_DIR is the dangerous one. It is an ABSOLUTE override read in
// `getDataDir()`, so it bypasses HOME entirely: a developer who exports it
// (or a shell left over from `codeman web -d`) has the suite reading and
// WRITING their real `state.json`, `users.json`, `intents.json` and
// `hook-secret` instead of a throwaway tree. Found live 2026-08-29 (#356):
// `session-routes-workspace-hooks.test.ts` overwrote a production
// `remote-hosts.json` with its `h1/box/10.0.0.5` fixture. `os.homedir()`
// itself DOES follow `$HOME`, so with this var gone `getDataDir()` lands
// under the temp HOME like everything else. (#356 first answered this by
// pointing the var at a second throwaway dir; deleting it is the same
// protection with one tree to clean up.)
// - CODEMAN_INSTANCE moves the data dir to `~/.codeman-<name>` and the socket to
// `codeman-<name>`. Inside the temp HOME that is not a data-loss risk, but it
// silently changes the paths tests assert on — and `scripts/run-beta.sh`
// exports it, so any shell that has run a beta carries it.
// - CODEMAN_TMUX_SOCKET renames the socket `resolveTmuxSocketName()` returns.
// `TmuxManager` no-ops its shell commands under vitest, so this is assertion
// drift rather than a stray `tmux -L` against prod — but it is the same class
// of leak and the same one-line fix.
//
// ⚠️ These must be deleted HERE rather than in a test, because `CODEMAN_INSTANCE`
// is captured into a module-level const the first time `config/instance.ts` is
// imported. A setup file runs before any application module loads; a beforeEach
// would already be too late.
delete process.env.CODEMAN_INSTANCE;
delete process.env.CODEMAN_DATA_DIR;
delete process.env.CODEMAN_TMUX_SOCKET;
// Docker Compose binds cases outside HOME. Leaving this set makes route tests
// write into the deployment's real case root instead of their temp HOME.
delete process.env.CODEMAN_CASES_PATH;
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
afterAll(async () => {
// Let in-flight console-log rpc forwards drain before the worker environment
// tears down. On loaded CI runners the channel otherwise closes while the last
// "onUserConsoleLog" call is still pending, and that single unhandled
// EnvironmentTeardownError fails the run after every test has passed
// (observed twice on the PR #175/#176 merge commit; never locally).
const { promise: drained, resolve: drainDone } = Promise.withResolvers<void>();
setTimeout(drainDone, 50);
await drained;
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
if (originalVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = originalVitest;
if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH;
else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath;
// A straggling writer can recreate files between rmSync's readdir and rmdir and fail the whole file
// with ENOTEMPTY after every test has passed. The usual culprit is a real agent CLI on a developer
// machine (the `claude` a test's code path probes or spawns) flushing its own `~/.claude.json` and
// `~/.claude/backups/` into the temp HOME a moment after the test ends; CI has no such CLI. Keep
// removing until it goes quiet, for at most about 5 s, and if it never does, leave the temp dir to
// the `exit` backstop below: a leaked temp folder must not fail a test file that passed.
// ⚠️ The bound is a deadline, not an attempt count: this hook runs under vitest's 10 s
// `hookTimeout`, and each failed attempt costs rmSync's own retry sleeps (50 + 100 + 150 ms) plus
// the wait below, so 20 attempts overran it and failed the file with "Hook timed out".
const deadline = Date.now() + 5_000;
for (;;) {
try {
rmSync(testHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
break;
} catch {
if (Date.now() >= deadline) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
});
// afterAll never fires for a fully-skipped test file (no tests execute), which
// would leak the temp home created above. The exit hook is the backstop; rmSync
// with force is a no-op when afterAll already removed it.
process.on('exit', () => {
rmSync(testHome, { recursive: true, force: true });
});