diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 24e64d5c..81e0d677 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -57,7 +57,8 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const backendLabel = getBackendLabel(backend); - const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend); + const checkFn = config.checkForUpdatesFn ?? checkForUpdates; + const updateResult = await checkFn(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index b97add9f..7b1612e6 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -138,6 +138,18 @@ export interface ServiceStartResult { error?: string; } +/** + * Test-only seams for ensureCliproxyService. Production callers omit this and + * get the real implementations. Tests inject stubs to avoid bun's + * `mock.module()`, which is process-wide and leaks across test files. + */ +export interface EnsureCliproxyServiceDeps { + ensureBinaryFn?: typeof ensureCLIProxyBinary; + detectRunningProxyFn?: typeof detectRunningProxy; + configNeedsRegenerationFn?: typeof configNeedsRegeneration; + withStartupLockFn?: typeof withStartupLock; +} + /** * Ensure CLIProxy service is running * @@ -146,12 +158,18 @@ export interface ServiceStartResult { * * @param port CLIProxy port (default: 8317) * @param verbose Show debug output + * @param deps Test-only dependency overrides (see EnsureCliproxyServiceDeps) * @returns Result indicating success and whether it was already running */ export async function ensureCliproxyService( port: number = CLIPROXY_DEFAULT_PORT, - verbose: boolean = false + verbose: boolean = false, + deps: EnsureCliproxyServiceDeps = {} ): Promise { + const ensureBinaryFn = deps.ensureBinaryFn ?? ensureCLIProxyBinary; + const detectRunningProxyFn = deps.detectRunningProxyFn ?? detectRunningProxy; + const configNeedsRegenerationFn = deps.configNeedsRegenerationFn ?? configNeedsRegeneration; + const withStartupLockFn = deps.withStartupLockFn ?? withStartupLock; const log = (msg: string) => { if (verbose) { console.error(`[cliproxy-service] ${msg}`); @@ -160,17 +178,17 @@ export async function ensureCliproxyService( // Check if config needs update (even if running) let configRegenerated = false; - if (configNeedsRegeneration()) { + if (configNeedsRegenerationFn()) { log('Config outdated, regenerating...'); regenerateConfig(port); configRegenerated = true; } // Use startup lock to coordinate with other CCS processes (ccs agy, ccs config, etc.) - return await withStartupLock(async () => { + return await withStartupLockFn(async () => { // Use unified detection (HTTP check + session-lock + port-process) log(`Checking if CLIProxy is running on port ${port}...`); - const proxyStatus = await detectRunningProxy(port); + const proxyStatus = await detectRunningProxyFn(port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); if (proxyStatus.running && proxyStatus.verified) { @@ -216,7 +234,7 @@ export async function ensureCliproxyService( // 1. Ensure binary exists let binaryPath: string; try { - binaryPath = await ensureCLIProxyBinary(verbose, { + binaryPath = await ensureBinaryFn(verbose, { allowInstall: false, skipAutoUpdate: true, }); diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 2202e345..abf00136 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -4,6 +4,7 @@ */ import type { CompositeTierConfig } from '../config/unified-config-types'; +import type { UpdateCheckResult } from './binary/types'; /** * Supported operating systems @@ -56,6 +57,18 @@ export interface BinaryManagerConfig { allowInstall: boolean; /** Backend variant (original vs plus) */ backend?: CLIProxyBackend; + /** + * Test-only seam: override the auto-update check. When omitted, the real + * `checkForUpdates` from ./binary/version-checker is used. Provided so tests + * can verify "skipAutoUpdate respected" without resorting to bun's + * `mock.module()`, which leaks across test files in the same process. + */ + checkForUpdatesFn?: ( + binPath: string, + configVersion: string, + verbose?: boolean, + backend?: CLIProxyBackend + ) => Promise; } /** diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts index 5b85f9fc..5fb4818e 100644 --- a/tests/unit/cliproxy/service-manager-startup.test.ts +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -1,65 +1,26 @@ -import { describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; -const ensureBinaryCalls: Array = []; - -mock.module('../../../src/cliproxy/binary-manager', () => ({ - ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => { - ensureBinaryCalls.push(options); - throw new Error( - 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' - ); - }, -})); - -mock.module('../../../src/cliproxy/config-generator', () => ({ - ensureConfigDir: () => undefined, - generateConfig: () => '/tmp/cliproxy-config.yaml', - regenerateConfig: () => '/tmp/cliproxy-config.yaml', - configNeedsRegeneration: () => false, - CLIPROXY_DEFAULT_PORT: 8317, - getCliproxyWritablePath: () => '/tmp', -})); - -mock.module('../../../src/cliproxy/proxy-detector', () => ({ - detectRunningProxy: async () => ({ running: false, verified: false }), - waitForProxyHealthy: async () => false, -})); - -mock.module('../../../src/cliproxy/startup-lock', () => ({ - withStartupLock: async (fn: () => Promise) => await fn(), -})); - -mock.module('../../../src/cliproxy/session-tracker', () => ({ - registerSession: () => undefined, -})); - -mock.module('../../../src/cliproxy/stats-fetcher', () => ({ - isCliproxyRunning: async () => false, -})); - -mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({ - getTokenRefreshConfig: () => null, -})); - -mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({ - TokenRefreshWorker: class { - isActive(): boolean { - return false; - } - start(): void {} - stop(): void {} - }, -})); - -const { ensureCliproxyService } = await import( - `../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}` -); +import { ensureCliproxyService } from '../../../src/cliproxy/service-manager'; describe('ensureCliproxyService', () => { it('fails fast without attempting a runtime install when the local binary is missing', async () => { - ensureBinaryCalls.length = 0; + // Inject stubs via the deps seam instead of bun's `mock.module()`. The + // module-level mock approach is process-wide and was previously leaking + // stubbed binary-manager / stats-fetcher modules into unrelated test + // suites (notably cliproxy-stats-routes-*) that import them transitively. + const ensureBinaryCalls: Array = []; - const result = await ensureCliproxyService(8317, false); + const result = await ensureCliproxyService(8317, false, { + ensureBinaryFn: async (_verbose, options) => { + ensureBinaryCalls.push(options); + throw new Error( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }, + detectRunningProxyFn: async () => ({ running: false, verified: false }), + configNeedsRegenerationFn: () => false, + withStartupLockFn: async (fn) => await fn(), + }); expect(result).toEqual({ started: false, diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index c8829faa..fe87bdb1 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -91,33 +91,27 @@ describe('version-checker stale cache fallback', () => { it('skips update lookups when runtime startup prefers the installed binary', async () => { const { getExecutableName } = await import('../../../src/cliproxy/platform-detector'); + const { ensureBinary } = await import('../../../src/cliproxy/binary/lifecycle'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); fs.mkdirSync(plusBinDir, { recursive: true }); fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary'); + // Verify the contract via dependency injection rather than mock.module(). + // bun's mock.module() is process-wide and is NOT undone by mock.restore(), + // which previously leaked a stubbed version-checker into unrelated test + // files (cliproxy-stats-routes-*) that transitively import it. let checkForUpdatesCalls = 0; - - mock.module('../../../src/cliproxy/binary/version-checker', () => ({ - checkForUpdates: async () => { - checkForUpdatesCalls += 1; - return { - hasUpdate: false, - currentVersion: '6.8.2-0', - latestVersion: '6.8.2-0', - fromCache: false, - checkedAt: Date.now(), - }; - }, - fetchLatestVersion: async () => { - throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled'); - }, - isNewerVersion: () => false, - isVersionFaulty: () => false, - })); - - const { ensureBinary } = await import( - `../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}` - ); + const checkForUpdatesSpy = async () => { + checkForUpdatesCalls += 1; + return { + hasUpdate: false, + currentVersion: '6.8.2-0', + latestVersion: '6.8.2-0', + fromCache: false, + checkedAt: Date.now(), + }; + }; const binaryPath = await ensureBinary({ version: '6.8.2-0', @@ -129,6 +123,7 @@ describe('version-checker stale cache fallback', () => { skipAutoUpdate: true, allowInstall: true, backend: 'plus', + checkForUpdatesFn: checkForUpdatesSpy, }); expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus')));