diff --git a/tests/hooks/bash-hook-dispatcher.test.js b/tests/hooks/bash-hook-dispatcher.test.js index 893b45d5b..2f98d7aeb 100644 --- a/tests/hooks/bash-hook-dispatcher.test.js +++ b/tests/hooks/bash-hook-dispatcher.test.js @@ -11,6 +11,13 @@ const { spawnSync } = require('child_process'); const preDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dispatcher.js'); const postDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-dispatcher.js'); +// The post-Bash dispatcher appends to ~/.claude/bash-commands.log and +// ~/.claude/cost-tracker.log, so runs get an empty home instead of the +// developer's real one unless a test passes its own HOME. +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-bash-dispatcher-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); + function test(name, fn) { try { fn(); @@ -29,6 +36,7 @@ function runScript(scriptPath, input, env = {}) { encoding: 'utf8', env: { ...process.env, + ...ISOLATED_HOME_ENV, ...env, }, timeout: 10000, diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index ef6e2463f..40805ee77 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -15,6 +15,12 @@ const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracke // Harness caches belong to a real private root shared by parent and hook child. const cacheRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-cache-'))); +// cost-tracker.js appends to ~/.claude/metrics/costs.jsonl and writes cost +// snapshots there, so runs get an empty home instead of the developer's real +// one unless a test passes withTempHome(). +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); const savedTempEnvironment = Object.fromEntries(['TMPDIR', 'TMP', 'TEMP'] .map(name => [name, process.env[name]])); for (const name of Object.keys(savedTempEnvironment)) process.env[name] = cacheRoot; @@ -56,7 +62,7 @@ function runScript(input, envOverrides = {}) { encoding: 'utf8', input: inputStr, timeout: 10000, - env: { ...process.env, ...envOverrides, TMPDIR: cacheRoot, TMP: cacheRoot, TEMP: cacheRoot }, + env: { ...process.env, ...ISOLATED_HOME_ENV, ...envOverrides, TMPDIR: cacheRoot, TMP: cacheRoot, TEMP: cacheRoot }, }); return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; } diff --git a/tests/hooks/evaluate-session.test.js b/tests/hooks/evaluate-session.test.js index f49bb6a4b..45a69c0b8 100644 --- a/tests/hooks/evaluate-session.test.js +++ b/tests/hooks/evaluate-session.test.js @@ -15,6 +15,16 @@ const { spawnSync } = require('child_process'); const evaluateScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'evaluate-session.js'); +// evaluate-session.js creates its learned-skills directory under the home +// directory, so every run gets an empty home instead of the developer's real one. +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-session-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); + +function isolatedEnv(extra = {}) { + return { ...process.env, ...ISOLATED_HOME_ENV, ...extra }; +} + // Test helpers function test(name, fn) { try { @@ -62,6 +72,7 @@ function runEvaluate(stdinJson) { encoding: 'utf8', input: JSON.stringify(stdinJson), timeout: 10000, + env: isolatedEnv(), }); return { code: result.status || 0, @@ -135,6 +146,7 @@ function runTests() { encoding: 'utf8', input: 'not valid json at all', timeout: 10000, + env: isolatedEnv(), }); assert.strictEqual(result.status, 0, 'Should exit 0 even on bad stdin'); })) passed++; else failed++; @@ -253,6 +265,7 @@ function runTests() { encoding: 'utf8', input: '', timeout: 10000, + env: isolatedEnv(), }); // Empty stdin → JSON.parse('') throws → fallback to env var (unset) → null → exit 0 assert.strictEqual(result.status, 0, 'Should exit 0 on empty stdin'); @@ -269,7 +282,7 @@ function runTests() { encoding: 'utf8', input: 'invalid json {{{', timeout: 10000, - env: { ...process.env, CLAUDE_TRANSCRIPT_PATH: transcript } + env: isolatedEnv({ CLAUDE_TRANSCRIPT_PATH: transcript }) }); assert.strictEqual(result.status, 0, 'Should exit 0'); @@ -396,8 +409,8 @@ function runTests() { 'Should NOT contain literal ~ in output (should be expanded)'); assert.ok(result.stderr.includes('custom-learned-skills-dir'), `Should reference the custom learned skills dir. Got: ${result.stderr}`); - // The ~ should have been replaced with os.homedir() - assert.ok(result.stderr.includes(os.homedir()), + // The ~ should have been replaced with the child's home directory + assert.ok(result.stderr.includes(ISOLATED_HOME), `Should contain expanded home directory. Got: ${result.stderr}`); cleanupTestDir(testDir); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index e31cdf449..db469d34d 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -177,12 +177,19 @@ async function asyncTest(name, fn) { } } +// Hook scripts write session files, compaction logs, learned skills and +// homunculus data under the home directory, so script runs get an empty home +// instead of the developer's real one unless a test passes its own HOME. +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); + // Run a script and capture output function runScript(scriptPath, input = '', env = {}, cwd = process.cwd()) { return new Promise((resolve, reject) => { const proc = spawn('node', [scriptPath], { cwd, - env: { ...process.env, ...env }, + env: { ...process.env, ...ISOLATED_HOME_ENV, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -209,7 +216,7 @@ function runShellScript(scriptPath, args = [], input = '', env = {}, cwd = proce return new Promise((resolve, reject) => { const proc = spawn('bash', [toBashPath(scriptPath), ...args], { cwd, - env: { ...process.env, ...env }, + env: { ...process.env, ...ISOLATED_HOME_ENV, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -1515,7 +1522,7 @@ async function runTests() { if ( await asyncTest('creates compaction log', async () => { await runScript(path.join(scriptsDir, 'pre-compact.js')); - const logFile = path.join(getCanonicalSessionsDir(os.homedir()), 'compaction-log.txt'); + const logFile = path.join(getCanonicalSessionsDir(ISOLATED_HOME), 'compaction-log.txt'); assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); }) ) @@ -3700,7 +3707,7 @@ async function runTests() { const shell = process.platform === 'win32' ? 'bash' : 'bash'; const proc = spawn(shell, ['-lc', shellCommand], { - env: process.env, + env: { ...process.env, ...ISOLATED_HOME_ENV }, stdio: ['ignore', 'pipe', 'pipe'] }); diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index bd541c454..d00ea4295 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -47,13 +47,20 @@ function withTempHome(homeDir) { }; } +// session-activity-tracker.js appends to ~/.claude/metrics/tool-usage.jsonl, so +// runs get an empty home instead of the developer's real one unless a test +// passes withTempHome(). +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); + function runScript(input, envOverrides = {}, options = {}) { const inputStr = typeof input === 'string' ? input : JSON.stringify(input); const result = spawnSync('node', [script], { encoding: 'utf8', input: inputStr, timeout: 10000, - env: { ...process.env, ...envOverrides }, + env: { ...process.env, ...ISOLATED_HOME_ENV, ...envOverrides }, cwd: options.cwd, }); return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index e834eb222..785f6a884 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -31,6 +31,9 @@ const SUBPROCESS_TIMEOUT_MS = process.platform === 'darwin' && process.env.CI == const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stop-stdout-')); // non-git cwd const dataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stop-data-')); +// Stop hooks also write learned skills under the home directory; give them +// an empty one instead of the developer's real one. +const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stop-home-')); function test(name, fn) { try { @@ -58,6 +61,8 @@ function stopPayload(messageCharacters, character = 'm') { function hookEnv() { const env = { ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, ECC_HOOK_PROFILE: 'standard', ECC_AGENT_DATA_HOME: dataHome, CLAUDE_SESSION_ID: `stop-stdout-test-${process.pid}` @@ -448,6 +453,7 @@ else failed++; try { fs.rmSync(workDir, { recursive: true, force: true }); fs.rmSync(dataHome, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 96ad9b4d1..55722eca3 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -41,6 +41,13 @@ async function asyncTest(name, fn) { } } +// Hook scripts write session data, learned skills and homunculus observations +// under the home directory, so runs get an empty home instead of the developer's +// real one unless a test passes its own HOME. +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-integration-home-')); +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME }; +process.on('exit', () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })); + /** * Run a hook script with simulated Claude Code input * @param {string} scriptPath - Path to the hook script @@ -51,7 +58,7 @@ async function asyncTest(name, fn) { function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) { return new Promise((resolve, reject) => { const proc = spawn('node', [scriptPath], { - env: { ...process.env, ...env }, + env: { ...process.env, ...ISOLATED_HOME_ENV, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -110,7 +117,7 @@ function getSessionStartPayload(stdout) { function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) { return new Promise((resolve, reject) => { const isWindows = process.platform === 'win32'; - const mergedEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env }; + const mergedEnv = { ...process.env, ...ISOLATED_HOME_ENV, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env }; if (Array.isArray(command)) { const [program, ...args] = command; const proc = spawn(program, args, { env: mergedEnv, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -297,6 +304,7 @@ async function runTests() { if (await asyncTest('hooks handle malformed JSON input', async () => { const proc = spawn('node', [path.join(scriptsDir, 'session-start.js')], { + env: { ...process.env, ...ISOLATED_HOME_ENV }, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -887,6 +895,7 @@ async function runTests() { // session-end parses stdin JSON. If input is > 1MB and truncated mid-JSON, // JSON.parse should fail and fall back to env var const proc = spawn('node', [path.join(scriptsDir, 'session-end.js')], { + env: { ...process.env, ...ISOLATED_HOME_ENV }, stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/tests/lib/package-manager.test.js b/tests/lib/package-manager.test.js index 0a00caabd..8d6932eba 100644 --- a/tests/lib/package-manager.test.js +++ b/tests/lib/package-manager.test.js @@ -767,8 +767,8 @@ function runTests() { // setPreferredPackageManager success console.log('\nsetPreferredPackageManager (success):'); - if (test('successfully saves preferred package manager', () => { - // This writes to ~/.claude/package-manager.json — read original to restore + if (test('successfully saves preferred package manager', () => withIsolatedHome(() => { + // Writes ~/.claude/package-manager.json under the isolated home, not the real one const utils = require('../../scripts/lib/utils'); const configPath = path.join(utils.getClaudeDir(), 'package-manager.json'); const original = utils.readFile(configPath); @@ -791,7 +791,7 @@ function runTests() { } } } - })) passed++; + }))) passed++; else failed++; // getCommandPattern completeness diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js index d03032baa..b900fc7d6 100644 --- a/tests/pi/pi-extension-adapter.test.js +++ b/tests/pi/pi-extension-adapter.test.js @@ -73,6 +73,13 @@ function stripComments(source) { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "") } +// Hook scripts write homunculus observations under the home directory, so +// runner calls get an empty home instead of the developer's real one unless a +// test passes its own HOME. +const ISOLATED_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-pi-home-")) +const ISOLATED_HOME_ENV = { HOME: ISOLATED_HOME, USERPROFILE: ISOLATED_HOME } +process.on("exit", () => fs.rmSync(ISOLATED_HOME, { recursive: true, force: true })) + /** * Invokes ECC's hook runner like the adapter (`runEccHook` in * .pi/extensions/index.ts): it uses the test host's Node executable with the @@ -85,7 +92,7 @@ function runHookRunner(eccRoot, hookId, relScript, profiles, payload, extraEnv, encoding: "utf8", cwd: cwd || eccRoot, timeout: 30000, - env: { ...process.env, CLAUDE_PLUGIN_ROOT: eccRoot, ECC_PLUGIN_ROOT: eccRoot, ...extraEnv }, + env: { ...process.env, ...ISOLATED_HOME_ENV, CLAUDE_PLUGIN_ROOT: eccRoot, ECC_PLUGIN_ROOT: eccRoot, ...extraEnv }, }) }