Files
openclaude/tests/sdk/query-methods.test.ts
JATMNandGitHub f12eb1c9e8 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.
2026-05-16 15:18:42 +08:00

245 lines
7.5 KiB
TypeScript

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(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(() => {
try {
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
else process.env[AUTH_KEY] = savedApiKey
} finally {
releaseSharedMutationLock()
}
})
describe('QueryImpl.setModel', () => {
test('updates model in app state', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
await q.setModel('claude-haiku-4-5')
const state = (q as any).appStateStore.getState()
expect(state.mainLoopModel).toBe('claude-haiku-4-5')
expect(state.mainLoopModelForSession).toBe('claude-haiku-4-5')
q.interrupt()
})
})
describe('QueryImpl.supportedAgents', () => {
test('returns agentType list from active agents', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
// Simulate agents loaded into app state
;(q as any).appStateStore.setState(() => ({
...(q as any).appStateStore.getState(),
agentDefinitions: {
activeAgents: [
{ agentType: 'code-reviewer' },
{ agentType: 'test-runner' },
],
},
}))
const agents = q.supportedAgents()
expect(agents).toEqual(['code-reviewer', 'test-runner'])
q.interrupt()
})
test('returns empty array when no agents loaded', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const agents = q.supportedAgents()
expect(agents).toEqual([])
q.interrupt()
})
test('filters out entries with falsy agentType', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
;(q as any).appStateStore.setState(() => ({
...(q as any).appStateStore.getState(),
agentDefinitions: {
activeAgents: [
{ agentType: 'valid-agent' },
{ agentType: null },
{ agentType: '' },
],
},
}))
const agents = q.supportedAgents()
expect(agents).toEqual(['valid-agent'])
q.interrupt()
})
})
describe('QueryImpl.supportedCommands', () => {
test('returns command names from app state', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
;(q as any).appStateStore.setState(() => ({
...(q as any).appStateStore.getState(),
mcp: {
...(q as any).appStateStore.getState().mcp,
commands: [
{ name: '/help' },
{ name: '/clear' },
],
},
}))
const cmds = q.supportedCommands()
expect(cmds).toEqual(['/help', '/clear'])
q.interrupt()
})
test('returns empty array when no commands', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const cmds = q.supportedCommands()
expect(cmds).toEqual([])
q.interrupt()
})
})
describe('QueryImpl.supportedModels', () => {
test('returns current model as array', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
;(q as any).appStateStore.setState(() => ({
...(q as any).appStateStore.getState(),
mainLoopModel: 'claude-sonnet-4-6',
}))
const models = q.supportedModels()
expect(models).toEqual(['claude-sonnet-4-6'])
q.interrupt()
})
test('returns empty array when no model set', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
;(q as any).appStateStore.setState(() => ({
...(q as any).appStateStore.getState(),
mainLoopModel: undefined,
}))
const models = q.supportedModels()
expect(models).toEqual([])
q.interrupt()
})
})
describe('QueryImpl.setMaxThinkingTokens', () => {
test('enables thinking with budget', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
q.setMaxThinkingTokens(10000)
const state = (q as any).appStateStore.getState()
expect(state.thinkingEnabled).toBe(true)
expect(state.thinkingBudgetTokens).toBe(10000)
q.interrupt()
})
test('disables thinking when tokens is 0', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
// First enable
q.setMaxThinkingTokens(5000)
// Then disable
q.setMaxThinkingTokens(0)
const state = (q as any).appStateStore.getState()
expect(state.thinkingEnabled).toBe(false)
expect(state.thinkingBudgetTokens).toBeUndefined()
q.interrupt()
})
})
describe('QueryImpl.respondToPermission', () => {
test('resolves pending allow decision', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const promise = (q as any).registerPendingPermission('tool-123')
q.respondToPermission('tool-123', { behavior: 'allow' })
const decision = await promise
expect(decision.behavior).toBe('allow')
q.interrupt()
})
test('resolves pending deny decision with message', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const promise = (q as any).registerPendingPermission('tool-456')
q.respondToPermission('tool-456', {
behavior: 'deny',
message: 'Blocked by policy',
})
const decision = await promise
expect(decision.behavior).toBe('deny')
expect(decision.message).toBe('Blocked by policy')
q.interrupt()
})
test('deny with no message uses default', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const promise = (q as any).registerPendingPermission('tool-789')
q.respondToPermission('tool-789', { behavior: 'deny' })
const decision = await promise
expect(decision.behavior).toBe('deny')
expect(decision.message).toBe('Permission denied')
q.interrupt()
})
test('no-op for unknown toolUseId', () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
// Should not throw
expect(() =>
q.respondToPermission('nonexistent', { behavior: 'allow' })
).not.toThrow()
q.interrupt()
})
test('allow with updatedInput passes through', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const promise = (q as any).registerPendingPermission('tool-input')
q.respondToPermission('tool-input', {
behavior: 'allow',
updatedInput: { path: '/safe/dir' },
})
const decision = await promise
expect(decision.behavior).toBe('allow')
expect(decision.updatedInput).toEqual({ path: '/safe/dir' })
q.interrupt()
})
})
describe('QueryImpl.rewindFiles', () => {
test('returns canRewind false when no file history', async () => {
const q = query({ prompt: 'test', options: { cwd: process.cwd() } })
const result = await q.rewindFiles()
expect(result.canRewind).toBe(false)
q.interrupt()
})
})
// setPermissionMode is tested via buildPermissionContext in permissions.test.ts
// (mode mapping, additionalDirectories, bypass flag). The QueryImpl.setPermissionMode
// method delegates to buildPermissionContext + getTools + engine.updateTools — the
// latter two depend on CI environment state, so integration tests are fragile.