mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
Harden test isolation and smoke checks (#1440)
* fix(test): isolate provider-related attribution and preconnect tests
Remove process-global provider mocks from apiPreconnect tests and exercise real env-based provider resolution with hermetic first-party setup.
Reset bootstrap/settings state around attribution tests and reload the attribution module per test so provider and client state cannot leak across suites.
Verification: bun test --max-concurrency=1 src/utils/apiPreconnect.test.ts src/utils/attribution.test.ts
* Fix full local check failures
Add a check script that runs smoke plus the full single-concurrency Bun test suite, and wire it into CONTRIBUTING, the PR template, and PR checks.
Fix Windows/full-suite failures by preferring Git Bash over the WSL bash launcher, normalizing settings paths before source matching, making path and warning-glyph tests platform-aware, and restoring persistent Bun module mocks for AgentTool and hook-chain tests.
Verified with bun test src\tools\BashTool\BashTool.errorOutput.test.ts --max-concurrency=1 and bun run check.
* fix(test): eliminate mock.module() leaks and platform-specific test failures
## Problem
The full test suite (bun test --max-concurrency=1) had 10 failing tests on
Windows. Investigation revealed 4 distinct root causes, all stemming from
bun's mock.module() not being fully reversible by mock.restore(). When a
test file replaces a shared module via mock.module(), stale bindings persist
in already-imported modules even after mock.restore() is called. This is a
known bun limitation.
The CI (Ubuntu) only showed 1 consistent failure (the attribution test),
but the Windows-local failures exposed real bugs that could surface in CI
under different test ordering.
## Changes
### src/utils/hookChains.integration.test.ts (root polluter)
This file was the biggest source of test pollution with 9 mock.module()
calls replacing shared modules (analytics, growthbook, policyLimits,
teammateMailbox, teammate, AgentTool, replBridge, etc.) with partial
surfaces. For example, the teammateMailbox mock only exported writeToMailbox
but the real module has 20+ exports including isIdleNotification,
createIdleNotification, readMailbox, etc. When mock.restore() didn't fully
undo these mocks, downstream tests got undefined for missing exports.
Fix: Import real modules via cache-busted dynamic imports before setting up
mocks, then spread the real module surface into each mock.module() call.
This way even if the mock leaks, downstream tests see the full module
surface with only the intended overrides. All 9 mock.module calls now
spread their real module counterparts.
Also fixed: the test was failing in isolation with SyntaxError because
attachments.ts transitively imports isIdleNotification from
teammateMailbox.js, which was missing from the partial mock.
### src/utils/settings/changeDetector.test.ts (Windows path normalization)
4 tests failed because getSourceForPath() normalizes paths using
path.normalize() which converts forward slashes to backslashes on Windows.
The test hardcoded Unix-style paths (/tmp/openclaude/user/settings.json)
but path.normalize produces \tmp\openclaude\user\settings.json on
Windows. The path comparison always failed, so handleChange() returned
early without triggering any callbacks or debounce timers.
Fix: Import normalize from 'path' and apply it to all test path constants
(pathsBySource, getManagedSettingsDropInDir). This matches what the
production code does.
### src/utils/exportFormats.test.ts (Windows path separator)
resolveExportFilepath() uses path.join() which produces backslash-separated
paths on Windows. The test expected forward-slash paths.
Fix: Import join from 'path' and use it in the expected value so the
assertion is platform-agnostic.
### src/utils/file.test.ts (growthbook mock leak)
importFileModuleWithKillswitchEnabled() mocked growthbook.js with only
getFeatureValue_CACHED_MAY_BE_STALE: () => killswitchEnabled. When
killswitchEnabled was false, this poisoned isAgentSwarmsEnabled() for all
downstream tests because agentSwarmsEnabled.ts has a static import of
getFeatureValue_CACHED_MAY_BE_STALE that captured the mock binding.
Fix: Import the real growthbook module and spread it into the mock, so
all exports remain available even if the mock leaks.
### src/utils/plugins/officialMarketplaceStartupCheck.test.ts (same pattern)
Same growthbook mock leak pattern. Top-level mock.module with only
getFeatureValue_CACHED_MAY_BE_STALE: () => true.
Fix: Import real growthbook module and spread into mock.
### src/tools/AgentTool/AgentTool.teammateModel.test.ts (transitive mock binding)
4 tests failed with 'Agent Teams is not yet available on your plan' because
isAgentSwarmsEnabled() returned false. The function checks
getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true) from
growthbook.js, but the static import binding in agentSwarmsEnabled.ts was
captured from a leaked mock that returned false.
Cache-busting the AgentTool.js import doesn't help because
agentSwarmsEnabled.ts is a transitive dependency that keeps its
already-loaded (mocked) growthbook binding.
Fix: Add mock.module for agentSwarmsEnabled.js in importAgentToolWithSpawnMock()
to pin isAgentSwarmsEnabled to true, matching the test's intent (it sets
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).
## Verification
- bun run smoke: passes
- bun test --max-concurrency=1: 3019 pass, 0 fail (verified twice)
- No skipped tests (test.skip/it.skip/describe.skip), no test.todo,
no flaky markers, no test exclusions in config
## Known remaining risks
6 test files still have partial mock.module() calls on providers.js
(withRetry, officialRegistry, domainCheck, conversationRecovery, fastMode)
that don't spread the real module. These don't cause failures under current
test ordering but are latent risks if bun changes file execution order.
* Fix remaining provider mock leak risks
Address the known remaining risks from 7583157 by making provider mocks in withRetry, officialRegistry, and fastMode tests spread and restore the real providers module surface.
Verified with the targeted provider-mock test group and bun run check.
* Harden smoke test coverage
Remove the CI-only skip and unrelated error swallowing from the SDK query lifecycle tests so fork/resume behavior is asserted in CI and local runs.
Isolate test-suite global state by disabling built-in SDK agents for the lifecycle test, restoring MACRO presence exactly, clearing the agent cache, restoring axios mocks, and protecting xAI loopback tests from proxy/fetch leakage.
Make the provider API test script run serially to match the shared env/proxy mutation surface.
Validation: bun run check; CI=1 bun test tests\\sdk\\query-lifecycle.test.ts --max-concurrency=1; bun run test:provider; npm run test:provider-recommendation; bun run security:pr-scan -- --base upstream/main; bun run web:typecheck; bun run web:build; python -m pytest -q -p no:cacheprovider python/tests.
* Expose hidden SDK test failures
Tighten SDK test drains so they only suppress expected lifecycle abort errors instead of swallowing arbitrary init and bootstrap failures.
Replace no-op test assertions with real checks and add V2 lifecycle isolation for MACRO, built-in agents, and agent cache state.
Fix SDK V2 sendMessage to fast-exit when the caller-provided AbortController is already aborted, preventing aborted sessions from submitting work and producing result messages.
Validation: bun test scripts\\feature-flags-source-guard.test.ts tests\\sdk\\query-concurrency.test.ts tests\\sdk\\sdk-v2-lifecycle.test.ts --max-concurrency=1; bun test tests\\sdk\\query-concurrency.test.ts tests\\sdk\\query-lifecycle.test.ts tests\\sdk\\sdk-v2-lifecycle.test.ts --max-concurrency=1; bun run check.
* Fix CI smoke test failures
Respect SDK context null session project directories so regenerated SDK sessions do not fall back to global project state.
Isolate attribution tests from CI provider/model environment and replace nondeterministic live query permission checks with direct assertions against the SDK permission machinery.
Validation: bun test src\\utils\\attribution.test.ts tests\\sdk\\query-lifecycle.test.ts tests\\sdk\\permissions.test.ts --max-concurrency=1; bun test tests\\sdk\\sdk-context-isolation.test.ts tests\\sdk\\query-concurrency.test.ts --max-concurrency=1; bun run check.
* Stabilize attribution contract test
Assert that includeCoAuthoredBy emits the default co-author trailer without pinning the active provider's model label, which can legitimately differ in CI provider environments.
Validation: bun test src\\utils\\attribution.test.ts --max-concurrency=1; ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 CLAUDE_CODE_USE_BEDROCK=1 bun test src\\utils\\attribution.test.ts --max-concurrency=1; bun run check.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
- [ ] `bun run build`
|
||||
- [ ] `bun run smoke`
|
||||
- [ ] `bun run check`
|
||||
- [ ] focused tests:
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -39,11 +39,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Smoke check
|
||||
run: bun run smoke
|
||||
|
||||
- name: Full unit test suite
|
||||
run: bun test --max-concurrency=1
|
||||
- name: Smoke and full unit test suite
|
||||
run: bun run check
|
||||
|
||||
- name: Install Python test dependencies
|
||||
run: python -m pip install -r python/requirements.txt
|
||||
|
||||
+7
-2
@@ -32,6 +32,12 @@ Smoke test:
|
||||
bun run smoke
|
||||
```
|
||||
|
||||
Full local check:
|
||||
|
||||
```bash
|
||||
bun run check
|
||||
```
|
||||
|
||||
Run the app locally:
|
||||
|
||||
```bash
|
||||
@@ -60,8 +66,7 @@ At minimum, run the most relevant checks for your change.
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run smoke
|
||||
bun run check
|
||||
```
|
||||
|
||||
Focused tests:
|
||||
|
||||
+3
-1
@@ -51,15 +51,17 @@
|
||||
"web:preview": "bun run --cwd web preview",
|
||||
"web:typecheck": "bun run --cwd web typecheck",
|
||||
"test": "bun test",
|
||||
"test:full": "bun test --max-concurrency=1",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
|
||||
"test:coverage:ui": "bun run scripts/render-coverage-heatmap.ts",
|
||||
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
|
||||
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "bun run build && node dist/cli.mjs --version",
|
||||
"check": "bun run smoke && bun run test:full",
|
||||
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
|
||||
"build:verified": "bun run build && bun run verify:privacy",
|
||||
"test:provider": "bun test src/services/api/*.test.ts src/utils/context.test.ts",
|
||||
"test:provider": "bun test --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts",
|
||||
"doctor:runtime": "bun run scripts/system-check.ts",
|
||||
"doctor:runtime:json": "bun run scripts/system-check.ts --json",
|
||||
"doctor:report": "bun run scripts/system-check.ts --out reports/doctor-runtime.json",
|
||||
|
||||
@@ -42,6 +42,6 @@ test('build feature flags are not enabled without their source files', () => {
|
||||
|
||||
// When the source IS present, the flag can be either true or false; either
|
||||
// is fine. We only care about the "enabled but missing" combination.
|
||||
expect(true).toBe(true)
|
||||
expect(isEnabled && !sourceExists).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -514,7 +514,7 @@ export const onSessionSwitch = sessionSwitched.subscribe
|
||||
*/
|
||||
export function getSessionProjectDir(): string | null {
|
||||
const ctx = getSdkContext()
|
||||
return ctx?.sessionProjectDir ?? STATE.sessionProjectDir
|
||||
return ctx ? ctx.sessionProjectDir : STATE.sessionProjectDir
|
||||
}
|
||||
|
||||
export function getOriginalCwd(): string {
|
||||
|
||||
@@ -256,6 +256,10 @@ class SDKSessionImpl implements SDKSession {
|
||||
const self = this
|
||||
const inner = runWithSdkContext(sdkContext, () => {
|
||||
return (async function* (): AsyncGenerator<SDKMessage> {
|
||||
// Fast exit: if the caller's AbortController was already aborted
|
||||
// before iteration starts, do not initialize or submit a turn.
|
||||
if (self._abortController?.signal.aborted) return
|
||||
|
||||
await init()
|
||||
|
||||
// Load agent definitions once (not on every sendMessage call)
|
||||
@@ -310,7 +314,9 @@ class SDKSessionImpl implements SDKSession {
|
||||
switchSession(self._sessionId as SessionId, self._sessionProjectDir)
|
||||
|
||||
try {
|
||||
if (self._abortController?.signal.aborted) return
|
||||
for await (const engineMsg of self.engine.submitMessage(content)) {
|
||||
if (self._abortController?.signal.aborted) break
|
||||
yield engineMsg
|
||||
yield* self.drainTimeoutQueue()
|
||||
yield* self.drainAgentFailureQueue()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
type ProvidersModule = typeof import('../../utils/model/providers.js')
|
||||
|
||||
// Helper to build a mock APIError with specific headers
|
||||
function makeError(headers: Record<string, string>): APIError {
|
||||
@@ -16,6 +17,7 @@ function makeError(headers: Record<string, string>): APIError {
|
||||
|
||||
// Save/restore env vars between tests
|
||||
const originalEnv = { ...process.env }
|
||||
let originalProvidersModule: ProvidersModule | undefined
|
||||
|
||||
const envKeys = [
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
@@ -46,11 +48,20 @@ afterEach(() => {
|
||||
else process.env[key] = originalEnv[key]
|
||||
}
|
||||
mock.restore()
|
||||
if (originalProvidersModule) {
|
||||
mock.module('src/utils/model/providers.js', () => originalProvidersModule!)
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
async function importActualProviders(): Promise<ProvidersModule> {
|
||||
return import(
|
||||
`../../utils/model/providers.ts?withRetryActual=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
}
|
||||
|
||||
async function importFreshWithRetryModule(
|
||||
provider:
|
||||
| 'firstParty'
|
||||
@@ -63,7 +74,9 @@ async function importFreshWithRetryModule(
|
||||
| 'foundry' = 'firstParty',
|
||||
) {
|
||||
mock.restore()
|
||||
originalProvidersModule ??= await importActualProviders()
|
||||
mock.module('src/utils/model/providers.js', () => ({
|
||||
...originalProvidersModule!,
|
||||
getAPIProvider: () => provider,
|
||||
getAPIProviderForStatsig: () => provider,
|
||||
isFirstPartyAnthropicBaseUrl: () => provider === 'firstParty',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { fetch as httpFetch } from 'undici'
|
||||
|
||||
import { startXaiOAuthCallback } from './xaiOAuthCallback.js'
|
||||
|
||||
@@ -31,23 +32,42 @@ async function startTestServer() {
|
||||
return { handle, port }
|
||||
}
|
||||
|
||||
describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
describe.serial('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
let cleanup: (() => void) | null = null
|
||||
let savedProxyEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup = null
|
||||
savedProxyEnv = {
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
ALL_PROXY: process.env.ALL_PROXY,
|
||||
NO_PROXY: process.env.NO_PROXY,
|
||||
}
|
||||
delete process.env.HTTP_PROXY
|
||||
delete process.env.HTTPS_PROXY
|
||||
delete process.env.ALL_PROXY
|
||||
process.env.NO_PROXY = '127.0.0.1,localhost'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
for (const [key, value] of Object.entries(savedProxyEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
savedProxyEnv = {}
|
||||
})
|
||||
|
||||
test('OPTIONS preflight from auth.x.ai returns 204 with CORS echo', async () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://auth.x.ai',
|
||||
@@ -84,7 +104,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://auth.x.ai',
|
||||
@@ -102,7 +122,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://accounts.x.ai' },
|
||||
})
|
||||
@@ -116,7 +136,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://attacker.example.com' },
|
||||
})
|
||||
@@ -128,7 +148,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'http://auth.x.ai' },
|
||||
})
|
||||
@@ -140,7 +160,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://auth.x.ai.evil.example.com' },
|
||||
})
|
||||
@@ -153,7 +173,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const callbackPromise = handle.waitForCallback()
|
||||
const res = await fetch(
|
||||
const res = await httpFetch(
|
||||
`http://127.0.0.1:${port}/callback?code=ABC123&state=xyz`,
|
||||
{ headers: { Origin: 'https://auth.x.ai' } },
|
||||
)
|
||||
@@ -171,7 +191,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const callbackPromise = handle.waitForCallback()
|
||||
const res = await fetch(
|
||||
const res = await httpFetch(
|
||||
`http://127.0.0.1:${port}/callback?error=access_denied`,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
@@ -182,7 +202,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/something-else`)
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/something-else`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
let settled = false
|
||||
@@ -202,7 +222,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
const { handle, port } = await startTestServer()
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||
const res = await httpFetch(`http://127.0.0.1:${port}/callback`, {
|
||||
method: 'POST',
|
||||
body: 'code=ABC&state=xyz',
|
||||
})
|
||||
@@ -221,7 +241,7 @@ describe('startXaiOAuthCallback (CORS-aware loopback for xAI auth)', () => {
|
||||
cleanup = () => handle.close()
|
||||
|
||||
const callbackPromise = handle.waitForCallback()
|
||||
const res = await fetch(
|
||||
const res = await httpFetch(
|
||||
`http://127.0.0.1:${port}/callback?code=A&state=B`,
|
||||
)
|
||||
const body = await res.text()
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../test/sharedMutationLock.js'
|
||||
type ProvidersModule = typeof import('../../utils/model/providers.js')
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalAxiosGet = axios.get
|
||||
let originalProvidersModule: ProvidersModule | undefined
|
||||
|
||||
async function importFreshModule() {
|
||||
mock.restore()
|
||||
@@ -23,17 +25,32 @@ afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
axios.get = originalAxiosGet
|
||||
mock.restore()
|
||||
if (originalProvidersModule) {
|
||||
mock.module('../../utils/model/providers.js', () => originalProvidersModule!)
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
async function importActualProviders(): Promise<ProvidersModule> {
|
||||
return import(
|
||||
`../../utils/model/providers.ts?officialRegistryActual=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
}
|
||||
|
||||
async function mockApiProvider(provider: string): Promise<void> {
|
||||
originalProvidersModule ??= await importActualProviders()
|
||||
mock.module('../../utils/model/providers.js', () => ({
|
||||
...originalProvidersModule!,
|
||||
getAPIProvider: () => provider,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('prefetchOfficialMcpUrls', () => {
|
||||
test('does not fetch registry when using OpenAI mode', async () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
mock.module('../../utils/model/providers.js', () => ({
|
||||
getAPIProvider: () => 'openai',
|
||||
}))
|
||||
await mockApiProvider('openai')
|
||||
const getSpy = mock(() => Promise.resolve({ data: { servers: [] } }))
|
||||
axios.get = getSpy as typeof axios.get
|
||||
|
||||
@@ -45,9 +62,7 @@ describe('prefetchOfficialMcpUrls', () => {
|
||||
|
||||
test('does not fetch registry when using Gemini mode', async () => {
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
mock.module('../../utils/model/providers.js', () => ({
|
||||
getAPIProvider: () => 'gemini',
|
||||
}))
|
||||
await mockApiProvider('gemini')
|
||||
const getSpy = mock(() => Promise.resolve({ data: { servers: [] } }))
|
||||
axios.get = getSpy as typeof axios.get
|
||||
|
||||
@@ -62,9 +77,7 @@ describe('prefetchOfficialMcpUrls', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
mock.module('../../utils/model/providers.js', () => ({
|
||||
getAPIProvider: () => 'firstParty',
|
||||
}))
|
||||
await mockApiProvider('firstParty')
|
||||
const getSpy = mock(() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
|
||||
type ModelAllowlistModule = typeof import('../../utils/model/modelAllowlist.js')
|
||||
type SpawnMultiAgentModule = typeof import('../shared/spawnMultiAgent.js')
|
||||
type AgentSwarmsEnabledModule = typeof import('../../utils/agentSwarmsEnabled.js')
|
||||
type SpawnTeammateConfig = Parameters<SpawnMultiAgentModule['spawnTeammate']>[0]
|
||||
|
||||
let originalModelAllowlistModule: ModelAllowlistModule | undefined
|
||||
let originalSpawnMultiAgentModule: SpawnMultiAgentModule | undefined
|
||||
let originalAgentSwarmsEnabledModule: AgentSwarmsEnabledModule | undefined
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS:
|
||||
@@ -41,6 +43,12 @@ afterEach(async () => {
|
||||
() => originalSpawnMultiAgentModule!,
|
||||
)
|
||||
}
|
||||
if (originalAgentSwarmsEnabledModule) {
|
||||
mock.module(
|
||||
'../../utils/agentSwarmsEnabled.js',
|
||||
() => originalAgentSwarmsEnabledModule!,
|
||||
)
|
||||
}
|
||||
restoreEnv('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS')
|
||||
restoreEnv('CLAUDE_CODE_SUBAGENT_MODEL')
|
||||
} finally {
|
||||
@@ -69,12 +77,19 @@ async function importActualSpawnMultiAgent(): Promise<SpawnMultiAgentModule> {
|
||||
)
|
||||
}
|
||||
|
||||
async function importActualAgentSwarmsEnabled(): Promise<AgentSwarmsEnabledModule> {
|
||||
return import(
|
||||
`../../utils/agentSwarmsEnabled.ts?agentToolActual=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
}
|
||||
|
||||
async function importAgentToolWithSpawnMock(): Promise<{
|
||||
AgentTool: typeof import('./AgentTool.js').AgentTool
|
||||
spawnTeammate: ReturnType<typeof mock>
|
||||
}> {
|
||||
originalModelAllowlistModule ??= await importActualModelAllowlist()
|
||||
originalSpawnMultiAgentModule ??= await importActualSpawnMultiAgent()
|
||||
originalAgentSwarmsEnabledModule ??= await importActualAgentSwarmsEnabled()
|
||||
const spawnTeammate = mock(async () => ({
|
||||
data: {
|
||||
teammate_id: 'teammate-1',
|
||||
@@ -93,6 +108,15 @@ async function importAgentToolWithSpawnMock(): Promise<{
|
||||
...originalSpawnMultiAgentModule!,
|
||||
spawnTeammate,
|
||||
}))
|
||||
// Pin isAgentSwarmsEnabled to true — a prior test's mock.module on
|
||||
// growthbook.js may have left a stale binding in agentSwarmsEnabled.ts
|
||||
// that returns false for the killswitch check. Cache-busting AgentTool.js
|
||||
// doesn't help because agentSwarmsEnabled.ts is a transitive dep that
|
||||
// keeps its already-loaded (mocked) growthbook import.
|
||||
mock.module('../../utils/agentSwarmsEnabled.js', () => ({
|
||||
...originalAgentSwarmsEnabledModule!,
|
||||
isAgentSwarmsEnabled: () => true,
|
||||
}))
|
||||
|
||||
const { AgentTool } = await import(
|
||||
`./AgentTool.js?teammateModel=${Date.now()}-${Math.random()}`
|
||||
|
||||
+24
-3
@@ -94,12 +94,27 @@ export async function findSuitableShell(): Promise<string> {
|
||||
const isEnvShellSupported =
|
||||
env_shell && (env_shell.includes('bash') || env_shell.includes('zsh'))
|
||||
const preferBash = env_shell?.includes('bash')
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
// Try to locate shells using which (uses Bun.which when available)
|
||||
const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')])
|
||||
|
||||
// Populate shell paths from which results and fallback locations
|
||||
const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
|
||||
// Populate shell paths from which results and fallback locations. On Windows,
|
||||
// prefer Git Bash over the Windows/WSL bash launcher, which can exist even
|
||||
// when WSL cannot start a shell in the current environment.
|
||||
const shellPaths =
|
||||
isWindows
|
||||
? [
|
||||
'C:/Program Files/Git/bin',
|
||||
'C:/Program Files/Git/usr/bin',
|
||||
'C:/Program Files (x86)/Git/bin',
|
||||
'C:/Program Files (x86)/Git/usr/bin',
|
||||
'/bin',
|
||||
'/usr/bin',
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
]
|
||||
: ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
|
||||
|
||||
// Order shells based on user preference
|
||||
const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash']
|
||||
@@ -110,7 +125,13 @@ export async function findSuitableShell(): Promise<string> {
|
||||
// Add discovered paths to the beginning of our search list
|
||||
// Put the user's preferred shell type first
|
||||
if (preferBash) {
|
||||
if (bashPath) supportedShells.unshift(bashPath)
|
||||
if (bashPath) {
|
||||
if (isWindows) {
|
||||
supportedShells.push(bashPath)
|
||||
} else {
|
||||
supportedShells.unshift(bashPath)
|
||||
}
|
||||
}
|
||||
if (zshPath) supportedShells.push(zshPath)
|
||||
} else {
|
||||
if (zshPath) supportedShells.unshift(zshPath)
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
import * as realProviders from './model/providers.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalFetch = globalThis.fetch
|
||||
@@ -23,7 +22,6 @@ afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
globalThis.fetch = originalFetch
|
||||
mock.restore()
|
||||
mock.module('./model/providers.js', () => realProviders)
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
@@ -32,10 +30,6 @@ afterEach(() => {
|
||||
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 })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
|
||||
@@ -47,10 +41,6 @@ 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 })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
|
||||
@@ -62,10 +52,6 @@ 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 })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
|
||||
@@ -79,9 +65,21 @@ describe('preconnectAnthropicApi', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.OPENAI_API_BASE
|
||||
delete process.env.OPENAI_MODEL
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.XAI_API_KEY
|
||||
delete process.env.MINIMAX_API_KEY
|
||||
delete process.env.VENICE_API_KEY
|
||||
delete process.env.MIMO_API_KEY
|
||||
delete process.env.NVIDIA_NIM
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
delete process.env.HTTPS_PROXY
|
||||
delete process.env.https_proxy
|
||||
delete process.env.HTTP_PROXY
|
||||
@@ -90,10 +88,6 @@ describe('preconnectAnthropicApi', () => {
|
||||
delete process.env.CLAUDE_CODE_CLIENT_CERT
|
||||
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 })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { getClientType, setClientType } from '../bootstrap/state.js'
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import {
|
||||
getAttributionTexts,
|
||||
getDefaultCommitCoAuthorEmail,
|
||||
getDefaultCommitCoAuthorName,
|
||||
getEnhancedPRAttribution,
|
||||
} from './attribution.js'
|
||||
getClientType,
|
||||
resetStateForTests,
|
||||
setClientType,
|
||||
} from '../bootstrap/state.js'
|
||||
import {
|
||||
resetSettingsCache,
|
||||
setSessionSettingsCache,
|
||||
@@ -14,7 +12,11 @@ import type { SettingsJson } from './settings/types.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
||||
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OPENCLAUDE_DISABLE_CO_AUTHORED_BY:
|
||||
process.env.OPENCLAUDE_DISABLE_CO_AUTHORED_BY,
|
||||
CLAUDE_CODE_REMOTE_SESSION_ID: process.env.CLAUDE_CODE_REMOTE_SESSION_ID,
|
||||
@@ -22,6 +24,7 @@ const originalEnv = {
|
||||
USER_TYPE: process.env.USER_TYPE,
|
||||
}
|
||||
const originalClientType = getClientType()
|
||||
let attributionModule: typeof import('./attribution.js')
|
||||
|
||||
const defaultPrAttribution =
|
||||
'🤖 Generated with [OpenClaude](https://github.com/Gitlawb/openclaude)'
|
||||
@@ -40,18 +43,27 @@ function restoreEnv(): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
mock.restore()
|
||||
resetStateForTests()
|
||||
resetSettingsCache()
|
||||
setClientType('cli')
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_MODEL = 'gpt-5.5'
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
delete process.env.OPENCLAUDE_DISABLE_CO_AUTHORED_BY
|
||||
delete process.env.CLAUDE_CODE_REMOTE_SESSION_ID
|
||||
delete process.env.SESSION_INGRESS_URL
|
||||
delete process.env.USER_TYPE
|
||||
attributionModule = await import('./attribution.js')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
resetStateForTests()
|
||||
resetSettingsCache()
|
||||
setClientType(originalClientType)
|
||||
restoreEnv()
|
||||
@@ -60,7 +72,7 @@ afterEach(() => {
|
||||
describe('getDefaultCommitCoAuthorName', () => {
|
||||
it('does not label unknown non-Claude provider models as Opus', () => {
|
||||
expect(
|
||||
getDefaultCommitCoAuthorName({
|
||||
attributionModule.getDefaultCommitCoAuthorName({
|
||||
model: 'gpt-5.5',
|
||||
apiProvider: 'openai',
|
||||
isInternalRepo: false,
|
||||
@@ -70,7 +82,7 @@ describe('getDefaultCommitCoAuthorName', () => {
|
||||
|
||||
it('does not apply internal Claude formatting to non-Claude providers', () => {
|
||||
expect(
|
||||
getDefaultCommitCoAuthorName({
|
||||
attributionModule.getDefaultCommitCoAuthorName({
|
||||
model: 'gpt-5.5',
|
||||
apiProvider: 'openai',
|
||||
isInternalRepo: true,
|
||||
@@ -80,7 +92,7 @@ describe('getDefaultCommitCoAuthorName', () => {
|
||||
|
||||
it('keeps the codename-safe fallback for unknown first-party models', () => {
|
||||
expect(
|
||||
getDefaultCommitCoAuthorName({
|
||||
attributionModule.getDefaultCommitCoAuthorName({
|
||||
model: 'unreleased-internal-model',
|
||||
apiProvider: 'firstParty',
|
||||
isInternalRepo: false,
|
||||
@@ -90,7 +102,7 @@ describe('getDefaultCommitCoAuthorName', () => {
|
||||
|
||||
it('sanitizes unknown internal Claude co-author names', () => {
|
||||
expect(
|
||||
getDefaultCommitCoAuthorName({
|
||||
attributionModule.getDefaultCommitCoAuthorName({
|
||||
model: 'bad\nmodel<id>',
|
||||
apiProvider: 'firstParty',
|
||||
isInternalRepo: true,
|
||||
@@ -100,7 +112,7 @@ describe('getDefaultCommitCoAuthorName', () => {
|
||||
|
||||
it('does not duplicate the Claude prefix for Claude model names', () => {
|
||||
expect(
|
||||
getDefaultCommitCoAuthorName({
|
||||
attributionModule.getDefaultCommitCoAuthorName({
|
||||
model: 'claude-opus-4-6',
|
||||
apiProvider: 'firstParty',
|
||||
isInternalRepo: false,
|
||||
@@ -109,10 +121,10 @@ describe('getDefaultCommitCoAuthorName', () => {
|
||||
})
|
||||
|
||||
it('uses the OpenClaude email for commit attribution across providers', () => {
|
||||
expect(getDefaultCommitCoAuthorEmail('openai')).toBe(
|
||||
expect(attributionModule.getDefaultCommitCoAuthorEmail('openai')).toBe(
|
||||
'openclaude@gitlawb.com',
|
||||
)
|
||||
expect(getDefaultCommitCoAuthorEmail('firstParty')).toBe(
|
||||
expect(attributionModule.getDefaultCommitCoAuthorEmail('firstParty')).toBe(
|
||||
'openclaude@gitlawb.com',
|
||||
)
|
||||
})
|
||||
@@ -122,7 +134,7 @@ describe('getAttributionTexts', () => {
|
||||
it('returns no commit or PR attribution when no attribution settings are configured', () => {
|
||||
useSettings({})
|
||||
|
||||
expect(getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
})
|
||||
|
||||
it('honors custom commit attribution exactly and keeps omitted PR attribution off', () => {
|
||||
@@ -130,7 +142,7 @@ describe('getAttributionTexts', () => {
|
||||
attribution: { commit: 'Signed-off-by: Human <h@example.com>' },
|
||||
})
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({
|
||||
commit: 'Signed-off-by: Human <h@example.com>',
|
||||
pr: '',
|
||||
})
|
||||
@@ -139,13 +151,13 @@ describe('getAttributionTexts', () => {
|
||||
it('keeps commit attribution off when configured as an empty string', () => {
|
||||
useSettings({ attribution: { commit: '' } })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
})
|
||||
|
||||
it('honors custom PR attribution exactly and keeps omitted commit attribution off', () => {
|
||||
useSettings({ attribution: { pr: 'Reviewed by release engineering.' } })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({
|
||||
commit: '',
|
||||
pr: 'Reviewed by release engineering.',
|
||||
})
|
||||
@@ -154,29 +166,29 @@ describe('getAttributionTexts', () => {
|
||||
it('keeps PR attribution off when configured as an empty string', () => {
|
||||
useSettings({ attribution: { pr: '' } })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
})
|
||||
|
||||
it('preserves includeCoAuthoredBy true as an explicit old-default opt-in', () => {
|
||||
useSettings({ includeCoAuthoredBy: true })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
commit: 'Co-Authored-By: OpenClaude (gpt-5.5) <openclaude@gitlawb.com>',
|
||||
pr: defaultPrAttribution,
|
||||
})
|
||||
const attribution = attributionModule.getAttributionTexts()
|
||||
expect(attribution.commit).toStartWith('Co-Authored-By: ')
|
||||
expect(attribution.commit).toEndWith(' <openclaude@gitlawb.com>')
|
||||
expect(attribution.pr).toBe(defaultPrAttribution)
|
||||
})
|
||||
|
||||
it('keeps attribution off when includeCoAuthoredBy is false', () => {
|
||||
useSettings({ includeCoAuthoredBy: false })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({ commit: '', pr: '' })
|
||||
})
|
||||
|
||||
it('uses OPENCLAUDE_DISABLE_CO_AUTHORED_BY to disable the old default co-author trailer', () => {
|
||||
process.env.OPENCLAUDE_DISABLE_CO_AUTHORED_BY = '1'
|
||||
useSettings({ includeCoAuthoredBy: true })
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({
|
||||
commit: '',
|
||||
pr: defaultPrAttribution,
|
||||
})
|
||||
@@ -188,7 +200,7 @@ describe('getAttributionTexts', () => {
|
||||
attribution: { commit: 'Reviewed-by: Human <h@example.com>' },
|
||||
})
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({
|
||||
commit: 'Reviewed-by: Human <h@example.com>',
|
||||
pr: '',
|
||||
})
|
||||
@@ -199,7 +211,7 @@ describe('getAttributionTexts', () => {
|
||||
process.env.CLAUDE_CODE_REMOTE_SESSION_ID = 'session_remote_123'
|
||||
useSettings({})
|
||||
|
||||
expect(getAttributionTexts()).toEqual({
|
||||
expect(attributionModule.getAttributionTexts()).toEqual({
|
||||
commit: 'https://claude.ai/code/session_remote_123',
|
||||
pr: 'https://claude.ai/code/session_remote_123',
|
||||
})
|
||||
@@ -211,7 +223,7 @@ describe('getEnhancedPRAttribution', () => {
|
||||
useSettings({})
|
||||
|
||||
await expect(
|
||||
getEnhancedPRAttribution(() => {
|
||||
attributionModule.getEnhancedPRAttribution(() => {
|
||||
throw new Error('app state should not be read when attribution is off')
|
||||
}),
|
||||
).resolves.toBe('')
|
||||
@@ -221,7 +233,7 @@ describe('getEnhancedPRAttribution', () => {
|
||||
useSettings({ attribution: { pr: 'PR reviewed under repo policy.' } })
|
||||
|
||||
await expect(
|
||||
getEnhancedPRAttribution(() => {
|
||||
attributionModule.getEnhancedPRAttribution(() => {
|
||||
throw new Error('app state should not be read for custom attribution')
|
||||
}),
|
||||
).resolves.toBe('PR reviewed under repo policy.')
|
||||
@@ -231,7 +243,7 @@ describe('getEnhancedPRAttribution', () => {
|
||||
useSettings({ attribution: { pr: '' } })
|
||||
|
||||
await expect(
|
||||
getEnhancedPRAttribution(() => {
|
||||
attributionModule.getEnhancedPRAttribution(() => {
|
||||
throw new Error('app state should not be read for empty attribution')
|
||||
}),
|
||||
).resolves.toBe('')
|
||||
@@ -240,7 +252,9 @@ describe('getEnhancedPRAttribution', () => {
|
||||
it('preserves includeCoAuthoredBy true as an explicit opt-in to generated PR attribution', async () => {
|
||||
useSettings({ includeCoAuthoredBy: true })
|
||||
|
||||
await expect(getEnhancedPRAttribution(() => ({} as never))).resolves.toBe(
|
||||
await expect(
|
||||
attributionModule.getEnhancedPRAttribution(() => ({} as never)),
|
||||
).resolves.toBe(
|
||||
defaultPrAttribution,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { join } from 'path'
|
||||
|
||||
import {
|
||||
ensureExportFilenameExtension,
|
||||
@@ -233,7 +234,9 @@ describe('parseExportArgs', () => {
|
||||
|
||||
describe('resolveExportFilepath', () => {
|
||||
test('resolves relative export filenames under the current working directory', () => {
|
||||
expect(resolveExportFilepath('/work/project', 'transcript.md')).toBe('/work/project/transcript.md')
|
||||
expect(resolveExportFilepath('/work/project', 'transcript.md')).toBe(
|
||||
join('/work/project', 'transcript.md'),
|
||||
)
|
||||
})
|
||||
|
||||
test('preserves absolute export filenames', () => {
|
||||
|
||||
@@ -3,22 +3,36 @@ import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
type ProvidersModule = typeof import('./model/providers.js')
|
||||
type AxiosModule = typeof import('axios')
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
let originalProvidersModule: ProvidersModule | undefined
|
||||
let originalAxiosModule: AxiosModule | undefined
|
||||
|
||||
async function importFreshFastModeModule() {
|
||||
return import(`./fastMode.ts?ts=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
function installCommonMocks(options?: {
|
||||
async function installCommonMocks(options?: {
|
||||
cachedEnabled?: boolean
|
||||
apiKey?: string | null
|
||||
oauthToken?: string | null
|
||||
hasProfileScope?: boolean
|
||||
axiosReject?: boolean
|
||||
}) {
|
||||
originalProvidersModule ??= await importActualProviders()
|
||||
originalAxiosModule ??= await import('axios')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
defaults: {},
|
||||
interceptors: {
|
||||
request: {
|
||||
use: () => 0,
|
||||
eject: () => {},
|
||||
},
|
||||
},
|
||||
get: options?.axiosReject
|
||||
? async () => {
|
||||
throw new Error('network fail')
|
||||
@@ -157,6 +171,7 @@ function installCommonMocks(options?: {
|
||||
}))
|
||||
|
||||
mock.module('./model/providers.js', () => ({
|
||||
...originalProvidersModule!,
|
||||
getAPIProvider: () => 'firstParty',
|
||||
getAPIProviderForStatsig: () => 'firstParty',
|
||||
isFirstPartyAnthropicBaseUrl: () => true,
|
||||
@@ -165,6 +180,12 @@ function installCommonMocks(options?: {
|
||||
}))
|
||||
}
|
||||
|
||||
async function importActualProviders(): Promise<ProvidersModule> {
|
||||
return import(
|
||||
`./model/providers.ts?fastModeActual=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
}
|
||||
|
||||
async function prepareFastModeTestState(): Promise<void> {
|
||||
const { setIsInteractive } = await import('../bootstrap/state.js')
|
||||
setIsInteractive(true)
|
||||
@@ -199,6 +220,12 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
try {
|
||||
mock.restore()
|
||||
if (originalProvidersModule) {
|
||||
mock.module('./model/providers.js', () => originalProvidersModule!)
|
||||
}
|
||||
if (originalAxiosModule) {
|
||||
mock.module('axios', () => originalAxiosModule!)
|
||||
}
|
||||
process.env = { ...originalEnv }
|
||||
const { resetStateForTests } = await import('../bootstrap/state.js')
|
||||
resetStateForTests()
|
||||
@@ -213,7 +240,7 @@ describe('fastMode ant-only fallback cleanup', () => {
|
||||
test('resolveFastModeStatusFromCache does not force-enable from USER_TYPE=ant', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
forceFirstPartyProviderEnv()
|
||||
installCommonMocks({ cachedEnabled: false })
|
||||
await installCommonMocks({ cachedEnabled: false })
|
||||
|
||||
const {
|
||||
resolveFastModeStatusFromCache,
|
||||
@@ -231,7 +258,7 @@ describe('fastMode ant-only fallback cleanup', () => {
|
||||
test('prefetchFastModeStatus without auth does not force-enable from USER_TYPE=ant', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
forceFirstPartyProviderEnv()
|
||||
installCommonMocks({ cachedEnabled: false, apiKey: null, oauthToken: null })
|
||||
await installCommonMocks({ cachedEnabled: false, apiKey: null, oauthToken: null })
|
||||
|
||||
const {
|
||||
prefetchFastModeStatus,
|
||||
@@ -249,7 +276,7 @@ describe('fastMode ant-only fallback cleanup', () => {
|
||||
test('prefetchFastModeStatus network failure does not force-enable from USER_TYPE=ant', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
forceFirstPartyProviderEnv()
|
||||
installCommonMocks({
|
||||
await installCommonMocks({
|
||||
cachedEnabled: false,
|
||||
apiKey: 'test-key',
|
||||
axiosReject: true,
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
async function importFileModuleWithKillswitchEnabled(
|
||||
killswitchEnabled: boolean,
|
||||
) {
|
||||
const realGrowthbook = await import(
|
||||
`../services/analytics/growthbook.js?real=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
mock.module('../services/analytics/growthbook.js', () => ({
|
||||
...realGrowthbook,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => killswitchEnabled,
|
||||
}))
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
type HookChainsModule = typeof import('./hookChains.js')
|
||||
|
||||
type ImportHarnessOptions = {
|
||||
allowRemoteSessions?: boolean
|
||||
teamFile?:
|
||||
@@ -33,6 +32,37 @@ async function createConfigFile(config: unknown): Promise<string> {
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Cache-busted imports of the real modules so mock.module can spread them.
|
||||
// This prevents partial mocks from breaking transitive imports in other test
|
||||
// files that run after this one (mock.module is process-global and
|
||||
// mock.restore does not fully undo module mocks in Bun).
|
||||
async function importRealModules() {
|
||||
const ts = Date.now()
|
||||
const rand = Math.random()
|
||||
return {
|
||||
analytics: await import(`../services/analytics/index.js?real=${ts}-${rand}`) as typeof import('../services/analytics/index.js'),
|
||||
events: await import(`./telemetry/events.js?real=${ts}-${rand}`) as typeof import('./telemetry/events.js'),
|
||||
policyLimits: await import(`../services/policyLimits/index.js?real=${ts}-${rand}`) as typeof import('../services/policyLimits/index.js'),
|
||||
teamHelpers: await import(`./swarm/teamHelpers.js?real=${ts}-${rand}`) as typeof import('./swarm/teamHelpers.js'),
|
||||
teammateMailbox: await import(`./teammateMailbox.js?real=${ts}-${rand}`) as typeof import('./teammateMailbox.js'),
|
||||
teammate: await import(`./teammate.js?real=${ts}-${rand}`) as typeof import('./teammate.js'),
|
||||
replBridge: await import(`../bridge/replBridgeHandle.js?real=${ts}-${rand}`) as typeof import('../bridge/replBridgeHandle.js'),
|
||||
agentTool: await import(`../tools/AgentTool/AgentTool.js?real=${ts}-${rand}`) as typeof import('../tools/AgentTool/AgentTool.js'),
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePersistentModuleMocks(): Promise<void> {
|
||||
const real = await importRealModules()
|
||||
mock.module('../services/analytics/index.js', () => real.analytics)
|
||||
mock.module('./telemetry/events.js', () => real.events)
|
||||
mock.module('../services/policyLimits/index.js', () => real.policyLimits)
|
||||
mock.module('./swarm/teamHelpers.js', () => real.teamHelpers)
|
||||
mock.module('./teammateMailbox.js', () => real.teammateMailbox)
|
||||
mock.module('./teammate.js', () => real.teammate)
|
||||
mock.module('../bridge/replBridgeHandle.js', () => real.replBridge)
|
||||
mock.module('../tools/AgentTool/AgentTool.js', () => real.agentTool)
|
||||
}
|
||||
|
||||
async function importHookChainsHarness(
|
||||
options: ImportHarnessOptions = {},
|
||||
): Promise<{
|
||||
@@ -54,48 +84,51 @@ async function importHookChainsHarness(
|
||||
agentId: 'agent-fallback-1',
|
||||
},
|
||||
}))
|
||||
const real = await importRealModules()
|
||||
|
||||
mock.module('../services/analytics/index.js', () => ({
|
||||
...real.analytics,
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
mock.module('./telemetry/events.js', () => ({
|
||||
...real.events,
|
||||
logOTelEvent: async () => {},
|
||||
}))
|
||||
|
||||
mock.module('../services/policyLimits/index.js', () => ({
|
||||
...real.policyLimits,
|
||||
isPolicyAllowed: () => allowRemoteSessions,
|
||||
}))
|
||||
|
||||
mock.module('./swarm/teamHelpers.js', () => ({
|
||||
...real.teamHelpers,
|
||||
readTeamFileAsync: async () => options.teamFile ?? null,
|
||||
}))
|
||||
|
||||
mock.module('./teammateMailbox.js', () => ({
|
||||
...real.teammateMailbox,
|
||||
writeToMailbox: writeToMailboxSpy,
|
||||
}))
|
||||
|
||||
mock.module('./teammate.js', () => ({
|
||||
...real.teammate,
|
||||
getAgentName: () => senderName,
|
||||
getTeamName: () => teamName,
|
||||
getTeammateColor: () => 'blue',
|
||||
// Keep parity with the real module's surface so later tests that
|
||||
// run after this file (mock.module is process-global and mock.restore
|
||||
// does not undo module mocks in Bun) do not see undefined members.
|
||||
isTeammate: () => false,
|
||||
isPlanModeRequired: () => false,
|
||||
getAgentId: () => undefined,
|
||||
getParentSessionId: () => undefined,
|
||||
}))
|
||||
|
||||
mock.module('../bridge/replBridgeHandle.js', () => ({
|
||||
...real.replBridge,
|
||||
getReplBridgeHandle: () => replBridgeHandle,
|
||||
}))
|
||||
|
||||
// Integration mock target requested in the task: fallback action can route
|
||||
// through this mocked tool launcher from runtime callback wiring.
|
||||
mock.module('../tools/AgentTool/AgentTool.js', () => ({
|
||||
...real.agentTool,
|
||||
AgentTool: {
|
||||
...real.agentTool.AgentTool,
|
||||
call: agentToolCallSpy,
|
||||
},
|
||||
}))
|
||||
@@ -112,6 +145,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
try {
|
||||
mock.restore()
|
||||
await restorePersistentModuleMocks()
|
||||
|
||||
if (originalHookChainsEnabled === undefined) {
|
||||
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
|
||||
|
||||
@@ -38,7 +38,11 @@ const addMarketplaceSource = mock(async () => ({
|
||||
|
||||
await acquireSharedMutationLock('utils/plugins/officialMarketplaceStartupCheck.test.ts')
|
||||
|
||||
const realGrowthbook = await import(
|
||||
`../../services/analytics/growthbook.js?real=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
mock.module('../../services/analytics/growthbook.js', () => ({
|
||||
...realGrowthbook,
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => true,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { normalize } from 'path'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
@@ -12,11 +13,11 @@ type SettingsChangeDetectorModule = typeof import('./changeDetector.js') & {
|
||||
}
|
||||
|
||||
const pathsBySource: Record<SettingSource, string | null> = {
|
||||
userSettings: '/tmp/openclaude/user/settings.json',
|
||||
projectSettings: '/tmp/openclaude/project/.claude/settings.json',
|
||||
localSettings: '/tmp/openclaude/project/.claude/settings.local.json',
|
||||
userSettings: normalize('/tmp/openclaude/user/settings.json'),
|
||||
projectSettings: normalize('/tmp/openclaude/project/.claude/settings.json'),
|
||||
localSettings: normalize('/tmp/openclaude/project/.claude/settings.local.json'),
|
||||
flagSettings: null,
|
||||
policySettings: '/tmp/openclaude/managed/managed-settings.json',
|
||||
policySettings: normalize('/tmp/openclaude/managed/managed-settings.json'),
|
||||
}
|
||||
|
||||
let resetSettingsCache = mock(() => {})
|
||||
@@ -43,7 +44,7 @@ async function importFreshModule(): Promise<SettingsChangeDetectorModule> {
|
||||
consumeInternalWrite,
|
||||
executeConfigChangeHooks,
|
||||
getManagedSettingsDropInDir: () =>
|
||||
'/tmp/openclaude/managed/managed-settings.d',
|
||||
normalize('/tmp/openclaude/managed/managed-settings.d'),
|
||||
getSettingsFilePathForSource: (source: SettingSource) =>
|
||||
pathsBySource[source],
|
||||
hasBlockingResult: (results: { blocked: boolean }[]) =>
|
||||
|
||||
@@ -399,14 +399,18 @@ function getSourceForPath(path: string): SettingSource | undefined {
|
||||
const normalizedPath = platformPath.normalize(path)
|
||||
|
||||
// Check if the path is inside the managed-settings.d/ drop-in directory
|
||||
const dropInDir = dependencies.getManagedSettingsDropInDir()
|
||||
const dropInDir = platformPath.normalize(
|
||||
dependencies.getManagedSettingsDropInDir(),
|
||||
)
|
||||
if (normalizedPath.startsWith(dropInDir + platformPath.sep)) {
|
||||
return 'policySettings'
|
||||
}
|
||||
|
||||
return SETTING_SOURCES.find(
|
||||
source =>
|
||||
dependencies.getSettingsFilePathForSource(source) === normalizedPath,
|
||||
platformPath.normalize(
|
||||
dependencies.getSettingsFilePathForSource(source) ?? '',
|
||||
) === normalizedPath,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import figures from 'figures'
|
||||
import type { StatusNoticeContext } from './statusNoticeDefinitions.js'
|
||||
import {
|
||||
getActiveNotices,
|
||||
@@ -169,13 +170,15 @@ describe('safety notice rendering', () => {
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(thirdPartyNotice).toContain('⚠ bypassPermissions')
|
||||
expect(thirdPartyNotice).not.toContain('⚠bypassPermissions')
|
||||
expect(thirdPartyNotice).toContain(`${figures.warning} bypassPermissions`)
|
||||
expect(thirdPartyNotice).not.toContain(
|
||||
`${figures.warning}bypassPermissions`,
|
||||
)
|
||||
expect(dangerouslySkipNotice).toContain(
|
||||
'⚠ --dangerously-skip-permissions',
|
||||
`${figures.warning} --dangerously-skip-permissions`,
|
||||
)
|
||||
expect(dangerouslySkipNotice).not.toContain(
|
||||
'⚠--dangerously-skip-permissions',
|
||||
`${figures.warning}--dangerously-skip-permissions`,
|
||||
)
|
||||
expect(
|
||||
thirdPartyNotice
|
||||
|
||||
@@ -151,7 +151,8 @@ export function createMultiTurnConversation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely drains a query's async iterator, catching any abort errors.
|
||||
* Safely drains a query's async iterator, catching only expected lifecycle
|
||||
* abort errors.
|
||||
* Returns all collected SDKMessages.
|
||||
*/
|
||||
export async function drainQuery(q: Query): Promise<unknown[]> {
|
||||
@@ -160,12 +161,25 @@ export async function drainQuery(q: Query): Promise<unknown[]> {
|
||||
for await (const msg of q) {
|
||||
messages.push(msg)
|
||||
}
|
||||
} catch {
|
||||
// AbortError or similar — expected when interrupt/close is called
|
||||
} catch (err) {
|
||||
if (!isExpectedDrainAbort(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
export function isExpectedDrainAbort(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false
|
||||
const text = `${err.name}\n${err.message}`.toLowerCase()
|
||||
return (
|
||||
text.includes('abort') ||
|
||||
text.includes('interrupt') ||
|
||||
text.includes('cancel') ||
|
||||
text.includes('closed')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all messages from a query without suppressing errors.
|
||||
* Use when you expect the query to complete normally.
|
||||
|
||||
@@ -13,6 +13,10 @@ import { drainQuery, UUID_REGEX } from './helpers/query-test-doubles.js'
|
||||
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
||||
let savedApiKey: string | undefined
|
||||
|
||||
async function drainInterruptedQuery(q: ReturnType<typeof query>): Promise<void> {
|
||||
await drainQuery(q)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('sdk-query-concurrency')
|
||||
savedApiKey = process.env[AUTH_KEY]
|
||||
@@ -43,7 +47,7 @@ describe('SEC-1: env override isolation', () => {
|
||||
},
|
||||
})
|
||||
q.interrupt()
|
||||
try { for await (const _ of q) {} } catch {}
|
||||
await drainInterruptedQuery(q)
|
||||
|
||||
expect(process.env[key]).toBe('original')
|
||||
} finally {
|
||||
@@ -72,8 +76,8 @@ describe('SEC-1: env override isolation', () => {
|
||||
q1.interrupt()
|
||||
q2.interrupt()
|
||||
|
||||
try { for await (const _ of q1) {} } catch {}
|
||||
try { for await (const _ of q2) {} } catch {}
|
||||
await drainInterruptedQuery(q1)
|
||||
await drainInterruptedQuery(q2)
|
||||
|
||||
expect(process.env[key]).toBe(originalVal)
|
||||
} finally {
|
||||
@@ -101,8 +105,8 @@ describe('SEC-1: env override isolation', () => {
|
||||
q1.interrupt()
|
||||
q2.interrupt()
|
||||
|
||||
try { for await (const _ of q1) {} } catch {}
|
||||
try { for await (const _ of q2) {} } catch {}
|
||||
await drainInterruptedQuery(q1)
|
||||
await drainInterruptedQuery(q2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, test, expect, afterEach, beforeAll, afterAll } from 'bun:test'
|
||||
import { query, forkSession, getSessionMessages, unstable_v2_createSession } from '../../src/entrypoints/sdk/index.js'
|
||||
import {
|
||||
buildPermissionContext,
|
||||
createDefaultCanUseTool,
|
||||
createExternalCanUseTool,
|
||||
createPermissionTarget,
|
||||
} from '../../src/entrypoints/sdk/permissions.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { rmSync } from 'fs'
|
||||
import {
|
||||
@@ -14,18 +20,36 @@ import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import { clearAgentDefinitionsCache } from '../../src/tools/AgentTool/loadAgentsDir.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'
|
||||
const DISABLE_BUILTIN_AGENTS_KEY = 'CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS'
|
||||
let savedApiKey: string | undefined
|
||||
let savedDisableBuiltinAgents: string | undefined
|
||||
let hadSavedMacro = false
|
||||
let savedMacro: unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
await acquireSharedMutationLock('tests/sdk/query-lifecycle.test.ts')
|
||||
savedApiKey = process.env[AUTH_KEY]
|
||||
savedDisableBuiltinAgents = process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
||||
hadSavedMacro = Object.hasOwn(globalThis, 'MACRO')
|
||||
savedMacro = (globalThis as Record<string, unknown>).MACRO
|
||||
if (!savedApiKey) {
|
||||
process.env[AUTH_KEY] = 'sk-test-lifecycle-stub'
|
||||
}
|
||||
process.env[DISABLE_BUILTIN_AGENTS_KEY] = '1'
|
||||
;(globalThis as Record<string, unknown>).MACRO = {
|
||||
VERSION: '0.0.0-test',
|
||||
DISPLAY_VERSION: '0.0.0-test',
|
||||
BUILD_TIME: 'test',
|
||||
ISSUES_EXPLAINER: 'test',
|
||||
PACKAGE_URL: 'test',
|
||||
NATIVE_PACKAGE_URL: undefined,
|
||||
}
|
||||
clearAgentDefinitionsCache()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -35,6 +59,17 @@ afterAll(() => {
|
||||
} else {
|
||||
process.env[AUTH_KEY] = savedApiKey
|
||||
}
|
||||
if (savedDisableBuiltinAgents === undefined) {
|
||||
delete process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
||||
} else {
|
||||
process.env[DISABLE_BUILTIN_AGENTS_KEY] = savedDisableBuiltinAgents
|
||||
}
|
||||
if (hadSavedMacro) {
|
||||
;(globalThis as Record<string, unknown>).MACRO = savedMacro
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>).MACRO
|
||||
}
|
||||
clearAgentDefinitionsCache()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
@@ -227,11 +262,7 @@ describe('Query resume lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// These tests require full init() without mock engine. They fail in CI
|
||||
// where axios/proxy/agent-loading side-effects crash init(). Skip on CI.
|
||||
const testIfNotCI = process.env.CI ? test.skip : test
|
||||
|
||||
testIfNotCI('query() with fork:true — creates new sessionId', async () => {
|
||||
test('query() with fork:true — creates new sessionId', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
tempDirs.push(dir)
|
||||
const sid = randomUUID()
|
||||
@@ -242,29 +273,15 @@ describe('Query resume lifecycle', () => {
|
||||
prompt: 'forked conversation',
|
||||
options: { cwd: dir, sessionId: sid, fork: true },
|
||||
})
|
||||
// Fork happens lazily during iteration; iterate to trigger it.
|
||||
// Interrupt after a short delay to let fork logic run.
|
||||
const interruptTimer = setTimeout(() => q.interrupt(), 100)
|
||||
let caughtError: unknown = null
|
||||
// Fork happens lazily during iteration. The first item is enough to
|
||||
// trigger session resolution without advancing into the API request.
|
||||
const iterator = q[Symbol.asyncIterator]()
|
||||
try {
|
||||
for await (const _ of q) {
|
||||
// drain
|
||||
}
|
||||
} catch (err) {
|
||||
caughtError = err
|
||||
const first = await iterator.next()
|
||||
expect(first.done).toBe(false)
|
||||
} finally {
|
||||
clearTimeout(interruptTimer)
|
||||
}
|
||||
if (caughtError instanceof Error) {
|
||||
// Full-suite runs can hit unrelated global axios bootstrap side effects.
|
||||
// Accept that environmental failure mode so this test only asserts
|
||||
// fork behavior when the query engine actually initializes.
|
||||
expect(
|
||||
/axios\.defaults\.proxy|MACRO is not defined|unknown tool 'Glob'/.test(
|
||||
caughtError.message,
|
||||
),
|
||||
).toBe(true)
|
||||
return
|
||||
q.interrupt()
|
||||
await iterator.return?.()
|
||||
}
|
||||
expect(q.sessionId).toBeDefined()
|
||||
expect(q.sessionId).not.toBe(sid)
|
||||
@@ -290,7 +307,7 @@ describe('Query resume lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
testIfNotCI('query() with resumeSessionAt pointing to invalid UUID — throws', async () => {
|
||||
test('query() with resumeSessionAt pointing to invalid UUID — throws', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
tempDirs.push(dir)
|
||||
const sid = randomUUID()
|
||||
@@ -309,14 +326,8 @@ describe('Query resume lifecycle', () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
caught = true
|
||||
if (err.message.includes('axios.defaults.proxy')) {
|
||||
// See note in fork:true test above — tolerate suite-level bootstrap
|
||||
// contamination so this test remains deterministic.
|
||||
expect(err.message).toContain('axios.defaults.proxy')
|
||||
} else {
|
||||
expect(err.message).toContain('resumeSessionAt')
|
||||
expect(err.message).toContain('not found')
|
||||
}
|
||||
expect(err.message).toContain('resumeSessionAt')
|
||||
expect(err.message).toContain('not found')
|
||||
}
|
||||
expect(caught).toBe(true)
|
||||
})
|
||||
@@ -325,18 +336,23 @@ describe('Query resume lifecycle', () => {
|
||||
|
||||
describe('Secure-by-default permissions (SEC-2)', () => {
|
||||
test('createDefaultCanUseTool denies all tools when no callback is provided', async () => {
|
||||
// We test this indirectly: create a query with no canUseTool or
|
||||
// onPermissionRequest, and verify that tool uses are denied.
|
||||
// The query engine will attempt to use tools, and the deny-by-default
|
||||
// behavior should produce permission_denials in the result.
|
||||
const q = query({
|
||||
prompt: 'Read the file test.txt',
|
||||
options: { cwd: process.cwd() },
|
||||
})
|
||||
const canUseTool = createDefaultCanUseTool(
|
||||
buildPermissionContext({ cwd: process.cwd() }),
|
||||
{ warn: () => {} },
|
||||
)
|
||||
|
||||
const messages = await drainQuery(q)
|
||||
// The query should complete (not hang) and messages should be present
|
||||
expect(Array.isArray(messages)).toBe(true)
|
||||
const result = await canUseTool(
|
||||
{ name: 'Read' } as any,
|
||||
{ file_path: 'test.txt' },
|
||||
{} as any,
|
||||
{} as any,
|
||||
'tool-use-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(result.message).toContain('no canUseTool or onPermissionRequest')
|
||||
expect(result.decisionReason).toEqual({ type: 'mode', mode: 'default' })
|
||||
})
|
||||
|
||||
test('canUseTool callback overrides deny-by-default', async () => {
|
||||
@@ -404,40 +420,46 @@ describe('Secure-by-default permissions (SEC-2)', () => {
|
||||
|
||||
describe('Permission timeout eventing (PTO-1)', () => {
|
||||
test('timeout emits permission_timeout message in stream', async () => {
|
||||
const messages: unknown[] = []
|
||||
|
||||
const q = query({
|
||||
prompt: 'Read the file test.txt',
|
||||
options: {
|
||||
cwd: process.cwd(),
|
||||
_permissionTimeoutMs: 100,
|
||||
onPermissionRequest: () => {
|
||||
// Deliberately do NOT call respondToPermission() — force timeout
|
||||
},
|
||||
const permissionTarget = createPermissionTarget()
|
||||
const timeoutMessages: unknown[] = []
|
||||
const warnings: string[] = []
|
||||
const fallback = createDefaultCanUseTool(
|
||||
buildPermissionContext({ cwd: process.cwd() }),
|
||||
{ warn: () => {} },
|
||||
)
|
||||
const canUseTool = createExternalCanUseTool(
|
||||
undefined,
|
||||
fallback,
|
||||
permissionTarget,
|
||||
() => {
|
||||
// Deliberately do NOT resolve the pending permission; force timeout.
|
||||
},
|
||||
})
|
||||
|
||||
// Drain with a timeout safety net
|
||||
const drainPromise = drainQuery(q).then(msgs => { messages.push(...msgs) })
|
||||
await drainPromise
|
||||
|
||||
// Verify no crash occurred
|
||||
expect(Array.isArray(messages)).toBe(true)
|
||||
|
||||
// Check if a permission_timeout message was produced
|
||||
const timeoutMsgs = messages.filter(
|
||||
(msg: any) => msg?.type === 'permission_timeout',
|
||||
message => timeoutMessages.push(message),
|
||||
10,
|
||||
'session-id',
|
||||
{ warn: message => warnings.push(message) },
|
||||
)
|
||||
|
||||
// If the engine tried to use a tool and hit the permission callback,
|
||||
// we should see exactly one timeout message
|
||||
if (timeoutMsgs.length > 0) {
|
||||
const msg = timeoutMsgs[0] as Record<string, unknown>
|
||||
expect(msg.type).toBe('permission_timeout')
|
||||
expect(typeof msg.tool_name).toBe('string')
|
||||
expect(typeof msg.tool_use_id).toBe('string')
|
||||
expect(typeof msg.timed_out_after_ms).toBe('number')
|
||||
expect(msg.timed_out_after_ms).toBe(100)
|
||||
}
|
||||
const result = await canUseTool(
|
||||
{ name: 'Read' } as any,
|
||||
{ file_path: 'test.txt' },
|
||||
{} as any,
|
||||
{} as any,
|
||||
'tool-use-id',
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(timeoutMessages).toHaveLength(1)
|
||||
expect(timeoutMessages[0]).toMatchObject({
|
||||
type: 'permission_timeout',
|
||||
tool_name: 'Read',
|
||||
tool_use_id: 'tool-use-id',
|
||||
timed_out_after_ms: 10,
|
||||
session_id: 'session-id',
|
||||
})
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('timed out after 10ms')
|
||||
expect(permissionTarget.pendingPermissionPrompts.size).toBe(0)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import { clearAgentDefinitionsCache } from '../../src/tools/AgentTool/loadAgentsDir.js'
|
||||
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
drainQuery,
|
||||
@@ -26,12 +27,17 @@ import {
|
||||
createSessionJsonl,
|
||||
createMinimalConversation,
|
||||
createMultiTurnConversation,
|
||||
isExpectedDrainAbort,
|
||||
UUID_REGEX,
|
||||
} from './helpers/query-test-doubles.js'
|
||||
|
||||
// sendMessage drains trigger init(), which checks auth. Stub it for CI.
|
||||
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
||||
const DISABLE_BUILTIN_AGENTS_KEY = 'CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS'
|
||||
let savedApiKey: string | undefined
|
||||
let savedDisableBuiltinAgents: string | undefined
|
||||
let hadSavedMacro = false
|
||||
let savedMacro: unknown
|
||||
let originalSessionId: SessionId
|
||||
let originalSessionProjectDir: string | null
|
||||
let originalCwd: string
|
||||
@@ -43,13 +49,37 @@ const tempDirs: string[] = []
|
||||
beforeAll(async () => {
|
||||
await acquireSharedMutationLock('sdk-v2-lifecycle')
|
||||
savedApiKey = process.env[AUTH_KEY]
|
||||
savedDisableBuiltinAgents = process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
||||
hadSavedMacro = Object.hasOwn(globalThis, 'MACRO')
|
||||
savedMacro = (globalThis as Record<string, unknown>).MACRO
|
||||
if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-v2-lifecycle-stub'
|
||||
process.env[DISABLE_BUILTIN_AGENTS_KEY] = '1'
|
||||
;(globalThis as Record<string, unknown>).MACRO = {
|
||||
VERSION: '0.0.0-test',
|
||||
DISPLAY_VERSION: '0.0.0-test',
|
||||
BUILD_TIME: 'test',
|
||||
ISSUES_EXPLAINER: 'test',
|
||||
PACKAGE_URL: 'test',
|
||||
NATIVE_PACKAGE_URL: undefined,
|
||||
}
|
||||
clearAgentDefinitionsCache()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
||||
else process.env[AUTH_KEY] = savedApiKey
|
||||
if (savedDisableBuiltinAgents === undefined) {
|
||||
delete process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
||||
} else {
|
||||
process.env[DISABLE_BUILTIN_AGENTS_KEY] = savedDisableBuiltinAgents
|
||||
}
|
||||
if (hadSavedMacro) {
|
||||
;(globalThis as Record<string, unknown>).MACRO = savedMacro
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>).MACRO
|
||||
}
|
||||
clearAgentDefinitionsCache()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
@@ -120,16 +150,20 @@ describe('V2: session interrupt', () => {
|
||||
abortController: ac,
|
||||
})
|
||||
ac.abort()
|
||||
let caught = false
|
||||
const messages: unknown[] = []
|
||||
let caught: unknown = null
|
||||
try {
|
||||
for await (const _ of session.sendMessage('test')) {
|
||||
// drain
|
||||
for await (const msg of session.sendMessage('test')) {
|
||||
messages.push(msg)
|
||||
}
|
||||
} catch {
|
||||
caught = true
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
if (caught) {
|
||||
expect(isExpectedDrainAbort(caught)).toBe(true)
|
||||
} else {
|
||||
expect(messages.length).toBe(0)
|
||||
}
|
||||
// Either completes with no messages or throws — both are acceptable
|
||||
expect(true).toBe(true)
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user