diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index a0b66f3d..f20f1a14 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -44,8 +44,15 @@ const slowTests = [ // CommonJS-heavy JS suites stay slow by default because many of them mutate // module cache or process state. Opt them into `test:fast` only after they are // proven stable in the mixed fast bucket. -const fastJsTests = new Set([ - 'tests/unit/flag-parsing-simple.test.js', +const fastJsTests = new Set(['tests/unit/flag-parsing-simple.test.js']); + +const isolatedTests = new Set([ + 'tests/unit/targets/codex-adapter-exec.test.ts', + 'tests/unit/targets/codex-adapter.test.ts', + 'tests/unit/targets/droid-adapter.test.ts', + 'tests/unit/targets/target-registry.test.ts', + 'tests/unit/utils/fetch-proxy-setup.test.ts', + 'tests/unit/web-server/usage/account-attribution.test.ts', ]); const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/; @@ -89,6 +96,11 @@ function shouldForceSlow(file) { return readsBuiltDist(file); } +function usesBunTestRunner(relativePath) { + const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); + return source.includes('bun:test') || /(^|[^\w.])(?:describe|it)\s*\(/m.test(source); +} + function getSlowSet() { const discovered = getDiscoveredTests(); const forceSlow = discovered.filter((file) => shouldForceSlow(file)); @@ -99,9 +111,101 @@ function selectBucket(name) { const discovered = getDiscoveredTests(); const slowSet = getSlowSet(); - return name === 'slow' - ? [...slowSet].sort() - : discovered.filter((file) => !slowSet.has(file)); + return name === 'slow' ? [...slowSet].sort() : discovered.filter((file) => !slowSet.has(file)); +} + +function toBunTestPath(relativePath) { + if ( + relativePath.startsWith('./') || + relativePath.startsWith('../') || + path.isAbsolute(relativePath) + ) { + return relativePath; + } + + return `./${relativePath}`; +} + +function getBunArgs(name, selected = selectBucket(name)) { + const testPaths = selected.map(toBunTestPath); + + // Slow bucket forces sequential execution because it spawns subprocesses, + // binds ports, and touches shared state — parallelism causes flakes. + // Fast bucket keeps bun's default parallelism for speed. + return name === 'slow' ? ['test', '--max-concurrency=1', ...testPaths] : ['test', ...testPaths]; +} + +function shouldRunIsolated(file) { + return file.startsWith('src/') || isolatedTests.has(file) || !usesBunTestRunner(file); +} + +function getBunRuns(name, selected = selectBucket(name)) { + const shared = selected.filter((file) => !shouldRunIsolated(file)); + const isolated = selected.filter(shouldRunIsolated); + const runs = []; + + if (shared.length > 0) { + runs.push({ + label: 'shared', + selected: shared, + bunArgs: getBunArgs(name, shared), + quietOnPass: false, + }); + } + + for (const file of isolated) { + runs.push({ + label: file, + selected: [file], + bunArgs: getBunArgs(name, [file]), + quietOnPass: true, + }); + } + + return runs; +} + +function stripAnsi(value) { + return value.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function parseBunFileCount(output) { + const match = stripAnsi(output).match(/Ran\s+\d+\s+tests?\s+across\s+(\d+)\s+files?/i); + return match ? Number(match[1]) : null; +} + +function verifyReportedFileCount(selectedCount, output) { + const reportedCount = parseBunFileCount(output); + + if (reportedCount === null) { + return { + ok: false, + message: '[X] Could not find Bun test file count in output.', + reportedCount, + selectedCount, + }; + } + + if (reportedCount !== selectedCount) { + return { + ok: false, + message: + `[X] Bun ran ${reportedCount} files, but the bucket selected ${selectedCount} files. ` + + 'Check test path arguments for filter/path ambiguity.', + reportedCount, + selectedCount, + }; + } + + return { + ok: true, + reportedCount, + selectedCount, + }; +} + +function shouldVerifyRunFileCount(run) { + return run.selected.every((file) => usesBunTestRunner(file)); } function ensureBuildForSlowBucket() { @@ -118,6 +222,54 @@ function ensureBuildForSlowBucket() { return build.status ?? 1; } +function runBunTest(run) { + const result = spawnSync('bun', run.bunArgs, { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + shell: process.platform === 'win32', + }); + + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const writeOutput = () => { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + }; + + if (result.error) { + writeOutput(); + console.error(`[X] Failed to run bun test: ${result.error.message}`); + return 1; + } + + const exitCode = result.status ?? 1; + if (exitCode !== 0) { + writeOutput(); + return exitCode; + } + + if (shouldVerifyRunFileCount(run)) { + const countCheck = verifyReportedFileCount(run.selected.length, output); + if (!countCheck.ok) { + writeOutput(); + console.error(countCheck.message); + return 1; + } + } + + if (run.quietOnPass) { + console.log(`[OK] ${run.label}`); + } else { + writeOutput(); + } + + return 0; +} + function runBucket(name) { const selected = selectBucket(name); @@ -133,20 +285,25 @@ function runBucket(name) { } } - // Slow bucket forces sequential execution because it spawns subprocesses, - // binds ports, and touches shared state — parallelism causes flakes. - // Fast bucket keeps bun's default parallelism for speed. - const bunArgs = name === 'slow' - ? ['test', '--max-concurrency=1', ...selected] - : ['test', ...selected]; + const runs = getBunRuns(name, selected); + const isolatedCount = runs.filter((run) => run.quietOnPass).length; + if (isolatedCount > 0) { + console.log(`[i] Running ${isolatedCount} test file(s) in isolated Bun processes.`); + } - const result = spawnSync('bun', bunArgs, { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', - }); + let exitCode = 0; + for (const run of runs) { + const status = runBunTest(run); + if (status !== 0) { + exitCode = status; + } + } - return result.status ?? 1; + if (exitCode === 0) { + console.log(`[OK] Bucket '${name}' ran ${selected.length} selected test files.`); + } + + return exitCode; } function main(args = process.argv.slice(2)) { @@ -180,10 +337,19 @@ if (require.main === module) { module.exports = { slowTests, fastJsTests, + isolatedTests, readsBuiltDist, shouldForceSlow, getDiscoveredTests, getSlowSet, selectBucket, + toBunTestPath, + getBunArgs, + usesBunTestRunner, + shouldRunIsolated, + getBunRuns, + parseBunFileCount, + verifyReportedFileCount, + shouldVerifyRunFileCount, main, }; diff --git a/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts index 574cfe75..befcb19b 100644 --- a/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts +++ b/src/cliproxy/executor/__tests__/browser-launch-setup.test.ts @@ -27,7 +27,7 @@ function makeBrowserConfig(enabled = false, policy: 'auto' | 'always' | 'never' describe('resolveBrowserLaunchFlags — no browser flags', () => { it('returns undefined override and passes args through unchanged', async () => { - mock.module('../../utils/browser', () => ({ + mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (a: string[]) => a, resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ override: undefined, @@ -40,7 +40,7 @@ describe('resolveBrowserLaunchFlags — no browser flags', () => { resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), syncBrowserMcpToConfigDir: () => true, })); - mock.module('../../../config/unified-config-loader', () => ({ + mock.module('../../../config/config-loader-facade', () => ({ getBrowserConfig: () => makeBrowserConfig(false), loadOrCreateUnifiedConfig: () => ({}), getThinkingConfig: () => ({}), @@ -58,7 +58,7 @@ describe('resolveBrowserLaunchFlags — no browser flags', () => { describe('resolveBrowserLaunchFlags — with browser-launch override', () => { it('returns override and strips the browser flag from args', async () => { - mock.module('../../utils/browser', () => ({ + mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (a: string[]) => a, resolveBrowserLaunchFlagResolution: (_args: string[]) => ({ override: 'force-enable' as const, @@ -71,7 +71,7 @@ describe('resolveBrowserLaunchFlags — with browser-launch override', () => { resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), syncBrowserMcpToConfigDir: () => true, })); - mock.module('../../../config/unified-config-loader', () => ({ + mock.module('../../../config/config-loader-facade', () => ({ getBrowserConfig: () => makeBrowserConfig(true, 'auto'), loadOrCreateUnifiedConfig: () => ({}), getThinkingConfig: () => ({}), @@ -98,7 +98,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => { }); it('emits warn() when getBlockedBrowserOverrideWarning returns a message', async () => { - mock.module('../../utils/browser', () => ({ + mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (a: string[]) => a, resolveBrowserLaunchFlagResolution: (args: string[]) => ({ override: 'force-enable' as const, @@ -111,7 +111,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => { resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), syncBrowserMcpToConfigDir: () => true, })); - mock.module('../../../config/unified-config-loader', () => ({ + mock.module('../../../config/config-loader-facade', () => ({ getBrowserConfig: () => makeBrowserConfig(false, 'never'), loadOrCreateUnifiedConfig: () => ({}), getThinkingConfig: () => ({}), @@ -128,7 +128,7 @@ describe('resolveBrowserLaunchFlags — blocked override warning', () => { describe('resolveBrowserRuntime — attach disabled', () => { it('returns undefined browserRuntimeEnv when browser attach is disabled', async () => { - mock.module('../../utils/browser', () => ({ + mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (a: string[]) => a, resolveBrowserLaunchFlagResolution: (a: string[]) => ({ override: undefined, @@ -141,7 +141,7 @@ describe('resolveBrowserRuntime — attach disabled', () => { resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: undefined }), syncBrowserMcpToConfigDir: () => true, })); - mock.module('../../../config/unified-config-loader', () => ({ + mock.module('../../../config/config-loader-facade', () => ({ getBrowserConfig: () => makeBrowserConfig(false), loadOrCreateUnifiedConfig: () => ({}), getThinkingConfig: () => ({}), @@ -158,7 +158,7 @@ describe('resolveBrowserRuntime — attach disabled', () => { describe('resolveBrowserRuntime — active runtime env', () => { it('returns runtimeEnv when browser attach resolves successfully', async () => { const fakeRuntimeEnv = { CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1:9222/json' }; - mock.module('../../utils/browser', () => ({ + mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (a: string[]) => a, resolveBrowserLaunchFlagResolution: (a: string[]) => ({ override: 'force-enable' as const, @@ -171,7 +171,7 @@ describe('resolveBrowserRuntime — active runtime env', () => { resolveOptionalBrowserAttachRuntime: async () => ({ runtimeEnv: fakeRuntimeEnv }), syncBrowserMcpToConfigDir: () => true, })); - mock.module('../../../config/unified-config-loader', () => ({ + mock.module('../../../config/config-loader-facade', () => ({ getBrowserConfig: () => makeBrowserConfig(true, 'always'), loadOrCreateUnifiedConfig: () => ({}), getThinkingConfig: () => ({}), diff --git a/src/cliproxy/executor/__tests__/claude-launcher.test.ts b/src/cliproxy/executor/__tests__/claude-launcher.test.ts index 535bef1f..e6111e50 100644 --- a/src/cliproxy/executor/__tests__/claude-launcher.test.ts +++ b/src/cliproxy/executor/__tests__/claude-launcher.test.ts @@ -34,7 +34,7 @@ mock.module('child_process', () => ({ const mockEscapeShellArg = jest.fn((s: string) => `"${s}"`); const mockGetWindowsEscapedCommandShell = jest.fn().mockReturnValue('cmd.exe'); -mock.module('../../utils/shell-executor', () => ({ +mock.module('../../../utils/shell-executor', () => ({ escapeShellArg: mockEscapeShellArg, getWindowsEscapedCommandShell: mockGetWindowsEscapedCommandShell, })); @@ -46,7 +46,7 @@ mock.module('../../config/config-generator', () => ({ getProviderConfig: jest.fn(), })); -mock.module('../../utils/websearch-manager', () => ({ +mock.module('../../../utils/websearch-manager', () => ({ appendThirdPartyWebSearchToolArgs: (args: string[]) => args, createWebSearchTraceContext: jest.fn().mockReturnValue({}), ensureWebSearchMcpOrThrow: jest.fn(), @@ -54,17 +54,17 @@ mock.module('../../utils/websearch-manager', () => ({ getWebSearchHookEnv: jest.fn().mockReturnValue({}), })); -mock.module('../../utils/image-analysis', () => ({ +mock.module('../../../utils/image-analysis', () => ({ appendThirdPartyImageAnalysisToolArgs: (args: string[]) => [...args, '--mcp-image-analysis'], syncImageAnalysisMcpToConfigDir: jest.fn(), ensureImageAnalysisMcpOrThrow: jest.fn().mockReturnValue(true), })); -mock.module('../../utils/browser', () => ({ +mock.module('../../../utils/browser', () => ({ appendBrowserToolArgs: (args: string[]) => [...args, '--browser'], })); -mock.module('../accounts/account-manager', () => ({ +mock.module('../../accounts/account-manager', () => ({ getDefaultAccount: jest.fn().mockReturnValue(null), })); @@ -82,7 +82,7 @@ mock.module('../account-resolution', () => ({ })); // Dynamic import for quota-manager -mock.module('../quota/quota-manager', () => ({ +mock.module('../../quota/quota-manager', () => ({ startQuotaMonitor: jest.fn(), stopQuotaMonitor: jest.fn(), })); @@ -202,7 +202,7 @@ describe('launchClaude', () => { it('uses shell mode for .cmd executables on Windows', async () => { await launchClaude(baseContext({ claudeCli: 'C:\\tools\\claude.cmd' })); - const spawnOpts = mockSpawn.mock.calls[0][2] as { shell: string | boolean }; + const spawnOpts = mockSpawn.mock.calls[0][1] as { shell: string | boolean }; // shell property should be set (cmd.exe from mock) expect(spawnOpts.shell).toBeTruthy(); }); diff --git a/src/cliproxy/executor/__tests__/model-warnings.test.ts b/src/cliproxy/executor/__tests__/model-warnings.test.ts index 054b3a7a..19573510 100644 --- a/src/cliproxy/executor/__tests__/model-warnings.test.ts +++ b/src/cliproxy/executor/__tests__/model-warnings.test.ts @@ -5,7 +5,7 @@ * silent for healthy ones, covering both simple and composite providers. */ -import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, jest, mock } from 'bun:test'; // ── Stubs ───────────────────────────────────────────────────────────────────── @@ -23,26 +23,21 @@ const mockGetSuggestedReplacementModel = jest .fn() .mockReturnValue(undefined); -jest.mock('../model-warnings', () => { - // We test the real implementation — only mock its dependencies - return jest.requireActual('../model-warnings'); -}); - -jest.mock('../../config/model-config', () => ({ +mock.module('../../config/model-config', () => ({ getCurrentModel: mockGetCurrentModel, })); -jest.mock('../../cliproxy/model-catalog', () => ({ +mock.module('../../model-catalog', () => ({ isModelBroken: mockIsModelBroken, getModelIssueUrl: mockGetModelIssueUrl, findModel: mockFindModel, getSuggestedReplacementModel: mockGetSuggestedReplacementModel, })); -// We import from real path after jest.mock so actual module is used -import { warnBrokenModels } from '../model-warnings'; import type { ExecutorConfig } from '../../types'; +const { warnBrokenModels } = await import('../model-warnings'); + // ── Helpers ──────────────────────────────────────────────────────────────────── function makeSimpleCfg(overrides: Partial = {}): ExecutorConfig { diff --git a/src/cliproxy/executor/account-resolution.ts b/src/cliproxy/executor/account-resolution.ts index 9d1c49fb..64b14455 100644 --- a/src/cliproxy/executor/account-resolution.ts +++ b/src/cliproxy/executor/account-resolution.ts @@ -150,6 +150,7 @@ export async function resolveAccounts( } } process.exit(1); + return { earlyExit: true }; } setDefaultAccount(provider, account.id); touchAccount(provider, account.id); @@ -166,6 +167,7 @@ export async function resolveAccounts( console.error(fail(`No account found for ${providerConfig.displayName}`)); console.error(` Run "ccs ${provider} --auth" to add an account first`); process.exit(1); + return { earlyExit: true }; } try { const success = renameAccount(provider, defaultAccount.id, setNickname); @@ -174,12 +176,15 @@ export async function resolveAccounts( } else { console.error(fail('Failed to rename account')); process.exit(1); + return { earlyExit: true }; } } catch (err) { console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); process.exit(1); + return { earlyExit: true }; } process.exit(0); + return { earlyExit: true }; } return { earlyExit: false }; diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index e85a00b9..8cc917d6 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -12,12 +12,14 @@ * by a direct atomic write; the running-proxy PATCH path is covered at the * clearDrainOrderPriorities unit level (drain-order.test.ts). */ -import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { handleOrderSubcommand } from '../order-subcommand'; +mock.module('../../../cliproxy/proxy/proxy-detector', () => ({ + detectRunningProxy: async () => ({ running: false, verified: false }), +})); describe('handleOrderSubcommand', () => { let tempHome: string; @@ -52,6 +54,13 @@ describe('handleOrderSubcommand', () => { return import(`../../../cliproxy/accounts/registry?order-subcommand=${Date.now()}`); } + async function runOrderSubcommand(args: string[]): Promise { + const { handleOrderSubcommand } = await import( + `../order-subcommand?order-subcommand=${Date.now()}` + ); + await handleOrderSubcommand(args); + } + beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-')); originalCcsHome = process.env.CCS_HOME; @@ -86,7 +95,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-a.json', 'a@x.com'); registerAccount('claude', 'claude-b.json', 'b@x.com'); - await handleOrderSubcommand(['claude']); + await runOrderSubcommand(['claude']); const output = lines.join('\n'); // b@x.com (priority 5) must appear before a@x.com (priority 1). @@ -113,7 +122,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-a.json', 'a@x.com'); registerAccount('claude', 'claude-b.json', 'b@x.com'); - await handleOrderSubcommand(['claude']); + await runOrderSubcommand(['claude']); const output = lines.join('\n'); expect(output).toContain('no priority set'); @@ -131,7 +140,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-b.json', 'b@x.com'); saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); - await handleOrderSubcommand(['claude', '--reset']); + await runOrderSubcommand(['claude', '--reset']); // Residual priority is actually gone from disk (not just config deleted). expect('priority' in readAuthFile('claude-a.json')).toBe(false); @@ -149,7 +158,7 @@ describe('handleOrderSubcommand', () => { const { registerAccount } = await registerClaude(); registerAccount('claude', 'claude-a.json', 'a@x.com'); - await handleOrderSubcommand(['claude', '--reset']); + await runOrderSubcommand(['claude', '--reset']); const output = lines.join('\n'); expect(output).toContain('reset to file order'); diff --git a/src/glmt/delta-accumulator.ts b/src/glmt/delta-accumulator.ts index 7915267c..a7624854 100644 --- a/src/glmt/delta-accumulator.ts +++ b/src/glmt/delta-accumulator.ts @@ -231,6 +231,7 @@ export class DeltaAccumulator { if (usage) { this.inputTokens = usage.prompt_tokens || usage.input_tokens || 0; this.outputTokens = usage.completion_tokens || usage.output_tokens || 0; + this.usageReceived = true; } } diff --git a/tests/integration/token-counting-test.js b/tests/integration/token-counting-test.js index 8b966f9c..8973b3ae 100755 --- a/tests/integration/token-counting-test.js +++ b/tests/integration/token-counting-test.js @@ -1,8 +1,8 @@ #!/usr/bin/env node 'use strict'; -const GlmtTransformer = require('../dist/glmt/glmt-transformer').default; -const { DeltaAccumulator } = require('../dist/glmt/delta-accumulator'); +const GlmtTransformer = require('../../dist/glmt/glmt-transformer').default; +const { DeltaAccumulator } = require('../../dist/glmt/delta-accumulator'); /** * Token Counting Validation Tests @@ -60,8 +60,8 @@ function assertEqual(actual, expected, message) { if (actual !== expected) { throw new Error( `${message || 'Assertion failed'}\n` + - ` Expected: ${JSON.stringify(expected)}\n` + - ` Actual: ${JSON.stringify(actual)}` + ` Expected: ${JSON.stringify(expected)}\n` + + ` Actual: ${JSON.stringify(actual)}` ); } } @@ -84,8 +84,8 @@ function assertDeepEqual(actual, expected, message) { if (actualStr !== expectedStr) { throw new Error( `${message || 'Deep equality failed'}\n` + - ` Expected: ${expectedStr}\n` + - ` Actual: ${actualStr}` + ` Expected: ${expectedStr}\n` + + ` Actual: ${actualStr}` ); } } @@ -102,14 +102,14 @@ runner.test('message_delta includes both input_tokens and output_tokens', () => // Simulate usage data accumulator.updateUsage({ prompt_tokens: 150, - completion_tokens: 75 + completion_tokens: 75, }); accumulator.finishReason = 'stop'; const events = transformer.finalizeDelta(accumulator); // Find message_delta event - const messageDelta = events.find(e => e.event === 'message_delta'); + const messageDelta = events.find((e) => e.event === 'message_delta'); assertExists(messageDelta, 'message_delta event should exist'); assertExists(messageDelta.data.usage, 'usage should exist in message_delta'); assertEqual(messageDelta.data.usage.input_tokens, 150, 'input_tokens should be 150'); @@ -124,18 +124,20 @@ runner.test('token counting with simple prompt (no tools)', () => { const openaiResponse = { id: 'chatcmpl-123', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: 'Simple response' + choices: [ + { + message: { + role: 'assistant', + content: 'Simple response', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], + ], usage: { prompt_tokens: 10, completion_tokens: 5, - total_tokens: 15 - } + total_tokens: 15, + }, }; const result = transformer.transformResponse(openaiResponse, {}); @@ -153,26 +155,30 @@ runner.test('token counting with tool calls', () => { const openaiResponse = { id: 'chatcmpl-456', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call_1', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location":"London"}' - } - }] + choices: [ + { + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"London"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', }, - finish_reason: 'tool_calls' - }], + ], usage: { prompt_tokens: 50, completion_tokens: 25, - total_tokens: 75 - } + total_tokens: 75, + }, }; const result = transformer.transformResponse(openaiResponse, {}); @@ -181,7 +187,10 @@ runner.test('token counting with tool calls', () => { assertEqual(result.usage.input_tokens, 50, 'input_tokens should be 50'); assertEqual(result.usage.output_tokens, 25, 'output_tokens should be 25'); assertEqual(result.stop_reason, 'tool_use', 'stop_reason should be tool_use'); - assertTrue(result.content.some(b => b.type === 'tool_use'), 'should have tool_use block'); + assertTrue( + result.content.some((b) => b.type === 'tool_use'), + 'should have tool_use block' + ); }); // ======================================== @@ -192,27 +201,31 @@ runner.test('token counting with thinking blocks and tool calls', () => { const openaiResponse = { id: 'chatcmpl-789', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - reasoning_content: 'Let me analyze this request...', - content: 'I need to call a tool', - tool_calls: [{ - id: 'call_2', - type: 'function', - function: { - name: 'calculate', - arguments: '{"expression":"2+2"}' - } - }] + choices: [ + { + message: { + role: 'assistant', + reasoning_content: 'Let me analyze this request...', + content: 'I need to call a tool', + tool_calls: [ + { + id: 'call_2', + type: 'function', + function: { + name: 'calculate', + arguments: '{"expression":"2+2"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', }, - finish_reason: 'tool_calls' - }], + ], usage: { prompt_tokens: 100, completion_tokens: 80, - total_tokens: 180 - } + total_tokens: 180, + }, }; const result = transformer.transformResponse(openaiResponse, {}); @@ -220,8 +233,14 @@ runner.test('token counting with thinking blocks and tool calls', () => { assertExists(result.usage, 'usage should exist'); assertEqual(result.usage.input_tokens, 100, 'input_tokens should be 100'); assertEqual(result.usage.output_tokens, 80, 'output_tokens should be 80'); - assertTrue(result.content.some(b => b.type === 'thinking'), 'should have thinking block'); - assertTrue(result.content.some(b => b.type === 'tool_use'), 'should have tool_use block'); + assertTrue( + result.content.some((b) => b.type === 'thinking'), + 'should have thinking block' + ); + assertTrue( + result.content.some((b) => b.type === 'tool_use'), + 'should have tool_use block' + ); }); // ======================================== @@ -242,17 +261,19 @@ runner.test('deferred finalization waits for usage data', () => { const openaiEvent1 = { event: 'data', data: { - choices: [{ - delta: {}, - finish_reason: 'stop' - }] - } + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + }, }; const events1 = transformer.transformDelta(openaiEvent1, accumulator); // Should NOT have message_stop event yet - const hasMessageStop1 = events1.some(e => e.event === 'message_stop'); + const hasMessageStop1 = events1.some((e) => e.event === 'message_stop'); assertEqual(hasMessageStop1, false, 'should NOT finalize without usage'); assertEqual(accumulator.finalized, false, 'accumulator should NOT be finalized'); @@ -262,15 +283,15 @@ runner.test('deferred finalization waits for usage data', () => { data: { usage: { prompt_tokens: 200, - completion_tokens: 100 - } - } + completion_tokens: 100, + }, + }, }; const events2 = transformer.transformDelta(openaiEvent2, accumulator); // Should NOW finalize since we have both finish_reason AND usage - const hasMessageStop2 = events2.some(e => e.event === 'message_stop'); + const hasMessageStop2 = events2.some((e) => e.event === 'message_stop'); assertEqual(hasMessageStop2, true, 'should finalize when usage arrives'); assertEqual(accumulator.finalized, true, 'accumulator should be finalized'); assertEqual(accumulator.usageReceived, true, 'usageReceived should be true'); @@ -290,9 +311,9 @@ runner.test('finalization waits for BOTH finish_reason AND usage', () => { data: { usage: { prompt_tokens: 50, - completion_tokens: 30 - } - } + completion_tokens: 30, + }, + }, }; const events1 = transformer.transformDelta(openaiEvent1, accumulator); @@ -303,18 +324,20 @@ runner.test('finalization waits for BOTH finish_reason AND usage', () => { const openaiEvent2 = { event: 'data', data: { - choices: [{ - delta: {}, - finish_reason: 'stop' - }] - } + choices: [ + { + delta: {}, + finish_reason: 'stop', + }, + ], + }, }; const events2 = transformer.transformDelta(openaiEvent2, accumulator); assertEqual(accumulator.finishReason, 'stop', 'finish_reason should be set'); assertEqual(accumulator.finalized, true, 'should finalize when both present'); - const messageDelta = events2.find(e => e.event === 'message_delta'); + const messageDelta = events2.find((e) => e.event === 'message_delta'); assertExists(messageDelta, 'message_delta should exist'); assertEqual(messageDelta.data.usage.input_tokens, 50, 'input_tokens in message_delta'); assertEqual(messageDelta.data.usage.output_tokens, 30, 'output_tokens in message_delta'); @@ -333,14 +356,14 @@ runner.test('graceful degradation when usage never arrives', () => { // Simulate [DONE] event without usage const doneEvent = { - event: 'done' + event: 'done', }; const events = transformer.transformDelta(doneEvent, accumulator); // Should finalize with zero tokens (graceful degradation) assertEqual(accumulator.finalized, true, 'should finalize on [DONE]'); - const messageDelta = events.find(e => e.event === 'message_delta'); + const messageDelta = events.find((e) => e.event === 'message_delta'); assertExists(messageDelta, 'message_delta should exist'); assertEqual(messageDelta.data.usage.input_tokens, 0, 'input_tokens should be 0'); assertEqual(messageDelta.data.usage.output_tokens, 0, 'output_tokens should be 0'); @@ -358,10 +381,12 @@ runner.test('no regression: thinking blocks still work', () => { event: 'data', data: { model: 'GLM-4.6', - choices: [{ - delta: { role: 'assistant' } - }] - } + choices: [ + { + delta: { role: 'assistant' }, + }, + ], + }, }; transformer.transformDelta(event1, accumulator); @@ -369,25 +394,25 @@ runner.test('no regression: thinking blocks still work', () => { const event2 = { event: 'data', data: { - choices: [{ - delta: { - reasoning_content: 'Analyzing the problem...' - } - }] - } + choices: [ + { + delta: { + reasoning_content: 'Analyzing the problem...', + }, + }, + ], + }, }; const events2 = transformer.transformDelta(event2, accumulator); // Check thinking block was created - const hasThinkingStart = events2.some(e => - e.event === 'content_block_start' && - e.data.content_block.type === 'thinking' + const hasThinkingStart = events2.some( + (e) => e.event === 'content_block_start' && e.data.content_block.type === 'thinking' ); assertEqual(hasThinkingStart, true, 'thinking block should start'); - const hasThinkingDelta = events2.some(e => - e.event === 'content_block_delta' && - e.data.delta.type === 'thinking_delta' + const hasThinkingDelta = events2.some( + (e) => e.event === 'content_block_delta' && e.data.delta.type === 'thinking_delta' ); assertEqual(hasThinkingDelta, true, 'thinking delta should be emitted'); }); @@ -404,34 +429,36 @@ runner.test('no regression: tool calls execute correctly', () => { const event = { event: 'data', data: { - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id: 'call_abc', - type: 'function', - function: { - name: 'search', - arguments: '{"q":"test"}' - } - }] - } - }] - } + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_abc', + type: 'function', + function: { + name: 'search', + arguments: '{"q":"test"}', + }, + }, + ], + }, + }, + ], + }, }; const events = transformer.transformDelta(event, accumulator); - const toolUseStart = events.find(e => - e.event === 'content_block_start' && - e.data.content_block.type === 'tool_use' + const toolUseStart = events.find( + (e) => e.event === 'content_block_start' && e.data.content_block.type === 'tool_use' ); assertExists(toolUseStart, 'tool_use block should start'); assertEqual(toolUseStart.data.content_block.name, 'search', 'tool name should be search'); - const inputJsonDelta = events.find(e => - e.event === 'content_block_delta' && - e.data.delta.type === 'input_json_delta' + const inputJsonDelta = events.find( + (e) => e.event === 'content_block_delta' && e.data.delta.type === 'input_json_delta' ); assertExists(inputJsonDelta, 'input_json_delta should be emitted'); }); @@ -448,32 +475,41 @@ runner.test('no regression: streaming still works', () => { event: 'data', data: { model: 'GLM-4.6', - choices: [{ delta: { role: 'assistant' } }] - } + choices: [{ delta: { role: 'assistant' } }], + }, }; const events1 = transformer.transformDelta(event1, accumulator); - assertTrue(events1.some(e => e.event === 'message_start'), 'message_start event'); + assertTrue( + events1.some((e) => e.event === 'message_start'), + 'message_start event' + ); // Text delta const event2 = { event: 'data', data: { - choices: [{ delta: { content: 'Hello' } }] - } + choices: [{ delta: { content: 'Hello' } }], + }, }; const events2 = transformer.transformDelta(event2, accumulator); - assertTrue(events2.some(e => e.event === 'content_block_start'), 'content_block_start'); - assertTrue(events2.some(e => e.event === 'content_block_delta'), 'content_block_delta'); + assertTrue( + events2.some((e) => e.event === 'content_block_start'), + 'content_block_start' + ); + assertTrue( + events2.some((e) => e.event === 'content_block_delta'), + 'content_block_delta' + ); // More text const event3 = { event: 'data', data: { - choices: [{ delta: { content: ' world' } }] - } + choices: [{ delta: { content: ' world' } }], + }, }; const events3 = transformer.transformDelta(event3, accumulator); - const textDelta = events3.find(e => e.data?.delta?.type === 'text_delta'); + const textDelta = events3.find((e) => e.data?.delta?.type === 'text_delta'); assertExists(textDelta, 'text_delta should exist'); assertEqual(textDelta.data.delta.text, ' world', 'delta text should be " world"'); }); @@ -487,27 +523,35 @@ runner.test('no regression: buffered mode (non-streaming) works', () => { const openaiResponse = { id: 'chatcmpl-buffered', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - reasoning_content: 'Thinking step by step...', - content: 'Final answer' + choices: [ + { + message: { + role: 'assistant', + reasoning_content: 'Thinking step by step...', + content: 'Final answer', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], + ], usage: { prompt_tokens: 20, completion_tokens: 15, - total_tokens: 35 - } + total_tokens: 35, + }, }; const result = transformer.transformResponse(openaiResponse, {}); assertEqual(result.type, 'message', 'type should be message'); assertEqual(result.role, 'assistant', 'role should be assistant'); - assertTrue(result.content.some(b => b.type === 'thinking'), 'has thinking'); - assertTrue(result.content.some(b => b.type === 'text'), 'has text'); + assertTrue( + result.content.some((b) => b.type === 'thinking'), + 'has thinking' + ); + assertTrue( + result.content.some((b) => b.type === 'text'), + 'has text' + ); assertEqual(result.usage.input_tokens, 20, 'input_tokens'); assertEqual(result.usage.output_tokens, 15, 'output_tokens'); }); @@ -522,7 +566,7 @@ runner.test('usageReceived flag is set when usage data arrives', () => { accumulator.updateUsage({ prompt_tokens: 100, - completion_tokens: 50 + completion_tokens: 50, }); assertEqual(accumulator.usageReceived, true, 'should be true after updateUsage'); @@ -558,65 +602,87 @@ runner.test('streaming: token counts with thinking + text + tools', () => { const accumulator = new DeltaAccumulator(); // Message start - transformer.transformDelta({ - event: 'data', - data: { - model: 'GLM-4.6', - choices: [{ delta: { role: 'assistant' } }] - } - }, accumulator); + transformer.transformDelta( + { + event: 'data', + data: { + model: 'GLM-4.6', + choices: [{ delta: { role: 'assistant' } }], + }, + }, + accumulator + ); // Thinking - transformer.transformDelta({ - event: 'data', - data: { - choices: [{ delta: { reasoning_content: 'Thinking...' } }] - } - }, accumulator); + transformer.transformDelta( + { + event: 'data', + data: { + choices: [{ delta: { reasoning_content: 'Thinking...' } }], + }, + }, + accumulator + ); // Text - transformer.transformDelta({ - event: 'data', - data: { - choices: [{ delta: { content: 'Answer' } }] - } - }, accumulator); + transformer.transformDelta( + { + event: 'data', + data: { + choices: [{ delta: { content: 'Answer' } }], + }, + }, + accumulator + ); // Tool call - transformer.transformDelta({ - event: 'data', - data: { - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id: 'call_1', - type: 'function', - function: { name: 'tool', arguments: '{}' } - }] - } - }] - } - }, accumulator); + transformer.transformDelta( + { + event: 'data', + data: { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'tool', arguments: '{}' }, + }, + ], + }, + }, + ], + }, + }, + accumulator + ); // Usage arrives - transformer.transformDelta({ - event: 'data', - data: { - usage: { prompt_tokens: 300, completion_tokens: 200 } - } - }, accumulator); + transformer.transformDelta( + { + event: 'data', + data: { + usage: { prompt_tokens: 300, completion_tokens: 200 }, + }, + }, + accumulator + ); // finish_reason arrives - const finalEvents = transformer.transformDelta({ - event: 'data', - data: { - choices: [{ delta: {}, finish_reason: 'tool_calls' }] - } - }, accumulator); + const finalEvents = transformer.transformDelta( + { + event: 'data', + data: { + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }, + }, + accumulator + ); // Verify message_delta has correct tokens - const messageDelta = finalEvents.find(e => e.event === 'message_delta'); + const messageDelta = finalEvents.find((e) => e.event === 'message_delta'); assertExists(messageDelta, 'message_delta should exist'); assertEqual(messageDelta.data.usage.input_tokens, 300, 'input_tokens should be 300'); assertEqual(messageDelta.data.usage.output_tokens, 200, 'output_tokens should be 200'); @@ -624,9 +690,12 @@ runner.test('streaming: token counts with thinking + text + tools', () => { }); // Run all tests -runner.run().then(success => { - process.exit(success ? 0 : 1); -}).catch(error => { - console.error('Test runner error:', error); - process.exit(1); -}); +runner + .run() + .then((success) => { + process.exit(success ? 0 : 1); + }) + .catch((error) => { + console.error('Test runner error:', error); + process.exit(1); + }); diff --git a/tests/integration/z-ai-streaming-test.js b/tests/integration/z-ai-streaming-test.js index da05662e..07c51c5b 100755 --- a/tests/integration/z-ai-streaming-test.js +++ b/tests/integration/z-ai-streaming-test.js @@ -7,10 +7,9 @@ const API_KEY = process.env.Z_AI_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN; const MODEL = 'GLM-4.6'; if (!API_KEY || API_KEY === 'your-api-key-here') { - console.error('[ERROR] Z.AI API key not found'); - console.error('[INFO] Set Z_AI_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable'); - console.error('[INFO] Example: export Z_AI_API_KEY=your-key-here'); - process.exit(1); + console.log('[SKIP] Z.AI API key not found'); + console.log('[INFO] Set Z_AI_API_KEY or ANTHROPIC_AUTH_TOKEN to run this external smoke test'); + process.exit(0); } // Test request with reasoning @@ -19,14 +18,15 @@ const requestBody = JSON.stringify({ messages: [ { role: 'user', - content: 'Solve this math problem step by step: What is 27 * 453? Show your reasoning process.' - } + content: + 'Solve this math problem step by step: What is 27 * 453? Show your reasoning process.', + }, ], stream: true, reasoning: true, reasoning_effort: 'medium', max_tokens: 4096, - do_sample: true + do_sample: true, }); const options = { @@ -36,10 +36,10 @@ const options = { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${API_KEY}`, + Authorization: `Bearer ${API_KEY}`, 'Content-Length': Buffer.byteLength(requestBody), - 'User-Agent': 'CCS-GLMT-StreamingTest/1.0' - } + 'User-Agent': 'CCS-GLMT-StreamingTest/1.0', + }, }; console.log('═══════════════════════════════════════════════════════════════'); @@ -63,7 +63,7 @@ const req = https.request(options, (res) => { if (res.statusCode !== 200) { let errorBody = ''; - res.on('data', chunk => errorBody += chunk); + res.on('data', (chunk) => (errorBody += chunk)); res.on('end', () => { console.error('[ERROR] Request failed:', res.statusCode, res.statusMessage); console.error('[ERROR] Response:', errorBody); @@ -98,7 +98,7 @@ const req = https.request(options, (res) => { // Keep incomplete line in buffer buffer = lines.pop() || ''; - lines.forEach(line => { + lines.forEach((line) => { if (line.startsWith('data: ')) { eventCount++; const data = line.substring(6); @@ -124,7 +124,10 @@ const req = https.request(options, (res) => { console.log(`[EVENT ${eventCount}] *** REASONING IN DELTA ***`); console.log(' Delta keys:', Object.keys(delta).join(', ')); console.log(' Reasoning chunk length:', delta.reasoning_content.length); - console.log(' Reasoning chunk preview:', JSON.stringify(delta.reasoning_content.substring(0, 80))); + console.log( + ' Reasoning chunk preview:', + JSON.stringify(delta.reasoning_content.substring(0, 80)) + ); console.log(''); } @@ -134,7 +137,10 @@ const req = https.request(options, (res) => { console.log(`[EVENT ${eventCount}] *** REASONING IN MESSAGE (COMPLETE) ***`); console.log(' Message keys:', Object.keys(message).join(', ')); console.log(' Reasoning total length:', message.reasoning_content.length); - console.log(' Reasoning preview:', message.reasoning_content.substring(0, 100).replace(/\n/g, ' ')); + console.log( + ' Reasoning preview:', + message.reasoning_content.substring(0, 100).replace(/\n/g, ' ') + ); console.log(''); } @@ -159,7 +165,6 @@ const req = https.request(options, (res) => { console.log(`[EVENT ${eventCount}] Usage:`, JSON.stringify(parsed.usage)); console.log(''); } - } catch (e) { console.log(`[EVENT ${eventCount}] [PARSE ERROR]`, e.message); console.log(' Raw:', data.substring(0, 100)); diff --git a/tests/unit/glmt/debug-mode-test.js b/tests/unit/glmt/debug-mode-test.js index 594f077e..6ae9674f 100755 --- a/tests/unit/glmt/debug-mode-test.js +++ b/tests/unit/glmt/debug-mode-test.js @@ -10,7 +10,12 @@ const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; * Manual test for debug mode file logging */ -const logDir = path.join(os.homedir(), '.ccs', 'logs'); +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-debug-mode-test-')); +const logDir = path.join(testRoot, 'logs'); + +process.on('exit', () => { + fs.rmSync(testRoot, { recursive: true, force: true }); +}); // Clean up any existing logs console.log('Cleaning up existing logs...'); @@ -20,12 +25,12 @@ if (fs.existsSync(logDir)) { console.log('\n=== Test 1: Debug Mode OFF (default) ==='); { - const transformer = new GlmtTransformer({ verbose: false }); + const transformer = new GlmtTransformer({ verbose: false, debugLogDir: logDir }); console.log(`Debug logging: ${transformer.debugLog}`); const input = { model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test without debug' }] + messages: [{ role: 'user', content: 'Test without debug' }], }; const { openaiRequest } = transformer.transformRequest(input); @@ -33,15 +38,17 @@ console.log('\n=== Test 1: Debug Mode OFF (default) ==='); const openaiResponse = { id: 'test-1', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: 'Response without debug', - reasoning_content: 'Some reasoning...' + choices: [ + { + message: { + role: 'assistant', + content: 'Response without debug', + reasoning_content: 'Some reasoning...', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, }; transformer.transformResponse(openaiResponse, {}); @@ -59,13 +66,13 @@ console.log('\n=== Test 1: Debug Mode OFF (default) ==='); console.log('\n=== Test 2: Debug Mode ON (via config) ==='); { - const transformer = new GlmtTransformer({ verbose: true, debugLog: true }); + const transformer = new GlmtTransformer({ verbose: true, debugLog: true, debugLogDir: logDir }); console.log(`Debug logging: ${transformer.debugLog}`); console.log(`Log directory: ${transformer.debugLogDir}`); const input = { model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test with debug' }] + messages: [{ role: 'user', content: 'Test with debug' }], }; const { openaiRequest } = transformer.transformRequest(input); @@ -73,15 +80,17 @@ console.log('\n=== Test 2: Debug Mode ON (via config) ==='); const openaiResponse = { id: 'test-2', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: 'Response with debug', - reasoning_content: 'Detailed reasoning process for debugging...' + choices: [ + { + message: { + role: 'assistant', + content: 'Response with debug', + reasoning_content: 'Detailed reasoning process for debugging...', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, }; transformer.transformResponse(openaiResponse, {}); @@ -105,10 +114,10 @@ console.log('\n=== Test 2: Debug Mode ON (via config) ==='); } // Check file names - const hasRequestAnthropic = files.some(f => f.includes('request-anthropic')); - const hasRequestOpenai = files.some(f => f.includes('request-openai')); - const hasResponseOpenai = files.some(f => f.includes('response-openai')); - const hasResponseAnthropic = files.some(f => f.includes('response-anthropic')); + const hasRequestAnthropic = files.some((f) => f.includes('request-anthropic')); + const hasRequestOpenai = files.some((f) => f.includes('request-openai')); + const hasResponseOpenai = files.some((f) => f.includes('response-openai')); + const hasResponseAnthropic = files.some((f) => f.includes('response-anthropic')); console.log(`\nFile types found:`); console.log(` request-anthropic: ${hasRequestAnthropic ? '✓' : '✗'}`); @@ -122,25 +131,27 @@ console.log('\n=== Test 2: Debug Mode ON (via config) ==='); } // Check file contents - const responseOpenaiFile = files.find(f => f.includes('response-openai')); + const responseOpenaiFile = files.find((f) => f.includes('response-openai')); const responseOpenaiPath = path.join(logDir, responseOpenaiFile); const responseOpenaiData = JSON.parse(fs.readFileSync(responseOpenaiPath, 'utf8')); console.log(`\nChecking response-openai.json for reasoning_content...`); if (responseOpenaiData.choices[0].message.reasoning_content) { console.log('✓ reasoning_content found in response-openai.json'); - console.log(` Length: ${responseOpenaiData.choices[0].message.reasoning_content.length} chars`); + console.log( + ` Length: ${responseOpenaiData.choices[0].message.reasoning_content.length} chars` + ); } else { console.log('ERROR: reasoning_content NOT found in response-openai.json!'); process.exit(1); } - const responseAnthropicFile = files.find(f => f.includes('response-anthropic')); + const responseAnthropicFile = files.find((f) => f.includes('response-anthropic')); const responseAnthropicPath = path.join(logDir, responseAnthropicFile); const responseAnthropicData = JSON.parse(fs.readFileSync(responseAnthropicPath, 'utf8')); console.log(`\nChecking response-anthropic.json for thinking block...`); - const thinkingBlock = responseAnthropicData.content.find(b => b.type === 'thinking'); + const thinkingBlock = responseAnthropicData.content.find((b) => b.type === 'thinking'); if (thinkingBlock) { console.log('✓ Thinking block found in response-anthropic.json'); console.log(` Length: ${thinkingBlock.thinking.length} chars`); @@ -158,12 +169,12 @@ console.log('\n=== Test 3: Debug Mode via CCS_DEBUG=1 ==='); fs.rmSync(logDir, { recursive: true, force: true }); process.env.CCS_DEBUG = '1'; - const transformer = new GlmtTransformer({ verbose: false }); + const transformer = new GlmtTransformer({ verbose: false, debugLogDir: logDir }); console.log(`Debug logging: ${transformer.debugLog}`); const input = { model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test with env var' }] + messages: [{ role: 'user', content: 'Test with env var' }], }; transformer.transformRequest(input); @@ -171,15 +182,17 @@ console.log('\n=== Test 3: Debug Mode via CCS_DEBUG=1 ==='); const openaiResponse = { id: 'test-3', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: 'Response', - reasoning_content: 'Reasoning...' + choices: [ + { + message: { + role: 'assistant', + content: 'Response', + reasoning_content: 'Reasoning...', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, }; transformer.transformResponse(openaiResponse, {}); @@ -211,14 +224,14 @@ console.log('\n=== Test 4: Error Handling (No Write Permission) ==='); const transformer = new GlmtTransformer({ debugLog: true, debugLogDir: readOnlyDir, - verbose: false + verbose: false, }); console.log('Testing graceful error handling with read-only directory...'); const input = { model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test error handling' }] + messages: [{ role: 'user', content: 'Test error handling' }], }; try { diff --git a/tests/unit/glmt/performance-test.js b/tests/unit/glmt/performance-test.js index c00175fa..66aadbcc 100644 --- a/tests/unit/glmt/performance-test.js +++ b/tests/unit/glmt/performance-test.js @@ -2,28 +2,35 @@ 'use strict'; const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; +const fs = require('fs'); +const path = require('path'); +const os = require('os'); console.log('=== Performance Test: Debug Mode Impact ===\n'); const iterations = 1000; +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-performance-test-')); +const logDir = path.join(testRoot, 'logs'); const sampleRequest = { model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test performance' }] + messages: [{ role: 'user', content: 'Test performance' }], }; const sampleResponse = { id: 'perf-test', model: 'GLM-4.6', - choices: [{ - message: { - role: 'assistant', - content: 'Quick response', - reasoning_content: 'Some reasoning content here...' + choices: [ + { + message: { + role: 'assistant', + content: 'Quick response', + reasoning_content: 'Some reasoning content here...', + }, + finish_reason: 'stop', }, - finish_reason: 'stop' - }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, }; // Test 1: Debug OFF @@ -41,7 +48,7 @@ console.log(` Average per request: ${(timeOff / iterations).toFixed(2)}ms`); // Test 2: Debug ON console.log(`\nTest 2: Debug ON (${iterations} iterations)`); -const transformerOn = new GlmtTransformer({ debugLog: true, verbose: false }); +const transformerOn = new GlmtTransformer({ debugLog: true, verbose: false, debugLogDir: logDir }); const startOn = Date.now(); for (let i = 0; i < iterations; i++) { transformerOn.transformRequest(sampleRequest); @@ -66,11 +73,8 @@ console.log(`\nNote: Debug mode is opt-in only and disabled by default.`); console.log(`When disabled, there is NO performance impact (early return in _writeDebugLog).`); // Cleanup -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const logDir = path.join(os.homedir(), '.ccs', 'logs'); if (fs.existsSync(logDir)) { fs.rmSync(logDir, { recursive: true, force: true }); console.log(`\nCleaned up ${iterations * 4} debug log files.`); } +fs.rmSync(testRoot, { recursive: true, force: true }); diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js index bbb9acee..fc60e328 100644 --- a/tests/unit/scripts/run-test-bucket.test.js +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -44,6 +44,103 @@ describe('run-test-bucket', () => { expect(discovered).toContain('src/cliproxy/types/__tests__/types-backward-compat.test.ts'); }); + test('passes selected tests to Bun as explicit relative paths', () => { + const args = bucket.getBunArgs('fast', [ + 'tests/unit/example.test.ts', + 'src/cliproxy/types/__tests__/types-backward-compat.test.ts', + ]); + + expect(args).toEqual([ + 'test', + './tests/unit/example.test.ts', + './src/cliproxy/types/__tests__/types-backward-compat.test.ts', + ]); + }); + + test('keeps slow bucket concurrency flag before explicit test paths', () => { + const args = bucket.getBunArgs('slow', ['tests/integration/example.test.ts']); + + expect(args).toEqual(['test', '--max-concurrency=1', './tests/integration/example.test.ts']); + }); + + test('isolates src tests while keeping already-covered tests in a shared run', () => { + const runs = bucket.getBunRuns('fast', [ + 'tests/unit/scripts/run-test-bucket.test.js', + 'src/cliproxy/types/__tests__/types-backward-compat.test.ts', + 'src/errors/__tests__/error-types.test.ts', + ]); + + expect(runs.map((run) => run.label)).toEqual([ + 'shared', + 'src/cliproxy/types/__tests__/types-backward-compat.test.ts', + 'src/errors/__tests__/error-types.test.ts', + ]); + expect(runs[0].selected).toEqual(['tests/unit/scripts/run-test-bucket.test.js']); + expect(runs[0].bunArgs).toEqual(['test', './tests/unit/scripts/run-test-bucket.test.js']); + expect(runs[1].bunArgs).toEqual([ + 'test', + './src/cliproxy/types/__tests__/types-backward-compat.test.ts', + ]); + expect(runs[1].quietOnPass).toBe(true); + }); + + test('isolates known sticky mock suites outside src', () => { + const runs = bucket.getBunRuns('fast', [ + 'tests/unit/scripts/run-test-bucket.test.js', + 'tests/unit/targets/target-registry.test.ts', + ]); + + expect(runs.map((run) => run.label)).toEqual([ + 'shared', + 'tests/unit/targets/target-registry.test.ts', + ]); + }); + + test('isolates standalone validation scripts that are not Bun test suites', () => { + const runs = bucket.getBunRuns('slow', [ + 'tests/integration/cursor-daemon-lifecycle.test.ts', + 'tests/integration/token-counting-test.js', + ]); + + expect(bucket.usesBunTestRunner('tests/integration/cursor-daemon-lifecycle.test.ts')).toBe( + true + ); + expect(bucket.usesBunTestRunner('tests/integration/token-counting-test.js')).toBe(false); + expect(runs.map((run) => run.label)).toEqual([ + 'shared', + 'tests/integration/token-counting-test.js', + ]); + expect(runs[1].quietOnPass).toBe(true); + }); + + test('parses Bun file counts from test summaries', () => { + expect(bucket.parseBunFileCount('\u001b[32mRan 2559 tests across 272 files\u001b[0m')).toBe( + 272 + ); + }); + + test('detects when Bun reports fewer files than the bucket selected', () => { + const result = bucket.verifyReportedFileCount(364, 'Ran 2559 tests across 272 files'); + + expect(result.ok).toBe(false); + expect(result.reportedCount).toBe(272); + expect(result.selectedCount).toBe(364); + expect(result.message).toContain('Bun ran 272 files'); + }); + + test('skips Bun file-count verification for standalone validation scripts', () => { + expect( + bucket.shouldVerifyRunFileCount({ + selected: ['tests/integration/token-counting-test.js'], + }) + ).toBe(false); + expect( + bucket.shouldVerifyRunFileCount({ + selected: ['tests/integration/cursor-daemon-lifecycle.test.ts'], + }) + ).toBe(true); + }); + test('keeps dist-independent javascript tests in the fast bucket', () => { expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false); });