From f12eb1c9e8ff37fe41663a67b73ebd38ec1e008d Mon Sep 17 00:00:00 2001 From: JATMN Date: Sat, 16 May 2026 00:18:42 -0700 Subject: [PATCH] Harden test isolation for smoke stability (#1192) * Fix flaky smoke build checks Replace feature-flag build preprocessing with a Bun onLoad transform so smoke/build no longer rewrites tracked src files while tests may be reading them. Keep telemetry stubs ahead of the feature transform in both CLI and SDK builds, preserve non-empty text token counts in hybrid context splitting, and make the corrupted Orama stress test assert against the actual project directory used by the test. Verified with bun run smoke, bun test src/utils/hybridContextStrategy.test.ts, bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 3, and bun test --max-concurrency=1. * Harden KnowledgeGraph smoke stress isolation Give each KnowledgeGraph stress test its own temporary config directory and remove it during teardown so Orama, SQLite, and corrupted-file state cannot bleed between stress cases or later PR test runs. Reviewed at least 20 open PRs and found the recurring smoke-and-tests failure cluster is the full unit suite, especially KnowledgeGraph corrupted Orama recovery. Verified with bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 5, bun test --max-concurrency=1, and bun run smoke. * Harden smoke test isolation Audit and harden broad smoke-adjacent test suites for process-global leaks, including env/config restoration, shared registry/module mock cleanup, fetch/axios/mock restoration, and global MACRO/platform/sandbox mutations. Replace fragile render sleeps in interactive tests with output-driven waits, and isolate provider/model/profile tests behind the shared mutation lock so unrelated PRs do not inherit stale process state. Make SQLite knowledge graph cleanup clear closed on-disk databases before best-effort file cleanup, with coverage for the stale database reset path. Verified: bun run smoke; bun test --max-concurrency=1; python -m pytest -q python/tests; bun run security:pr-scan -- --base origin/main; bun run test:provider; npm run test:provider-recommendation. * Harden test isolation across smoke suite Guard process-global test mutations with the shared mutation lock across env, module mock, config cache, and storage tests.\n\nDeep-copy global config snapshots, restore transient globals precisely, and make plugin/LSP mocks expose compatible export surfaces so concurrent test loading does not poison unrelated suites.\n\nReplace fixed SDK cleanup sleeps with call polling to remove timing sensitivity.\n\nVerification:\n- bun test --max-concurrency=1\n- bun run smoke\n- python -m pytest -q python/tests\n- bun run test:provider\n- npm run test:provider-recommendation\n- bun run security:pr-scan -- --base origin/main\n- git diff --check * Close remaining test global-state leaks Guard remaining cache, plugin, console, and VS Code module-mock tests with the shared mutation lock.\n\nThis follow-up audit covers non-env process-global state that can leak across test files: tool schema cache, cache stats tracker state, plugin loader caches, console.error replacement, and VS Code mock.module usage.\n\nVerification:\n- leak-surface scans for env/global/mock.module/cache outliers\n- duplicate top-level mock collision cluster\n- affected tests cluster\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check * Guard remaining mock restore cleanup Lock tests that call bun:test mock.restore without installing module mocks themselves.\n\nmock.restore is process-global, so these cleanup hooks can still tear down another test file's active module mocks when files run concurrently.\n\nVerification:\n- expanded leak scans for env, globals, module mocks, mock.restore, timers, argv, and caches\n- bun test src/components/useCodexOAuthFlow.test.tsx src/services/github/deviceFlow.test.ts\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check * Harden test isolation for smoke stability Serialize tests that mutate process-global state behind the shared mutation lock, including process.env, transient globals, global config/cache state, storage mocks, and Bun module mocks. Add isolated env mutex instances for SDK mutex tests so timeout coverage no longer manipulates the live process-global mutex. Move top-level mock.module setup behind lock acquisition and restore mocks before releasing locks to prevent cross-file leakage under parallel smoke runs. Verified with: bun test --max-concurrency=1; bun test; bun run smoke. --- scripts/build.ts | 77 ++++------ scripts/no-telemetry-growthbook-stub.test.ts | 39 ++++- src/commands/cacheStats/cacheStats.test.ts | 18 ++- src/commands/init.test.ts | 26 +++- src/commands/knowledge/knowledge.test.ts | 41 ++++- src/commands/model/model.test.tsx | 78 ++++++---- src/commands/provider/provider.test.tsx | 65 ++++---- src/components/ConsoleOAuthFlow.test.tsx | 33 +++- src/components/Feedback.test.ts | 25 ++- .../PromptInputQueuedCommands.test.tsx | 13 +- src/components/ProviderManager.test.tsx | 28 +++- src/components/StartupScreen.test.ts | 18 ++- src/components/TextInput.test.tsx | 40 +++-- src/components/ThemePicker.test.tsx | 16 +- .../design-system/ThemeProvider.test.tsx | 18 ++- src/components/useCodexOAuthFlow.test.tsx | 16 +- src/constants/promptIdentity.test.ts | 94 +++++++++--- src/cost-tracker.cacheIntegration.test.ts | 17 ++- src/entrypoints/mcp.test.ts | 16 +- src/entrypoints/sdk/shared.ts | 142 +++++++++++------- src/hooks/useApiKeyVerification.test.tsx | 16 +- src/ink/termio/osc.test.ts | 28 +++- src/integrations/discoveryService.test.ts | 4 +- src/integrations/registry.test.ts | 19 ++- src/services/api/cacheStatsTracker.test.ts | 18 ++- src/services/api/compressToolHistory.test.ts | 17 ++- .../api/openaiShim.compression.test.ts | 23 +-- src/services/api/openaiShim.test.ts | 4 +- ...iderConfig.runtimeCodexCredentials.test.ts | 16 +- src/services/compact/autoCompact.test.ts | 51 ++++--- src/services/github/deviceFlow.test.ts | 16 +- src/services/mcp/officialRegistry.test.ts | 17 ++- .../auth-code-listener.analytics.test.ts | 16 +- src/services/tips/sponsoredTips.test.ts | 16 +- src/services/tips/tipScheduler.test.ts | 16 +- src/tools.lsp.test.ts | 51 ++++++- src/tools/BashTool/bashPermissions.test.ts | 35 ++++- src/tools/BashTool/shouldUseSandbox.test.ts | 22 ++- .../WebFetchTool/applyPromptFallback.test.ts | 11 +- src/tools/WebFetchTool/domainCheck.test.ts | 17 ++- .../WebSearchTool/providers/brave.test.ts | 36 +++-- .../WebSearchTool/providers/custom.test.ts | 21 ++- src/tools/WebSearchTool/providers/exa.test.ts | 36 +++-- .../WebSearchTool/providers/firecrawl.test.ts | 20 ++- .../WebSearchTool/providers/index.test.ts | 41 +++-- src/utils/apiPreconnect.test.ts | 23 ++- src/utils/buildConfig.test.ts | 29 +++- src/utils/codexCredentials.test.ts | 36 +++-- src/utils/conversationRecovery.hooks.test.ts | 30 +++- src/utils/conversationRecovery.test.ts | 37 +++-- src/utils/effort.codex.test.ts | 16 +- src/utils/execFileNoThrow.test.ts | 16 +- src/utils/fastMode.test.ts | 26 +++- src/utils/file.test.ts | 18 ++- src/utils/geminiAuth.test.ts | 38 +++-- src/utils/geminiCredentials.test.ts | 19 ++- .../githubModelsCredentials.hydrate.test.ts | 26 +++- .../githubModelsCredentials.refresh.test.ts | 30 +++- src/utils/githubModelsCredentials.test.ts | 42 +++--- src/utils/handlePromptSubmit.test.ts | 15 +- src/utils/hookChains.integration.test.ts | 29 ++-- src/utils/hookChains.test.ts | 29 ++-- src/utils/hybridContextStrategy.ts | 17 ++- src/utils/knowledgeGraph.stress.test.ts | 29 ++-- src/utils/knowledgeGraph.test.ts | 20 ++- src/utils/knowledgeGraph.ts | 2 +- src/utils/model/agent.test.ts | 16 +- src/utils/model/minimaxModels.test.ts | 19 ++- src/utils/model/model.github.test.ts | 40 +++-- .../model/model.openai-shim-providers.test.ts | 28 ++-- src/utils/model/modelOptions.github.test.ts | 72 ++++++--- src/utils/model/modelStrings.github.test.ts | 33 +++- src/utils/model/openaiModelDiscovery.test.ts | 32 ++-- src/utils/model/providers.test.ts | 41 +++-- src/utils/multiTurnContext.test.ts | 19 ++- src/utils/openclaudeInstallSurfaces.test.ts | 34 ++++- src/utils/plugins/gitEnv.test.ts | 19 ++- src/utils/plugins/lspRecommendation.test.ts | 124 ++++++++++++--- .../officialMarketplaceStartupCheck.test.ts | 50 +++++- src/utils/plugins/pluginLoader.test.ts | 20 ++- src/utils/promptShellExecution.test.ts | 20 ++- src/utils/providerDiscovery.test.ts | 28 +++- src/utils/providerFlag.test.ts | 21 ++- src/utils/providerValidation.test.ts | 21 ++- .../secureStorage/platformStorage.test.ts | 30 +++- src/utils/status.test.ts | 18 ++- src/utils/storage/JSONProvider.test.ts | 22 ++- src/utils/storage/SQLiteProvider.test.ts | 8 +- src/utils/storage/SQLiteProvider.ts | 22 ++- src/utils/swarm/spawnUtils.test.ts | 17 ++- src/utils/swarm/teammateModel.test.ts | 17 ++- src/utils/thinking.test.ts | 25 ++- src/utils/user.test.ts | 43 +++++- tests/sdk/engine-mutators.test.ts | 15 +- tests/sdk/mcp-cleanup.test.ts | 42 ++++-- tests/sdk/query-happy-path.test.ts | 19 ++- tests/sdk/query-lifecycle.test.ts | 19 ++- tests/sdk/query-methods.test.ts | 15 +- tests/sdk/shared-utils.test.ts | 94 +++++++----- tests/sdk/tool-schema-cache.test.ts | 19 ++- .../openclaude-vscode/src/extension.test.js | 19 +++ 101 files changed, 2234 insertions(+), 761 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 5e5caa666..d5ed48fd6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -8,8 +8,7 @@ * - src/ path aliases */ -import { readFileSync, readdirSync, writeFileSync } from 'fs' -import { join } from 'path' +import { readFileSync } from 'fs' import { noTelemetryPlugin } from './no-telemetry-plugin' import { CLI_EXTERNALS, SDK_EXTERNALS } from './externals.js' @@ -69,53 +68,42 @@ const featureFlags: Record = { // so the previous onResolve/onLoad shim was silently ineffective — ALL // feature() calls evaluated to false regardless of the featureFlags map. // -// Fix: pre-process source files to strip the bun:bundle import and -// replace feature('FLAG') calls with their boolean literal. Files are -// modified in-place before Bun.build() and restored in a finally block. +// Fix: transform source as Bun loads each module, stripping the bun:bundle +// import and replacing feature('FLAG') calls with their boolean literal. +// The working tree stays immutable while smoke/build runs. // Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n) const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g -const modifiedFiles = new Map() // path → original content +const featureFlagTransformedFiles = new Set() -function preProcessFeatureFlags(dir: string) { - for (const ent of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, ent.name) - if (ent.isDirectory()) { preProcessFeatureFlags(full); continue } - if (!/\.(ts|tsx)$/.test(ent.name)) continue +const featureFlagPreprocessPlugin = { + name: 'feature-flag-preprocess', + setup(build) { + build.onLoad({ filter: /\.[cm]?tsx?$/ }, args => { + const normalizedPath = args.path.replace(/\\/g, '/') + if (!normalizedPath.includes('/src/')) return null - const raw = readFileSync(full, 'utf-8') - if (!raw.includes('feature(')) continue + const raw = readFileSync(args.path, 'utf-8') + if (!raw.includes('feature(')) return null - let contents = raw - contents = contents.replace(featureImportRe, '') - contents = contents.replace(featureCallRe, (_match, name) => - String((featureFlags as Record)[name] ?? false), - ) + let contents = raw + contents = contents.replace(featureImportRe, '') + contents = contents.replace(featureCallRe, (_match, name) => + String((featureFlags as Record)[name] ?? false), + ) - if (contents !== raw) { - modifiedFiles.set(full, raw) - writeFileSync(full, contents) - } - } -} + if (contents === raw) return null -function restoreModifiedFiles() { - for (const [path, original] of modifiedFiles) { - writeFileSync(path, original) - } - modifiedFiles.clear() -} - -preProcessFeatureFlags(join(import.meta.dir, '..', 'src')) -const numModified = modifiedFiles.size - -// Restore source files on abrupt termination (Ctrl+C, kill, etc.) -for (const signal of ['SIGINT', 'SIGTERM'] as const) { - process.on(signal, () => { - restoreModifiedFiles() - process.exit(signal === 'SIGINT' ? 130 : 143) - }) + featureFlagTransformedFiles.add(args.path) + return { + contents, + loader: args.path.endsWith('.tsx') || args.path.endsWith('.jsx') + ? 'tsx' + : 'ts', + } + }) + }, } let result: Awaited> | undefined @@ -149,6 +137,7 @@ result = await Bun.build({ }, plugins: [ noTelemetryPlugin, + featureFlagPreprocessPlugin, { name: 'bun-bundle-shim', setup(build) { @@ -185,8 +174,7 @@ export async function handleBgFlag() { throw new Error("Background sessions are ], ] as const) - // bun:bundle feature() replacement is handled by the source - // pre-processing step above (see preProcessFeatureFlags). + // bun:bundle feature() replacement is handled by featureFlagPreprocessPlugin. // The previous onResolve/onLoad shim was ineffective in Bun // v1.3.9+ because the bun: namespace is resolved natively // before the JS plugin phase runs. @@ -456,6 +444,7 @@ sdkResult = await Bun.build({ external: SDK_EXTERNALS, plugins: [ noTelemetryPlugin, + featureFlagPreprocessPlugin, // Stub missing internal/optional modules (same pattern as CLI build) { name: 'sdk-missing-stub', @@ -860,9 +849,7 @@ if (!sdkResult.success) { } } finally { - // Always restore source files, even if Bun.build() throws - restoreModifiedFiles() - console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`) + console.log(` 🔄 feature-flags: transformed ${featureFlagTransformedFiles.size} files during bundling`) } // ── Validate SDK bundle for React/Ink leakage ────────────────────────────── diff --git a/scripts/no-telemetry-growthbook-stub.test.ts b/scripts/no-telemetry-growthbook-stub.test.ts index f0423668d..70afb12a3 100644 --- a/scripts/no-telemetry-growthbook-stub.test.ts +++ b/scripts/no-telemetry-growthbook-stub.test.ts @@ -1,22 +1,34 @@ -import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { acquireEnvMutex, releaseEnvMutex } from '../src/entrypoints/sdk/shared.js' // --------------------------------------------------------------------------- // Setup: dynamically import the source-level growthbook no-op stub. // The stub reads ~/.claude/feature-flags.json for local flag overrides. // --------------------------------------------------------------------------- +const originalFlagsFile = process.env.CLAUDE_FEATURE_FLAGS_FILE const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`) const flagsFile = join(testDir, 'test-flags.json') +let envLockAcquired = false +let stub: typeof import('../src/services/analytics/growthbook.js') -mkdirSync(testDir, { recursive: true }) +beforeAll(async () => { + const envLock = await acquireEnvMutex() + if (!envLock.acquired) { + throw new Error('Failed to acquire env mutex for growthbook stub test') + } + envLockAcquired = true -// Point the stub at our test flags file before import -process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile + mkdirSync(testDir, { recursive: true }) -const stub = await import('../src/services/analytics/growthbook.js') + // Point the stub at our test flags file before import + process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile + + stub = await import('../src/services/analytics/growthbook.js') +}) // --------------------------------------------------------------------------- // Tests @@ -29,8 +41,19 @@ describe('growthbook stub — local feature flag overrides', () => { }) afterAll(() => { - rmSync(testDir, { recursive: true, force: true }) - delete process.env.CLAUDE_FEATURE_FLAGS_FILE + try { + rmSync(testDir, { recursive: true, force: true }) + if (originalFlagsFile === undefined) { + delete process.env.CLAUDE_FEATURE_FLAGS_FILE + } else { + process.env.CLAUDE_FEATURE_FLAGS_FILE = originalFlagsFile + } + } finally { + if (envLockAcquired) { + releaseEnvMutex() + envLockAcquired = false + } + } }) // ── File absent ────────────────────────────────────────────────── @@ -145,4 +168,4 @@ describe('growthbook stub — local feature flag overrides', () => { writeFileSync(flagsFile, JSON.stringify({ tengu_disable_bypass_permissions_mode: true })) expect(await stub.checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')).toBe(false) }) -}) \ No newline at end of file +}) diff --git a/src/commands/cacheStats/cacheStats.test.ts b/src/commands/cacheStats/cacheStats.test.ts index a508344c8..63b2f7f40 100644 --- a/src/commands/cacheStats/cacheStats.test.ts +++ b/src/commands/cacheStats/cacheStats.test.ts @@ -5,7 +5,11 @@ * label padding, conditional N/A footnote, recent-rows cap) which can * silently regress — these snapshot tests keep it honest. */ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import type { CacheMetrics } from '../../services/api/cacheMetrics.js' import { _setHistoryCapForTesting, @@ -50,11 +54,21 @@ async function runCommand(): Promise { return result.value } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('commands/cacheStats/cacheStats.test.ts') resetSessionCacheStats() _setHistoryCapForTesting(500) }) +afterEach(() => { + try { + resetSessionCacheStats() + _setHistoryCapForTesting(500) + } finally { + releaseSharedMutationLock() + } +}) + describe('/cache-stats — empty session', () => { test('shows friendly "no requests yet" message', async () => { const value = await runCommand() diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index a6921744a..e75dad735 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -1,4 +1,8 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT @@ -6,13 +10,21 @@ async function importInitCommand() { return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default } -afterEach(() => { - mock.restore() +beforeEach(async () => { + await acquireSharedMutationLock('commands/init.test.ts') +}) - if (originalClaudeCodeNewInit === undefined) { - delete process.env.CLAUDE_CODE_NEW_INIT - } else { - process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit +afterEach(() => { + try { + mock.restore() + + if (originalClaudeCodeNewInit === undefined) { + delete process.env.CLAUDE_CODE_NEW_INIT + } else { + process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit + } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/commands/knowledge/knowledge.test.ts b/src/commands/knowledge/knowledge.test.ts index e3cdc66aa..6a6f7ebe7 100644 --- a/src/commands/knowledge/knowledge.test.ts +++ b/src/commands/knowledge/knowledge.test.ts @@ -1,16 +1,53 @@ -import { describe, expect, it, beforeEach } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { call as knowledgeCall } from './knowledge.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { getArc, addEntity, resetArc } from '../../utils/conversationArc.js' import { getGlobalGraph, resetGlobalGraph } from '../../utils/knowledgeGraph.js' +import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' describe('knowledge command', () => { const mockContext = {} as any + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + let configDir: string | undefined - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('commands/knowledge.test.ts') + configDir = mkdtempSync(join(tmpdir(), 'openclaude-knowledge-command-')) + process.env.CLAUDE_CONFIG_DIR = configDir + setClaudeConfigHomeDirForTesting(configDir) resetArc() resetGlobalGraph() }) + + afterEach(() => { + try { + resetArc() + resetGlobalGraph() + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + setClaudeConfigHomeDirForTesting(undefined) + } finally { + const dirToRemove = configDir + configDir = undefined + try { + if (dirToRemove) { + rmSync(dirToRemove, { recursive: true, force: true }) + } + } finally { + releaseSharedMutationLock() + } + } + }) const knowledgeCallWithCapture = async (args: string) => { const result = await knowledgeCall(args, mockContext) diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index cf01f1811..b04f4bcd6 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -1,6 +1,10 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const originalEnv = { CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, @@ -36,25 +40,47 @@ function restoreEnv(key: string, value: string | undefined): void { } } +async function expectModelCommandDoesNotWaitForRefresh( + commandPromise: Promise, +): Promise { + const result = await Promise.race([ + commandPromise, + new Promise(resolve => + setTimeout(() => resolve(Symbol.for('openclaude.test.timeout')), 1_000), + ), + ]) + + expect(result).not.toBe(Symbol.for('openclaude.test.timeout')) + return result +} + +beforeEach(async () => { + await acquireSharedMutationLock('commands/model/model.test.tsx') +}) + afterEach(() => { - mock.restore() - restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) - restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI) - restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB) - restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL) - restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalEnv.CLAUDE_CODE_USE_BEDROCK) - restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX) - restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY) - restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) - restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) - restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) - restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) - restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) - restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS) - restoreEnv( - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', - originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, - ) + try { + mock.restore() + restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) + restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI) + restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB) + restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL) + restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalEnv.CLAUDE_CODE_USE_BEDROCK) + restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX) + restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY) + restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE) + restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) + restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY) + restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS) + restoreEnv( + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, + ) + } finally { + releaseSharedMutationLock() + } }) test('opens the model picker without awaiting local model discovery refresh', async () => { @@ -88,12 +114,7 @@ test('opens the model picker without awaiting local model discovery refresh', as // Use a fresh module instance so per-test mocks stay local to this test. const { call } = await importFreshModelModule('local-discovery') - const result = await Promise.race([ - call(() => {}, {} as never, ''), - new Promise(resolve => setTimeout(() => resolve('timeout'), 50)), - ]) - - expect(result).not.toBe('timeout') + await expectModelCommandDoesNotWaitForRefresh(call(() => {}, {} as never, '')) }) test('opens the model picker without awaiting descriptor-backed route refresh', async () => { @@ -142,12 +163,7 @@ test('opens the model picker without awaiting descriptor-backed route refresh', })) const { call } = await importFreshModelModule('descriptor-refresh-open') - const result = await Promise.race([ - call(() => {}, {} as never, ''), - new Promise(resolve => setTimeout(() => resolve('timeout'), 50)), - ]) - - expect(result).not.toBe('timeout') + await expectModelCommandDoesNotWaitForRefresh(call(() => {}, {} as never, '')) }) test('shouldAutoRefreshRouteCatalog respects discovery refresh modes', async () => { diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index 86e4688de..bd561f54c 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import stripAnsi from 'strip-ansi' @@ -16,6 +16,10 @@ import { ProviderWizard, TextEntryDialog, } from './provider.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { createProfileFile } from '../../utils/providerProfile.js' const SYNC_START = '\x1B[?2026h' @@ -156,31 +160,39 @@ function createTestStreams(): { } } +beforeEach(async () => { + await acquireSharedMutationLock('commands/provider/provider.test.tsx') +}) + afterEach(() => { - mock.restore() + try { + mock.restore() - if (ORIGINAL_SIMPLE_ENV === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV - } + if (ORIGINAL_SIMPLE_ENV === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV + } - if (ORIGINAL_CODEX_API_KEY === undefined) { - delete process.env.CODEX_API_KEY - } else { - process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY - } + if (ORIGINAL_CODEX_API_KEY === undefined) { + delete process.env.CODEX_API_KEY + } else { + process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY + } - if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) { - delete process.env.CHATGPT_ACCOUNT_ID - } else { - process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID - } + if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) { + delete process.env.CHATGPT_ACCOUNT_ID + } else { + process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID + } - if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) { - delete process.env.CODEX_ACCOUNT_ID - } else { - process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID + if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) { + delete process.env.CODEX_ACCOUNT_ID + } else { + process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID + } + } finally { + releaseSharedMutationLock() } }) @@ -244,9 +256,9 @@ test('wizard step remount prevents a typed API key from leaking into the next fi , ) - await Bun.sleep(25) + await waitForOutput(getOutput, output => output.includes('API key step')) stdin.write('sk-secret-12345678') - await Bun.sleep(25) + await waitForOutput(getOutput, output => output.includes('********')) root.render( @@ -262,13 +274,14 @@ test('wizard step remount prevents a typed API key from leaking into the next fi , ) - await Bun.sleep(25) + const output = await waitForOutput( + getOutput, + frame => frame.includes('Model step') && !frame.includes('sk-secret-12345678'), + ) root.unmount() stdin.end() stdout.end() - await Bun.sleep(25) - const output = stripAnsi(extractLastFrame(getOutput())) expect(output).toContain('Model step') expect(output).not.toContain('sk-secret-12345678') }) diff --git a/src/components/ConsoleOAuthFlow.test.tsx b/src/components/ConsoleOAuthFlow.test.tsx index 2fee0442d..6a332f71e 100644 --- a/src/components/ConsoleOAuthFlow.test.tsx +++ b/src/components/ConsoleOAuthFlow.test.tsx @@ -87,13 +87,34 @@ async function renderFrame(node: React.ReactNode): Promise { , ) - await Bun.sleep(50) - root.unmount() - stdin.end() - stdout.end() - await Bun.sleep(25) + try { + return await waitForOutput( + getOutput, + output => output.includes('Select login method:') || output.includes('Set up provider'), + ) + } finally { + root.unmount() + stdin.end() + stdout.end() + } +} - return stripAnsi(extractLastFrame(getOutput())) +async function waitForOutput( + getOutput: () => string, + predicate: (output: string) => boolean, + timeoutMs = 2500, +): Promise { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const output = stripAnsi(extractLastFrame(getOutput())) + if (predicate(output)) { + return output + } + await Bun.sleep(10) + } + + throw new Error('Timed out waiting for ConsoleOAuthFlow test output') } test('login picker shows the third-party platform option', async () => { diff --git a/src/components/Feedback.test.ts b/src/components/Feedback.test.ts index 3e91ee837..70f9024d7 100644 --- a/src/components/Feedback.test.ts +++ b/src/components/Feedback.test.ts @@ -1,8 +1,29 @@ -import { expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' import { createGitHubIssueUrl } from './Feedback.tsx' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' -(globalThis as { MACRO?: { VERSION?: string } }).MACRO = { VERSION: '0.1.7' } +const originalMacro = (globalThis as { MACRO?: unknown }).MACRO + +beforeEach(async () => { + await acquireSharedMutationLock('Feedback.test.ts') + ;(globalThis as { MACRO?: { VERSION?: string } }).MACRO = { VERSION: '0.1.7' } +}) + +afterEach(() => { + try { + if (originalMacro === undefined) { + delete (globalThis as { MACRO?: unknown }).MACRO + } else { + ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro + } + } finally { + releaseSharedMutationLock() + } +}) test('createGitHubIssueUrl omits empty feedback IDs', () => { const url = decodeURIComponent( diff --git a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx index 50cf44032..28525e704 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx @@ -1,9 +1,14 @@ import React from 'react' import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { renderToString } from '../../utils/staticRender.js' describe('PromptInputQueuedCommands', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('components/PromptInput/PromptInputQueuedCommands.test.tsx') mock.module('../../hooks/useCommandQueue.js', () => ({ useCommandQueue: () => [ { @@ -21,7 +26,11 @@ describe('PromptInputQueuedCommands', () => { }) afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) it('shows a next-turn guidance banner for queued prompt messages', async () => { diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index bb1ca4cc6..ad7b0cd3c 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -1,12 +1,16 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import stripAnsi from 'strip-ansi' import { createRoot } from '../ink.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { AppStateProvider } from '../state/AppState.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const SYNC_START = '\x1B[?2026h' const SYNC_END = '\x1B[?2026l' @@ -502,15 +506,23 @@ async function renderProviderManagerFrame( return output } -afterEach(() => { - mock.restore() +beforeEach(async () => { + await acquireSharedMutationLock('components/ProviderManager.test.tsx') +}) - for (const [key, value] of Object.entries(ORIGINAL_ENV)) { - if (value === undefined) { - delete process.env[key as keyof typeof ORIGINAL_ENV] - } else { - process.env[key as keyof typeof ORIGINAL_ENV] = value +afterEach(() => { + try { + mock.restore() + + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[key as keyof typeof ORIGINAL_ENV] + } else { + process.env[key as keyof typeof ORIGINAL_ENV] = value + } } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/components/StartupScreen.test.ts b/src/components/StartupScreen.test.ts index 3c4aef3b9..a45ac17b3 100644 --- a/src/components/StartupScreen.test.ts +++ b/src/components/StartupScreen.test.ts @@ -1,8 +1,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test' const actualSettings = await import('../utils/settings/settings.js') +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('StartupScreen.test.ts') mock.module('../utils/settings/settings.js', () => ({ ...actualSettings, getSettings_DEPRECATED: () => ({}), @@ -10,12 +15,16 @@ beforeAll(() => { }) afterAll(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) import stripAnsi from 'strip-ansi' import { detectProvider, printStartupScreen } from './StartupScreen.js' -import { saveGlobalConfig } from '../utils/config.js' +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' import { resetSettingsCache, setSessionSettingsCache, @@ -49,6 +58,7 @@ const originalEnv: Record = {} const originalMacro = (globalThis as Record).MACRO const originalIsTTY = process.stdout.isTTY const originalWrite = process.stdout.write +const originalModel = getGlobalConfig().model beforeEach(() => { for (const key of ENV_KEYS) { @@ -66,7 +76,7 @@ afterEach(() => { resetSettingsCache() saveGlobalConfig(current => ({ ...current, - model: undefined, + model: originalModel, })) ;(globalThis as Record).MACRO = originalMacro Object.defineProperty(process.stdout, 'isTTY', { diff --git a/src/components/TextInput.test.tsx b/src/components/TextInput.test.tsx index 979974c0d..0f18e4de3 100644 --- a/src/components/TextInput.test.tsx +++ b/src/components/TextInput.test.tsx @@ -74,6 +74,24 @@ function createTestStreams(): { } } +async function waitForOutput( + getOutput: () => string, + predicate: (output: string) => boolean, + timeoutMs = 2500, +): Promise { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const output = stripAnsi(extractLastFrame(getOutput())) + if (predicate(output)) { + return output + } + await Bun.sleep(10) + } + + throw new Error('Timed out waiting for TextInput test output') +} + function DelayedControlledTextInput(): React.ReactNode { const [value, setValue] = React.useState('') const [cursorOffset, setCursorOffset] = React.useState(0) @@ -183,18 +201,18 @@ test('TextInput renders typed characters before delayed parent value commits', a root.render() - await Bun.sleep(50) + await waitForOutput(getOutput, output => output.includes('Type here...')) stdin.write('a') - await Bun.sleep(25) stdin.write('b') - await Bun.sleep(25) - const output = stripAnsi(extractLastFrame(getOutput())) + const output = await waitForOutput( + getOutput, + frame => frame.includes('ab') && !frame.includes('Type here...'), + ) root.unmount() stdin.end() stdout.end() - await Bun.sleep(25) expect(output).toContain('ab') expect(output).not.toContain('Type here...') @@ -217,22 +235,20 @@ test('VimTextInput preserves rapid typed characters before delayed parent value root.render() - await Bun.sleep(50) + await waitForOutput(getOutput, output => output.includes('Type here...')) stdin.write('a') - await Bun.sleep(25) stdin.write('s') - await Bun.sleep(25) stdin.write('d') - await Bun.sleep(25) stdin.write('f') - await Bun.sleep(25) - const output = stripAnsi(extractLastFrame(getOutput())) + const output = await waitForOutput( + getOutput, + frame => frame.includes('asdf') && !frame.includes('Type here...'), + ) root.unmount() stdin.end() stdout.end() - await Bun.sleep(25) expect(output).toContain('asdf') expect(output).not.toContain('Type here...') diff --git a/src/components/ThemePicker.test.tsx b/src/components/ThemePicker.test.tsx index 1202884cd..3fa682471 100644 --- a/src/components/ThemePicker.test.tsx +++ b/src/components/ThemePicker.test.tsx @@ -1,14 +1,20 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterAll, expect, mock, test } from 'bun:test' import React from 'react' import stripAnsi from 'strip-ansi' import { createRoot, Text, useTheme } from '../ink.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { AppStateProvider } from '../state/AppState.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { ThemeProvider } from './design-system/ThemeProvider.js' +await acquireSharedMutationLock('components/ThemePicker.test.tsx') + mock.module('./StructuredDiff.js', () => ({ StructuredDiff: function StructuredDiffPreview(): React.ReactNode { const [theme] = useTheme() @@ -115,8 +121,12 @@ async function waitForFrame( return frame } -afterEach(() => { - mock.restore() +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) test('updates the preview when keyboard focus moves to another theme', async () => { diff --git a/src/components/design-system/ThemeProvider.test.tsx b/src/components/design-system/ThemeProvider.test.tsx index ef203c46e..1d3396d8b 100644 --- a/src/components/design-system/ThemeProvider.test.tsx +++ b/src/components/design-system/ThemeProvider.test.tsx @@ -18,15 +18,21 @@ */ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterAll, expect, mock, test } from 'bun:test' import React, { useEffect } from 'react' import stripAnsi from 'strip-ansi' import { createRoot, Text, useTheme } from '../../ink.js' import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' import { AppStateProvider } from '../../state/AppState.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { ThemeProvider, usePreviewTheme } from './ThemeProvider.js' +await acquireSharedMutationLock('components/design-system/ThemeProvider.test.tsx') + mock.module('../StructuredDiff.js', () => ({ StructuredDiff: function StructuredDiffPreview(): React.ReactNode { return diff @@ -98,8 +104,12 @@ async function waitForFrame( return frame } -afterEach(() => { - mock.restore() +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) /** @@ -213,4 +223,4 @@ test('usePreviewTheme() setPreviewTheme changes displayed theme', async () => { stdout.end() await Bun.sleep(0) } -}) \ No newline at end of file +}) diff --git a/src/components/useCodexOAuthFlow.test.tsx b/src/components/useCodexOAuthFlow.test.tsx index 5d39ce0f7..716058b58 100644 --- a/src/components/useCodexOAuthFlow.test.tsx +++ b/src/components/useCodexOAuthFlow.test.tsx @@ -1,9 +1,13 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import { createRoot, Text } from '../ink.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const SYNC_START = '\x1B[?2026h' const SYNC_END = '\x1B[?2026l' @@ -91,8 +95,16 @@ const TOKENS = { apiKey: 'oauth-api-key', } +beforeEach(async () => { + await acquireSharedMutationLock('components/useCodexOAuthFlow.test.tsx') +}) + afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) test('does not persist credentials when downstream setup rejects', async () => { diff --git a/src/constants/promptIdentity.test.ts b/src/constants/promptIdentity.test.ts index da3a611a9..52921dff3 100644 --- a/src/constants/promptIdentity.test.ts +++ b/src/constants/promptIdentity.test.ts @@ -1,29 +1,79 @@ -import { afterEach, expect, test } from 'bun:test' - -// MACRO is replaced at build time by Bun.define but not in test mode. -// Define it globally so tests that import modules using MACRO don't crash. -;(globalThis as Record).MACRO = { - VERSION: '99.0.0', - DISPLAY_VERSION: '0.0.0-test', - BUILD_TIME: new Date().toISOString(), - ISSUES_EXPLAINER: 'report the issue at https://github.com/Gitlawb/openclaude/issues', - PACKAGE_URL: '@gitlawb/openclaude', - NATIVE_PACKAGE_URL: undefined, -} - -import { clearSystemPromptSections } from './systemPromptSections.js' -import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js' -import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js' -import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js' -import { GENERAL_PURPOSE_AGENT } from '../tools/AgentTool/built-in/generalPurposeAgent.js' -import { EXPLORE_AGENT } from '../tools/AgentTool/built-in/exploreAgent.js' -import { PLAN_AGENT } from '../tools/AgentTool/built-in/planAgent.js' -import { STATUSLINE_SETUP_AGENT } from '../tools/AgentTool/built-in/statuslineSetup.js' +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const originalSimpleEnv = process.env.CLAUDE_CODE_SIMPLE +const originalMacro = (globalThis as Record).MACRO +const hadOriginalMacro = Object.hasOwn(globalThis, 'MACRO') + +let clearSystemPromptSections: typeof import('./systemPromptSections.js').clearSystemPromptSections +let getSystemPrompt: typeof import('./prompts.js').getSystemPrompt +let DEFAULT_AGENT_PROMPT: typeof import('./prompts.js').DEFAULT_AGENT_PROMPT +let CLI_SYSPROMPT_PREFIXES: typeof import('./system.js').CLI_SYSPROMPT_PREFIXES +let getCLISyspromptPrefix: typeof import('./system.js').getCLISyspromptPrefix +let CLAUDE_CODE_GUIDE_AGENT: + typeof import('../tools/AgentTool/built-in/claudeCodeGuideAgent.js').CLAUDE_CODE_GUIDE_AGENT +let GENERAL_PURPOSE_AGENT: + typeof import('../tools/AgentTool/built-in/generalPurposeAgent.js').GENERAL_PURPOSE_AGENT +let EXPLORE_AGENT: + typeof import('../tools/AgentTool/built-in/exploreAgent.js').EXPLORE_AGENT +let PLAN_AGENT: typeof import('../tools/AgentTool/built-in/planAgent.js').PLAN_AGENT +let STATUSLINE_SETUP_AGENT: + typeof import('../tools/AgentTool/built-in/statuslineSetup.js').STATUSLINE_SETUP_AGENT + +beforeAll(async () => { + await acquireSharedMutationLock('constants/promptIdentity.test.ts') + + // MACRO is replaced at build time by Bun.define but not in test mode. + // Define it globally under the shared lock before importing modules that use it. + ;(globalThis as Record).MACRO = { + VERSION: '99.0.0', + DISPLAY_VERSION: '0.0.0-test', + BUILD_TIME: new Date().toISOString(), + ISSUES_EXPLAINER: + 'report the issue at https://github.com/Gitlawb/openclaude/issues', + PACKAGE_URL: '@gitlawb/openclaude', + NATIVE_PACKAGE_URL: undefined, + } + + ;({ clearSystemPromptSections } = await import('./systemPromptSections.js')) + ;({ getSystemPrompt, DEFAULT_AGENT_PROMPT } = await import('./prompts.js')) + ;({ CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } = await import('./system.js')) + ;({ CLAUDE_CODE_GUIDE_AGENT } = await import( + '../tools/AgentTool/built-in/claudeCodeGuideAgent.js' + )) + ;({ GENERAL_PURPOSE_AGENT } = await import( + '../tools/AgentTool/built-in/generalPurposeAgent.js' + )) + ;({ EXPLORE_AGENT } = await import( + '../tools/AgentTool/built-in/exploreAgent.js' + )) + ;({ PLAN_AGENT } = await import('../tools/AgentTool/built-in/planAgent.js')) + ;({ STATUSLINE_SETUP_AGENT } = await import( + '../tools/AgentTool/built-in/statuslineSetup.js' + )) +}) + +afterAll(() => { + try { + if (hadOriginalMacro) { + ;(globalThis as Record).MACRO = originalMacro + } else { + delete (globalThis as Record).MACRO + } + } finally { + releaseSharedMutationLock() + } +}) afterEach(() => { - process.env.CLAUDE_CODE_SIMPLE = originalSimpleEnv + if (originalSimpleEnv === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = originalSimpleEnv + } clearSystemPromptSections() }) diff --git a/src/cost-tracker.cacheIntegration.test.ts b/src/cost-tracker.cacheIntegration.test.ts index 528af3f9b..7b715a7cb 100644 --- a/src/cost-tracker.cacheIntegration.test.ts +++ b/src/cost-tracker.cacheIntegration.test.ts @@ -14,12 +14,16 @@ * rather than mocking the tracker module. Fewer moving parts, and the * test fails for the right reason if anyone breaks the wrapping. */ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { addToTotalSessionCost, resetCostState } from './cost-tracker.js' import { getCurrentTurnCacheMetrics, getSessionCacheMetrics, } from './services/api/cacheStatsTracker.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from './test/sharedMutationLock.js' // BetaUsage-compatible shape — minimum fields addToTotalSessionCost // needs to run without throwing. Cache fields are the ones we care @@ -40,12 +44,21 @@ function anthropicUsage(partial: { } as Parameters[1] } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('cost-tracker.cacheIntegration.test.ts') // resetCostState is the wrapped version that ALSO clears the cache // tracker — this line is itself part of what we're verifying. resetCostState() }) +afterEach(() => { + try { + resetCostState() + } finally { + releaseSharedMutationLock() + } +}) + describe('addToTotalSessionCost → cacheStatsTracker wiring', () => { test('records normalized cache metrics on the tracker for each call', () => { addToTotalSessionCost( diff --git a/src/entrypoints/mcp.test.ts b/src/entrypoints/mcp.test.ts index 939f50593..61d94faf7 100644 --- a/src/entrypoints/mcp.test.ts +++ b/src/entrypoints/mcp.test.ts @@ -1,8 +1,14 @@ -import { describe, it, expect, mock } from 'bun:test' +import { afterAll, describe, it, expect, mock } from 'bun:test' import { getCombinedTools, loadReexposedMcpTools } from './mcp.js' import type { Tool as InternalTool } from '../Tool.js' import type { MCPServerConnection } from '../services/mcp/types.js' import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +await acquireSharedMutationLock('entrypoints/mcp.test.ts') // Mock the MCP client service to control the tools and connections returned const mockGetMcpToolsCommandsAndResources = mock(async (onConnectionAttempt: any) => {}) @@ -10,6 +16,14 @@ mock.module('../services/mcp/client.js', () => ({ getMcpToolsCommandsAndResources: mockGetMcpToolsCommandsAndResources })) +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + describe('getCombinedTools', () => { it('deduplicates builtins when mcpTools have the same name, prioritizing mcpTools', () => { const builtinBash = { name: 'Bash', isMcp: false } as unknown as InternalTool diff --git a/src/entrypoints/sdk/shared.ts b/src/entrypoints/sdk/shared.ts index bcfd4563d..86ab66fa2 100644 --- a/src/entrypoints/sdk/shared.ts +++ b/src/entrypoints/sdk/shared.ts @@ -52,9 +52,6 @@ export function assertValidSessionId(sessionId: string): void { * } * ``` */ -const envMutationQueue: Array<() => void> = [] -let envMutationLocked = false - export interface MutexAcquireOptions { /** Maximum time to wait for mutex in milliseconds. Default: no timeout (wait forever). */ timeoutMs?: number @@ -67,64 +64,90 @@ export interface MutexAcquireResult { reason?: 'timeout' } -export async function acquireEnvMutex(options?: MutexAcquireOptions): Promise { - if (!envMutationLocked) { - envMutationLocked = true - return { acquired: true } - } +function createEnvMutexState() { + const queue: Array<() => void> = [] + let locked = false - if (options?.timeoutMs === undefined) { - // No timeout - wait forever (original behavior for backward compatibility) + async function acquire( + options?: MutexAcquireOptions, + ): Promise { + if (!locked) { + locked = true + return { acquired: true } + } + + if (options?.timeoutMs === undefined) { + // No timeout - wait forever (original behavior for backward compatibility) + return new Promise(resolve => { + queue.push(() => resolve({ acquired: true })) + }) + } + + // With timeout - race between queue and timeout return new Promise(resolve => { - envMutationQueue.push(() => resolve({ acquired: true })) + let resolved = false + let callback: () => void + + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true + // Remove ourselves from the queue to prevent orphaned callback + const index = queue.indexOf(callback) + if (index !== -1) { + queue.splice(index, 1) + } + resolve({ acquired: false, reason: 'timeout' }) + } + }, options.timeoutMs) + + callback = () => { + if (!resolved) { + resolved = true + clearTimeout(timeoutId) + resolve({ acquired: true }) + } + } + + queue.push(callback) }) } - // With timeout - race between queue and timeout - return new Promise(resolve => { - let resolved = false - let callback: () => void - - const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true - // Remove ourselves from the queue to prevent orphaned callback - const index = envMutationQueue.indexOf(callback) - if (index !== -1) { - envMutationQueue.splice(index, 1) + function release(): void { + if (queue.length > 0) { + const next = queue.shift() + if (next) { + try { + next() + } catch { + // If callback throws, ensure mutex is unlocked so next caller can acquire + // The error is intentionally not propagated - callback errors should not + // block the mutex system. Callers should handle their own errors. + locked = false } - resolve({ acquired: false, reason: 'timeout' }) - } - }, options.timeoutMs) - - callback = () => { - if (!resolved) { - resolved = true - clearTimeout(timeoutId) - resolve({ acquired: true }) } + } else { + locked = false } + } - envMutationQueue.push(callback) - }) + function reset(): void { + queue.length = 0 + locked = false + } + + return { acquire, release, reset } +} + +const envMutex = createEnvMutexState() + +export async function acquireEnvMutex( + options?: MutexAcquireOptions, +): Promise { + return envMutex.acquire(options) } export function releaseEnvMutex(): void { - if (envMutationQueue.length > 0) { - const next = envMutationQueue.shift() - if (next) { - try { - next() - } catch { - // If callback throws, ensure mutex is unlocked so next caller can acquire - // The error is intentionally not propagated - callback errors should not - // block the mutex system. Callers should handle their own errors. - envMutationLocked = false - } - } - } else { - envMutationLocked = false - } + envMutex.release() } /** @@ -133,8 +156,25 @@ export function releaseEnvMutex(): void { * @internal */ export function resetEnvMutexForTesting(): void { - envMutationQueue.length = 0 - envMutationLocked = false + envMutex.reset() +} + +/** + * Create an isolated mutex instance for tests that need to exercise timeout + * behavior without touching the process-global SDK env mutex. + * @internal + */ +export function createEnvMutexForTesting(): { + acquireEnvMutex: typeof acquireEnvMutex + releaseEnvMutex: typeof releaseEnvMutex + resetEnvMutex: () => void +} { + const isolated = createEnvMutexState() + return { + acquireEnvMutex: isolated.acquire, + releaseEnvMutex: isolated.release, + resetEnvMutex: isolated.reset, + } } // ============================================================================ diff --git a/src/hooks/useApiKeyVerification.test.tsx b/src/hooks/useApiKeyVerification.test.tsx index 438cfd173..85c356dec 100644 --- a/src/hooks/useApiKeyVerification.test.tsx +++ b/src/hooks/useApiKeyVerification.test.tsx @@ -1,8 +1,12 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import React from 'react' import { createRoot, Text } from '../ink.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' type AuthState = { anthropicAuthEnabled: boolean @@ -53,8 +57,16 @@ async function waitForCondition( throw new Error('Timed out waiting for useApiKeyVerification test state') } +beforeEach(async () => { + await acquireSharedMutationLock('hooks/useApiKeyVerification.test.tsx') +}) + afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => { diff --git a/src/ink/termio/osc.test.ts b/src/ink/termio/osc.test.ts index 34ee118d9..3952582c1 100644 --- a/src/ink/termio/osc.test.ts +++ b/src/ink/termio/osc.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { join } from 'node:path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const originalEnv = { ...process.env } const originalPlatform = process.platform @@ -46,7 +50,8 @@ async function waitForExecCall( } describe('Windows clipboard fallback', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('ink/termio/osc.test.ts') installOscMocks() execFileNoThrowMock.mockClear() generateTempFilePathMock.mockClear() @@ -57,8 +62,13 @@ describe('Windows clipboard fallback', () => { }) afterEach(() => { - process.env = { ...originalEnv } - Object.defineProperty(process, 'platform', { value: originalPlatform }) + try { + mock.restore() + process.env = { ...originalEnv } + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } finally { + releaseSharedMutationLock() + } }) test('uses PowerShell instead of clip.exe for local Windows copy', async () => { @@ -97,7 +107,8 @@ describe('Windows clipboard fallback', () => { }) describe('clipboard path behavior remains stable', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('ink/termio/osc.test.ts') installOscMocks() execFileNoThrowMock.mockClear() process.env = { ...originalEnv } @@ -106,8 +117,13 @@ describe('clipboard path behavior remains stable', () => { }) afterEach(() => { - process.env = { ...originalEnv } - Object.defineProperty(process, 'platform', { value: originalPlatform }) + try { + mock.restore() + process.env = { ...originalEnv } + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } finally { + releaseSharedMutationLock() + } }) test('getClipboardPath stays native on local macOS', async () => { diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 9c48c2041..6005794a8 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { registerGateway } from './index.js' +import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from './index.js' import { acquireSharedMutationLock, releaseSharedMutationLock, @@ -90,6 +90,8 @@ afterEach(() => { restoreEnvValue('CLAUDE_CODE_USE_VERTEX') restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY') restoreEnvValue('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') + _clearRegistryForTesting() + ensureIntegrationsLoaded() } finally { releaseSharedMutationLock() } diff --git a/src/integrations/registry.test.ts b/src/integrations/registry.test.ts index 3be588c3f..e866cb9a1 100644 --- a/src/integrations/registry.test.ts +++ b/src/integrations/registry.test.ts @@ -1,6 +1,6 @@ // src/integrations/registry.test.ts -import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { ensureIntegrationsLoaded } from './index.js' import { _clearRegistryForTesting, @@ -22,14 +22,23 @@ import { registerVendor, validateIntegrationRegistry, } from './registry.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('integrations/registry.test.ts') _clearRegistryForTesting() }) -afterAll(() => { - _clearRegistryForTesting() - ensureIntegrationsLoaded() +afterEach(() => { + try { + _clearRegistryForTesting() + ensureIntegrationsLoaded() + } finally { + releaseSharedMutationLock() + } }) // --------------------------------------------------------------------------- diff --git a/src/services/api/cacheStatsTracker.test.ts b/src/services/api/cacheStatsTracker.test.ts index c1617b0e3..e0cda223b 100644 --- a/src/services/api/cacheStatsTracker.test.ts +++ b/src/services/api/cacheStatsTracker.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, expect, test, describe } from 'bun:test' +import { afterEach, beforeEach, expect, test, describe } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { _setHistoryCapForTesting, getCacheStatsHistory, @@ -21,11 +25,21 @@ function makeMetrics(partial: Partial): CacheMetrics { } } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('services/api/cacheStatsTracker.test.ts') resetSessionCacheStats() _setHistoryCapForTesting(500) }) +afterEach(() => { + try { + resetSessionCacheStats() + _setHistoryCapForTesting(500) + } finally { + releaseSharedMutationLock() + } +}) + describe('cacheStatsTracker — aggregation', () => { test('currentTurn and session both start empty and unsupported', () => { expect(getCurrentTurnCacheMetrics().supported).toBe(false) diff --git a/src/services/api/compressToolHistory.test.ts b/src/services/api/compressToolHistory.test.ts index ed5f136f9..09bb6bf61 100644 --- a/src/services/api/compressToolHistory.test.ts +++ b/src/services/api/compressToolHistory.test.ts @@ -1,4 +1,8 @@ -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { compressToolHistory, getTiers } from './compressToolHistory.js' // Mock the two dependencies so tests are deterministic and don't read disk config. @@ -7,6 +11,8 @@ const mockState = { effectiveWindow: 100_000, } +await acquireSharedMutationLock('services/api/compressToolHistory.test.ts') + mock.module('../../utils/config.js', () => ({ getGlobalConfig: () => ({ toolHistoryCompressionEnabled: mockState.enabled, @@ -22,9 +28,12 @@ beforeEach(() => { mockState.effectiveWindow = 100_000 }) -afterEach(() => { - mockState.enabled = true - mockState.effectiveWindow = 100_000 +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) type Block = Record diff --git a/src/services/api/openaiShim.compression.test.ts b/src/services/api/openaiShim.compression.test.ts index 9f82c47ab..2546fbbbb 100644 --- a/src/services/api/openaiShim.compression.test.ts +++ b/src/services/api/openaiShim.compression.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, afterEach, beforeEach, expect, mock, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { createOpenAIShimClient } from './openaiShim.js' @@ -17,6 +17,8 @@ const mockState = { effectiveWindow: 100_000, // Copilot gpt-4o tier } +await acquireSharedMutationLock('openaiShim.compression.test.ts') + mock.module('../../utils/config.js', () => ({ getGlobalConfig: () => ({ toolHistoryCompressionEnabled: mockState.enabled, @@ -97,7 +99,6 @@ function makeFakeResponse(): Response { } beforeEach(async () => { - await acquireSharedMutationLock('openaiShim.compression.test.ts') process.env.OPENAI_BASE_URL = 'http://example.test/v1' process.env.OPENAI_API_KEY = 'test-key' delete process.env.OPENAI_MODEL @@ -106,14 +107,18 @@ beforeEach(async () => { }) afterEach(() => { + if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + if (originalEnv.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY + if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL + else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL + globalThis.fetch = originalFetch +}) + +afterAll(() => { try { - if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL - else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL - if (originalEnv.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY - else process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY - if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL - else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL - globalThis.fetch = originalFetch + mock.restore() } finally { releaseSharedMutationLock() } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 34e584e57..77069d9f1 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' -import { registerGateway } from '../../integrations/index.ts' +import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from '../../integrations/index.ts' import { createOpenAIShimClient } from './openaiShim.ts' type FetchType = typeof globalThis.fetch @@ -151,6 +151,8 @@ afterEach(() => { restoreEnv('DEEPSEEK_API_KEY', originalEnv.DEEPSEEK_API_KEY) restoreEnv('MIMO_API_KEY', originalEnv.MIMO_API_KEY) globalThis.fetch = originalFetch + _clearRegistryForTesting() + ensureIntegrationsLoaded() } finally { releaseSharedMutationLock() } diff --git a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts index 2c9a3d520..c707cfeea 100644 --- a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts +++ b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts @@ -1,12 +1,24 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { resolveRuntimeCodexCredentials } from './providerConfig.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +beforeEach(async () => { + await acquireSharedMutationLock('services/api/providerConfig.runtimeCodexCredentials.test.ts') +}) afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) function makeJwt(payload: Record): string { diff --git a/src/services/compact/autoCompact.test.ts b/src/services/compact/autoCompact.test.ts index b0decd3ae..5f42c4d74 100644 --- a/src/services/compact/autoCompact.test.ts +++ b/src/services/compact/autoCompact.test.ts @@ -1,8 +1,31 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { getEffectiveContextWindowSize, getAutoCompactThreshold, } from './autoCompact.ts' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +let originalUseOpenAI: string | undefined + +beforeEach(async () => { + await acquireSharedMutationLock('services/compact/autoCompact.test.ts') + originalUseOpenAI = process.env.CLAUDE_CODE_USE_OPENAI +}) + +afterEach(() => { + try { + if (originalUseOpenAI === undefined) { + delete process.env.CLAUDE_CODE_USE_OPENAI + } else { + process.env.CLAUDE_CODE_USE_OPENAI = originalUseOpenAI + } + } finally { + releaseSharedMutationLock() + } +}) describe('getEffectiveContextWindowSize', () => { test('returns positive value for known models with large context windows', () => { @@ -24,16 +47,12 @@ describe('getEffectiveContextWindowSize', () => { // disabled it's 20k + 13k = 33k. Assert the worst case so the test is // stable regardless of flag state in CI vs local. process.env.CLAUDE_CODE_USE_OPENAI = '1' - try { - const effective = getEffectiveContextWindowSize('some-unknown-3p-model') - expect(effective).toBeGreaterThan(0) - // 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_BUFFER_TOKENS (13k). - // Covers the anti-regression intent of issue #635 without assuming - // the GrowthBook flag state. - expect(effective).toBeGreaterThanOrEqual(21_000) - } finally { - delete process.env.CLAUDE_CODE_USE_OPENAI - } + const effective = getEffectiveContextWindowSize('some-unknown-3p-model') + expect(effective).toBeGreaterThan(0) + // 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_BUFFER_TOKENS (13k). + // Covers the anti-regression intent of issue #635 without assuming + // the GrowthBook flag state. + expect(effective).toBeGreaterThanOrEqual(21_000) }) }) @@ -45,11 +64,7 @@ describe('getAutoCompactThreshold', () => { test('never returns negative threshold even for unknown 3P models (issue #635)', () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' - try { - const threshold = getAutoCompactThreshold('some-unknown-3p-model') - expect(threshold).toBeGreaterThan(0) - } finally { - delete process.env.CLAUDE_CODE_USE_OPENAI - } + const threshold = getAutoCompactThreshold('some-unknown-3p-model') + expect(threshold).toBeGreaterThan(0) }) -}) \ No newline at end of file +}) diff --git a/src/services/github/deviceFlow.test.ts b/src/services/github/deviceFlow.test.ts index 775144ff8..3fdb824d4 100644 --- a/src/services/github/deviceFlow.test.ts +++ b/src/services/github/deviceFlow.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { DEFAULT_GITHUB_DEVICE_SCOPE, @@ -12,8 +16,16 @@ async function importFreshModule() { return import(`./deviceFlow.ts?ts=${Date.now()}-${Math.random()}`) } +beforeEach(async () => { + await acquireSharedMutationLock('services/github/deviceFlow.test.ts') +}) + afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) describe('requestDeviceCode', () => { diff --git a/src/services/mcp/officialRegistry.test.ts b/src/services/mcp/officialRegistry.test.ts index 75ab4f049..f0ddba959 100644 --- a/src/services/mcp/officialRegistry.test.ts +++ b/src/services/mcp/officialRegistry.test.ts @@ -1,20 +1,31 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import axios from 'axios' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const originalEnv = { ...process.env } +const originalAxiosGet = axios.get async function importFreshModule() { mock.restore() return import(`./officialRegistry.ts?ts=${Date.now()}-${Math.random()}`) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('services/mcp/officialRegistry.test.ts') process.env = { ...originalEnv } }) afterEach(() => { - process.env = { ...originalEnv } - mock.restore() + try { + process.env = { ...originalEnv } + axios.get = originalAxiosGet + mock.restore() + } finally { + releaseSharedMutationLock() + } }) describe('prefetchOfficialMcpUrls', () => { diff --git a/src/services/oauth/auth-code-listener.analytics.test.ts b/src/services/oauth/auth-code-listener.analytics.test.ts index 278005967..fd54f6f70 100644 --- a/src/services/oauth/auth-code-listener.analytics.test.ts +++ b/src/services/oauth/auth-code-listener.analytics.test.ts @@ -1,7 +1,19 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +beforeEach(async () => { + await acquireSharedMutationLock('services/oauth/auth-code-listener.analytics.test.ts') +}) afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) test('custom error responses log the error redirect analytics event', async () => { diff --git a/src/services/tips/sponsoredTips.test.ts b/src/services/tips/sponsoredTips.test.ts index 6eade0d95..da61d9764 100644 --- a/src/services/tips/sponsoredTips.test.ts +++ b/src/services/tips/sponsoredTips.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, mock, test } from 'bun:test' +import { afterAll, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' type StubSettings = { sponsoredTipsEnabled?: boolean @@ -12,6 +16,8 @@ const configRef: { } = { value: { numStartups: 100 } } // mock.module is process-global — install once, then mutate the refs per test. +await acquireSharedMutationLock('services/tips/sponsoredTips.test.ts') + mock.module('../../utils/settings/settings.js', () => ({ getSettings_DEPRECATED: () => settingsRef.value, getInitialSettings: () => settingsRef.value, @@ -25,6 +31,14 @@ mock.module('../../utils/config.js', () => ({ }, })) +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + async function freshImport() { const stamp = `${Date.now()}-${Math.random()}` return { diff --git a/src/services/tips/tipScheduler.test.ts b/src/services/tips/tipScheduler.test.ts index 530067f0e..a3c15232b 100644 --- a/src/services/tips/tipScheduler.test.ts +++ b/src/services/tips/tipScheduler.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, mock, test } from 'bun:test' +import { afterAll, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import type { Tip } from './types.js' const settingsRef: { @@ -18,6 +22,8 @@ const configRef: { const relevantTipsRef: { value: Tip[] } = { value: [] } +await acquireSharedMutationLock('services/tips/tipScheduler.test.ts') + mock.module('../../utils/settings/settings.js', () => ({ getSettings_DEPRECATED: () => settingsRef.value, getInitialSettings: () => settingsRef.value, @@ -39,6 +45,14 @@ mock.module('../analytics/index.js', () => ({ logEvent: () => undefined, })) +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + async function freshScheduler() { const stamp = `${Date.now()}-${Math.random()}` return import(`./tipScheduler.ts?ts=${stamp}`) diff --git a/src/tools.lsp.test.ts b/src/tools.lsp.test.ts index 293db4a7a..169ef71ed 100644 --- a/src/tools.lsp.test.ts +++ b/src/tools.lsp.test.ts @@ -1,18 +1,67 @@ -import { beforeEach, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, expect, mock, test } from 'bun:test' import { getEmptyToolPermissionContext } from './Tool.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from './test/sharedMutationLock.js' let lspConnected = false +const HOOK_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'PermissionRequest', + 'PermissionDenied', + 'Setup', + 'TeammateIdle', + 'TaskCreated', + 'TaskCompleted', + 'Elicitation', + 'ElicitationResult', + 'ConfigChange', + 'WorktreeCreate', + 'WorktreeRemove', + 'InstructionsLoaded', + 'CwdChanged', + 'FileChanged', +] as const + +await acquireSharedMutationLock('tools.lsp.test.ts') + +mock.module('./entrypoints/agentSdkTypes.js', () => ({ HOOK_EVENTS })) +mock.module('src/entrypoints/agentSdkTypes.js', () => ({ HOOK_EVENTS })) mock.module('./services/lsp/manager.js', () => ({ getInitializationStatus: () => ({ status: 'success' }), getLspServerManager: () => undefined, + initializeLspServerManager: async () => {}, isLspConnected: () => lspConnected, reinitializeLspServerManager: () => {}, + resetLspServerManagerForTesting: () => {}, + shutdownLspServerManager: async () => {}, waitForInitialization: async () => {}, })) const { getAllBaseTools, getTools } = await import('./tools.js') +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + beforeEach(() => { lspConnected = false }) diff --git a/src/tools/BashTool/bashPermissions.test.ts b/src/tools/BashTool/bashPermissions.test.ts index 84af86b51..02097527a 100644 --- a/src/tools/BashTool/bashPermissions.test.ts +++ b/src/tools/BashTool/bashPermissions.test.ts @@ -1,6 +1,10 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { getEmptyToolPermissionContext } from '../../Tool.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' import { bashToolHasPermission, stripAllLeadingEnvVars } from './bashPermissions.js' @@ -11,15 +15,30 @@ const originalSandboxMethods = { areUnsandboxedCommandsAllowed: SandboxManager.areUnsandboxedCommandsAllowed, getExcludedCommands: SandboxManager.getExcludedCommands, } +const originalMacro = (globalThis as Record).MACRO +const hadOriginalMacro = Object.hasOwn(globalThis, 'MACRO') + +beforeEach(async () => { + await acquireSharedMutationLock('tools/BashTool/bashPermissions.test.ts') +}) afterEach(() => { - SandboxManager.isSandboxingEnabled = - originalSandboxMethods.isSandboxingEnabled - SandboxManager.isAutoAllowBashIfSandboxedEnabled = - originalSandboxMethods.isAutoAllowBashIfSandboxedEnabled - SandboxManager.areUnsandboxedCommandsAllowed = - originalSandboxMethods.areUnsandboxedCommandsAllowed - SandboxManager.getExcludedCommands = originalSandboxMethods.getExcludedCommands + try { + SandboxManager.isSandboxingEnabled = + originalSandboxMethods.isSandboxingEnabled + SandboxManager.isAutoAllowBashIfSandboxedEnabled = + originalSandboxMethods.isAutoAllowBashIfSandboxedEnabled + SandboxManager.areUnsandboxedCommandsAllowed = + originalSandboxMethods.areUnsandboxedCommandsAllowed + SandboxManager.getExcludedCommands = originalSandboxMethods.getExcludedCommands + if (hadOriginalMacro) { + ;(globalThis as Record).MACRO = originalMacro + } else { + delete (globalThis as Record).MACRO + } + } finally { + releaseSharedMutationLock() + } }) function makeToolUseContext() { diff --git a/src/tools/BashTool/shouldUseSandbox.test.ts b/src/tools/BashTool/shouldUseSandbox.test.ts index f2b9f0dc7..b49680ec7 100644 --- a/src/tools/BashTool/shouldUseSandbox.test.ts +++ b/src/tools/BashTool/shouldUseSandbox.test.ts @@ -1,5 +1,9 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' import { BashTool } from './BashTool.js' import { PowerShellTool } from '../PowerShellTool/PowerShellTool.js' @@ -10,11 +14,19 @@ const originalSandboxMethods = { areUnsandboxedCommandsAllowed: SandboxManager.areUnsandboxedCommandsAllowed, } +beforeEach(async () => { + await acquireSharedMutationLock('tools/BashTool/shouldUseSandbox.test.ts') +}) + afterEach(() => { - SandboxManager.isSandboxingEnabled = - originalSandboxMethods.isSandboxingEnabled - SandboxManager.areUnsandboxedCommandsAllowed = - originalSandboxMethods.areUnsandboxedCommandsAllowed + try { + SandboxManager.isSandboxingEnabled = + originalSandboxMethods.isSandboxingEnabled + SandboxManager.areUnsandboxedCommandsAllowed = + originalSandboxMethods.areUnsandboxedCommandsAllowed + } finally { + releaseSharedMutationLock() + } }) test('model-facing Bash schema rejects dangerouslyDisableSandbox', () => { diff --git a/src/tools/WebFetchTool/applyPromptFallback.test.ts b/src/tools/WebFetchTool/applyPromptFallback.test.ts index f9914a74b..bcdb1b4d0 100644 --- a/src/tools/WebFetchTool/applyPromptFallback.test.ts +++ b/src/tools/WebFetchTool/applyPromptFallback.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' // Mock the Anthropic-API-side before importing the module under test, so // queryHaiku resolves into whatever the individual test wants (slow, failing, @@ -7,6 +11,7 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' const haikuMock = mock() beforeEach(async () => { + await acquireSharedMutationLock('tools/WebFetchTool/applyPromptFallback.test.ts') haikuMock.mockReset() const actual = await import('../../services/api/claude.js') mock.module('../../services/api/claude.js', () => ({ @@ -16,7 +21,11 @@ beforeEach(async () => { }) afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) async function runApply(markdown = 'Hello world.', signal?: AbortSignal): Promise { diff --git a/src/tools/WebFetchTool/domainCheck.test.ts b/src/tools/WebFetchTool/domainCheck.test.ts index 243fedcee..580a4b10d 100644 --- a/src/tools/WebFetchTool/domainCheck.test.ts +++ b/src/tools/WebFetchTool/domainCheck.test.ts @@ -1,20 +1,31 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import axios from 'axios' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const originalEnv = { ...process.env } +const originalAxiosGet = axios.get async function importFreshModule() { mock.restore() return import(`./utils.ts?ts=${Date.now()}-${Math.random()}`) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('tools/WebFetchTool/domainCheck.test.ts') process.env = { ...originalEnv } }) afterEach(() => { - process.env = { ...originalEnv } - mock.restore() + try { + process.env = { ...originalEnv } + axios.get = originalAxiosGet + mock.restore() + } finally { + releaseSharedMutationLock() + } }) describe('checkDomainBlocklist', () => { diff --git a/src/tools/WebSearchTool/providers/brave.test.ts b/src/tools/WebSearchTool/providers/brave.test.ts index 2eb685393..a57f2014f 100644 --- a/src/tools/WebSearchTool/providers/brave.test.ts +++ b/src/tools/WebSearchTool/providers/brave.test.ts @@ -1,20 +1,32 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' - -import { braveProvider } from './brave.ts' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' + +import { braveProvider } from './brave.ts' const originalEnv = { BRAVE_API_KEY: process.env.BRAVE_API_KEY, } -const originalFetch = globalThis.fetch - -afterEach(() => { - for (const [k, v] of Object.entries(originalEnv)) { - if (v === undefined) delete process.env[k] - else process.env[k] = v - } - globalThis.fetch = originalFetch -}) +const originalFetch = globalThis.fetch + +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/brave.test.ts') +}) + +afterEach(() => { + try { + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + globalThis.fetch = originalFetch + } finally { + releaseSharedMutationLock() + } +}) describe('braveProvider isConfigured', () => { test('true when BRAVE_API_KEY is set', () => { diff --git a/src/tools/WebSearchTool/providers/custom.test.ts b/src/tools/WebSearchTool/providers/custom.test.ts index e2be6b47a..12b0465a7 100644 --- a/src/tools/WebSearchTool/providers/custom.test.ts +++ b/src/tools/WebSearchTool/providers/custom.test.ts @@ -1,6 +1,23 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' import { extractHits, customProvider, isPrivateHostname } from './custom.js' +async function importFreshCustomProvider() { + const stamp = `${Date.now()}-${Math.random()}` + return import(`./custom.ts?ts=${stamp}`) +} + +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/custom.test.ts') +}) + +afterEach(() => { + releaseSharedMutationLock() +}) + // --------------------------------------------------------------------------- // extractHits — flexible response parsing // --------------------------------------------------------------------------- @@ -261,8 +278,8 @@ describe('built-in preset request shapes', () => { return new Response(JSON.stringify({ items: [] }), { status: 200 }) }) as typeof fetch - const { customProvider } = require('./custom.js') - await customProvider.search({ query: 'hello world' }) + const { customProvider: freshCustomProvider } = await importFreshCustomProvider() + await freshCustomProvider.search({ query: 'hello world' }) expect(capturedUrl).toContain('https://www.googleapis.com/customsearch/v1') expect(capturedUrl).toContain('key=gck-test-key') diff --git a/src/tools/WebSearchTool/providers/exa.test.ts b/src/tools/WebSearchTool/providers/exa.test.ts index 67a1577e3..050c12980 100644 --- a/src/tools/WebSearchTool/providers/exa.test.ts +++ b/src/tools/WebSearchTool/providers/exa.test.ts @@ -1,20 +1,32 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' - -import { exaProvider } from './exa.ts' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' + +import { exaProvider } from './exa.ts' const originalEnv = { EXA_API_KEY: process.env.EXA_API_KEY, } -const originalFetch = globalThis.fetch - -afterEach(() => { - for (const [k, v] of Object.entries(originalEnv)) { - if (v === undefined) delete process.env[k] - else process.env[k] = v - } - globalThis.fetch = originalFetch -}) +const originalFetch = globalThis.fetch + +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/exa.test.ts') +}) + +afterEach(() => { + try { + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + globalThis.fetch = originalFetch + } finally { + releaseSharedMutationLock() + } +}) describe('exaProvider isConfigured', () => { test('true when EXA_API_KEY is set', () => { diff --git a/src/tools/WebSearchTool/providers/firecrawl.test.ts b/src/tools/WebSearchTool/providers/firecrawl.test.ts index d0e3d0e93..dc643e34b 100644 --- a/src/tools/WebSearchTool/providers/firecrawl.test.ts +++ b/src/tools/WebSearchTool/providers/firecrawl.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' import { firecrawlProvider } from './firecrawl.ts' @@ -15,9 +19,17 @@ function restoreEnv(key: string, value: string | undefined): void { } } +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/firecrawl.test.ts') +}) + afterEach(() => { - restoreEnv('FIRECRAWL_API_KEY', originalEnv.FIRECRAWL_API_KEY) - restoreEnv('FIRECRAWL_API_URL', originalEnv.FIRECRAWL_API_URL) + try { + restoreEnv('FIRECRAWL_API_KEY', originalEnv.FIRECRAWL_API_KEY) + restoreEnv('FIRECRAWL_API_URL', originalEnv.FIRECRAWL_API_URL) + } finally { + releaseSharedMutationLock() + } }) describe('firecrawlProvider isConfigured', () => { @@ -28,7 +40,7 @@ describe('firecrawlProvider isConfigured', () => { }) test('true when FIRECRAWL_API_URL is set only', () => { - process.env.FIRECRAWL_API_KEY = undefined + delete process.env.FIRECRAWL_API_KEY process.env.FIRECRAWL_API_URL = 'https://self-hosted.firecrawl.dev' expect(firecrawlProvider.isConfigured()).toBe(true) }) diff --git a/src/tools/WebSearchTool/providers/index.test.ts b/src/tools/WebSearchTool/providers/index.test.ts index 26934d2ea..aa87a8632 100644 --- a/src/tools/WebSearchTool/providers/index.test.ts +++ b/src/tools/WebSearchTool/providers/index.test.ts @@ -1,22 +1,43 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' import { getProviderMode, getProviderChain, getAvailableProviders } from './index.js' import type { ProviderMode } from './index.js' +const savedWebSearchEnv = { + WEB_SEARCH_PROVIDER: process.env.WEB_SEARCH_PROVIDER, + TAVILY_API_KEY: process.env.TAVILY_API_KEY, +} + +function restoreWebSearchEnv() { + for (const [key, value] of Object.entries(savedWebSearchEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/index.test.ts') +}) + +afterEach(() => { + try { + restoreWebSearchEnv() + } finally { + releaseSharedMutationLock() + } +}) + // --------------------------------------------------------------------------- // getProviderMode // --------------------------------------------------------------------------- describe('getProviderMode', () => { - const savedEnv = process.env.WEB_SEARCH_PROVIDER - - afterEach(() => { - if (savedEnv === undefined) { - delete process.env.WEB_SEARCH_PROVIDER - } else { - process.env.WEB_SEARCH_PROVIDER = savedEnv - } - }) - test('returns auto by default', () => { delete process.env.WEB_SEARCH_PROVIDER expect(getProviderMode()).toBe('auto') diff --git a/src/utils/apiPreconnect.test.ts b/src/utils/apiPreconnect.test.ts index 194846c1b..f45297eb8 100644 --- a/src/utils/apiPreconnect.test.ts +++ b/src/utils/apiPreconnect.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realProviders from './model/providers.js' const originalEnv = { ...process.env } const originalFetch = globalThis.fetch @@ -8,20 +13,27 @@ async function importFreshModule() { return import(`./apiPreconnect.ts?ts=${Date.now()}-${Math.random()}`) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/apiPreconnect.test.ts') process.env = { ...originalEnv } }) afterEach(() => { - process.env = { ...originalEnv } - globalThis.fetch = originalFetch - mock.restore() + try { + process.env = { ...originalEnv } + globalThis.fetch = originalFetch + mock.restore() + mock.module('./model/providers.js', () => realProviders) + } finally { + releaseSharedMutationLock() + } }) describe('preconnectAnthropicApi', () => { test('does not fetch when OpenAI mode is enabled', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'openai', })) const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 }))) @@ -36,6 +48,7 @@ describe('preconnectAnthropicApi', () => { test('does not fetch when Gemini mode is enabled', async () => { process.env.CLAUDE_CODE_USE_GEMINI = '1' mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'gemini', })) const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 }))) @@ -50,6 +63,7 @@ describe('preconnectAnthropicApi', () => { test('does not fetch when GitHub mode is enabled', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'github', })) const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 }))) @@ -77,6 +91,7 @@ describe('preconnectAnthropicApi', () => { delete process.env.CLAUDE_CODE_CLIENT_KEY mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'firstParty', })) const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 }))) diff --git a/src/utils/buildConfig.test.ts b/src/utils/buildConfig.test.ts index b58fcfa87..b07f85372 100644 --- a/src/utils/buildConfig.test.ts +++ b/src/utils/buildConfig.test.ts @@ -1,20 +1,39 @@ -import { expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' import { isAntEmployee } from './buildConfig.ts' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' // Finding #42-2: process.env.USER_TYPE === 'ant' is checked directly in multiple // places, allowing any external user to activate Anthropic-internal code paths. // In OpenClaude, this must always be false regardless of env var. +let originalUserType: string | undefined + +beforeEach(async () => { + await acquireSharedMutationLock('utils/buildConfig.test.ts') + originalUserType = process.env.USER_TYPE +}) + +afterEach(() => { + try { + if (originalUserType === undefined) { + delete process.env.USER_TYPE + } else { + process.env.USER_TYPE = originalUserType + } + } finally { + releaseSharedMutationLock() + } +}) + test('isAntEmployee always returns false in OpenClaude regardless of USER_TYPE env var', () => { - const original = process.env.USER_TYPE process.env.USER_TYPE = 'ant' expect(isAntEmployee()).toBe(false) - process.env.USER_TYPE = original }) test('isAntEmployee returns false even when USER_TYPE is unset', () => { - const original = process.env.USER_TYPE delete process.env.USER_TYPE expect(isAntEmployee()).toBe(false) - process.env.USER_TYPE = original }) diff --git a/src/utils/codexCredentials.test.ts b/src/utils/codexCredentials.test.ts index dd5be38be..5fc6b6211 100644 --- a/src/utils/codexCredentials.test.ts +++ b/src/utils/codexCredentials.test.ts @@ -2,7 +2,11 @@ * These tests avoid static imports so Bun can mock secureStorage before * codexCredentials is first loaded. */ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' function makeJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })) @@ -16,20 +20,28 @@ describe('codexCredentials', () => { const originalCodeKey = process.env.CODEX_API_KEY const originalFetch = globalThis.fetch + beforeEach(async () => { + await acquireSharedMutationLock('utils/codexCredentials.test.ts') + }) + afterEach(() => { - mock.restore() - globalThis.fetch = originalFetch + try { + mock.restore() + globalThis.fetch = originalFetch - if (originalSimple === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = originalSimple - } + if (originalSimple === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = originalSimple + } - if (originalCodeKey === undefined) { - delete process.env.CODEX_API_KEY - } else { - process.env.CODEX_API_KEY = originalCodeKey + if (originalCodeKey === undefined) { + delete process.env.CODEX_API_KEY + } else { + process.env.CODEX_API_KEY = originalCodeKey + } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/conversationRecovery.hooks.test.ts b/src/utils/conversationRecovery.hooks.test.ts index 988f7cd39..e1891b1c0 100644 --- a/src/utils/conversationRecovery.hooks.test.ts +++ b/src/utils/conversationRecovery.hooks.test.ts @@ -3,10 +3,16 @@ * conversationRecovery so Bun's mock.module can replace sessionStart before * that module is first loaded. */ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realProviders from './model/providers.js' +import * as realSessionStart from './sessionStart.js' const tempDirs: string[] = [] const originalEnv = { ...process.env } @@ -44,13 +50,24 @@ async function writeJsonl(entry: unknown): Promise { return filePath } -afterEach(async () => { - mock.restore() +beforeEach(async () => { + await acquireSharedMutationLock('utils/conversationRecovery.hooks.test.ts') mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'firstParty', })) - process.env = { ...originalEnv } - await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +afterEach(async () => { + try { + mock.restore() + mock.module('./model/providers.js', () => realProviders) + mock.module('./sessionStart.js', () => realSessionStart) + process.env = { ...originalEnv } + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) + } finally { + releaseSharedMutationLock() + } }) test('loadConversationForResume rejects oversized transcripts before resume hooks run', async () => { @@ -60,6 +77,7 @@ test('loadConversationForResume rejects oversized transcripts before resume hook const hookSpy = mock(() => Promise.resolve([{ type: 'hook' }])) mock.module('./sessionStart.js', () => ({ + ...realSessionStart, processSessionStartHooks: hookSpy, })) @@ -109,6 +127,7 @@ test('deserializeMessagesWithInterruptDetection strips thinking blocks only for ] mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'openai', })) @@ -130,6 +149,7 @@ test('deserializeMessagesWithInterruptDetection strips thinking blocks only for ).not.toContain('only hidden reasoning') mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => 'bedrock', })) diff --git a/src/utils/conversationRecovery.test.ts b/src/utils/conversationRecovery.test.ts index 22d605da9..fe94001ea 100644 --- a/src/utils/conversationRecovery.test.ts +++ b/src/utils/conversationRecovery.test.ts @@ -1,7 +1,12 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realProviders from './model/providers.js' const tempDirs: string[] = [] const originalSimple = process.env.CLAUDE_CODE_SIMPLE @@ -59,23 +64,37 @@ async function writeJsonl(entry: unknown): Promise { return filePath } +beforeEach(async () => { + await acquireSharedMutationLock('utils/conversationRecovery.test.ts') +}) + afterEach(async () => { - mock.restore() - process.env.CLAUDE_CODE_SIMPLE = originalSimple - for (const key of providerEnvKeys) { - const value = originalProviderEnv[key] - if (value === undefined) { - delete process.env[key] + try { + mock.restore() + mock.module('./model/providers.js', () => realProviders) + if (originalSimple === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE } else { - process.env[key] = value + process.env.CLAUDE_CODE_SIMPLE = originalSimple } + for (const key of providerEnvKeys) { + const value = originalProviderEnv[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) + } finally { + releaseSharedMutationLock() } - await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) async function importFreshConversationRecovery() { mock.restore() mock.module('./model/providers.js', () => ({ + ...realProviders, getAPIProvider: () => { if (process.env.CLAUDE_CODE_USE_GITHUB) return 'github' if (process.env.CLAUDE_CODE_USE_OPENAI) return 'openai' diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index e92314de4..dca3ce2be 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -1,4 +1,8 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' // Import the real auth.js and providerConfig.js up front so we can spread // their export surfaces into mock factories. `mock.module()` is process-global // in bun:test and `mock.restore()` does not undo it (see user.test.ts), so @@ -12,8 +16,16 @@ import * as actualGrowthbook from 'src/services/analytics/growthbook.js' import * as actualProviders from './model/providers.js' import * as actualModelSupportOverrides from './model/modelSupportOverrides.js' +beforeEach(async () => { + await acquireSharedMutationLock('utils/effort.codex.test.ts') +}) + afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) async function importFreshEffortModule(options: { diff --git a/src/utils/execFileNoThrow.test.ts b/src/utils/execFileNoThrow.test.ts index 5bc012f2c..d4ee74f06 100644 --- a/src/utils/execFileNoThrow.test.ts +++ b/src/utils/execFileNoThrow.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -55,11 +55,15 @@ test('execFileNoThrowWithCwd preserves Windows .cmd compatibility', async () => const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const dir = mkdtempSync(join(tmpdir(), 'openclaude-execfile-')) - const file = join(dir, 'hello.cmd') - writeFileSync(file, '@echo off\r\necho hello\r\n') + try { + const file = join(dir, 'hello.cmd') + writeFileSync(file, '@echo off\r\necho hello\r\n') - const result = await execFileNoThrowWithCwd(file, []) + const result = await execFileNoThrowWithCwd(file, []) - expect(result.code).toBe(0) - expect(result.stdout).toContain('hello') + expect(result.code).toBe(0) + expect(result.stdout).toContain('hello') + } finally { + rmSync(dir, { recursive: true, force: true }) + } }) diff --git a/src/utils/fastMode.test.ts b/src/utils/fastMode.test.ts index 74e211fff..6fdbeefab 100644 --- a/src/utils/fastMode.test.ts +++ b/src/utils/fastMode.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const originalEnv = { ...process.env } @@ -188,13 +192,21 @@ function forceFirstPartyProviderEnv(): void { delete process.env.OPENAI_MODEL } +beforeEach(async () => { + await acquireSharedMutationLock('utils/fastMode.test.ts') +}) + afterEach(async () => { - mock.restore() - process.env = { ...originalEnv } - const { resetStateForTests } = await import('../bootstrap/state.js') - resetStateForTests() - const { _setGlobalConfigCacheForTesting } = await import('./config.js') - _setGlobalConfigCacheForTesting(null) + try { + mock.restore() + process.env = { ...originalEnv } + const { resetStateForTests } = await import('../bootstrap/state.js') + resetStateForTests() + const { _setGlobalConfigCacheForTesting } = await import('./config.js') + _setGlobalConfigCacheForTesting(null) + } finally { + releaseSharedMutationLock() + } }) describe('fastMode ant-only fallback cleanup', () => { diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index d748bd27f..41aca6c2b 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' async function importFileModuleWithKillswitchEnabled( killswitchEnabled: boolean, @@ -10,8 +14,16 @@ async function importFileModuleWithKillswitchEnabled( return import(`./file.js?ts=${Date.now()}-${Math.random()}`) } -afterEach(() => { - mock.restore() +beforeAll(async () => { + await acquireSharedMutationLock('utils/file.test.ts') +}) + +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) describe('addLineNumbers', () => { diff --git a/src/utils/geminiAuth.test.ts b/src/utils/geminiAuth.test.ts index 8bb9a5c29..f7572273b 100644 --- a/src/utils/geminiAuth.test.ts +++ b/src/utils/geminiAuth.test.ts @@ -1,5 +1,9 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { getGeminiProjectIdHint, mayHaveGeminiAdcCredentials, @@ -28,19 +32,27 @@ function restoreEnv(key: string, value: string | undefined): void { } } +beforeEach(async () => { + await acquireSharedMutationLock('utils/geminiAuth.test.ts') +}) + afterEach(() => { - restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY) - restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY) - restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN) - restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE) - restoreEnv( - 'GOOGLE_APPLICATION_CREDENTIALS', - originalEnv.GOOGLE_APPLICATION_CREDENTIALS, - ) - restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT) - restoreEnv('GCLOUD_PROJECT', originalEnv.GCLOUD_PROJECT) - restoreEnv('GOOGLE_PROJECT_ID', originalEnv.GOOGLE_PROJECT_ID) - restoreEnv('APPDATA', originalEnv.APPDATA) + try { + restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY) + restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY) + restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN) + restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE) + restoreEnv( + 'GOOGLE_APPLICATION_CREDENTIALS', + originalEnv.GOOGLE_APPLICATION_CREDENTIALS, + ) + restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT) + restoreEnv('GCLOUD_PROJECT', originalEnv.GCLOUD_PROJECT) + restoreEnv('GOOGLE_PROJECT_ID', originalEnv.GOOGLE_PROJECT_ID) + restoreEnv('APPDATA', originalEnv.APPDATA) + } finally { + releaseSharedMutationLock() + } }) describe('resolveGeminiCredential', () => { diff --git a/src/utils/geminiCredentials.test.ts b/src/utils/geminiCredentials.test.ts index a48abdf34..e4a05cbcc 100644 --- a/src/utils/geminiCredentials.test.ts +++ b/src/utils/geminiCredentials.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' type MockStorageData = Record @@ -26,7 +30,8 @@ async function importFreshModule() { return import(`./geminiCredentials.ts?ts=${Date.now()}-${Math.random()}`) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/geminiCredentials.test.ts') process.env = { ...originalEnv } delete process.env.CLAUDE_CODE_SIMPLE process.argv = originalArgv.filter(arg => arg !== '--bare') @@ -34,10 +39,14 @@ beforeEach(() => { }) afterEach(() => { - process.env = { ...originalEnv } - process.argv = [...originalArgv] - storageState = {} - mock.restore() + try { + process.env = { ...originalEnv } + process.argv = [...originalArgv] + storageState = {} + mock.restore() + } finally { + releaseSharedMutationLock() + } }) test('saveGeminiAccessToken stores and reads back the token', async () => { diff --git a/src/utils/githubModelsCredentials.hydrate.test.ts b/src/utils/githubModelsCredentials.hydrate.test.ts index fd0d257d9..4f9315b64 100644 --- a/src/utils/githubModelsCredentials.hydrate.test.ts +++ b/src/utils/githubModelsCredentials.hydrate.test.ts @@ -3,7 +3,11 @@ * githubModelsCredentials so Bun's mock.module can replace secureStorage * before that module is first loaded. */ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' describe('hydrateGithubModelsTokenFromSecureStorage', () => { const orig = { @@ -15,14 +19,22 @@ describe('hydrateGithubModelsTokenFromSecureStorage', () => { CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE, } + beforeEach(async () => { + await acquireSharedMutationLock('utils/githubModelsCredentials.hydrate.test.ts') + }) + afterEach(() => { - mock.restore() - for (const [k, v] of Object.entries(orig)) { - if (v === undefined) { - delete process.env[k as keyof typeof orig] - } else { - process.env[k as keyof typeof orig] = v + try { + mock.restore() + for (const [k, v] of Object.entries(orig)) { + if (v === undefined) { + delete process.env[k as keyof typeof orig] + } else { + process.env[k as keyof typeof orig] = v + } } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/githubModelsCredentials.refresh.test.ts b/src/utils/githubModelsCredentials.refresh.test.ts index 1e720c493..d7ca7ea04 100644 --- a/src/utils/githubModelsCredentials.refresh.test.ts +++ b/src/utils/githubModelsCredentials.refresh.test.ts @@ -1,4 +1,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realDeviceFlow from '../services/github/deviceFlow.js' +import * as realSecureStorage from './secureStorage/index.js' async function importFreshModule() { mock.restore() @@ -13,17 +19,25 @@ describe('refreshGithubModelsTokenIfNeeded', () => { GH_TOKEN: process.env.GH_TOKEN, } - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/githubModelsCredentials.refresh.test.ts') mock.restore() }) afterEach(() => { - for (const [k, v] of Object.entries(orig)) { - if (v === undefined) { - delete process.env[k as keyof typeof orig] - } else { - process.env[k as keyof typeof orig] = v + try { + mock.restore() + mock.module('./secureStorage/index.js', () => realSecureStorage) + mock.module('../services/github/deviceFlow.js', () => realDeviceFlow) + for (const [k, v] of Object.entries(orig)) { + if (v === undefined) { + delete process.env[k as keyof typeof orig] + } else { + process.env[k as keyof typeof orig] = v + } } + } finally { + releaseSharedMutationLock() } }) @@ -42,6 +56,7 @@ describe('refreshGithubModelsTokenIfNeeded', () => { } mock.module('./secureStorage/index.js', () => ({ + ...realSecureStorage, getSecureStorage: () => ({ read: () => store, update: (next: Record) => { @@ -52,6 +67,7 @@ describe('refreshGithubModelsTokenIfNeeded', () => { })) mock.module('../services/github/deviceFlow.js', () => ({ + ...realDeviceFlow, DEFAULT_GITHUB_DEVICE_SCOPE: 'read:user', exchangeForCopilotToken: async () => ({ token: `tid=fresh;exp=${futureExp};sku=free`, @@ -90,6 +106,7 @@ describe('refreshGithubModelsTokenIfNeeded', () => { })) mock.module('./secureStorage/index.js', () => ({ + ...realSecureStorage, getSecureStorage: () => ({ read: () => ({ githubModels: { @@ -102,6 +119,7 @@ describe('refreshGithubModelsTokenIfNeeded', () => { })) mock.module('../services/github/deviceFlow.js', () => ({ + ...realDeviceFlow, DEFAULT_GITHUB_DEVICE_SCOPE: 'read:user', exchangeForCopilotToken: exchangeSpy, })) diff --git a/src/utils/githubModelsCredentials.test.ts b/src/utils/githubModelsCredentials.test.ts index c31926e55..1eda8864e 100644 --- a/src/utils/githubModelsCredentials.test.ts +++ b/src/utils/githubModelsCredentials.test.ts @@ -1,4 +1,26 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +const originalSimple = process.env.CLAUDE_CODE_SIMPLE + +beforeEach(async () => { + await acquireSharedMutationLock('utils/githubModelsCredentials.test.ts') +}) + +afterEach(() => { + try { + if (originalSimple === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = originalSimple + } + } finally { + releaseSharedMutationLock() + } +}) describe('readGithubModelsToken', () => { test('returns undefined in bare mode', async () => { @@ -6,14 +28,8 @@ describe('readGithubModelsToken', () => { './githubModelsCredentials.js?read-bare-mode' ) - const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' expect(readGithubModelsToken()).toBeUndefined() - if (prev === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = prev - } }) }) @@ -23,16 +39,10 @@ describe('saveGithubModelsToken / clearGithubModelsToken', () => { './githubModelsCredentials.js?save-bare-mode' ) - const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' const r = saveGithubModelsToken('abc') expect(r.success).toBe(false) expect(r.warning).toContain('Bare mode') - if (prev === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = prev - } }) test('clear succeeds in bare mode', async () => { @@ -40,14 +50,8 @@ describe('saveGithubModelsToken / clearGithubModelsToken', () => { './githubModelsCredentials.js?clear-bare-mode' ) - const prev = process.env.CLAUDE_CODE_SIMPLE process.env.CLAUDE_CODE_SIMPLE = '1' expect(clearGithubModelsToken().success).toBe(true) - if (prev === undefined) { - delete process.env.CLAUDE_CODE_SIMPLE - } else { - process.env.CLAUDE_CODE_SIMPLE = prev - } }) }) diff --git a/src/utils/handlePromptSubmit.test.ts b/src/utils/handlePromptSubmit.test.ts index c6dbb4f5c..037fd7e87 100644 --- a/src/utils/handlePromptSubmit.test.ts +++ b/src/utils/handlePromptSubmit.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { getCommandQueue, resetCommandQueue } from './messageQueueManager.js' describe('handlePromptSubmit', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/handlePromptSubmit.test.ts') resetCommandQueue() mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {}, @@ -10,8 +15,12 @@ describe('handlePromptSubmit', () => { }) afterEach(() => { - resetCommandQueue() - mock.restore() + try { + resetCommandQueue() + mock.restore() + } finally { + releaseSharedMutationLock() + } }) it('queues prompt submissions during generation without interrupting the current turn', async () => { diff --git a/src/utils/hookChains.integration.test.ts b/src/utils/hookChains.integration.test.ts index dec63f010..b4fa1de33 100644 --- a/src/utils/hookChains.integration.test.ts +++ b/src/utils/hookChains.integration.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' type HookChainsModule = typeof import('./hookChains.js') @@ -100,22 +104,27 @@ async function importHookChainsHarness( return { mod, writeToMailboxSpy, agentToolCallSpy } } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/hookChains.integration.test.ts') process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1' }) afterEach(async () => { - mock.restore() + try { + mock.restore() - if (originalHookChainsEnabled === undefined) { - delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS - } else { - process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + if (originalHookChainsEnabled === undefined) { + delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + } else { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + } + + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) + } finally { + releaseSharedMutationLock() } - - await Promise.all( - tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), - ) }) describe('hookChains integration dispatch', () => { diff --git a/src/utils/hookChains.test.ts b/src/utils/hookChains.test.ts index a5575272c..738b82410 100644 --- a/src/utils/hookChains.test.ts +++ b/src/utils/hookChains.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' type HookChainsModule = typeof import('./hookChains.js') @@ -38,22 +42,27 @@ async function importHookChainsModule(options?: { return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`) } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/hookChains.test.ts') process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1' }) afterEach(async () => { - mock.restore() + try { + mock.restore() - if (originalHookChainsEnabled === undefined) { - delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS - } else { - process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + if (originalHookChainsEnabled === undefined) { + delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS + } else { + process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled + } + + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) + } finally { + releaseSharedMutationLock() } - - await Promise.all( - tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), - ) }) describe('hookChains schema validation', () => { diff --git a/src/utils/hybridContextStrategy.ts b/src/utils/hybridContextStrategy.ts index 9e1103460..92ad2ff59 100644 --- a/src/utils/hybridContextStrategy.ts +++ b/src/utils/hybridContextStrategy.ts @@ -111,9 +111,14 @@ function getCacheAge(message: Message): number { } function getMessageTokenCount(message: Message): number { + const countTextTokens = (text: string): number => { + if (text.length === 0) return 0 + return Math.max(1, roughTokenCountEstimation(text)) + } + const content = message.message?.content if (typeof content === 'string') { - return roughTokenCountEstimation(content) + return countTextTokens(content) } if (Array.isArray(content)) { let tokens = 0 @@ -123,17 +128,17 @@ function getMessageTokenCount(message: Message): number { const b = block as Record if (b.type === 'text' && typeof b.text === 'string') { - tokens += roughTokenCountEstimation(b.text) + tokens += countTextTokens(b.text) } else if (b.type === 'tool_use') { const inputSize = JSON.stringify(b.input ?? {}).length tokens += Math.ceil(inputSize / 4) + 20 } else if (b.type === 'tool_result') { if (typeof b.content === 'string') { - tokens += roughTokenCountEstimation(b.content) + tokens += countTextTokens(b.content) } else if (Array.isArray(b.content)) { for (const rc of b.content) { if (typeof rc === 'object' && rc !== null && 'text' in rc) { - tokens += roughTokenCountEstimation((rc as { text: string }).text) + tokens += countTextTokens((rc as { text: string }).text) } } } else { @@ -141,7 +146,7 @@ function getMessageTokenCount(message: Message): number { } if (b.is_error === true) tokens += 10 } else if (b.type === 'thinking' && typeof b.thinking === 'string') { - tokens += roughTokenCountEstimation(b.thinking) + tokens += countTextTokens(b.thinking) } } return tokens @@ -303,4 +308,4 @@ export function getHybridStats(split: ContextSplit) { messageCount: split.cached.length + split.fresh.length, efficiency: split.totalTokens / (split.cachedTokens + split.freshTokens + 1), } -} \ No newline at end of file +} diff --git a/src/utils/knowledgeGraph.stress.test.ts b/src/utils/knowledgeGraph.stress.test.ts index b98508fd4..f12cd427f 100644 --- a/src/utils/knowledgeGraph.stress.test.ts +++ b/src/utils/knowledgeGraph.stress.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach, afterAll } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { addGlobalEntity, addGlobalSummary, @@ -10,7 +10,7 @@ import { } from './knowledgeGraph.js' import { mkdtempSync, rmSync, existsSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { dirname, join } from 'path' import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' import { setClaudeConfigHomeDirForTesting } from './envUtils.js' import { getFsImplementation } from './fsOperations.js' @@ -18,8 +18,7 @@ import { getFsImplementation } from './fsOperations.js' describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalOrama = process.env.OPENCLAUDE_KNOWLEDGE_ORAMA - const configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-')) - const cwd = getFsImplementation().cwd() + let configDir: string | undefined const removeDirWithRetry = (dir: string) => { for (let attempt = 0; attempt < 5; attempt++) { @@ -47,6 +46,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { beforeEach(async () => { await acquireEnvMutex() + configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-')) process.env.CLAUDE_CONFIG_DIR = configDir process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = '1' setClaudeConfigHomeDirForTesting(configDir) @@ -69,14 +69,18 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { } setClaudeConfigHomeDirForTesting(undefined) } finally { - releaseEnvMutex() + const dirToRemove = configDir + configDir = undefined + try { + if (dirToRemove) { + removeDirWithRetry(dirToRemove) + } + } finally { + releaseEnvMutex() + } } }) - afterAll(() => { - removeDirWithRetry(configDir) - }) - it('handles high-volume entity insertion (Stress Test)', async () => { const count = 50 @@ -109,6 +113,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { // 1. Create a valid DB await addGlobalEntity('type', 'valid', { val: '1' }) const { getOramaPersistencePath } = await import('./knowledgeGraph.js') + const cwd = getFsImplementation().cwd() const oramaPath = getOramaPersistencePath(cwd) expect(existsSync(oramaPath)).toBe(true) @@ -126,8 +131,8 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { // 5. Verify the corrupted file was moved const { readdirSync } = await import('fs') - const projectsBaseDir = join(configDir, 'projects') - expect(existsSync(projectsBaseDir)).toBe(true) + const projectDir = dirname(oramaPath) + expect(existsSync(projectDir)).toBe(true) // Search recursively for the corrupted file const findCorrupted = (dir: string): boolean => { const entries = readdirSync(dir, { withFileTypes: true }) @@ -140,7 +145,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => { } return false } - expect(findCorrupted(projectsBaseDir)).toBe(true) + expect(findCorrupted(projectDir)).toBe(true) }) it('maintains consistency between JSON and Orama', async () => { diff --git a/src/utils/knowledgeGraph.test.ts b/src/utils/knowledgeGraph.test.ts index 518ba0615..7b58485c4 100644 --- a/src/utils/knowledgeGraph.test.ts +++ b/src/utils/knowledgeGraph.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach, afterAll } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import { addGlobalEntity, addGlobalRelation, @@ -18,8 +18,9 @@ import { sanitizePath } from './sessionStoragePortable.js' describe('KnowledgeGraph Global Persistence & RAG', () => { const originalConfigDir = process.env.CLAUDE_CONFIG_DIR - const configDir = mkdtempSync(join(tmpdir(), 'openclaude-test-')) const cwd = process.cwd() + let configDir: string | undefined + const removeDirWithRetry = (dir: string) => { for (let attempt = 0; attempt < 5; attempt++) { try { @@ -46,6 +47,7 @@ describe('KnowledgeGraph Global Persistence & RAG', () => { beforeEach(async () => { await acquireEnvMutex() + configDir = mkdtempSync(join(tmpdir(), 'openclaude-test-')) process.env.CLAUDE_CONFIG_DIR = configDir setClaudeConfigHomeDirForTesting(configDir) resetGlobalGraph() @@ -62,14 +64,18 @@ describe('KnowledgeGraph Global Persistence & RAG', () => { } setClaudeConfigHomeDirForTesting(undefined) } finally { - releaseEnvMutex() + const dirToRemove = configDir + configDir = undefined + try { + if (dirToRemove) { + removeDirWithRetry(dirToRemove) + } + } finally { + releaseEnvMutex() + } } }) - afterAll(() => { - removeDirWithRetry(configDir) - }) - it('persists entities across loads', async () => { await addGlobalEntity('tool', 'openclaude', { status: 'alpha' }) const path = getProjectGraphPath(cwd) diff --git a/src/utils/knowledgeGraph.ts b/src/utils/knowledgeGraph.ts index 2143d59ea..ca79645ed 100644 --- a/src/utils/knowledgeGraph.ts +++ b/src/utils/knowledgeGraph.ts @@ -702,7 +702,7 @@ export function resetGlobalGraph(): void { join(projectDir, 'knowledge.db-wal'), join(projectDir, 'knowledge.db-shm'), ]) { - removePathWithRetry(sqlitePath) + removePathWithRetry(sqlitePath, { requireMissingAfterCleanup: !sqliteCleared }) } const oramaPath = getOramaPersistencePath(cwd) diff --git a/src/utils/model/agent.test.ts b/src/utils/model/agent.test.ts index 9b979ab0c..4111b2c14 100644 --- a/src/utils/model/agent.test.ts +++ b/src/utils/model/agent.test.ts @@ -1,9 +1,21 @@ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' describe('getAgentModel provider-aware fallback', () => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/model/agent.test.ts') + }) + // Restore all mocks after each test afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) describe('Claude-native providers', () => { @@ -258,4 +270,4 @@ describe('getAgentModel provider-aware fallback', () => { expect(checkIsClaudeNativeProvider()).toBe(false) }) }) -}) \ No newline at end of file +}) diff --git a/src/utils/model/minimaxModels.test.ts b/src/utils/model/minimaxModels.test.ts index 74a61d74f..a1ef2b622 100644 --- a/src/utils/model/minimaxModels.test.ts +++ b/src/utils/model/minimaxModels.test.ts @@ -1,18 +1,27 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { getCachedMiniMaxModelOptions } from './minimaxModels.js' const ORIGINAL_MINIMAX_API_KEY = process.env.MINIMAX_API_KEY -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/model/minimaxModels.test.ts') process.env.MINIMAX_API_KEY = 'minimax-test' }) afterEach(() => { - if (ORIGINAL_MINIMAX_API_KEY === undefined) { - delete process.env.MINIMAX_API_KEY - } else { - process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY + try { + if (ORIGINAL_MINIMAX_API_KEY === undefined) { + delete process.env.MINIMAX_API_KEY + } else { + process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY + } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/model/model.github.test.ts b/src/utils/model/model.github.test.ts index a9280317a..418ab362e 100644 --- a/src/utils/model/model.github.test.ts +++ b/src/utils/model/model.github.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' -import { saveGlobalConfig } from '../config.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { getGlobalConfig, saveGlobalConfig } from '../config.js' import { getDefaultMainLoopModelSetting, getUserSpecifiedModelSetting } from './model.js' const env = { @@ -12,8 +16,18 @@ const env = { CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, OPENAI_MODEL: process.env.OPENAI_MODEL, } +const originalModel = getGlobalConfig().model -beforeEach(() => { +function restoreEnv(key: keyof typeof env): void { + if (env[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = env[key] + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('model/model.github.test.ts') process.env.CLAUDE_CODE_USE_GITHUB = '1' delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI @@ -28,17 +42,17 @@ beforeEach(() => { }) afterEach(() => { - process.env.CLAUDE_CODE_USE_GITHUB = env.CLAUDE_CODE_USE_GITHUB - process.env.CLAUDE_CODE_USE_OPENAI = env.CLAUDE_CODE_USE_OPENAI - process.env.CLAUDE_CODE_USE_GEMINI = env.CLAUDE_CODE_USE_GEMINI - process.env.CLAUDE_CODE_USE_BEDROCK = env.CLAUDE_CODE_USE_BEDROCK - process.env.CLAUDE_CODE_USE_VERTEX = env.CLAUDE_CODE_USE_VERTEX - process.env.CLAUDE_CODE_USE_FOUNDRY = env.CLAUDE_CODE_USE_FOUNDRY - process.env.OPENAI_MODEL = env.OPENAI_MODEL - saveGlobalConfig(current => ({ - ...current, - model: undefined, - })) + try { + for (const key of Object.keys(env) as Array) { + restoreEnv(key) + } + saveGlobalConfig(current => ({ + ...current, + model: originalModel, + })) + } finally { + releaseSharedMutationLock() + } }) test('github default model setting ignores non-string saved model', () => { diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index 093dedd94..2a20c4137 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' -import { saveGlobalConfig } from '../config.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { getGlobalConfig, saveGlobalConfig } from '../config.js' async function importFreshModelModule() { mock.restore() @@ -52,6 +56,7 @@ const SAVED_ENV = { CODEX_API_KEY: process.env.CODEX_API_KEY, CHATGPT_ACCOUNT_ID: process.env.CHATGPT_ACCOUNT_ID, } +const savedModel = getGlobalConfig().model function restoreEnv(key: keyof typeof SAVED_ENV): void { if (SAVED_ENV[key] === undefined) { @@ -61,7 +66,8 @@ function restoreEnv(key: keyof typeof SAVED_ENV): void { } } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('model/model.openai-shim-providers.test.ts') // Other test files (notably modelOptions.github.test.ts) install a // persistent mock.module for './providers.js' that overrides getAPIProvider // globally. Without mock.restore() here, those overrides bleed into this @@ -88,14 +94,18 @@ beforeEach(() => { }) afterEach(() => { - mock.restore() - for (const key of Object.keys(SAVED_ENV) as Array) { - restoreEnv(key) + try { + mock.restore() + for (const key of Object.keys(SAVED_ENV) as Array) { + restoreEnv(key) + } + saveGlobalConfig(current => ({ + ...current, + model: savedModel, + })) + } finally { + releaseSharedMutationLock() } - saveGlobalConfig(current => ({ - ...current, - model: undefined, - })) }) test('codex provider reads OPENAI_MODEL, not stale settings.model', async () => { diff --git a/src/utils/model/modelOptions.github.test.ts b/src/utils/model/modelOptions.github.test.ts index 2a5dd9bbf..5b7414cd8 100644 --- a/src/utils/model/modelOptions.github.test.ts +++ b/src/utils/model/modelOptions.github.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' import { mock } from 'bun:test' import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' -import { saveGlobalConfig } from '../config.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { getGlobalConfig, saveGlobalConfig } from '../config.js' import { resetSettingsCache, setSessionSettingsCache, @@ -32,6 +36,22 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, ANTHROPIC_CUSTOM_MODEL_OPTION: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION, } +const initialConfig = getGlobalConfig() +const originalConfig = { + additionalModelOptionsCache: structuredClone( + initialConfig.additionalModelOptionsCache ?? [], + ), + additionalModelOptionsCacheScope: + initialConfig.additionalModelOptionsCacheScope, + openaiAdditionalModelOptionsCache: structuredClone( + initialConfig.openaiAdditionalModelOptionsCache ?? [], + ), + openaiAdditionalModelOptionsCacheByProfile: structuredClone( + initialConfig.openaiAdditionalModelOptionsCacheByProfile ?? {}, + ), + providerProfiles: structuredClone(initialConfig.providerProfiles ?? []), + activeProviderProfileId: initialConfig.activeProviderProfileId, +} function restoreEnvValue( key: keyof typeof originalEnv, @@ -44,7 +64,8 @@ function restoreEnvValue( } } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('model/modelOptions.github.test.ts') mock.restore() setSessionSettingsCache({ settings: {}, errors: [] }) delete process.env.CLAUDE_CODE_USE_GITHUB @@ -60,27 +81,32 @@ beforeEach(() => { }) afterEach(() => { - mock.restore() - resetSettingsCache() - restoreEnvValue('CLAUDE_CODE_USE_GITHUB') - restoreEnvValue('CLAUDE_CODE_USE_OPENAI') - restoreEnvValue('CLAUDE_CODE_USE_GEMINI') - restoreEnvValue('CLAUDE_CODE_USE_BEDROCK') - restoreEnvValue('CLAUDE_CODE_USE_VERTEX') - restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY') - restoreEnvValue('OPENAI_MODEL') - restoreEnvValue('OPENAI_BASE_URL') - restoreEnvValue('ANTHROPIC_CUSTOM_MODEL_OPTION') - saveGlobalConfig(current => ({ - ...current, - additionalModelOptionsCache: [], - additionalModelOptionsCacheScope: undefined, - openaiAdditionalModelOptionsCache: [], - openaiAdditionalModelOptionsCacheByProfile: {}, - providerProfiles: [], - activeProviderProfileId: undefined, - })) - resetModelStringsForTestingOnly() + try { + mock.restore() + resetSettingsCache() + restoreEnvValue('CLAUDE_CODE_USE_GITHUB') + restoreEnvValue('CLAUDE_CODE_USE_OPENAI') + restoreEnvValue('CLAUDE_CODE_USE_GEMINI') + restoreEnvValue('CLAUDE_CODE_USE_BEDROCK') + restoreEnvValue('CLAUDE_CODE_USE_VERTEX') + restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY') + restoreEnvValue('OPENAI_MODEL') + restoreEnvValue('OPENAI_BASE_URL') + restoreEnvValue('ANTHROPIC_CUSTOM_MODEL_OPTION') + saveGlobalConfig(current => ({ + ...current, + additionalModelOptionsCache: originalConfig.additionalModelOptionsCache, + additionalModelOptionsCacheScope: originalConfig.additionalModelOptionsCacheScope, + openaiAdditionalModelOptionsCache: originalConfig.openaiAdditionalModelOptionsCache, + openaiAdditionalModelOptionsCacheByProfile: + originalConfig.openaiAdditionalModelOptionsCacheByProfile, + providerProfiles: originalConfig.providerProfiles, + activeProviderProfileId: originalConfig.activeProviderProfileId, + })) + resetModelStringsForTestingOnly() + } finally { + releaseSharedMutationLock() + } }) test('GitHub provider exposes default + all Copilot models in /model options', async () => { diff --git a/src/utils/model/modelStrings.github.test.ts b/src/utils/model/modelStrings.github.test.ts index c6e95f7c1..19c906115 100644 --- a/src/utils/model/modelStrings.github.test.ts +++ b/src/utils/model/modelStrings.github.test.ts @@ -1,6 +1,10 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { parseUserSpecifiedModel } from './model.js' import { getModelStrings } from './modelStrings.js' @@ -22,14 +26,27 @@ function clearProviderFlags(): void { delete process.env.CLAUDE_CODE_USE_FOUNDRY } +function restoreEnv(key: keyof typeof originalEnv): void { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('model/modelStrings.github.test.ts') +}) + afterEach(() => { - process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB - process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI - process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI - process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK - process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX - process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY - resetModelStringsForTestingOnly() + try { + for (const key of Object.keys(originalEnv) as Array) { + restoreEnv(key) + } + resetModelStringsForTestingOnly() + } finally { + releaseSharedMutationLock() + } }) test('GitHub provider model strings are concrete IDs', () => { diff --git a/src/utils/model/openaiModelDiscovery.test.ts b/src/utils/model/openaiModelDiscovery.test.ts index 6df56055b..507c8bced 100644 --- a/src/utils/model/openaiModelDiscovery.test.ts +++ b/src/utils/model/openaiModelDiscovery.test.ts @@ -1,6 +1,11 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import axios from 'axios' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +const originalAxiosGet = axios.get const originalEnv = { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, @@ -17,15 +22,24 @@ function restoreEnv(key: string, value: string | undefined): void { } } +beforeEach(async () => { + await acquireSharedMutationLock('utils/model/openaiModelDiscovery.test.ts') +}) + afterEach(() => { - mock.restore() - restoreEnv( - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', - originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, - ) - restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) - restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) - restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + try { + mock.restore() + axios.get = originalAxiosGet + restoreEnv( + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + originalEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, + ) + restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI) + restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) + restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + } finally { + releaseSharedMutationLock() + } }) test('skips legacy OpenAI-compatible model discovery when nonessential traffic is disabled', async () => { diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index 6600695fe..dc67b4cd2 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -1,4 +1,9 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const originalEnv = { CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, @@ -13,21 +18,31 @@ const originalEnv = { OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_MODEL: process.env.OPENAI_MODEL, XAI_API_KEY: process.env.XAI_API_KEY, + VENICE_API_KEY: process.env.VENICE_API_KEY, + MIMO_API_KEY: process.env.MIMO_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, } +function restoreEnv(key: keyof typeof originalEnv): void { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('model/providers.test.ts') +}) + afterEach(() => { - process.env.CLAUDE_CODE_USE_GEMINI = originalEnv.CLAUDE_CODE_USE_GEMINI - process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB - process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI - process.env.CLAUDE_CODE_USE_BEDROCK = originalEnv.CLAUDE_CODE_USE_BEDROCK - process.env.CLAUDE_CODE_USE_VERTEX = originalEnv.CLAUDE_CODE_USE_VERTEX - process.env.CLAUDE_CODE_USE_FOUNDRY = originalEnv.CLAUDE_CODE_USE_FOUNDRY - process.env.NVIDIA_NIM = originalEnv.NVIDIA_NIM - process.env.MINIMAX_API_KEY = originalEnv.MINIMAX_API_KEY - process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL - process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE - process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL - process.env.XAI_API_KEY = originalEnv.XAI_API_KEY + try { + for (const key of Object.keys(originalEnv) as Array) { + restoreEnv(key) + } + } finally { + releaseSharedMutationLock() + } }) async function importFreshProvidersModule() { diff --git a/src/utils/multiTurnContext.test.ts b/src/utils/multiTurnContext.test.ts index 97c959ef1..7f0acd1ab 100644 --- a/src/utils/multiTurnContext.test.ts +++ b/src/utils/multiTurnContext.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'bun:test' +import { afterEach, describe, expect, it, beforeEach } from 'bun:test' import { startNewTurn, getCurrentTurn, @@ -12,6 +12,10 @@ import { resetMultiTurnState, createMultiTurnTracker, } from './multiTurnContext.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' function createMessage(role: string, content: string): any { return { @@ -21,10 +25,21 @@ function createMessage(role: string, content: string): any { } describe('multiTurnContext', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/multiTurnContext.test.ts') + createMultiTurnTracker() resetMultiTurnState() }) + afterEach(() => { + try { + createMultiTurnTracker() + resetMultiTurnState() + } finally { + releaseSharedMutationLock() + } + }) + describe('startNewTurn', () => { it('creates a new turn', () => { const turn = startNewTurn() diff --git a/src/utils/openclaudeInstallSurfaces.test.ts b/src/utils/openclaudeInstallSurfaces.test.ts index d5f20b6af..8e55b129c 100644 --- a/src/utils/openclaudeInstallSurfaces.test.ts +++ b/src/utils/openclaudeInstallSurfaces.test.ts @@ -1,15 +1,37 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import * as fsPromises from 'fs/promises' import { homedir } from 'os' import { join } from 'path' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realEnv from './env.js' +import * as realEnvUtils from './envUtils.js' +import * as realExecFileNoThrow from './execFileNoThrow.js' const originalEnv = { ...process.env } const originalMacro = (globalThis as Record).MACRO +beforeEach(async () => { + await acquireSharedMutationLock('utils/openclaudeInstallSurfaces.test.ts') +}) + afterEach(() => { - process.env = { ...originalEnv } - ;(globalThis as Record).MACRO = originalMacro - mock.restore() + try { + process.env = { ...originalEnv } + if (originalMacro === undefined) { + delete (globalThis as Record).MACRO + } else { + ;(globalThis as Record).MACRO = originalMacro + } + mock.restore() + mock.module('../utils/env.js', () => realEnv) + mock.module('./envUtils.js', () => realEnvUtils) + mock.module('./execFileNoThrow.js', () => realExecFileNoThrow) + } finally { + releaseSharedMutationLock() + } }) async function importFreshInstallCommand() { @@ -22,6 +44,7 @@ async function importFreshInstaller() { test('install command displays ~/.local/bin/openclaude on non-Windows', async () => { mock.module('../utils/env.js', () => ({ + ...realEnv, env: { platform: 'darwin' }, })) @@ -32,6 +55,7 @@ test('install command displays ~/.local/bin/openclaude on non-Windows', async () test('install command displays openclaude.exe path on Windows', async () => { mock.module('../utils/env.js', () => ({ + ...realEnv, env: { platform: 'win32' }, })) @@ -56,6 +80,7 @@ test('cleanupNpmInstallations removes both openclaude and legacy claude local in })) mock.module('./execFileNoThrow.js', () => ({ + ...realExecFileNoThrow, execFileNoThrowWithCwd: async () => ({ code: 1, stderr: 'npm ERR! code E404', @@ -63,6 +88,7 @@ test('cleanupNpmInstallations removes both openclaude and legacy claude local in })) mock.module('./envUtils.js', () => ({ + ...realEnvUtils, getClaudeConfigHomeDir: () => join(homedir(), '.openclaude'), isEnvTruthy: (value: string | undefined) => value === '1', })) diff --git a/src/utils/plugins/gitEnv.test.ts b/src/utils/plugins/gitEnv.test.ts index 5b389c6dc..516c9ec54 100644 --- a/src/utils/plugins/gitEnv.test.ts +++ b/src/utils/plugins/gitEnv.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { __resetGitEnvWarningForTesting, buildGitChildEnv, @@ -62,16 +66,21 @@ describe('buildGitChildEnv', () => { const ORIGINAL_BAD_KEY = 'OPENCLAUDE_TEST_BAD_ENV_FOR_GIT' let originalValue: string | undefined - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/plugins/gitEnv.test.ts') __resetGitEnvWarningForTesting() originalValue = process.env[ORIGINAL_BAD_KEY] }) afterEach(() => { - if (originalValue === undefined) { - delete process.env[ORIGINAL_BAD_KEY] - } else { - process.env[ORIGINAL_BAD_KEY] = originalValue + try { + if (originalValue === undefined) { + delete process.env[ORIGINAL_BAD_KEY] + } else { + process.env[ORIGINAL_BAD_KEY] = originalValue + } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/plugins/lspRecommendation.test.ts b/src/utils/plugins/lspRecommendation.test.ts index bfa7686ca..93e061a39 100644 --- a/src/utils/plugins/lspRecommendation.test.ts +++ b/src/utils/plugins/lspRecommendation.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' type MarketplaceEntry = { name: string @@ -23,7 +27,14 @@ let config = { } let addMarketplaceSourceFn = mock(() => {}) +await acquireSharedMutationLock('utils/plugins/lspRecommendation.test.ts') + mock.module('./marketplaceManager.js', () => ({ + addMarketplaceSource: addMarketplaceSourceFn, + getMarketplaceCacheOnly: async (name: string) => ({ + plugins: marketplaces[name] ?? [], + }), + getMarketplacesCacheDir: () => '/tmp/openclaude-marketplaces', loadKnownMarketplacesConfig: async () => Object.fromEntries( Object.keys(marketplaces).map(name => [ @@ -31,10 +42,19 @@ mock.module('./marketplaceManager.js', () => ({ { installLocation: `/tmp/${name}` }, ]), ), + loadKnownMarketplacesConfigSafe: async () => + Object.fromEntries( + Object.keys(marketplaces).map(name => [ + name, + { installLocation: `/tmp/${name}` }, + ]), + ), getMarketplace: async (name: string) => ({ plugins: marketplaces[name] ?? [], }), - addMarketplaceSource: addMarketplaceSourceFn, + getPluginById: async () => undefined, + getPluginByIdCacheOnly: async () => undefined, + saveKnownMarketplacesConfig: mock(async () => {}), })) mock.module('../binaryCheck.js', () => ({ @@ -42,20 +62,92 @@ mock.module('../binaryCheck.js', () => ({ })) mock.module('./installedPluginsManager.js', () => ({ + addInstalledPlugin: mock(() => {}), + addPluginInstallation: mock(() => {}), + clearInstalledPluginsCache: mock(() => {}), + getInMemoryInstalledPlugins: () => ({ installations: {}, schemaVersion: 2 }), + getGitCommitSha: async () => undefined, + getPendingUpdateCount: () => 0, + getPendingUpdatesDetails: () => [], + hasPendingUpdates: () => false, + loadInstalledPluginsFromDisk: () => ({ + installations: {}, + schemaVersion: 2, + }), + removeAllPluginsForMarketplace: () => ({ removed: 0 }), + removeInstalledPlugin: mock(() => {}), + removePluginInstallation: mock(() => {}), + resetInMemoryState: mock(() => {}), isPluginInstalled: (pluginId: string) => installedPlugins.has(pluginId), + isPluginGloballyInstalled: (pluginId: string) => installedPlugins.has(pluginId), + updateInstallationPathOnDisk: mock(() => {}), })) mock.module('../config.js', () => ({ + checkHasTrustDialogAccepted: () => true, + enableConfigs: mock(() => {}), + getCurrentProjectConfig: () => ({}), getGlobalConfig: () => config, + getGlobalConfigWriteCount: () => 0, + getAutoUpdaterDisabledReason: () => null, + formatAutoUpdaterDisabledReason: () => 'enabled', + getManagedClaudeRulesDir: () => '/tmp/openclaude-managed-rules', + getMemoryPath: () => '/tmp/openclaude-memory.md', + getOrCreateUserID: () => 'test-user-id', + getProjectPathForConfig: () => '/tmp/openclaude-project-config.json', + getRemoteControlAtStartup: () => false, + getUserClaudeRulesDir: () => '/tmp/openclaude-user-rules', + isAutoUpdaterDisabled: () => false, + recordFirstStartTime: mock(() => {}), + getCustomApiKeyStatus: () => ({ hasCustomApiKey: false }), + isGlobalConfigKey: () => false, + isPathTrusted: () => true, + isProjectConfigKey: () => false, + resetTrustDialogAcceptedCacheForTesting: mock(() => {}), + shouldSkipPluginAutoupdate: () => false, saveGlobalConfig: mock((updater: (current: typeof config) => typeof config) => { config = updater(config) }), + saveCurrentProjectConfig: mock(() => {}), })) -const { - getMatchingLspPlugins, - listLspPluginCandidates, -} = await import('./lspRecommendation.js') +let getMatchingLspPlugins: typeof import('./lspRecommendation.js').getMatchingLspPlugins +let listLspPluginCandidates: typeof import('./lspRecommendation.js').listLspPluginCandidates + +function resetTestState(): void { + marketplaces = { + 'claude-plugins-official': [ + lspPlugin('typescript-lsp', 'typescript-language-server', [ + '.ts', + '.tsx', + '.js', + ]), + lspPlugin('pyright-lsp', 'pyright-langserver', ['.py', '.pyi']), + ], + community: [lspPlugin('rust-analyzer-lsp', 'rust-analyzer', ['.rs'])], + } + installedPlugins = new Set() + installedBinaries = new Set(['typescript-language-server']) + config = { + lspRecommendationDisabled: false, + lspRecommendationNeverPlugins: [], + lspRecommendationIgnoredCount: 0, + } + addMarketplaceSourceFn.mockClear() +} + +resetTestState() +const mod = await import('./lspRecommendation.js') +getMatchingLspPlugins = mod.getMatchingLspPlugins +listLspPluginCandidates = mod.listLspPluginCandidates + +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) function lspPlugin( name: string, @@ -78,25 +170,7 @@ function lspPlugin( } beforeEach(() => { - marketplaces = { - 'claude-plugins-official': [ - lspPlugin('typescript-lsp', 'typescript-language-server', [ - '.ts', - '.tsx', - '.js', - ]), - lspPlugin('pyright-lsp', 'pyright-langserver', ['.py', '.pyi']), - ], - community: [lspPlugin('rust-analyzer-lsp', 'rust-analyzer', ['.rs'])], - } - installedPlugins = new Set() - installedBinaries = new Set(['typescript-language-server']) - config = { - lspRecommendationDisabled: false, - lspRecommendationNeverPlugins: [], - lspRecommendationIgnoredCount: 0, - } - addMarketplaceSourceFn.mockClear() + resetTestState() }) describe('listLspPluginCandidates', () => { diff --git a/src/utils/plugins/officialMarketplaceStartupCheck.test.ts b/src/utils/plugins/officialMarketplaceStartupCheck.test.ts index 68dd14376..f4bfb8c71 100644 --- a/src/utils/plugins/officialMarketplaceStartupCheck.test.ts +++ b/src/utils/plugins/officialMarketplaceStartupCheck.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' type TestGlobalConfig = { officialMarketplaceAutoInstallAttempted?: boolean @@ -32,6 +36,8 @@ const addMarketplaceSource = mock(async () => ({ resolvedSource: {}, })) +await acquireSharedMutationLock('utils/plugins/officialMarketplaceStartupCheck.test.ts') + mock.module('../../services/analytics/growthbook.js', () => ({ getFeatureValue_CACHED_MAY_BE_STALE: () => true, })) @@ -41,8 +47,29 @@ mock.module('../../services/analytics/index.js', () => ({ })) mock.module('../config.js', () => ({ + checkHasTrustDialogAccepted: () => true, + enableConfigs: mock(() => {}), + getCurrentProjectConfig: () => ({}), getGlobalConfig: () => config, + getGlobalConfigWriteCount: () => 0, + getAutoUpdaterDisabledReason: () => null, + formatAutoUpdaterDisabledReason: () => 'enabled', + getManagedClaudeRulesDir: () => '/tmp/openclaude-managed-rules', + getMemoryPath: () => '/tmp/openclaude-memory.md', + getOrCreateUserID: () => 'test-user-id', + getProjectPathForConfig: () => '/tmp/openclaude-project-config.json', + getRemoteControlAtStartup: () => false, + getUserClaudeRulesDir: () => '/tmp/openclaude-user-rules', + isAutoUpdaterDisabled: () => false, + recordFirstStartTime: mock(() => {}), + getCustomApiKeyStatus: () => ({ hasCustomApiKey: false }), + isGlobalConfigKey: () => false, + isPathTrusted: () => true, + isProjectConfigKey: () => false, + resetTrustDialogAcceptedCacheForTesting: mock(() => {}), + shouldSkipPluginAutoupdate: () => false, saveGlobalConfig, + saveCurrentProjectConfig: mock(() => {}), })) mock.module('../debug.js', () => ({ @@ -64,8 +91,13 @@ mock.module('./marketplaceHelpers.js', () => ({ mock.module('./marketplaceManager.js', () => ({ addMarketplaceSource, + getMarketplace: async () => ({ plugins: [] }), + getMarketplaceCacheOnly: async () => ({ plugins: [] }), getMarketplacesCacheDir: () => '/tmp/openclaude-marketplaces', + getPluginById: async () => undefined, + getPluginByIdCacheOnly: async () => undefined, loadKnownMarketplacesConfig: async () => knownMarketplaces, + loadKnownMarketplacesConfigSafe: async () => knownMarketplaces, saveKnownMarketplacesConfig, })) @@ -73,9 +105,19 @@ mock.module('./officialMarketplaceGcs.js', () => ({ fetchOfficialMarketplaceFromGcs, })) -const { checkAndInstallOfficialMarketplace } = await import( - './officialMarketplaceStartupCheck.js' -) +let checkAndInstallOfficialMarketplace: + typeof import('./officialMarketplaceStartupCheck.js').checkAndInstallOfficialMarketplace + +const mod = await import('./officialMarketplaceStartupCheck.js') +checkAndInstallOfficialMarketplace = mod.checkAndInstallOfficialMarketplace + +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) beforeEach(() => { config = {} diff --git a/src/utils/plugins/pluginLoader.test.ts b/src/utils/plugins/pluginLoader.test.ts index eb2e3750f..99818752d 100644 --- a/src/utils/plugins/pluginLoader.test.ts +++ b/src/utils/plugins/pluginLoader.test.ts @@ -1,9 +1,13 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { setInlinePlugins } from '../../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import type { LoadedPlugin } from '../../types/plugin.js' import { clearPluginCache, @@ -14,10 +18,18 @@ import { } from './pluginLoader.js' import { clearPluginSkillsCache, getPluginSkills } from './loadPluginCommands.js' +beforeEach(async () => { + await acquireSharedMutationLock('utils/plugins/pluginLoader.test.ts') +}) + afterEach(() => { - setInlinePlugins([]) - clearPluginCache('pluginLoader.test cleanup') - clearPluginSkillsCache() + try { + setInlinePlugins([]) + clearPluginCache('pluginLoader.test cleanup') + clearPluginSkillsCache() + } finally { + releaseSharedMutationLock() + } }) function marketplacePlugin( diff --git a/src/utils/promptShellExecution.test.ts b/src/utils/promptShellExecution.test.ts index bd8f13537..76be2f918 100644 --- a/src/utils/promptShellExecution.test.ts +++ b/src/utils/promptShellExecution.test.ts @@ -1,16 +1,28 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' import { getEmptyToolPermissionContext } from '../Tool.js' import { BashTool } from '../tools/BashTool/BashTool.js' import { executeShellCommandsInPrompt } from './promptShellExecution.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const originalCall = BashTool.call const originalMapToolResultToToolResultBlockParam = BashTool.mapToolResultToToolResultBlockParam +beforeEach(async () => { + await acquireSharedMutationLock('utils/promptShellExecution.test.ts') +}) + afterEach(() => { - BashTool.call = originalCall - BashTool.mapToolResultToToolResultBlockParam = - originalMapToolResultToToolResultBlockParam + try { + BashTool.call = originalCall + BashTool.mapToolResultToToolResultBlockParam = + originalMapToolResultToToolResultBlockParam + } finally { + releaseSharedMutationLock() + } }) test('executeShellCommandsInPrompt normalizes null shell output', async () => { diff --git a/src/utils/providerDiscovery.test.ts b/src/utils/providerDiscovery.test.ts index 38d1738b8..f691b626d 100644 --- a/src/utils/providerDiscovery.test.ts +++ b/src/utils/providerDiscovery.test.ts @@ -1,4 +1,9 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' async function loadProviderDiscoveryModule() { // @ts-expect-error cache-busting query string for Bun module mocks @@ -10,9 +15,26 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, } +function restoreEnv(key: keyof typeof originalEnv): void { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('providerDiscovery.test.ts') +}) + afterEach(() => { - globalThis.fetch = originalFetch - process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL + try { + mock.restore() + globalThis.fetch = originalFetch + restoreEnv('OPENAI_BASE_URL') + } finally { + releaseSharedMutationLock() + } }) test('lists models from a local openai-compatible /models endpoint', async () => { diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index 9688e36f1..b060d54a8 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { parseProviderFlag, parseModelFlag, @@ -32,7 +36,8 @@ const ENV_KEYS = [ const originalEnv: Record = {} -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/providerFlag.test.ts') for (const key of ENV_KEYS) { originalEnv[key] = process.env[key] delete process.env[key] @@ -68,12 +73,16 @@ beforeEach(() => { }) afterEach(() => { - for (const key of ENV_KEYS) { - if (originalEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = originalEnv[key] + try { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index 792fd34fa..9f1441123 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test' import { ensureIntegrationsLoaded, getAllGateways } from '../integrations/index.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { getProviderValidationError, @@ -41,7 +45,8 @@ beforeAll(() => { ensureIntegrationsLoaded() }) -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/providerValidation.test.ts') for (const key of ENV_KEYS) { originalEnv[key] = process.env[key] delete process.env[key] @@ -49,12 +54,16 @@ beforeEach(() => { }) afterEach(() => { - for (const key of ENV_KEYS) { - if (originalEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = originalEnv[key] + try { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index e0e94bfbb..3ba512b67 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -1,24 +1,33 @@ -import { expect, test, mock, describe, beforeEach, afterEach } from "bun:test"; -import { linuxSecretStorage } from "./linuxSecretStorage.js"; -import { windowsCredentialStorage } from "./windowsCredentialStorage.js"; +import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test"; +import * as realExeca from "execa"; import { getSecureStorageServiceName, CREDENTIALS_SERVICE_SUFFIX } from "./macOsKeychainHelpers.js"; import { acquireSharedMutationLock, releaseSharedMutationLock, } from "../../test/sharedMutationLock.js"; +import type { linuxSecretStorage as LinuxSecretStorage } from "./linuxSecretStorage.js"; +import type { windowsCredentialStorage as WindowsCredentialStorage } from "./windowsCredentialStorage.js"; // Mock execaSync const mockExecaSync = mock(() => ({ exitCode: 0, stdout: "" })); -mock.module("execa", () => ({ - execaSync: mockExecaSync, -})); describe("Secure Storage Platform Implementations", () => { const originalEnv = process.env; + let linuxSecretStorage: typeof LinuxSecretStorage; + let windowsCredentialStorage: typeof WindowsCredentialStorage; - beforeEach(async () => { + beforeAll(async () => { await acquireSharedMutationLock("platformStorage.test.ts"); + mock.module("execa", () => ({ + ...realExeca, + execaSync: mockExecaSync, + })); + ({ linuxSecretStorage } = await import("./linuxSecretStorage.js")); + ({ windowsCredentialStorage } = await import("./windowsCredentialStorage.js")); + }); + + beforeEach(() => { process.env = { ...originalEnv }; mockExecaSync.mockClear(); // Default mock behavior @@ -26,8 +35,13 @@ describe("Secure Storage Platform Implementations", () => { }); afterEach(() => { + process.env = originalEnv; + }); + + afterAll(() => { try { - process.env = originalEnv; + mock.restore(); + mock.module("execa", () => realExeca); } finally { releaseSharedMutationLock(); } diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts index 11e50428d..76a45af08 100644 --- a/src/utils/status.test.ts +++ b/src/utils/status.test.ts @@ -1,6 +1,10 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' const ORIGINAL_ENV = { ...process.env } @@ -41,9 +45,17 @@ async function readPropertyValue( ?.value } +beforeEach(async () => { + await acquireSharedMutationLock('utils/status.test.ts') +}) + afterEach(() => { - mock.restore() - restoreEnv() + try { + mock.restore() + restoreEnv() + } finally { + releaseSharedMutationLock() + } }) test('buildAPIProviderProperties labels NVIDIA NIM sessions', async () => { diff --git a/src/utils/storage/JSONProvider.test.ts b/src/utils/storage/JSONProvider.test.ts index a5d7c6c92..b47c10248 100644 --- a/src/utils/storage/JSONProvider.test.ts +++ b/src/utils/storage/JSONProvider.test.ts @@ -1,8 +1,12 @@ -import { afterEach, describe, expect, it } from 'bun:test' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { join } from 'path' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { JSONProvider } from './JSONProvider.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' const tempDirs: string[] = [] @@ -31,11 +35,19 @@ function captureConsoleError(run: () => T): { result: T; calls: unknown[][] } } } +beforeEach(async () => { + await acquireSharedMutationLock('utils/storage/JSONProvider.test.ts') +}) + afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop() - if (!dir) continue - rmSync(dir, { recursive: true, force: true }) + try { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (!dir) continue + rmSync(dir, { recursive: true, force: true }) + } + } finally { + releaseSharedMutationLock() } }) diff --git a/src/utils/storage/SQLiteProvider.test.ts b/src/utils/storage/SQLiteProvider.test.ts index 9b4d6cf9a..7457f4b6e 100644 --- a/src/utils/storage/SQLiteProvider.test.ts +++ b/src/utils/storage/SQLiteProvider.test.ts @@ -175,7 +175,7 @@ describe('SQLite Storage Layer', () => { expect(Object.keys(graph.entities).length).toBe(count) }) - it('does not report a closed on-disk database as cleared', async () => { + it('clears a closed on-disk database without an existing provider handle', async () => { const projectDir = join(getProjectsDir(), sanitizePath(workspaceDir)) const sqlitePath = join(projectDir, 'knowledge.db') @@ -185,6 +185,10 @@ describe('SQLite Storage Layer', () => { clearMemoryOnly() const closedProvider = new SQLiteProvider(projectDir) - expect(closedProvider.clear()).toBe(false) + expect(closedProvider.clear()).toBe(true) + + await closedProvider.init() + expect(closedProvider.loadGraph()).toBeNull() + closedProvider.close() }) }) diff --git a/src/utils/storage/SQLiteProvider.ts b/src/utils/storage/SQLiteProvider.ts index 41b790a68..e412f9b25 100644 --- a/src/utils/storage/SQLiteProvider.ts +++ b/src/utils/storage/SQLiteProvider.ts @@ -261,7 +261,27 @@ export class SQLiteProvider { public clear(): boolean { if (!this.db) { - return !existsSync(this.dbPath) + if (!existsSync(this.dbPath)) { + return true + } + + if (typeof Bun === 'undefined') { + return false + } + + try { + const { Database } = require('bun:sqlite') + this.db = new Database(this.dbPath) + this.db.exec('PRAGMA journal_mode = WAL;') + this.db.exec('PRAGMA foreign_keys = ON;') + this.createTables() + return this.clear() + } catch (e) { + console.error('Failed to open SQLite knowledge graph for clearing:', e) + return false + } finally { + this.close() + } } try { diff --git a/src/utils/swarm/spawnUtils.test.ts b/src/utils/swarm/spawnUtils.test.ts index 08bdd2cb0..b7e577292 100644 --- a/src/utils/swarm/spawnUtils.test.ts +++ b/src/utils/swarm/spawnUtils.test.ts @@ -1,20 +1,29 @@ import { afterEach, beforeEach, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' import { buildInheritedEnvVars } from './spawnUtils.js' const ORIGINAL_ENV = { ...process.env } -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/swarm/spawnUtils.test.ts') for (const key of Object.keys(process.env)) { delete process.env[key] } }) afterEach(() => { - for (const key of Object.keys(process.env)) { - delete process.env[key] + try { + for (const key of Object.keys(process.env)) { + delete process.env[key] + } + Object.assign(process.env, ORIGINAL_ENV) + } finally { + releaseSharedMutationLock() } - Object.assign(process.env, ORIGINAL_ENV) }) test('buildInheritedEnvVars marks spawned teammates as host-managed for provider routing', () => { diff --git a/src/utils/swarm/teammateModel.test.ts b/src/utils/swarm/teammateModel.test.ts index ec779eca4..51ed25c51 100644 --- a/src/utils/swarm/teammateModel.test.ts +++ b/src/utils/swarm/teammateModel.test.ts @@ -1,11 +1,22 @@ -import { afterEach, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +beforeEach(async () => { + await acquireSharedMutationLock('utils/swarm/teammateModel.test.ts') +}) afterEach(() => { - mock.restore() + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } }) async function importFreshTeammateModelModule(provider = 'mistral') { - mock.restore() mock.module('../model/providers.js', () => ({ getAPIProvider: () => provider, })) diff --git a/src/utils/thinking.test.ts b/src/utils/thinking.test.ts index 082e1d38e..c8575550e 100644 --- a/src/utils/thinking.test.ts +++ b/src/utils/thinking.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import { resetSettingsCache } from './settings/settingsCache.js' const ENV_KEYS = [ @@ -26,7 +30,8 @@ const ENV_KEYS = [ const originalEnv: Record = {} -beforeEach(() => { +beforeEach(async () => { + await acquireSharedMutationLock('utils/thinking.test.ts') for (const key of ENV_KEYS) { originalEnv[key] = process.env[key] delete process.env[key] @@ -35,15 +40,19 @@ beforeEach(() => { }) afterEach(() => { - mock.restore() - for (const key of ENV_KEYS) { - if (originalEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = originalEnv[key] + try { + mock.restore() + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } } + resetSettingsCache() + } finally { + releaseSharedMutationLock() } - resetSettingsCache() }) async function importFreshThinkingModule() { diff --git a/src/utils/user.test.ts b/src/utils/user.test.ts index 2c5654ba8..72cafeb9f 100644 --- a/src/utils/user.test.ts +++ b/src/utils/user.test.ts @@ -1,6 +1,17 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import * as realExeca from 'execa' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realAuth from './auth.js' +import * as realConfig from './config.js' +import * as realCwd from './cwd.js' +import * as realEnv from './env.js' +import * as realEnvUtils from './envUtils.js' const originalEnv = { ...process.env } +const originalMacro = (globalThis as Record).MACRO async function importFreshUserModule() { return import(`./user.ts?ts=${Date.now()}-${Math.random()}`) @@ -18,6 +29,7 @@ function installCommonMocks(options?: { // which is fine — these tests only assert email, not sessionId. mock.module('./auth.js', () => ({ + ...realAuth, getOauthAccountInfo: () => options?.oauthEmail ? { @@ -31,25 +43,30 @@ function installCommonMocks(options?: { })) mock.module('./config.js', () => ({ + ...realConfig, getGlobalConfig: () => ({}), getOrCreateUserID: () => 'device-test', })) mock.module('./cwd.js', () => ({ + ...realCwd, getCwd: () => 'C:\\repo', })) mock.module('./env.js', () => ({ + ...realEnv, env: { platform: 'windows' }, getHostPlatformForAnalytics: () => 'windows', })) mock.module('./envUtils.js', () => ({ + ...realEnvUtils, isEnvTruthy: (value: string | undefined) => !!value && value !== '0' && value.toLowerCase() !== 'false', })) mock.module('execa', () => ({ + ...realExeca, execa: async () => ({ exitCode: options?.gitEmail ? 0 : 1, stdout: options?.gitEmail ?? '', @@ -57,10 +74,28 @@ function installCommonMocks(options?: { })) } +beforeEach(async () => { + await acquireSharedMutationLock('utils/user.test.ts') +}) + afterEach(() => { - mock.restore() - process.env = { ...originalEnv } - delete (globalThis as Record).MACRO + try { + mock.restore() + mock.module('./auth.js', () => realAuth) + mock.module('./config.js', () => realConfig) + mock.module('./cwd.js', () => realCwd) + mock.module('./env.js', () => realEnv) + mock.module('./envUtils.js', () => realEnvUtils) + mock.module('execa', () => realExeca) + process.env = { ...originalEnv } + if (originalMacro === undefined) { + delete (globalThis as Record).MACRO + } else { + ;(globalThis as Record).MACRO = originalMacro + } + } finally { + releaseSharedMutationLock() + } }) describe('user email fallbacks', () => { diff --git a/tests/sdk/engine-mutators.test.ts b/tests/sdk/engine-mutators.test.ts index d02b4268a..435c4f9d9 100644 --- a/tests/sdk/engine-mutators.test.ts +++ b/tests/sdk/engine-mutators.test.ts @@ -2,19 +2,28 @@ import { describe, test, expect, afterEach, beforeAll, afterAll } from 'bun:test import { unstable_v2_createSession, } from '../../src/entrypoints/sdk/index.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' // sendMessage drains trigger init(), which checks auth. Stub it for CI. const AUTH_KEY = 'ANTHROPIC_API_KEY' let savedApiKey: string | undefined -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('tests/sdk/engine-mutators.test.ts') savedApiKey = process.env[AUTH_KEY] if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-engine-mutators-stub' }) afterAll(() => { - if (savedApiKey === undefined) delete process.env[AUTH_KEY] - else process.env[AUTH_KEY] = savedApiKey + try { + if (savedApiKey === undefined) delete process.env[AUTH_KEY] + else process.env[AUTH_KEY] = savedApiKey + } finally { + releaseSharedMutationLock() + } }) import { QueryEngine } from '../../src/QueryEngine.js' import type { QueryEngineConfig } from '../../src/QueryEngine.js' diff --git a/tests/sdk/mcp-cleanup.test.ts b/tests/sdk/mcp-cleanup.test.ts index 92c871a9d..515052c59 100644 --- a/tests/sdk/mcp-cleanup.test.ts +++ b/tests/sdk/mcp-cleanup.test.ts @@ -2,21 +2,44 @@ import { describe, test, expect, vi, beforeEach, afterEach, beforeAll, afterAll import { unstable_v2_createSession } from '../../src/entrypoints/sdk/index.js' import { query } from '../../src/entrypoints/sdk/index.js' import type { MCPServerConnection } from '../../src/services/mcp/types.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' // sendMessage drains trigger init(), which checks auth. Stub it for CI. const AUTH_KEY = 'ANTHROPIC_API_KEY' let savedApiKey: string | undefined -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('tests/sdk/mcp-cleanup.test.ts') savedApiKey = process.env[AUTH_KEY] if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-mcp-cleanup-stub' }) afterAll(() => { - if (savedApiKey === undefined) delete process.env[AUTH_KEY] - else process.env[AUTH_KEY] = savedApiKey + try { + if (savedApiKey === undefined) delete process.env[AUTH_KEY] + else process.env[AUTH_KEY] = savedApiKey + } finally { + releaseSharedMutationLock() + } }) +async function waitForMockCall( + mockFn: ReturnType, + timeoutMs = 1000, +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (mockFn.mock.calls.length > 0) { + return + } + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('Timed out waiting for MCP cleanup mock call') +} + describe('MCP cleanup on session close', () => { test('session.close() disconnects MCP clients', async () => { // Create a mock MCP client with a cleanup method @@ -44,8 +67,7 @@ describe('MCP cleanup on session close', () => { // Close the session session.close() - // Verify MCP client cleanup was called (fire-and-forget, wait a bit) - await new Promise(resolve => setTimeout(resolve, 10)) + await waitForMockCall(mockCleanup) expect(mockCleanup).toHaveBeenCalled() }) @@ -74,8 +96,7 @@ describe('MCP cleanup on session close', () => { // Close should not throw despite MCP cleanup error expect(() => session.close()).not.toThrow() - // Verify cleanup was attempted even though it will reject - await new Promise(resolve => setTimeout(resolve, 10)) + await waitForMockCall(mockCleanup) expect(mockCleanup).toHaveBeenCalled() }) @@ -156,8 +177,7 @@ describe('MCP cleanup on query close', () => { q.close() - // Verify cleanup was called (fire-and-forget, wait a bit) - await new Promise(resolve => setTimeout(resolve, 10)) + await waitForMockCall(mockCleanup) expect(mockCleanup).toHaveBeenCalled() }) @@ -183,7 +203,7 @@ describe('MCP cleanup on query close', () => { } expect(() => q.close()).not.toThrow() - await new Promise(resolve => setTimeout(resolve, 10)) + await waitForMockCall(mockCleanup) expect(mockCleanup).toHaveBeenCalled() }) @@ -214,4 +234,4 @@ describe('MCP cleanup on query close', () => { expect(() => q.close()).not.toThrow() }) -}) \ No newline at end of file +}) diff --git a/tests/sdk/query-happy-path.test.ts b/tests/sdk/query-happy-path.test.ts index 398774985..377eb6fff 100644 --- a/tests/sdk/query-happy-path.test.ts +++ b/tests/sdk/query-happy-path.test.ts @@ -1,6 +1,10 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' import { MockQueryEngine } from './helpers/mock-engine.js' import { query } from '../../src/entrypoints/sdk/index.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' // --------------------------------------------------------------------------- // No mock.module() — avoids module-cache leakage across test files. @@ -12,7 +16,8 @@ import { query } from '../../src/entrypoints/sdk/index.js' const AUTH_KEY = 'ANTHROPIC_API_KEY' let savedApiKey: string | undefined -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('tests/sdk/query-happy-path.test.ts') savedApiKey = process.env[AUTH_KEY] if (!savedApiKey) { process.env[AUTH_KEY] = 'sk-test-happy-path-stub' @@ -20,10 +25,14 @@ beforeAll(() => { }) afterAll(() => { - if (savedApiKey === undefined) { - delete process.env[AUTH_KEY] - } else { - process.env[AUTH_KEY] = savedApiKey + try { + if (savedApiKey === undefined) { + delete process.env[AUTH_KEY] + } else { + process.env[AUTH_KEY] = savedApiKey + } + } finally { + releaseSharedMutationLock() } }) diff --git a/tests/sdk/query-lifecycle.test.ts b/tests/sdk/query-lifecycle.test.ts index 8fb85df9f..577bc5d85 100644 --- a/tests/sdk/query-lifecycle.test.ts +++ b/tests/sdk/query-lifecycle.test.ts @@ -10,13 +10,18 @@ import { createMultiTurnConversation, UUID_REGEX, } from './helpers/query-test-doubles.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' // Tests that drain fully (no early interrupt) trigger init(), which checks // for auth credentials. Provide a stub key so init() succeeds without network. const AUTH_KEY = 'ANTHROPIC_API_KEY' let savedApiKey: string | undefined -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('tests/sdk/query-lifecycle.test.ts') savedApiKey = process.env[AUTH_KEY] if (!savedApiKey) { process.env[AUTH_KEY] = 'sk-test-lifecycle-stub' @@ -24,10 +29,14 @@ beforeAll(() => { }) afterAll(() => { - if (savedApiKey === undefined) { - delete process.env[AUTH_KEY] - } else { - process.env[AUTH_KEY] = savedApiKey + try { + if (savedApiKey === undefined) { + delete process.env[AUTH_KEY] + } else { + process.env[AUTH_KEY] = savedApiKey + } + } finally { + releaseSharedMutationLock() } }) diff --git a/tests/sdk/query-methods.test.ts b/tests/sdk/query-methods.test.ts index c4f00e08a..ae0b3c392 100644 --- a/tests/sdk/query-methods.test.ts +++ b/tests/sdk/query-methods.test.ts @@ -1,19 +1,28 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' import { query } from '../../src/entrypoints/sdk/index.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' // These tests don't iterate — they test QueryImpl methods that manipulate // internal state. Auth stub needed because query() triggers init() path. const AUTH_KEY = 'ANTHROPIC_API_KEY' let savedApiKey: string | undefined -beforeAll(() => { +beforeAll(async () => { + await acquireSharedMutationLock('tests/sdk/query-methods.test.ts') savedApiKey = process.env[AUTH_KEY] if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-query-methods-stub' }) afterAll(() => { - if (savedApiKey === undefined) delete process.env[AUTH_KEY] - else process.env[AUTH_KEY] = savedApiKey + try { + if (savedApiKey === undefined) delete process.env[AUTH_KEY] + else process.env[AUTH_KEY] = savedApiKey + } finally { + releaseSharedMutationLock() + } }) describe('QueryImpl.setModel', () => { diff --git a/tests/sdk/shared-utils.test.ts b/tests/sdk/shared-utils.test.ts index 0e91f6404..74111b730 100644 --- a/tests/sdk/shared-utils.test.ts +++ b/tests/sdk/shared-utils.test.ts @@ -1,10 +1,8 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect } from 'bun:test' import { assertValidSessionId, mapMessageToSDK, - acquireEnvMutex, - releaseEnvMutex, - resetEnvMutexForTesting, + createEnvMutexForTesting, } from '../../src/entrypoints/sdk/shared.js' describe('assertValidSessionId', () => { @@ -83,69 +81,87 @@ describe('mapMessageToSDK', () => { }) describe.serial('env mutex timeout', () => { - beforeEach(() => { - resetEnvMutexForTesting() - }) - - afterEach(() => { - resetEnvMutexForTesting() - }) - test('acquireEnvMutex returns timeout result when mutex is locked', async () => { + const { acquireEnvMutex, releaseEnvMutex } = createEnvMutexForTesting() // First acquire locks the mutex - const firstResult = await acquireEnvMutex() + const firstResult = await acquireEnvMutex({ timeoutMs: 5_000 }) expect(firstResult.acquired).toBe(true) // Second acquire with timeout should return timeout result - const secondResult = await acquireEnvMutex({ timeoutMs: 100 }) - expect(secondResult.acquired).toBe(false) - expect(secondResult.reason).toBe('timeout') - - // Clean up - releaseEnvMutex() + try { + const secondResult = await acquireEnvMutex({ timeoutMs: 100 }) + expect(secondResult.acquired).toBe(false) + expect(secondResult.reason).toBe('timeout') + } finally { + releaseEnvMutex() + } }) test('acquireEnvMutex succeeds before timeout', async () => { - await acquireEnvMutex() + const { acquireEnvMutex, releaseEnvMutex } = createEnvMutexForTesting() + const firstResult = await acquireEnvMutex({ timeoutMs: 5_000 }) + expect(firstResult.acquired).toBe(true) // Release after 50ms setTimeout(releaseEnvMutex, 50) // Second acquire with 200ms timeout should succeed - const result = await acquireEnvMutex({ timeoutMs: 200 }) - expect(result.acquired).toBe(true) - - releaseEnvMutex() + let acquiredSecond = false + try { + const result = await acquireEnvMutex({ timeoutMs: 200 }) + acquiredSecond = result.acquired + expect(result.acquired).toBe(true) + } finally { + if (acquiredSecond) { + releaseEnvMutex() + } + } }) test('acquireEnvMutex without timeout waits indefinitely (default behavior)', async () => { - await acquireEnvMutex() + const { acquireEnvMutex, releaseEnvMutex } = createEnvMutexForTesting() + const firstResult = await acquireEnvMutex({ timeoutMs: 5_000 }) + expect(firstResult.acquired).toBe(true) // Release after short delay setTimeout(releaseEnvMutex, 50) // No timeout option - should wait and succeed - const result = await acquireEnvMutex() - expect(result.acquired).toBe(true) - - releaseEnvMutex() + let acquiredSecond = false + try { + const result = await acquireEnvMutex() + acquiredSecond = result.acquired + expect(result.acquired).toBe(true) + } finally { + if (acquiredSecond) { + releaseEnvMutex() + } + } }) test('mutex remains functional after timeout', async () => { + const { acquireEnvMutex, releaseEnvMutex } = createEnvMutexForTesting() // First acquire locks it - await acquireEnvMutex() + const firstResult = await acquireEnvMutex({ timeoutMs: 5_000 }) + expect(firstResult.acquired).toBe(true) - // Second acquire with timeout fails - const result2 = await acquireEnvMutex({ timeoutMs: 50 }) - expect(result2.acquired).toBe(false) - - // Release the first - releaseEnvMutex() + try { + // Second acquire with timeout fails + const result2 = await acquireEnvMutex({ timeoutMs: 50 }) + expect(result2.acquired).toBe(false) + } finally { + // Release the first + releaseEnvMutex() + } // Third acquire should succeed (mutex not permanently locked) const result3 = await acquireEnvMutex({ timeoutMs: 100 }) - expect(result3.acquired).toBe(true) - - releaseEnvMutex() + try { + expect(result3.acquired).toBe(true) + } finally { + if (result3.acquired) { + releaseEnvMutex() + } + } }) }) diff --git a/tests/sdk/tool-schema-cache.test.ts b/tests/sdk/tool-schema-cache.test.ts index a0900610e..796339c0a 100644 --- a/tests/sdk/tool-schema-cache.test.ts +++ b/tests/sdk/tool-schema-cache.test.ts @@ -1,4 +1,8 @@ -import { describe, test, expect, beforeEach } from 'bun:test' +import { afterEach, describe, test, expect, beforeEach } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../src/test/sharedMutationLock.js' import { getToolSchemaCache, clearToolSchemaCache, @@ -6,10 +10,19 @@ import { } from '../../src/utils/toolSchemaCache.js' describe('invalidateRemovedToolSchemas', () => { - beforeEach(() => { + beforeEach(async () => { + await acquireSharedMutationLock('tests/sdk/tool-schema-cache.test.ts') clearToolSchemaCache() }) + afterEach(() => { + try { + clearToolSchemaCache() + } finally { + releaseSharedMutationLock() + } + }) + test('removes entries for tools not in retained set', () => { const cache = getToolSchemaCache() // Simulate cached tool schemas with different key formats @@ -81,4 +94,4 @@ describe('invalidateRemovedToolSchemas', () => { expect(cache.get('A')?.description).toBe('Tool A') expect(cache.get('B')?.description).toBe('Tool B') }) -}) \ No newline at end of file +}) diff --git a/vscode-extension/openclaude-vscode/src/extension.test.js b/vscode-extension/openclaude-vscode/src/extension.test.js index 2c2662806..bbd9141d0 100644 --- a/vscode-extension/openclaude-vscode/src/extension.test.js +++ b/vscode-extension/openclaude-vscode/src/extension.test.js @@ -1,6 +1,25 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { mock } = require('bun:test'); +const { + acquireEnvMutex, + releaseEnvMutex, +} = require('../../../src/entrypoints/sdk/shared.js'); + +test.beforeEach(async () => { + const result = await acquireEnvMutex(); + if (!result.acquired) { + throw new Error('Timed out acquiring shared test mutation lock for vscode extension test'); + } +}); + +test.afterEach(() => { + try { + mock.restore(); + } finally { + releaseEnvMutex(); + } +}); function createStatus(overrides = {}) { return {