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