From 04fd8ff01997c7475cab0247f8e2ab86e9f0588c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Mar 2026 09:51:41 -0400 Subject: [PATCH] fix: restore parallel-safe validate pipeline --- package.json | 4 +- scripts/run-tests-serial.js | 64 ------- src/utils/claude-config-path.ts | 8 +- src/utils/config-manager.ts | 41 +++++ ...codex-plan-compatibility-reconcile.test.ts | 22 +-- tests/unit/commands/config-command.test.ts | 49 +----- .../commands/persist-command-handler.test.ts | 78 ++++----- .../proxy-lifecycle-subcommand.test.ts | 28 ++- .../claude-extension-routes.test.ts | 4 +- .../cliproxy-stats-routes-install.test.ts | 163 +++++++----------- .../web-server/cliproxy-usage-syncer.test.ts | 21 ++- 11 files changed, 176 insertions(+), 306 deletions(-) delete mode 100644 scripts/run-tests-serial.js diff --git a/package.json b/package.json index b128fed9..d27beb6e 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,8 @@ "verify:bundle": "node scripts/verify-bundle.js", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", - "test:all": "node scripts/run-tests-serial.js tests/unit tests/integration tests/npm", - "test:unit": "node scripts/run-tests-serial.js tests/unit", + "test:all": "bun test tests/unit tests/integration tests/npm", + "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", diff --git a/scripts/run-tests-serial.js b/scripts/run-tests-serial.js deleted file mode 100644 index 81feba36..00000000 --- a/scripts/run-tests-serial.js +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); -const { spawnSync } = require('child_process'); - -const roots = process.argv.slice(2); -const testRoots = roots.length > 0 ? roots : ['tests']; -const testFilePattern = /(?:\.test|_test_|\.spec|_spec_)\.(?:[cm]?[jt]s)$/; - -function collectTestFiles(rootDir) { - if (!fs.existsSync(rootDir)) { - return []; - } - - const stats = fs.statSync(rootDir); - if (stats.isFile()) { - return testFilePattern.test(path.basename(rootDir)) ? [rootDir] : []; - } - - const entries = fs.readdirSync(rootDir, { withFileTypes: true }); - const files = []; - - for (const entry of entries) { - const fullPath = path.join(rootDir, entry.name); - - if (entry.isDirectory()) { - files.push(...collectTestFiles(fullPath)); - continue; - } - - if (!entry.isFile()) { - continue; - } - - if (testFilePattern.test(entry.name)) { - files.push(fullPath); - } - } - - return files; -} - -const testFiles = [...new Set(testRoots.flatMap((rootDir) => collectTestFiles(rootDir)))].sort(); - -if (testFiles.length === 0) { - console.error('No test files found.'); - process.exit(1); -} - -for (let index = 0; index < testFiles.length; index += 1) { - const file = testFiles[index]; - console.log(`\n[${index + 1}/${testFiles.length}] ${file}`); - const bunTestPath = path.isAbsolute(file) ? file : `./${file}`; - - const result = spawnSync('bun', ['test', bunTestPath], { - stdio: 'inherit', - env: process.env, - }); - - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index e084cceb..0cd853ae 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -1,5 +1,5 @@ -import * as os from 'os'; import * as path from 'path'; +import { getCcsHome } from './config-manager'; /** * Resolve Claude config directory with test/dev overrides. @@ -13,11 +13,7 @@ export function getClaudeConfigDir(): string { return path.resolve(process.env.CLAUDE_CONFIG_DIR); } - if (process.env.CCS_HOME) { - return path.join(path.resolve(process.env.CCS_HOME), '.claude'); - } - - return path.join(os.homedir(), '.claude'); + return path.join(getCcsHome(), '.claude'); } /** Resolve Claude settings.json path. */ diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 251e4367..e20e3660 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'async_hooks'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -13,6 +14,38 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf // Module-level state for --config-dir CLI flag override let _globalConfigDir: string | undefined; +const configScopeStorage = new AsyncLocalStorage<{ ccsHome?: string; ccsDir?: string }>(); + +function normalizeScopedPath(dir: string | undefined): string | undefined { + return dir ? path.resolve(dir) : undefined; +} + +export async function runWithScopedConfig( + scope: { ccsHome?: string; ccsDir?: string }, + fn: () => Promise | T +): Promise { + return await configScopeStorage.run( + { + ccsHome: normalizeScopedPath(scope.ccsHome), + ccsDir: normalizeScopedPath(scope.ccsDir), + }, + fn + ); +} + +export async function runWithScopedCcsHome( + ccsHome: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsHome }, fn); +} + +export async function runWithScopedConfigDir( + ccsDir: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsDir }, fn); +} /** * Set global config directory from --config-dir CLI flag. @@ -28,6 +61,10 @@ export function setGlobalConfigDir(dir: string | undefined): void { * @returns Home directory path */ export function getCcsHome(): string { + const scopedHome = configScopeStorage.getStore()?.ccsHome; + if (scopedHome) { + return scopedHome; + } return process.env.CCS_HOME || os.homedir(); } @@ -36,6 +73,10 @@ export function getCcsHome(): string { * Single source of truth for precedence logic. */ function _resolveCcsDir(): { source: string; dir: string } { + const scopedConfig = configScopeStorage.getStore(); + if (scopedConfig?.ccsDir) return { source: 'scoped:CCS_DIR', dir: scopedConfig.ccsDir }; + if (scopedConfig?.ccsHome) + return { source: 'scoped:CCS_HOME', dir: path.join(scopedConfig.ccsHome, '.ccs') }; if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; if (process.env.CCS_HOME) diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index b22ac1fa..7c104741 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -2,8 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache'; afterEach(() => { + clearQuotaCache(); mock.restore(); }); @@ -57,10 +59,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'free@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -103,10 +101,6 @@ describe('codex plan compatibility reconcile', () => { throw new Error('should not fetch quota without a default account'); }, })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -153,10 +147,6 @@ describe('codex plan compatibility reconcile', () => { accountId: `${planType}@example.com`, }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -202,10 +192,6 @@ describe('codex plan compatibility reconcile', () => { error: 'network timeout', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -250,10 +236,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'missing-plan@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 27c5b44c..d5c539f9 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; const startServerCalls: Array> = []; -const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = []; const configAuthCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; @@ -14,7 +13,6 @@ let originalProcessExit: typeof process.exit; beforeEach(() => { startServerCalls.length = 0; - resolveDashboardUrlsCalls.length = 0; configAuthCalls.length = 0; logLines = []; errorLines = []; @@ -91,42 +89,6 @@ beforeEach(() => { mock.module('../../../src/utils/ui', () => uiModule); mock.module('../../../src/utils/ui.ts', () => uiModule); - mock.module('../../../src/commands/config-dashboard-host', () => ({ - normalizeDashboardHost: (host: string | undefined) => { - if (!host) { - return undefined; - } - - if (host.startsWith('[') && host.endsWith(']') && host.includes(':')) { - return host.slice(1, -1); - } - - return host; - }, - isLoopbackHost: (host: string) => - ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.trim().toLowerCase()), - isWildcardHost: (host: string) => ['0.0.0.0', '::', '[::]'].includes(host.trim().toLowerCase()), - resolveDashboardUrls: (host: string | undefined, port: number) => { - resolveDashboardUrlsCalls.push([host, port]); - if (!host) { - return { browserUrl: `http://localhost:${port}` }; - } - - if (host === '0.0.0.0' || host === '::') { - return { - bindHost: host, - browserUrl: `http://localhost:${port}`, - networkUrls: [`http://192.168.1.25:${port}`, `http://100.64.0.12:${port}`], - }; - } - - return { - bindHost: host, - browserUrl: `http://${host}:${port}`, - }; - }, - })); - mock.module('../../../src/commands/config-auth', () => ({ handleConfigAuthCommand: async (args: string[]) => { configAuthCalls.push([...args]); @@ -158,7 +120,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); }); @@ -169,7 +130,6 @@ describe('config command dashboard startup', () => { expect(configAuthCalls).toEqual([['setup']]); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); }); it('rejects unknown config subcommands before dashboard startup', async () => { @@ -181,7 +141,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); @@ -192,12 +151,11 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); - expect(resolveDashboardUrlsCalls).toEqual([['::', 3000]]); const rendered = logLines.join('\n'); expect(rendered).toContain('Dashboard: http://localhost:3000'); expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain('Network URLs:'); + expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.'); expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); @@ -210,13 +168,10 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); - expect(resolveDashboardUrlsCalls).toEqual([['0.0.0.0', 4100]]); const rendered = logLines.join('\n'); + expect(rendered).toContain('Dashboard: http://localhost:4100'); expect(rendered).toContain('Bind host: 0.0.0.0'); - expect(rendered).toContain('Network URLs:'); - expect(rendered).toContain('http://192.168.1.25:4100'); - expect(rendered).toContain('http://100.64.0.12:4100'); expect(rendered).toContain( 'Dashboard may be reachable from other devices that can connect to this machine.' ); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index a298c09d..6235be55 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -6,6 +6,7 @@ import * as lockfile from 'proper-lockfile'; import { handlePersistCommand } from '../../../src/commands/persist-command'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; interface RestoreFixture { claudeDir: string; @@ -18,11 +19,14 @@ interface RestoreFixture { let tempRoot: string; let originalClaudeConfigDir: string | undefined; -let originalCcsHome: string | undefined; let originalProcessExit: typeof process.exit; let originalFsOpen: typeof fs.promises.open; let originalFsRename: typeof fs.promises.rename; +async function withScopedHome(fn: () => Promise): Promise { + return await runWithScopedCcsHome(tempRoot, fn); +} + async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.F_OK); @@ -69,11 +73,9 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - originalCcsHome = process.env.CCS_HOME; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; - process.env.CCS_HOME = tempRoot; }); afterEach(async () => { @@ -87,12 +89,6 @@ afterEach(async () => { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; } - if (originalCcsHome === undefined) { - delete process.env.CCS_HOME; - } else { - process.env.CCS_HOME = originalCcsHome; - } - if (tempRoot) { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } @@ -100,50 +96,50 @@ afterEach(async () => { describe('persist command real handler paths', () => { it('throws parseError for missing --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for empty inline --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for invalid --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow( - /Invalid --permission-mode/ - ); + await expect( + withScopedHome(() => handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])) + ).rejects.toThrow(/Invalid --permission-mode/); }); it('throws parseError for unknown flags on real handler path', async () => { - await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow( /Unknown option\(s\)/ ); }); it('throws parseError for list/restore conflict on real handler path', async () => { - await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow( '--list-backups cannot be used with --restore' ); }); it('throws parseError for permission flags with --restore on real handler path', async () => { - await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow( /Permission flags are not valid with backup operations/ ); }); it('shows help when --help is present even with other invalid args', async () => { - await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined(); + await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined(); }); it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => { const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early'); process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir; - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); expect(await pathExists(isolatedClaudeDir)).toBe(false); @@ -163,9 +159,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); } finally { await release(); } @@ -186,9 +182,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup read fails with ELOOP (symlink rejection)', async () => { @@ -206,9 +202,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup path resolves to a non-regular file', async () => { @@ -229,9 +225,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('rolls back settings when restore write fails mid-flight', async () => { @@ -248,9 +244,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.rename; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8'); const finalSettings = JSON.parse(finalContent); @@ -273,9 +269,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true); } finally { console.log = originalConsoleLog; @@ -313,7 +309,9 @@ describe('persist command Claude extension parity', () => { ) + '\n', 'utf8' ); - saveUnifiedConfig(config); + await withScopedHome(async () => { + saveUnifiedConfig(config); + }); } it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => { @@ -337,7 +335,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['work', '--yes']); + await withScopedHome(() => handlePersistCommand(['work', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; @@ -371,7 +369,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['default', '--yes']); + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 3488ecfe..0cc8835b 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -4,9 +4,9 @@ import * as os from 'os'; import * as path from 'path'; import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let tempDir: string; -let originalCcsDir: string | undefined; function writeUnifiedConfig(localPort: number): void { const configPath = path.join(tempDir, 'config.yaml'); @@ -34,33 +34,31 @@ cliproxy_server: describe('resolveLifecyclePort', () => { beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); - originalCcsDir = process.env.CCS_DIR; - process.env.CCS_DIR = tempDir; }); afterEach(() => { - if (originalCcsDir !== undefined) { - process.env.CCS_DIR = originalCcsDir; - } else { - delete process.env.CCS_DIR; - } - if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); - it('uses configured cliproxy_server.local.port', () => { + it('uses configured cliproxy_server.local.port', async () => { writeUnifiedConfig(9456); - expect(resolveLifecyclePort()).toBe(9456); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(9456); + }); }); - it('falls back to default port when configured local port is invalid', () => { + it('falls back to default port when configured local port is invalid', async () => { writeUnifiedConfig(70000); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); - it('falls back to default port when config file is missing', () => { - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + it('falls back to default port when config file is missing', async () => { + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 204b113a..753e40fd 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -60,7 +60,9 @@ describe('web-server claude-extension-routes', () => { }); afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } setGlobalConfigDir(undefined); if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index cf3e8be2..40578506 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -1,104 +1,57 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { Server } from 'http'; -const installSpy = { - calls: 0, -}; - -mock.module('../../../src/config/unified-config-loader', () => ({ - isUnifiedMode: () => true, - loadUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), - getGlobalEnvConfig: () => ({}), - getThinkingConfig: () => ({ - mode: 'auto', - tier_defaults: { - opus: 'high', - sonnet: 'high', - haiku: 'medium', - }, - provider_overrides: {}, - show_warnings: true, - }), - getCliproxySafetyConfig: () => ({ - antigravity_ack_bypass: false, - }), - saveUnifiedConfig: () => {}, -})); - -mock.module('../../../src/cliproxy/binary-manager', () => ({ - ensureCLIProxyBinary: async () => '/tmp/cliproxy', - isCLIProxyInstalled: () => true, - getCLIProxyPath: () => '/tmp/cliproxy', - checkCliproxyUpdate: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - backend: 'plus', - backendLabel: 'CLIProxy Plus', - isStable: true, - maxStableVersion: '9.9.999-0', - }), - getInstalledCliproxyVersion: () => '6.6.80', - installCliproxyVersion: async () => {}, - fetchLatestCliproxyVersion: async () => '6.6.89', -})); - -mock.module('../../../src/cliproxy/binary/version-checker', () => ({ - fetchAllVersions: async () => ({ - versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], - latestStable: '6.6.89', - latest: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - }), - isNewerVersion: (version: string, maxStable: string) => { - const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number); - const versionParts = normalize(version); - const maxStableParts = normalize(maxStable); - - for (let index = 0; index < 3; index += 1) { - const versionPart = versionParts[index] || 0; - const maxStablePart = maxStableParts[index] || 0; - - if (versionPart > maxStablePart) return true; - if (versionPart < maxStablePart) return false; - } - - return false; - }, - isVersionFaulty: (version: string) => - ['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes( - version - ), -})); - -mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({ - installDashboardCliproxyVersion: async () => { - installSpy.calls += 1; - return { - success: true, - restarted: true, - port: 8317, - message: 'installed', - }; - }, -})); - let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default; +let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; +let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; +let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir; +let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion; +let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache; + let server: Server; let baseUrl = ''; +let tempHome = ''; +let originalCcsHome: string | undefined; beforeAll(async () => { - cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes')) - .default; + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-install-route-')); + process.env.CCS_HOME = tempHome; + + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); + ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); + ({ writeInstalledVersion, writeVersionListCache } = await import( + '../../../src/cliproxy/binary/version-cache' + )); + + const ccsDir = path.join(tempHome, '.ccs'); + const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + setGlobalConfigDir(ccsDir); + + const config = createEmptyUnifiedConfig(); + config.cliproxy = { backend: 'plus' }; + saveUnifiedConfig(config); + + writeInstalledVersion(plusBinDir, '6.6.80'); + writeVersionListCache( + { + versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], + latestStable: '6.6.89', + latest: '6.6.89', + checkedAt: Date.now(), + }, + 'plus' + ); + + ({ default: cliproxyStatsRoutes } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + )); const app = express(); app.use(express.json()); @@ -118,18 +71,26 @@ beforeAll(async () => { if (!address || typeof address === 'string') { throw new Error('Unable to resolve test server port'); } - baseUrl = `http://127.0.0.1:${address.port}`; -}); -beforeEach(() => { - installSpy.calls = 0; + baseUrl = `http://127.0.0.1:${address.port}`; }); afterAll(async () => { if (server) { await new Promise((resolve) => server.close(() => resolve())); } - mock.restore(); + + setGlobalConfigDir(undefined); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } }); describe('cliproxy-stats-routes install contract', () => { @@ -145,7 +106,7 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' }); }); - it('returns faulty confirmation metadata without calling the installer', async () => { + it('returns faulty confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -165,10 +126,9 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(true); expect(body.isExperimental).toBe(false); expect(body.message).toContain('known bugs'); - expect(installSpy.calls).toBe(0); }); - it('returns experimental confirmation metadata without calling the installer', async () => { + it('returns experimental confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -188,6 +148,5 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(false); expect(body.isExperimental).toBe(true); expect(body.message).toContain('experimental'); - expect(installSpy.calls).toBe(0); }); }); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 1d3f165e..d8f3fa34 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -3,15 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let ccsDir = ''; let rawResponse: CliproxyUsageApiResponse | null = null; let fetchCalls = 0; -mock.module('../../../src/utils/config-manager', () => ({ - getCcsDir: () => ccsDir, -})); - mock.module('../../../src/cliproxy/stats-fetcher', () => ({ fetchCliproxyUsageRaw: async () => { fetchCalls++; @@ -69,12 +66,16 @@ afterAll(() => { describe('cliproxy usage syncer', () => { it('writes and loads snapshot data', async () => { - await syncer.syncCliproxyUsage(); + await runWithScopedConfigDir(ccsDir, async () => { + await syncer.syncCliproxyUsage(); + }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); - const cached = await syncer.loadCachedCliproxyData(); + const cached = await runWithScopedConfigDir(ccsDir, async () => { + return await syncer.loadCachedCliproxyData(); + }); expect(cached.daily).toHaveLength(1); expect(cached.daily[0].source).toBe('cliproxy'); expect(cached.daily[0].inputTokens).toBe(100); @@ -82,11 +83,13 @@ describe('cliproxy usage syncer', () => { expect(cached.monthly).toHaveLength(1); }); - it('startCliproxySync is idempotent and starts only one interval', () => { + it('startCliproxySync is idempotent and starts only one interval', async () => { const intervalSpy = spyOn(globalThis, 'setInterval'); - syncer.startCliproxySync(); - syncer.startCliproxySync(); + await runWithScopedConfigDir(ccsDir, async () => { + syncer.startCliproxySync(); + syncer.startCliproxySync(); + }); expect(intervalSpy).toHaveBeenCalledTimes(1); expect(fetchCalls).toBeGreaterThan(0);