mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
- providerRegistry: OpenAI 兼容 provider 切换(Cerebras/Groq/DeepSeek/Qwen) - StatusLine: 增强状态栏(缓存命中率、TTL 倒计时、自定义 shell 命令) - cacheStats: 缓存命中率和 token 签名追踪 - ultrareviewPreflight: 代码审查预检服务 - SkillsMenu/filterSkills: 技能菜单过滤增强 - MagicDocs/langfuse prompts: 提示词更新 - claude.ts: API 客户端更新 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
import {
|
|
afterAll,
|
|
describe,
|
|
test,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
mock,
|
|
} from 'bun:test'
|
|
import * as path from 'node:path'
|
|
import * as os from 'node:os'
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import * as fsp from 'node:fs/promises'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock envUtils so getClaudeConfigHomeDir returns a temp dir while THIS
|
|
// suite runs. After it finishes, getClaudeConfigHomeDir falls back to the
|
|
// real semantics (process.env.CLAUDE_CONFIG_DIR ?? ~/.claude) so other
|
|
// tests in the same process (envUtils.test.ts in particular) don't see
|
|
// the test's tmpDir leaked as the user config home.
|
|
// ---------------------------------------------------------------------------
|
|
let tmpDir = ''
|
|
let useMockForCacheStats = true
|
|
afterAll(() => {
|
|
useMockForCacheStats = false
|
|
})
|
|
|
|
// Provide REAL semantics for every other envUtils export — this mock is
|
|
// process-global, so envUtils.test.ts and other consumers (providers,
|
|
// model, etc.) running in the same process see real behavior for
|
|
// hasNodeOption, isEnvTruthy, isBareMode, parseEnvVars, etc. Only
|
|
// getClaudeConfigHomeDir is overridden (to point at the test temp dir).
|
|
const VERTEX_REGION_OVERRIDES: ReadonlyArray<[string, string]> = [
|
|
['claude-haiku-4-5', 'VERTEX_REGION_CLAUDE_HAIKU_4_5'],
|
|
['claude-3-5-haiku', 'VERTEX_REGION_CLAUDE_3_5_HAIKU'],
|
|
['claude-3-5-sonnet', 'VERTEX_REGION_CLAUDE_3_5_SONNET'],
|
|
['claude-3-7-sonnet', 'VERTEX_REGION_CLAUDE_3_7_SONNET'],
|
|
['claude-opus-4-1', 'VERTEX_REGION_CLAUDE_4_1_OPUS'],
|
|
['claude-opus-4', 'VERTEX_REGION_CLAUDE_4_0_OPUS'],
|
|
['claude-sonnet-4-6', 'VERTEX_REGION_CLAUDE_4_6_SONNET'],
|
|
['claude-sonnet-4-5', 'VERTEX_REGION_CLAUDE_4_5_SONNET'],
|
|
['claude-sonnet-4', 'VERTEX_REGION_CLAUDE_4_0_SONNET'],
|
|
]
|
|
|
|
const realIsEnvTruthy = (v: string | boolean | undefined): boolean => {
|
|
if (!v) return false
|
|
if (typeof v === 'boolean') return v
|
|
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim())
|
|
}
|
|
const realIsEnvDefinedFalsy = (v: string | boolean | undefined): boolean => {
|
|
if (v === undefined) return false
|
|
if (typeof v === 'boolean') return !v
|
|
if (!v) return false
|
|
return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim())
|
|
}
|
|
const realDefaultVertexRegion = (): string =>
|
|
process.env.CLOUD_ML_REGION || 'us-east5'
|
|
|
|
// Real getClaudeConfigHomeDir is memoized via lodash, so consumers may call
|
|
// `.cache.clear()` on it (see tasks.test.ts). Provide a no-op .cache stub.
|
|
const mockedGetClaudeConfigHomeDir: (() => string) & {
|
|
cache: { clear: () => void; get: (k: unknown) => unknown }
|
|
} = Object.assign(
|
|
() =>
|
|
useMockForCacheStats
|
|
? tmpDir
|
|
: (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize(
|
|
'NFC',
|
|
),
|
|
{
|
|
cache: {
|
|
clear: () => {},
|
|
get: (_k: unknown) => undefined,
|
|
},
|
|
},
|
|
)
|
|
|
|
mock.module('src/utils/envUtils.js', () => ({
|
|
getClaudeConfigHomeDir: mockedGetClaudeConfigHomeDir,
|
|
isEnvTruthy: realIsEnvTruthy,
|
|
hasNodeOption: (flag: string) => {
|
|
const opts = process.env.NODE_OPTIONS
|
|
return !!opts && opts.split(/\s+/).includes(flag)
|
|
},
|
|
isEnvDefinedFalsy: realIsEnvDefinedFalsy,
|
|
isBareMode: () =>
|
|
realIsEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) ||
|
|
process.argv.includes('--bare'),
|
|
parseEnvVars: (rawEnvArgs: string[] | undefined) => {
|
|
const parsed: Record<string, string> = {}
|
|
if (rawEnvArgs) {
|
|
for (const envStr of rawEnvArgs) {
|
|
const [key, ...valueParts] = envStr.split('=')
|
|
if (!key || valueParts.length === 0) {
|
|
throw new Error(
|
|
`Invalid environment variable format: ${envStr}, environment variables should be added as: -e KEY1=value1 -e KEY2=value2`,
|
|
)
|
|
}
|
|
parsed[key] = valueParts.join('=')
|
|
}
|
|
}
|
|
return parsed
|
|
},
|
|
getAWSRegion: () =>
|
|
process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
|
getDefaultVertexRegion: realDefaultVertexRegion,
|
|
shouldMaintainProjectWorkingDir: () =>
|
|
realIsEnvTruthy(process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR),
|
|
isRunningOnHomespace: () =>
|
|
process.env.USER_TYPE === 'ant' &&
|
|
realIsEnvTruthy(process.env.COO_RUNNING_ON_HOMESPACE),
|
|
isInProtectedNamespace: () => false,
|
|
getTeamsDir: () =>
|
|
useMockForCacheStats
|
|
? `${tmpDir}/teams`
|
|
: join(
|
|
(
|
|
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
|
|
).normalize('NFC'),
|
|
'teams',
|
|
),
|
|
getEnvBool: () => false,
|
|
getEnvNumber: () => undefined,
|
|
getVertexRegionForModel: (model: string | undefined) => {
|
|
if (model) {
|
|
const match = VERTEX_REGION_OVERRIDES.find(([prefix]) =>
|
|
model.startsWith(prefix),
|
|
)
|
|
if (match) {
|
|
return process.env[match[1]] || realDefaultVertexRegion()
|
|
}
|
|
}
|
|
return realDefaultVertexRegion()
|
|
},
|
|
}))
|
|
|
|
import {
|
|
computeHitRate,
|
|
tokenSignature,
|
|
getStateFilePath,
|
|
readState,
|
|
writeStateAtomic,
|
|
type CacheUsage,
|
|
type CacheStatsState,
|
|
} from '../cacheStats.js'
|
|
|
|
import {
|
|
onResponse,
|
|
getCacheStatsState,
|
|
initCacheStatsState,
|
|
_resetCacheStatsStateForTest,
|
|
} from '../cacheStatsState.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function usage(input: number, create: number, read: number): CacheUsage {
|
|
return {
|
|
input_tokens: input,
|
|
cache_creation_input_tokens: create,
|
|
cache_read_input_tokens: read,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeHitRate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeHitRate', () => {
|
|
test('returns null for null input', () => {
|
|
expect(computeHitRate(null)).toBeNull()
|
|
})
|
|
|
|
test('returns null when all fields are 0 (denominator = 0)', () => {
|
|
expect(computeHitRate(usage(0, 0, 0))).toBeNull()
|
|
})
|
|
|
|
test('100% when all tokens are cache reads', () => {
|
|
expect(computeHitRate(usage(0, 0, 1000))).toBe(100)
|
|
})
|
|
|
|
test('0% when no cache reads', () => {
|
|
expect(computeHitRate(usage(1000, 0, 0))).toBe(0)
|
|
})
|
|
|
|
test('rounds to integer (50%)', () => {
|
|
expect(computeHitRate(usage(500, 0, 500))).toBe(50)
|
|
})
|
|
|
|
test('rounds fractional values', () => {
|
|
// read=1, total=3 → 33.33... → rounds to 33
|
|
expect(computeHitRate(usage(2, 0, 1))).toBe(33)
|
|
})
|
|
|
|
test('handles large numbers without overflow', () => {
|
|
const big = 1_000_000_000
|
|
expect(computeHitRate(usage(big, big, big))).toBe(33)
|
|
})
|
|
|
|
test('cache_creation does not count as reads', () => {
|
|
// Only cache_read_input_tokens in numerator
|
|
expect(computeHitRate(usage(0, 1000, 0))).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tokenSignature
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('tokenSignature', () => {
|
|
test('produces deterministic string', () => {
|
|
const u = usage(100, 200, 300)
|
|
expect(tokenSignature(u)).toBe('100|200|300')
|
|
})
|
|
|
|
test('changes when input_tokens changes', () => {
|
|
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
|
tokenSignature(usage(9, 2, 3)),
|
|
)
|
|
})
|
|
|
|
test('changes when cache_creation changes', () => {
|
|
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
|
tokenSignature(usage(1, 9, 3)),
|
|
)
|
|
})
|
|
|
|
test('changes when cache_read changes', () => {
|
|
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
|
tokenSignature(usage(1, 2, 9)),
|
|
)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State file: getStateFilePath
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getStateFilePath', () => {
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('returns path inside config home dir', () => {
|
|
const p = getStateFilePath('session-abc')
|
|
expect(p).toContain('cache-stats')
|
|
expect(p.startsWith(tmpDir)).toBe(true)
|
|
})
|
|
|
|
test('different sessionIds produce different paths', () => {
|
|
const p1 = getStateFilePath('session-one')
|
|
const p2 = getStateFilePath('session-two')
|
|
expect(p1).not.toBe(p2)
|
|
})
|
|
|
|
test('same sessionId always produces same path (deterministic)', () => {
|
|
expect(getStateFilePath('s1')).toBe(getStateFilePath('s1'))
|
|
})
|
|
|
|
test('file name is 16 hex chars + .json', () => {
|
|
const p = getStateFilePath('any-session-id')
|
|
const base = path.basename(p)
|
|
expect(base).toMatch(/^[0-9a-f]{16}\.json$/)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State file: readState / writeStateAtomic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('readState / writeStateAtomic', () => {
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('readState returns init defaults when file is missing', async () => {
|
|
const p = path.join(tmpDir, 'cache-stats', 'nonexistent.json')
|
|
const s = await readState(p)
|
|
expect(s.version).toBe(1)
|
|
expect(s.signature).toBeNull()
|
|
expect(s.lastResetAt).toBeNull()
|
|
expect(s.lastHitRate).toBeNull()
|
|
})
|
|
|
|
test('readState returns init defaults on corrupt JSON', async () => {
|
|
const p = path.join(tmpDir, 'bad.json')
|
|
await fsp.writeFile(p, 'not-json!!!', 'utf8')
|
|
const s = await readState(p)
|
|
expect(s.signature).toBeNull()
|
|
})
|
|
|
|
test('readState returns init defaults on invalid shape', async () => {
|
|
const p = path.join(tmpDir, 'bad-shape.json')
|
|
await fsp.writeFile(p, JSON.stringify({ version: 2, foo: 'bar' }), 'utf8')
|
|
const s = await readState(p)
|
|
expect(s.signature).toBeNull()
|
|
})
|
|
|
|
test('round-trip: writeStateAtomic then readState', async () => {
|
|
const p = getStateFilePath('round-trip-session')
|
|
const state: CacheStatsState = {
|
|
version: 1,
|
|
signature: '100|200|300',
|
|
lastResetAt: 1_700_000_000_000,
|
|
lastHitRate: 75,
|
|
}
|
|
await writeStateAtomic(p, state)
|
|
const read = await readState(p)
|
|
expect(read).toEqual(state)
|
|
})
|
|
|
|
test('writeStateAtomic creates parent directory if missing', async () => {
|
|
const p = path.join(tmpDir, 'deep', 'nested', 'state.json')
|
|
const state: CacheStatsState = {
|
|
version: 1,
|
|
signature: null,
|
|
lastResetAt: null,
|
|
lastHitRate: null,
|
|
}
|
|
await writeStateAtomic(p, state)
|
|
const read = await readState(p)
|
|
expect(read.version).toBe(1)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// onResponse / getCacheStatsState (in-memory singleton)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('onResponse', () => {
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
|
_resetCacheStatsStateForTest()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('initial state has null signature and lastResetAt', () => {
|
|
const s = getCacheStatsState()
|
|
expect(s.signature).toBeNull()
|
|
expect(s.lastResetAt).toBeNull()
|
|
})
|
|
|
|
test('first onResponse sets lastResetAt and signature', () => {
|
|
const u = usage(100, 0, 50)
|
|
const before = Date.now()
|
|
const s = onResponse(u)
|
|
const after = Date.now()
|
|
expect(s.signature).toBe(tokenSignature(u))
|
|
expect(s.lastResetAt).toBeGreaterThanOrEqual(before)
|
|
expect(s.lastResetAt).toBeLessThanOrEqual(after)
|
|
expect(s.lastHitRate).toBe(33) // 50/(100+50) ≈ 33
|
|
})
|
|
|
|
test('same signature does NOT reset lastResetAt', async () => {
|
|
const u = usage(100, 0, 50)
|
|
onResponse(u)
|
|
const firstState = getCacheStatsState()
|
|
const firstResetAt = firstState.lastResetAt
|
|
|
|
// Wait a tick to ensure Date.now() would differ
|
|
await new Promise(r => setTimeout(r, 5))
|
|
|
|
onResponse(u) // same signature
|
|
const secondState = getCacheStatsState()
|
|
expect(secondState.lastResetAt).toBe(firstResetAt)
|
|
})
|
|
|
|
test('different signature RESETS lastResetAt', async () => {
|
|
const u1 = usage(100, 0, 50)
|
|
onResponse(u1)
|
|
const firstState = getCacheStatsState()
|
|
|
|
await new Promise(r => setTimeout(r, 5))
|
|
|
|
const u2 = usage(200, 0, 100) // different signature
|
|
onResponse(u2)
|
|
const secondState = getCacheStatsState()
|
|
expect(secondState.lastResetAt).toBeGreaterThan(firstState.lastResetAt!)
|
|
})
|
|
|
|
test('lastHitRate is updated on signature change', () => {
|
|
onResponse(usage(1000, 0, 0)) // 0% hit rate
|
|
const s1 = getCacheStatsState()
|
|
expect(s1.lastHitRate).toBe(0)
|
|
|
|
onResponse(usage(0, 0, 1000)) // 100% hit rate — different sig
|
|
const s2 = getCacheStatsState()
|
|
expect(s2.lastHitRate).toBe(100)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-session isolation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('multi-session file isolation', () => {
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('different session IDs produce different state files', async () => {
|
|
const p1 = getStateFilePath('session-alpha')
|
|
const p2 = getStateFilePath('session-beta')
|
|
|
|
const s1: CacheStatsState = {
|
|
version: 1,
|
|
signature: 'sig-alpha',
|
|
lastResetAt: 1000,
|
|
lastHitRate: 90,
|
|
}
|
|
const s2: CacheStatsState = {
|
|
version: 1,
|
|
signature: 'sig-beta',
|
|
lastResetAt: 2000,
|
|
lastHitRate: 10,
|
|
}
|
|
|
|
await writeStateAtomic(p1, s1)
|
|
await writeStateAtomic(p2, s2)
|
|
|
|
const r1 = await readState(p1)
|
|
const r2 = await readState(p2)
|
|
|
|
expect(r1.signature).toBe('sig-alpha')
|
|
expect(r2.signature).toBe('sig-beta')
|
|
expect(r1.lastHitRate).toBe(90)
|
|
expect(r2.lastHitRate).toBe(10)
|
|
})
|
|
|
|
test('initCacheStatsState loads persisted fallback values', async () => {
|
|
_resetCacheStatsStateForTest()
|
|
const sid = 'test-session-init'
|
|
const p = getStateFilePath(sid)
|
|
const persisted: CacheStatsState = {
|
|
version: 1,
|
|
signature: '500|100|400',
|
|
lastResetAt: 1_700_000_000_000,
|
|
lastHitRate: 40,
|
|
}
|
|
await writeStateAtomic(p, persisted)
|
|
|
|
await initCacheStatsState(sid)
|
|
const s = getCacheStatsState()
|
|
expect(s.lastHitRate).toBe(40)
|
|
expect(s.lastResetAt).toBe(1_700_000_000_000)
|
|
expect(s.signature).toBe('500|100|400')
|
|
})
|
|
})
|