diff --git a/src/utils/__tests__/upstream-install-corrupt.test.ts b/src/utils/__tests__/upstream-install-corrupt.test.ts new file mode 100644 index 000000000..db5f8597e --- /dev/null +++ b/src/utils/__tests__/upstream-install-corrupt.test.ts @@ -0,0 +1,79 @@ +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { installStatusLine } from '../claude-settings'; + +// Upstream 475e401 (#627): a settings file that cannot be parsed (trailing +// comma, EACCES, a read racing Claude Code's own write) used to be replaced +// with a file holding only statusLine — permissions, hooks and env lost. +// The install must abort instead. + +describe('upstream 475e401: unreadable Claude settings are not overwritten', () => { + let savedEnv: Record = {}; + let workDir = ''; + + beforeEach(() => { + savedEnv = { + HOME: process.env.HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, + GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM + }; + workDir = mkdtempSync(join(tmpdir(), 'ccsl-install-corrupt-')); + process.env.HOME = workDir; + process.env.CLAUDE_CONFIG_DIR = workDir; + process.env.GIT_CONFIG_GLOBAL = '/dev/null'; + process.env.GIT_CONFIG_SYSTEM = '/dev/null'; + }); + + afterEach(() => { + process.env.HOME = savedEnv.HOME; + process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR; + process.env.GIT_CONFIG_GLOBAL = savedEnv.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_SYSTEM = savedEnv.GIT_CONFIG_SYSTEM; + rmSync(workDir, { recursive: true, force: true }); + }); + + it('aborts without touching settings that cannot be parsed', async () => { + const settingsPath = join(workDir, 'settings.json'); + writeFileSync(settingsPath, '{ invalid json'); + + await expect(installStatusLine({ commandMode: 'auto-npx' })).rejects.toThrow(/left unchanged/); + + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json'); + }); + + it('keeps an unreadable file when the install is asked to keep existing', async () => { + const settingsPath = join(workDir, 'settings.json'); + writeFileSync(settingsPath, '{ invalid json'); + + const result = await installStatusLine({ commandMode: 'auto-npx', keepExisting: true }); + + expect(result.statusLineWritten).toBe(false); + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json'); + }); + + it('keeps the existing status line when asked to, even without keep-unreadable errors', async () => { + const settingsPath = join(workDir, 'settings.json'); + const original = JSON.stringify({ statusLine: { type: 'command', command: 'old' }, env: { X: '1' } }); + writeFileSync(settingsPath, original); + + const result = await installStatusLine({ commandMode: 'auto-npx', keepExisting: true }); + + expect(result.statusLineWritten).toBe(false); + expect(readFileSync(settingsPath, 'utf-8')).toBe(original); + }); +}); diff --git a/src/utils/__tests__/upstream-no-proxy.test.ts b/src/utils/__tests__/upstream-no-proxy.test.ts new file mode 100644 index 000000000..79d2a66ce --- /dev/null +++ b/src/utils/__tests__/upstream-no-proxy.test.ts @@ -0,0 +1,63 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + cleanupProbe, + createProbe, + runUsageProbe, + type ProbeHandle +} from './usage-probe'; + +// Upstream e8ef3cb: the usage request carries the account's OAuth token, and +// a user who keeps api.anthropic.com off a proxy (NO_PROXY) still had it sent +// through HTTPS_PROXY — for example through a TLS-inspecting corporate one. + +describe('upstream e8ef3cb: NO_PROXY keeps the usage request off the proxy', () => { + let probe: ProbeHandle; + + beforeEach(() => { + probe = createProbe(); + }); + + afterEach(() => { + cleanupProbe(probe); + }); + + it('does not configure a proxy agent for a NO_PROXY-excluded host', () => { + const result = runUsageProbe(probe, { + observe: true, + httpsProxy: 'http://127.0.0.1:9', + noProxy: 'api.anthropic.com' + }); + + expect(result.calls).toBeGreaterThan(0); + expect(result.agentPresent.every(present => !present)).toBe(true); + }); + + it('still configures the proxy agent when NO_PROXY does not exclude the host', () => { + const result = runUsageProbe(probe, { + observe: true, + httpsProxy: 'http://127.0.0.1:9', + noProxy: 'example.com' + }); + + expect(result.calls).toBeGreaterThan(0); + expect(result.agentPresent.some(present => present)).toBe(true); + }); + + it('honors the lowercase no_proxy spelling', () => { + const result = runUsageProbe(probe, { + observe: true, + httpsProxy: 'http://127.0.0.1:9', + lowercaseNoProxy: 'anthropic.com' + }); + + expect(result.calls).toBeGreaterThan(0); + expect(result.agentPresent.every(present => !present)).toBe(true); + }); +}); diff --git a/src/utils/__tests__/upstream-powerline-font-temp.test.ts b/src/utils/__tests__/upstream-powerline-font-temp.test.ts new file mode 100644 index 000000000..3272aeb45 --- /dev/null +++ b/src/utils/__tests__/upstream-powerline-font-temp.test.ts @@ -0,0 +1,79 @@ +import { + execFileSync, + execSync +} from 'node:child_process'; +import { + mkdirSync, + rmSync, + statSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename } from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { installPowerlineFonts } from '../powerline'; + +// Upstream 6aefa6b: the font installer cloned into +// os.tmpdir()/ccstatusline-powerline-fonts- — a name another +// account on a shared /tmp could create first (world-writable) and then +// swap install.sh before it runs. The folder now comes from mkdtemp: +// random name, created 0700 for this account. + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn() +})); + +const mockedExecSync = execSync as unknown as { mockImplementation: (impl: (command: string) => string) => void }; +const mockedExecFileSync = execFileSync as unknown as { mockImplementation: (impl: (file: string, args: string[]) => string) => void }; + +describe('upstream 6aefa6b: powerline fonts download into a private temp folder', () => { + // Observation and cleanup track only the clone target the mock extracts + // from the installer's own execFileSync call: a shared tmpdir can hold + // leftovers from killed runs (or a real font install) that would poison a + // prefix scan and delete other accounts' directories. + const observed: { name: string; mode: number }[] = []; + const cloneTargets: string[] = []; + + beforeEach(() => { + observed.length = 0; + cloneTargets.length = 0; + mockedExecSync.mockImplementation(() => ''); + // git clone creates the target folder; the mock does the same so the + // folder's name and permissions can be observed mid-install + mockedExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'git' && args[0] === 'clone') { + const target = args.find( + argument => argument.startsWith(tmpdir()) && argument.includes('ccstatusline-powerline-fonts-') + ); + if (target) { + mkdirSync(target, { recursive: true }); + cloneTargets.push(target); + observed.push({ name: basename(target), mode: statSync(target).mode & 0o777 }); + } + } + return ''; + }); + }); + + afterEach(() => { + for (const target of cloneTargets) { + rmSync(target, { recursive: true, force: true }); + } + }); + + it('downloads into a randomly named folder readable only by this account', async () => { + await installPowerlineFonts(); + + expect(observed).toHaveLength(1); + expect(observed[0]?.name).not.toMatch(/^ccstatusline-powerline-fonts-\d+$/); + expect(observed[0]?.mode).toBe(0o700); + }); +}); diff --git a/src/utils/__tests__/upstream-settings-permissions.test.ts b/src/utils/__tests__/upstream-settings-permissions.test.ts new file mode 100644 index 000000000..29d546148 --- /dev/null +++ b/src/utils/__tests__/upstream-settings-permissions.test.ts @@ -0,0 +1,102 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { installStatusLine } from '../claude-settings'; +import { + initConfigPath, + saveSettings +} from '../config'; + +// Upstream 4c62e31: saving rewrote a private settings file through a temp +// file (which took the default permissions) and wrote .orig/.bak backups +// world-readable — both leak the same secrets (env, apiKeyHelper) the +// private original held. + +describe('upstream 4c62e31: private settings stay private on save', () => { + let savedEnv: Record = {}; + let workDir = ''; + + beforeEach(() => { + savedEnv = { + HOME: process.env.HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, + GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM + }; + workDir = mkdtempSync(join(tmpdir(), 'ccsl-settings-perms-')); + process.env.HOME = workDir; + process.env.CLAUDE_CONFIG_DIR = workDir; + process.env.GIT_CONFIG_GLOBAL = '/dev/null'; + process.env.GIT_CONFIG_SYSTEM = '/dev/null'; + }); + + afterEach(() => { + process.env.HOME = savedEnv.HOME; + process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR; + process.env.GIT_CONFIG_GLOBAL = savedEnv.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_SYSTEM = savedEnv.GIT_CONFIG_SYSTEM; + initConfigPath(); + rmSync(workDir, { recursive: true, force: true }); + }); + + it('keeps 0600 on ccstatusline settings across a save', async () => { + const configPath = join(workDir, 'settings.json'); + writeFileSync(configPath, '{}'); + chmodSync(configPath, 0o600); + initConfigPath(configPath); + + await saveSettings(DEFAULT_SETTINGS); + + expect(statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it('gives the .orig backup of private Claude settings their 0600', async () => { + const settingsPath = join(workDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ env: { API_KEY: 'secret' } })); + chmodSync(settingsPath, 0o600); + + await installStatusLine({ commandMode: 'auto-npx' }); + + const origPath = `${settingsPath}.orig`; + expect(existsSync(origPath)).toBe(true); + expect(statSync(origPath).mode & 0o777).toBe(0o600); + expect(existsSync(`${settingsPath}.bak`)).toBe(true); + }); + + it('recreates an existing backup so it does not keep loose permissions', async () => { + const settingsPath = join(workDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ env: { API_KEY: 'secret' } })); + chmodSync(settingsPath, 0o600); + writeFileSync(`${settingsPath}.orig`, '{ "stale": true }'); + chmodSync(`${settingsPath}.orig`, 0o777); + + await installStatusLine({ commandMode: 'auto-npx' }); + + expect(statSync(`${settingsPath}.orig`).mode & 0o777).toBe(0o600); + }); + + it('re-installs over an existing status line', async () => { + const settingsPath = join(workDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ statusLine: { type: 'command', command: 'old' } })); + + const result = await installStatusLine({ commandMode: 'auto-npx' }); + + expect(result.statusLineWritten).toBe(true); + }); +}); diff --git a/src/utils/__tests__/upstream-stalled-proxy.test.ts b/src/utils/__tests__/upstream-stalled-proxy.test.ts new file mode 100644 index 000000000..a0f93eed1 --- /dev/null +++ b/src/utils/__tests__/upstream-stalled-proxy.test.ts @@ -0,0 +1,68 @@ +import { createServer } from 'node:net'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + cleanupProbe, + createProbe, + runUsageProbe, + type ProbeHandle +} from './usage-probe'; + +// Upstream 610dcc9: the usage request's `timeout` only covers an idle socket, +// and behind HTTPS_PROXY the request has no socket until the proxy answers +// CONNECT — a proxy that accepts and never answers held the render forever, +// and its agent kept the process alive afterwards. The fetch now has an +// absolute deadline that also closes the proxy agent. + +const PROBE_BUDGET_MS = 20000; + +describe('upstream 610dcc9: a stalled proxy cannot hold the render forever', () => { + let probe: ProbeHandle; + let stalledProxy: ReturnType; + + beforeEach(() => { + probe = createProbe(); + // accepts the TCP connection and never responds: the CONNECT the proxy + // agent waits for never arrives + stalledProxy = createServer(() => undefined); + stalledProxy.listen(0, '127.0.0.1'); + }); + + afterEach(() => { + stalledProxy.close(); + cleanupProbe(probe); + }); + + it('finishes the fetch against a proxy that never answers', async () => { + // the fixed deadline itself is 5s; allow the probe process to boot and + // the deadline to fire with margin, but far under the hang of the bug + await new Promise((resolve) => { + if (stalledProxy.listening) { + resolve(); + return; + } + stalledProxy.once('listening', resolve); + }); + + const address = stalledProxy.address(); + if (address === null || typeof address === 'string') { + throw new Error('stalled proxy did not start'); + } + + const started = Date.now(); + const result = runUsageProbe(probe, { + httpsProxy: `http://127.0.0.1:${address.port}`, + timeoutMs: PROBE_BUDGET_MS + }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(PROBE_BUDGET_MS); + expect(result.resultKind).toBe('api-error'); + }, 25000); +}); diff --git a/src/utils/__tests__/upstream-usage-lock.test.ts b/src/utils/__tests__/upstream-usage-lock.test.ts new file mode 100644 index 000000000..76015cabe --- /dev/null +++ b/src/utils/__tests__/upstream-usage-lock.test.ts @@ -0,0 +1,57 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + cleanupProbe, + createProbe, + runUsageProbe, + type ProbeHandle +} from './usage-probe'; + +// Upstream 0bcc7eb (#624): a successful fetch removed usage.lock +// unconditionally. When two renders fetch concurrently and one receives a 429, +// the other's later success deleted the rate-limit lock, and the next render +// fetched inside the server's backoff window. The clear is now conditional on +// the lock still holding this render's own in-flight record. + +const SUCCESS_BODY = JSON.stringify({ + five_hour: { + utilization: 42, + resets_at: '2030-01-01T00:00:00.000Z' + }, + seven_day: { + utilization: 17, + resets_at: '2030-01-07T00:00:00.000Z' + } +}); + +describe('upstream 0bcc7eb: only the in-flight lock this render wrote is cleared', () => { + let probe: ProbeHandle; + + beforeEach(() => { + probe = createProbe(); + }); + + afterEach(() => { + cleanupProbe(probe); + }); + + it('does not clear a rate-limit lock a concurrent render wrote', () => { + const rateLimitLock = JSON.stringify({ blockedUntil: Math.floor(Date.now() / 1000) + 60, error: 'rate-limited' }); + + const result = runUsageProbe(probe, { + observe: true, + httpsProxy: 'http://127.0.0.1:9', + responseBody: SUCCESS_BODY, + lockDuringRequest: rateLimitLock + }); + + expect(result.lockExistsAfter).toBe(true); + expect(result.lockContentsAfter).toBe(rateLimitLock); + }); +}); diff --git a/src/utils/__tests__/usage-probe.ts b/src/utils/__tests__/usage-probe.ts new file mode 100644 index 000000000..1984ef6bf --- /dev/null +++ b/src/utils/__tests__/usage-probe.ts @@ -0,0 +1,230 @@ +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +interface BunSpawnResult { + exitCode: number | null; + stdout: Buffer; + stderr: Buffer; +} + +interface BunRuntime { spawnSync(command: string[], options: { env: NodeJS.ProcessEnv; stdout: 'pipe'; stderr: 'pipe'; timeout?: number }): BunSpawnResult } + +// Other suites mock node:child_process and that mock leaks across files under +// the bun runner, so subprocesses are spawned through Bun.spawnSync there; +// vitest/Node keeps test files isolated and takes the real spawnSync. +function runProcess(command: string, args: string[], options: { env: NodeJS.ProcessEnv; timeout?: number }): string { + const bun = (globalThis as { Bun?: BunRuntime }).Bun; + + if (bun) { + const result = bun.spawnSync([command, ...args], { + env: options.env, + stdout: 'pipe', + stderr: 'pipe', + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}) + }); + if (result.exitCode !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr.toString().slice(0, 400)}`); + } + return result.stdout.toString(); + } + + const result = spawnSync(command, args, { + env: options.env, + encoding: 'utf8', + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}) + }); + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr.slice(0, 400)}`); + } + return result.stdout; +} + +// Shared subprocess harness for usage-fetch tests. The probe source is bundled +// with bun (extensionless imports resolve) and executed with node, whose +// os.homedir() honors HOME: with HOME pointed at a temp directory, the usage +// cache and lock files the probe reads and writes live in that temp home and +// never touch the live ones. In-process isolation is impossible here (bun's +// homedir() ignores HOME). The https.request spy is installed by a --require +// patch that runs before the bundle: a CJS-require mutation alone would not +// reach the bundle's ESM import bindings. + +export interface ProbeResult { + calls: number; + agentPresent: boolean[]; + lockExistsAfter: boolean; + lockContentsAfter: string | null; + resultKind: string | null; +} + +export interface ProbeHandle { + root: string; + bin: string; + claudeConfigDir: string; + probeBundle: string; + patchPath: string; +} + +export function createProbe(): ProbeHandle { + const root = mkdtempSync(path.join(os.tmpdir(), 'ccsl-usage-probe-')); + const home = path.join(root, 'home'); + const bin = path.join(root, 'bin'); + const claudeConfigDir = path.join(root, 'claude-config'); + const probeEntry = path.join(root, 'probe-usage.mjs'); + const probeBundle = path.join(root, 'probe-usage.bundle.mjs'); + const patchPath = path.join(root, 'patch.cjs'); + + mkdirSync(home, { recursive: true }); + mkdirSync(bin, { recursive: true }); + mkdirSync(claudeConfigDir, { recursive: true }); + writeFileSync(path.join(bin, 'security'), '#!/bin/sh\n/bin/cat "$CLAUDE_CONFIG_DIR/.credentials.json"\n'); + chmodSync(path.join(bin, 'security'), 0o755); + writeFileSync( + path.join(claudeConfigDir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }) + ); + + const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url)); + writeFileSync( + probeEntry, + ` +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as usageModule from ${JSON.stringify(usageModulePath)}; + +const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock'); +// createUsageMemoryCache exists only on the request-scoped-cache code; older +// trees take the plain requiredFields call +const probeOptions = usageModule.createUsageMemoryCache + ? { requiredFields: [], cache: usageModule.createUsageMemoryCache() } + : { requiredFields: [] }; +const first = await usageModule.fetchUsageData(probeOptions); +process.stdout.write(JSON.stringify({ + ...(globalThis.__probe ?? { calls: 0, agentPresent: [] }), + lockExistsAfter: fs.existsSync(lockFile), + lockContentsAfter: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null, + resultKind: first.error ?? null +})); +` + ); + writeFileSync( + patchPath, + ` +if (process.env.USAGE_PROBE_OBSERVE === '1') { + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const { EventEmitter } = require('node:events'); + const https = require('node:https'); + globalThis.__probe = { calls: 0, agentPresent: [] }; + const responseBody = process.env.USAGE_PROBE_RESPONSE_BODY; + https.request = function (...args) { + globalThis.__probe.calls += 1; + const callback = args.find(v => typeof v === 'function'); + const options = args.find(v => v !== null && typeof v === 'object' && !Buffer.isBuffer(v)) || {}; + globalThis.__probe.agentPresent.push(Boolean(options.agent)); + const request = new EventEmitter(); + request.end = () => { + const lockDuring = process.env.TEST_LOCK_DURING_REQUEST; + if (lockDuring !== undefined) { + const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock'); + fs.writeFileSync(lockFile, lockDuring); + } + if (responseBody === undefined) { + setTimeout(() => request.emit('error', new Error('probe: no network')), 10); + return request; + } + if (callback) { + callback({ + statusCode: 200, + headers: {}, + setEncoding() {}, + on(event, handler) { + if (event === 'data') { + handler(responseBody); + } + if (event === 'end') { + handler(); + } + return this; + } + }); + } + return request; + }; + request.destroy = () => request; + request.setTimeout = () => request; + request.on = EventEmitter.prototype.on; + return request; + }; +} +` + ); + + runProcess('bun', ['build', probeEntry, '--outfile', probeBundle, '--target=node'], { env: { ...process.env } }); + + return { root, bin, claudeConfigDir, probeBundle, patchPath }; +} + +export interface RunProbeOptions { + observe?: boolean; + httpsProxy?: string; + lockDuringRequest?: string; + noProxy?: string; + lowercaseNoProxy?: string; + /** Serve a 200 response with this body instead of a request error. */ + responseBody?: string; + /** Kill the probe process after this many ms (a hung render fails the test). */ + timeoutMs?: number; +} + +export function runUsageProbe(handle: ProbeHandle, options: RunProbeOptions = {}): ProbeResult { + const env: NodeJS.ProcessEnv = { + HOME: path.join(handle.root, 'home'), + USERPROFILE: path.join(handle.root, 'home'), + CLAUDE_CONFIG_DIR: handle.claudeConfigDir, + PATH: `${handle.bin}${path.delimiter}${process.env.PATH ?? ''}` + }; + + if (options.observe === true) { + env.USAGE_PROBE_OBSERVE = '1'; + } + if (options.httpsProxy !== undefined) { + env.HTTPS_PROXY = options.httpsProxy; + } + if (options.noProxy !== undefined) { + env.NO_PROXY = options.noProxy; + } + if (options.lowercaseNoProxy !== undefined) { + env.no_proxy = options.lowercaseNoProxy; + } + if (options.lockDuringRequest !== undefined) { + env.TEST_LOCK_DURING_REQUEST = options.lockDuringRequest; + } + if (options.responseBody !== undefined) { + env.USAGE_PROBE_RESPONSE_BODY = options.responseBody; + } + + const output = runProcess('node', ['--require', handle.patchPath, handle.probeBundle], { + env, + timeout: options.timeoutMs + }); + + return JSON.parse(output) as ProbeResult; +} + +export function cleanupProbe(handle: ProbeHandle): void { + rmSync(handle.root, { recursive: true, force: true }); +} diff --git a/vitest.stryker.config.ts b/vitest.stryker.config.ts index d08c14000..157fca377 100644 --- a/vitest.stryker.config.ts +++ b/vitest.stryker.config.ts @@ -8,6 +8,7 @@ import { defineConfig } from 'vitest/config'; // mutation scope is narrowed. Regular runs use vitest.config.ts. export default defineConfig({ test: { + globalSetup: ['./vitest.stryker.global-setup.ts'], include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], exclude: [ '**/node_modules/**', diff --git a/vitest.stryker.global-setup.ts b/vitest.stryker.global-setup.ts new file mode 100644 index 000000000..072f522a7 --- /dev/null +++ b/vitest.stryker.global-setup.ts @@ -0,0 +1,20 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +// Mutation runs execute the whole suite against mutated code, and a mutant +// can turn an "isolated" test into one that reads or writes the real user +// home: a config parse that now fails materialises defaults, an install +// that now succeeds writes settings. Redirect the home directories once per +// run — every worker and test file inherits this environment, so no mutant +// can reach real files. +export default function setup(): () => void { + const sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-mutation-home-')); + process.env.HOME = sandboxHome; + process.env.CLAUDE_CONFIG_DIR = path.join(sandboxHome, '.claude'); + process.env.GIT_CONFIG_GLOBAL = '/dev/null'; + process.env.GIT_CONFIG_SYSTEM = '/dev/null'; + return (): void => { + fs.rmSync(sandboxHome, { recursive: true, force: true }); + }; +}