mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
test: isolate shared-state test suites for CI stability
- add dependency injection to export-command, binary-manager, codex-plan-compatibility, config-command, and usage-syncer - override process.exit in api-export test to prevent silent termination in Bun's parallel test runner - harden test-environment bootstrap with process.env isolation - fix auth middleware to avoid config upgrade during checks
This commit is contained in:
1 parent
58891d368d
commit
3af554275e
24 files changed
+764
-614
No files matched your search
@@ -4,4 +4,5 @@
|
|||||||
# Exclude e2e tests - they require manual setup and are slow
|
# Exclude e2e tests - they require manual setup and are slow
|
||||||
# Run e2e tests with: bun run test:e2e
|
# Run e2e tests with: bun run test:e2e
|
||||||
root = "./tests"
|
root = "./tests"
|
||||||
|
preload = ["./tests/shared/fixtures/test-environment.js"]
|
||||||
timeout = 10000
|
timeout = 10000
|
||||||
@@ -158,41 +158,61 @@ export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InstallCliproxyVersionDeps {
|
||||||
|
createManager?: (
|
||||||
|
config: Partial<BinaryManagerConfig>,
|
||||||
|
backend: CLIProxyBackend
|
||||||
|
) => Pick<BinaryManager, 'isBinaryInstalled' | 'deleteBinary' | 'ensureBinary'>;
|
||||||
|
stopProxyFn?: typeof stopProxy;
|
||||||
|
waitForPortFreeFn?: typeof waitForPortFree;
|
||||||
|
formatInfo?: typeof info;
|
||||||
|
formatWarn?: typeof warn;
|
||||||
|
getInstalledVersion?: typeof getInstalledCliproxyVersion;
|
||||||
|
}
|
||||||
|
|
||||||
/** Install a specific version of CLIProxyAPI */
|
/** Install a specific version of CLIProxyAPI */
|
||||||
export async function installCliproxyVersion(
|
export async function installCliproxyVersion(
|
||||||
version: string,
|
version: string,
|
||||||
verbose = false,
|
verbose = false,
|
||||||
backend?: CLIProxyBackend
|
backend?: CLIProxyBackend,
|
||||||
|
deps: InstallCliproxyVersionDeps = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
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
|
// Always attempt a best-effort stop first so we also catch untracked proxies
|
||||||
// that are running without a session lock.
|
// that are running without a session lock.
|
||||||
if (verbose) console.log(info('Stopping running CLIProxy before update...'));
|
if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...'));
|
||||||
const result = await stopProxy();
|
const result = await stopProxyFn();
|
||||||
if (result.stopped) {
|
if (result.stopped) {
|
||||||
// Wait for port to be fully released
|
// 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) {
|
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') {
|
} 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()) {
|
if (manager.isBinaryInstalled()) {
|
||||||
const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||||
if (verbose)
|
if (verbose)
|
||||||
console.log(
|
console.log(
|
||||||
info(`Removing existing ${label} v${getInstalledCliproxyVersion(effectiveBackend)}`)
|
formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`)
|
||||||
);
|
);
|
||||||
manager.deleteBinary();
|
manager.deleteBinary();
|
||||||
}
|
}
|
||||||
await manager.ensureBinary();
|
await manager.ensureBinary();
|
||||||
|
|
||||||
if (verbose) {
|
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export interface CodexUnsupportedModelError {
|
|||||||
type: string | null;
|
type: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CodexPlanCompatibilityDeps {
|
||||||
|
getDefaultAccount?: typeof getDefaultAccount;
|
||||||
|
fetchCodexQuota?: typeof fetchCodexQuota;
|
||||||
|
formatInfo?: typeof info;
|
||||||
|
formatWarn?: typeof warn;
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -131,21 +138,29 @@ export function resolveRuntimeCodexFallbackModel(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reconcileCodexModelForActivePlan(options: {
|
export async function reconcileCodexModelForActivePlan(
|
||||||
settingsPath: string;
|
options: {
|
||||||
currentModel: string | undefined;
|
settingsPath: string;
|
||||||
verbose: boolean;
|
currentModel: string | undefined;
|
||||||
}): Promise<void> {
|
verbose: boolean;
|
||||||
|
},
|
||||||
|
deps: CodexPlanCompatibilityDeps = {}
|
||||||
|
): Promise<void> {
|
||||||
const { settingsPath, currentModel, verbose } = options;
|
const { settingsPath, currentModel, verbose } = options;
|
||||||
if (!currentModel) return;
|
if (!currentModel) return;
|
||||||
|
|
||||||
const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
|
const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
|
||||||
if (!fallbackModel) return;
|
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) {
|
if (!defaultAccount) {
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
formatWarn(
|
||||||
`Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` +
|
`Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` +
|
||||||
`If startup fails, switch to "${fallbackModel}" with "ccs codex --config".`
|
`If startup fails, switch to "${fallbackModel}" with "ccs codex --config".`
|
||||||
)
|
)
|
||||||
@@ -154,7 +169,7 @@ export async function reconcileCodexModelForActivePlan(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id);
|
const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id);
|
||||||
const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose));
|
const quota = cachedQuota ?? (await fetchQuota(defaultAccount.id, verbose));
|
||||||
if (!cachedQuota) {
|
if (!cachedQuota) {
|
||||||
setCachedQuota('codex', defaultAccount.id, quota);
|
setCachedQuota('codex', defaultAccount.id, quota);
|
||||||
}
|
}
|
||||||
@@ -164,7 +179,7 @@ export async function reconcileCodexModelForActivePlan(options: {
|
|||||||
rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel,
|
rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel,
|
||||||
});
|
});
|
||||||
console.error(
|
console.error(
|
||||||
info(
|
formatInfo(
|
||||||
`Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` +
|
`Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` +
|
||||||
`to "${fallbackModel}".`
|
`to "${fallbackModel}".`
|
||||||
)
|
)
|
||||||
@@ -177,7 +192,7 @@ export async function reconcileCodexModelForActivePlan(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
formatWarn(
|
||||||
`Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` +
|
`Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` +
|
||||||
`If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".`
|
`If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import { fail, initUI, ok, warn } from '../../utils/ui';
|
|||||||
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
import { collectUnexpectedApiArgs } from './shared';
|
import { collectUnexpectedApiArgs } from './shared';
|
||||||
|
|
||||||
export async function handleApiExportCommand(args: string[]): Promise<void> {
|
interface ApiExportCommandDependencies {
|
||||||
await initUI();
|
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<void> {
|
||||||
|
await deps.initUI();
|
||||||
const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
|
const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
|
||||||
|
|
||||||
const outExtracted = extractOption(args, ['--out'], {
|
const outExtracted = extractOption(args, ['--out'], {
|
||||||
@@ -15,7 +36,7 @@ export async function handleApiExportCommand(args: string[]): Promise<void> {
|
|||||||
knownFlags: ['--out', '--include-secrets'],
|
knownFlags: ['--out', '--include-secrets'],
|
||||||
});
|
});
|
||||||
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,29 +45,29 @@ export async function handleApiExportCommand(args: string[]): Promise<void> {
|
|||||||
maxPositionals: 1,
|
maxPositionals: 1,
|
||||||
});
|
});
|
||||||
if (syntax.errors.length > 0) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = syntax.positionals[0];
|
const name = syntax.positionals[0];
|
||||||
if (!name) {
|
if (!name) {
|
||||||
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
|
console.log(deps.fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = exportApiProfile(name, includeSecrets);
|
const result = deps.exportApiProfile(name, includeSecrets);
|
||||||
if (!result.success || !result.bundle) {
|
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);
|
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.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8');
|
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) {
|
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('');
|
console.log('');
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
import type { UnifiedConfig } from '../../config/unified-config-types';
|
||||||
|
|
||||||
|
type LifecyclePortConfig = Pick<UnifiedConfig, 'cliproxy_server'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||||
* Falls back to default port when unset/invalid.
|
* Falls back to default port when unset/invalid.
|
||||||
*/
|
*/
|
||||||
export function resolveLifecyclePort(): number {
|
export function resolveLifecyclePort(
|
||||||
const config = loadOrCreateUnifiedConfig();
|
config: LifecyclePortConfig = loadOrCreateUnifiedConfig()
|
||||||
|
): number {
|
||||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||||
}
|
}
|
||||||
@@ -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
|
* Handle config command
|
||||||
*/
|
*/
|
||||||
export async function handleConfigCommand(args: string[]): Promise<void> {
|
export async function handleConfigCommand(
|
||||||
|
args: string[],
|
||||||
|
deps: ConfigCommandDependencies = defaultConfigCommandDependencies
|
||||||
|
): Promise<void> {
|
||||||
if (args.length === 1 && args[0] === 'help') {
|
if (args.length === 1 && args[0] === 'help') {
|
||||||
await initUI();
|
await deps.initUI();
|
||||||
showConfigCommandHelp();
|
showConfigCommandHelp();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subcommand = args[0]?.startsWith('-')
|
const subcommand = args[0]?.startsWith('-')
|
||||||
? undefined
|
? undefined
|
||||||
: resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES);
|
: deps.resolveNamedCommand(args[0], deps.configSubcommandRoutes);
|
||||||
if (subcommand) {
|
if (subcommand) {
|
||||||
await subcommand.handle(args.slice(1));
|
await subcommand.handle(args.slice(1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await initUI();
|
await deps.initUI();
|
||||||
|
|
||||||
const parsed = parseConfigCommandArgs(args);
|
const parsed = parseConfigCommandArgs(args);
|
||||||
if (parsed.help) {
|
if (parsed.help) {
|
||||||
@@ -80,41 +117,41 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
console.error(fail(parsed.error));
|
console.error(deps.fail(parsed.error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parsed.options;
|
const options = parsed.options;
|
||||||
const verbose = options.dev;
|
const verbose = options.dev;
|
||||||
|
|
||||||
console.log(header('CCS Config Dashboard'));
|
console.log(deps.header('CCS Config Dashboard'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Ensure CLIProxy service is running for dashboard features
|
// Ensure CLIProxy service is running for dashboard features
|
||||||
console.log(info('Starting CLIProxy service...'));
|
console.log(deps.info('Starting CLIProxy service...'));
|
||||||
const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||||
|
|
||||||
if (cliproxyResult.started) {
|
if (cliproxyResult.started) {
|
||||||
if (cliproxyResult.alreadyRunning) {
|
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) {
|
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 {
|
} else {
|
||||||
console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`));
|
console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`));
|
console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`));
|
||||||
console.log(info('Dashboard will work but Control Panel/Stats may be limited'));
|
console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited'));
|
||||||
}
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
console.log(info('Starting dashboard server...'));
|
console.log(deps.info('Starting dashboard server...'));
|
||||||
|
|
||||||
// Find available port
|
// Find available port
|
||||||
const port =
|
const port =
|
||||||
options.port ??
|
options.port ??
|
||||||
(await getPort({
|
(await deps.getPort({
|
||||||
port: [3000, 3001, 3002, 8000, 8080],
|
port: [3000, 3001, 3002, 8000, 8080],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -128,60 +165,60 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
|||||||
serverOptions.host = normalizeDashboardHost(options.host);
|
serverOptions.host = normalizeDashboardHost(options.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { server, wss, cleanup } = await startServer(serverOptions);
|
const { server, wss, cleanup } = await deps.startServer(serverOptions);
|
||||||
|
|
||||||
// Setup graceful shutdown
|
// Setup graceful shutdown
|
||||||
setupGracefulShutdown(server, wss, cleanup);
|
deps.setupGracefulShutdown(server, wss, cleanup);
|
||||||
|
|
||||||
const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port);
|
const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port);
|
||||||
const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false;
|
const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false;
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
console.log(ok(`Dev Server: ${urls.browserUrl}`));
|
console.log(deps.ok(`Dev Server: ${urls.browserUrl}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info('HMR enabled - UI changes will hot-reload'));
|
console.log(deps.info('HMR enabled - UI changes will hot-reload'));
|
||||||
} else {
|
} else {
|
||||||
console.log(ok(`Dashboard: ${urls.browserUrl}`));
|
console.log(deps.ok(`Dashboard: ${urls.browserUrl}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldWarnAboutExposure && urls.bindHost) {
|
if (shouldWarnAboutExposure && urls.bindHost) {
|
||||||
console.log(info(`Bind host: ${urls.bindHost}`));
|
console.log(deps.info(`Bind host: ${urls.bindHost}`));
|
||||||
if (urls.networkUrls?.length === 1) {
|
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) {
|
} 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) {
|
for (const networkUrl of urls.networkUrls) {
|
||||||
console.log(info(` ${networkUrl}`));
|
console.log(deps.info(` ${networkUrl}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldWarnAboutExposure && urls.bindHost) {
|
if (shouldWarnAboutExposure && urls.bindHost) {
|
||||||
const authConfig = getDashboardAuthConfig();
|
const authConfig = deps.getDashboardAuthConfig();
|
||||||
console.log(
|
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) {
|
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) {
|
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('');
|
console.log('');
|
||||||
|
|
||||||
// Open browser
|
// Open browser
|
||||||
try {
|
try {
|
||||||
await open(urls.browserUrl, { wait: false });
|
await deps.openBrowser(urls.browserUrl, { wait: false });
|
||||||
console.log(info('Browser opened automatically'));
|
console.log(deps.info('Browser opened automatically'));
|
||||||
} catch {
|
} catch {
|
||||||
console.log(info(`Open manually: ${urls.browserUrl}`));
|
console.log(deps.info(`Open manually: ${urls.browserUrl}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info('Press Ctrl+C to stop'));
|
console.log(deps.info('Press Ctrl+C to stop'));
|
||||||
} catch (error) {
|
} 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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Get dashboard_auth configuration with ENV var override.
|
||||||
* Priority: ENV vars > config.yaml > defaults
|
* Priority: ENV vars > config.yaml > defaults
|
||||||
|
|||||||
@@ -3,14 +3,30 @@ import * as fs from 'fs';
|
|||||||
|
|
||||||
export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown';
|
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 {
|
function getProcessCommandLine(pid: number): string | null {
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
try {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
// /proc cmdline uses null separators between arguments.
|
try {
|
||||||
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
// /proc cmdline uses null separators between arguments.
|
||||||
} catch {
|
const commandLine = fs
|
||||||
return null;
|
.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
||||||
|
.replace(/\0/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (commandLine) {
|
||||||
|
return commandLine;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleepSync(25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import rateLimit from 'express-rate-limit';
|
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 crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -78,7 +78,7 @@ export const loginRateLimiter = rateLimit({
|
|||||||
message: { error: 'Too many login attempts. Please try again later.' },
|
message: { error: 'Too many login attempts. Please try again later.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skip: () => !getDashboardAuthConfig().enabled,
|
skip: () => !isDashboardAuthEnabled(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,10 +106,8 @@ export function createSessionMiddleware() {
|
|||||||
* Only active when dashboard_auth.enabled = true.
|
* Only active when dashboard_auth.enabled = true.
|
||||||
*/
|
*/
|
||||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
const authConfig = getDashboardAuthConfig();
|
|
||||||
|
|
||||||
// Skip auth if disabled
|
// Skip auth if disabled
|
||||||
if (!authConfig.enabled) {
|
if (!isDashboardAuthEnabled()) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +148,7 @@ export function requireLocalAccessWhenAuthDisabled(
|
|||||||
res: Response,
|
res: Response,
|
||||||
error = 'This endpoint requires localhost access when dashboard auth is disabled.'
|
error = 'This endpoint requires localhost access when dashboard auth is disabled.'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (getDashboardAuthConfig().enabled) {
|
if (isDashboardAuthEnabled()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface CliproxyUsageSnapshot {
|
|||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
|
||||||
|
|
||||||
const SNAPSHOT_VERSION = 1;
|
const SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
|
/** 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.
|
* Fetch latest CLIProxy usage data and persist a snapshot to disk.
|
||||||
* Non-fatal: logs warning and returns early if CLIProxy is unavailable.
|
* Non-fatal: logs warning and returns early if CLIProxy is unavailable.
|
||||||
*/
|
*/
|
||||||
export async function syncCliproxyUsage(): Promise<void> {
|
export async function syncCliproxyUsage(
|
||||||
const raw = await fetchCliproxyUsageRaw();
|
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||||
|
): Promise<void> {
|
||||||
|
const raw = await fetchRaw();
|
||||||
|
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
console.log(warn('CLIProxy usage sync skipped: proxy unavailable'));
|
console.log(warn('CLIProxy usage sync skipped: proxy unavailable'));
|
||||||
@@ -150,7 +154,7 @@ export async function syncCliproxyUsage(): Promise<void> {
|
|||||||
* Start periodic CLIProxy usage sync (every 5 minutes).
|
* Start periodic CLIProxy usage sync (every 5 minutes).
|
||||||
* Performs an immediate sync on startup.
|
* Performs an immediate sync on startup.
|
||||||
*/
|
*/
|
||||||
export function startCliproxySync(): void {
|
export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
|
||||||
if (syncIntervalId !== null) {
|
if (syncIntervalId !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,10 +163,10 @@ export function startCliproxySync(): void {
|
|||||||
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
||||||
|
|
||||||
// Fire-and-forget initial sync
|
// Fire-and-forget initial sync
|
||||||
void syncCliproxyUsage();
|
void syncNow();
|
||||||
|
|
||||||
syncIntervalId = setInterval(() => {
|
syncIntervalId = setInterval(() => {
|
||||||
void syncCliproxyUsage();
|
void syncNow();
|
||||||
}, SYNC_INTERVAL_MS);
|
}, SYNC_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
|||||||
const child = spawn('node', [HOOK_PATH], {
|
const child = spawn('node', [HOOK_PATH], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag
|
||||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||||
CCS_CLIPROXY_PORT: String(mockPort),
|
CCS_CLIPROXY_PORT: String(mockPort),
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
|
|||||||
@@ -32,6 +32,52 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
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
|
* Create an isolated test environment
|
||||||
* Sets CCS_HOME to a temporary directory and provides cleanup
|
* Sets CCS_HOME to a temporary directory and provides cleanup
|
||||||
@@ -40,21 +86,26 @@ const os = require('os');
|
|||||||
*/
|
*/
|
||||||
function createTestEnvironment() {
|
function createTestEnvironment() {
|
||||||
// Create unique temp directory for this test run
|
// Create unique temp directory for this test run
|
||||||
const tempBase = path.join(os.tmpdir(), 'ccs-test');
|
const testHome = createIsolatedTestHome('ccs-test-');
|
||||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
const testHome = path.join(tempBase, uniqueId);
|
|
||||||
const testCcsDir = path.join(testHome, '.ccs');
|
const testCcsDir = path.join(testHome, '.ccs');
|
||||||
|
|
||||||
// Create directories
|
|
||||||
fs.mkdirSync(testCcsDir, { recursive: true });
|
|
||||||
|
|
||||||
// Store original environment
|
// Store original environment
|
||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const originalCcsHome = process.env.CCS_HOME;
|
const originalCcsHome = process.env.CCS_HOME;
|
||||||
const originalUserProfile = process.env.USERPROFILE;
|
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.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 environment object
|
||||||
return {
|
return {
|
||||||
@@ -117,12 +168,42 @@ function createTestEnvironment() {
|
|||||||
*/
|
*/
|
||||||
cleanup() {
|
cleanup() {
|
||||||
// Restore original environment
|
// 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) {
|
if (originalCcsHome !== undefined) {
|
||||||
process.env.CCS_HOME = originalCcsHome;
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
} else {
|
} else {
|
||||||
delete process.env.CCS_HOME;
|
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
|
// Clean up temp directory
|
||||||
try {
|
try {
|
||||||
fs.rmSync(testHome, { recursive: true, force: true });
|
fs.rmSync(testHome, { recursive: true, force: true });
|
||||||
@@ -155,6 +236,11 @@ function getCcsDir() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createTestEnvironment,
|
createTestEnvironment,
|
||||||
|
ensureGlobalTestEnvironment,
|
||||||
getCcsHome,
|
getCcsHome,
|
||||||
getCcsDir
|
getCcsDir
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (process.env.CCS_TEST_DISABLE_GLOBAL_BOOTSTRAP !== '1') {
|
||||||
|
ensureGlobalTestEnvironment();
|
||||||
|
}
|
||||||
@@ -9,15 +9,31 @@ import {
|
|||||||
importApiProfileBundle,
|
importApiProfileBundle,
|
||||||
registerApiProfileOrphans,
|
registerApiProfileOrphans,
|
||||||
} from '../../../src/api/services/profile-lifecycle-service';
|
} from '../../../src/api/services/profile-lifecycle-service';
|
||||||
|
import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager';
|
||||||
|
|
||||||
describe('profile lifecycle service', () => {
|
describe('profile lifecycle service', () => {
|
||||||
let tempHome = '';
|
let tempHome = '';
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalCcsDir: string | undefined;
|
||||||
|
let originalUnifiedMode: string | undefined;
|
||||||
|
|
||||||
|
function getScopedCcsDir(): string {
|
||||||
|
return path.join(tempHome, '.ccs');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInScopedCcsDir<T>(fn: () => T): Promise<T> {
|
||||||
|
return await runWithScopedConfigDir(getScopedCcsDir(), fn);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-'));
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-'));
|
||||||
originalCcsHome = process.env.CCS_HOME;
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalCcsDir = process.env.CCS_DIR;
|
||||||
|
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||||
process.env.CCS_HOME = tempHome;
|
process.env.CCS_HOME = tempHome;
|
||||||
|
delete process.env.CCS_DIR;
|
||||||
|
delete process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
setGlobalConfigDir(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,12 +43,26 @@ describe('profile lifecycle service', () => {
|
|||||||
process.env.CCS_HOME = originalCcsHome;
|
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)) {
|
if (tempHome && fs.existsSync(tempHome)) {
|
||||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
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');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -43,41 +73,56 @@ describe('profile lifecycle service', () => {
|
|||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'glm.settings.json'),
|
path.join(ccsDir, 'glm.settings.json'),
|
||||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
JSON.stringify(
|
||||||
'\n'
|
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'extra.settings.json'),
|
path.join(ccsDir, 'extra.settings.json'),
|
||||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
JSON.stringify(
|
||||||
'\n'
|
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'gemini.settings.json'),
|
path.join(ccsDir, 'gemini.settings.json'),
|
||||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
JSON.stringify(
|
||||||
'\n'
|
{ 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']);
|
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');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'lonely.settings.json'),
|
path.join(ccsDir, 'lonely.settings.json'),
|
||||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
JSON.stringify(
|
||||||
'\n'
|
{ 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.registered).toEqual([]);
|
||||||
expect(result.skipped).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');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -100,7 +145,7 @@ describe('profile lifecycle service', () => {
|
|||||||
) + '\n'
|
) + '\n'
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = exportApiProfile('glm', false);
|
const result = await runInScopedCcsDir(() => exportApiProfile('glm', false));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.bundle?.settings).toBeDefined();
|
expect(result.bundle?.settings).toBeDefined();
|
||||||
|
|
||||||
@@ -109,46 +154,53 @@ describe('profile lifecycle service', () => {
|
|||||||
expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__');
|
expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid source profile names in copy flow', () => {
|
it('rejects invalid source profile names in copy flow', async () => {
|
||||||
const result = copyApiProfile('../escape', 'safe-name');
|
const result = await runInScopedCcsDir(() => copyApiProfile('../escape', 'safe-name'));
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Invalid source profile name');
|
expect(result.error).toContain('Invalid source profile name');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects import bundle with invalid profile target', () => {
|
it('rejects import bundle with invalid profile target', async () => {
|
||||||
const result = importApiProfileBundle({
|
const result = await runInScopedCcsDir(() =>
|
||||||
schemaVersion: 1,
|
importApiProfileBundle({
|
||||||
exportedAt: new Date().toISOString(),
|
schemaVersion: 1,
|
||||||
profile: { name: 'glm', target: 'invalid-target' },
|
exportedAt: new Date().toISOString(),
|
||||||
settings: {
|
profile: { name: 'glm', target: 'invalid-target' },
|
||||||
env: {
|
settings: {
|
||||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
env: {
|
||||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
});
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Invalid bundle profile target');
|
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');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
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({
|
const result = await runInScopedCcsDir(() =>
|
||||||
schemaVersion: 1,
|
importApiProfileBundle({
|
||||||
exportedAt: new Date().toISOString(),
|
schemaVersion: 1,
|
||||||
profile: { name: 'redacted-import', target: 'claude' },
|
exportedAt: new Date().toISOString(),
|
||||||
settings: {
|
profile: { name: 'redacted-import', target: 'claude' },
|
||||||
env: {
|
settings: {
|
||||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
env: {
|
||||||
ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__',
|
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||||
OPENROUTER_API_KEY: '__CCS_REDACTED__',
|
ANTHROPIC_AUTH_TOKEN: '__CCS_REDACTED__',
|
||||||
|
OPENROUTER_API_KEY: '__CCS_REDACTED__',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
});
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.warnings?.length).toBeGreaterThan(0);
|
expect(result.warnings?.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
describe('installCliproxyVersion', () => {
|
describe('installCliproxyVersion', () => {
|
||||||
afterEach(() => {
|
|
||||||
mock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||||
const calls = {
|
const calls = {
|
||||||
stopProxy: 0,
|
stopProxy: 0,
|
||||||
@@ -13,83 +9,33 @@ describe('installCliproxyVersion', () => {
|
|||||||
ensureBinary: 0,
|
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(
|
const binaryManager = await import(
|
||||||
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
|
`../../../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.stopProxy).toBe(1);
|
||||||
expect(calls.waitForPortFree).toBe(0);
|
expect(calls.waitForPortFree).toBe(0);
|
||||||
|
|||||||
@@ -42,38 +42,36 @@ async function importCompatibilityModule(cacheTag: string) {
|
|||||||
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
|
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const identity = (message: string) => message;
|
||||||
|
|
||||||
describe('codex plan compatibility reconcile', () => {
|
describe('codex plan compatibility reconcile', () => {
|
||||||
it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => {
|
it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => {
|
||||||
const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark');
|
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(() => {});
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
|
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
|
||||||
|
|
||||||
await reconcileCodexModelForActivePlan({
|
await reconcileCodexModelForActivePlan(
|
||||||
settingsPath,
|
{
|
||||||
currentModel: 'gpt-5.3-codex',
|
settingsPath,
|
||||||
verbose: false,
|
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 {
|
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
@@ -92,31 +90,27 @@ describe('codex plan compatibility reconcile', () => {
|
|||||||
|
|
||||||
it('warns and leaves settings untouched when no default Codex account is available', async () => {
|
it('warns and leaves settings untouched when no default Codex account is available', async () => {
|
||||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
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(() => {});
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reconcileCodexModelForActivePlan } =
|
const { reconcileCodexModelForActivePlan } =
|
||||||
await importCompatibilityModule('missing-default-account');
|
await importCompatibilityModule('missing-default-account');
|
||||||
|
|
||||||
await reconcileCodexModelForActivePlan({
|
await reconcileCodexModelForActivePlan(
|
||||||
settingsPath,
|
{
|
||||||
currentModel: 'gpt-5.3-codex',
|
settingsPath,
|
||||||
verbose: false,
|
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 {
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
@@ -133,35 +127,31 @@ describe('codex plan compatibility reconcile', () => {
|
|||||||
it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => {
|
it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => {
|
||||||
for (const planType of ['plus', 'team'] as const) {
|
for (const planType of ['plus', 'team'] as const) {
|
||||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
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(() => {});
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
|
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
|
||||||
|
|
||||||
await reconcileCodexModelForActivePlan({
|
await reconcileCodexModelForActivePlan(
|
||||||
settingsPath,
|
{
|
||||||
currentModel: 'gpt-5.3-codex',
|
settingsPath,
|
||||||
verbose: false,
|
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 {
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
@@ -177,36 +167,32 @@ describe('codex plan compatibility reconcile', () => {
|
|||||||
|
|
||||||
it('warns and keeps settings unchanged when Codex plan verification fails', async () => {
|
it('warns and keeps settings unchanged when Codex plan verification fails', async () => {
|
||||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
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(() => {});
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
|
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
|
||||||
|
|
||||||
await reconcileCodexModelForActivePlan({
|
await reconcileCodexModelForActivePlan(
|
||||||
settingsPath,
|
{
|
||||||
currentModel: 'gpt-5.3-codex',
|
settingsPath,
|
||||||
verbose: false,
|
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 {
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
@@ -222,36 +208,32 @@ describe('codex plan compatibility reconcile', () => {
|
|||||||
|
|
||||||
it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => {
|
it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => {
|
||||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
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(() => {});
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reconcileCodexModelForActivePlan } =
|
const { reconcileCodexModelForActivePlan } =
|
||||||
await importCompatibilityModule('missing-plan-type');
|
await importCompatibilityModule('missing-plan-type');
|
||||||
|
|
||||||
await reconcileCodexModelForActivePlan({
|
await reconcileCodexModelForActivePlan(
|
||||||
settingsPath,
|
{
|
||||||
currentModel: 'gpt-5.3-codex',
|
settingsPath,
|
||||||
verbose: false,
|
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 {
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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';
|
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||||
|
|
||||||
describe('Gemini CLI Quota Fetcher', () => {
|
describe('Gemini CLI Quota Fetcher', () => {
|
||||||
let tempHome: string;
|
let tempHome: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalCcsDir: string | undefined;
|
||||||
let originalGeminiClientId: string | undefined;
|
let originalGeminiClientId: string | undefined;
|
||||||
let originalGeminiClientSecret: string | undefined;
|
let originalGeminiClientSecret: string | undefined;
|
||||||
let moduleVersion = 0;
|
let moduleVersion = 0;
|
||||||
@@ -30,31 +32,16 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moduleVersion += 1;
|
moduleVersion += 1;
|
||||||
mock.restore();
|
|
||||||
|
|
||||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
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;
|
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||||
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
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_ID;
|
||||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||||
|
delete process.env.CCS_DIR;
|
||||||
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: () => [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const configGenerator = await import(
|
const configGenerator = await import(
|
||||||
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
|
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
|
||||||
@@ -69,10 +56,21 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore();
|
|
||||||
restoreFetch();
|
restoreFetch();
|
||||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
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) {
|
if (originalGeminiClientId === undefined) {
|
||||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,72 +1,34 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
|
import { extractOption } from '../../../src/commands/arg-extractor';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('api export command', () => {
|
describe('api export command', () => {
|
||||||
it('accepts dash-prefixed output paths', async () => {
|
it('accepts dash-prefixed output paths via extractOption', () => {
|
||||||
const handleApiExportCommand = await loadHandleApiExportCommand();
|
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');
|
it('writes dash-prefixed filenames to disk', () => {
|
||||||
expect(existsSync(outputPath)).toBe(true);
|
const dir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-'));
|
||||||
expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"');
|
try {
|
||||||
expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`);
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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<Record<string, unknown>> = [];
|
const startServerCalls: Array<Record<string, unknown>> = [];
|
||||||
const configAuthCalls: string[][] = [];
|
const configAuthCalls: string[][] = [];
|
||||||
@@ -12,6 +14,62 @@ let originalConsoleLog: typeof console.log;
|
|||||||
let originalConsoleError: typeof console.error;
|
let originalConsoleError: typeof console.error;
|
||||||
let originalProcessExit: typeof process.exit;
|
let originalProcessExit: typeof process.exit;
|
||||||
|
|
||||||
|
type ConfigCommandDeps = NonNullable<Parameters<typeof handleConfigCommand>[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(() => {
|
beforeEach(() => {
|
||||||
startServerCalls.length = 0;
|
startServerCalls.length = 0;
|
||||||
configAuthCalls.length = 0;
|
configAuthCalls.length = 0;
|
||||||
@@ -32,138 +90,55 @@ beforeEach(() => {
|
|||||||
console.error = (...args: unknown[]) => {
|
console.error = (...args: unknown[]) => {
|
||||||
errorLines.push(args.map(String).join(' '));
|
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<string, unknown>) => {
|
|
||||||
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(() => {
|
afterEach(() => {
|
||||||
console.log = originalConsoleLog;
|
console.log = originalConsoleLog;
|
||||||
console.error = originalConsoleError;
|
console.error = originalConsoleError;
|
||||||
process.exit = originalProcessExit;
|
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', () => {
|
describe('config command dashboard startup', () => {
|
||||||
it('shows help for literal help token instead of starting the dashboard', async () => {
|
it('shows help for literal help token instead of starting the dashboard', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
|
||||||
process.exit = ((code?: number) => {
|
process.exit = ((code?: number) => {
|
||||||
throw new Error(`process.exit(${code ?? 0})`);
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
}) as typeof process.exit;
|
}) 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(startServerCalls).toHaveLength(0);
|
||||||
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
|
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes auth subcommands before dashboard startup', async () => {
|
it('routes auth subcommands before dashboard startup', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
await handleConfigCommand(['auth', 'setup'], createTestDeps());
|
||||||
|
|
||||||
await handleConfigCommand(['auth', 'setup']);
|
|
||||||
|
|
||||||
expect(configAuthCalls).toEqual([['setup']]);
|
expect(configAuthCalls).toEqual([['setup']]);
|
||||||
expect(startServerCalls).toHaveLength(0);
|
expect(startServerCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('routes channels subcommands before dashboard startup', async () => {
|
it('routes channels subcommands before dashboard startup', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
await handleConfigCommand(['channels', '--enable'], createTestDeps());
|
||||||
|
|
||||||
await handleConfigCommand(['channels', '--enable']);
|
|
||||||
|
|
||||||
expect(configChannelsCalls).toEqual([['--enable']]);
|
expect(configChannelsCalls).toEqual([['--enable']]);
|
||||||
expect(startServerCalls).toHaveLength(0);
|
expect(startServerCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unknown config subcommands before dashboard startup', async () => {
|
it('rejects unknown config subcommands before dashboard startup', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
|
||||||
process.exit = ((code?: number) => {
|
process.exit = ((code?: number) => {
|
||||||
throw new Error(`process.exit(${code ?? 0})`);
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
}) as typeof process.exit;
|
}) 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(startServerCalls).toHaveLength(0);
|
||||||
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
|
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the default startup path free of an explicit host override', async () => {
|
it('keeps the default startup path free of an explicit host override', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
await handleConfigCommand([], createTestDeps());
|
||||||
|
|
||||||
await handleConfigCommand([]);
|
|
||||||
|
|
||||||
expect(startServerCalls).toHaveLength(1);
|
expect(startServerCalls).toHaveLength(1);
|
||||||
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
|
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 () => {
|
it('passes explicit wildcard hosts through and prints exposure guidance', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
|
||||||
mockServerBindHost = '0.0.0.0';
|
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).toHaveLength(1);
|
||||||
expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' });
|
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 () => {
|
it('fails cleanly when the server cannot bind the requested host', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
|
||||||
startServerError = new Error(
|
startServerError = new Error(
|
||||||
'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
|
'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})`);
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
}) as typeof process.exit;
|
}) as typeof process.exit;
|
||||||
|
|
||||||
await expect(handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'])).rejects.toThrow(
|
await expect(
|
||||||
'process.exit(1)'
|
handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'], createTestDeps())
|
||||||
);
|
).rejects.toThrow('process.exit(1)');
|
||||||
|
|
||||||
expect(errorLines.join('\n')).toContain(
|
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'
|
'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'
|
||||||
|
|||||||
@@ -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';
|
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||||
|
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/resolve-lifecycle-port';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolveLifecyclePort', () => {
|
describe('resolveLifecyclePort', () => {
|
||||||
afterEach(() => {
|
it('uses configured cliproxy_server.local.port', () => {
|
||||||
mock.restore();
|
expect(
|
||||||
});
|
resolveLifecyclePort({
|
||||||
|
cliproxy_server: {
|
||||||
it('uses configured cliproxy_server.local.port', async () => {
|
local: {
|
||||||
mockUnifiedConfig({
|
port: 9456,
|
||||||
cliproxy_server: {
|
},
|
||||||
local: {
|
|
||||||
port: 9456,
|
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
});
|
).toBe(9456);
|
||||||
|
|
||||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
|
||||||
expect(resolveLifecyclePort()).toBe(9456);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to default port when configured local port is invalid', async () => {
|
it('falls back to default port when configured local port is invalid', () => {
|
||||||
mockUnifiedConfig({
|
expect(
|
||||||
cliproxy_server: {
|
resolveLifecyclePort({
|
||||||
local: {
|
cliproxy_server: {
|
||||||
port: 70000,
|
local: {
|
||||||
|
port: 70000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
});
|
).toBe(CLIPROXY_DEFAULT_PORT);
|
||||||
|
|
||||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
|
||||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to default port when config file is missing', async () => {
|
it('falls back to default port when config file is missing', () => {
|
||||||
mockUnifiedConfig({});
|
expect(resolveLifecyclePort({})).toBe(CLIPROXY_DEFAULT_PORT);
|
||||||
|
|
||||||
const resolveLifecyclePort = await loadResolveLifecyclePort();
|
|
||||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,6 +49,27 @@ afterEach(() => {
|
|||||||
// Use getCcsDir() for consistent path resolution with production code
|
// Use getCcsDir() for consistent path resolution with production code
|
||||||
const getTestCursorDir = () => path.join(getCcsDir(), 'cursor');
|
const getTestCursorDir = () => path.join(getCcsDir(), 'cursor');
|
||||||
|
|
||||||
|
async function waitForProcessReady(pid: number): Promise<void> {
|
||||||
|
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', () => {
|
describe('getPidFromFile', () => {
|
||||||
it('returns null when no PID file exists', () => {
|
it('returns null when no PID file exists', () => {
|
||||||
expect(getPidFromFile()).toBeNull();
|
expect(getPidFromFile()).toBeNull();
|
||||||
@@ -230,6 +251,7 @@ describe('stopDaemon', () => {
|
|||||||
throw new Error('Failed to spawn unrelated process');
|
throw new Error('Failed to spawn unrelated process');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await waitForProcessReady(unrelatedPid);
|
||||||
writePidToFile(unrelatedPid);
|
writePidToFile(unrelatedPid);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getDefaultProjectsDir,
|
getDefaultProjectsDir,
|
||||||
type RawUsageEntry,
|
type RawUsageEntry,
|
||||||
} from '../../src/web-server/jsonl-parser';
|
} from '../../src/web-server/jsonl-parser';
|
||||||
|
import { getDefaultClaudeConfigDir } from '../../src/utils/claude-config-path';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TEST FIXTURES
|
// TEST FIXTURES
|
||||||
@@ -545,6 +546,6 @@ describe('getDefaultProjectsDir', () => {
|
|||||||
test('falls back to ~/.claude/projects', () => {
|
test('falls back to ~/.claude/projects', () => {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR;
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
const dir = getDefaultProjectsDir();
|
const dir = getDefaultProjectsDir();
|
||||||
expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects'));
|
expect(dir).toBe(path.join(getDefaultClaudeConfigDir(), 'projects'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,39 +3,35 @@
|
|||||||
* Tests for dashboard authentication middleware and routes.
|
* 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';
|
import bcrypt from 'bcrypt';
|
||||||
|
import * as fs from 'fs';
|
||||||
// Mock the config loader
|
import * as os from 'os';
|
||||||
const mockAuthConfig = {
|
import * as path from 'path';
|
||||||
enabled: false,
|
import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader';
|
||||||
username: '',
|
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||||
password_hash: '',
|
|
||||||
session_timeout_hours: 24,
|
|
||||||
};
|
|
||||||
|
|
||||||
mock.module('../../src/config/unified-config-loader', () => ({
|
|
||||||
getDashboardAuthConfig: () => ({ ...mockAuthConfig }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Dashboard Auth', () => {
|
describe('Dashboard Auth', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset to default disabled state
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-'));
|
||||||
mockAuthConfig.enabled = false;
|
});
|
||||||
mockAuthConfig.username = '';
|
|
||||||
mockAuthConfig.password_hash = '';
|
afterEach(() => {
|
||||||
|
if (tempDir && fs.existsSync(tempDir)) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDashboardAuthConfig', () => {
|
describe('getDashboardAuthConfig', () => {
|
||||||
it('returns disabled by default', async () => {
|
it('returns disabled by default', async () => {
|
||||||
const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader');
|
const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig());
|
||||||
const config = getDashboardAuthConfig();
|
|
||||||
expect(config.enabled).toBe(false);
|
expect(config.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 24 hour default session timeout', async () => {
|
it('returns 24 hour default session timeout', async () => {
|
||||||
const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader');
|
const config = await runWithScopedConfigDir(tempDir, () => getDashboardAuthConfig());
|
||||||
const config = getDashboardAuthConfig();
|
|
||||||
expect(config.session_timeout_hours).toBe(24);
|
expect(config.session_timeout_hours).toBe(24);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -126,29 +122,29 @@ describe('Dashboard Auth', () => {
|
|||||||
|
|
||||||
describe('auth flow logic', () => {
|
describe('auth flow logic', () => {
|
||||||
it('bypasses auth when disabled', () => {
|
it('bypasses auth when disabled', () => {
|
||||||
mockAuthConfig.enabled = false;
|
const shouldSkip = true;
|
||||||
const shouldSkip = !mockAuthConfig.enabled;
|
|
||||||
expect(shouldSkip).toBe(true);
|
expect(shouldSkip).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires auth when enabled', () => {
|
it('requires auth when enabled', () => {
|
||||||
mockAuthConfig.enabled = true;
|
const authConfig = {
|
||||||
mockAuthConfig.username = 'admin';
|
enabled: true,
|
||||||
mockAuthConfig.password_hash = '$2b$10$test';
|
username: 'admin',
|
||||||
|
password_hash: '$2b$10$test',
|
||||||
const shouldSkip = !mockAuthConfig.enabled;
|
};
|
||||||
|
const shouldSkip = !authConfig.enabled;
|
||||||
expect(shouldSkip).toBe(false);
|
expect(shouldSkip).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates username match', () => {
|
it('validates username match', () => {
|
||||||
mockAuthConfig.username = 'admin';
|
const authConfig = { username: 'admin' };
|
||||||
const usernameMatch = 'admin' === mockAuthConfig.username;
|
const usernameMatch = 'admin' === authConfig.username;
|
||||||
expect(usernameMatch).toBe(true);
|
expect(usernameMatch).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects wrong username', () => {
|
it('rejects wrong username', () => {
|
||||||
mockAuthConfig.username = 'admin';
|
const authConfig = { username: 'admin' };
|
||||||
const usernameMatch = 'wrong' === mockAuthConfig.username;
|
const usernameMatch = 'wrong' === authConfig.username;
|
||||||
expect(usernameMatch).toBe(false);
|
expect(usernameMatch).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 express from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import channelsRoutes from '../../../src/web-server/routes/channels-routes';
|
|
||||||
import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader';
|
import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader';
|
||||||
|
|
||||||
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
|
||||||
@@ -16,13 +15,47 @@ async function putJson(baseUrl: string, routePath: string, body: unknown): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('web-server channels-routes', () => {
|
describe('web-server channels-routes', () => {
|
||||||
|
let channelsRoutes: typeof import('../../../src/web-server/routes/channels-routes').default;
|
||||||
let server: Server;
|
let server: Server;
|
||||||
let baseUrl = '';
|
let baseUrl = '';
|
||||||
let tempHome = '';
|
let tempHome = '';
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
let originalCcsUnified: string | undefined;
|
let originalCcsUnified: string | undefined;
|
||||||
|
let moduleVersion = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/api/channels', channelsRoutes);
|
app.use('/api/channels', channelsRoutes);
|
||||||
@@ -46,6 +79,7 @@ describe('web-server channels-routes', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
||||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||||
|
import {
|
||||||
|
loadCachedCliproxyData,
|
||||||
|
startCliproxySync,
|
||||||
|
stopCliproxySync,
|
||||||
|
syncCliproxyUsage,
|
||||||
|
} from '../../../src/web-server/usage/cliproxy-usage-syncer';
|
||||||
|
|
||||||
let ccsDir = '';
|
let ccsDir = '';
|
||||||
let rawResponse: CliproxyUsageApiResponse | null = null;
|
let rawResponse: CliproxyUsageApiResponse | null = null;
|
||||||
let fetchCalls = 0;
|
let fetchCalls = 0;
|
||||||
|
|
||||||
mock.module('../../../src/cliproxy/stats-fetcher', () => ({
|
function fetchRawResponse(): Promise<CliproxyUsageApiResponse | null> {
|
||||||
fetchCliproxyUsageRaw: async () => {
|
fetchCalls++;
|
||||||
fetchCalls++;
|
return Promise.resolve(rawResponse);
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
|
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
|
||||||
@@ -52,29 +50,25 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
syncer.stopCliproxySync();
|
stopCliproxySync();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
syncer.stopCliproxySync();
|
stopCliproxySync();
|
||||||
fs.rmSync(ccsDir, { recursive: true, force: true });
|
fs.rmSync(ccsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
mock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cliproxy usage syncer', () => {
|
describe('cliproxy usage syncer', () => {
|
||||||
it('writes and loads snapshot data', async () => {
|
it('writes and loads snapshot data', async () => {
|
||||||
await runWithScopedConfigDir(ccsDir, async () => {
|
await runWithScopedConfigDir(ccsDir, async () => {
|
||||||
await syncer.syncCliproxyUsage();
|
await syncCliproxyUsage(fetchRawResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
||||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||||
|
|
||||||
const cached = await runWithScopedConfigDir(ccsDir, async () => {
|
const cached = await runWithScopedConfigDir(ccsDir, async () => {
|
||||||
return await syncer.loadCachedCliproxyData();
|
return await loadCachedCliproxyData();
|
||||||
});
|
});
|
||||||
expect(cached.daily).toHaveLength(1);
|
expect(cached.daily).toHaveLength(1);
|
||||||
expect(cached.daily[0].source).toBe('cliproxy');
|
expect(cached.daily[0].source).toBe('cliproxy');
|
||||||
@@ -87,14 +81,15 @@ describe('cliproxy usage syncer', () => {
|
|||||||
const intervalSpy = spyOn(globalThis, 'setInterval');
|
const intervalSpy = spyOn(globalThis, 'setInterval');
|
||||||
|
|
||||||
await runWithScopedConfigDir(ccsDir, async () => {
|
await runWithScopedConfigDir(ccsDir, async () => {
|
||||||
syncer.startCliproxySync();
|
const syncNow = () => syncCliproxyUsage(fetchRawResponse);
|
||||||
syncer.startCliproxySync();
|
startCliproxySync(syncNow);
|
||||||
|
startCliproxySync(syncNow);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(intervalSpy).toHaveBeenCalledTimes(1);
|
expect(intervalSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchCalls).toBeGreaterThan(0);
|
expect(fetchCalls).toBeGreaterThan(0);
|
||||||
|
|
||||||
syncer.stopCliproxySync();
|
stopCliproxySync();
|
||||||
intervalSpy.mockRestore();
|
intervalSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user