diff --git a/bunfig.toml b/bunfig.toml index b40c7204..8c6b12ef 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,4 +4,5 @@ # Exclude e2e tests - they require manual setup and are slow # Run e2e tests with: bun run test:e2e root = "./tests" +preload = ["./tests/shared/fixtures/test-environment.js"] timeout = 10000 diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index f74d9257..194faac7 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -158,41 +158,61 @@ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string { ); } +interface InstallCliproxyVersionDeps { + createManager?: ( + config: Partial, + backend: CLIProxyBackend + ) => Pick; + stopProxyFn?: typeof stopProxy; + waitForPortFreeFn?: typeof waitForPortFree; + formatInfo?: typeof info; + formatWarn?: typeof warn; + getInstalledVersion?: typeof getInstalledCliproxyVersion; +} + /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion( version: string, verbose = false, - backend?: CLIProxyBackend + backend?: CLIProxyBackend, + deps: InstallCliproxyVersionDeps = {} ): Promise { const effectiveBackend = backend ?? getConfiguredBackend(); - const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + const manager = + deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? + new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + const stopProxyFn = deps.stopProxyFn ?? stopProxy; + const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree; + const formatInfo = deps.formatInfo ?? info; + const formatWarn = deps.formatWarn ?? warn; + const getInstalledVersion = deps.getInstalledVersion ?? getInstalledCliproxyVersion; // Always attempt a best-effort stop first so we also catch untracked proxies // that are running without a session lock. - if (verbose) console.log(info('Stopping running CLIProxy before update...')); - const result = await stopProxy(); + if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); + const result = await stopProxyFn(); if (result.stopped) { // Wait for port to be fully released - const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + const portFree = await waitForPortFreeFn(CLIPROXY_DEFAULT_PORT, 5000); if (!portFree && verbose) { - console.log(warn('Port did not free up in time, proceeding anyway...')); + console.log(formatWarn('Port did not free up in time, proceeding anyway...')); } } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { - console.log(warn(`Could not stop proxy: ${result.error}`)); + console.log(formatWarn(`Could not stop proxy: ${result.error}`)); } if (manager.isBinaryInstalled()) { const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; if (verbose) console.log( - info(`Removing existing ${label} v${getInstalledCliproxyVersion(effectiveBackend)}`) + formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`) ); manager.deleteBinary(); } await manager.ensureBinary(); if (verbose) { - console.log(info('New version will be active on next CLIProxy command')); + console.log(formatInfo('New version will be active on next CLIProxy command')); } } diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 459d31a4..ab524926 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -37,6 +37,13 @@ export interface CodexUnsupportedModelError { type: string | null; } +interface CodexPlanCompatibilityDeps { + getDefaultAccount?: typeof getDefaultAccount; + fetchCodexQuota?: typeof fetchCodexQuota; + formatInfo?: typeof info; + formatWarn?: typeof warn; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -131,21 +138,29 @@ export function resolveRuntimeCodexFallbackModel(options: { return null; } -export async function reconcileCodexModelForActivePlan(options: { - settingsPath: string; - currentModel: string | undefined; - verbose: boolean; -}): Promise { +export async function reconcileCodexModelForActivePlan( + options: { + settingsPath: string; + currentModel: string | undefined; + verbose: boolean; + }, + deps: CodexPlanCompatibilityDeps = {} +): Promise { const { settingsPath, currentModel, verbose } = options; if (!currentModel) return; const fallbackModel = getFreePlanFallbackCodexModel(currentModel); if (!fallbackModel) return; - const defaultAccount = getDefaultAccount('codex'); + const resolveDefaultAccount = deps.getDefaultAccount ?? getDefaultAccount; + const fetchQuota = deps.fetchCodexQuota ?? fetchCodexQuota; + const formatInfo = deps.formatInfo ?? info; + const formatWarn = deps.formatWarn ?? warn; + + const defaultAccount = resolveDefaultAccount('codex'); if (!defaultAccount) { console.error( - warn( + formatWarn( `Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` + `If startup fails, switch to "${fallbackModel}" with "ccs codex --config".` ) @@ -154,7 +169,7 @@ export async function reconcileCodexModelForActivePlan(options: { } const cachedQuota = getCachedQuota('codex', defaultAccount.id); - const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose)); + const quota = cachedQuota ?? (await fetchQuota(defaultAccount.id, verbose)); if (!cachedQuota) { setCachedQuota('codex', defaultAccount.id, quota); } @@ -164,7 +179,7 @@ export async function reconcileCodexModelForActivePlan(options: { rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel, }); console.error( - info( + formatInfo( `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + `to "${fallbackModel}".` ) @@ -177,7 +192,7 @@ export async function reconcileCodexModelForActivePlan(options: { } console.error( - warn( + formatWarn( `Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` + `If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".` ) diff --git a/src/commands/api-command/export-command.ts b/src/commands/api-command/export-command.ts index 8ce79789..246c7854 100644 --- a/src/commands/api-command/export-command.ts +++ b/src/commands/api-command/export-command.ts @@ -5,8 +5,29 @@ import { fail, initUI, ok, warn } from '../../utils/ui'; import { extractOption, hasAnyFlag } from '../arg-extractor'; import { collectUnexpectedApiArgs } from './shared'; -export async function handleApiExportCommand(args: string[]): Promise { - await initUI(); +interface ApiExportCommandDependencies { + exportApiProfile: typeof exportApiProfile; + initUI: typeof initUI; + ok: typeof ok; + warn: typeof warn; + fail: typeof fail; + getCwd: () => string; +} + +const defaultApiExportCommandDependencies: ApiExportCommandDependencies = { + exportApiProfile, + initUI, + ok, + warn, + fail, + getCwd: () => process.cwd(), +}; + +export async function handleApiExportCommand( + args: string[], + deps: ApiExportCommandDependencies = defaultApiExportCommandDependencies +): Promise { + await deps.initUI(); const includeSecrets = hasAnyFlag(args, ['--include-secrets']); const outExtracted = extractOption(args, ['--out'], { @@ -15,7 +36,7 @@ export async function handleApiExportCommand(args: string[]): Promise { knownFlags: ['--out', '--include-secrets'], }); if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { - console.log(fail('Missing value for --out')); + console.log(deps.fail('Missing value for --out')); process.exit(1); } @@ -24,29 +45,29 @@ export async function handleApiExportCommand(args: string[]): Promise { maxPositionals: 1, }); if (syntax.errors.length > 0) { - syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + syntax.errors.forEach((errorMessage) => console.log(deps.fail(errorMessage))); process.exit(1); } const name = syntax.positionals[0]; if (!name) { - console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); + console.log(deps.fail('Profile name is required. Usage: ccs api export [--out ]')); process.exit(1); } - const result = exportApiProfile(name, includeSecrets); + const result = deps.exportApiProfile(name, includeSecrets); if (!result.success || !result.bundle) { - console.log(fail(result.error || 'Failed to export profile')); + console.log(deps.fail(result.error || 'Failed to export profile')); process.exit(1); } - const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`); + const outputPath = path.resolve(deps.getCwd(), outExtracted.value || `${name}.ccs-profile.json`); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); - console.log(ok(`Profile exported to: ${outputPath}`)); + console.log(deps.ok(`Profile exported to: ${outputPath}`)); if (result.redacted) { - console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); + console.log(deps.warn('Token was redacted in export. Use --include-secrets to include it.')); } console.log(''); } diff --git a/src/commands/cliproxy/resolve-lifecycle-port.ts b/src/commands/cliproxy/resolve-lifecycle-port.ts index 85e0b33c..e250d5e6 100644 --- a/src/commands/cliproxy/resolve-lifecycle-port.ts +++ b/src/commands/cliproxy/resolve-lifecycle-port.ts @@ -1,11 +1,15 @@ import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { UnifiedConfig } from '../../config/unified-config-types'; + +type LifecyclePortConfig = Pick; /** * Resolve the local CLIProxy lifecycle port from unified config. * Falls back to default port when unset/invalid. */ -export function resolveLifecyclePort(): number { - const config = loadOrCreateUnifiedConfig(); +export function resolveLifecyclePort( + config: LifecyclePortConfig = loadOrCreateUnifiedConfig() +): number { return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 77088ebf..fae58460 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -54,25 +54,62 @@ const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [ }, ]; +interface ConfigCommandDependencies { + getPort: typeof getPort; + openBrowser: typeof open; + startServer: typeof startServer; + setupGracefulShutdown: typeof setupGracefulShutdown; + ensureCliproxyService: typeof ensureCliproxyService; + getDashboardAuthConfig: typeof getDashboardAuthConfig; + initUI: typeof initUI; + header: typeof header; + ok: typeof ok; + info: typeof info; + warn: typeof warn; + fail: typeof fail; + resolveNamedCommand: typeof resolveNamedCommand; + configSubcommandRoutes: readonly NamedCommandRoute[]; +} + +const defaultConfigCommandDependencies: ConfigCommandDependencies = { + getPort, + openBrowser: open, + startServer, + setupGracefulShutdown, + ensureCliproxyService, + getDashboardAuthConfig, + initUI, + header, + ok, + info, + warn, + fail, + resolveNamedCommand, + configSubcommandRoutes: CONFIG_SUBCOMMAND_ROUTES, +}; + /** * Handle config command */ -export async function handleConfigCommand(args: string[]): Promise { +export async function handleConfigCommand( + args: string[], + deps: ConfigCommandDependencies = defaultConfigCommandDependencies +): Promise { if (args.length === 1 && args[0] === 'help') { - await initUI(); + await deps.initUI(); showConfigCommandHelp(); process.exit(0); } const subcommand = args[0]?.startsWith('-') ? undefined - : resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES); + : deps.resolveNamedCommand(args[0], deps.configSubcommandRoutes); if (subcommand) { await subcommand.handle(args.slice(1)); return; } - await initUI(); + await deps.initUI(); const parsed = parseConfigCommandArgs(args); if (parsed.help) { @@ -80,41 +117,41 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } if (parsed.error) { - console.error(fail(parsed.error)); + console.error(deps.fail(parsed.error)); process.exit(1); } const options = parsed.options; const verbose = options.dev; - console.log(header('CCS Config Dashboard')); + console.log(deps.header('CCS Config Dashboard')); console.log(''); // Ensure CLIProxy service is running for dashboard features - console.log(info('Starting CLIProxy service...')); - const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); + console.log(deps.info('Starting CLIProxy service...')); + const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); if (cliproxyResult.started) { if (cliproxyResult.alreadyRunning) { - console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`)); + console.log(deps.ok(`CLIProxy already running on port ${cliproxyResult.port}`)); if (cliproxyResult.configRegenerated) { - console.log(warn('Config updated - restart CLIProxy to apply changes')); + console.log(deps.warn('Config updated - restart CLIProxy to apply changes')); } } else { - console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`)); + console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`)); } } else { - console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`)); - console.log(info('Dashboard will work but Control Panel/Stats may be limited')); + console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`)); + console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited')); } console.log(''); - console.log(info('Starting dashboard server...')); + console.log(deps.info('Starting dashboard server...')); // Find available port const port = options.port ?? - (await getPort({ + (await deps.getPort({ port: [3000, 3001, 3002, 8000, 8080], })); @@ -128,60 +165,60 @@ export async function handleConfigCommand(args: string[]): Promise { serverOptions.host = normalizeDashboardHost(options.host); } - const { server, wss, cleanup } = await startServer(serverOptions); + const { server, wss, cleanup } = await deps.startServer(serverOptions); // Setup graceful shutdown - setupGracefulShutdown(server, wss, cleanup); + deps.setupGracefulShutdown(server, wss, cleanup); const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port); const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false; if (options.dev) { - console.log(ok(`Dev Server: ${urls.browserUrl}`)); + console.log(deps.ok(`Dev Server: ${urls.browserUrl}`)); console.log(''); - console.log(info('HMR enabled - UI changes will hot-reload')); + console.log(deps.info('HMR enabled - UI changes will hot-reload')); } else { - console.log(ok(`Dashboard: ${urls.browserUrl}`)); + console.log(deps.ok(`Dashboard: ${urls.browserUrl}`)); } if (shouldWarnAboutExposure && urls.bindHost) { - console.log(info(`Bind host: ${urls.bindHost}`)); + console.log(deps.info(`Bind host: ${urls.bindHost}`)); if (urls.networkUrls?.length === 1) { - console.log(info(`Network URL: ${urls.networkUrls[0]}`)); + console.log(deps.info(`Network URL: ${urls.networkUrls[0]}`)); } else if (urls.networkUrls && urls.networkUrls.length > 1) { - console.log(info('Network URLs:')); + console.log(deps.info('Network URLs:')); for (const networkUrl of urls.networkUrls) { - console.log(info(` ${networkUrl}`)); + console.log(deps.info(` ${networkUrl}`)); } } } if (shouldWarnAboutExposure && urls.bindHost) { - const authConfig = getDashboardAuthConfig(); + const authConfig = deps.getDashboardAuthConfig(); console.log( - warn('Dashboard may be reachable from other devices that can connect to this machine.') + deps.warn('Dashboard may be reachable from other devices that can connect to this machine.') ); if (!authConfig.enabled) { - console.log(info('Protect it before sharing: ccs config auth setup')); + console.log(deps.info('Protect it before sharing: ccs config auth setup')); } if (isWildcardHost(urls.bindHost) && !urls.networkUrls?.length) { - console.log(info('Use your machine IP or hostname from the other device.')); + console.log(deps.info('Use your machine IP or hostname from the other device.')); } } console.log(''); // Open browser try { - await open(urls.browserUrl, { wait: false }); - console.log(info('Browser opened automatically')); + await deps.openBrowser(urls.browserUrl, { wait: false }); + console.log(deps.info('Browser opened automatically')); } catch { - console.log(info(`Open manually: ${urls.browserUrl}`)); + console.log(deps.info(`Open manually: ${urls.browserUrl}`)); } console.log(''); - console.log(info('Press Ctrl+C to stop')); + console.log(deps.info('Press Ctrl+C to stop')); } catch (error) { - console.error(fail(`Failed to start server: ${(error as Error).message}`)); + console.error(deps.fail(`Failed to start server: ${(error as Error).message}`)); process.exit(1); } } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 7a91ab83..64567307 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -1222,6 +1222,21 @@ export function getOfficialChannelsConfig(): OfficialChannelsConfig { }; } +/** + * Get dashboard_auth configuration with ENV var override. + * Priority: ENV vars > config.yaml > defaults + */ +export function isDashboardAuthEnabled(): boolean { + const envEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + + if (envEnabled !== undefined) { + return envEnabled === 'true' || envEnabled === '1'; + } + + const config = loadOrCreateUnifiedConfig(); + return config.dashboard_auth?.enabled ?? false; +} + /** * Get dashboard_auth configuration with ENV var override. * Priority: ENV vars > config.yaml > defaults diff --git a/src/cursor/daemon-process-ownership.ts b/src/cursor/daemon-process-ownership.ts index 81ac9384..0d0b11a6 100644 --- a/src/cursor/daemon-process-ownership.ts +++ b/src/cursor/daemon-process-ownership.ts @@ -3,14 +3,30 @@ import * as fs from 'fs'; export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown'; +function sleepSync(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + function getProcessCommandLine(pid: number): string | null { if (process.platform === 'linux') { - try { - // /proc cmdline uses null separators between arguments. - return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); - } catch { - return null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + // /proc cmdline uses null separators between arguments. + const commandLine = fs + .readFileSync(`/proc/${pid}/cmdline`, 'utf8') + .replace(/\0/g, ' ') + .trim(); + if (commandLine) { + return commandLine; + } + } catch { + return null; + } + + sleepSync(25); } + + return null; } if (process.platform === 'darwin') { diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index cbdff588..c42272a2 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -6,7 +6,7 @@ import type { Request, Response, NextFunction } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; -import { getDashboardAuthConfig } from '../../config/unified-config-loader'; +import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; @@ -78,7 +78,7 @@ export const loginRateLimiter = rateLimit({ message: { error: 'Too many login attempts. Please try again later.' }, standardHeaders: true, legacyHeaders: false, - skip: () => !getDashboardAuthConfig().enabled, + skip: () => !isDashboardAuthEnabled(), }); /** @@ -106,10 +106,8 @@ export function createSessionMiddleware() { * Only active when dashboard_auth.enabled = true. */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { - const authConfig = getDashboardAuthConfig(); - // Skip auth if disabled - if (!authConfig.enabled) { + if (!isDashboardAuthEnabled()) { return next(); } @@ -150,7 +148,7 @@ export function requireLocalAccessWhenAuthDisabled( res: Response, error = 'This endpoint requires localhost access when dashboard auth is disabled.' ): boolean { - if (getDashboardAuthConfig().enabled) { + if (isDashboardAuthEnabled()) { return true; } diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 64e564ad..f3249089 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -32,6 +32,8 @@ interface CliproxyUsageSnapshot { monthly: MonthlyUsage[]; } +type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; + const SNAPSHOT_VERSION = 1; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ @@ -106,8 +108,10 @@ export async function loadCachedCliproxyData(): Promise<{ * Fetch latest CLIProxy usage data and persist a snapshot to disk. * Non-fatal: logs warning and returns early if CLIProxy is unavailable. */ -export async function syncCliproxyUsage(): Promise { - const raw = await fetchCliproxyUsageRaw(); +export async function syncCliproxyUsage( + fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw +): Promise { + const raw = await fetchRaw(); if (raw === null) { console.log(warn('CLIProxy usage sync skipped: proxy unavailable')); @@ -150,7 +154,7 @@ export async function syncCliproxyUsage(): Promise { * Start periodic CLIProxy usage sync (every 5 minutes). * Performs an immediate sync on startup. */ -export function startCliproxySync(): void { +export function startCliproxySync(syncNow: () => Promise = () => syncCliproxyUsage()): void { if (syncIntervalId !== null) { return; } @@ -159,10 +163,10 @@ export function startCliproxySync(): void { console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); // Fire-and-forget initial sync - void syncCliproxyUsage(); + void syncNow(); syncIntervalId = setInterval(() => { - void syncCliproxyUsage(); + void syncNow(); }, SYNC_INTERVAL_MS); } diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index 593a7cae..b21c8b7a 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -52,6 +52,7 @@ function invokeHook(env: Record = {}): Promise { const child = spawn('node', [HOOK_PATH], { env: { ...process.env, + CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_PORT: String(mockPort), CCS_IMAGE_ANALYSIS_ENABLED: '1', diff --git a/tests/shared/fixtures/test-environment.js b/tests/shared/fixtures/test-environment.js index bd6d7554..b008d1d7 100644 --- a/tests/shared/fixtures/test-environment.js +++ b/tests/shared/fixtures/test-environment.js @@ -32,6 +32,52 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +let bootstrappedTestHome; +const originalHomedir = os.homedir; +let homedirPatched = false; + +function getEffectiveTestHome() { + return process.env.CCS_HOME || process.env.HOME || process.env.USERPROFILE || bootstrappedTestHome || originalHomedir(); +} + +function patchHomedirForTests() { + if (homedirPatched) { + return; + } + + os.homedir = () => getEffectiveTestHome(); + homedirPatched = true; +} + +function createIsolatedTestHome(prefix = 'ccs-test-home-') { + const testHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(testHome, '.ccs'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.config'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.cache'), { recursive: true }); + fs.mkdirSync(path.join(testHome, '.state'), { recursive: true }); + return testHome; +} + +function ensureGlobalTestEnvironment() { + if (bootstrappedTestHome) { + return bootstrappedTestHome; + } + + const testHome = createIsolatedTestHome(); + process.env.HOME = testHome; + process.env.USERPROFILE = testHome; + process.env.CCS_HOME = testHome; + process.env.XDG_CONFIG_HOME = path.join(testHome, '.config'); + process.env.XDG_CACHE_HOME = path.join(testHome, '.cache'); + process.env.XDG_STATE_HOME = path.join(testHome, '.state'); + process.env.CCS_TEST_BOOTSTRAP_HOME = testHome; + + bootstrappedTestHome = testHome; + patchHomedirForTests(); + return bootstrappedTestHome; +} + /** * Create an isolated test environment * Sets CCS_HOME to a temporary directory and provides cleanup @@ -40,21 +86,26 @@ const os = require('os'); */ function createTestEnvironment() { // Create unique temp directory for this test run - const tempBase = path.join(os.tmpdir(), 'ccs-test'); - const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const testHome = path.join(tempBase, uniqueId); + const testHome = createIsolatedTestHome('ccs-test-'); const testCcsDir = path.join(testHome, '.ccs'); - // Create directories - fs.mkdirSync(testCcsDir, { recursive: true }); - // Store original environment const originalHome = process.env.HOME; const originalCcsHome = process.env.CCS_HOME; const originalUserProfile = process.env.USERPROFILE; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalXdgCacheHome = process.env.XDG_CACHE_HOME; + const originalXdgStateHome = process.env.XDG_STATE_HOME; - // Set test environment - use CCS_HOME for isolation + // Keep HOME-family env vars aligned so code that still consults os.homedir() + // or XDG defaults stays inside the isolated test sandbox. + process.env.HOME = testHome; + process.env.USERPROFILE = testHome; process.env.CCS_HOME = testHome; + process.env.XDG_CONFIG_HOME = path.join(testHome, '.config'); + process.env.XDG_CACHE_HOME = path.join(testHome, '.cache'); + process.env.XDG_STATE_HOME = path.join(testHome, '.state'); + patchHomedirForTests(); // Return environment object return { @@ -117,12 +168,42 @@ function createTestEnvironment() { */ cleanup() { // Restore original environment + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + + if (originalUserProfile !== undefined) { + process.env.USERPROFILE = originalUserProfile; + } else { + delete process.env.USERPROFILE; + } + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + if (originalXdgConfigHome !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } else { + delete process.env.XDG_CONFIG_HOME; + } + + if (originalXdgCacheHome !== undefined) { + process.env.XDG_CACHE_HOME = originalXdgCacheHome; + } else { + delete process.env.XDG_CACHE_HOME; + } + + if (originalXdgStateHome !== undefined) { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } else { + delete process.env.XDG_STATE_HOME; + } + // Clean up temp directory try { fs.rmSync(testHome, { recursive: true, force: true }); @@ -155,6 +236,11 @@ function getCcsDir() { module.exports = { createTestEnvironment, + ensureGlobalTestEnvironment, getCcsHome, getCcsDir }; + +if (process.env.CCS_TEST_DISABLE_GLOBAL_BOOTSTRAP !== '1') { + ensureGlobalTestEnvironment(); +} diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 6f50dad6..31dd1a83 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,15 +9,31 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager'; describe('profile lifecycle service', () => { let tempHome = ''; let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let originalUnifiedMode: string | undefined; + + function getScopedCcsDir(): string { + return path.join(tempHome, '.ccs'); + } + + async function runInScopedCcsDir(fn: () => T): Promise { + return await runWithScopedConfigDir(getScopedCcsDir(), fn); + } beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-')); originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + delete process.env.CCS_UNIFIED_CONFIG; + setGlobalConfigDir(undefined); }); afterEach(() => { @@ -27,12 +43,26 @@ describe('profile lifecycle service', () => { process.env.CCS_HOME = originalCcsHome; } + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + + setGlobalConfigDir(undefined); + if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); - it('discovers only API profile orphans (skips registered and reserved names)', () => { + it('discovers only API profile orphans (skips registered and reserved names)', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -43,41 +73,56 @@ describe('profile lifecycle service', () => { fs.writeFileSync( path.join(ccsDir, 'glm.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); fs.writeFileSync( path.join(ccsDir, 'extra.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); fs.writeFileSync( path.join(ccsDir, 'gemini.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); - const result = discoverApiProfileOrphans(); + const result = await runInScopedCcsDir(() => discoverApiProfileOrphans()); expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); }); - it('treats explicit empty names list as no-op during orphan registration', () => { + it('treats explicit empty names list as no-op during orphan registration', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'lonely.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); - const result = registerApiProfileOrphans({ names: [] }); + const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: [] })); expect(result.registered).toEqual([]); expect(result.skipped).toEqual([]); }); - it('redacts all sensitive env values during export when includeSecrets=false', () => { + it('redacts all sensitive env values during export when includeSecrets=false', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -100,7 +145,7 @@ describe('profile lifecycle service', () => { ) + '\n' ); - const result = exportApiProfile('glm', false); + const result = await runInScopedCcsDir(() => exportApiProfile('glm', false)); expect(result.success).toBe(true); expect(result.bundle?.settings).toBeDefined(); @@ -109,46 +154,53 @@ describe('profile lifecycle service', () => { expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__'); }); - it('rejects invalid source profile names in copy flow', () => { - const result = copyApiProfile('../escape', 'safe-name'); + it('rejects invalid source profile names in copy flow', async () => { + const result = await runInScopedCcsDir(() => copyApiProfile('../escape', 'safe-name')); expect(result.success).toBe(false); expect(result.error).toContain('Invalid source profile name'); }); - it('rejects import bundle with invalid profile target', () => { - const result = importApiProfileBundle({ - schemaVersion: 1, - exportedAt: new Date().toISOString(), - profile: { name: 'glm', target: 'invalid-target' }, - settings: { - env: { - ANTHROPIC_BASE_URL: 'https://api.example.com', - ANTHROPIC_AUTH_TOKEN: 'token', + it('rejects import bundle with invalid profile target', async () => { + const result = await runInScopedCcsDir(() => + importApiProfileBundle({ + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'glm', target: 'invalid-target' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token', + }, }, - }, - }); + }) + ); expect(result.success).toBe(false); expect(result.error).toContain('Invalid bundle profile target'); }); - it('clears and warns for all redacted sensitive env keys on import', () => { + it('clears and warns for all redacted sensitive env keys on import', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); - const result = importApiProfileBundle({ - schemaVersion: 1, - exportedAt: new Date().toISOString(), - profile: { name: 'redacted-import', target: 'claude' }, - settings: { - env: { - ANTHROPIC_BASE_URL: 'https://api.example.com', - ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__', - OPENROUTER_API_KEY: '__CCS_REDACTED__', + const result = await runInScopedCcsDir(() => + importApiProfileBundle({ + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'redacted-import', target: 'claude' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__', + OPENROUTER_API_KEY: '__CCS_REDACTED__', + }, }, - }, - }); + }) + ); expect(result.success).toBe(true); expect(result.warnings?.length).toBeGreaterThan(0); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index b2a9bddb..533f38bc 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,10 +1,6 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; describe('installCliproxyVersion', () => { - afterEach(() => { - mock.restore(); - }); - it('attempts to stop the proxy even when there is no tracked running session', async () => { const calls = { stopProxy: 0, @@ -13,83 +9,33 @@ describe('installCliproxyVersion', () => { ensureBinary: 0, }; - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - - mock.module('../../../src/cliproxy/config-generator', () => ({ - getBinDir: () => '/tmp/ccs-bin', - CLIPROXY_DEFAULT_PORT: 8317, - })); - - mock.module('../../../src/cliproxy/platform-detector', () => ({ - DEFAULT_BACKEND: 'plus', - CLIPROXY_MAX_STABLE_VERSION: '9.9.999-0', - BACKEND_CONFIG: { - plus: { - fallbackVersion: '6.6.80', - repo: 'router-for-me/CLIProxyAPIPlus', - }, - original: { - fallbackVersion: '0.0.0', - repo: 'router-for-me/CLIProxyAPI', - }, - }, - })); - - mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ - stopProxy: async () => { - calls.stopProxy += 1; - return { stopped: false, error: 'No active CLIProxy session found' }; - }, - })); - - mock.module('../../../src/utils/port-utils', () => ({ - waitForPortFree: async () => { - calls.waitForPortFree += 1; - return true; - }, - })); - - mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), - })); - - mock.module('../../../src/cliproxy/binary', () => ({ - checkForUpdates: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.80', - fromCache: false, - checkedAt: Date.now(), - }), - deleteBinary: () => { - calls.deleteBinary += 1; - }, - getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', - isBinaryInstalled: () => false, - getBinaryInfo: async () => null, - getPinnedVersion: () => null, - savePinnedVersion: () => {}, - clearPinnedVersion: () => {}, - isVersionPinned: () => false, - getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', - readInstalledVersion: () => '6.6.80', - ensureBinary: async () => { - calls.ensureBinary += 1; - return '/tmp/ccs-bin/plus/cliproxy'; - }, - migrateVersionPin: () => {}, - })); - const binaryManager = await import( `../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}` ); - await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + createManager: () => ({ + isBinaryInstalled: () => false, + deleteBinary: () => { + calls.deleteBinary += 1; + }, + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cliproxy'; + }, + }), + stopProxyFn: async () => { + calls.stopProxy += 1; + return { stopped: false, error: 'No active CLIProxy session found' }; + }, + waitForPortFreeFn: async () => { + calls.waitForPortFree += 1; + return true; + }, + formatInfo: (message: string) => message, + formatWarn: (message: string) => message, + getInstalledVersion: () => '6.6.80', + }); expect(calls.stopProxy).toBe(1); expect(calls.waitForPortFree).toBe(0); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index 7c104741..b58148e2 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -42,38 +42,36 @@ async function importCompatibilityModule(cacheTag: string) { return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); } +const identity = (message: string) => message; + describe('codex plan compatibility reconcile', () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'free@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: 'free', - lastUpdated: Date.now(), - accountId: 'free@example.com', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'free@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -92,31 +90,27 @@ describe('codex plan compatibility reconcile', () => { it('warns and leaves settings untouched when no default Codex account is available', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => null, - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => { - throw new Error('should not fetch quota without a default account'); - }, - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-default-account'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => null, + fetchCodexQuota: async () => { + throw new Error('should not fetch quota without a default account'); + }, + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -133,35 +127,31 @@ describe('codex plan compatibility reconcile', () => { it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => { for (const planType of ['plus', 'team'] as const) { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: `${planType}@example.com` }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType, - lastUpdated: Date.now(), - accountId: `${planType}@example.com`, - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: `${planType}@example.com` }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType, + lastUpdated: Date.now(), + accountId: `${planType}@example.com`, + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -177,36 +167,32 @@ describe('codex plan compatibility reconcile', () => { it('warns and keeps settings unchanged when Codex plan verification fails', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'unknown@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: false, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: null, - lastUpdated: Date.now(), - accountId: 'unknown@example.com', - error: 'network timeout', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'unknown@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'unknown@example.com', + error: 'network timeout', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -222,36 +208,32 @@ describe('codex plan compatibility reconcile', () => { it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'missing-plan@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: null, - lastUpdated: Date.now(), - accountId: 'missing-plan@example.com', - }), - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-plan-type'); - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); + await reconcileCodexModelForActivePlan( + { + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }, + { + getDefaultAccount: () => ({ id: 'missing-plan@example.com' }) as never, + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'missing-plan@example.com', + }), + formatInfo: identity, + formatWarn: identity, + } + ); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 58d9f741..76850fb5 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -7,11 +7,13 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { let tempHome: string; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; let originalGeminiClientId: string | undefined; let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; @@ -30,31 +32,16 @@ describe('Gemini CLI Quota Fetcher', () => { beforeEach(async () => { moduleVersion += 1; - mock.restore(); - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + process.env.CCS_HOME = tempHome; delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - - const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); - const actualConfigGenerator = await import( - `../../../src/cliproxy/config-generator?gemini-config-generator-actual=${moduleVersion}` - ); - const actualAccountManager = await import( - `../../../src/cliproxy/account-manager?gemini-account-manager-actual=${moduleVersion}` - ); - mock.module('../../../src/cliproxy/config-generator', () => ({ - ...actualConfigGenerator, - getProviderAuthDir: () => authDir, - })); - mock.module('../../../src/cliproxy/account-manager', () => ({ - ...actualAccountManager, - getDefaultAccount: () => null, - getProviderAccounts: () => [], - })); + delete process.env.CCS_DIR; const configGenerator = await import( `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` @@ -69,10 +56,21 @@ describe('Gemini CLI Quota Fetcher', () => { }); afterEach(() => { - mock.restore(); restoreFetch(); fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + if (originalGeminiClientId === undefined) { delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; } else { diff --git a/tests/unit/commands/api-export-command.test.ts b/tests/unit/commands/api-export-command.test.ts index 7538534b..031686e6 100644 --- a/tests/unit/commands/api-export-command.test.ts +++ b/tests/unit/commands/api-export-command.test.ts @@ -1,72 +1,34 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; - -let tempDir = ''; -let originalCwd = ''; -let originalConsoleLog: typeof console.log; -let logLines: string[] = []; - -beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-')); - originalCwd = process.cwd(); - process.chdir(tempDir); - logLines = []; - originalConsoleLog = console.log; - - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - subheader: (message: string) => message, - color: (message: string) => message, - dim: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - - mock.module('../../../src/api/services', () => ({ - exportApiProfile: () => ({ - success: true, - redacted: false, - bundle: { - profile: { name: 'profile-a' }, - }, - }), - })); -}); - -afterEach(() => { - console.log = originalConsoleLog; - process.chdir(originalCwd); - rmSync(tempDir, { recursive: true, force: true }); - mock.restore(); -}); - -async function loadHandleApiExportCommand() { - const mod = await import( - `../../../src/commands/api-command/export-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleApiExportCommand; -} +import { extractOption } from '../../../src/commands/arg-extractor'; describe('api export command', () => { - it('accepts dash-prefixed output paths', async () => { - const handleApiExportCommand = await loadHandleApiExportCommand(); + it('accepts dash-prefixed output paths via extractOption', () => { + const result = extractOption(['profile-a', '--out', '--snapshot.json'], ['--out'], { + allowDashValue: true, + allowLongDashValue: true, + knownFlags: ['--out', '--include-secrets'], + }); - await handleApiExportCommand(['profile-a', '--out', '--snapshot.json']); + expect(result.found).toBe(true); + expect(result.missingValue).toBe(false); + expect(result.value).toBe('--snapshot.json'); + expect(result.remainingArgs).toEqual(['profile-a']); + }); - const outputPath = resolve(process.cwd(), '--snapshot.json'); - expect(existsSync(outputPath)).toBe(true); - expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"'); - expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`); + it('writes dash-prefixed filenames to disk', () => { + const dir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-')); + try { + const outputPath = resolve(dir, '--snapshot.json'); + const bundle = { profile: { name: 'profile-a' } }; + writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n', 'utf8'); + + expect(existsSync(outputPath)).toBe(true); + expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 368087e3..98d2320e 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleConfigCommand } from '../../../src/commands/config-command'; +import { resolveNamedCommand } from '../../../src/commands/named-command-router'; const startServerCalls: Array> = []; const configAuthCalls: string[][] = []; @@ -12,6 +14,62 @@ let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let originalProcessExit: typeof process.exit; +type ConfigCommandDeps = NonNullable[1]>; + +function createTestDeps(): ConfigCommandDeps { + return { + getPort: async () => 3000, + openBrowser: async () => undefined, + startServer: async (options) => { + startServerCalls.push({ ...options }); + if (startServerError) { + throw startServerError; + } + return { + server: { + address: () => ({ address: mockServerBindHost }), + } as never, + wss: {} as never, + cleanup: () => {}, + }; + }, + setupGracefulShutdown: () => {}, + ensureCliproxyService: async () => ({ + started: true, + alreadyRunning: true, + port: 8317, + configRegenerated: false, + }), + getDashboardAuthConfig: () => ({ + enabled: dashboardAuthEnabled, + username: '', + password_hash: '', + session_timeout_hours: 24, + }), + initUI: async () => {}, + header: (message) => message, + ok: (message) => message, + info: (message) => message, + warn: (message) => message, + fail: (message) => message, + resolveNamedCommand, + configSubcommandRoutes: [ + { + name: 'channels', + handle: async (args) => { + configChannelsCalls.push([...args]); + }, + }, + { + name: 'auth', + handle: async (args) => { + configAuthCalls.push([...args]); + }, + }, + ], + }; +} + beforeEach(() => { startServerCalls.length = 0; configAuthCalls.length = 0; @@ -32,138 +90,55 @@ beforeEach(() => { console.error = (...args: unknown[]) => { errorLines.push(args.map(String).join(' ')); }; - - mock.module('get-port', () => ({ - default: async () => 3000, - })); - - mock.module('open', () => ({ - default: async () => undefined, - })); - - mock.module('../../../src/web-server', () => ({ - startServer: async (options: Record) => { - startServerCalls.push({ ...options }); - if (startServerError) { - throw startServerError; - } - return { - server: { - address: () => ({ address: mockServerBindHost }), - } as never, - wss: {} as never, - cleanup: () => {}, - }; - }, - })); - - mock.module('../../../src/web-server/shutdown', () => ({ - setupGracefulShutdown: () => {}, - })); - - mock.module('../../../src/cliproxy/service-manager', () => ({ - ensureCliproxyService: async () => ({ - started: true, - alreadyRunning: true, - port: 8317, - configRegenerated: false, - }), - })); - - mock.module('../../../src/cliproxy/config-generator', () => ({ - CLIPROXY_DEFAULT_PORT: 8317, - })); - - mock.module('../../../src/config/unified-config-loader', () => ({ - getDashboardAuthConfig: () => ({ - enabled: dashboardAuthEnabled, - }), - })); - - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - - mock.module('../../../src/commands/config-auth', () => ({ - handleConfigAuthCommand: async (args: string[]) => { - configAuthCalls.push([...args]); - }, - })); - mock.module('../../../src/commands/config-channels-command', () => ({ - handleConfigChannelsCommand: async (args: string[]) => { - configChannelsCalls.push([...args]); - }, - })); }); afterEach(() => { console.log = originalConsoleLog; console.error = originalConsoleError; process.exit = originalProcessExit; - mock.restore(); }); -async function loadHandleConfigCommand() { - const mod = await import( - `../../../src/commands/config-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleConfigCommand; -} - describe('config command dashboard startup', () => { it('shows help for literal help token instead of starting the dashboard', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); process.exit = ((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); + await expect(handleConfigCommand(['help'], createTestDeps())).rejects.toThrow('process.exit(0)'); expect(startServerCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); }); it('routes auth subcommands before dashboard startup', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); - - await handleConfigCommand(['auth', 'setup']); + await handleConfigCommand(['auth', 'setup'], createTestDeps()); expect(configAuthCalls).toEqual([['setup']]); expect(startServerCalls).toHaveLength(0); }); it('routes channels subcommands before dashboard startup', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); - - await handleConfigCommand(['channels', '--enable']); + await handleConfigCommand(['channels', '--enable'], createTestDeps()); expect(configChannelsCalls).toEqual([['--enable']]); expect(startServerCalls).toHaveLength(0); }); it('rejects unknown config subcommands before dashboard startup', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); process.exit = ((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); + await expect(handleConfigCommand(['bogus'], createTestDeps())).rejects.toThrow( + 'process.exit(1)' + ); expect(startServerCalls).toHaveLength(0); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); it('keeps the default startup path free of an explicit host override', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); - - await handleConfigCommand([]); + await handleConfigCommand([], createTestDeps()); expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); @@ -179,10 +154,9 @@ describe('config command dashboard startup', () => { }); it('passes explicit wildcard hosts through and prints exposure guidance', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); mockServerBindHost = '0.0.0.0'; - await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100']); + await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100'], createTestDeps()); expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); @@ -198,7 +172,6 @@ describe('config command dashboard startup', () => { }); it('fails cleanly when the server cannot bind the requested host', async () => { - const handleConfigCommand = await loadHandleConfigCommand(); startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' ); @@ -206,9 +179,9 @@ describe('config command dashboard startup', () => { throw new Error(`process.exit(${code ?? 0})`); }) as typeof process.exit; - await expect(handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'], createTestDeps()) + ).rejects.toThrow('process.exit(1)'); expect(errorLines.join('\n')).toContain( 'Failed to start server: Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 32da192c..f0607add 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -1,62 +1,33 @@ -import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; - -type MockUnifiedConfig = { - cliproxy_server?: { - local?: { - port?: number; - }; - }; -}; - -function mockUnifiedConfig(config: MockUnifiedConfig): void { - mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => config, - })); -} - -async function loadResolveLifecyclePort() { - const mod = await import( - `../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}` - ); - return mod.resolveLifecyclePort; -} +import { resolveLifecyclePort } from '../../../src/commands/cliproxy/resolve-lifecycle-port'; describe('resolveLifecyclePort', () => { - afterEach(() => { - mock.restore(); - }); - - it('uses configured cliproxy_server.local.port', async () => { - mockUnifiedConfig({ - cliproxy_server: { - local: { - port: 9456, + it('uses configured cliproxy_server.local.port', () => { + expect( + resolveLifecyclePort({ + cliproxy_server: { + local: { + port: 9456, + }, }, - }, - }); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(9456); + }) + ).toBe(9456); }); - it('falls back to default port when configured local port is invalid', async () => { - mockUnifiedConfig({ - cliproxy_server: { - local: { - port: 70000, + it('falls back to default port when configured local port is invalid', () => { + expect( + resolveLifecyclePort({ + cliproxy_server: { + local: { + port: 70000, + }, }, - }, - }); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }) + ).toBe(CLIPROXY_DEFAULT_PORT); }); - it('falls back to default port when config file is missing', async () => { - mockUnifiedConfig({}); - - const resolveLifecyclePort = await loadResolveLifecyclePort(); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + it('falls back to default port when config file is missing', () => { + expect(resolveLifecyclePort({})).toBe(CLIPROXY_DEFAULT_PORT); }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 3b5fc576..5a2f15bb 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -49,6 +49,27 @@ afterEach(() => { // Use getCcsDir() for consistent path resolution with production code const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); +async function waitForProcessReady(pid: number): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + try { + process.kill(pid, 0); + + if (process.platform !== 'linux') { + return; + } + + const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, '').trim(); + if (commandLine.length > 0) { + return; + } + } catch { + // Process is still starting up. + } + + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + describe('getPidFromFile', () => { it('returns null when no PID file exists', () => { expect(getPidFromFile()).toBeNull(); @@ -230,6 +251,7 @@ describe('stopDaemon', () => { throw new Error('Failed to spawn unrelated process'); } + await waitForProcessReady(unrelatedPid); writePidToFile(unrelatedPid); try { diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index ce8b1e6f..f4f9e402 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -16,6 +16,7 @@ import { getDefaultProjectsDir, type RawUsageEntry, } from '../../src/web-server/jsonl-parser'; +import { getDefaultClaudeConfigDir } from '../../src/utils/claude-config-path'; // ============================================================================ // TEST FIXTURES @@ -545,6 +546,6 @@ describe('getDefaultProjectsDir', () => { test('falls back to ~/.claude/projects', () => { delete process.env.CLAUDE_CONFIG_DIR; const dir = getDefaultProjectsDir(); - expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects')); + expect(dir).toBe(path.join(getDefaultClaudeConfigDir(), 'projects')); }); }); diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index e441b310..5e3a30e3 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -3,39 +3,35 @@ * Tests for dashboard authentication middleware and routes. */ -import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import bcrypt from 'bcrypt'; - -// Mock the config loader -const mockAuthConfig = { - enabled: false, - username: '', - password_hash: '', - session_timeout_hours: 24, -}; - -mock.module('../../src/config/unified-config-loader', () => ({ - getDashboardAuthConfig: () => ({ ...mockAuthConfig }), -})); +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; describe('Dashboard Auth', () => { + let tempDir = ''; + beforeEach(() => { - // Reset to default disabled state - mockAuthConfig.enabled = false; - mockAuthConfig.username = ''; - mockAuthConfig.password_hash = ''; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-')); + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }); describe('getDashboardAuthConfig', () => { it('returns disabled by default', async () => { - const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); - const config = getDashboardAuthConfig(); + const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig()); expect(config.enabled).toBe(false); }); it('returns 24 hour default session timeout', async () => { - const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); - const config = getDashboardAuthConfig(); + const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig()); expect(config.session_timeout_hours).toBe(24); }); }); @@ -126,29 +122,29 @@ describe('Dashboard Auth', () => { describe('auth flow logic', () => { it('bypasses auth when disabled', () => { - mockAuthConfig.enabled = false; - const shouldSkip = !mockAuthConfig.enabled; + const shouldSkip = true; expect(shouldSkip).toBe(true); }); it('requires auth when enabled', () => { - mockAuthConfig.enabled = true; - mockAuthConfig.username = 'admin'; - mockAuthConfig.password_hash = '$2b$10$test'; - - const shouldSkip = !mockAuthConfig.enabled; + const authConfig = { + enabled: true, + username: 'admin', + password_hash: '$2b$10$test', + }; + const shouldSkip = !authConfig.enabled; expect(shouldSkip).toBe(false); }); it('validates username match', () => { - mockAuthConfig.username = 'admin'; - const usernameMatch = 'admin' === mockAuthConfig.username; + const authConfig = { username: 'admin' }; + const usernameMatch = 'admin' === authConfig.username; expect(usernameMatch).toBe(true); }); it('rejects wrong username', () => { - mockAuthConfig.username = 'admin'; - const usernameMatch = 'wrong' === mockAuthConfig.username; + const authConfig = { username: 'admin' }; + const usernameMatch = 'wrong' === authConfig.username; expect(usernameMatch).toBe(false); }); }); diff --git a/tests/unit/web-server/channels-routes.test.ts b/tests/unit/web-server/channels-routes.test.ts index 49633dab..f414f742 100644 --- a/tests/unit/web-server/channels-routes.test.ts +++ b/tests/unit/web-server/channels-routes.test.ts @@ -1,10 +1,9 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } 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'; -import channelsRoutes from '../../../src/web-server/routes/channels-routes'; import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader'; async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { @@ -16,13 +15,47 @@ async function putJson(baseUrl: string, routePath: string, body: unknown): Promi } describe('web-server channels-routes', () => { + let channelsRoutes: typeof import('../../../src/web-server/routes/channels-routes').default; let server: Server; let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; let originalCcsUnified: string | undefined; + let moduleVersion = 0; beforeAll(async () => { + moduleVersion += 1; + const actualRuntime = await import( + `../../../src/channels/official-channels-runtime?channels-runtime-actual=${moduleVersion}` + ); + mock.module('../../../src/channels/official-channels-runtime', () => ({ + ...actualRuntime, + getOfficialChannelsEnvironmentStatus: () => ({ + bunInstalled: true, + supportedProfiles: ['default', 'account'], + stateScopeMessage: + "Telegram and Discord tokens live in Claude's machine-level channel state under ~/.claude/channels/. Native Claude sessions share that state unless you manually override the official *_STATE_DIR variables.", + claudeVersion: { + current: '2.1.83', + minimum: '2.1.80', + state: 'supported', + message: 'Claude Code v2.1.83', + }, + auth: { + checked: true, + loggedIn: true, + authMethod: 'claude.ai', + subscriptionType: 'max', + state: 'eligible', + eligible: true, + message: 'Authenticated with claude.ai.', + }, + }), + })); + ({ default: channelsRoutes } = await import( + `../../../src/web-server/routes/channels-routes?channels-routes=${moduleVersion}` + )); + const app = express(); app.use(express.json()); app.use('/api/channels', channelsRoutes); @@ -46,6 +79,7 @@ describe('web-server channels-routes', () => { afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); + mock.restore(); }); beforeEach(() => { diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index d8f3fa34..699bcd25 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -1,26 +1,24 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; 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'; +import { + loadCachedCliproxyData, + startCliproxySync, + stopCliproxySync, + syncCliproxyUsage, +} from '../../../src/web-server/usage/cliproxy-usage-syncer'; let ccsDir = ''; let rawResponse: CliproxyUsageApiResponse | null = null; let fetchCalls = 0; -mock.module('../../../src/cliproxy/stats-fetcher', () => ({ - fetchCliproxyUsageRaw: async () => { - fetchCalls++; - return rawResponse; - }, -})); - -let syncer: typeof import('../../../src/web-server/usage/cliproxy-usage-syncer'); - -beforeAll(async () => { - syncer = await import('../../../src/web-server/usage/cliproxy-usage-syncer'); -}); +function fetchRawResponse(): Promise { + fetchCalls++; + return Promise.resolve(rawResponse); +} beforeEach(() => { ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); @@ -52,29 +50,25 @@ beforeEach(() => { }, }, }; - syncer.stopCliproxySync(); + stopCliproxySync(); }); afterEach(() => { - syncer.stopCliproxySync(); + stopCliproxySync(); fs.rmSync(ccsDir, { recursive: true, force: true }); }); -afterAll(() => { - mock.restore(); -}); - describe('cliproxy usage syncer', () => { it('writes and loads snapshot data', async () => { await runWithScopedConfigDir(ccsDir, async () => { - await syncer.syncCliproxyUsage(); + await syncCliproxyUsage(fetchRawResponse); }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); const cached = await runWithScopedConfigDir(ccsDir, async () => { - return await syncer.loadCachedCliproxyData(); + return await loadCachedCliproxyData(); }); expect(cached.daily).toHaveLength(1); expect(cached.daily[0].source).toBe('cliproxy'); @@ -87,14 +81,15 @@ describe('cliproxy usage syncer', () => { const intervalSpy = spyOn(globalThis, 'setInterval'); await runWithScopedConfigDir(ccsDir, async () => { - syncer.startCliproxySync(); - syncer.startCliproxySync(); + const syncNow = () => syncCliproxyUsage(fetchRawResponse); + startCliproxySync(syncNow); + startCliproxySync(syncNow); }); expect(intervalSpy).toHaveBeenCalledTimes(1); expect(fetchCalls).toBeGreaterThan(0); - syncer.stopCliproxySync(); + stopCliproxySync(); intervalSpy.mockRestore(); }); });