mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 00:14:49 +00:00
refactor(cliproxy): flatten module structure and colocate tests (#1135)
- Split types.ts (331 LOC) into 4 concern-based files under types/: platform-types, binary-types, provider-types, config-types - Preserve backward compat via barrel re-export (types.ts → types/index) - Reorganize 53 root-level files into 8 subdirectories: accounts/, ai-providers/, auth/, binary/, config/, executor/, management/, proxy/, quota/, routing/, services/, sync/ - Reduce src/cliproxy/ root from 65 to 8 files (target: ≤10) - Colocate 87 unit tests from tests/unit/cliproxy/ into src/cliproxy/*/__tests__/ (13 colocated test directories) - Update import paths across 40+ consumer files - Add TDD backward-compat test for types split Refs #1135
This commit is contained in:
1 parent
561189efe6
commit
1c72b4b8d5
225 files changed
+935
-905
No files matched your search
@@ -1,444 +0,0 @@
|
||||
/**
|
||||
* Account Manager Discovery Tests
|
||||
*
|
||||
* Tests for discoverExistingAccounts() function including:
|
||||
* - Kiro/GHCP provider mapping
|
||||
* - Email extraction from filename
|
||||
* - Multiple accounts per provider
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('Account Manager - discoverExistingAccounts', () => {
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let accountManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for test
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
const ccsDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(ccsDir, 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
// Mock CCS_HOME to use temp directory
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testDir;
|
||||
|
||||
// Clear cache and reload - must clear all modular submodules too
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
// Clear new modular structure (accounts/ and config/ submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/token-file-ops')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/query')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/types')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config/path-resolver')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore CCS_HOME
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createAuthFile(filename, content) {
|
||||
const authDir = path.join(testDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.writeFileSync(path.join(authDir, filename), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function getAccountsFile() {
|
||||
const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
if (fs.existsSync(accountsPath)) {
|
||||
return JSON.parse(fs.readFileSync(accountsPath, 'utf-8'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provider Type Mapping Tests
|
||||
// =========================================================================
|
||||
describe('Provider Type Mapping', () => {
|
||||
it('discovers kiro accounts with type="kiro"', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
provider: 'Google',
|
||||
profile_arn: 'arn:aws:codewhisperer:us-east-1::profile/123',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts, 'accounts.json should be created');
|
||||
assert(accounts.providers.kiro, 'kiro provider should exist');
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
1,
|
||||
'Should have 1 kiro account'
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers kiro accounts with type="codewhisperer"', () => {
|
||||
createAuthFile('codewhisperer-user@example.com.json', {
|
||||
type: 'codewhisperer',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.kiro, 'codewhisperer type should map to kiro provider');
|
||||
});
|
||||
|
||||
it('discovers ghcp accounts with type="github-copilot"', () => {
|
||||
createAuthFile('ghcp-user@example.com.json', {
|
||||
type: 'github-copilot',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.ghcp, 'github-copilot type should map to ghcp provider');
|
||||
});
|
||||
|
||||
it('discovers ghcp accounts with type="copilot"', () => {
|
||||
createAuthFile('copilot-user@example.com.json', {
|
||||
type: 'copilot',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider');
|
||||
});
|
||||
|
||||
it('handles case-insensitive type values', () => {
|
||||
createAuthFile('kiro-test.json', {
|
||||
type: 'KIRO',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.kiro, 'KIRO (uppercase) should map to kiro provider');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email Extraction Tests
|
||||
// =========================================================================
|
||||
describe('Email Extraction from Filename', () => {
|
||||
it('extracts email from filename when data.email is empty', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
provider: 'Google',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'user@example.com', 'Should extract email from filename');
|
||||
});
|
||||
|
||||
it('uses data.email when available', () => {
|
||||
createAuthFile('kiro-test.json', {
|
||||
type: 'kiro',
|
||||
email: 'actual@email.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available');
|
||||
});
|
||||
|
||||
it('generates unique ID for kiro/ghcp when no email in file or filename', () => {
|
||||
createAuthFile('kiro-nomail.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
// For kiro/ghcp without email, generates unique ID like "kiro-1"
|
||||
assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email');
|
||||
});
|
||||
|
||||
it('handles dots in email local part', () => {
|
||||
createAuthFile('kiro-google-first.last@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(
|
||||
accountIds[0],
|
||||
'first.last@example.com',
|
||||
'Should handle dots in local part'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles plus addressing in email', () => {
|
||||
createAuthFile('kiro-google-user+tag@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'user+tag@example.com', 'Should handle plus addressing');
|
||||
});
|
||||
|
||||
it('handles subdomain email addresses', () => {
|
||||
createAuthFile('codex-user@mail.example.com.json', {
|
||||
type: 'codex',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.codex.accounts);
|
||||
assert.strictEqual(accountIds[0], 'user@mail.example.com', 'Should handle subdomains');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Multiple Accounts Tests
|
||||
// =========================================================================
|
||||
describe('Multiple Accounts', () => {
|
||||
it('discovers multiple kiro accounts', () => {
|
||||
createAuthFile('kiro-google-user1@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
createAuthFile('kiro-google-user2@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
2,
|
||||
'Should discover 2 kiro accounts'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overwrite existing accounts', () => {
|
||||
// Create first account
|
||||
createAuthFile('kiro-google-user1@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
// Add another file and run again
|
||||
createAuthFile('kiro-google-user2@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Reload module to clear in-memory cache (including all submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
2,
|
||||
'Should have 2 accounts without overwriting'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets first discovered account as default', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert.strictEqual(
|
||||
accounts.providers.kiro.default,
|
||||
'user@example.com',
|
||||
'First account should be default'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates sequential IDs for multiple kiro files without email', () => {
|
||||
// Create multiple kiro files without email in filename
|
||||
// Files like "kiro-nomail.json" don't match oauth pattern (need 2+ hyphens)
|
||||
createAuthFile('kiro-account1.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
createAuthFile('kiro-account2.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
|
||||
// Both should have kiro-N format since filenames don't match oauth pattern (1 hyphen only)
|
||||
assert(accountIds.includes('kiro-1'), 'Should have kiro-1');
|
||||
assert(accountIds.includes('kiro-2'), 'Should have kiro-2');
|
||||
});
|
||||
|
||||
it('skips to next ID when collision exists', () => {
|
||||
// Pre-create accounts.json with kiro-1 already registered
|
||||
const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
const existingRegistry = {
|
||||
version: 1,
|
||||
providers: {
|
||||
kiro: {
|
||||
default: 'kiro-1',
|
||||
accounts: {
|
||||
'kiro-1': {
|
||||
tokenFile: 'kiro-existing.json',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(accountsPath, JSON.stringify(existingRegistry));
|
||||
|
||||
// Create the existing token file (matches registry)
|
||||
createAuthFile('kiro-existing.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Create new file that will need auto-generated ID (single hyphen = no oauth pattern match)
|
||||
createAuthFile('kiro-newaccount.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Reload module to pick up pre-existing accounts.json (including all submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
|
||||
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
|
||||
assert(accountIds.includes('kiro-1'), 'Should keep existing kiro-1');
|
||||
assert(accountIds.includes('kiro-2'), 'New account should be kiro-2 (skipped kiro-1)');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
describe('Edge Cases', () => {
|
||||
it('skips files without type field', () => {
|
||||
createAuthFile('invalid.json', {
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
// Should either be null or have no providers
|
||||
if (accounts) {
|
||||
assert.strictEqual(Object.keys(accounts.providers).length, 0, 'Should have no providers');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips files with unknown type', () => {
|
||||
createAuthFile('unknown.json', {
|
||||
type: 'unknown-provider',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
if (accounts) {
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers).length,
|
||||
0,
|
||||
'Should skip unknown providers'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips non-JSON files', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '.ccs', 'cliproxy', 'auth', 'readme.txt'),
|
||||
'This is not JSON'
|
||||
);
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
// Should not throw, should skip the file
|
||||
if (accounts) {
|
||||
assert.strictEqual(Object.keys(accounts.providers).length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles malformed JSON gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '.ccs', 'cliproxy', 'auth', 'bad.json'),
|
||||
'{not valid json'
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => {
|
||||
accountManager.discoverExistingAccounts();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing auth directory', () => {
|
||||
// Remove auth directory
|
||||
fs.rmSync(path.join(testDir, '.ccs', 'cliproxy', 'auth'), { recursive: true });
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => {
|
||||
accountManager.discoverExistingAccounts();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
import { describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(homeDir, () => fn(homeDir));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegistryModule() {
|
||||
return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function captureConsoleError<T>(fn: () => Promise<T> | T): Promise<{ result: T; messages: string[] }> {
|
||||
const originalError = console.error;
|
||||
const messages: string[] = [];
|
||||
|
||||
console.error = ((...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(' '));
|
||||
}) as typeof console.error;
|
||||
|
||||
try {
|
||||
return {
|
||||
result: await fn(),
|
||||
messages,
|
||||
};
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
function listRegistryBackups(registryPath: string): string[] {
|
||||
return fs
|
||||
.readdirSync(path.dirname(registryPath))
|
||||
.filter((file) => /^accounts\.json\.corrupt-.*\.bak$/.test(file));
|
||||
}
|
||||
|
||||
describe('account registry integrity', () => {
|
||||
it('does not create accounts.json during no-op discovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { discoverExistingAccounts } = await loadRegistryModule();
|
||||
discoverExistingAccounts();
|
||||
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write accounts.json during provider account reads', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
expect(getProviderAccounts('kiro')).toEqual([]);
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes stale accounts before choosing the next default during registration', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'kiro-github-ABC123.json'),
|
||||
JSON.stringify({ type: 'kiro' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
providers: {
|
||||
kiro: {
|
||||
default: 'github-OLD999',
|
||||
accounts: {
|
||||
'github-OLD999': {
|
||||
nickname: 'old',
|
||||
tokenFile: 'kiro-github-OLD999.json',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
lastUsedAt: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
const account = registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
const registry = loadAccountsRegistry();
|
||||
const kiroAccounts = registry.providers.kiro;
|
||||
|
||||
expect(account.id).toBe('github-ABC123');
|
||||
expect(kiroAccounts?.default).toBe('github-ABC123');
|
||||
expect(kiroAccounts?.accounts['github-OLD999']).toBeUndefined();
|
||||
expect(Object.keys(kiroAccounts?.accounts ?? {})).toEqual(['github-ABC123']);
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers corrupted accounts.json from token-backed accounts and preserves a backup', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-user@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'user@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{not-valid-json', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const accounts = getProviderAccounts('codex');
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.id).toBe('user@example.com');
|
||||
expect(accounts[0]?.isDefault).toBe(true);
|
||||
expect(registry.providers.codex?.accounts['user@example.com']?.tokenFile).toBe(
|
||||
'codex-user@example.com.json'
|
||||
);
|
||||
|
||||
const backups = listRegistryBackups(registryPath);
|
||||
expect(backups).toHaveLength(1);
|
||||
expect(
|
||||
fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8')
|
||||
).toBe('{not-valid-json');
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers paused token-backed accounts when rebuilding a corrupted registry', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(pausedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pausedDir, 'codex-paused@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const accounts = getProviderAccounts('codex');
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.id).toBe('paused@example.com');
|
||||
expect(accounts[0]?.paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces skipped token files only during corruption recovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-valid@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'valid@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-invalid@example.com.json'),
|
||||
JSON.stringify({ type: 123, email: 'invalid@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const { result: accounts, messages } = await captureConsoleError(() =>
|
||||
getProviderAccounts('codex')
|
||||
);
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.id).toBe('valid@example.com');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain('Recovered corrupted account registry');
|
||||
expect(messages[0]).toContain('auth/codex-invalid@example.com.json');
|
||||
expect(messages[0]).toContain('invalid token type');
|
||||
});
|
||||
});
|
||||
|
||||
it('logs successful corruption recovery even when every token file is recoverable', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-clean@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'clean@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const { result: accounts, messages } = await captureConsoleError(() =>
|
||||
getProviderAccounts('codex')
|
||||
);
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain('Recovered corrupted account registry');
|
||||
expect(messages[0]).toContain('backup saved to');
|
||||
expect(messages[0]).not.toContain('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers active tokens over paused duplicates during corruption recovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
const pausedDir = path.join(cliproxyDir, 'auth-paused');
|
||||
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.mkdirSync(pausedDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-shared@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'active@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pausedDir, 'codex-shared@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const accounts = getProviderAccounts('codex');
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.id).toBe('active@example.com');
|
||||
expect(accounts[0]?.tokenFile).toBe('codex-shared@example.com.json');
|
||||
expect(accounts[0]?.paused).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('infers subdomain emails from token file names during corruption recovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-user@mail.example.com.json'),
|
||||
JSON.stringify({ type: 'codex' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
const accounts = getProviderAccounts('codex');
|
||||
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.id).toBe('user@mail.example.com');
|
||||
expect(accounts[0]?.email).toBe('user@mail.example.com');
|
||||
expect(accounts[0]?.tokenFile).toBe('codex-user@mail.example.com.json');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'codex-user@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'user@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(((file, data, options) => {
|
||||
if (String(file).includes('accounts.json.tmp.')) {
|
||||
throw new Error('simulated recovery write failure');
|
||||
}
|
||||
|
||||
return originalWriteFileSync(file, data, options as never);
|
||||
}) as typeof fs.writeFileSync);
|
||||
|
||||
try {
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
|
||||
expect(() => loadAccountsRegistry()).toThrow('simulated recovery write failure');
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
const backups = listRegistryBackups(registryPath);
|
||||
expect(backups).toHaveLength(1);
|
||||
expect(
|
||||
fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8')
|
||||
).toBe('{"providers":');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
expect(loadAccountsRegistry()).toEqual({
|
||||
version: 1,
|
||||
providers: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not repopulate paused tokens during normal startup discovery', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
fs.mkdirSync(pausedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pausedDir, 'codex-paused@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const { discoverExistingAccounts } = await loadRegistryModule();
|
||||
discoverExistingAccounts();
|
||||
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
|
||||
const { getProviderAccounts } = await loadAccountManager();
|
||||
expect(getProviderAccounts('codex')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers to an empty registry when no token files are available', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy');
|
||||
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
const { result: registry, messages } = await captureConsoleError(() => loadAccountsRegistry());
|
||||
|
||||
expect(registry).toEqual({
|
||||
version: 1,
|
||||
providers: {},
|
||||
});
|
||||
expect(fs.existsSync(registryPath)).toBe(true);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain('Recovered corrupted account registry');
|
||||
expect(messages[0]).toContain('backup saved to');
|
||||
expect(listRegistryBackups(registryPath)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`);
|
||||
}
|
||||
|
||||
function writeTokenFile(homeDir: string, tokenFile: string): void {
|
||||
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), '{}', 'utf8');
|
||||
}
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-'));
|
||||
try {
|
||||
return await runWithScopedCcsHome(testDir, () => fn(testDir));
|
||||
} finally {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerAccount optional nickname flow', () => {
|
||||
it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => {
|
||||
const account = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
});
|
||||
|
||||
expect(account.id).toBe('github-ABC123');
|
||||
expect(account.nickname).toBe('github-ABC123');
|
||||
});
|
||||
|
||||
it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => {
|
||||
const { first, second } = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-nomail.json');
|
||||
writeTokenFile(homeDir, 'kiro-second.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return {
|
||||
first: registerAccount('kiro', 'kiro-nomail.json'),
|
||||
second: registerAccount('kiro', 'kiro-second.json'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(first.id).toBe('kiro-1');
|
||||
expect(first.nickname).toBe('kiro-1');
|
||||
expect(second.id).toBe('kiro-2');
|
||||
expect(second.nickname).toBe('kiro-2');
|
||||
});
|
||||
|
||||
it('keeps user nicknames optional metadata separate from internal ids', async () => {
|
||||
const account = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'ghcp-amazon-XYZ789.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work');
|
||||
});
|
||||
|
||||
expect(account.id).toBe('amazon-XYZ789');
|
||||
expect(account.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('preserves an existing custom nickname when the same token file is re-registered', async () => {
|
||||
const reauthenticated = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work');
|
||||
return registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
});
|
||||
|
||||
expect(reauthenticated.id).toBe('github-ABC123');
|
||||
expect(reauthenticated.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('rejects nickname collisions against existing account ids and nicknames', async () => {
|
||||
await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-NEW123.json');
|
||||
const { registerAccount, renameAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal');
|
||||
|
||||
expect(() =>
|
||||
registerAccount('kiro', 'kiro-google-NEW123.json', undefined, 'github-ABC123')
|
||||
).toThrow(/already exists/i);
|
||||
expect(() => renameAccount('kiro', second.id, 'github-ABC123')).toThrow(/already used/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('avoids auto-generated ids that would collide with an existing nickname', async () => {
|
||||
const added = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-google-XYZ789.json');
|
||||
const { registerAccount } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789');
|
||||
return registerAccount('kiro', 'kiro-google-XYZ789.json');
|
||||
});
|
||||
|
||||
expect(added.id).toBe('kiro-1');
|
||||
expect(added.nickname).toBe('kiro-1');
|
||||
});
|
||||
|
||||
it('does not resolve ambiguous nickname prefixes to the first generated account', async () => {
|
||||
const match = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'kiro-github-ABC123.json');
|
||||
writeTokenFile(homeDir, 'kiro-github-DEF456.json');
|
||||
const { registerAccount, findAccountByQuery } = await loadAccountManager();
|
||||
registerAccount('kiro', 'kiro-github-ABC123.json');
|
||||
registerAccount('kiro', 'kiro-github-DEF456.json');
|
||||
return findAccountByQuery('kiro', 'github');
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('treats duplicate Codex emails as distinct accounts and rejects bare-email lookups', async () => {
|
||||
const result = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
|
||||
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
|
||||
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
|
||||
const team = registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const free = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
return {
|
||||
team,
|
||||
free,
|
||||
accounts: getProviderAccounts('codex'),
|
||||
ambiguousLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com'),
|
||||
teamLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com#04a0f049-team'),
|
||||
freeLookup: findAccountByQuery('codex', free.id),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.team.id).toBe('kaidu.kd@gmail.com');
|
||||
expect(result.free.id).toBe('kaidu.kd@gmail.com#free');
|
||||
expect(result.accounts.map((account) => account.id).sort()).toEqual([
|
||||
'kaidu.kd@gmail.com#04a0f049-team',
|
||||
'kaidu.kd@gmail.com#free',
|
||||
]);
|
||||
expect(result.ambiguousLookup).toBeNull();
|
||||
expect(result.teamLookup?.id).toBe('kaidu.kd@gmail.com#04a0f049-team');
|
||||
expect(result.freeLookup?.id).toBe('kaidu.kd@gmail.com#free');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,621 +0,0 @@
|
||||
/**
|
||||
* Account Safety Guards Unit Tests
|
||||
*
|
||||
* Tests ban detection, email masking, cross-provider duplicate detection,
|
||||
* enforcement lifecycle, and crash recovery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
isBanResponse,
|
||||
maskEmail,
|
||||
detectCrossProviderDuplicates,
|
||||
enforceProviderIsolation,
|
||||
cleanupStaleAutoPauses,
|
||||
restoreAutoPausedAccounts,
|
||||
checkNewAccountConflict,
|
||||
handleBanDetection,
|
||||
warnCrossProviderDuplicates,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
|
||||
// --- Test isolation: use temp CCS_HOME ---
|
||||
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs
|
||||
function ccsDir(): string {
|
||||
return path.join(tmpDir, '.ccs');
|
||||
}
|
||||
|
||||
// --- Helper: write accounts registry ---
|
||||
|
||||
function writeRegistry(providers: Record<string, unknown>): void {
|
||||
const registryDir = path.join(ccsDir(), 'cliproxy');
|
||||
fs.mkdirSync(registryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(registryDir, 'accounts.json'),
|
||||
JSON.stringify({ version: 1, providers }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helper: write auto-paused file ---
|
||||
|
||||
function writeAutoPaused(sessions: unknown[]): void {
|
||||
const dir = path.join(ccsDir(), 'cliproxy');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2));
|
||||
}
|
||||
|
||||
function readAutoPaused(): { sessions: unknown[] } {
|
||||
const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json');
|
||||
if (!fs.existsSync(filePath)) return { sessions: [] };
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
// --- Helper: write dummy token files ---
|
||||
|
||||
function writeTokenFile(filename: string, paused = false): void {
|
||||
const dir = paused
|
||||
? path.join(ccsDir(), 'cliproxy', 'auth-paused')
|
||||
: path.join(ccsDir(), 'cliproxy', 'auth');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' }));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// isBanResponse
|
||||
// ========================================
|
||||
|
||||
describe('isBanResponse', () => {
|
||||
it('should detect "disabled in this account"', () => {
|
||||
expect(isBanResponse('API access disabled in this account')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "violation of terms of service"', () => {
|
||||
expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "account has been suspended"', () => {
|
||||
expect(isBanResponse('This account has been suspended by Google')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal errors', () => {
|
||||
expect(isBanResponse('Rate limit exceeded')).toBe(false);
|
||||
expect(isBanResponse('Internal server error')).toBe(false);
|
||||
expect(isBanResponse('Network timeout')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isBanResponse('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// maskEmail
|
||||
// ========================================
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('should mask standard email', () => {
|
||||
expect(maskEmail('user@example.com')).toBe('use***@example.com');
|
||||
});
|
||||
|
||||
it('should handle short local part', () => {
|
||||
expect(maskEmail('ab@example.com')).toBe('ab***@example.com');
|
||||
});
|
||||
|
||||
it('should handle single char local part', () => {
|
||||
expect(maskEmail('a@example.com')).toBe('a***@example.com');
|
||||
});
|
||||
|
||||
it('should return input if no @ sign', () => {
|
||||
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
||||
});
|
||||
|
||||
it('should return input if empty string', () => {
|
||||
expect(maskEmail('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectCrossProviderDuplicates
|
||||
// ========================================
|
||||
|
||||
describe('detectCrossProviderDuplicates', () => {
|
||||
it('should return empty map when no duplicates', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect same email across providers', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(1);
|
||||
expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']);
|
||||
});
|
||||
|
||||
it('should skip paused accounts', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
paused: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should be case-insensitive on email', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'User@Gmail.com',
|
||||
accounts: {
|
||||
'User@Gmail.com': {
|
||||
email: 'User@Gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'agy-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dupes = detectCrossProviderDuplicates();
|
||||
expect(dupes.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// checkNewAccountConflict
|
||||
// ========================================
|
||||
|
||||
describe('checkNewAccountConflict', () => {
|
||||
it('should return null for non-Google provider', () => {
|
||||
const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no conflict', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'other@gmail.com',
|
||||
accounts: {
|
||||
'other@gmail.com': {
|
||||
email: 'other@gmail.com',
|
||||
tokenFile: 'gemini-other.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkNewAccountConflict('agy', 'new@gmail.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return conflicting providers', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkNewAccountConflict('agy', 'shared@gmail.com');
|
||||
expect(result).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should return null when email is undefined', () => {
|
||||
const result = checkNewAccountConflict('agy', undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// cleanupStaleAutoPauses
|
||||
// ========================================
|
||||
|
||||
describe('cleanupStaleAutoPauses', () => {
|
||||
it('should do nothing when no sessions', () => {
|
||||
// No auto-paused.json exists
|
||||
cleanupStaleAutoPauses();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should remove sessions with dead PIDs', () => {
|
||||
// Use PID 999999999 which is almost certainly dead
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: 999999999,
|
||||
pausedAt: new Date().toISOString(),
|
||||
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
// Write registry with the paused account so resumeAccount can find it
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'test@gmail.com',
|
||||
accounts: {
|
||||
'test@gmail.com': {
|
||||
email: 'test@gmail.com',
|
||||
tokenFile: 'agy-test.json',
|
||||
paused: true,
|
||||
pausedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('agy-test.json', true);
|
||||
|
||||
cleanupStaleAutoPauses();
|
||||
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should keep sessions with alive PIDs', () => {
|
||||
const alivePid = process.pid; // Current process is alive
|
||||
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: alivePid,
|
||||
pausedAt: new Date().toISOString(),
|
||||
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
cleanupStaleAutoPauses();
|
||||
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// enforceProviderIsolation
|
||||
// ========================================
|
||||
|
||||
describe('enforceProviderIsolation', () => {
|
||||
it('should return 0 for non-Google provider', () => {
|
||||
const result = enforceProviderIsolation('kiro' as never);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when no conflicting accounts', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user1.json');
|
||||
writeTokenFile('agy-user2.json');
|
||||
|
||||
const result = enforceProviderIsolation('gemini');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should pause conflicting accounts and record session', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-shared.json');
|
||||
writeTokenFile('agy-shared.json');
|
||||
|
||||
const result = enforceProviderIsolation('gemini');
|
||||
expect(result).toBe(1);
|
||||
|
||||
// Verify auto-paused.json was written
|
||||
const data = readAutoPaused();
|
||||
expect(data.sessions.length).toBe(1);
|
||||
expect(data.sessions[0].initiator).toBe('gemini');
|
||||
expect(data.sessions[0].pid).toBe(process.pid);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// restoreAutoPausedAccounts
|
||||
// ========================================
|
||||
|
||||
describe('restoreAutoPausedAccounts', () => {
|
||||
it('should do nothing when no session exists', () => {
|
||||
restoreAutoPausedAccounts('gemini');
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should skip accounts re-paused after enforcement', () => {
|
||||
const enforcementTime = '2024-01-01T00:00:00.000Z';
|
||||
const laterTime = '2024-01-01T01:00:00.000Z';
|
||||
|
||||
writeAutoPaused([
|
||||
{
|
||||
initiator: 'gemini',
|
||||
pid: process.pid,
|
||||
pausedAt: enforcementTime,
|
||||
accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }],
|
||||
},
|
||||
]);
|
||||
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'banned@gmail.com',
|
||||
accounts: {
|
||||
'banned@gmail.com': {
|
||||
email: 'banned@gmail.com',
|
||||
tokenFile: 'agy-banned.json',
|
||||
paused: true,
|
||||
pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban)
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('agy-banned.json', true);
|
||||
|
||||
restoreAutoPausedAccounts('gemini');
|
||||
|
||||
// Account should NOT be restored because it was re-paused later
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// handleBanDetection
|
||||
// ========================================
|
||||
|
||||
describe('handleBanDetection', () => {
|
||||
it('should pause account when ban error detected', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user.json');
|
||||
|
||||
const result = handleBanDetection(
|
||||
'gemini',
|
||||
'user@gmail.com',
|
||||
'API access disabled in this account'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify account was paused in registry
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-ban errors', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user@gmail.com',
|
||||
accounts: {
|
||||
'user@gmail.com': {
|
||||
email: 'user@gmail.com',
|
||||
tokenFile: 'gemini-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeTokenFile('gemini-user.json');
|
||||
|
||||
const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded');
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Verify account was NOT paused
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||
);
|
||||
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// warnCrossProviderDuplicates
|
||||
// ========================================
|
||||
|
||||
describe('warnCrossProviderDuplicates', () => {
|
||||
it('should return true when duplicates exist', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'gemini-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'shared@gmail.com',
|
||||
accounts: {
|
||||
'shared@gmail.com': {
|
||||
email: 'shared@gmail.com',
|
||||
tokenFile: 'agy-shared.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('gemini');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no duplicates', () => {
|
||||
writeRegistry({
|
||||
gemini: {
|
||||
default: 'user1@gmail.com',
|
||||
accounts: {
|
||||
'user1@gmail.com': {
|
||||
email: 'user1@gmail.com',
|
||||
tokenFile: 'gemini-user1.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
agy: {
|
||||
default: 'user2@gmail.com',
|
||||
accounts: {
|
||||
'user2@gmail.com': {
|
||||
email: 'user2@gmail.com',
|
||||
tokenFile: 'agy-user2.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('gemini');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-Google providers', () => {
|
||||
writeRegistry({
|
||||
kiro: {
|
||||
default: 'user@example.com',
|
||||
accounts: {
|
||||
'user@example.com': {
|
||||
email: 'user@example.com',
|
||||
tokenFile: 'kiro-user.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = warnCrossProviderDuplicates('kiro' as never);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
function getCliproxyConfigPath(homeDir: string): string {
|
||||
return path.join(homeDir, '.ccs', 'cliproxy', 'config.yaml');
|
||||
}
|
||||
|
||||
function writeCliproxyConfig(homeDir: string, value: Record<string, unknown>): void {
|
||||
const configPath = getCliproxyConfigPath(homeDir);
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, yaml.dump(value), 'utf8');
|
||||
}
|
||||
|
||||
function readCliproxyConfig(homeDir: string): Record<string, any> {
|
||||
return (
|
||||
(yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record<string, any>) || {}
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAiProviderService() {
|
||||
return import(`../../../src/cliproxy/ai-providers/service?stable-id=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('ai-provider service stable ids', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-ids-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
it('backfills and persists stable ids for api-key provider entries', async () => {
|
||||
const { listAiProviders } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [{ 'api-key': 'alpha' }, { 'api-key': 'beta' }],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const family = listed.families.find((entry) => entry.id === 'gemini-api-key');
|
||||
|
||||
expect(family).toBeDefined();
|
||||
expect(family?.entries).toHaveLength(2);
|
||||
expect(family?.entries[0]?.id).toBeTruthy();
|
||||
expect(family?.entries[1]?.id).toBeTruthy();
|
||||
expect(family?.entries[0]?.id).not.toBe(family?.entries[1]?.id);
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe(family?.entries[0]?.id);
|
||||
expect(persisted[1]?.id).toBe(family?.entries[1]?.id);
|
||||
});
|
||||
|
||||
it('updates and deletes api-key entries by stable id while preserving the id', async () => {
|
||||
const { updateAiProviderEntry, deleteAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [
|
||||
{ 'api-key': 'alpha', id: 'gemini-a' },
|
||||
{ 'api-key': 'beta', id: 'gemini-b' },
|
||||
],
|
||||
});
|
||||
|
||||
await updateAiProviderEntry('gemini-api-key', 'gemini-a', {
|
||||
apiKey: 'gamma',
|
||||
baseUrl: 'https://example.test/gemini',
|
||||
});
|
||||
|
||||
let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('gamma');
|
||||
expect(persisted[0]?.['base-url']).toBe('https://example.test/gemini');
|
||||
|
||||
await deleteAiProviderEntry('gemini-api-key', 'gemini-a');
|
||||
|
||||
persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted[0]?.id).toBe('gemini-b');
|
||||
});
|
||||
|
||||
it('keeps legacy numeric index updates working during the route transition', async () => {
|
||||
const { updateAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'gemini-api-key': [{ 'api-key': 'alpha', id: 'gemini-a' }],
|
||||
});
|
||||
|
||||
await updateAiProviderEntry('gemini-api-key', '0', {
|
||||
apiKey: 'legacy-index-update',
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('legacy-index-update');
|
||||
});
|
||||
|
||||
it('backfills and preserves stable ids for openai-compatible connectors', async () => {
|
||||
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'openai-compatibility': [
|
||||
{
|
||||
name: 'openrouter',
|
||||
'base-url': 'https://openrouter.ai/api/v1',
|
||||
'api-key-entries': [{ 'api-key': 'sk-openrouter' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const family = listed.families.find((entry) => entry.id === 'openai-compatibility');
|
||||
const connectorId = family?.entries[0]?.id;
|
||||
|
||||
expect(connectorId).toBeTruthy();
|
||||
|
||||
await updateAiProviderEntry('openai-compatibility', connectorId!, {
|
||||
name: 'openrouter',
|
||||
baseUrl: 'https://router.example/v1',
|
||||
preserveSecrets: true,
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['openai-compatibility'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe(connectorId);
|
||||
expect(persisted[0]?.['base-url']).toBe('https://router.example/v1');
|
||||
expect((persisted[0]?.['api-key-entries'] as Array<Record<string, unknown>>)[0]?.['api-key']).toBe(
|
||||
'sk-openrouter'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes plain openai-compatible model rules without aliases', async () => {
|
||||
const { listAiProviders } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'openai-compatibility': [
|
||||
{
|
||||
name: 'openrouter',
|
||||
'base-url': 'https://openrouter.ai/api/v1',
|
||||
'api-key-entries': [{ 'api-key': 'sk-openrouter' }],
|
||||
models: [{ name: 'gpt-4o-mini' }, null, { name: 42 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const family = listed.families.find((entry) => entry.id === 'openai-compatibility');
|
||||
|
||||
expect(family?.entries[0]?.models).toEqual([{ name: 'gpt-4o-mini', alias: '' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ANTIGRAVITY_ACK_PHRASE,
|
||||
ANTIGRAVITY_ACK_VERSION,
|
||||
hasAntigravityRiskAcceptanceFlag,
|
||||
isAntigravityResponsibilityBypassEnabled,
|
||||
validateAntigravityRiskAcknowledgement,
|
||||
} from '../../../src/cliproxy/antigravity-responsibility';
|
||||
|
||||
describe('antigravity-responsibility', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalAgyRiskEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-risk-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalAgyRiskEnv = process.env.CCS_ACCEPT_AGY_RISK;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_ACCEPT_AGY_RISK;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalAgyRiskEnv !== undefined) {
|
||||
process.env.CCS_ACCEPT_AGY_RISK = originalAgyRiskEnv;
|
||||
} else {
|
||||
delete process.env.CCS_ACCEPT_AGY_RISK;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a complete acknowledgement payload', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue509: true,
|
||||
understandsBanRisk: true,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
|
||||
});
|
||||
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts phrase with extra spacing and lowercase', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue509: true,
|
||||
understandsBanRisk: true,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: ' i accept risk ',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects payload when checklist steps are not fully completed', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue509: true,
|
||||
understandsBanRisk: false,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
|
||||
});
|
||||
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.error).toContain('checklist');
|
||||
});
|
||||
|
||||
it('rejects payload when version is outdated', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: 'older-version',
|
||||
reviewedIssue509: true,
|
||||
understandsBanRisk: true,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
|
||||
});
|
||||
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.error).toContain('version');
|
||||
});
|
||||
|
||||
it('rejects payload when phrase does not match', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue509: true,
|
||||
understandsBanRisk: true,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: 'I AGREE',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.error).toContain('phrase');
|
||||
});
|
||||
|
||||
it('detects explicit antigravity acceptance flags', () => {
|
||||
expect(hasAntigravityRiskAcceptanceFlag(['--accept-agr-risk'])).toBeTrue();
|
||||
expect(hasAntigravityRiskAcceptanceFlag(['--accept-antigravity-risk'])).toBeTrue();
|
||||
expect(hasAntigravityRiskAcceptanceFlag(['--auth'])).toBeFalse();
|
||||
});
|
||||
|
||||
it('enables bypass when CCS_ACCEPT_AGY_RISK is set', () => {
|
||||
process.env.CCS_ACCEPT_AGY_RISK = 'true';
|
||||
expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue();
|
||||
});
|
||||
|
||||
it('enables bypass when cliproxy safety setting is enabled', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
`version: 8
|
||||
cliproxy:
|
||||
safety:
|
||||
antigravity_ack_bypass: true
|
||||
`
|
||||
);
|
||||
|
||||
expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -1,425 +0,0 @@
|
||||
/**
|
||||
* Auth Token Manager Tests
|
||||
*
|
||||
* Comprehensive test suite for CLIProxy auth token management including:
|
||||
* - Token generation (cryptographic security)
|
||||
* - Token masking
|
||||
* - Pure function logic tests
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
|
||||
describe('Auth Token Manager', () => {
|
||||
// =========================================================================
|
||||
// Token Generation Tests (Pure Functions)
|
||||
// =========================================================================
|
||||
describe('generateSecureToken', () => {
|
||||
let generateSecureToken;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache and reload
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')];
|
||||
const authTokenManager = require('../../../dist/cliproxy/auth-token-manager');
|
||||
generateSecureToken = authTokenManager.generateSecureToken;
|
||||
});
|
||||
|
||||
it('generates token of correct length (base64url encoding)', () => {
|
||||
const token = generateSecureToken(32);
|
||||
// 32 bytes = 43 chars in base64url (without padding)
|
||||
assert.strictEqual(token.length, 43);
|
||||
});
|
||||
|
||||
it('generates unique tokens each call', () => {
|
||||
const tokens = new Set();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.add(generateSecureToken(32));
|
||||
}
|
||||
assert.strictEqual(tokens.size, 100, 'All 100 tokens should be unique');
|
||||
});
|
||||
|
||||
it('uses base64url encoding (no +/= characters)', () => {
|
||||
// Generate many tokens to ensure we hit all character ranges
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const token = generateSecureToken(32);
|
||||
assert(!token.includes('+'), 'Should not contain +');
|
||||
assert(!token.includes('/'), 'Should not contain /');
|
||||
assert(!token.includes('='), 'Should not contain padding =');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts custom length parameter', () => {
|
||||
const short = generateSecureToken(16);
|
||||
const long = generateSecureToken(64);
|
||||
|
||||
// 16 bytes = 22 chars, 64 bytes = 86 chars in base64url
|
||||
assert.strictEqual(short.length, 22);
|
||||
assert.strictEqual(long.length, 86);
|
||||
});
|
||||
|
||||
it('handles edge case: length = 0', () => {
|
||||
const empty = generateSecureToken(0);
|
||||
assert.strictEqual(empty.length, 0);
|
||||
});
|
||||
|
||||
it('handles edge case: very large length', () => {
|
||||
const large = generateSecureToken(256);
|
||||
assert.strictEqual(large.length, 342); // 256 bytes = 342 chars in base64url
|
||||
});
|
||||
|
||||
it('uses cryptographically secure random bytes', () => {
|
||||
// Verify the function uses crypto.randomBytes internally
|
||||
// by checking statistical properties of generated tokens
|
||||
const tokens = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(generateSecureToken(32));
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
const uniqueTokens = new Set(tokens);
|
||||
assert.strictEqual(uniqueTokens.size, 1000, 'All tokens should be unique');
|
||||
|
||||
// Check that first character has good distribution (not always same)
|
||||
const firstChars = new Set(tokens.map((t) => t[0]));
|
||||
assert(firstChars.size > 10, 'First character should have good distribution');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Token Masking Tests (Pure Function Simulation)
|
||||
// =========================================================================
|
||||
describe('maskToken (logic validation)', () => {
|
||||
// Simulates the maskToken function from tokens-command.ts and settings-routes.ts
|
||||
function maskToken(token) {
|
||||
if (token.length <= 8) return '****';
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
it('masks tokens showing first 4 and last 4 characters', () => {
|
||||
assert.strictEqual(maskToken('1234567890abcdef'), '1234...cdef');
|
||||
});
|
||||
|
||||
it('returns **** for tokens <= 8 characters', () => {
|
||||
assert.strictEqual(maskToken('12345678'), '****');
|
||||
assert.strictEqual(maskToken('1234567'), '****');
|
||||
assert.strictEqual(maskToken('abc'), '****');
|
||||
assert.strictEqual(maskToken(''), '****');
|
||||
});
|
||||
|
||||
it('handles exactly 9 character tokens', () => {
|
||||
assert.strictEqual(maskToken('123456789'), '1234...6789');
|
||||
});
|
||||
|
||||
it('handles long tokens (typical API keys)', () => {
|
||||
const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
|
||||
assert.strictEqual(maskToken(longToken), 'test...wxyz');
|
||||
});
|
||||
|
||||
it('preserves special characters', () => {
|
||||
assert.strictEqual(maskToken('!@#$abcd____wxyz'), '!@#$...wxyz');
|
||||
});
|
||||
|
||||
it('handles default CCS internal key', () => {
|
||||
const internalKey = 'ccs-internal-managed';
|
||||
assert.strictEqual(maskToken(internalKey), 'ccs-...aged');
|
||||
});
|
||||
|
||||
it('handles default CCS control panel secret', () => {
|
||||
const secret = 'ccs';
|
||||
assert.strictEqual(maskToken(secret), '****');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Inheritance Chain Logic Tests
|
||||
// =========================================================================
|
||||
describe('getEffectiveApiKey logic', () => {
|
||||
/**
|
||||
* Simulates the inheritance logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
// Priority 1: Per-variant override
|
||||
if (variantName) {
|
||||
const variant = config.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) {
|
||||
return variant.auth.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.api_key) {
|
||||
return config.cliproxy.auth.api_key;
|
||||
}
|
||||
|
||||
// Priority 3: Default constant
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('returns global auth when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('returns variant auth when set (highest priority)', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { api_key: 'variant-key' } },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'variant-key');
|
||||
});
|
||||
|
||||
it('falls back to global when variant has no auth', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
|
||||
it('falls back to default when variant and global missing', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { type: 'claude' },
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), DEFAULT_KEY);
|
||||
});
|
||||
|
||||
it('ignores variant name when variant does not exist', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'global-custom' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'non-existent', DEFAULT_KEY), 'global-custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveManagementSecret logic', () => {
|
||||
/**
|
||||
* Simulates the management secret logic from auth-token-manager.ts
|
||||
*/
|
||||
function getEffectiveManagementSecret(config, defaultSecret) {
|
||||
// Priority 1: Global cliproxy.auth
|
||||
if (config.cliproxy?.auth?.management_secret) {
|
||||
return config.cliproxy.auth.management_secret;
|
||||
}
|
||||
|
||||
// Priority 2: Default constant
|
||||
return defaultSecret;
|
||||
}
|
||||
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns default when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), DEFAULT_SECRET);
|
||||
});
|
||||
|
||||
it('returns custom secret when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'custom-secret');
|
||||
});
|
||||
|
||||
it('is global only (no per-variant override)', () => {
|
||||
// Management secret does not support per-variant override
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {
|
||||
gemini: { auth: { management_secret: 'should-be-ignored' } },
|
||||
},
|
||||
auth: { management_secret: 'global-secret' },
|
||||
},
|
||||
};
|
||||
// The function only checks global auth
|
||||
assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'global-secret');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Auth Summary Logic Tests
|
||||
// =========================================================================
|
||||
describe('getAuthSummary logic', () => {
|
||||
function getAuthSummary(config, defaultApiKey, defaultSecret) {
|
||||
const customApiKey = config.cliproxy?.auth?.api_key;
|
||||
const customSecret = config.cliproxy?.auth?.management_secret;
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
value: customApiKey || defaultApiKey,
|
||||
isCustom: !!customApiKey,
|
||||
},
|
||||
managementSecret: {
|
||||
value: customSecret || defaultSecret,
|
||||
isCustom: !!customSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_KEY = 'ccs-internal-managed';
|
||||
const DEFAULT_SECRET = 'ccs';
|
||||
|
||||
it('returns defaults with isCustom=false when no custom config', () => {
|
||||
const config = { cliproxy: { variants: {} } };
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
assert.strictEqual(summary.managementSecret.value, DEFAULT_SECRET);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('returns custom values with isCustom=true when set', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key', management_secret: 'custom-secret' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.value, 'custom-key');
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.value, 'custom-secret');
|
||||
assert.strictEqual(summary.managementSecret.isCustom, true);
|
||||
});
|
||||
|
||||
it('handles partial custom config', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: 'custom-key' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
assert.strictEqual(summary.apiKey.isCustom, true);
|
||||
assert.strictEqual(summary.managementSecret.isCustom, false);
|
||||
});
|
||||
|
||||
it('treats empty string as no custom value', () => {
|
||||
const config = {
|
||||
cliproxy: {
|
||||
variants: {},
|
||||
auth: { api_key: '' },
|
||||
},
|
||||
};
|
||||
const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
|
||||
|
||||
// Empty string is falsy, so isCustom should be false
|
||||
assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
|
||||
assert.strictEqual(summary.apiKey.isCustom, false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined config gracefully', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
assert.strictEqual(getEffectiveApiKey(undefined, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey(null, undefined, 'default'), 'default');
|
||||
assert.strictEqual(getEffectiveApiKey({}, undefined, 'default'), 'default');
|
||||
});
|
||||
|
||||
it('handles deeply nested missing properties', () => {
|
||||
function getEffectiveApiKey(config, variantName, defaultKey) {
|
||||
if (variantName) {
|
||||
const variant = config?.cliproxy?.variants?.[variantName];
|
||||
if (variant?.auth?.api_key) return variant.auth.api_key;
|
||||
}
|
||||
if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
|
||||
return defaultKey;
|
||||
}
|
||||
|
||||
const config = { cliproxy: {} };
|
||||
assert.strictEqual(getEffectiveApiKey(config, 'test', 'default'), 'default');
|
||||
});
|
||||
|
||||
it('crypto.randomBytes is available and working', () => {
|
||||
// Ensure crypto module is available
|
||||
const bytes = crypto.randomBytes(32);
|
||||
assert.strictEqual(bytes.length, 32);
|
||||
assert(Buffer.isBuffer(bytes));
|
||||
});
|
||||
|
||||
it('base64url encoding produces valid characters', () => {
|
||||
const bytes = crypto.randomBytes(32);
|
||||
const token = bytes.toString('base64url');
|
||||
|
||||
// Valid base64url characters: A-Z, a-z, 0-9, -, _
|
||||
const validChars = /^[A-Za-z0-9_-]+$/;
|
||||
assert(validChars.test(token), 'Token should only contain base64url characters');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Constants Validation
|
||||
// =========================================================================
|
||||
describe('Default Constants', () => {
|
||||
let CCS_INTERNAL_API_KEY;
|
||||
let CCS_CONTROL_PANEL_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY;
|
||||
CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET;
|
||||
});
|
||||
|
||||
it('CCS_INTERNAL_API_KEY is defined', () => {
|
||||
assert(CCS_INTERNAL_API_KEY, 'CCS_INTERNAL_API_KEY should be defined');
|
||||
assert.strictEqual(typeof CCS_INTERNAL_API_KEY, 'string');
|
||||
});
|
||||
|
||||
it('CCS_CONTROL_PANEL_SECRET is defined', () => {
|
||||
assert(CCS_CONTROL_PANEL_SECRET, 'CCS_CONTROL_PANEL_SECRET should be defined');
|
||||
assert.strictEqual(typeof CCS_CONTROL_PANEL_SECRET, 'string');
|
||||
});
|
||||
|
||||
it('default API key has expected value', () => {
|
||||
assert.strictEqual(CCS_INTERNAL_API_KEY, 'ccs-internal-managed');
|
||||
});
|
||||
|
||||
it('default secret has expected value', () => {
|
||||
assert.strictEqual(CCS_CONTROL_PANEL_SECRET, 'ccs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getManagementAuthUrlPath,
|
||||
getPasteCallbackStartPath,
|
||||
getManagementOAuthCallbackPath,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
|
||||
describe('auth-types paste-callback start path', () => {
|
||||
it('maps providers to CLIProxyAPI management auth-url routes', () => {
|
||||
expect(getPasteCallbackStartPath('gemini')).toBe(
|
||||
'/v0/management/gemini-cli-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true');
|
||||
expect(getPasteCallbackStartPath('agy')).toBe(
|
||||
'/v0/management/antigravity-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('claude')).toBe(
|
||||
'/v0/management/anthropic-auth-url?is_webui=true'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('maps Kiro management-supported methods to the management auth-url route', () => {
|
||||
expect(getPasteCallbackStartPath('kiro')).toBe(
|
||||
'/v0/management/kiro-auth-url?is_webui=true&method=aws'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'google' })).toBe(
|
||||
'/v0/management/kiro-auth-url?is_webui=true&method=google'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for Kiro CLI-only paste-callback modes', () => {
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'aws-authcode' })).toBeNull();
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'idc' })).toBeNull();
|
||||
});
|
||||
|
||||
it('still exposes the generic management auth-url helper', () => {
|
||||
expect(getManagementAuthUrlPath('kiro')).toBe('/v0/management/kiro-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('uses CLIProxyAPI management oauth-callback route', () => {
|
||||
expect(getManagementOAuthCallbackPath()).toBe('/v0/management/oauth-callback');
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Auth Utilities Unit Tests
|
||||
*
|
||||
* Tests for shared authentication utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
getTokenExpiryTimestamp,
|
||||
isTokenExpired,
|
||||
sanitizeEmail,
|
||||
} from '../../../src/cliproxy/auth-utils';
|
||||
|
||||
describe('Auth Utilities', () => {
|
||||
describe('sanitizeEmail', () => {
|
||||
it('should replace @ with underscore', () => {
|
||||
const result = sanitizeEmail('user@example.com');
|
||||
expect(result).not.toContain('@');
|
||||
expect(result).toContain('user_example');
|
||||
});
|
||||
|
||||
it('should replace . with underscore', () => {
|
||||
const result = sanitizeEmail('user@example.com');
|
||||
expect(result).not.toContain('.');
|
||||
expect(result).toBe('user_example_com');
|
||||
});
|
||||
|
||||
it('should handle multiple dots', () => {
|
||||
const result = sanitizeEmail('user.name@sub.example.com');
|
||||
expect(result).toBe('user_name_sub_example_com');
|
||||
});
|
||||
|
||||
it('should handle email without dots in domain', () => {
|
||||
const result = sanitizeEmail('user@localhost');
|
||||
expect(result).toBe('user_localhost');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = sanitizeEmail('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTokenExpired', () => {
|
||||
it('should return false for undefined input', () => {
|
||||
const result = isTokenExpired(undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
const result = isTokenExpired('');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for past date', () => {
|
||||
const pastDate = new Date(Date.now() - 3600000).toISOString(); // 1 hour ago
|
||||
const result = isTokenExpired(pastDate);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for future date', () => {
|
||||
const futureDate = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
|
||||
const result = isTokenExpired(futureDate);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid date string', () => {
|
||||
// new Date('invalid') returns Invalid Date, getTime() returns NaN
|
||||
// NaN < Date.now() is false
|
||||
const result = isTokenExpired('not-a-date');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle ISO date strings', () => {
|
||||
const pastISO = '2020-01-01T00:00:00.000Z';
|
||||
expect(isTokenExpired(pastISO)).toBe(true);
|
||||
|
||||
const futureISO = '2030-01-01T00:00:00.000Z';
|
||||
expect(isTokenExpired(futureISO)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Unix timestamp strings deterministically', () => {
|
||||
const pastTimestamp = String(Date.now() - 86400000); // Yesterday
|
||||
expect(isTokenExpired(pastTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Unix timestamps provided as numbers', () => {
|
||||
const futureTimestamp = Date.now() + 86400000;
|
||||
expect(isTokenExpired(futureTimestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat Unix-seconds values as seconds, not milliseconds', () => {
|
||||
const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000);
|
||||
expect(isTokenExpired(futureUnixSeconds)).toBe(false);
|
||||
expect(isTokenExpired(String(futureUnixSeconds))).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose normalized expiry timestamps for string and numeric inputs', () => {
|
||||
const futureTimestamp = Date.now() + 60000;
|
||||
expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp);
|
||||
expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp);
|
||||
const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000);
|
||||
expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000);
|
||||
expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000);
|
||||
expect(getTokenExpiryTimestamp('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* Tests for Auto-Sync Watcher
|
||||
* Verifies debounce behavior, watcher lifecycle, and state management.
|
||||
*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
|
||||
describe('Auto-Sync Watcher', () => {
|
||||
const autoSyncWatcher = require('../../../dist/cliproxy/sync/auto-sync-watcher');
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ensure clean state before each test
|
||||
await autoSyncWatcher.resetWatcherState();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up after each test
|
||||
await autoSyncWatcher.stopAutoSyncWatcher();
|
||||
});
|
||||
|
||||
describe('isAutoSyncEnabled', () => {
|
||||
it('returns a boolean', () => {
|
||||
const result = autoSyncWatcher.isAutoSyncEnabled();
|
||||
assert.ok(typeof result === 'boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoSyncStatus', () => {
|
||||
it('returns status object with required fields', () => {
|
||||
const status = autoSyncWatcher.getAutoSyncStatus();
|
||||
|
||||
assert.ok(typeof status === 'object');
|
||||
assert.ok(typeof status.enabled === 'boolean');
|
||||
assert.ok(typeof status.watching === 'boolean');
|
||||
assert.ok(typeof status.syncing === 'boolean');
|
||||
});
|
||||
|
||||
it('reports not watching when watcher not started', () => {
|
||||
const status = autoSyncWatcher.getAutoSyncStatus();
|
||||
assert.strictEqual(status.watching, false);
|
||||
});
|
||||
|
||||
it('reports not syncing initially', () => {
|
||||
const status = autoSyncWatcher.getAutoSyncStatus();
|
||||
assert.strictEqual(status.syncing, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startAutoSyncWatcher', () => {
|
||||
it('does not throw when called', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
autoSyncWatcher.startAutoSyncWatcher();
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent (can be called multiple times)', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
autoSyncWatcher.startAutoSyncWatcher();
|
||||
autoSyncWatcher.startAutoSyncWatcher();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAutoSyncWatcher', () => {
|
||||
it('resolves without error', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
await autoSyncWatcher.stopAutoSyncWatcher();
|
||||
});
|
||||
});
|
||||
|
||||
it('can stop when not started', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
await autoSyncWatcher.stopAutoSyncWatcher();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartAutoSyncWatcher', () => {
|
||||
it('resolves without error', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
await autoSyncWatcher.restartAutoSyncWatcher();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetWatcherState', () => {
|
||||
it('clears all state', async () => {
|
||||
// Start watcher to set state
|
||||
autoSyncWatcher.startAutoSyncWatcher();
|
||||
|
||||
// Reset state
|
||||
await autoSyncWatcher.resetWatcherState();
|
||||
|
||||
// Verify state is cleared
|
||||
const status = autoSyncWatcher.getAutoSyncStatus();
|
||||
assert.strictEqual(status.watching, false);
|
||||
assert.strictEqual(status.syncing, false);
|
||||
});
|
||||
|
||||
it('is safe to call multiple times', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
await autoSyncWatcher.resetWatcherState();
|
||||
await autoSyncWatcher.resetWatcherState();
|
||||
await autoSyncWatcher.resetWatcherState();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debounce Behavior', () => {
|
||||
/**
|
||||
* Debounce is set to 3000ms (DEBOUNCE_MS constant).
|
||||
* This prevents sync storms during rapid edits.
|
||||
*
|
||||
* Testing actual debounce requires file system manipulation
|
||||
* and timing-based assertions, which are flaky in unit tests.
|
||||
* Integration tests should cover this behavior.
|
||||
*/
|
||||
it('debounce constant is documented', () => {
|
||||
// DEBOUNCE_MS = 3000 (3 seconds)
|
||||
assert.ok(true, 'Debounce documented in code comments');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Tests for CLIProxy Backend Selection
|
||||
* Verifies backend selection feature for original vs plus CLIProxyAPI variants
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Backend Selection', () => {
|
||||
const platformDetector = require('../../../dist/cliproxy/platform-detector');
|
||||
const types = require('../../../dist/cliproxy/types');
|
||||
|
||||
describe('BACKEND_CONFIG', () => {
|
||||
it('has correct configuration for original backend', () => {
|
||||
const config = platformDetector.BACKEND_CONFIG.original;
|
||||
assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPI');
|
||||
assert.strictEqual(config.binaryPrefix, 'CLIProxyAPI');
|
||||
assert.strictEqual(config.executable, 'cli-proxy-api');
|
||||
assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+$/), 'original version has no suffix');
|
||||
});
|
||||
|
||||
it('has correct configuration for plus backend', () => {
|
||||
const config = platformDetector.BACKEND_CONFIG.plus;
|
||||
assert.strictEqual(config.repo, 'kaitranntt/CLIProxyAPIPlus');
|
||||
assert.strictEqual(config.binaryPrefix, 'CLIProxyAPIPlus');
|
||||
assert.strictEqual(config.executable, 'cli-proxy-api-plus');
|
||||
assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+-\d+$/), 'plus version has -0 suffix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_BACKEND', () => {
|
||||
it('defaults to original backend', () => {
|
||||
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPlatform', () => {
|
||||
it('generates correct binary name for original backend', () => {
|
||||
const info = platformDetector.detectPlatform('6.6.51', 'original');
|
||||
assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_'));
|
||||
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('generates correct binary name for plus backend', () => {
|
||||
const info = platformDetector.detectPlatform('6.6.51-0', 'plus');
|
||||
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
|
||||
});
|
||||
|
||||
it('uses original backend by default', () => {
|
||||
const info = platformDetector.detectPlatform();
|
||||
assert(info.binaryName.startsWith('CLIProxyAPI_'));
|
||||
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('uses fallback version when version not specified', () => {
|
||||
const infoOriginal = platformDetector.detectPlatform(undefined, 'original');
|
||||
const fallbackOriginal = platformDetector.BACKEND_CONFIG.original.fallbackVersion;
|
||||
assert(infoOriginal.binaryName.includes(fallbackOriginal));
|
||||
|
||||
const infoPlus = platformDetector.detectPlatform(undefined, 'plus');
|
||||
const fallbackPlus = platformDetector.BACKEND_CONFIG.plus.fallbackVersion;
|
||||
assert(infoPlus.binaryName.includes(fallbackPlus));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExecutableName', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
it('returns correct name for original backend', () => {
|
||||
const name = platformDetector.getExecutableName('original');
|
||||
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
assert.strictEqual(name, expected);
|
||||
});
|
||||
|
||||
it('returns correct name for plus backend', () => {
|
||||
const name = platformDetector.getExecutableName('plus');
|
||||
const expected = isWindows ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus';
|
||||
assert.strictEqual(name, expected);
|
||||
});
|
||||
|
||||
it('defaults to original backend', () => {
|
||||
const name = platformDetector.getExecutableName();
|
||||
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
assert.strictEqual(name, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDownloadUrl', () => {
|
||||
it('uses correct repo for original backend', () => {
|
||||
const url = platformDetector.getDownloadUrl('6.6.51', 'original');
|
||||
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
|
||||
assert(!url.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('uses correct repo for plus backend', () => {
|
||||
const url = platformDetector.getDownloadUrl('6.6.51-0', 'plus');
|
||||
assert(url.includes('kaitranntt/CLIProxyAPIPlus/releases'));
|
||||
});
|
||||
|
||||
it('defaults to original backend', () => {
|
||||
const url = platformDetector.getDownloadUrl();
|
||||
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
|
||||
assert(!url.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFallbackVersion', () => {
|
||||
it('returns correct version for original backend', () => {
|
||||
const version = platformDetector.getFallbackVersion('original');
|
||||
assert.strictEqual(version, platformDetector.BACKEND_CONFIG.original.fallbackVersion);
|
||||
});
|
||||
|
||||
it('returns correct version for plus backend', () => {
|
||||
const version = platformDetector.getFallbackVersion('plus');
|
||||
assert.strictEqual(version, platformDetector.BACKEND_CONFIG.plus.fallbackVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLUS_ONLY_PROVIDERS', () => {
|
||||
it('includes kiro as plus-only provider', () => {
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('kiro'));
|
||||
});
|
||||
|
||||
it('includes ghcp as plus-only provider', () => {
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp'));
|
||||
});
|
||||
|
||||
it('includes the newer CLIProxyAPIPlus providers as plus-only', () => {
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('cursor'));
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('gitlab'));
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('codebuddy'));
|
||||
assert(types.PLUS_ONLY_PROVIDERS.includes('kilo'));
|
||||
});
|
||||
|
||||
it('does not include gemini as plus-only provider', () => {
|
||||
assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini'));
|
||||
});
|
||||
|
||||
it('does not include agy as plus-only provider', () => {
|
||||
assert(!types.PLUS_ONLY_PROVIDERS.includes('agy'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Default Port Sync Test
|
||||
*
|
||||
* Keeps backend and UI default ports in sync while allowing independent modules.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||
import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS,
|
||||
getProviderDescription as getBackendProviderDescription,
|
||||
getProviderDisplayName as getBackendProviderDisplayName,
|
||||
getProvidersByOAuthFlow,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import {
|
||||
CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT,
|
||||
DEFAULT_CURSOR_PORT as UI_CURSOR_DEFAULT_PORT,
|
||||
} from '../../../ui/src/lib/default-ports';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
|
||||
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
|
||||
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
PROVIDER_METADATA as UI_PROVIDER_METADATA,
|
||||
} from '../../../ui/src/lib/provider-config';
|
||||
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||
|
||||
function sorted(values: readonly string[]): string[] {
|
||||
return [...values].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
describe('Default Port Sync', () => {
|
||||
test('CLIProxy default port is synced between backend and UI', () => {
|
||||
expect(UI_CLIPROXY_DEFAULT_PORT).toBe(BACKEND_CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
test('Cursor default port is synced between backend and UI', () => {
|
||||
expect(UI_CURSOR_DEFAULT_PORT).toBe(BACKEND_CURSOR_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
test('CLIProxy provider IDs are synced between backend and UI', () => {
|
||||
expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS));
|
||||
});
|
||||
|
||||
test('Device code providers are synced between backend and UI', () => {
|
||||
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
|
||||
});
|
||||
|
||||
test('plus-extra providers are synced between backend and UI', () => {
|
||||
expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS));
|
||||
expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual(
|
||||
sorted(
|
||||
BACKEND_CLIPROXY_PROVIDER_IDS.filter(
|
||||
(provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Provider display names are synced between backend and UI', () => {
|
||||
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
|
||||
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
|
||||
}
|
||||
});
|
||||
|
||||
test('Provider descriptions are synced between backend and UI', () => {
|
||||
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
|
||||
expect(UI_PROVIDER_METADATA[provider].description).toBe(getBackendProviderDescription(provider));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Provider Sync Test
|
||||
*
|
||||
* Validates that backend CLIPROXY_PROFILES and UI CLIPROXY_PROVIDERS stay in sync.
|
||||
* This test catches mismatches when adding new providers.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { CLIPROXY_PROFILES } from '../../../src/auth/profile-detector';
|
||||
import { CLIPROXY_PROVIDERS } from '../../../ui/src/lib/provider-config';
|
||||
|
||||
describe('Provider Sync', () => {
|
||||
test('backend CLIPROXY_PROFILES matches UI CLIPROXY_PROVIDERS', () => {
|
||||
const backend = [...CLIPROXY_PROFILES].sort();
|
||||
const ui = [...CLIPROXY_PROVIDERS].sort();
|
||||
|
||||
expect(backend).toEqual(ui);
|
||||
});
|
||||
|
||||
test('both arrays have same length', () => {
|
||||
expect(CLIPROXY_PROFILES.length).toBe(CLIPROXY_PROVIDERS.length);
|
||||
});
|
||||
|
||||
test('UI array contains all backend providers', () => {
|
||||
for (const provider of CLIPROXY_PROFILES) {
|
||||
expect(CLIPROXY_PROVIDERS).toContain(provider);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { loadBaseConfig } from '../../../src/cliproxy/base-config-loader';
|
||||
|
||||
describe('base-config-loader new providers', () => {
|
||||
it.each([
|
||||
['cursor', '/api/provider/cursor', 'composer-2'],
|
||||
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
|
||||
['codebuddy', '/api/provider/codebuddy', 'auto'],
|
||||
['kilo', '/api/provider/kilo', 'kilo/auto'],
|
||||
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
|
||||
const config = loadBaseConfig(provider);
|
||||
|
||||
expect(config.env.ANTHROPIC_BASE_URL).toContain(baseUrlPath);
|
||||
expect(config.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||
expect(config.env.ANTHROPIC_MODEL).toBe(defaultModel);
|
||||
expect(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL.length).toBeGreaterThan(0);
|
||||
expect(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL.length).toBeGreaterThan(0);
|
||||
expect(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Binary Downloader Proxy Support Tests
|
||||
*
|
||||
* Tests for proxy environment variable detection and NO_PROXY bypass logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { __testExports } from '../../../src/cliproxy/binary/downloader';
|
||||
|
||||
const { getProxyUrl, shouldBypassProxy, getHostname, getProxyAgent } = __testExports;
|
||||
|
||||
describe('Binary Downloader Proxy Support', () => {
|
||||
// Store original env vars to restore after tests
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
const proxyEnvVars = [
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY',
|
||||
'no_proxy',
|
||||
'NO_PROXY',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original values
|
||||
proxyEnvVars.forEach((key) => {
|
||||
originalEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original values
|
||||
proxyEnvVars.forEach((key) => {
|
||||
if (originalEnv[key] !== undefined) {
|
||||
process.env[key] = originalEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyUrl', () => {
|
||||
describe('HTTPS requests', () => {
|
||||
it('should return undefined when no proxy env vars are set', () => {
|
||||
expect(getProxyUrl(true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer https_proxy (lowercase)', () => {
|
||||
process.env.https_proxy = 'http://proxy1:8080';
|
||||
process.env.HTTPS_PROXY = 'http://proxy2:8080';
|
||||
expect(getProxyUrl(true)).toBe('http://proxy1:8080');
|
||||
});
|
||||
|
||||
it('should fall back to HTTPS_PROXY (uppercase)', () => {
|
||||
process.env.HTTPS_PROXY = 'http://proxy:8080';
|
||||
expect(getProxyUrl(true)).toBe('http://proxy:8080');
|
||||
});
|
||||
|
||||
it('should fall back to all_proxy', () => {
|
||||
process.env.all_proxy = 'http://allproxy:8080';
|
||||
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
|
||||
});
|
||||
|
||||
it('should fall back to ALL_PROXY', () => {
|
||||
process.env.ALL_PROXY = 'http://allproxy:8080';
|
||||
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
|
||||
});
|
||||
|
||||
it('should skip invalid HTTP_PROXY and fall back to ALL_PROXY', () => {
|
||||
process.env.HTTP_PROXY = 'not-a-valid-url';
|
||||
process.env.ALL_PROXY = 'http://allproxy:8080';
|
||||
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP requests', () => {
|
||||
it('should return undefined when no proxy env vars are set', () => {
|
||||
expect(getProxyUrl(false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer http_proxy (lowercase)', () => {
|
||||
process.env.http_proxy = 'http://proxy1:8080';
|
||||
process.env.HTTP_PROXY = 'http://proxy2:8080';
|
||||
expect(getProxyUrl(false)).toBe('http://proxy1:8080');
|
||||
});
|
||||
|
||||
it('should fall back to HTTP_PROXY (uppercase)', () => {
|
||||
process.env.HTTP_PROXY = 'http://proxy:8080';
|
||||
expect(getProxyUrl(false)).toBe('http://proxy:8080');
|
||||
});
|
||||
|
||||
it('should fall back to all_proxy for HTTP', () => {
|
||||
process.env.all_proxy = 'http://allproxy:8080';
|
||||
expect(getProxyUrl(false)).toBe('http://allproxy:8080');
|
||||
});
|
||||
|
||||
it('should skip invalid HTTP_PROXY and use ALL_PROXY for HTTP', () => {
|
||||
process.env.HTTP_PROXY = 'not-a-valid-url';
|
||||
process.env.ALL_PROXY = 'http://allproxy:8080';
|
||||
expect(getProxyUrl(false)).toBe('http://allproxy:8080');
|
||||
});
|
||||
|
||||
it('should not use https_proxy for HTTP requests', () => {
|
||||
process.env.https_proxy = 'http://httpsproxy:8080';
|
||||
expect(getProxyUrl(false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldBypassProxy', () => {
|
||||
describe('when NO_PROXY is not set', () => {
|
||||
it('should return false', () => {
|
||||
expect(shouldBypassProxy('example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wildcard pattern', () => {
|
||||
it('should bypass all hosts with *', () => {
|
||||
process.env.NO_PROXY = '*';
|
||||
expect(shouldBypassProxy('example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('any.host.here')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exact match', () => {
|
||||
it('should match exact hostname', () => {
|
||||
process.env.NO_PROXY = 'example.com';
|
||||
expect(shouldBypassProxy('example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('other.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
process.env.NO_PROXY = 'Example.COM';
|
||||
expect(shouldBypassProxy('example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('EXAMPLE.COM')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match subdomains for exact pattern', () => {
|
||||
process.env.NO_PROXY = 'example.com';
|
||||
expect(shouldBypassProxy('sub.example.com')).toBe(true); // matches as suffix
|
||||
expect(shouldBypassProxy('notexample.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain suffix pattern', () => {
|
||||
it('should match subdomains with leading dot', () => {
|
||||
process.env.NO_PROXY = '.example.com';
|
||||
expect(shouldBypassProxy('sub.example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('deep.sub.example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('example.com')).toBe(true); // exact match without dot
|
||||
});
|
||||
|
||||
it('should not match different domains', () => {
|
||||
process.env.NO_PROXY = '.example.com';
|
||||
expect(shouldBypassProxy('notexample.com')).toBe(false);
|
||||
expect(shouldBypassProxy('example.org')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple patterns', () => {
|
||||
it('should check comma-separated list', () => {
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,.internal.corp';
|
||||
expect(shouldBypassProxy('localhost')).toBe(true);
|
||||
expect(shouldBypassProxy('127.0.0.1')).toBe(true);
|
||||
expect(shouldBypassProxy('api.internal.corp')).toBe(true);
|
||||
expect(shouldBypassProxy('external.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle whitespace in list', () => {
|
||||
process.env.NO_PROXY = ' localhost , example.com , .corp ';
|
||||
expect(shouldBypassProxy('localhost')).toBe(true);
|
||||
expect(shouldBypassProxy('example.com')).toBe(true);
|
||||
expect(shouldBypassProxy('host.corp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lowercase no_proxy', () => {
|
||||
it('should respect no_proxy (lowercase)', () => {
|
||||
process.env.no_proxy = 'example.com';
|
||||
expect(shouldBypassProxy('example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer no_proxy over NO_PROXY', () => {
|
||||
process.env.no_proxy = 'lower.com';
|
||||
process.env.NO_PROXY = 'upper.com';
|
||||
expect(shouldBypassProxy('lower.com')).toBe(true);
|
||||
expect(shouldBypassProxy('upper.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHostname', () => {
|
||||
it('should extract hostname from HTTPS URL', () => {
|
||||
expect(getHostname('https://example.com/path')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should extract hostname from HTTP URL', () => {
|
||||
expect(getHostname('http://api.github.com:443/repos')).toBe('api.github.com');
|
||||
});
|
||||
|
||||
it('should return empty string for invalid URL', () => {
|
||||
expect(getHostname('not-a-url')).toBe('');
|
||||
expect(getHostname('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle localhost', () => {
|
||||
expect(getHostname('http://localhost:8080')).toBe('localhost');
|
||||
});
|
||||
|
||||
it('should handle IP addresses', () => {
|
||||
expect(getHostname('http://192.168.1.1:3000')).toBe('192.168.1.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyAgent', () => {
|
||||
describe('when no proxy is configured', () => {
|
||||
it('should return false for HTTPS URLs', () => {
|
||||
expect(getProxyAgent('https://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for HTTP URLs', () => {
|
||||
expect(getProxyAgent('http://example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when proxy is configured', () => {
|
||||
it('should return HttpsProxyAgent for HTTPS URLs', () => {
|
||||
process.env.https_proxy = 'http://proxy:8080';
|
||||
const agent = getProxyAgent('https://github.com/releases');
|
||||
expect(agent).not.toBe(false);
|
||||
expect(agent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return HttpProxyAgent for HTTP URLs', () => {
|
||||
process.env.http_proxy = 'http://proxy:8080';
|
||||
const agent = getProxyAgent('http://example.com');
|
||||
expect(agent).not.toBe(false);
|
||||
expect(agent).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NO_PROXY bypass', () => {
|
||||
it('should bypass proxy for NO_PROXY hosts', () => {
|
||||
process.env.https_proxy = 'http://proxy:8080';
|
||||
process.env.NO_PROXY = 'github.com';
|
||||
expect(getProxyAgent('https://github.com/releases')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use proxy for non-bypassed hosts', () => {
|
||||
process.env.https_proxy = 'http://proxy:8080';
|
||||
process.env.NO_PROXY = 'internal.corp';
|
||||
const agent = getProxyAgent('https://github.com/releases');
|
||||
expect(agent).not.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return false for invalid proxy URL', () => {
|
||||
process.env.https_proxy = 'not-a-valid-url';
|
||||
expect(getProxyAgent('https://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep supporting socks proxy URLs for downloader traffic', () => {
|
||||
process.env.https_proxy = 'socks5://proxy:1080';
|
||||
expect(getProxyAgent('https://example.com')).not.toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid target URL', () => {
|
||||
process.env.https_proxy = 'http://proxy:8080';
|
||||
expect(getProxyAgent('not-a-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Binary Installer ETXTBSY Guard Tests
|
||||
*
|
||||
* Tests the error handling in deleteBinary() when unlinkSync fails.
|
||||
* Uses real temp files to avoid global fs mock pollution.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { deleteBinary } from '../../../src/cliproxy/binary/installer';
|
||||
|
||||
describe('deleteBinary ETXTBSY guard', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-etxtbsy-test-'));
|
||||
// Create a fake binary file that deleteBinary will target
|
||||
const binDir = path.join(tmpDir, 'plus');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(binDir, 'cli-proxy-api-plus'), 'fake-binary');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('deletes binary successfully when file is not in use', () => {
|
||||
const binDir = path.join(tmpDir, 'plus');
|
||||
const binaryPath = path.join(binDir, 'cli-proxy-api-plus');
|
||||
expect(fs.existsSync(binaryPath)).toBe(true);
|
||||
|
||||
deleteBinary(binDir, false, 'plus');
|
||||
|
||||
expect(fs.existsSync(binaryPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when binary does not exist', () => {
|
||||
const emptyDir = path.join(tmpDir, 'empty');
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
expect(() => deleteBinary(emptyDir, false, 'plus')).not.toThrow();
|
||||
});
|
||||
|
||||
it('ETXTBSY catch block produces correct error message', () => {
|
||||
// Verify the error message format by testing the catch logic directly.
|
||||
// We can't reliably trigger ETXTBSY in tests (need a running Go binary),
|
||||
// so we verify the code structure matches the expected behavior.
|
||||
const err = Object.assign(new Error('ETXTBSY: text file busy'), { code: 'ETXTBSY' });
|
||||
const code =
|
||||
err instanceof Error && 'code' in err ? (err as { code: string }).code : '';
|
||||
expect(code).toBe('ETXTBSY');
|
||||
// The guard only catches ETXTBSY, not EBUSY
|
||||
expect(code === 'ETXTBSY').toBe(true);
|
||||
expect(code === 'EBUSY').toBe(false);
|
||||
});
|
||||
|
||||
it('EBUSY is not treated as "binary in use"', () => {
|
||||
// Verify that EBUSY (Windows mount/directory) is distinguished from ETXTBSY
|
||||
const err = Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' });
|
||||
const code =
|
||||
err instanceof Error && 'code' in err ? (err as { code: string }).code : '';
|
||||
expect(code).toBe('EBUSY');
|
||||
expect(code === 'ETXTBSY').toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempHome = '';
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-binary-manager-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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('installCliproxyVersion', () => {
|
||||
it('keeps explicit plus backend requests on plus before install flows run', async () => {
|
||||
let seenBackend: string | undefined;
|
||||
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-explicit-plus=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
|
||||
createManager: (_config: unknown, backend: string) => {
|
||||
seenBackend = backend;
|
||||
return {
|
||||
isBinaryInstalled: () => false,
|
||||
deleteBinary: () => undefined,
|
||||
ensureBinary: async () => '/tmp/ccs-bin/plus/cliproxy',
|
||||
};
|
||||
},
|
||||
stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }),
|
||||
waitForPortFreeFn: async () => true,
|
||||
formatInfo: (message: string) => message,
|
||||
formatWarn: (message: string) => message,
|
||||
getInstalledVersion: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(seenBackend).toBe('plus');
|
||||
});
|
||||
|
||||
it('returns plus and emits an informational notice when plus backend is resolved locally', async () => {
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}`
|
||||
);
|
||||
|
||||
const writes: string[] = [];
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
try {
|
||||
expect(binaryManager.resolveLocalBackend('plus', { notifyOnPlus: true })).toBe('plus');
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
|
||||
expect(writes.join('')).toContain('optional community-maintained CLIProxyAPIPlus backend');
|
||||
expect(writes.join('')).toContain('backend: original');
|
||||
expect(writes.join('')).toContain('kaitranntt/CLIProxyAPIPlus');
|
||||
});
|
||||
|
||||
it('uses plus binary and pin state when local runtime is configured for plus', async () => {
|
||||
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
|
||||
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
|
||||
const binaryService = await import(
|
||||
`../../../src/cliproxy/services/binary-service?binary-service-plus-migration=${Date.now()}`
|
||||
);
|
||||
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'fake-binary');
|
||||
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
|
||||
savePinnedVersion('6.6.80-0', 'plus');
|
||||
|
||||
const status = binaryService.getBinaryStatus();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.pinnedVersion).toBe('6.6.80-0');
|
||||
expect(status.binaryPath).toContain('/plus/');
|
||||
});
|
||||
|
||||
it('copies plus binary and pin state when legacy deleted upstream fallback is active', async () => {
|
||||
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
|
||||
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const platformDetector = await import('../../../src/cliproxy/platform-detector');
|
||||
const binaryService = await import(
|
||||
`../../../src/cliproxy/services/binary-service?binary-service-legacy-plus-fallback=${Date.now()}`
|
||||
);
|
||||
|
||||
const plusConfig = platformDetector.BACKEND_CONFIG.plus as unknown as { repo: string };
|
||||
const originalRepo = plusConfig.repo;
|
||||
|
||||
try {
|
||||
plusConfig.repo = 'router-for-me/CLIProxyAPIPlus';
|
||||
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(plusBinDir, platformDetector.getExecutableName('plus')),
|
||||
'fake-binary'
|
||||
);
|
||||
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
|
||||
savePinnedVersion('6.6.80-0', 'plus');
|
||||
|
||||
const status = binaryService.getBinaryStatus();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.pinnedVersion).toBe('6.6.80-0');
|
||||
expect(status.binaryPath).toContain('/original/');
|
||||
} finally {
|
||||
plusConfig.repo = originalRepo;
|
||||
}
|
||||
});
|
||||
|
||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||
const calls = {
|
||||
stopProxy: 0,
|
||||
waitForPortFree: 0,
|
||||
deleteBinary: 0,
|
||||
ensureBinary: 0,
|
||||
};
|
||||
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-install=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
|
||||
createManager: () => ({
|
||||
isBinaryInstalled: () => false,
|
||||
deleteBinary: () => {
|
||||
calls.deleteBinary += 1;
|
||||
},
|
||||
ensureBinary: async () => {
|
||||
calls.ensureBinary += 1;
|
||||
return '/tmp/ccs-bin/plus/cliproxy';
|
||||
},
|
||||
}),
|
||||
stopProxyFn: async () => {
|
||||
calls.stopProxy += 1;
|
||||
return { stopped: false, error: 'No active CLIProxy session found' };
|
||||
},
|
||||
waitForPortFreeFn: async () => {
|
||||
calls.waitForPortFree += 1;
|
||||
return true;
|
||||
},
|
||||
formatInfo: (message: string) => message,
|
||||
formatWarn: (message: string) => message,
|
||||
getInstalledVersion: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(calls.stopProxy).toBe(1);
|
||||
expect(calls.waitForPortFree).toBe(0);
|
||||
expect(calls.deleteBinary).toBe(0);
|
||||
expect(calls.ensureBinary).toBe(1);
|
||||
});
|
||||
|
||||
it('fails fast when runtime startup forbids installing a missing binary', async () => {
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-runtime=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(
|
||||
binaryManager.ensureCLIProxyBinary(false, {
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'CLIProxy binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
function createCodexSettingsFixture(haikuModel: string = 'gpt-5.4-mini'): {
|
||||
tmpDir: string;
|
||||
settingsPath: string;
|
||||
} {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-'));
|
||||
const settingsPath = path.join(tmpDir, 'codex.settings.json');
|
||||
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return { tmpDir, settingsPath };
|
||||
}
|
||||
|
||||
async function importCompatibilityModule(cacheTag: string) {
|
||||
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
|
||||
}
|
||||
|
||||
const identity = (message: string) => message;
|
||||
|
||||
describe('codex plan compatibility reconcile', () => {
|
||||
it('warns about the GPT-5.5 free-plan fallback without rewriting saved settings', async () => {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('gpt-5-5-free');
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.5',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => ({ id: 'free@example.com' }) as never,
|
||||
fetchCodexQuota: async () => ({
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: 'free',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'free@example.com',
|
||||
}),
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Codex free plan detected. Keeping saved model "gpt-5.5" in settings; runtime requests will fall back to "gpt-5.4" when needed.'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps saved Codex settings intact and warns about runtime fallback for free-plan accounts', async () => {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark');
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => ({ id: 'free@example.com' }) as never,
|
||||
fetchCodexQuota: async () => ({
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: 'free',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'free@example.com',
|
||||
}),
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.3-codex-spark');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5.4" when needed.'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and leaves settings untouched when no default Codex account is available', async () => {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } =
|
||||
await importCompatibilityModule('missing-default-account');
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => null,
|
||||
fetchCodexQuota: async () => {
|
||||
throw new Error('should not fetch quota without a default account');
|
||||
},
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5.4" with "ccs codex --config".'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => {
|
||||
for (const planType of ['plus', 'team'] as const) {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => ({ id: `${planType}@example.com` }) as never,
|
||||
fetchCodexQuota: async () => ({
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType,
|
||||
lastUpdated: Date.now(),
|
||||
accountId: `${planType}@example.com`,
|
||||
}),
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
mock.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and keeps settings unchanged when Codex plan verification fails', async () => {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => ({ id: 'unknown@example.com' }) as never,
|
||||
fetchCodexQuota: async () => ({
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'unknown@example.com',
|
||||
error: 'network timeout',
|
||||
}),
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => {
|
||||
const { tmpDir, settingsPath } = createCodexSettingsFixture();
|
||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { reconcileCodexModelForActivePlan } =
|
||||
await importCompatibilityModule('missing-plan-type');
|
||||
|
||||
await reconcileCodexModelForActivePlan(
|
||||
{
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
verbose: false,
|
||||
},
|
||||
{
|
||||
getDefaultAccount: () => ({ id: 'missing-plan@example.com' }) as never,
|
||||
fetchCodexQuota: async () => ({
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'missing-plan@example.com',
|
||||
}),
|
||||
formatInfo: identity,
|
||||
formatWarn: identity,
|
||||
}
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog';
|
||||
import {
|
||||
getDefaultCodexModel,
|
||||
getFreePlanFallbackCodexModel,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from '../../../src/cliproxy/codex-plan-compatibility';
|
||||
|
||||
describe('codex plan compatibility', () => {
|
||||
it('uses a cross-plan safe Codex default', () => {
|
||||
expect(getDefaultCodexModel()).toBe('gpt-5.4');
|
||||
expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('maps paid-only free-plan models to safe fallbacks', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5.4-mini');
|
||||
});
|
||||
|
||||
it('does not rewrite cross-plan or already-safe Codex models', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects upstream Codex model_not_supported responses', () => {
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
});
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves runtime fallbacks without retrying the rejected model again', () => {
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.3-codex',
|
||||
modelMap: { defaultModel: 'gpt-5.4' },
|
||||
})
|
||||
).toBe('gpt-5.4');
|
||||
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.3-codex',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5.4-mini',
|
||||
},
|
||||
excludeModels: ['gpt-5.4'],
|
||||
})
|
||||
).toBe('gpt-5.4-mini');
|
||||
});
|
||||
|
||||
it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => {
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh');
|
||||
});
|
||||
});
|
||||
@@ -1,434 +0,0 @@
|
||||
import * as http from 'http';
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildCodexModelEffortMap,
|
||||
CodexReasoningProxy,
|
||||
getEffortForModel,
|
||||
} from '../../../src/cliproxy/codex-reasoning-proxy';
|
||||
import {
|
||||
parseEnvThinkingOverride,
|
||||
resolveRuntimeThinkingOverride,
|
||||
shouldDisableCodexReasoning,
|
||||
} from '../../../src/cliproxy/executor/thinking-override-resolver';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function listenOnRandomPort(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address !== 'object' || !address) {
|
||||
reject(new Error('Failed to resolve server address'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(
|
||||
url: string,
|
||||
body: JsonRecord
|
||||
): Promise<{ statusCode: number; body: JsonRecord }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const payload = JSON.stringify(body);
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let responseBody = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
let parsedResponse: JsonRecord = {};
|
||||
try {
|
||||
parsedResponse = responseBody ? (JSON.parse(responseBody) as JsonRecord) : {};
|
||||
} catch {
|
||||
parsedResponse = {};
|
||||
}
|
||||
resolve({ statusCode: res.statusCode ?? 0, body: parsedResponse });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
const cleanupServers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupServers.length > 0) {
|
||||
const server = cleanupServers.pop();
|
||||
if (server) {
|
||||
await closeServer(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes [1m] suffixes in effort map lookups', () => {
|
||||
const map = buildCodexModelEffortMap({
|
||||
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||
sonnetModel: 'gpt-5.3-codex-high[1m]',
|
||||
haikuModel: 'gpt-5-mini-medium[1m]',
|
||||
});
|
||||
|
||||
expect(getEffortForModel('gpt-5.3-codex-high', map, 'medium')).toBe('high');
|
||||
expect(getEffortForModel('gpt-5-mini-medium', map, 'high')).toBe('medium');
|
||||
});
|
||||
|
||||
it('strips [1m] and codex effort suffixes before forwarding upstream', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
let capturedPath = '';
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedPath = req.url || '';
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||
opusModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||
sonnetModel: 'gpt-5.3-codex-high[1m]',
|
||||
haikuModel: 'gpt-5-mini-medium[1m]',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex-high[1m]',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(capturedPath).toBe('/api/provider/codex/v1/messages');
|
||||
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
});
|
||||
|
||||
it('skips reasoning injection when disableEffort is enabled', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
sonnetModel: 'gpt-5.3-codex-high',
|
||||
},
|
||||
disableEffort: true,
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex-high',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not strip unknown model ids that merely end with "-high"', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.1-codex-mini',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'enterprise-internal-high',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(capturedBody?.model).toBe('enterprise-internal-high');
|
||||
});
|
||||
|
||||
it('retries unsupported live-session models once and remembers the fallback', async () => {
|
||||
const capturedModels: string[] = [];
|
||||
const capturedEfforts: Array<string | undefined> = [];
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
const reasoning = requestBody.reasoning as JsonRecord | undefined;
|
||||
const model = String(requestBody.model ?? '');
|
||||
const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined;
|
||||
|
||||
capturedModels.push(model);
|
||||
capturedEfforts.push(effort);
|
||||
|
||||
if (model === 'gpt-5.4') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
model,
|
||||
effort: effort ?? null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5-codex-mini',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const firstResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
const secondResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.body.model).toBe('gpt-5.4-mini');
|
||||
expect(firstResponse.body.effort).toBe('high');
|
||||
expect(secondResponse.body.model).toBe('gpt-5.4-mini');
|
||||
expect(secondResponse.body.effort).toBe('high');
|
||||
expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-mini']);
|
||||
expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']);
|
||||
});
|
||||
|
||||
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
expect(parseEnvThinkingOverride('high')).toBe('high');
|
||||
const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'high');
|
||||
const disableEffort = shouldDisableCodexReasoning(
|
||||
{
|
||||
mode: 'off',
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
show_warnings: true,
|
||||
},
|
||||
thinkingOverride
|
||||
);
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
disableEffort,
|
||||
defaultEffort: 'medium',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
},
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex-high',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(disableEffort).toBe(false);
|
||||
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
});
|
||||
|
||||
it('disables reasoning when CCS_THINKING=off is provided', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
expect(parseEnvThinkingOverride('off')).toBe('off');
|
||||
const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'off');
|
||||
const disableEffort = shouldDisableCodexReasoning(
|
||||
{
|
||||
mode: 'auto',
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
show_warnings: true,
|
||||
},
|
||||
thinkingOverride
|
||||
);
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
disableEffort,
|
||||
defaultEffort: 'medium',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
},
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.3-codex-high',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(disableEffort).toBe(true);
|
||||
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
buildCodexModelEffortMap,
|
||||
getEffortForModel,
|
||||
injectReasoningEffortIntoBody,
|
||||
} = require('../../../dist/cliproxy/codex-reasoning-proxy');
|
||||
|
||||
describe('Codex Reasoning Proxy', () => {
|
||||
describe('buildCodexModelEffortMap', () => {
|
||||
it('maps tier models to expected efforts', () => {
|
||||
const map = buildCodexModelEffortMap({
|
||||
defaultModel: 'gpt-5.2-codex',
|
||||
opusModel: 'gpt-5.2-codex',
|
||||
sonnetModel: 'gpt-5.2-codex-high',
|
||||
haikuModel: 'gpt-5.2-codex-mini',
|
||||
});
|
||||
|
||||
assert.strictEqual(map.get('gpt-5.2-codex'), 'xhigh');
|
||||
assert.strictEqual(map.get('gpt-5.2-codex-high'), 'high');
|
||||
assert.strictEqual(map.get('gpt-5.2-codex-mini'), 'medium');
|
||||
});
|
||||
|
||||
it('uses the lowest effort when the same model appears in multiple tiers', () => {
|
||||
const map = buildCodexModelEffortMap({
|
||||
opusModel: 'same',
|
||||
sonnetModel: 'same',
|
||||
haikuModel: 'same',
|
||||
});
|
||||
|
||||
assert.strictEqual(map.get('same'), 'medium');
|
||||
});
|
||||
|
||||
it('returns empty map when all models undefined', () => {
|
||||
const map = buildCodexModelEffortMap({});
|
||||
assert.strictEqual(map.size, 0);
|
||||
});
|
||||
|
||||
it('ignores empty string models', () => {
|
||||
const map = buildCodexModelEffortMap({ defaultModel: '', opusModel: ' ' });
|
||||
assert.strictEqual(map.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffortForModel', () => {
|
||||
it('defaults to medium for unknown model', () => {
|
||||
const map = buildCodexModelEffortMap({ opusModel: 'm1' });
|
||||
assert.strictEqual(getEffortForModel('unknown', map, 'medium'), 'medium');
|
||||
});
|
||||
|
||||
it('handles null model', () => {
|
||||
const map = buildCodexModelEffortMap({ opusModel: 'm1' });
|
||||
assert.strictEqual(getEffortForModel(null, map, 'high'), 'high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectReasoningEffortIntoBody', () => {
|
||||
it('overrides reasoning.effort on object bodies', () => {
|
||||
const out = injectReasoningEffortIntoBody(
|
||||
{ model: 'm1', reasoning: { effort: 'xhigh' } },
|
||||
'high'
|
||||
);
|
||||
assert.deepStrictEqual(out, { model: 'm1', reasoning: { effort: 'high' } });
|
||||
});
|
||||
|
||||
it('leaves non-object bodies unchanged', () => {
|
||||
assert.strictEqual(injectReasoningEffortIntoBody('x', 'medium'), 'x');
|
||||
});
|
||||
|
||||
it('leaves array bodies unchanged (not treated as record)', () => {
|
||||
const arr = [1, 2, 3];
|
||||
assert.deepStrictEqual(injectReasoningEffortIntoBody(arr, 'high'), arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model suffix aliasing', () => {
|
||||
it('lets callers use model-id suffixes without changing upstream model ids', () => {
|
||||
// We can't call the full proxy in unit tests, but we can validate the intent:
|
||||
// - injected effort is based on the suffix
|
||||
// - callers should set request.model to the base model upstream (proxy does this)
|
||||
const out = injectReasoningEffortIntoBody(
|
||||
{ model: 'gpt-5.2-codex', reasoning: { effort: 'medium' } },
|
||||
'xhigh'
|
||||
);
|
||||
assert.deepStrictEqual(out, {
|
||||
model: 'gpt-5.2-codex',
|
||||
reasoning: { effort: 'xhigh' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPathPrefix (remote mode)', () => {
|
||||
// Tests the path prefix stripping logic used in remote proxy mode
|
||||
// Remote CLIProxyAPI expects /v1/messages, but Claude sends /api/provider/codex/v1/messages
|
||||
|
||||
/**
|
||||
* Updated to match the version in codex-reasoning-proxy.ts with boundary check.
|
||||
* Only strips if prefix matches a complete path segment (not partial like /codex matching /codextra)
|
||||
*/
|
||||
function stripPathPrefix(path, prefix) {
|
||||
if (
|
||||
prefix &&
|
||||
path.startsWith(prefix) &&
|
||||
(path.length === prefix.length || path[prefix.length] === '/')
|
||||
) {
|
||||
let stripped = path.slice(prefix.length);
|
||||
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
|
||||
stripped = stripped.replace(/^\/+/, '/') || '/';
|
||||
if (!stripped.startsWith('/')) {
|
||||
stripped = '/' + stripped;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
it('strips /api/provider/codex prefix for remote mode', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('returns root path when prefix equals full path', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/');
|
||||
});
|
||||
|
||||
it('leaves path unchanged when prefix is undefined', () => {
|
||||
const result = stripPathPrefix('/v1/messages', undefined);
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('leaves path unchanged when prefix does not match', () => {
|
||||
const result = stripPathPrefix('/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles empty prefix', () => {
|
||||
const result = stripPathPrefix('/v1/messages', '');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles various provider prefixes', () => {
|
||||
// Gemini
|
||||
assert.strictEqual(
|
||||
stripPathPrefix('/api/provider/gemini/v1/chat', '/api/provider/gemini'),
|
||||
'/v1/chat'
|
||||
);
|
||||
// Agy
|
||||
assert.strictEqual(
|
||||
stripPathPrefix('/api/provider/agy/v1/messages', '/api/provider/agy'),
|
||||
'/v1/messages'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves query strings after stripping', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex/v1/messages?stream=true', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages?stream=true');
|
||||
});
|
||||
|
||||
// Edge cases for path normalization
|
||||
it('collapses double slashes after stripping', () => {
|
||||
// e.g., '/api/provider/codex//v1/messages' → '/v1/messages'
|
||||
const result = stripPathPrefix('/api/provider/codex//v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles multiple leading slashes after stripping', () => {
|
||||
// e.g., '/api/provider/codex///v1' → '/v1'
|
||||
const result = stripPathPrefix('/api/provider/codex///v1', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1');
|
||||
});
|
||||
|
||||
it('adds leading slash if missing after strip', () => {
|
||||
const result = stripPathPrefix('/prefix/suffix', '/prefix');
|
||||
assert.strictEqual(result, '/suffix');
|
||||
});
|
||||
|
||||
// Boundary check tests - prevent partial segment matching
|
||||
it('does NOT strip partial path segment matches', () => {
|
||||
// /codex should NOT match /codextra
|
||||
const result = stripPathPrefix('/api/provider/codextra/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/api/provider/codextra/v1/messages');
|
||||
});
|
||||
|
||||
it('does NOT strip when prefix matches but next char is not slash', () => {
|
||||
// /api should NOT match /api-v2
|
||||
const result = stripPathPrefix('/api-v2/messages', '/api');
|
||||
assert.strictEqual(result, '/api-v2/messages');
|
||||
});
|
||||
|
||||
it('strips when prefix matches exactly with slash boundary', () => {
|
||||
// /api/provider/codex should match /api/provider/codex/v1
|
||||
const result = stripPathPrefix('/api/provider/codex/v1', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Composite environment routing tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver';
|
||||
|
||||
const tiers = {
|
||||
opus: { provider: 'agy' as const, model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'gemini' as const, model: 'gemini-2.5-pro' },
|
||||
haiku: { provider: 'codex' as const, model: 'gpt-5.4-mini' },
|
||||
};
|
||||
|
||||
describe('buildClaudeEnvironment - composite remote routing', () => {
|
||||
it('uses remote base URL and auth token for direct remote composite mode', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'agy',
|
||||
useRemoteProxy: true,
|
||||
remoteConfig: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-auth-token',
|
||||
},
|
||||
localPort: 8318,
|
||||
verbose: false,
|
||||
isComposite: true,
|
||||
compositeTiers: tiers,
|
||||
compositeDefaultTier: 'sonnet',
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://remote.example.com:9443');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token');
|
||||
expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/');
|
||||
});
|
||||
|
||||
it('uses local tunnel endpoint for HTTPS remote composite mode', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'agy',
|
||||
useRemoteProxy: true,
|
||||
remoteConfig: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-auth-token',
|
||||
},
|
||||
httpsTunnel: {} as never,
|
||||
tunnelPort: 9911,
|
||||
localPort: 8318,
|
||||
verbose: false,
|
||||
isComposite: true,
|
||||
compositeTiers: tiers,
|
||||
compositeDefaultTier: 'sonnet',
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9911');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token');
|
||||
expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/');
|
||||
});
|
||||
|
||||
it('applies thinking override to resolved env models', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'gemini',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8318,
|
||||
verbose: false,
|
||||
thinkingOverride: 'high',
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toContain('gemini');
|
||||
// Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended.
|
||||
expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/);
|
||||
});
|
||||
|
||||
it('normalizes dotted Claude model IDs for antigravity tiers in composite mode', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'agy',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8318,
|
||||
verbose: false,
|
||||
isComposite: true,
|
||||
compositeTiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
haiku: { provider: 'codex', model: 'gpt-5.4-mini' },
|
||||
},
|
||||
compositeDefaultTier: 'opus',
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/);
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.4-mini(?:-medium)?$/);
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
/**
|
||||
* Composite Variant Fallback Tests
|
||||
*
|
||||
* Tests fallback detection and application for composite variants
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
isProviderError,
|
||||
detectFailedTier,
|
||||
PROVIDER_ERROR_PATTERNS,
|
||||
} from '../../../src/cliproxy/executor/retry-handler';
|
||||
import { applyFallback } from '../../../src/cliproxy/executor/env-resolver';
|
||||
import { CompositeTierConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
// ========================================
|
||||
// isProviderError
|
||||
// ========================================
|
||||
|
||||
describe('isProviderError', () => {
|
||||
it('should return false for exit code 0 (success)', () => {
|
||||
const result = isProviderError(0, 'Error: 500 Internal Server Error');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect 4xx error codes', () => {
|
||||
const stderr = 'Error: 401 Unauthorized';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 5xx error codes', () => {
|
||||
const stderr = 'Error: 503 Service Unavailable';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect overloaded errors', () => {
|
||||
const stderr = 'Provider is currently overloaded. Please try again later.';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded errors', () => {
|
||||
const stderr = 'quota has been exceeded for this account';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect connection refused errors', () => {
|
||||
const stderr = 'ECONNREFUSED 127.0.0.1:8317';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect rate limit errors', () => {
|
||||
const stderr = 'Rate limit exceeded. Please slow down.';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal user exit', () => {
|
||||
const stderr = 'User cancelled the operation';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty stderr with non-zero exit', () => {
|
||||
const result = isProviderError(1, '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive for error patterns', () => {
|
||||
const stderr = 'ERROR: OVERLOADED';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple error patterns in same stderr', () => {
|
||||
const stderr = 'Error: 429 rate limit exceeded';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectFailedTier
|
||||
// ========================================
|
||||
|
||||
describe('detectFailedTier', () => {
|
||||
const tiers: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
} = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
|
||||
};
|
||||
|
||||
it('should detect opus tier from model name in stderr', () => {
|
||||
const stderr = 'Error calling model claude-opus-4-6-thinking: 503 overloaded';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect sonnet tier from model name in stderr', () => {
|
||||
const stderr = 'Failed to connect: gemini-3-pro-preview returned 500';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should detect haiku tier from model name in stderr', () => {
|
||||
const stderr = 'gpt-4o-mini: rate limit exceeded';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
|
||||
it('should return null when no tier model found in stderr', () => {
|
||||
const stderr = 'Generic error with no model mention';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for empty stderr', () => {
|
||||
const result = detectFailedTier('', tiers);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should match first tier when multiple models mentioned', () => {
|
||||
const stderr = 'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('opus'); // First match
|
||||
});
|
||||
|
||||
it('should handle model names with suffixes (thinking budgets)', () => {
|
||||
const tiersWithBudget: typeof tiers = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(high)' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview(medium)' },
|
||||
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
|
||||
};
|
||||
|
||||
// Stderr might not include suffix
|
||||
const stderr = 'Error with claude-opus-4-6-thinking: timeout';
|
||||
const result = detectFailedTier(stderr, tiersWithBudget);
|
||||
// Should still match because model name is substring
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should strip complex thinking suffix with comma-separated params', () => {
|
||||
const tiersWithComplexBudget: typeof tiers = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(32768,extended)' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' },
|
||||
haiku: { provider: 'agy', model: 'claude-3-5-haiku' },
|
||||
};
|
||||
|
||||
// Stderr contains base model name without suffix
|
||||
const stderr = 'Error: claude-opus-4-6-thinking overloaded';
|
||||
const result = detectFailedTier(stderr, tiersWithComplexBudget);
|
||||
// Should match because regex strips (32768,extended) to get base name
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// applyFallback
|
||||
// ========================================
|
||||
|
||||
describe('applyFallback', () => {
|
||||
it('should update opus tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
// Should not modify other tiers
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should update sonnet tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'sonnet', {
|
||||
provider: 'codex',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4o');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should update haiku tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'haiku', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-flash-preview',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
});
|
||||
|
||||
it('should update ANTHROPIC_MODEL when failed tier is default tier', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Both tier model and default model should be updated
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
|
||||
it('should NOT update ANTHROPIC_MODEL when failed tier is not default tier', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', // Sonnet is default
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Opus tier should be updated but ANTHROPIC_MODEL should remain sonnet
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
});
|
||||
|
||||
it('should preserve other env vars', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
CUSTOM_VAR: 'custom_value',
|
||||
ANOTHER_VAR: '12345',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.CUSTOM_VAR).toBe('custom_value');
|
||||
expect(result.ANOTHER_VAR).toBe('12345');
|
||||
expect(result.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8318');
|
||||
});
|
||||
|
||||
it('should not mutate original env object', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const original = { ...env };
|
||||
applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Original env should be unchanged
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe(original.ANTHROPIC_DEFAULT_OPUS_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// PROVIDER_ERROR_PATTERNS
|
||||
// ========================================
|
||||
|
||||
describe('PROVIDER_ERROR_PATTERNS', () => {
|
||||
it('should export error pattern regexes', () => {
|
||||
expect(PROVIDER_ERROR_PATTERNS).toBeDefined();
|
||||
expect(Array.isArray(PROVIDER_ERROR_PATTERNS)).toBe(true);
|
||||
expect(PROVIDER_ERROR_PATTERNS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should match 4xx errors', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 401'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match 5xx errors', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 503'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match overloaded keyword', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('overloaded'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match quota exceeded', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('quota exceeded'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match ECONNREFUSED', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('ECONNREFUSED'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match rate limit', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('rate limit'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,586 +0,0 @@
|
||||
/**
|
||||
* Composite Variant Thinking Configuration Tests
|
||||
*
|
||||
* Tests per-tier thinking configuration for composite variants
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
applyThinkingConfig,
|
||||
applyThinkingSuffix,
|
||||
detectTierFromModel,
|
||||
getThinkingValueForTier,
|
||||
} from '../../../src/cliproxy/config/thinking-config';
|
||||
import { CLIProxyProvider } from '../../../src/cliproxy/types';
|
||||
|
||||
// ========================================
|
||||
// applyThinkingSuffix
|
||||
// ========================================
|
||||
|
||||
describe('applyThinkingSuffix', () => {
|
||||
it('should append level name to model', () => {
|
||||
const result = applyThinkingSuffix('gemini-3-pro-preview', 'high');
|
||||
expect(result).toBe('gemini-3-pro-preview(high)');
|
||||
});
|
||||
|
||||
it('should append numeric budget to model', () => {
|
||||
const result = applyThinkingSuffix('claude-opus-4-6-thinking', 8192);
|
||||
expect(result).toBe('claude-opus-4-6-thinking(8192)');
|
||||
});
|
||||
|
||||
it('should not append suffix if model already has one', () => {
|
||||
const result = applyThinkingSuffix('gemini-3-pro-preview(medium)', 'high');
|
||||
expect(result).toBe('gemini-3-pro-preview(medium)');
|
||||
});
|
||||
|
||||
it('should not append suffix if model already has numeric budget', () => {
|
||||
const result = applyThinkingSuffix('claude-opus-4(8192)', 16384);
|
||||
expect(result).toBe('claude-opus-4(8192)');
|
||||
});
|
||||
|
||||
it('should handle empty parentheses as NOT having suffix', () => {
|
||||
const result = applyThinkingSuffix('model()', 'high');
|
||||
// Empty parens don't match regex /\([^)]+\)$/ (requires non-empty content)
|
||||
expect(result).toBe('model()(high)');
|
||||
});
|
||||
|
||||
it('should append to model with hyphens', () => {
|
||||
const result = applyThinkingSuffix('gpt-4o-mini', 'low');
|
||||
expect(result).toBe('gpt-4o-mini(low)');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectTierFromModel
|
||||
// ========================================
|
||||
|
||||
describe('detectTierFromModel', () => {
|
||||
it('should detect opus from model name containing "opus"', () => {
|
||||
const result = detectTierFromModel('claude-opus-4-6-thinking');
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect sonnet from model name containing "sonnet"', () => {
|
||||
const result = detectTierFromModel('claude-sonnet-4-5-thinking');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should detect haiku from model name containing "haiku"', () => {
|
||||
const result = detectTierFromModel('claude-haiku-4-5-20251001');
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
|
||||
it('should default to sonnet for unknown model names', () => {
|
||||
const result = detectTierFromModel('gpt-4o-mini');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should default to sonnet for gemini models', () => {
|
||||
const result = detectTierFromModel('gemini-3-pro-preview');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const result = detectTierFromModel('CLAUDE-OPUS-4');
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect haiku even with uppercase', () => {
|
||||
const result = detectTierFromModel('CLAUDE-HAIKU-4');
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// getThinkingValueForTier (mock config)
|
||||
// ========================================
|
||||
|
||||
describe('getThinkingValueForTier', () => {
|
||||
// Note: This function reads from unified config, so we're testing the logic
|
||||
// For full integration tests, see composite-variant-service.test.ts
|
||||
|
||||
it('should return tier default when no provider override', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('high');
|
||||
});
|
||||
|
||||
it('should return provider-specific override when configured', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
provider_overrides: {
|
||||
gemini: {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'high',
|
||||
haiku: 'medium',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'gemini' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('xhigh');
|
||||
});
|
||||
|
||||
it('should fall back to tier default when provider has no override for tier', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
provider_overrides: {
|
||||
gemini: {
|
||||
opus: 'xhigh',
|
||||
// No sonnet override
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('sonnet', 'gemini' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('medium');
|
||||
});
|
||||
|
||||
it('should use centralized defaults when tier_defaults undefined', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
// tier_defaults is optional - centralized defaults kick in
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig);
|
||||
// DEFAULT_THINKING_TIER_DEFAULTS.opus = 'high'
|
||||
expect(result).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// applyThinkingConfig with compositeTierThinking
|
||||
// ========================================
|
||||
|
||||
describe('applyThinkingConfig - composite variant integration', () => {
|
||||
it('should apply per-tier thinking from compositeTierThinking parameter', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined, // No CLI override
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Budget-based tier models are normalized to provider-specific budget values.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
// Haiku doesn't support thinking per model-catalog — no suffix
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
// ANTHROPIC_MODEL is validated: 'medium' → 8192 for budget-type models
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
});
|
||||
|
||||
it('should apply partial per-tier thinking (only some tiers specified)', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
// sonnet and haiku will fall back to global config
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Budget-based tier models are normalized to provider-specific budget values.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
// Sonnet gets defaults from global config (mode=auto)
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toContain('claude-sonnet-4-5-thinking');
|
||||
// Haiku doesn't support thinking — stays unchanged
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should allow per-tier thinking to be "off"', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'off', // Explicitly disabled for haiku
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(24576)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
// Haiku should NOT have suffix because thinking is not supported for haiku
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should prioritize CLI override over per-tier thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
'minimal', // CLI override takes priority
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// All thinking-capable tiers should use the validated CLI override.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(1024)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(1024)');
|
||||
// Haiku doesn't support thinking — stays unchanged regardless of CLI override
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should disable all tier thinking when CLI override is explicitly off', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
'off',
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('treats off override aliases case-insensitively', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'OFF', {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'high',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
});
|
||||
|
||||
it('uses per-tier provider capability checks for mixed-provider composites', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'gemini' as CLIProxyProvider,
|
||||
undefined,
|
||||
{
|
||||
sonnet: 'high',
|
||||
},
|
||||
{
|
||||
sonnet: { provider: 'agy' as CLIProxyProvider },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(24576)');
|
||||
});
|
||||
|
||||
it('applies tier thinking even when default model does not support thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'codex' as CLIProxyProvider,
|
||||
undefined,
|
||||
{
|
||||
sonnet: 'high',
|
||||
},
|
||||
{
|
||||
sonnet: { provider: 'agy' as CLIProxyProvider },
|
||||
}
|
||||
);
|
||||
|
||||
// Default model provider/model do not support thinking.
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini');
|
||||
// Supported mixed-provider tier must still receive its configured thinking value.
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(24576)');
|
||||
});
|
||||
|
||||
it('handles dotted agy Claude IDs for thinking capability lookup', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high');
|
||||
|
||||
// Capability lookup should succeed for dotted agy IDs after normalization.
|
||||
// Tier values are normalized through budget validation too.
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(24576)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(24576)');
|
||||
// Haiku does not support thinking in agy catalog.
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5');
|
||||
});
|
||||
|
||||
it('should handle numeric budgets in per-tier thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: '32768',
|
||||
sonnet: '8192',
|
||||
haiku: '512',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Tier models keep numeric values after validation when the provider supports them.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
// Haiku doesn't support thinking — stays unchanged
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should not apply thinking when mode is off and no override', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
// Mock thinking config with mode=off by calling without thinkingOverride
|
||||
// and ensuring global config mode is 'off'
|
||||
// This test would require mocking getThinkingConfig()
|
||||
// For now, test the behavior when compositeTierThinking is undefined
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
undefined // No per-tier thinking
|
||||
);
|
||||
|
||||
// When mode=auto and no compositeTierThinking, defaults apply
|
||||
// This depends on global config - skipping full integration test here
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip thinking for models that do not support it', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-4o-mini', // Model that doesn't support thinking
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'codex' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Models should remain unchanged (no thinking suffix) because they don't support it
|
||||
// Note: This depends on supportsThinking() logic in model-catalog.ts
|
||||
// For models that don't support thinking, no suffix should be added
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
it('should update ANTHROPIC_MODEL when it matches a tier model', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', // Matches opus tier
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// ANTHROPIC_MODEL is validated: xhigh → 32768 for budget-type models
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
// Tier model keeps the validated budget value.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
});
|
||||
|
||||
it('should preserve models that already have thinking suffix', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking(high)', // Already has suffix
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking(high)',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium', // Won't override existing suffix
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Existing suffix on main model should be preserved.
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
// Other supported tiers still receive configured thinking after validation.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
});
|
||||
|
||||
it('rewrites legacy budget suffixes for claude level-based models', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-7(32768)',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max');
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)');
|
||||
});
|
||||
|
||||
it('should use codex effort suffix style when provider is codex', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'codex' as CLIProxyProvider,
|
||||
'xhigh', // Explicit CLI override
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-high');
|
||||
});
|
||||
|
||||
it('should normalize legacy codex parenthesized tier suffix to effort suffix format', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex(high)',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(envVars, 'codex' as CLIProxyProvider, 'high');
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-high');
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Unit tests for shared composite variant validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
validateCompositeDefaultTier,
|
||||
validateCompositeTiers,
|
||||
} from '../../../src/cliproxy/composite-validator';
|
||||
|
||||
const validTier = {
|
||||
provider: 'agy',
|
||||
model: 'claude-sonnet-4-6',
|
||||
};
|
||||
|
||||
describe('validateCompositeDefaultTier', () => {
|
||||
it('accepts valid tier names', () => {
|
||||
expect(validateCompositeDefaultTier('opus')).toBeNull();
|
||||
expect(validateCompositeDefaultTier('sonnet')).toBeNull();
|
||||
expect(validateCompositeDefaultTier('haiku')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid tier names', () => {
|
||||
const error = validateCompositeDefaultTier('invalid-tier');
|
||||
expect(error).toContain("Invalid default_tier 'invalid-tier'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCompositeTiers', () => {
|
||||
it('rejects missing required tiers in create mode', () => {
|
||||
const error = validateCompositeTiers(
|
||||
{
|
||||
opus: validTier,
|
||||
sonnet: validTier,
|
||||
},
|
||||
{ defaultTier: 'sonnet', requireAllTiers: true }
|
||||
);
|
||||
|
||||
expect(error).toContain("Missing required tier 'haiku'");
|
||||
});
|
||||
|
||||
it('rejects null tier objects', () => {
|
||||
const error = validateCompositeTiers(
|
||||
{
|
||||
opus: null,
|
||||
sonnet: validTier,
|
||||
haiku: validTier,
|
||||
},
|
||||
{ defaultTier: 'sonnet', requireAllTiers: true }
|
||||
);
|
||||
|
||||
expect(error).toContain("Invalid tier config for 'opus'");
|
||||
});
|
||||
|
||||
it('accepts partial updates in update mode', () => {
|
||||
const error = validateCompositeTiers(
|
||||
{
|
||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
},
|
||||
{ defaultTier: 'sonnet' }
|
||||
);
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects circular fallback definitions', () => {
|
||||
const error = validateCompositeTiers(
|
||||
{
|
||||
opus: {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
fallback: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
},
|
||||
},
|
||||
{ defaultTier: 'opus' }
|
||||
);
|
||||
|
||||
expect(error).toContain("Circular fallback in tier 'opus'");
|
||||
});
|
||||
|
||||
it('rejects denylisted agy 4.5 models', () => {
|
||||
const error = validateCompositeTiers(
|
||||
{
|
||||
opus: { provider: 'agy', model: 'claude-opus-4.5-thinking' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
haiku: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||
},
|
||||
{ defaultTier: 'opus', requireAllTiers: true }
|
||||
);
|
||||
|
||||
expect(error).toContain('denylist');
|
||||
});
|
||||
});
|
||||
@@ -1,689 +0,0 @@
|
||||
/**
|
||||
* Unit tests for composite variant service operations
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import {
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
} from '../../../src/cliproxy/services/variant-service';
|
||||
import {
|
||||
saveCompositeVariantUnified,
|
||||
listVariantsFromConfig,
|
||||
} from '../../../src/cliproxy/services/variant-config-adapter';
|
||||
import { CompositeVariantConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
describe('updateCompositeVariant', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for isolated config
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
// Use CCS_DIR (not CCS_HOME which appends .ccs)
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
// Create unified config file
|
||||
const configDir = tmpDir;
|
||||
const configPath = path.join(configDir, 'config.yaml');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
`,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original CCS_DIR
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should update partial tier config (only opus model)', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: 'cliproxy/composite-test.settings.json',
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
// Create dummy settings file to avoid deletion error
|
||||
const settingsDir = path.join(tmpDir, 'cliproxy');
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'composite-test.settings.json'),
|
||||
JSON.stringify({ env: {} }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Test: Update only opus tier
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-6'); // Unchanged
|
||||
expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged
|
||||
});
|
||||
|
||||
it('should update default tier', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: 'cliproxy/composite-test.settings.json',
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
// Create dummy settings file
|
||||
const settingsDir = path.join(tmpDir, 'cliproxy');
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'composite-test.settings.json'),
|
||||
JSON.stringify({ env: {} }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Test: Update default tier
|
||||
const result = updateCompositeVariant('test', {
|
||||
defaultTier: 'opus',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.default_tier).toBe('opus');
|
||||
expect(result.variant?.provider).toBe('gemini'); // Provider from opus tier
|
||||
});
|
||||
|
||||
it('should update all tiers', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: 'cliproxy/composite-test.settings.json',
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
// Create dummy settings file
|
||||
const settingsDir = path.join(tmpDir, 'cliproxy');
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'composite-test.settings.json'),
|
||||
JSON.stringify({ env: {} }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Test: Update all tiers
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'codex', model: 'codex-sonnet-4-5' },
|
||||
haiku: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.tiers?.opus.provider).toBe('agy');
|
||||
expect(result.variant?.tiers?.sonnet.provider).toBe('codex');
|
||||
expect(result.variant?.tiers?.haiku.provider).toBe('gemini');
|
||||
});
|
||||
|
||||
it('should preserve optional tier fields when updating provider/model only', () => {
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: {
|
||||
provider: 'agy',
|
||||
model: 'claude-opus-4-6-thinking',
|
||||
fallback: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||
thinking: 'xhigh',
|
||||
account: 'team-a',
|
||||
},
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: 'cliproxy/composite-test.settings.json',
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
const settingsDir = path.join(tmpDir, 'cliproxy');
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'composite-test.settings.json'),
|
||||
JSON.stringify({ env: {} }),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.tiers?.opus.provider).toBe('gemini');
|
||||
expect(result.variant?.tiers?.opus.model).toBe('gemini-2.5-pro');
|
||||
expect(result.variant?.tiers?.opus.fallback).toEqual({
|
||||
provider: 'gemini',
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
expect(result.variant?.tiers?.opus.thinking).toBe('xhigh');
|
||||
expect(result.variant?.tiers?.opus.account).toBe('team-a');
|
||||
});
|
||||
|
||||
it('should preserve existing custom settings path and custom settings fields', () => {
|
||||
const customSettingsPath = path.join(tmpDir, 'custom', 'my-composite.settings.json');
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: customSettingsPath,
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
fs.mkdirSync(path.dirname(customSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
customSettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
CUSTOM_ENV: 'preserve-this',
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] },
|
||||
customPreset: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6(high)' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.settings).toBe(customSettingsPath);
|
||||
|
||||
const updatedSettings = JSON.parse(fs.readFileSync(customSettingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
hooks: { PreToolUse: unknown[] };
|
||||
customPreset: boolean;
|
||||
};
|
||||
expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(high)');
|
||||
expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this');
|
||||
expect(updatedSettings.hooks.PreToolUse.length).toBe(1);
|
||||
expect(updatedSettings.customPreset).toBe(true);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test.settings).toBe(customSettingsPath);
|
||||
});
|
||||
|
||||
it('should return error when variant does not exist', () => {
|
||||
const result = updateCompositeVariant('nonexistent', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return error when variant is not composite type', () => {
|
||||
// Setup: Create non-composite variant in unified config
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
simple:
|
||||
provider: gemini
|
||||
settings: cliproxy/simple.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const result = updateCompositeVariant('simple', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a composite variant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCompositeVariant', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
`,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns validation error for missing required tier in create flow', () => {
|
||||
const result = createCompositeVariant({
|
||||
name: 'broken',
|
||||
defaultTier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
} as unknown as {
|
||||
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Missing required tier 'haiku'");
|
||||
});
|
||||
|
||||
it('returns validation error for null tier payload in create flow', () => {
|
||||
const result = createCompositeVariant({
|
||||
name: 'broken-null',
|
||||
defaultTier: 'sonnet',
|
||||
tiers: {
|
||||
opus: null,
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
} as unknown as {
|
||||
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Invalid tier config for 'opus'");
|
||||
});
|
||||
|
||||
it('stores composite settings path under active CCS_DIR', () => {
|
||||
const result = createCompositeVariant({
|
||||
name: 'scoped',
|
||||
defaultTier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
const expectedPath = path.join(tmpDir, 'composite-scoped.settings.json');
|
||||
expect(variants.scoped.settings).toBe(expectedPath);
|
||||
expect(result.settingsPath).toBe(expectedPath);
|
||||
expect(fs.existsSync(expectedPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCompositeVariantUnified', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
// Create unified config file
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
`,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should save composite variant to unified config', () => {
|
||||
const config: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: 'cliproxy/composite-test.settings.json',
|
||||
port: 8318,
|
||||
};
|
||||
|
||||
saveCompositeVariantUnified('test', config);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.type).toBe('composite');
|
||||
expect(variants.test.default_tier).toBe('sonnet');
|
||||
expect(variants.test.tiers?.opus.model).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVariantsFromConfig - composite variants', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should list composite variants with fallback field', () => {
|
||||
// Create unified config with composite variant that has fallback
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
fallback:
|
||||
provider: gemini
|
||||
model: gemini-3-pro-preview
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-6
|
||||
haiku:
|
||||
provider: agy
|
||||
model: claude-haiku-4-5-20251001
|
||||
settings: cliproxy/composite-test.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.hasFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('should list composite variants with thinking field', () => {
|
||||
// Create unified config with composite variant that has per-tier thinking
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
thinking: xhigh
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-6
|
||||
thinking: medium
|
||||
haiku:
|
||||
provider: agy
|
||||
model: claude-haiku-4-5-20251001
|
||||
thinking: off
|
||||
settings: cliproxy/composite-test.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.tiers?.opus.thinking).toBe('xhigh');
|
||||
expect(variants.test.tiers?.sonnet.thinking).toBe('medium');
|
||||
expect(variants.test.tiers?.haiku.thinking).toBe('off');
|
||||
});
|
||||
|
||||
it('should have hasFallback=false when no fallbacks configured', () => {
|
||||
// Create unified config with composite variant WITHOUT fallback
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-6
|
||||
haiku:
|
||||
provider: agy
|
||||
model: claude-haiku-4-5-20251001
|
||||
settings: cliproxy/composite-test.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.hasFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip malformed composite variant and keep valid variants', () => {
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
bad:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-6
|
||||
settings: cliproxy/bad.settings.json
|
||||
port: 8318
|
||||
good:
|
||||
provider: gemini
|
||||
settings: cliproxy/good.settings.json
|
||||
port: 8319
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.bad).toBeUndefined();
|
||||
expect(variants.good).toBeDefined();
|
||||
expect(variants.good.provider).toBe('gemini');
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* Config Generator Port Tests
|
||||
*
|
||||
* Tests for per-port configuration in config-generator.ts.
|
||||
* Verifies port-specific config files (config-{port}.yaml) and path generation.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-config-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getConfigPathForPort,
|
||||
getCliproxyConfigPath,
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configExists,
|
||||
deleteConfigForPort,
|
||||
deleteConfig,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Config Generator Port', function () {
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
// Clean up any existing config files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('config')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('config')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('getConfigPathForPort', function () {
|
||||
it('returns config.yaml for default port (8317)', function () {
|
||||
const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
const filename = path.basename(configPath);
|
||||
assert.ok(configPath.endsWith('config.yaml'), `Expected path to end with config.yaml but got: ${configPath}`);
|
||||
assert.strictEqual(filename, 'config.yaml', `Expected filename to be config.yaml but got: ${filename}`);
|
||||
});
|
||||
|
||||
it('returns config-{port}.yaml for variant ports', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = getConfigPathForPort(variantPort);
|
||||
assert.ok(configPath.endsWith(`config-${variantPort}.yaml`));
|
||||
});
|
||||
|
||||
it('example: port 8318 -> config-8318.yaml', function () {
|
||||
const configPath = getConfigPathForPort(8318);
|
||||
assert.ok(configPath.endsWith('config-8318.yaml'));
|
||||
});
|
||||
|
||||
it('example: port 8417 -> config-8417.yaml', function () {
|
||||
const configPath = getConfigPathForPort(8417);
|
||||
assert.ok(configPath.endsWith('config-8417.yaml'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliproxyConfigPath', function () {
|
||||
it('returns path for default port', function () {
|
||||
const configPath = getCliproxyConfigPath();
|
||||
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
assert.strictEqual(configPath, defaultPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConfig', function () {
|
||||
it('creates config-{port}.yaml for non-default port', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.ok(fs.existsSync(configPath), 'Should create config-8318.yaml');
|
||||
});
|
||||
|
||||
it('creates config.yaml for default port', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
const configPath = path.join(cliproxyDir, 'config.yaml');
|
||||
assert.ok(fs.existsSync(configPath), 'Should create config.yaml');
|
||||
});
|
||||
|
||||
it('only creates if file does not exist (idempotent)', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create config first time
|
||||
generateConfig('gemini', variantPort);
|
||||
const stat1 = fs.statSync(configPath);
|
||||
|
||||
// Wait a tiny bit and try again
|
||||
const originalContent = fs.readFileSync(configPath, 'utf-8');
|
||||
generateConfig('gemini', variantPort);
|
||||
const newContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Content should be the same (not regenerated)
|
||||
assert.strictEqual(originalContent, newContent);
|
||||
});
|
||||
|
||||
it('sets correct port in config content', function () {
|
||||
const variantPort = 8320;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Check that port is set correctly
|
||||
assert.ok(content.includes(`port: ${variantPort}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerateConfig', function () {
|
||||
it('regenerates config-{port}.yaml with updated version', function () {
|
||||
const variantPort = 8318;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create initial config
|
||||
generateConfig('gemini', variantPort);
|
||||
const originalContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Modify the file
|
||||
fs.writeFileSync(configPath, '# modified content\n' + originalContent);
|
||||
|
||||
// Regenerate
|
||||
regenerateConfig(variantPort);
|
||||
const newContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Should not contain our modification
|
||||
assert.ok(!newContent.includes('# modified content'));
|
||||
});
|
||||
|
||||
it('preserves port value from existing config', function () {
|
||||
const variantPort = 8325;
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
|
||||
// Create config with specific port
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
// Regenerate
|
||||
regenerateConfig(variantPort);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Port should still be 8325
|
||||
assert.ok(content.includes(`port: ${variantPort}`));
|
||||
});
|
||||
|
||||
it('honors an explicit config path override', function () {
|
||||
const variantPort = 8318;
|
||||
const overrideDir = path.join(testHome, '.ccs-scope-override', 'cliproxy');
|
||||
const overrideConfigPath = path.join(overrideDir, `config-${variantPort}.yaml`);
|
||||
const overrideAuthDir = path.join(overrideDir, 'auth');
|
||||
|
||||
regenerateConfig(variantPort, {
|
||||
configPath: overrideConfigPath,
|
||||
authDir: overrideAuthDir,
|
||||
});
|
||||
|
||||
assert.ok(fs.existsSync(overrideConfigPath), 'Should create config at the override path');
|
||||
assert.ok(fs.existsSync(overrideAuthDir), 'Should create auth dir at the override path');
|
||||
|
||||
const defaultConfigPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.strictEqual(fs.existsSync(defaultConfigPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfigForPort', function () {
|
||||
it('deletes config-{port}.yaml for specified port', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
|
||||
assert.ok(fs.existsSync(configPath));
|
||||
|
||||
deleteConfigForPort(variantPort);
|
||||
assert.strictEqual(fs.existsSync(configPath), false);
|
||||
});
|
||||
|
||||
it('does nothing if file does not exist', function () {
|
||||
// Should not throw
|
||||
deleteConfigForPort(8399);
|
||||
});
|
||||
|
||||
it('does not affect other port configs', function () {
|
||||
const port1 = 8318;
|
||||
const port2 = 8319;
|
||||
|
||||
generateConfig('gemini', port1);
|
||||
generateConfig('gemini', port2);
|
||||
|
||||
deleteConfigForPort(port1);
|
||||
|
||||
const config1Path = path.join(cliproxyDir, `config-${port1}.yaml`);
|
||||
const config2Path = path.join(cliproxyDir, `config-${port2}.yaml`);
|
||||
|
||||
assert.strictEqual(fs.existsSync(config1Path), false);
|
||||
assert.ok(fs.existsSync(config2Path));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfig', function () {
|
||||
it('deletes config.yaml for default port', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
const configPath = path.join(cliproxyDir, 'config.yaml');
|
||||
assert.ok(fs.existsSync(configPath));
|
||||
|
||||
deleteConfig();
|
||||
assert.strictEqual(fs.existsSync(configPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configExists', function () {
|
||||
it('returns true if config-{port}.yaml exists', function () {
|
||||
const variantPort = 8318;
|
||||
generateConfig('gemini', variantPort);
|
||||
|
||||
assert.strictEqual(configExists(variantPort), true);
|
||||
});
|
||||
|
||||
it('returns false if config-{port}.yaml missing', function () {
|
||||
const variantPort = 8399;
|
||||
assert.strictEqual(configExists(variantPort), false);
|
||||
});
|
||||
|
||||
it('returns true for default port config', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
assert.strictEqual(configExists(CLIPROXY_DEFAULT_PORT), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Port Configs', function () {
|
||||
it('can create configs for multiple ports simultaneously', function () {
|
||||
const ports = [8318, 8319, 8320, CLIPROXY_DEFAULT_PORT];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
}
|
||||
|
||||
// All should exist
|
||||
for (const port of ports) {
|
||||
assert.ok(configExists(port), `Config for port ${port} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
it('each port config has correct port value', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
const configPath = getConfigPathForPort(port);
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
assert.ok(content.includes(`port: ${port}`), `Config should have port: ${port}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Tests for migrateDeprecatedModelNames() in env-builder.ts
|
||||
* Validates gemini-claude-* → claude-* prefix migration logic
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
// We test the migration indirectly through getEffectiveEnvVars,
|
||||
// but also directly by importing the module and checking file output.
|
||||
|
||||
describe('migrateDeprecatedModelNames', () => {
|
||||
let tmpDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-migration-test-'));
|
||||
settingsPath = path.join(tmpDir, 'test.settings.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSettings(env: Record<string, string>) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, string> {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')).env;
|
||||
}
|
||||
|
||||
// Import the migration function dynamically to test it
|
||||
// Since it's not exported, we test via the settings file write behavior
|
||||
// by writing a settings file with deprecated names and loading via env-builder
|
||||
|
||||
it('replaces gemini-claude- prefix with claude- prefix', () => {
|
||||
writeSettings({
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
// Simulate migration logic inline (same as env-builder.ts)
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_PREFIX = 'claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
let migrated = false;
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
if (migrated) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
const result = readSettings();
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5');
|
||||
expect(migrated).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves suffixes like [1m] after migration', () => {
|
||||
writeSettings({
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_PREFIX = 'claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
let migrated = false;
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
if (migrated) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
const result = readSettings();
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking[1m]');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||
expect(migrated).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when model names already use claude- prefix', () => {
|
||||
writeSettings({
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
const originalContent = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_PREFIX = 'claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
let migrated = false;
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(migrated).toBe(false);
|
||||
// File should not be rewritten
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('skips non-string env values', () => {
|
||||
// Write raw JSON with a non-string value
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: null,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 123,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_PREFIX = 'claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const loaded = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
let migrated = false;
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = loaded.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
loaded.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(migrated).toBe(true);
|
||||
expect(loaded.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(loaded.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeNull();
|
||||
expect(loaded.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(123);
|
||||
expect(loaded.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('does not touch non-model env vars', () => {
|
||||
writeSettings({
|
||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MAX_TOKENS: '64000',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const UPSTREAM_PREFIX = 'claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-model vars should be untouched
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy');
|
||||
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||
expect(settings.env.ANTHROPIC_MAX_TOKENS).toBe('64000');
|
||||
});
|
||||
|
||||
it('handles Gemini model names (non-Claude) without modification', () => {
|
||||
writeSettings({
|
||||
ANTHROPIC_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||
});
|
||||
|
||||
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||
const MODEL_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
];
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
let migrated = false;
|
||||
for (const key of MODEL_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(migrated).toBe(false);
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
});
|
||||
@@ -1,492 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
ensureProviderSettings,
|
||||
getEffectiveEnvVars,
|
||||
} from '../../../src/cliproxy/config/env-builder';
|
||||
|
||||
interface EnvSettings {
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||
}
|
||||
|
||||
function writeSettings(
|
||||
settingsPath: string,
|
||||
env: EnvSettings,
|
||||
extras?: Record<string, unknown>
|
||||
): void {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env, ...(extras || {}) }, null, 2));
|
||||
}
|
||||
|
||||
describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
let tempHome: string;
|
||||
let settingsPath: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-'));
|
||||
settingsPath = path.join(tempHome, 'codex.settings.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('rewrites wrong local provider path to the requested provider', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317/api/provider/codex');
|
||||
});
|
||||
|
||||
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
|
||||
});
|
||||
|
||||
it('normalizes dotted Claude major.minor IDs for agy provider settings', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||
});
|
||||
|
||||
const env = getEffectiveEnvVars('agy', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('migrates denylisted AGY 4.5 model IDs to supported 4.6 fallbacks', () => {
|
||||
writeSettings(
|
||||
settingsPath,
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||
},
|
||||
{
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy-45',
|
||||
default: 'claude-opus-4.5',
|
||||
opus: 'claude-opus-4-5-thinking',
|
||||
sonnet: 'claude-sonnet-4.5-thinking',
|
||||
haiku: 'claude-sonnet-4-5',
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const env = getEffectiveEnvVars('agy', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(persisted.presets?.[0]?.default).toBe('claude-opus-4-6-thinking');
|
||||
expect(persisted.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking');
|
||||
expect(persisted.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6');
|
||||
expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('preserves codex preset effort suffixes while loading provider env vars', () => {
|
||||
writeSettings(
|
||||
settingsPath,
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||
},
|
||||
{
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy-codex',
|
||||
default: 'gpt-5.3-codex-xhigh',
|
||||
opus: 'gpt-5.3-codex-xhigh',
|
||||
sonnet: 'gpt-5.3-codex-high',
|
||||
haiku: 'gpt-5-mini-medium',
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium');
|
||||
});
|
||||
|
||||
it('migrates iflow placeholder model IDs to a supported default', () => {
|
||||
const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json');
|
||||
writeSettings(
|
||||
iflowSettingsPath,
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'iflow-default',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'iflow-default',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'iflow-default',
|
||||
},
|
||||
{
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy-iflow',
|
||||
default: 'iflow-default',
|
||||
opus: 'iflow-default',
|
||||
sonnet: 'iflow-default',
|
||||
haiku: 'iflow-default',
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(persisted.presets[0]?.default).toBe('qwen3-coder-plus');
|
||||
expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('qwen3-coder-plus');
|
||||
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('migrates stale iflow model aliases (including kimi-k2.5) to supported IDs', () => {
|
||||
const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json');
|
||||
writeSettings(
|
||||
iflowSettingsPath,
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'kimi-k2.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7',
|
||||
},
|
||||
{
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy-iflow',
|
||||
default: 'kimi-k2.5',
|
||||
opus: 'iflow-default',
|
||||
sonnet: 'deepseek-v3.2-chat',
|
||||
haiku: 'minimax-m2.5',
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('kimi-k2');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6');
|
||||
expect(persisted.presets[0]?.default).toBe('kimi-k2');
|
||||
expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('deepseek-v3.2');
|
||||
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('repairs existing provider settings files that are missing env keys', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
agySettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: 'WebSearch', hooks: [] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
ensureProviderSettings('agy');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
||||
env?: Record<string, string>;
|
||||
hooks?: Record<string, unknown>;
|
||||
};
|
||||
expect(repaired.hooks?.PreToolUse).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy');
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined();
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined();
|
||||
});
|
||||
|
||||
it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json');
|
||||
const providerSettingsPath = path.join(
|
||||
tempHome,
|
||||
'.ccs',
|
||||
'cliproxy',
|
||||
'providers',
|
||||
'cursor.settings.json'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(legacySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacySettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:20129',
|
||||
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
|
||||
ANTHROPIC_API_KEY: 'legacy-cursor-api-key',
|
||||
ANTHROPIC_MODEL: 'claude-4-sonnet',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-4-opus',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-4-sonnet',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cursor-small',
|
||||
ANTHROPIC_SMALL_FAST_MODEL: 'cursor-small',
|
||||
DISABLE_TELEMETRY: '1',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [{ matcher: 'Read', hooks: [] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const env = getEffectiveEnvVars('cursor');
|
||||
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-4-sonnet');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-small');
|
||||
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small');
|
||||
expect(env.DISABLE_TELEMETRY).toBe('1');
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
|
||||
const migrated = JSON.parse(fs.readFileSync(providerSettingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
hooks?: Record<string, unknown>;
|
||||
};
|
||||
expect(migrated.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor');
|
||||
expect(migrated.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||
expect(migrated.env.ANTHROPIC_MODEL).toBe('claude-4-sonnet');
|
||||
expect(migrated.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus');
|
||||
expect(migrated.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small');
|
||||
expect(migrated.env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(migrated.hooks?.PreToolUse).toBeDefined();
|
||||
});
|
||||
|
||||
it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
agySettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking(8192)',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy',
|
||||
default: 'claude-sonnet-4-6-thinking',
|
||||
opus: 'claude-opus-4.6-thinking',
|
||||
sonnet: 'claude-sonnet-4.6-thinking',
|
||||
haiku: 'claude-sonnet-4-5',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
ensureProviderSettings('agy');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
||||
env?: Record<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(repaired.presets?.[0]?.default).toBe('claude-sonnet-4-6');
|
||||
expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking');
|
||||
expect(repaired.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6');
|
||||
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
codexSettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy',
|
||||
default: 'gpt-5.3-codex-xhigh',
|
||||
opus: 'gpt-5.3-codex-xhigh',
|
||||
sonnet: 'gpt-5.3-codex-high',
|
||||
haiku: 'gpt-5.4-mini-medium',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
ensureProviderSettings('codex');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(codexSettingsPath, 'utf-8')) as {
|
||||
env?: Record<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high');
|
||||
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true });
|
||||
fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}');
|
||||
|
||||
ensureProviderSettings('agy');
|
||||
|
||||
const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined();
|
||||
|
||||
const backups = fs
|
||||
.readdirSync(path.dirname(agySettingsPath))
|
||||
.filter((file) => file.startsWith('agy.settings.json.corrupt-'));
|
||||
expect(backups.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,377 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildClaudeEnvironment,
|
||||
resolveCliproxyImageAnalysisEnv,
|
||||
} from '../../../src/cliproxy/executor/env-resolver';
|
||||
import type { ImageAnalysisStatus } from '../../../src/utils/hooks';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createCodexSettingsFile(models: {
|
||||
defaultModel: string;
|
||||
opusModel: string;
|
||||
sonnetModel: string;
|
||||
haikuModel: string;
|
||||
}): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-fallback-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const settingsPath = path.join(tempDir, 'codex-test.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
ANTHROPIC_MODEL: models.defaultModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
function createImageAnalysisStatus(
|
||||
overrides: Partial<ImageAnalysisStatus> = {}
|
||||
): ImageAnalysisStatus {
|
||||
return {
|
||||
enabled: true,
|
||||
supported: true,
|
||||
status: 'active',
|
||||
backendId: 'agy',
|
||||
backendDisplayName: 'Antigravity',
|
||||
model: 'gemini-2.5-pro',
|
||||
resolutionSource: 'cliproxy-provider',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
persistencePath: '/tmp/orq.settings.json',
|
||||
runtimePath: '/api/provider/agy',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'agy',
|
||||
authDisplayName: 'Antigravity',
|
||||
authReason: null,
|
||||
proxyReadiness: 'ready',
|
||||
proxyReason: 'Local CLIProxy service is reachable.',
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const tempDir = tempDirs.pop();
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes codex effort aliases when reasoning proxy is unavailable', () => {
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex-high',
|
||||
opusModel: 'gpt-5.3-codex-xhigh',
|
||||
sonnetModel: 'gpt-5.3-codex-high',
|
||||
haikuModel: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex(high)');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)');
|
||||
});
|
||||
|
||||
it('keeps codex effort aliases when reasoning proxy is active', () => {
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex-high',
|
||||
opusModel: 'gpt-5.3-codex-xhigh',
|
||||
sonnetModel: 'gpt-5.3-codex-high',
|
||||
haikuModel: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
codexReasoningPort: 9444,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex');
|
||||
});
|
||||
|
||||
it('propagates inherited CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
opusModel: 'gpt-5.3-codex',
|
||||
sonnetModel: 'gpt-5.3-codex',
|
||||
haikuModel: 'gpt-5-mini',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||
});
|
||||
|
||||
it('uses an execution-aware image analysis env override when provided', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'agy',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
verbose: false,
|
||||
imageAnalysisEnv: {
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(env.CCS_CURRENT_PROVIDER).toBe('');
|
||||
expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCliproxyImageAnalysisEnv', () => {
|
||||
it('falls back to native read when runtime status is not launchable', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
profileSettingsPath: '/tmp/orq.settings.json',
|
||||
proxyTarget: {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
},
|
||||
proxyReachable: true,
|
||||
},
|
||||
{
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
hasImageAnalysisProfileHook: () => true,
|
||||
hasImageAnalyzerHook: () => true,
|
||||
resolveImageAnalysisRuntimeStatus: async (context, _config, deps) => {
|
||||
expect(context.profileName).toBe('orq');
|
||||
expect(context.cliproxyProvider).toBe('agy');
|
||||
expect(context.hookInstalled).toBe(true);
|
||||
expect(context.sharedHookInstalled).toBe(true);
|
||||
expect(deps?.getProxyTarget?.().isRemote).toBe(false);
|
||||
return createImageAnalysisStatus({
|
||||
authReadiness: 'missing',
|
||||
authReason: 'Antigravity auth is missing.',
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: 'Antigravity auth is missing.',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
|
||||
expect(result.warning).toContain('Antigravity auth is missing.');
|
||||
expect(result.warning).toContain('native Read');
|
||||
});
|
||||
|
||||
it('strips browser env injected through settings before building Claude runtime env', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const settingsPath = path.join(tempDir, 'browser-env.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime',
|
||||
CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'agy',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined();
|
||||
expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined();
|
||||
expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps cliproxy image analysis active when the execution target is reachable', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
isComposite: true,
|
||||
proxyTarget: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-token',
|
||||
managementKey: 'remote-management-key',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
},
|
||||
proxyReachable: true,
|
||||
},
|
||||
{
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
hasImageAnalysisProfileHook: () => true,
|
||||
hasImageAnalyzerHook: () => true,
|
||||
resolveImageAnalysisRuntimeStatus: async (context, _config, deps) => {
|
||||
expect(context.isComposite).toBe(true);
|
||||
expect(deps?.getProxyTarget?.().host).toBe('remote.example.com');
|
||||
expect(deps?.getProxyTarget?.().managementKey).toBe('remote-management-key');
|
||||
expect(deps?.getProxyTarget?.().allowSelfSigned).toBe(true);
|
||||
return createImageAnalysisStatus({
|
||||
resolutionSource: 'cliproxy-composite',
|
||||
proxyReadiness: 'remote',
|
||||
proxyReason: 'Remote CLIProxy target remote.example.com:9443 is reachable.',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('1');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('pins local cliproxy image analysis to the resolved local API key', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
profileSettingsPath: '/tmp/orq.settings.json',
|
||||
proxyTarget: {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
},
|
||||
proxyReachable: true,
|
||||
},
|
||||
{
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
hasImageAnalysisProfileHook: () => true,
|
||||
hasImageAnalyzerHook: () => true,
|
||||
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
|
||||
getLocalRuntimeApiKey: () => 'local-runtime-token',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('routes remote HTTPS image analysis through the local tunnel when available', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
profileSettingsPath: '/tmp/orq.settings.json',
|
||||
proxyTarget: {
|
||||
host: 'remote.example.com',
|
||||
port: 9443,
|
||||
protocol: 'https',
|
||||
authToken: 'remote-token',
|
||||
managementKey: 'remote-management-key',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
},
|
||||
tunnelPort: 9911,
|
||||
proxyReachable: true,
|
||||
},
|
||||
{
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
hasImageAnalysisProfileHook: () => true,
|
||||
hasImageAnalyzerHook: () => true,
|
||||
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:9911');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
execClaudeWithCLIProxy,
|
||||
hasGitLabTokenLoginFlag,
|
||||
readOptionValue,
|
||||
} from '../../../src/cliproxy/executor/index';
|
||||
|
||||
describe('readOptionValue', () => {
|
||||
it('parses split-token option values', () => {
|
||||
expect(readOptionValue(['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], '--kiro-idc-start-url')).toEqual({
|
||||
present: true,
|
||||
value: 'https://d-123.awsapps.com/start',
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses equals-form option values', () => {
|
||||
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: 'device',
|
||||
missingValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks empty or missing values as invalid', () => {
|
||||
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
|
||||
present: true,
|
||||
value: undefined,
|
||||
missingValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats both GitLab token-login flags as enabled', () => {
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
|
||||
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
|
||||
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
let tmpHome = '';
|
||||
let fakeClaudePath = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-executor-'));
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exits cleanly when conflicting browser launch flags are provided', async () => {
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await execClaudeWithCLIProxy(fakeClaudePath, 'gemini', ['--browser', '--no-browser'], {});
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith('[X] Use either `--browser` or `--no-browser`, not both.');
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Unit tests for extended context configuration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import {
|
||||
applyExtendedContextSuffix,
|
||||
shouldApplyExtendedContext,
|
||||
applyExtendedContextConfig,
|
||||
} from '../../../src/cliproxy/config/extended-context-config';
|
||||
|
||||
describe('applyExtendedContextSuffix', () => {
|
||||
it('appends [1m] to model without suffix', () => {
|
||||
expect(applyExtendedContextSuffix('gemini-2.5-pro')).toBe('gemini-2.5-pro[1m]');
|
||||
});
|
||||
|
||||
it('does not double-append [1m]', () => {
|
||||
expect(applyExtendedContextSuffix('gemini-2.5-pro[1m]')).toBe('gemini-2.5-pro[1m]');
|
||||
});
|
||||
|
||||
it('handles model with thinking suffix', () => {
|
||||
expect(applyExtendedContextSuffix('gemini-2.5-pro(high)')).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('handles uppercase [1M] suffix', () => {
|
||||
expect(applyExtendedContextSuffix('gemini-2.5-pro[1M]')).toBe('gemini-2.5-pro[1M]');
|
||||
});
|
||||
|
||||
it('handles mixed case [1m] suffix', () => {
|
||||
expect(applyExtendedContextSuffix('gemini-2.5-pro[1M]')).toBe('gemini-2.5-pro[1M]');
|
||||
});
|
||||
|
||||
it('returns empty input unchanged', () => {
|
||||
expect(applyExtendedContextSuffix('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldApplyExtendedContext', () => {
|
||||
let consoleSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('auto behavior (no override)', () => {
|
||||
it('returns true for native Gemini models with extendedContext in catalog', () => {
|
||||
// gemini-2.5-pro is in catalog with extendedContext: true
|
||||
expect(shouldApplyExtendedContext('gemini', 'gemini-2.5-pro', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for gemini-3-pro-preview', () => {
|
||||
expect(shouldApplyExtendedContext('gemini', 'gemini-3-pro-preview', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for Claude models without explicit flag', () => {
|
||||
expect(shouldApplyExtendedContext('agy', 'claude-opus-4-5-thinking', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for Claude models without explicit flag', () => {
|
||||
expect(shouldApplyExtendedContext('claude', 'claude-opus-4-5-20251101', undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('handles case-insensitive Gemini prefix', () => {
|
||||
expect(shouldApplyExtendedContext('gemini', 'GEMINI-2.5-pro', undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit --1m override', () => {
|
||||
it('returns true for Claude models with explicit --1m flag', () => {
|
||||
expect(shouldApplyExtendedContext('claude', 'claude-opus-4-5-20251101', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false and warns for model not in catalog', () => {
|
||||
const result = shouldApplyExtendedContext('qwen', 'qwen-coder-plus', true);
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false and warns for model without extendedContext support', () => {
|
||||
const result = shouldApplyExtendedContext('claude', 'claude-haiku-4-5-20251001', true);
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit --no-1m override', () => {
|
||||
it('returns false even for native Gemini with explicit --no-1m', () => {
|
||||
expect(shouldApplyExtendedContext('gemini', 'gemini-2.5-pro', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyExtendedContextConfig', () => {
|
||||
it('applies suffix to ANTHROPIC_MODEL for Gemini', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro[1m]');
|
||||
});
|
||||
|
||||
it('applies suffix to tier models independently', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash', // Not in catalog
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro[1m]');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview[1m]');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro[1m]');
|
||||
// Haiku not in catalog, should be unchanged
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('does not apply suffix when --no-1m is set', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', false);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('applies suffix to Claude with explicit --1m', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-5-20251101',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'claude', true);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]');
|
||||
});
|
||||
|
||||
it('applies explicit Claude [1m] per tier and leaves unsupported Haiku plain', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
applyExtendedContextConfig(env, 'claude', true);
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('strips existing suffixes before catalog lookup', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro(high)',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('handles model IDs that already include both thinking and [1m] suffixes', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro(high)[1m]',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('handles empty env vars gracefully', () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips [1m] suffix from models that no longer support extended context', () => {
|
||||
// Simulates user who had [1m] in saved settings before support was removed
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking[1m]',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'agy', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
});
|
||||
|
||||
it('strips stale Claude [1m] from unsupported mappings while preserving compatible ones', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101[1m]',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929[1m]',
|
||||
};
|
||||
|
||||
applyExtendedContextConfig(env, 'claude', true);
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]');
|
||||
});
|
||||
|
||||
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview[1m]',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro[1m]',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', false);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
describe('Gemini refresh delegation', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
moduleVersion += 1;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => {
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}`
|
||||
);
|
||||
const { ensureTokenValid } = await import(
|
||||
`../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}`
|
||||
);
|
||||
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'gemini-delegated.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
type: 'gemini',
|
||||
email: 'delegated@example.com',
|
||||
project_id: 'delegated-project',
|
||||
token: {
|
||||
access_token: 'expired-access-token',
|
||||
refresh_token: 'still-present-refresh-token',
|
||||
expiry: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await ensureTokenValid('gemini');
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
refreshed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response';
|
||||
|
||||
describe('parseGitLabPatAuthResponse', () => {
|
||||
it('sanitizes HTML error bodies for non-ok responses', () => {
|
||||
const result = parseGitLabPatAuthResponse(
|
||||
false,
|
||||
502,
|
||||
'<html><body>gateway error</body></html>',
|
||||
'glpat-secret-token'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorMessage).toBe('[HTML error response omitted]');
|
||||
}
|
||||
});
|
||||
|
||||
it('sanitizes reflected PAT tokens from structured error payloads', () => {
|
||||
const result = parseGitLabPatAuthResponse(
|
||||
false,
|
||||
400,
|
||||
JSON.stringify({ status: 'error', error: 'Rejected token glpat-secret-token for login' }),
|
||||
'glpat-secret-token'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorMessage).toContain('[redacted]');
|
||||
expect(result.errorMessage).not.toContain('glpat-secret-token');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects ok responses whose body is not valid success JSON', () => {
|
||||
const result = parseGitLabPatAuthResponse(
|
||||
true,
|
||||
200,
|
||||
'upstream temporarily unavailable',
|
||||
'glpat-secret-token'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorMessage).toContain('upstream temporarily unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts explicit success payloads', () => {
|
||||
const result = parseGitLabPatAuthResponse(
|
||||
true,
|
||||
200,
|
||||
JSON.stringify({ status: 'ok', saved_path: '/tmp/gitlab.json' }),
|
||||
'glpat-secret-token'
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.payload.status).toBe('ok');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { shouldStartHttpsTunnel } from '../../../src/cliproxy/executor/https-tunnel-policy';
|
||||
|
||||
describe('HTTPS tunnel startup policy', () => {
|
||||
it('skips the tunnel for single-provider Codex remote HTTPS launches', () => {
|
||||
expect(
|
||||
shouldStartHttpsTunnel({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: true,
|
||||
protocol: 'https',
|
||||
host: 'remote.example.test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the tunnel for non-Codex remote HTTPS launches', () => {
|
||||
expect(
|
||||
shouldStartHttpsTunnel({
|
||||
provider: 'gemini',
|
||||
useRemoteProxy: true,
|
||||
protocol: 'https',
|
||||
host: 'remote.example.test',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the tunnel for composite HTTPS launches', () => {
|
||||
expect(
|
||||
shouldStartHttpsTunnel({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: true,
|
||||
protocol: 'https',
|
||||
host: 'remote.example.test',
|
||||
isComposite: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start a tunnel without a remote HTTPS target', () => {
|
||||
expect(
|
||||
shouldStartHttpsTunnel({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: true,
|
||||
protocol: 'http',
|
||||
host: 'remote.example.test',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStartHttpsTunnel({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
protocol: 'https',
|
||||
host: 'remote.example.test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,468 +0,0 @@
|
||||
/**
|
||||
* HTTPS Tunnel Proxy Tests
|
||||
*
|
||||
* Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI.
|
||||
* Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
|
||||
// Import the class under test
|
||||
import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy';
|
||||
|
||||
describe('HttpsTunnelProxy', () => {
|
||||
let tunnel: HttpsTunnelProxy | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up tunnel
|
||||
if (tunnel) {
|
||||
tunnel.stop();
|
||||
tunnel = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should apply default values for optional config', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
// The proxy should be created without error
|
||||
expect(tunnel).toBeDefined();
|
||||
expect(tunnel.getPort()).toBeNull(); // Not started yet
|
||||
});
|
||||
|
||||
it('should accept custom config values', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'custom.host.com',
|
||||
remotePort: 8443,
|
||||
authToken: 'test-token',
|
||||
timeoutMs: 30000,
|
||||
verbose: true,
|
||||
allowSelfSigned: true,
|
||||
});
|
||||
|
||||
expect(tunnel).toBeDefined();
|
||||
});
|
||||
|
||||
// Hostname validation tests
|
||||
it('should throw error for empty hostname', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: '' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with protocol prefix', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'https://example.com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with spaces', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'example .com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with invalid characters', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'example@com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should accept valid hostnames', () => {
|
||||
// Standard domain
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'example.com' })).not.toThrow();
|
||||
// Subdomain
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'api.example.com' })).not.toThrow();
|
||||
// With dashes
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'my-api.example-site.com' })).not.toThrow();
|
||||
// IP-like
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: '192.168.1.1' })).not.toThrow();
|
||||
// Localhost
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'localhost' })).not.toThrow();
|
||||
// Single char hostname
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'a' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start()', () => {
|
||||
it('should start server and return valid port', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
|
||||
it('should return same port on subsequent start() calls', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port1 = await tunnel.start();
|
||||
const port2 = await tunnel.start();
|
||||
|
||||
expect(port1).toBe(port2);
|
||||
});
|
||||
|
||||
it('should bind to localhost only (127.0.0.1)', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
const net = await import('net');
|
||||
const socket = new net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('error', reject);
|
||||
socket.connect(port, '127.0.0.1', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
it('should clear port after stop', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
await tunnel.start();
|
||||
expect(tunnel.getPort()).not.toBeNull();
|
||||
|
||||
tunnel.stop();
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should be idempotent (safe to call multiple times)', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
// Stop without start - should not throw
|
||||
tunnel.stop();
|
||||
tunnel.stop();
|
||||
tunnel.stop();
|
||||
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow restart after stop', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port1 = await tunnel.start();
|
||||
tunnel.stop();
|
||||
|
||||
const port2 = await tunnel.start();
|
||||
|
||||
expect(port2).toBeGreaterThan(0);
|
||||
// Ports may or may not be the same depending on OS port reuse
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPort()', () => {
|
||||
it('should return null before start', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid port after start', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
await tunnel.start();
|
||||
|
||||
const port = tunnel.getPort();
|
||||
expect(port).not.toBeNull();
|
||||
expect(port).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildForwardHeaders (Authorization injection)', () => {
|
||||
// We test this indirectly through the proxy behavior
|
||||
// The buildForwardHeaders method is private, so we verify via integration
|
||||
|
||||
it('should forward existing Authorization header', async () => {
|
||||
// This test requires a mock HTTPS server
|
||||
// For now, we document the expected behavior
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'example.com',
|
||||
authToken: 'fallback-token',
|
||||
};
|
||||
|
||||
tunnel = new HttpsTunnelProxy(config);
|
||||
await tunnel.start();
|
||||
|
||||
// The tunnel should:
|
||||
// 1. Forward 'Authorization' header if present in request
|
||||
// 2. Inject 'Authorization: Bearer fallback-token' if not present
|
||||
expect(tunnel.getPort()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hop-by-hop headers filtering', () => {
|
||||
// RFC 7230 hop-by-hop headers should be filtered
|
||||
const hopByHopHeaders = [
|
||||
'host',
|
||||
'connection',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
];
|
||||
|
||||
it('should define all RFC 7230 hop-by-hop headers for filtering', () => {
|
||||
// Document expected filtered headers
|
||||
expect(hopByHopHeaders).toContain('connection');
|
||||
expect(hopByHopHeaders).toContain('transfer-encoding');
|
||||
expect(hopByHopHeaders).toContain('keep-alive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection tracking', () => {
|
||||
it('should track active connections for cleanup', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Create a connection
|
||||
const net = await import('net');
|
||||
const socket = new net.Socket();
|
||||
|
||||
// Use a promise that resolves when connection is established
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.on('error', reject);
|
||||
socket.connect(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
// Give the server time to register the connection
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Stop should forcefully close all connections and the server
|
||||
tunnel.stop();
|
||||
|
||||
// Verify stop() was called successfully (server is null after stop)
|
||||
// The key behavior is that stop() destroys server-side sockets
|
||||
// and clears activeConnections - we verify by checking getPort() returns null
|
||||
expect(tunnel.getPort()).toBe(null);
|
||||
|
||||
// Clean up client socket
|
||||
socket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle client disconnect (premature close)', async () => {
|
||||
// Test that tunnel handles client-side aborts gracefully
|
||||
// No need for mock - just test tunnel's resilience to client errors
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Make request and immediately abort
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
req.write('{}');
|
||||
req.end();
|
||||
|
||||
// Abort after a short delay to trigger premature close
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
req.destroy();
|
||||
|
||||
// Give time for error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Tunnel should still be operational
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
|
||||
it('should handle client request error', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
remotePort: 443,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Create socket and send malformed request
|
||||
const net = await import('net');
|
||||
const socket = new net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.connect(port, '127.0.0.1', () => {
|
||||
// Send partial HTTP request then destroy
|
||||
socket.write('POST /test HTTP/1.1\r\n');
|
||||
socket.write('Content-Length: 100\r\n\r\n'); // Claim 100 bytes
|
||||
socket.write('partial'); // Only send partial body
|
||||
socket.destroy(); // Trigger error
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', () => resolve()); // Ignore socket errors
|
||||
});
|
||||
|
||||
// Give time for error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Tunnel should still be operational
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
|
||||
it('should handle upstream connection errors gracefully', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'nonexistent.invalid.host',
|
||||
remotePort: 12345,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Make request to tunnel - should get 502 error
|
||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
resolve
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000);
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
});
|
||||
|
||||
it('should return JSON error response', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'nonexistent.invalid.host',
|
||||
remotePort: 12345,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
const body = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000);
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(body);
|
||||
expect(parsed).toHaveProperty('error');
|
||||
expect(typeof parsed.error).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config interface', () => {
|
||||
it('should export HttpsTunnelConfig type', () => {
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'test.com',
|
||||
remotePort: 443,
|
||||
authToken: 'token',
|
||||
timeoutMs: 60000,
|
||||
verbose: false,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
|
||||
expect(config.remoteHost).toBe('test.com');
|
||||
expect(config.remotePort).toBe(443);
|
||||
});
|
||||
|
||||
it('should allow minimal config with only remoteHost', () => {
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'minimal.test.com',
|
||||
};
|
||||
|
||||
expect(config.remoteHost).toBe('minimal.test.com');
|
||||
expect(config.remotePort).toBeUndefined();
|
||||
expect(config.authToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpsTunnelProxy stripPathPrefix integration with CodexReasoningProxy', () => {
|
||||
// Document the integration pattern between HttpsTunnelProxy and CodexReasoningProxy
|
||||
// In remote mode, the path flow is:
|
||||
// Claude → CodexReasoningProxy → HttpsTunnelProxy → Remote CLIProxyAPI
|
||||
//
|
||||
// CodexReasoningProxy strips /api/provider/codex prefix before forwarding
|
||||
// HttpsTunnelProxy then tunnels HTTP→HTTPS to remote server
|
||||
|
||||
it('should document path transformation for remote mode', () => {
|
||||
// Remote CLIProxyAPI expects: /v1/messages
|
||||
// Local CLIProxy expects: /api/provider/codex/v1/messages
|
||||
//
|
||||
// CodexReasoningProxy.stripPathPrefix handles this transformation:
|
||||
// Input: /api/provider/codex/v1/messages
|
||||
// Output: /v1/messages
|
||||
const inputPath = '/api/provider/codex/v1/messages';
|
||||
const prefix = '/api/provider/codex';
|
||||
const expectedOutput = '/v1/messages';
|
||||
|
||||
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
|
||||
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle root path after prefix strip', () => {
|
||||
const inputPath = '/api/provider/codex';
|
||||
const prefix = '/api/provider/codex';
|
||||
|
||||
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
|
||||
|
||||
expect(result).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Tests for Local Config Sync
|
||||
* Verifies YAML section replacement logic and sync behavior.
|
||||
*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Local Config Sync', () => {
|
||||
const localConfigSync = require('../../../dist/cliproxy/sync/local-config-sync');
|
||||
|
||||
describe('syncToLocalConfig', () => {
|
||||
it('returns success with zero count when no profiles', () => {
|
||||
// If no profiles are configured, sync should succeed with 0 count
|
||||
const result = localConfigSync.syncToLocalConfig();
|
||||
assert.ok(typeof result === 'object');
|
||||
assert.ok(typeof result.success === 'boolean');
|
||||
assert.ok(typeof result.syncedCount === 'number');
|
||||
assert.ok(typeof result.configPath === 'string');
|
||||
});
|
||||
|
||||
it('returns error when config file not found', () => {
|
||||
// Set CCS_HOME to temp dir without config
|
||||
const originalHome = process.env.CCS_HOME;
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
|
||||
try {
|
||||
process.env.CCS_HOME = tempDir;
|
||||
// Create a mock settings file to trigger sync attempt
|
||||
const settingsPath = path.join(tempDir, 'test.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-real-key',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Need to reload module to pick up new CCS_HOME
|
||||
// For this test, we just verify the function doesn't throw
|
||||
const result = localConfigSync.syncToLocalConfig();
|
||||
assert.ok(typeof result === 'object');
|
||||
} finally {
|
||||
process.env.CCS_HOME = originalHome;
|
||||
fs.rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalSyncStatus', () => {
|
||||
it('returns status object with required fields', () => {
|
||||
const result = localConfigSync.getLocalSyncStatus();
|
||||
|
||||
assert.ok(typeof result === 'object');
|
||||
assert.ok(typeof result.configExists === 'boolean');
|
||||
assert.ok(typeof result.configPath === 'string');
|
||||
assert.ok(typeof result.currentKeyCount === 'number');
|
||||
assert.ok(typeof result.syncableProfileCount === 'number');
|
||||
});
|
||||
|
||||
it('returns non-negative counts', () => {
|
||||
const result = localConfigSync.getLocalSyncStatus();
|
||||
assert.ok(result.currentKeyCount >= 0);
|
||||
assert.ok(result.syncableProfileCount >= 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YAML section replacement', () => {
|
||||
// Test the internal logic through integration
|
||||
// The replaceSectionInYaml function is not exported, so we test via syncToLocalConfig
|
||||
|
||||
it('preserves file permissions on sync', () => {
|
||||
// Verify atomic write behavior by checking syncToLocalConfig doesn't throw
|
||||
const result = localConfigSync.syncToLocalConfig();
|
||||
assert.ok(typeof result === 'object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Known Limitations (Documented)', () => {
|
||||
/**
|
||||
* YAML section replacement has known edge cases:
|
||||
* - Comments between section key and first item may be lost
|
||||
* - Multi-document YAML files (with ---) not supported
|
||||
* - Inline comments on section key line may be lost
|
||||
*
|
||||
* These are acceptable trade-offs for the simple section-based replacement.
|
||||
* Users with complex YAML structures should edit manually.
|
||||
*/
|
||||
it('documents known YAML edge cases', () => {
|
||||
// This test serves as documentation - no assertions needed
|
||||
assert.ok(true, 'Edge cases documented in test comments');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
interface MockAuthFileRecord {
|
||||
account_type?: string;
|
||||
name: string;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface DownloadResponse {
|
||||
body: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, status = 200): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function installFetchMock(options: {
|
||||
files: MockAuthFileRecord[];
|
||||
downloads?: Record<string, DownloadResponse | Error>;
|
||||
patchStatuses?: Record<string, number>;
|
||||
}) {
|
||||
const requests: Array<{ url: string; method: string; body: string | undefined }> = [];
|
||||
|
||||
global.fetch = mock((input: string | URL | Request, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = init?.method ?? 'GET';
|
||||
const body = typeof init?.body === 'string' ? init.body : undefined;
|
||||
|
||||
requests.push({ url, method, body });
|
||||
|
||||
if (url.endsWith('/v0/management/auth-files') && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse({ files: options.files }));
|
||||
}
|
||||
|
||||
if (url.includes('/v0/management/auth-files/download') && method === 'GET') {
|
||||
const name = new URL(url).searchParams.get('name');
|
||||
if (!name) {
|
||||
return Promise.reject(new Error(`Missing auth file name for ${url}`));
|
||||
}
|
||||
|
||||
const response = options.downloads?.[name];
|
||||
if (response instanceof Error) {
|
||||
return Promise.reject(response);
|
||||
}
|
||||
if (!response) {
|
||||
return Promise.resolve(textResponse('{}', 404));
|
||||
}
|
||||
|
||||
return Promise.resolve(textResponse(response.body, response.status ?? 200));
|
||||
}
|
||||
|
||||
if (url.endsWith('/v0/management/auth-files/fields') && method === 'PATCH') {
|
||||
const payload = JSON.parse(body ?? '{}') as { name?: string };
|
||||
const status = (payload.name && options.patchStatuses?.[payload.name]) ?? 200;
|
||||
return Promise.resolve(jsonResponse({ ok: status < 400 }, status));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch ${method} ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('ensureManagedModelPrefixes', () => {
|
||||
it('patches missing managed prefixes for matching oauth providers', async () => {
|
||||
const requests = installFetchMock({
|
||||
files: [
|
||||
{ account_type: 'oauth', name: 'gemini-main', provider: 'gemini' },
|
||||
{ account_type: 'oauth', name: 'agy-main', provider: 'antigravity' },
|
||||
{ account_type: 'apikey', name: 'gemini-key', provider: 'gemini' },
|
||||
],
|
||||
downloads: {
|
||||
'gemini-main': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) },
|
||||
'agy-main': { body: JSON.stringify({ prefix: null, provider: 'antigravity' }) },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureManagedModelPrefixes(['gemini']);
|
||||
|
||||
expect(result).toEqual({ checked: 1, updated: 1 });
|
||||
|
||||
const patchRequest = requests.find(
|
||||
(request) =>
|
||||
request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH'
|
||||
);
|
||||
expect(patchRequest?.body).toBe(JSON.stringify({ name: 'gemini-main', prefix: 'gcli' }));
|
||||
|
||||
const downloadedNames = requests
|
||||
.filter((request) => request.url.includes('/v0/management/auth-files/download'))
|
||||
.map((request) => new URL(request.url).searchParams.get('name'));
|
||||
expect(downloadedNames).toEqual(['gemini-main']);
|
||||
});
|
||||
|
||||
it('returns immediately when called for providers without managed prefixes', async () => {
|
||||
const fetchMock = mock(() => Promise.reject(new Error('should not fetch')));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const result = await ensureManagedModelPrefixes(['codex']);
|
||||
|
||||
expect(result).toEqual({ checked: 0, updated: 0 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips files that already have the managed prefix or a different custom prefix', async () => {
|
||||
const requests = installFetchMock({
|
||||
files: [
|
||||
{ account_type: 'oauth', name: 'gemini-managed', provider: 'gemini' },
|
||||
{ account_type: 'oauth', name: 'gemini-custom', provider: 'gemini' },
|
||||
],
|
||||
downloads: {
|
||||
'gemini-managed': { body: JSON.stringify({ prefix: 'gcli', provider: 'gemini' }) },
|
||||
'gemini-custom': { body: JSON.stringify({ prefix: 'team-a', provider: 'gemini' }) },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureManagedModelPrefixes(['gemini']);
|
||||
|
||||
expect(result).toEqual({ checked: 2, updated: 0 });
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips patching when the downloaded auth file belongs to a different provider', async () => {
|
||||
const requests = installFetchMock({
|
||||
files: [{ account_type: 'oauth', name: 'gemini-shadowed', provider: 'gemini' }],
|
||||
downloads: {
|
||||
'gemini-shadowed': {
|
||||
body: JSON.stringify({ prefix: null, provider: 'antigravity' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureManagedModelPrefixes(['gemini']);
|
||||
|
||||
expect(result).toEqual({ checked: 1, updated: 0 });
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('swallows read and patch failures so later files can still be repaired', async () => {
|
||||
const requests = installFetchMock({
|
||||
files: [
|
||||
{ account_type: 'oauth', name: 'gemini-unreadable', provider: 'gemini' },
|
||||
{ account_type: 'oauth', name: 'gemini-patch-fails', provider: 'gemini' },
|
||||
{ account_type: 'oauth', name: 'gemini-success', provider: 'gemini' },
|
||||
],
|
||||
downloads: {
|
||||
'gemini-unreadable': new Error('network down'),
|
||||
'gemini-patch-fails': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) },
|
||||
'gemini-success': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) },
|
||||
},
|
||||
patchStatuses: {
|
||||
'gemini-patch-fails': 500,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureManagedModelPrefixes(['gemini']);
|
||||
|
||||
expect(result).toEqual({ checked: 3, updated: 1 });
|
||||
|
||||
const patchPayloads = requests
|
||||
.filter(
|
||||
(request) =>
|
||||
request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH'
|
||||
)
|
||||
.map((request) => request.body);
|
||||
expect(patchPayloads).toEqual([
|
||||
JSON.stringify({ name: 'gemini-patch-fails', prefix: 'gcli' }),
|
||||
JSON.stringify({ name: 'gemini-success', prefix: 'gcli' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,578 +0,0 @@
|
||||
/**
|
||||
* Unit tests for management-api-client module
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
|
||||
import { ManagementApiClient } from '../../../src/cliproxy/management-api-client';
|
||||
import type {
|
||||
ManagementClientConfig,
|
||||
ManagementHealthStatus,
|
||||
ClaudeKey,
|
||||
} from '../../../src/cliproxy/management-api-types';
|
||||
|
||||
describe('management-api-client', () => {
|
||||
describe('ManagementApiClient', () => {
|
||||
let config: ManagementClientConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
managementKey: 'test-management-key-12345',
|
||||
timeout: 2000,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create client with provided config', () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
expect(client).toBeDefined();
|
||||
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('should use default timeout if not provided', () => {
|
||||
const configWithoutTimeout = { ...config };
|
||||
delete configWithoutTimeout.timeout;
|
||||
const client = new ManagementApiClient(configWithoutTimeout);
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaseUrl', () => {
|
||||
it('should construct HTTP URL with port', () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('should construct HTTPS URL with custom port', () => {
|
||||
const httpsConfig = { ...config, protocol: 'https' as const, port: 8443 };
|
||||
const client = new ManagementApiClient(httpsConfig);
|
||||
expect(client.getBaseUrl()).toBe('https://localhost:8443');
|
||||
});
|
||||
|
||||
it('should omit standard HTTP port 80', () => {
|
||||
const configPort80 = { ...config, port: 80 };
|
||||
const client = new ManagementApiClient(configPort80);
|
||||
expect(client.getBaseUrl()).toBe('http://localhost');
|
||||
});
|
||||
|
||||
it('should omit standard HTTPS port 443', () => {
|
||||
const configPort443 = { ...config, protocol: 'https' as const, port: 443 };
|
||||
const client = new ManagementApiClient(configPort443);
|
||||
expect(client.getBaseUrl()).toBe('https://localhost');
|
||||
});
|
||||
|
||||
it('should use default port 8317 for HTTP when port is undefined', () => {
|
||||
const configNoPort = { ...config };
|
||||
delete configNoPort.port;
|
||||
const client = new ManagementApiClient(configNoPort);
|
||||
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('should use default port 443 for HTTPS when port is undefined', () => {
|
||||
const configNoPort = { ...config, protocol: 'https' as const };
|
||||
delete configNoPort.port;
|
||||
const client = new ManagementApiClient(configNoPort);
|
||||
expect(client.getBaseUrl()).toBe('https://localhost');
|
||||
});
|
||||
|
||||
it('should warn and fall back when configured port is invalid', () => {
|
||||
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const client = new ManagementApiClient({ ...config, port: 99999 });
|
||||
expect(client.getBaseUrl()).toBe('http://localhost:8317');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[management-api-client] Invalid port "99999", using default 8317'
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing strategy helpers', () => {
|
||||
it('reads the routing strategy from the management endpoint', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ strategy: 'fill-first' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
|
||||
const strategy = await client.getRoutingStrategy();
|
||||
|
||||
expect(strategy).toBe('fill-first');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('writes the routing strategy using the expected payload shape', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
);
|
||||
global.fetch = fetchMock as typeof global.fetch;
|
||||
|
||||
const strategy = await client.putRoutingStrategy('round-robin');
|
||||
|
||||
expect(strategy).toBe('round-robin');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8317/v0/management/routing/strategy',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: 'round-robin' }),
|
||||
})
|
||||
);
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe('error code mapping', () => {
|
||||
it('should map ENOTFOUND to DNS_FAILED', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOTFOUND';
|
||||
|
||||
// Test via health check which uses mapErrorToCode internally
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(error));
|
||||
|
||||
client.health().then((status: ManagementHealthStatus) => {
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('DNS_FAILED');
|
||||
});
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map ECONNREFUSED to CONNECTION_REFUSED', () => {
|
||||
const error = new Error('connect ECONNREFUSED') as NodeJS.ErrnoException;
|
||||
error.code = 'ECONNREFUSED';
|
||||
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(error));
|
||||
|
||||
client.health().then((status: ManagementHealthStatus) => {
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('CONNECTION_REFUSED');
|
||||
});
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map ETIMEDOUT to TIMEOUT', () => {
|
||||
const error = new Error('request timeout') as NodeJS.ErrnoException;
|
||||
error.code = 'ETIMEDOUT';
|
||||
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(error));
|
||||
|
||||
client.health().then((status: ManagementHealthStatus) => {
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('TIMEOUT');
|
||||
});
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map ENETUNREACH to NETWORK_UNREACHABLE', () => {
|
||||
const error = new Error('network unreachable') as NodeJS.ErrnoException;
|
||||
error.code = 'ENETUNREACH';
|
||||
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(error));
|
||||
|
||||
client.health().then((status: ManagementHealthStatus) => {
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('NETWORK_UNREACHABLE');
|
||||
});
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map 401 status to AUTH_FAILED', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('AUTH_FAILED');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map 403 status to AUTH_FAILED', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('AUTH_FAILED');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map 404 status to NOT_FOUND', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('NOT_FOUND');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map 400 status to BAD_REQUEST', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map 500+ status to SERVER_ERROR', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('SERVER_ERROR');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should map unknown errors to UNKNOWN', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(new Error('Something weird happened')));
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('UNKNOWN');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication header', () => {
|
||||
it('should include Bearer token in Authorization header', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock((url: string, options?: RequestInit) => {
|
||||
if (options?.headers) {
|
||||
capturedHeaders = options.headers as Record<string, string>;
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.getClaudeKeys();
|
||||
expect(capturedHeaders['Authorization']).toBe('Bearer test-management-key-12345');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should mask management key in logs/errors', () => {
|
||||
// Management key should never appear in plain text in error messages
|
||||
const sensitiveKey = 'super-secret-key-abc123';
|
||||
const clientConfig = { ...config, managementKey: sensitiveKey };
|
||||
const client = new ManagementApiClient(clientConfig);
|
||||
|
||||
// The key should be used internally but not exposed
|
||||
expect(client.getBaseUrl()).not.toContain(sensitiveKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout handling', () => {
|
||||
it('should respect custom timeout value', () => {
|
||||
const customTimeout = 10000;
|
||||
const configWithTimeout = { ...config, timeout: customTimeout };
|
||||
const client = new ManagementApiClient(configWithTimeout);
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should abort request on timeout', async () => {
|
||||
const client = new ManagementApiClient({ ...config, timeout: 100 });
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
}, 150);
|
||||
})
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.errorCode).toBe('TIMEOUT');
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe('self-signed certificate option', () => {
|
||||
it('should use fetch for HTTP regardless of allowSelfSigned', async () => {
|
||||
const client = new ManagementApiClient({ ...config, allowSelfSigned: true });
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalled = false;
|
||||
global.fetch = mock(() => {
|
||||
fetchCalled = true;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.getClaudeKeys();
|
||||
expect(fetchCalled).toBe(true);
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should use https module for HTTPS with allowSelfSigned', () => {
|
||||
const httpsConfig = {
|
||||
...config,
|
||||
protocol: 'https' as const,
|
||||
allowSelfSigned: true,
|
||||
};
|
||||
const client = new ManagementApiClient(httpsConfig);
|
||||
expect(client).toBeDefined();
|
||||
// Actual HTTPS request would use native https module with rejectUnauthorized: false
|
||||
});
|
||||
|
||||
it('should use fetch for HTTPS without allowSelfSigned', async () => {
|
||||
const httpsConfig = {
|
||||
...config,
|
||||
protocol: 'https' as const,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
const client = new ManagementApiClient(httpsConfig);
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalled = false;
|
||||
global.fetch = mock(() => {
|
||||
fetchCalled = true;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.getClaudeKeys();
|
||||
expect(fetchCalled).toBe(true);
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check', () => {
|
||||
it('should return healthy status with version info', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'x-cpa-version': '1.2.3',
|
||||
'x-cpa-commit': 'abc123',
|
||||
}),
|
||||
text: () => Promise.resolve('{"claude-api-key":[]}'),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(true);
|
||||
expect(status.version).toBe('1.2.3');
|
||||
expect(status.commit).toBe('abc123');
|
||||
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should return unhealthy status on error', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() => Promise.reject(new Error('Connection failed')));
|
||||
|
||||
const status = await client.health();
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.error).toBeDefined();
|
||||
expect(status.errorCode).toBeDefined();
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CRUD operations', () => {
|
||||
it('should get claude keys', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const mockKeys: ClaudeKey[] = [{ 'api-key': 'sk-test-123', prefix: 'glm-' }];
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(JSON.stringify({ 'claude-api-key': mockKeys })),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const keys = await client.getClaudeKeys();
|
||||
expect(keys).toEqual(mockKeys);
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should put claude keys', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const mockKeys: ClaudeKey[] = [{ 'api-key': 'sk-test-456', prefix: 'kimi-' }];
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let requestBody: string | undefined;
|
||||
global.fetch = mock((url: string, options?: RequestInit) => {
|
||||
requestBody = options?.body as string;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.putClaudeKeys(mockKeys);
|
||||
expect(requestBody).toBe(JSON.stringify(mockKeys));
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should patch claude key', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const patch = {
|
||||
index: 0,
|
||||
value: { prefix: 'updated-' },
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let requestBody: string | undefined;
|
||||
global.fetch = mock((url: string, options?: RequestInit) => {
|
||||
requestBody = options?.body as string;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.patchClaudeKey(patch);
|
||||
expect(requestBody).toBe(JSON.stringify(patch));
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should delete claude key', async () => {
|
||||
const client = new ManagementApiClient(config);
|
||||
const apiKey = 'sk-test-to-delete';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
let requestUrl = '';
|
||||
global.fetch = mock((url: string) => {
|
||||
requestUrl = url;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await client.deleteClaudeKey(apiKey);
|
||||
expect(requestUrl).toContain(encodeURIComponent(apiKey));
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog';
|
||||
import {
|
||||
PROVIDER_TO_CHANNEL,
|
||||
SYNCABLE_PROVIDERS,
|
||||
mergeCatalog,
|
||||
} from '../../../src/cliproxy/catalog-cache';
|
||||
|
||||
describe('model-catalog compatibility lookups', () => {
|
||||
it('finds agy Claude models using dotted major.minor IDs', () => {
|
||||
const dottedThinking = findModel('agy', 'claude-opus-4.6-thinking');
|
||||
const dottedNonThinking = findModel('agy', 'claude-sonnet-4.6');
|
||||
|
||||
expect(dottedThinking?.id).toBe('claude-opus-4-6-thinking');
|
||||
expect(dottedNonThinking?.id).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('maps legacy agy 4.5 IDs to canonical 4.6 catalog models', () => {
|
||||
const legacyOpusThinking = findModel('agy', 'claude-opus-4.5-thinking');
|
||||
const legacySonnetThinking = findModel('agy', 'claude-sonnet-4.5-thinking');
|
||||
const legacySonnet = findModel('agy', 'claude-sonnet-4.5');
|
||||
|
||||
expect(legacyOpusThinking?.id).toBe('claude-opus-4-6-thinking');
|
||||
expect(legacySonnetThinking?.id).toBe('claude-sonnet-4-6');
|
||||
expect(legacySonnet?.id).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('supports thinking checks for dotted agy model IDs', () => {
|
||||
expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true);
|
||||
expect(supportsThinking('agy', 'claude-sonnet-4.6')).toBe(true);
|
||||
expect(supportsThinking('agy', 'claude-opus-4.5-thinking')).toBe(true);
|
||||
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps legacy sonnet 4.6 thinking aliases to canonical agy model', () => {
|
||||
const dottedLegacy = findModel('agy', 'claude-sonnet-4.6-thinking');
|
||||
const hyphenLegacy = findModel('agy', 'claude-sonnet-4-6-thinking');
|
||||
|
||||
expect(dottedLegacy?.id).toBe('claude-sonnet-4-6');
|
||||
expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('maps all dashboard providers to upstream catalog channels', () => {
|
||||
expect(SYNCABLE_PROVIDERS).toContain('qwen');
|
||||
expect(SYNCABLE_PROVIDERS).toContain('iflow');
|
||||
expect(SYNCABLE_PROVIDERS).toContain('kiro');
|
||||
expect(SYNCABLE_PROVIDERS).toContain('ghcp');
|
||||
expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot');
|
||||
});
|
||||
|
||||
it('does not re-add stale static-only models when live catalog data is present', () => {
|
||||
const catalog = mergeCatalog('gemini', [
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
display_name: 'Gemini 2.5 Pro',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
|
||||
});
|
||||
|
||||
it('preserves static maxLevel when live thinking metadata omits it', () => {
|
||||
const catalog = mergeCatalog('claude', [
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
display_name: 'Claude Opus 4.7',
|
||||
thinking: {
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
dynamic_allowed: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(catalog?.models[0]?.thinking).toMatchObject({
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
dynamicAllowed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,463 +0,0 @@
|
||||
/**
|
||||
* Tests for CLIProxy Model Catalog
|
||||
* Verifies model database structure and lookup functions
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
describe('Model Catalog', () => {
|
||||
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
|
||||
|
||||
describe('MODEL_CATALOG structure', () => {
|
||||
it('contains AGY provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.agy, 'Should have agy provider');
|
||||
assert.strictEqual(MODEL_CATALOG.agy.provider, 'agy');
|
||||
assert.strictEqual(MODEL_CATALOG.agy.displayName, 'Antigravity');
|
||||
});
|
||||
|
||||
it('contains Gemini provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.gemini, 'Should have gemini provider');
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.provider, 'gemini');
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini');
|
||||
});
|
||||
|
||||
it('contains Codex provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.codex, 'Should have codex provider');
|
||||
assert.strictEqual(MODEL_CATALOG.codex.provider, 'codex');
|
||||
assert.strictEqual(MODEL_CATALOG.codex.displayName, 'Copilot Codex');
|
||||
});
|
||||
|
||||
it('does not contain qwen (not configurable)', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.qwen, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kimi models', () => {
|
||||
it('contains Kimi provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.kimi, 'Should have kimi provider');
|
||||
assert.strictEqual(MODEL_CATALOG.kimi.provider, 'kimi');
|
||||
assert.strictEqual(MODEL_CATALOG.kimi.displayName, 'Kimi (Moonshot)');
|
||||
});
|
||||
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.kimi.defaultModel, 'kimi-k2.5');
|
||||
});
|
||||
|
||||
it('includes K2.5, K2 Thinking, K2', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.kimi.models.map((m) => m.id);
|
||||
assert(ids.includes('kimi-k2.5'), 'Should include kimi-k2.5');
|
||||
assert(ids.includes('kimi-k2-thinking'), 'Should include kimi-k2-thinking');
|
||||
assert(ids.includes('kimi-k2'), 'Should include kimi-k2');
|
||||
});
|
||||
|
||||
it('has 3 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.kimi.models.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGY models', () => {
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'claude-opus-4-6-thinking');
|
||||
});
|
||||
|
||||
it('includes Claude Opus 4.6 Thinking', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking');
|
||||
assert(opus, 'Should include Claude Opus 4.6 Thinking');
|
||||
assert.strictEqual(opus.name, 'Claude Opus 4.6 Thinking');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
assert(sonnet, 'Should include Claude Sonnet 4.6');
|
||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6');
|
||||
});
|
||||
|
||||
it('does not include deprecated Claude 4.5 AGY models', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.agy.models.map((m) => m.id);
|
||||
assert.strictEqual(ids.includes('claude-opus-4-5-thinking'), false);
|
||||
assert.strictEqual(ids.includes('claude-sonnet-4-5-thinking'), false);
|
||||
assert.strictEqual(ids.includes('claude-sonnet-4-5'), false);
|
||||
});
|
||||
|
||||
it('includes Gemini 3.1 Pro High via Antigravity', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-high');
|
||||
assert(gem3, 'Should include Gemini 3.1 Pro High');
|
||||
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro High');
|
||||
// AGY models are all free - no paid tier
|
||||
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('includes Gemini 3.1 Pro Low via Antigravity', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-low');
|
||||
assert(gem3, 'Should include Gemini 3.1 Pro Low');
|
||||
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro Low');
|
||||
// AGY models are all free - no paid tier
|
||||
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('includes Gemini Flash via Antigravity', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const flash = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-1-flash-preview');
|
||||
assert(flash, 'Should include Gemini Flash');
|
||||
assert.strictEqual(flash.name, 'Gemini Flash');
|
||||
assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier');
|
||||
});
|
||||
|
||||
it('has 5 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.models.length, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude models', () => {
|
||||
it('contains Claude provider catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert(MODEL_CATALOG.claude, 'Should have Claude provider');
|
||||
assert.strictEqual(MODEL_CATALOG.claude.provider, 'claude');
|
||||
assert.strictEqual(MODEL_CATALOG.claude.displayName, 'Claude (Anthropic)');
|
||||
});
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnet = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
assert(sonnet, 'Should include Claude Sonnet 4.6');
|
||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6');
|
||||
});
|
||||
|
||||
it('includes Claude Opus 4.7 with provider-specific settings', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7');
|
||||
assert(opus47, 'Should include Claude Opus 4.7');
|
||||
assert.strictEqual(opus47.name, 'Claude Opus 4.7');
|
||||
// Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens
|
||||
// is rejected by the Anthropic API with 400. Extended (1M) context is available.
|
||||
assert.strictEqual(opus47.thinking.type, 'levels');
|
||||
assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.strictEqual(opus47.thinking.maxLevel, 'max');
|
||||
assert.strictEqual(opus47.extendedContext, true);
|
||||
});
|
||||
|
||||
it('retains previous 4.5 snapshot models for explicit selection', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
|
||||
assert(ids.includes('claude-opus-4-5-20251101'));
|
||||
assert(ids.includes('claude-sonnet-4-5-20250929'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini models', () => {
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('includes Gemini 3.1 Pro with pro tier', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3.1-pro-preview');
|
||||
assert(gem3, 'Should include Gemini 3.1 Pro');
|
||||
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro');
|
||||
assert.strictEqual(gem3.tier, 'pro');
|
||||
});
|
||||
|
||||
it('includes Gemini 2.5 Pro without tier (free)', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const gem25 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-2.5-pro');
|
||||
assert(gem25, 'Should include Gemini 2.5 Pro');
|
||||
assert.strictEqual(gem25.name, 'Gemini 2.5 Pro');
|
||||
assert.strictEqual(gem25.tier, undefined);
|
||||
});
|
||||
|
||||
it('includes Gemini Flash with pro tier', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const flash = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-flash-preview');
|
||||
assert(flash, 'Should include Gemini Flash');
|
||||
assert.strictEqual(flash.name, 'Gemini Flash');
|
||||
assert.strictEqual(flash.tier, 'pro');
|
||||
});
|
||||
|
||||
it('has 3 models total', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex models', () => {
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.codex.defaultModel, 'gpt-5.4');
|
||||
});
|
||||
|
||||
it('advertises the current official Codex model set', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.codex.models.map((m) => m.id);
|
||||
assert.deepStrictEqual(ids, [
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-spark',
|
||||
'gpt-5.2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsModelConfig', () => {
|
||||
it('returns true for agy', () => {
|
||||
const { supportsModelConfig } = modelCatalog;
|
||||
assert.strictEqual(supportsModelConfig('agy'), true);
|
||||
});
|
||||
|
||||
it('returns true for gemini', () => {
|
||||
const { supportsModelConfig } = modelCatalog;
|
||||
assert.strictEqual(supportsModelConfig('gemini'), true);
|
||||
});
|
||||
|
||||
it('returns true for codex', () => {
|
||||
const { supportsModelConfig } = modelCatalog;
|
||||
assert.strictEqual(supportsModelConfig('codex'), true);
|
||||
});
|
||||
|
||||
it('returns true for kimi', () => {
|
||||
const { supportsModelConfig } = modelCatalog;
|
||||
assert.strictEqual(supportsModelConfig('kimi'), true);
|
||||
});
|
||||
|
||||
it('returns false for qwen', () => {
|
||||
const { supportsModelConfig } = modelCatalog;
|
||||
assert.strictEqual(supportsModelConfig('qwen'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderCatalog', () => {
|
||||
it('returns catalog for agy', () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('agy');
|
||||
assert(catalog, 'Should return catalog');
|
||||
assert.strictEqual(catalog.provider, 'agy');
|
||||
assert(Array.isArray(catalog.models));
|
||||
});
|
||||
|
||||
it('returns catalog for gemini', () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('gemini');
|
||||
assert(catalog, 'Should return catalog');
|
||||
assert.strictEqual(catalog.provider, 'gemini');
|
||||
});
|
||||
|
||||
it('returns catalog for codex', () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('codex');
|
||||
assert(catalog, 'Should return catalog');
|
||||
assert.strictEqual(catalog.provider, 'codex');
|
||||
assert(Array.isArray(catalog.models));
|
||||
});
|
||||
|
||||
it('returns catalog for kimi', () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('kimi');
|
||||
assert(catalog, 'Should return catalog');
|
||||
assert.strictEqual(catalog.provider, 'kimi');
|
||||
assert(Array.isArray(catalog.models));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findModel', () => {
|
||||
it('finds Claude Opus 4.7 in claude', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('claude', 'claude-opus-4-7');
|
||||
assert(model, 'Should find model');
|
||||
assert.strictEqual(model.name, 'Claude Opus 4.7');
|
||||
});
|
||||
|
||||
it('finds Claude Opus 4.6 Thinking in agy', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('agy', 'claude-opus-4-6-thinking');
|
||||
assert(model, 'Should find model');
|
||||
assert.strictEqual(model.name, 'Claude Opus 4.6 Thinking');
|
||||
});
|
||||
|
||||
it('finds Gemini 2.5 Pro in gemini', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('gemini', 'gemini-2.5-pro');
|
||||
assert(model, 'Should find model');
|
||||
assert.strictEqual(model.name, 'Gemini 2.5 Pro');
|
||||
});
|
||||
|
||||
it('maps legacy AGY 4.5 IDs to supported 4.6 models', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const legacyOpus = findModel('agy', 'claude-opus-4-5-thinking');
|
||||
const legacySonnetThinking = findModel('agy', 'claude-sonnet-4-5-thinking');
|
||||
const legacySonnet = findModel('agy', 'claude-sonnet-4-5');
|
||||
assert.strictEqual(legacyOpus?.id, 'claude-opus-4-6-thinking');
|
||||
assert.strictEqual(legacySonnetThinking?.id, 'claude-sonnet-4-6');
|
||||
assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('maps legacy Antigravity Gemini Pro aliases onto the current 3.1 high/low models', () => {
|
||||
const { findModel, getSuggestedReplacementModel } = modelCatalog;
|
||||
const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview');
|
||||
const legacyAgyGeminiLow = findModel('agy', 'gemini-3-pro-low');
|
||||
const legacyGemini = findModel('gemini', 'gemini-3-pro-preview');
|
||||
const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview');
|
||||
|
||||
assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-high');
|
||||
assert.strictEqual(legacyAgyGeminiLow?.id, 'gemini-3.1-pro-low');
|
||||
assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview');
|
||||
assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview');
|
||||
assert.strictEqual(
|
||||
getSuggestedReplacementModel('gemini', 'gemini-3.1-pro-preview'),
|
||||
'gemini-2.5-pro'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the next supported model when the default is excluded', () => {
|
||||
const { getSuggestedReplacementModel } = modelCatalog;
|
||||
|
||||
expect(getSuggestedReplacementModel('agy', 'claude-opus-4-6-thinking')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(getSuggestedReplacementModel('agy')).toBe('claude-opus-4-6-thinking');
|
||||
});
|
||||
|
||||
it('returns undefined when no provider catalog exists', () => {
|
||||
const { getSuggestedReplacementModel } = modelCatalog;
|
||||
|
||||
expect(getSuggestedReplacementModel('qwen')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for unknown model', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('agy', 'unknown-model');
|
||||
assert.strictEqual(model, undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for unsupported provider', () => {
|
||||
const { findModel } = modelCatalog;
|
||||
const model = findModel('codex', 'any-model');
|
||||
assert.strictEqual(model, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model entry structure', () => {
|
||||
it('all models have required fields', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
|
||||
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
|
||||
for (const model of catalog.models) {
|
||||
assert(model.id, `Model in ${provider} should have id`);
|
||||
assert(typeof model.id === 'string', `Model id should be string`);
|
||||
assert(model.name, `Model ${model.id} should have name`);
|
||||
assert(typeof model.name === 'string', `Model name should be string`);
|
||||
// tier is optional
|
||||
if (model.tier !== undefined) {
|
||||
assert(['free', 'pro', 'ultra'].includes(model.tier), `Invalid tier: ${model.tier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('all model IDs are unique within provider', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
|
||||
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
|
||||
const ids = catalog.models.map((m) => m.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.strictEqual(ids.length, uniqueIds.size, `Duplicate model IDs in ${provider}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('default model exists in models array', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
|
||||
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
|
||||
const defaultExists = catalog.models.some((m) => m.id === catalog.defaultModel);
|
||||
assert(defaultExists, `Default model ${catalog.defaultModel} not found in ${provider}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thinking models ordering', () => {
|
||||
it('Claude Opus 4.6 Thinking is not deprecated', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking');
|
||||
assert(opus, 'Should include Claude Opus 4.6 Thinking');
|
||||
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
|
||||
});
|
||||
|
||||
it('Claude Sonnet 4.6 is not deprecated', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnetThinking = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6');
|
||||
assert(sonnetThinking, 'Should include Claude Sonnet 4.6');
|
||||
assert.strictEqual(
|
||||
sonnetThinking.deprecated,
|
||||
undefined,
|
||||
'Should not be marked as deprecated'
|
||||
);
|
||||
});
|
||||
|
||||
it('thinking models are at the top of the list', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const models = MODEL_CATALOG.agy.models;
|
||||
|
||||
// Find indices of thinking models
|
||||
const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-6-thinking');
|
||||
const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6');
|
||||
|
||||
// Find indices of the remaining non-Claude model
|
||||
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-high');
|
||||
|
||||
// Primary Claude choices should appear ahead of Gemini fallback.
|
||||
assert(opusIdx < geminiIdx, 'Opus should be above Gemini');
|
||||
assert(sonnetThinkingIdx < geminiIdx, 'Sonnet should be above Gemini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isModelDeprecated', () => {
|
||||
it('returns false for active AGY Claude models', () => {
|
||||
const { isModelDeprecated } = modelCatalog;
|
||||
assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-6-thinking'), false);
|
||||
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-6'), false);
|
||||
});
|
||||
|
||||
it('returns false for non-deprecated models', () => {
|
||||
const { isModelDeprecated } = modelCatalog;
|
||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false);
|
||||
});
|
||||
|
||||
it('returns false for unknown models', () => {
|
||||
const { isModelDeprecated } = modelCatalog;
|
||||
assert.strictEqual(isModelDeprecated('agy', 'unknown-model'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelDeprecationReason', () => {
|
||||
it('returns undefined for active AGY Claude models', () => {
|
||||
const { getModelDeprecationReason } = modelCatalog;
|
||||
assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-6-thinking'), undefined);
|
||||
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-6'), undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for non-deprecated models', () => {
|
||||
const { getModelDeprecationReason } = modelCatalog;
|
||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-3-pro-preview'), undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Tests for CLIProxy Model Configuration
|
||||
* Verifies model configuration logic and settings management
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('Model Config', () => {
|
||||
const modelConfig = require('../../../dist/cliproxy/model-config');
|
||||
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
|
||||
|
||||
describe('hasUserSettings', () => {
|
||||
it('returns false when settings file does not exist', () => {
|
||||
// Ensure we're checking a non-existent path
|
||||
const { hasUserSettings } = modelConfig;
|
||||
// Since we can't easily mock getCcsDir, we test the function logic
|
||||
// by checking it doesn't throw
|
||||
const result = hasUserSettings('agy');
|
||||
assert(typeof result === 'boolean', 'Should return boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentModel', () => {
|
||||
it('returns undefined when settings file does not exist', () => {
|
||||
const { getCurrentModel } = modelConfig;
|
||||
// Test with a provider that likely has no settings in test env
|
||||
const result = getCurrentModel('agy');
|
||||
// Result depends on whether ~/.ccs/agy.settings.json exists
|
||||
// Just verify it returns string or undefined
|
||||
assert(
|
||||
result === undefined || typeof result === 'string',
|
||||
'Should return string or undefined'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureProviderModel', () => {
|
||||
it('returns false for unsupported provider (qwen)', async () => {
|
||||
const { configureProviderModel } = modelConfig;
|
||||
const result = await configureProviderModel('qwen', true);
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
// Note: Full interactive tests require mocking stdin
|
||||
// These are smoke tests to verify basic logic
|
||||
});
|
||||
|
||||
describe('showCurrentConfig', () => {
|
||||
it('does not throw for agy provider', async () => {
|
||||
const { showCurrentConfig } = modelConfig;
|
||||
// Just verify it doesn't throw (now async)
|
||||
await assert.doesNotReject(async () => showCurrentConfig('agy'));
|
||||
});
|
||||
|
||||
it('does not throw for gemini provider', async () => {
|
||||
const { showCurrentConfig } = modelConfig;
|
||||
await assert.doesNotReject(async () => showCurrentConfig('gemini'));
|
||||
});
|
||||
|
||||
it('does not throw for unsupported provider', async () => {
|
||||
const { showCurrentConfig } = modelConfig;
|
||||
await assert.doesNotReject(async () => showCurrentConfig('codex'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model catalog integration', () => {
|
||||
it('configureProviderModel uses correct catalog for agy', async () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('agy');
|
||||
|
||||
// Verify catalog structure is what configureProviderModel expects
|
||||
assert(catalog.models, 'Should have models array');
|
||||
assert(catalog.defaultModel, 'Should have defaultModel');
|
||||
assert(catalog.displayName, 'Should have displayName');
|
||||
});
|
||||
|
||||
it('configureProviderModel uses correct catalog for gemini', async () => {
|
||||
const { getProviderCatalog } = modelCatalog;
|
||||
const catalog = getProviderCatalog('gemini');
|
||||
|
||||
assert(catalog.models, 'Should have models array');
|
||||
assert(catalog.defaultModel, 'Should have defaultModel');
|
||||
assert(catalog.displayName, 'Should have displayName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings file format', () => {
|
||||
it('should generate correct settings structure', () => {
|
||||
// Test the expected settings structure
|
||||
const expectedStructure = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: expect.any(String),
|
||||
ANTHROPIC_AUTH_TOKEN: expect.any(String),
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
// Verify the structure is valid JSON
|
||||
const testSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||
},
|
||||
};
|
||||
|
||||
const json = JSON.stringify(testSettings, null, 2);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
assert(parsed.env, 'Should have env object');
|
||||
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
|
||||
assert.strictEqual(
|
||||
parsed.env.ANTHROPIC_MODEL,
|
||||
'claude-opus-4-5-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('all env values should be strings (PowerShell safety)', () => {
|
||||
const testSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||
},
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(testSettings.env)) {
|
||||
assert.strictEqual(
|
||||
typeof value,
|
||||
'string',
|
||||
`env.${key} should be string, got ${typeof value}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper for expect-like assertions
|
||||
const expect = {
|
||||
any: (type) => ({
|
||||
_type: type,
|
||||
toString: () => `expect.any(${type.name})`,
|
||||
}),
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
canonicalizeModelIdForProvider,
|
||||
extractProviderFromPathname,
|
||||
getDeniedModelIdReasonForProvider,
|
||||
isDeniedAntigravityModelId,
|
||||
isAntigravityProvider,
|
||||
migrateDeniedAntigravityModelAliases,
|
||||
normalizeClaudeDottedMajorMinor,
|
||||
normalizeClaudeDottedThinkingMajorMinor,
|
||||
normalizeCodexLegacyModelAliases,
|
||||
normalizeModelIdForProvider,
|
||||
normalizeModelIdForRouting,
|
||||
normalizeModelEnvVarsForProvider,
|
||||
} from '../../../src/cliproxy/model-id-normalizer';
|
||||
|
||||
describe('model-id-normalizer', () => {
|
||||
describe('provider parsing', () => {
|
||||
it('extracts provider from provider route path', () => {
|
||||
expect(extractProviderFromPathname('/api/provider/agy/v1/messages')).toBe('agy');
|
||||
expect(extractProviderFromPathname('/api/provider/antigravity')).toBe('antigravity');
|
||||
expect(extractProviderFromPathname('/v1/messages')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects antigravity provider aliases', () => {
|
||||
expect(isAntigravityProvider('agy')).toBe(true);
|
||||
expect(isAntigravityProvider('antigravity')).toBe(true);
|
||||
expect(isAntigravityProvider('gemini')).toBe(false);
|
||||
expect(isAntigravityProvider(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model normalization', () => {
|
||||
it('normalizes dotted Claude major.minor to hyphen format', () => {
|
||||
expect(normalizeClaudeDottedMajorMinor('claude-sonnet-4.6-thinking')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect(normalizeClaudeDottedMajorMinor('claude-opus-4.6')).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('normalizes only dotted thinking variants for root/composite routing', () => {
|
||||
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6-thinking')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6')).toBe(
|
||||
'claude-sonnet-4.6'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies provider-aware routing normalization', () => {
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6');
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6');
|
||||
expect(normalizeModelIdForRouting('kimi-k2.5', 'iflow')).toBe('kimi-k2');
|
||||
expect(normalizeModelIdForRouting('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)');
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4-6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4.6-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies provider-only normalization for antigravity', () => {
|
||||
expect(normalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe(
|
||||
'claude-opus-4-6-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('claude-opus-4.5-thinking', 'agy')).toBe(
|
||||
'claude-opus-4-5-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('claude-sonnet-4.5-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-5-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('claude-sonnet-4.5', 'agy')).toBe('claude-sonnet-4-5');
|
||||
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe(
|
||||
'claude-opus-4.6-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies provider canonicalization for codex and antigravity', () => {
|
||||
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe(
|
||||
'gpt-5.3-codex-xhigh'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider('claude-sonnet-4-6-thinking', 'claude')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
});
|
||||
|
||||
it('trims and canonicalizes provider model IDs with surrounding whitespace', () => {
|
||||
expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe(
|
||||
'gpt-5.3-codex-high'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider(' claude-sonnet-4-6-thinking ', 'claude')).toBe(
|
||||
'claude-sonnet-4-6-thinking'
|
||||
);
|
||||
expect(normalizeModelIdForRouting(' claude-sonnet-4.6-thinking ', null)).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes known legacy iflow model aliases to supported upstream IDs', () => {
|
||||
expect(normalizeModelIdForProvider('iflow-default', 'iflow')).toBe('qwen3-coder-plus');
|
||||
expect(normalizeModelIdForProvider('kimi-k2.5', 'iflow')).toBe('kimi-k2');
|
||||
expect(normalizeModelIdForProvider('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)');
|
||||
expect(canonicalizeModelIdForProvider('deepseek-v3.2-chat', 'iflow')).toBe('deepseek-v3.2');
|
||||
expect(canonicalizeModelIdForProvider('glm-4.7', 'iflow')).toBe('glm-4.6');
|
||||
expect(canonicalizeModelIdForProvider('minimax-m2.5', 'iflow')).toBe('qwen3-coder-plus');
|
||||
expect(canonicalizeModelIdForProvider('kimi-k2.5', 'gemini')).toBe('kimi-k2.5');
|
||||
});
|
||||
|
||||
it('normalizes legacy codex aliases to the current supported model IDs', () => {
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
|
||||
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
|
||||
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('env normalization', () => {
|
||||
it('normalizes model env vars for antigravity only', () => {
|
||||
const input: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||
UNRELATED: 'keep-me',
|
||||
};
|
||||
|
||||
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
|
||||
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||
expect(normalized.UNRELATED).toBe('keep-me');
|
||||
|
||||
const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini');
|
||||
expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5-thinking');
|
||||
expect(unchanged.UNRELATED).toBe('keep-me');
|
||||
});
|
||||
|
||||
it('normalizes iflow legacy model aliases in env vars', () => {
|
||||
const input: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'kimi-k2.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7',
|
||||
};
|
||||
|
||||
const normalized = normalizeModelEnvVarsForProvider(input, 'iflow');
|
||||
expect(normalized.ANTHROPIC_MODEL).toBe('kimi-k2');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2');
|
||||
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6');
|
||||
});
|
||||
|
||||
it('flags denylisted antigravity models', () => {
|
||||
expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true);
|
||||
expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true);
|
||||
expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'agy')).toContain('denylist');
|
||||
expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'gemini')).toBeNull();
|
||||
expect(getDeniedModelIdReasonForProvider('claude-haiku-4.5', 'agy')).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates denylisted antigravity IDs to supported 4.6 fallbacks', () => {
|
||||
expect(migrateDeniedAntigravityModelAliases('claude-opus-4.5')).toBe(
|
||||
'claude-opus-4-6-thinking'
|
||||
);
|
||||
expect(migrateDeniedAntigravityModelAliases('claude-sonnet-4-5-thinking(8192)')).toBe(
|
||||
'claude-sonnet-4-6(8192)'
|
||||
);
|
||||
expect(migrateDeniedAntigravityModelAliases('claude-haiku-4-5')).toBe('claude-haiku-4-5');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildCliproxyRoutingHints,
|
||||
getManagedModelPrefix,
|
||||
} from '../../../src/shared/cliproxy-model-routing';
|
||||
|
||||
describe('cliproxy model routing hints', () => {
|
||||
it('uses short managed prefixes for overlapping Gemini and Antigravity models', () => {
|
||||
const routing = buildCliproxyRoutingHints(
|
||||
{
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }],
|
||||
},
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
models: [{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' }],
|
||||
},
|
||||
},
|
||||
[
|
||||
{ id: 'gemini-3-flash-preview', owned_by: 'antigravity', type: 'antigravity' },
|
||||
{ id: 'gemini-3-flash', owned_by: 'antigravity', type: 'antigravity' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(getManagedModelPrefix('gemini')).toBe('gcli');
|
||||
expect(getManagedModelPrefix('agy')).toBe('agy');
|
||||
|
||||
expect(routing.gemini?.models[0]).toMatchObject({
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: false,
|
||||
unprefixedStatus: 'shadowed',
|
||||
effectiveProvider: 'agy',
|
||||
effectiveDisplayName: 'Antigravity',
|
||||
});
|
||||
|
||||
expect(routing.agy?.models[0]).toMatchObject({
|
||||
recommendedModelId: 'agy/gemini-3-flash',
|
||||
pinnedAvailable: false,
|
||||
unprefixedStatus: 'safe',
|
||||
effectiveProvider: 'agy',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks models as prefix-only when they are not advertised unprefixed', () => {
|
||||
const routing = buildCliproxyRoutingHints(
|
||||
{
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
models: [{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' }],
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
expect(routing.gemini?.prefixOnlyCount).toBe(1);
|
||||
expect(routing.gemini?.models[0]).toMatchObject({
|
||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||
pinnedAvailable: false,
|
||||
unprefixedStatus: 'prefix-only',
|
||||
effectiveProvider: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not promote custom auth-file prefixes as managed pinned model ids', () => {
|
||||
const routing = buildCliproxyRoutingHints(
|
||||
{
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }],
|
||||
},
|
||||
},
|
||||
[{ id: 'team-a/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }]
|
||||
);
|
||||
|
||||
expect(routing.gemini?.models[0]).toMatchObject({
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: false,
|
||||
unprefixedStatus: 'prefix-only',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the managed pinned route as available when the live model list advertises it', () => {
|
||||
const routing = buildCliproxyRoutingHints(
|
||||
{
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }],
|
||||
},
|
||||
},
|
||||
[{ id: 'gcli/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }]
|
||||
);
|
||||
|
||||
expect(routing.gemini?.models[0]).toMatchObject({
|
||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||
pinnedAvailable: true,
|
||||
unprefixedStatus: 'prefix-only',
|
||||
effectiveProvider: null,
|
||||
});
|
||||
expect(routing.gemini?.models[0]?.summary).toContain(
|
||||
'Use gcli/gemini-3-flash-preview to target Gemini.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
import { InteractivePrompt } from '../../../src/utils/prompt';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
|
||||
const remoteTarget: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
managementKey: 'test-mgmt-key',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
});
|
||||
|
||||
describe('requestPasteCallbackStart', () => {
|
||||
it('uses management auth-url route for non-kiro providers', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/,
|
||||
response: { auth_url: 'https://auth.example.com/claude' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-claude-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('claude', remoteTarget);
|
||||
|
||||
expect(startData.auth_url).toBe('https://auth.example.com/claude');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/anthropic-auth-url?is_webui=true'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
expect(request.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the Kiro management auth-url route for paste-callback compatible methods', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=aws$/,
|
||||
response: { auth_url: 'https://auth.example.com/kiro' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('kiro', remoteTarget, {
|
||||
kiroMethod: 'aws',
|
||||
});
|
||||
|
||||
expect(startData.auth_url).toBe('https://auth.example.com/kiro');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/kiro-auth-url?is_webui=true&method=aws'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
expect(request.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws for Kiro methods that require the local callback server flow', async () => {
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestPasteCallbackStart('kiro', remoteTarget, { kiroMethod: 'aws-authcode' })
|
||||
).rejects.toThrow(/paste-callback start is not available/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usesKiroLocalCallbackReplay', () => {
|
||||
it('limits local callback replay to CLI auth-code flows', async () => {
|
||||
const { usesKiroLocalCallbackReplay } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?kiro-local-callback-mode=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(usesKiroLocalCallbackReplay('aws-authcode', 'authcode')).toBe(true);
|
||||
expect(usesKiroLocalCallbackReplay('idc', 'authcode')).toBe(true);
|
||||
expect(usesKiroLocalCallbackReplay('idc', 'device')).toBe(false);
|
||||
expect(usesKiroLocalCallbackReplay('google', 'authcode')).toBe(false);
|
||||
expect(usesKiroLocalCallbackReplay('aws', 'authcode')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePasteCallbackAuthUrl', () => {
|
||||
it('returns the immediate auth URL without polling', async () => {
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-immediate-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(
|
||||
remoteTarget,
|
||||
{ auth_url: 'https://auth.example.com/direct' },
|
||||
50,
|
||||
0
|
||||
);
|
||||
|
||||
expect(authUrl).toBe('https://auth.example.com/direct');
|
||||
expect(getCapturedFetchRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('polls management status when the start response only returns state', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-123$/,
|
||||
response: { status: 'auth_url', auth_url: 'https://auth.example.com/polled' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-polled-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(remoteTarget, { state: 'state-123' }, 50, 0);
|
||||
|
||||
expect(authUrl).toBe('https://auth.example.com/polled');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/get-auth-status?state=state-123'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNewTokenSnapshotForManualAuth', () => {
|
||||
it('detects newly created provider token files', async () => {
|
||||
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
|
||||
const existingFile = path.join(tokenDir, 'kiro-existing.json');
|
||||
fs.writeFileSync(existingFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
|
||||
const existingMtimeMs = fs.statSync(existingFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}`
|
||||
);
|
||||
|
||||
const newFile = path.join(tokenDir, 'kiro-new.json');
|
||||
fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' }));
|
||||
|
||||
const snapshot = findNewTokenSnapshotForManualAuth(
|
||||
'kiro',
|
||||
tokenDir,
|
||||
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }]
|
||||
);
|
||||
|
||||
expect(snapshot?.file).toBe('kiro-new.json');
|
||||
fs.rmSync(tokenDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats a modified existing token as the new token during reauth', async () => {
|
||||
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-reauth-'));
|
||||
const tokenFile = path.join(tokenDir, 'kiro-existing.json');
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
|
||||
const existingMtimeMs = fs.statSync(tokenFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true }));
|
||||
const bumpedTime = new Date(existingMtimeMs + 10_000);
|
||||
fs.utimesSync(tokenFile, bumpedTime, bumpedTime);
|
||||
|
||||
const snapshot = findNewTokenSnapshotForManualAuth(
|
||||
'kiro',
|
||||
tokenDir,
|
||||
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }],
|
||||
'kiro-existing.json'
|
||||
);
|
||||
|
||||
expect(snapshot?.file).toBe('kiro-existing.json');
|
||||
fs.rmSync(tokenDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliAuthNicknameError', () => {
|
||||
it('allows omitted nicknames for no-email providers', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-empty=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', undefined, [])).toBeNull();
|
||||
expect(getCliAuthNicknameError('ghcp', undefined, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid supplied nicknames before OAuth starts', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-invalid=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'bad nickname', [])).toBe(
|
||||
'Nickname cannot contain whitespace'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects supplied nicknames that collide with existing ids or nicknames', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-conflict=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'ghcp-2', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getCliAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toBe(
|
||||
'Nickname "github-ABC123" is already in use. Choose a different one.'
|
||||
);
|
||||
expect(getCliAuthNicknameError('ghcp', 'work', existingAccounts)).toBe(
|
||||
'Nickname "work" is already in use. Choose a different one.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows reauth when the supplied nickname already belongs to the same account', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-reauth=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
{ id: 'amazon-XYZ789', nickname: 'personal' },
|
||||
];
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptGitLabPersonalAccessToken', () => {
|
||||
it('uses the masked password prompt and trims the token', async () => {
|
||||
const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation(
|
||||
mock(async () => ' glpat-secret-token ')
|
||||
);
|
||||
|
||||
const { promptGitLabPersonalAccessToken } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(promptGitLabPersonalAccessToken()).resolves.toBe('glpat-secret-token');
|
||||
expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token');
|
||||
});
|
||||
|
||||
it('returns null when the masked prompt is left blank', async () => {
|
||||
const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation(
|
||||
mock(async () => ' ')
|
||||
);
|
||||
|
||||
const { promptGitLabPersonalAccessToken } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt-blank=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(promptGitLabPersonalAccessToken()).resolves.toBeNull();
|
||||
expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGitLabBaseUrl', () => {
|
||||
it('returns undefined for blank values', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined();
|
||||
expect(normalizeGitLabBaseUrl(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe(
|
||||
'https://gitlab.example.com/custom'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow(
|
||||
'GitLab URL must be a valid http:// or https:// URL'
|
||||
);
|
||||
expect(() => normalizeGitLabBaseUrl('ftp://gitlab.example.com')).toThrow(
|
||||
'GitLab URL must use http:// or https://'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
analyzeSuccessfulAuthExit,
|
||||
extractLikelyAuthFailureFromLogs,
|
||||
extractLikelyAuthFailureFromStderr,
|
||||
extractLikelyOAuthAuthorizationUrl,
|
||||
getExpectedLocalCallback,
|
||||
getKiroBuilderIdSelectionInput,
|
||||
validateManualCallbackUrl,
|
||||
} from '../../../src/cliproxy/auth/oauth-process';
|
||||
|
||||
describe('oauth-process stderr parsing', () => {
|
||||
it('does not match provider-specific patterns for other providers', () => {
|
||||
const stderr =
|
||||
'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: example"';
|
||||
|
||||
expect(extractLikelyAuthFailureFromStderr('qwen', stderr)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts copilot verification failures from logrus lines', () => {
|
||||
const stderr =
|
||||
'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden"';
|
||||
|
||||
expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe(
|
||||
'github-copilot: failed to verify Copilot access - you may not have an active Copilot subscription: 403 Forbidden'
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts generic authentication failure lines', () => {
|
||||
const stderr = 'level=error msg="Authentication failed: state mismatch"';
|
||||
|
||||
expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch');
|
||||
});
|
||||
|
||||
it('caps extracted message length to prevent noisy broadcasts', () => {
|
||||
const longSuffix = 'x'.repeat(400);
|
||||
const stderr = `level=error msg="Authentication failed: ${longSuffix}"`;
|
||||
|
||||
const parsed = extractLikelyAuthFailureFromStderr('ghcp', stderr);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect((parsed as string).length).toBe(240);
|
||||
});
|
||||
|
||||
it('extracts kiro IDC failures from verbose stdout logs', () => {
|
||||
const logData =
|
||||
'[2026-04-07 11:01:21] [--------] [error] [kiro_login.go:236] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)';
|
||||
|
||||
expect(extractLikelyAuthFailureFromLogs('kiro', logData)).toBe(
|
||||
'login failed: failed to register client: register client failed (status 400)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process successful exit analysis', () => {
|
||||
it('treats unchanged existing kiro tokens as a failed add-account attempt', () => {
|
||||
const result = analyzeSuccessfulAuthExit({
|
||||
provider: 'kiro',
|
||||
knownTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100, fingerprint: 'same' }],
|
||||
currentTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100, fingerprint: 'same' }],
|
||||
stdoutData:
|
||||
'[error] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)',
|
||||
stderrData: '',
|
||||
});
|
||||
|
||||
expect(result.tokenSnapshot).toBeNull();
|
||||
expect(result.failureReason).toBe(
|
||||
'login failed: failed to register client: register client failed (status 400)'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a refreshed token file as success during reauth', () => {
|
||||
const result = analyzeSuccessfulAuthExit({
|
||||
provider: 'kiro',
|
||||
knownTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 100,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'before',
|
||||
},
|
||||
],
|
||||
currentTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 250,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'after',
|
||||
},
|
||||
],
|
||||
expectedAccountId: 'kiro-existing.json',
|
||||
stdoutData: '',
|
||||
stderrData: '',
|
||||
});
|
||||
|
||||
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
|
||||
expect(result.failureReason).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unrelated new token files during reauth', () => {
|
||||
const result = analyzeSuccessfulAuthExit({
|
||||
provider: 'kiro',
|
||||
knownTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 100,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'before',
|
||||
},
|
||||
],
|
||||
currentTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 100,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'before',
|
||||
},
|
||||
{
|
||||
file: 'kiro-other.json',
|
||||
mtimeMs: 150,
|
||||
accountId: 'kiro-other',
|
||||
fingerprint: 'other-after',
|
||||
},
|
||||
],
|
||||
expectedAccountId: 'kiro-existing',
|
||||
stdoutData: '',
|
||||
stderrData: '',
|
||||
});
|
||||
|
||||
expect(result.tokenSnapshot).toBeNull();
|
||||
expect(result.failureReason).toBeNull();
|
||||
});
|
||||
|
||||
it('treats fingerprint changes as success even when mtime is unchanged', () => {
|
||||
const result = analyzeSuccessfulAuthExit({
|
||||
provider: 'kiro',
|
||||
knownTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 100,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'before',
|
||||
},
|
||||
],
|
||||
currentTokenFiles: [
|
||||
{
|
||||
file: 'kiro-existing.json',
|
||||
mtimeMs: 100,
|
||||
accountId: 'kiro-existing',
|
||||
fingerprint: 'after',
|
||||
},
|
||||
],
|
||||
expectedAccountId: 'kiro-existing',
|
||||
stdoutData: '',
|
||||
stderrData: '',
|
||||
});
|
||||
|
||||
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
|
||||
expect(result.failureReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process manual callback validation', () => {
|
||||
const authUrl =
|
||||
'https://oidc.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state';
|
||||
|
||||
it('extracts the expected local callback target from the auth URL', () => {
|
||||
expect(getExpectedLocalCallback(authUrl)).toEqual({
|
||||
origin: 'http://127.0.0.1:9876',
|
||||
pathname: '/oauth/callback',
|
||||
state: 'test-state',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts matching loopback callback URLs', () => {
|
||||
expect(
|
||||
validateManualCallbackUrl(
|
||||
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=test-state',
|
||||
authUrl
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-loopback callback URLs', () => {
|
||||
expect(
|
||||
validateManualCallbackUrl(
|
||||
'https://evil.example.com/oauth/callback?code=abc123&state=test-state',
|
||||
authUrl
|
||||
)
|
||||
).toContain('local OAuth callback server');
|
||||
});
|
||||
|
||||
it('rejects callback URLs with the wrong path or state', () => {
|
||||
expect(
|
||||
validateManualCallbackUrl(
|
||||
'http://127.0.0.1:9876/not-the-callback?code=abc123&state=test-state',
|
||||
authUrl
|
||||
)
|
||||
).toContain('expected local OAuth callback target');
|
||||
|
||||
expect(
|
||||
validateManualCallbackUrl(
|
||||
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=wrong-state',
|
||||
authUrl
|
||||
)
|
||||
).toContain('state does not match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process Kiro Builder ID menu parsing', () => {
|
||||
it('selects Builder ID when it is the first option', () => {
|
||||
const output = `
|
||||
? Select login method:
|
||||
1) Use with Builder ID (personal AWS account)
|
||||
2) Use with IDC Account (organization SSO)
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n');
|
||||
});
|
||||
|
||||
it('selects the Builder ID option even when upstream reorders the menu', () => {
|
||||
const output = `
|
||||
Select login method
|
||||
1. IAM Identity Center
|
||||
2. AWS Builder ID
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBe('2\n');
|
||||
});
|
||||
|
||||
it('returns null when the Builder ID option is not present in the prompt window', () => {
|
||||
const output = `
|
||||
Select login method
|
||||
1. IAM Identity Center
|
||||
2. Google
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process OAuth URL extraction', () => {
|
||||
it('prefers the real auth URL over the IDC start URL banner', () => {
|
||||
const authUrl =
|
||||
'https://oidc.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
|
||||
const output = `
|
||||
Using IDC with Start URL: https://d-123.awsapps.com/start
|
||||
Region: us-east-1
|
||||
URL: ${authUrl}
|
||||
`;
|
||||
|
||||
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
|
||||
});
|
||||
|
||||
it('ignores local callback server URLs when the auth URL is also present', () => {
|
||||
const authUrl =
|
||||
'https://device.sso.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
|
||||
const output = `
|
||||
Callback server started, redirect URI: http://127.0.0.1:9876/oauth/callback
|
||||
URL: ${authUrl}
|
||||
`;
|
||||
|
||||
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
describe('openai-compat manager', () => {
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let originalCcsDir;
|
||||
let configGenerator;
|
||||
let openAICompatManager;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = testDir;
|
||||
process.env.CCS_DIR = path.join(testDir, '.ccs');
|
||||
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/openai-compat-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
|
||||
configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
openAICompatManager = require('../../../dist/cliproxy/openai-compat-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the generated header and connector entries across regeneration', () => {
|
||||
configGenerator.regenerateConfig();
|
||||
const configPath = configGenerator.getCliproxyConfigPath();
|
||||
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
|
||||
|
||||
openAICompatManager.addOpenAICompatProvider({
|
||||
name: 'mimo',
|
||||
baseUrl: 'https://api.xiaomimimo.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
|
||||
});
|
||||
|
||||
const afterWrite = fs.readFileSync(configPath, 'utf8');
|
||||
assert.strictEqual(afterWrite.split('\n')[0], initialHeader, 'Should preserve the generated header');
|
||||
assert(afterWrite.includes('openai-compatibility:'), 'Should write the openai-compatibility section');
|
||||
assert.strictEqual(
|
||||
configGenerator.configNeedsRegeneration(),
|
||||
false,
|
||||
'Legacy openai-compat writes should not force regeneration'
|
||||
);
|
||||
|
||||
configGenerator.regenerateConfig();
|
||||
|
||||
const afterRegen = fs.readFileSync(configPath, 'utf8');
|
||||
assert(afterRegen.includes('openai-compatibility:'), 'Connector section should survive regeneration');
|
||||
assert(afterRegen.includes('name: mimo'), 'Connector name should survive regeneration');
|
||||
assert(
|
||||
afterRegen.includes('base-url: https://api.xiaomimimo.com/v1'),
|
||||
'Connector base URL should survive regeneration'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the openai-compatibility section cleanly when the last legacy connector is deleted', () => {
|
||||
configGenerator.regenerateConfig();
|
||||
const configPath = configGenerator.getCliproxyConfigPath();
|
||||
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
|
||||
|
||||
openAICompatManager.addOpenAICompatProvider({
|
||||
name: 'mimo',
|
||||
baseUrl: 'https://api.xiaomimimo.com/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
|
||||
});
|
||||
|
||||
const removed = openAICompatManager.removeOpenAICompatProvider('mimo');
|
||||
assert.strictEqual(removed, true, 'Expected the legacy connector to be removed');
|
||||
|
||||
const afterRemove = fs.readFileSync(configPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterRemove.split('\n')[0],
|
||||
initialHeader,
|
||||
'Should preserve the generated header after removing the last connector'
|
||||
);
|
||||
assert(
|
||||
!afterRemove.includes('openai-compatibility:'),
|
||||
'Should remove the openai-compatibility section when the last connector is deleted'
|
||||
);
|
||||
assert(!afterRemove.includes('name: mimo'), 'Should remove the deleted connector payload');
|
||||
assert.strictEqual(
|
||||
configGenerator.configNeedsRegeneration(),
|
||||
false,
|
||||
'Removing the last legacy connector should not force regeneration'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* Port Validation Tests
|
||||
*
|
||||
* Comprehensive test suite for port validation across the CLIProxy system.
|
||||
* Tests the fix for undefined port bug (7.11.1 → 7.12.0 regression).
|
||||
*
|
||||
* Covers:
|
||||
* - validatePort() function edge cases
|
||||
* - Object spread undefined filtering in executor
|
||||
* - YAML port validation in resolver
|
||||
* - detectRunningProxy with invalid ports
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { validatePort, CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config-generator';
|
||||
import { resolveProxyConfig } from '../../../src/cliproxy/proxy-config-resolver';
|
||||
|
||||
describe('Port Validation', () => {
|
||||
describe('validatePort()', () => {
|
||||
describe('returns default port for invalid inputs', () => {
|
||||
it('handles undefined', () => {
|
||||
expect(validatePort(undefined)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles null', () => {
|
||||
expect(validatePort(null as unknown as number)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles NaN', () => {
|
||||
expect(validatePort(NaN)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles Infinity', () => {
|
||||
expect(validatePort(Infinity)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles negative Infinity', () => {
|
||||
expect(validatePort(-Infinity)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(validatePort(0)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
expect(validatePort(-1)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
expect(validatePort(-8317)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles ports > 65535', () => {
|
||||
expect(validatePort(65536)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
expect(validatePort(100000)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles floating point numbers', () => {
|
||||
expect(validatePort(8317.5)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
expect(validatePort(8317.999)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns valid port for valid inputs', () => {
|
||||
it('handles port 1 (minimum valid)', () => {
|
||||
expect(validatePort(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('handles port 65535 (maximum valid)', () => {
|
||||
expect(validatePort(65535)).toBe(65535);
|
||||
});
|
||||
|
||||
it('handles default port 8317', () => {
|
||||
expect(validatePort(8317)).toBe(8317);
|
||||
});
|
||||
|
||||
it('handles common ports', () => {
|
||||
expect(validatePort(80)).toBe(80);
|
||||
expect(validatePort(443)).toBe(443);
|
||||
expect(validatePort(3000)).toBe(3000);
|
||||
expect(validatePort(8080)).toBe(8080);
|
||||
});
|
||||
|
||||
it('handles variant ports (8318-8417)', () => {
|
||||
expect(validatePort(8318)).toBe(8318);
|
||||
expect(validatePort(8350)).toBe(8350);
|
||||
expect(validatePort(8417)).toBe(8417);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Object Spread Undefined Filtering', () => {
|
||||
/**
|
||||
* Bug: `{ ...DEFAULT_CONFIG, ...{ port: undefined } }` overwrites port with undefined
|
||||
* Fix: Filter undefined values before spreading
|
||||
*/
|
||||
it('demonstrates the original bug pattern', () => {
|
||||
const DEFAULT_CONFIG = { port: 8317, timeout: 2000 };
|
||||
const config = { port: undefined, timeout: undefined };
|
||||
|
||||
// BUG: Direct spread overwrites with undefined
|
||||
const bugResult = { ...DEFAULT_CONFIG, ...config };
|
||||
expect(bugResult.port).toBe(undefined); // This was the bug!
|
||||
|
||||
// FIX: Filter undefined values before spreading
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
);
|
||||
const fixResult = { ...DEFAULT_CONFIG, ...filteredConfig };
|
||||
expect(fixResult.port).toBe(8317); // Fixed!
|
||||
});
|
||||
|
||||
it('preserves explicit values while filtering undefined', () => {
|
||||
const DEFAULT_CONFIG = { port: 8317, timeout: 2000, protocol: 'http' };
|
||||
const config = { port: 9000, timeout: undefined, protocol: undefined };
|
||||
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
);
|
||||
const result = { ...DEFAULT_CONFIG, ...filteredConfig };
|
||||
|
||||
expect(result.port).toBe(9000); // Explicit value preserved
|
||||
expect(result.timeout).toBe(2000); // Default preserved (undefined filtered)
|
||||
expect(result.protocol).toBe('http'); // Default preserved (undefined filtered)
|
||||
});
|
||||
});
|
||||
|
||||
describe('YAML Port Validation in resolveProxyConfig', () => {
|
||||
it('validates YAML remote port', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: 9000 },
|
||||
});
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid YAML port (negative)', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: -1 },
|
||||
});
|
||||
expect(config.port).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid YAML port (> 65535)', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: 70000 },
|
||||
});
|
||||
expect(config.port).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid YAML port (zero)', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: 0 },
|
||||
});
|
||||
expect(config.port).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('validates YAML local port', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
local: { port: 9000 },
|
||||
});
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid YAML local port', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
local: { port: -100 },
|
||||
});
|
||||
expect(config.port).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('CLI port overrides invalid YAML port', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-port', '8080'], {
|
||||
remote: { host: 'example.com', port: -1 },
|
||||
});
|
||||
expect(config.port).toBe(8080);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Priority Chain with Invalid Values', () => {
|
||||
it('CLI valid > ENV valid > YAML invalid', () => {
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const { config } = resolveProxyConfig(['--proxy-port', '8080'], {
|
||||
remote: { host: 'example.com', port: -1 },
|
||||
});
|
||||
expect(config.port).toBe(8080); // CLI wins
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
});
|
||||
|
||||
it('ENV valid > YAML invalid > default', () => {
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: -1 },
|
||||
});
|
||||
expect(config.port).toBe(9000); // ENV wins
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
});
|
||||
|
||||
it('YAML valid when CLI and ENV not set', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'example.com', port: 9000 },
|
||||
});
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('default when all sources invalid/missing', () => {
|
||||
const { config } = resolveProxyConfig([], {});
|
||||
expect(config.port).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boundary Conditions', () => {
|
||||
it('handles port exactly at minimum boundary (1)', () => {
|
||||
expect(validatePort(1)).toBe(1);
|
||||
expect(validatePort(0)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles port exactly at maximum boundary (65535)', () => {
|
||||
expect(validatePort(65535)).toBe(65535);
|
||||
expect(validatePort(65536)).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('handles reserved ports (< 1024)', () => {
|
||||
// Reserved ports are still valid from validation perspective
|
||||
expect(validatePort(22)).toBe(22);
|
||||
expect(validatePort(80)).toBe(80);
|
||||
expect(validatePort(443)).toBe(443);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Tests for Profile Mapper
|
||||
* Verifies syncable profile detection and ClaudeKey mapping.
|
||||
*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
const fs = require('fs');
|
||||
|
||||
describe('Profile Mapper', () => {
|
||||
const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper');
|
||||
const profileReader = require('../../../dist/api/services/profile-reader');
|
||||
|
||||
describe('mapProfileToClaudeKey', () => {
|
||||
it('returns null when env is missing', () => {
|
||||
const profile = { name: 'test', settingsPath: '/path', isConfigured: true };
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('returns null when ANTHROPIC_AUTH_TOKEN is missing', () => {
|
||||
const profile = {
|
||||
name: 'test',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: { ANTHROPIC_BASE_URL: 'https://example.com' },
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('generates ClaudeKey with correct prefix', () => {
|
||||
const profile = {
|
||||
name: 'glm',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_MODEL: 'gpt-4',
|
||||
},
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
|
||||
assert.ok(result, 'Should return ClaudeKey');
|
||||
assert.strictEqual(result['api-key'], 'sk-test-key');
|
||||
assert.strictEqual(result.prefix, 'glm-');
|
||||
assert.strictEqual(result['base-url'], 'https://api.example.com');
|
||||
assert.ok(result.models, 'Should have models');
|
||||
assert.strictEqual(result.models[0].name, 'gpt-4');
|
||||
});
|
||||
|
||||
it('handles special characters in profile name', () => {
|
||||
const profile = {
|
||||
name: 'my@profile!',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' },
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.prefix, 'my-profile--');
|
||||
});
|
||||
|
||||
it('omits base-url when not provided', () => {
|
||||
const profile = {
|
||||
name: 'test',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' },
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result['base-url'], undefined);
|
||||
});
|
||||
|
||||
it('appends ANTHROPIC_EXTRA_MODELS as additional models, deduped against primary and each other', () => {
|
||||
const profile = {
|
||||
name: 'dashscope',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder-plus',
|
||||
// Includes whitespace, empty entries, a duplicate of the primary
|
||||
// model, and a repeated extra entry — all should be cleaned up.
|
||||
ANTHROPIC_EXTRA_MODELS: ' glm-4.6 , kimi-k2 , ,qwen3-coder-plus, kimi-k2 ',
|
||||
},
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
|
||||
assert.ok(result, 'Should return ClaudeKey');
|
||||
assert.deepStrictEqual(result.models, [
|
||||
{ name: 'qwen3-coder-plus', alias: '' },
|
||||
{ name: 'glm-4.6', alias: '' },
|
||||
{ name: 'kimi-k2', alias: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores empty ANTHROPIC_EXTRA_MODELS', () => {
|
||||
const profile = {
|
||||
name: 'test',
|
||||
settingsPath: '/path',
|
||||
isConfigured: true,
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test',
|
||||
ANTHROPIC_MODEL: 'gpt-4',
|
||||
ANTHROPIC_EXTRA_MODELS: ' ',
|
||||
},
|
||||
};
|
||||
const result = profileMapper.mapProfileToClaudeKey(profile);
|
||||
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.models.length, 1);
|
||||
assert.strictEqual(result.models[0].name, 'gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSyncableProfiles', () => {
|
||||
it('returns an array', () => {
|
||||
const result = profileMapper.loadSyncableProfiles();
|
||||
assert.ok(Array.isArray(result), 'Should return an array');
|
||||
});
|
||||
|
||||
it('filters out profiles with placeholder tokens', () => {
|
||||
// loadSyncableProfiles reads from disk, just verify it doesn't throw
|
||||
// and returns array (actual filtering tested via integration)
|
||||
const result = profileMapper.loadSyncableProfiles();
|
||||
assert.ok(Array.isArray(result));
|
||||
});
|
||||
|
||||
it('uses profile-provided settingsPath instead of reconstructing from profile name', () => {
|
||||
const originalListApiProfiles = profileReader.listApiProfiles;
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
|
||||
const customSettingsPath = '/tmp/custom-sync-path.settings.json';
|
||||
const readPaths: string[] = [];
|
||||
|
||||
try {
|
||||
profileReader.listApiProfiles = () => ({
|
||||
profiles: [
|
||||
{
|
||||
name: 'glm',
|
||||
settingsPath: customSettingsPath,
|
||||
isConfigured: true,
|
||||
configSource: 'legacy',
|
||||
target: 'claude',
|
||||
},
|
||||
],
|
||||
variants: [],
|
||||
});
|
||||
|
||||
fs.existsSync = (filePath: string) => filePath === customSettingsPath;
|
||||
fs.readFileSync = (filePath: string) => {
|
||||
readPaths.push(filePath);
|
||||
return JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const result = profileMapper.loadSyncableProfiles();
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].settingsPath, customSettingsPath);
|
||||
assert.deepStrictEqual(readPaths, [customSettingsPath]);
|
||||
} finally {
|
||||
profileReader.listApiProfiles = originalListApiProfiles;
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips profiles pinned to non-claude targets during local sync mapping', () => {
|
||||
const originalListApiProfiles = profileReader.listApiProfiles;
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
|
||||
const claudePath = '/tmp/claude-target.settings.json';
|
||||
const droidPath = '/tmp/droid-target.settings.json';
|
||||
const readPaths: string[] = [];
|
||||
|
||||
try {
|
||||
profileReader.listApiProfiles = () => ({
|
||||
profiles: [
|
||||
{
|
||||
name: 'claude-profile',
|
||||
settingsPath: claudePath,
|
||||
isConfigured: true,
|
||||
configSource: 'legacy',
|
||||
target: 'claude',
|
||||
},
|
||||
{
|
||||
name: 'droid-profile',
|
||||
settingsPath: droidPath,
|
||||
isConfigured: true,
|
||||
configSource: 'legacy',
|
||||
target: 'droid',
|
||||
},
|
||||
],
|
||||
variants: [],
|
||||
});
|
||||
|
||||
fs.existsSync = (filePath: string) => filePath === claudePath || filePath === droidPath;
|
||||
fs.readFileSync = (filePath: string) => {
|
||||
readPaths.push(filePath);
|
||||
return JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const result = profileMapper.loadSyncableProfiles();
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].name, 'claude-profile');
|
||||
assert.deepStrictEqual(readPaths, [claudePath]);
|
||||
} finally {
|
||||
profileReader.listApiProfiles = originalListApiProfiles;
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSyncPayload', () => {
|
||||
it('returns an array of ClaudeKey objects', () => {
|
||||
const result = profileMapper.generateSyncPayload();
|
||||
assert.ok(Array.isArray(result));
|
||||
// Each item should have api-key if present
|
||||
for (const key of result) {
|
||||
assert.ok(key['api-key'], 'Each key should have api-key');
|
||||
assert.ok(key.prefix, 'Each key should have prefix');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSyncPreview', () => {
|
||||
it('returns an array of preview items', () => {
|
||||
const result = profileMapper.generateSyncPreview();
|
||||
assert.ok(Array.isArray(result));
|
||||
for (const item of result) {
|
||||
assert.ok(typeof item.name === 'string', 'Each item should have name');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSyncableProfileCount', () => {
|
||||
it('returns a number', () => {
|
||||
const result = profileMapper.getSyncableProfileCount();
|
||||
assert.ok(typeof result === 'number');
|
||||
assert.ok(result >= 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildProviderAliasMap,
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getOAuthCallbackPort,
|
||||
getOAuthFlowType,
|
||||
PROVIDER_CAPABILITIES,
|
||||
getProviderDisplayName,
|
||||
getProvidersByOAuthFlow,
|
||||
isCLIProxyProvider,
|
||||
mapExternalProviderName,
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
isQuotaSupportedProvider,
|
||||
QUOTA_PROVIDER_OPTION_VALUES,
|
||||
QUOTA_PROVIDER_HELP_TEXT,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import {
|
||||
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
||||
OAUTH_FLOW_TYPES,
|
||||
} from '../../../src/management/oauth-port-diagnostics';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthArgs,
|
||||
getKiroCLIAuthFlag,
|
||||
normalizeKiroIDCFlow,
|
||||
normalizeKiroAuthMethod,
|
||||
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
|
||||
toKiroManagementMethod,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
|
||||
describe('provider-capabilities', () => {
|
||||
it('keeps canonical provider IDs backward-compatible', () => {
|
||||
expect(CLIPROXY_PROVIDER_IDS).toEqual([
|
||||
'gemini',
|
||||
'codex',
|
||||
'agy',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'claude',
|
||||
'kimi',
|
||||
'cursor',
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates provider IDs', () => {
|
||||
expect(isCLIProxyProvider('gemini')).toBe(true);
|
||||
expect(isCLIProxyProvider('ghcp')).toBe(true);
|
||||
expect(isCLIProxyProvider('gitlab')).toBe(true);
|
||||
expect(isCLIProxyProvider('not-a-provider')).toBe(false);
|
||||
expect(isCLIProxyProvider('Gemini')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns providers by OAuth flow capability', () => {
|
||||
expect(getProvidersByOAuthFlow('device_code')).toEqual([
|
||||
'qwen',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'kimi',
|
||||
'cursor',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
]);
|
||||
expect(getProvidersByOAuthFlow('authorization_code')).toEqual([
|
||||
'gemini',
|
||||
'codex',
|
||||
'agy',
|
||||
'iflow',
|
||||
'claude',
|
||||
'gitlab',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps external provider aliases to canonical IDs', () => {
|
||||
expect(mapExternalProviderName('gemini-cli')).toBe('gemini');
|
||||
expect(mapExternalProviderName('antigravity')).toBe('agy');
|
||||
expect(mapExternalProviderName('codewhisperer')).toBe('kiro');
|
||||
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('copilot')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('anthropic')).toBe('claude');
|
||||
expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab');
|
||||
expect(mapExternalProviderName('tencent')).toBe('codebuddy');
|
||||
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('')).toBeNull();
|
||||
expect(mapExternalProviderName('unknown-provider')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes quota-supported providers and guards correctly', () => {
|
||||
expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']);
|
||||
expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([
|
||||
'agy',
|
||||
'codex',
|
||||
'claude',
|
||||
'gemini',
|
||||
'ghcp',
|
||||
'antigravity',
|
||||
'anthropic',
|
||||
'gemini-cli',
|
||||
'github-copilot',
|
||||
'copilot',
|
||||
'all',
|
||||
]);
|
||||
expect(QUOTA_PROVIDER_HELP_TEXT).toBe(
|
||||
'agy, codex, claude, gemini, ghcp, antigravity, anthropic, gemini-cli, github-copilot, copilot, all'
|
||||
);
|
||||
expect(isQuotaSupportedProvider('ghcp')).toBe(true);
|
||||
expect(isQuotaSupportedProvider('kiro')).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes callback port and display name capabilities', () => {
|
||||
expect(getOAuthCallbackPort('qwen')).toBeNull();
|
||||
expect(getOAuthCallbackPort('kiro')).toBeNull();
|
||||
expect(getOAuthCallbackPort('cursor')).toBeNull();
|
||||
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
|
||||
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
||||
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
|
||||
expect(getProviderDisplayName('agy')).toBe('Antigravity');
|
||||
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
|
||||
});
|
||||
|
||||
it('throws when provider aliases collide across providers', () => {
|
||||
const capabilitiesWithCollision = {
|
||||
...PROVIDER_CAPABILITIES,
|
||||
gemini: {
|
||||
...PROVIDER_CAPABILITIES.gemini,
|
||||
aliases: ['shared-alias'],
|
||||
},
|
||||
codex: {
|
||||
...PROVIDER_CAPABILITIES.codex,
|
||||
aliases: ['shared-alias'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
buildProviderAliasMap(capabilitiesWithCollision as typeof PROVIDER_CAPABILITIES)
|
||||
).toThrow(/shared-alias/i);
|
||||
});
|
||||
|
||||
it('keeps diagnostics flow metadata in sync with provider capabilities', () => {
|
||||
for (const provider of CLIPROXY_PROVIDER_IDS) {
|
||||
expect(OAUTH_FLOW_TYPES[provider]).toBe(getOAuthFlowType(provider));
|
||||
expect(DIAGNOSTIC_CALLBACK_PORTS[provider]).toBe(getOAuthCallbackPort(provider));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not define callback ports for device code providers in auth constants', () => {
|
||||
for (const provider of getProvidersByOAuthFlow('device_code')) {
|
||||
expect(AUTH_CALLBACK_PORTS[provider]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps Kiro auth methods to upstream CLI/management contracts', () => {
|
||||
expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod()).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google');
|
||||
expect(normalizeKiroAuthMethod('IDC')).toBe('idc');
|
||||
expect(normalizeKiroAuthMethod('not-valid')).toBe('aws');
|
||||
expect(normalizeKiroIDCFlow()).toBe('authcode');
|
||||
expect(normalizeKiroIDCFlow('DEVICE')).toBe('device');
|
||||
|
||||
expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login');
|
||||
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
|
||||
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
|
||||
expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login');
|
||||
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual(
|
||||
['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode']
|
||||
);
|
||||
|
||||
expect(getKiroCallbackPort('aws')).toBeNull();
|
||||
expect(getKiroCallbackPort('google')).toBe(9876);
|
||||
expect(getKiroCallbackPort('github')).toBe(9876);
|
||||
expect(getKiroCallbackPort('aws-authcode')).toBe(9876);
|
||||
expect(getKiroCallbackPort('idc')).toBe(9876);
|
||||
expect(getKiroCallbackPort('idc', { idcFlow: 'device' })).toBeNull();
|
||||
|
||||
expect(toKiroManagementMethod('aws')).toBe('aws');
|
||||
expect(toKiroManagementMethod('aws-authcode')).toBe('aws');
|
||||
expect(toKiroManagementMethod('google')).toBe('google');
|
||||
expect(toKiroManagementMethod('github')).toBe('github');
|
||||
expect(toKiroManagementMethod('idc')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* Unit tests for proxy-config-resolver module
|
||||
*/
|
||||
const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
|
||||
|
||||
// Import from compiled dist
|
||||
const {
|
||||
parseProxyFlags,
|
||||
getProxyEnvVars,
|
||||
resolveProxyConfig,
|
||||
hasProxyFlags,
|
||||
PROXY_CLI_FLAGS,
|
||||
PROXY_ENV_VARS,
|
||||
} = require('../../../dist/cliproxy/proxy-config-resolver');
|
||||
|
||||
describe('proxy-config-resolver', () => {
|
||||
describe('PROXY_CLI_FLAGS', () => {
|
||||
it('should define all expected proxy flags', () => {
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-host');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-port');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--local-proxy');
|
||||
expect(PROXY_CLI_FLAGS).toContain('--remote-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROXY_ENV_VARS', () => {
|
||||
it('should define all expected environment variable names', () => {
|
||||
expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST');
|
||||
expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT');
|
||||
expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL');
|
||||
expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN');
|
||||
expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProxyFlags', () => {
|
||||
it('should parse --proxy-host flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']);
|
||||
expect(flags.host).toBe('192.168.1.100');
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-port flag', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']);
|
||||
expect(flags.port).toBe(9000);
|
||||
expect(remainingArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse --proxy-protocol flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'https']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should parse --proxy-auth-token flag', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']);
|
||||
expect(flags.authToken).toBe('secret123');
|
||||
});
|
||||
|
||||
it('should parse --local-proxy boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--local-proxy']);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse --remote-only boolean flag', () => {
|
||||
const { flags } = parseProxyFlags(['--remote-only']);
|
||||
expect(flags.remoteOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve non-proxy args in remainingArgs', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'--verbose',
|
||||
'--proxy-host',
|
||||
'localhost',
|
||||
'--some-other-flag',
|
||||
]);
|
||||
expect(flags.host).toBe('localhost');
|
||||
expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']);
|
||||
});
|
||||
|
||||
it('should handle mixed proxy and non-proxy args', () => {
|
||||
const { flags, remainingArgs } = parseProxyFlags([
|
||||
'arg1',
|
||||
'--proxy-port',
|
||||
'8080',
|
||||
'arg2',
|
||||
'--local-proxy',
|
||||
'arg3',
|
||||
]);
|
||||
expect(flags.port).toBe(8080);
|
||||
expect(flags.localProxy).toBe(true);
|
||||
expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']);
|
||||
});
|
||||
|
||||
it('should ignore invalid port values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-port', 'invalid']);
|
||||
expect(flags.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore out-of-range port values', () => {
|
||||
const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']);
|
||||
expect(flags1.port).toBeUndefined();
|
||||
|
||||
const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']);
|
||||
expect(flags2.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize protocol to lowercase', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']);
|
||||
expect(flags.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should ignore invalid protocol values', () => {
|
||||
const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']);
|
||||
expect(flags.protocol).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyEnvVars', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear proxy env vars
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return empty config when no env vars set', () => {
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBeUndefined();
|
||||
expect(config.port).toBeUndefined();
|
||||
expect(config.protocol).toBeUndefined();
|
||||
expect(config.authToken).toBeUndefined();
|
||||
expect(config.fallbackEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_HOST', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should read and parse CCS_PROXY_PORT', () => {
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_PROTOCOL', () => {
|
||||
process.env.CCS_PROXY_PROTOCOL = 'https';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.protocol).toBe('https');
|
||||
});
|
||||
|
||||
it('should read CCS_PROXY_AUTH_TOKEN', () => {
|
||||
process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token';
|
||||
const config = getProxyEnvVars();
|
||||
expect(config.authToken).toBe('my-secret-token');
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '1';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'true';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => {
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '0';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'false';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = 'no';
|
||||
expect(getProxyEnvVars().fallbackEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProxyConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CCS_PROXY_HOST;
|
||||
delete process.env.CCS_PROXY_PORT;
|
||||
delete process.env.CCS_PROXY_PROTOCOL;
|
||||
delete process.env.CCS_PROXY_AUTH_TOKEN;
|
||||
delete process.env.CCS_PROXY_FALLBACK_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('CCS_PROXY_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should return local mode by default', () => {
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.port).toBe(8317); // Default CLIProxy port
|
||||
expect(config.fallbackEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable remote mode when --proxy-host is provided', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('192.168.1.100');
|
||||
});
|
||||
|
||||
it('should enable remote mode when CCS_PROXY_HOST env is set', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig([]);
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('remote.example.com');
|
||||
});
|
||||
|
||||
it('should prioritize CLI flags over ENV vars', () => {
|
||||
process.env.CCS_PROXY_HOST = 'env-host';
|
||||
process.env.CCS_PROXY_PORT = '9000';
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']);
|
||||
expect(config.host).toBe('cli-host');
|
||||
expect(config.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('should force local mode with --local-proxy', () => {
|
||||
process.env.CCS_PROXY_HOST = 'remote.example.com';
|
||||
const { config } = resolveProxyConfig(['--local-proxy']);
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.forceLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('should set remoteOnly and disable fallback with --remote-only', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']);
|
||||
expect(config.remoteOnly).toBe(true);
|
||||
expect(config.fallbackEnabled).toBe(false);
|
||||
});
|
||||
|
||||
// Tests for YAML enabled:false handling (Bug fix validation)
|
||||
describe('YAML remote.enabled handling', () => {
|
||||
it('should block remote mode when YAML remote.enabled is false', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { enabled: false, host: 'yaml-host.example.com' },
|
||||
});
|
||||
expect(config.mode).toBe('local');
|
||||
// Host from YAML should NOT be used when enabled: false
|
||||
});
|
||||
|
||||
it('should allow remote mode when YAML remote.enabled is true', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { enabled: true, host: 'yaml-host.example.com' },
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('yaml-host.example.com');
|
||||
});
|
||||
|
||||
it('should allow remote mode when YAML remote.enabled is undefined (backwards compat)', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { host: 'yaml-host.example.com' },
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('yaml-host.example.com');
|
||||
});
|
||||
|
||||
it('should preserve YAML management key for remote mode', () => {
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: {
|
||||
host: 'yaml-host.example.com',
|
||||
auth_token: 'remote-auth-token',
|
||||
management_key: 'remote-management-key',
|
||||
},
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.managementKey).toBe('remote-management-key');
|
||||
});
|
||||
|
||||
it('should allow CLI --proxy-host to override YAML enabled:false', () => {
|
||||
const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
|
||||
remote: { enabled: false, host: 'yaml-host' },
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('cli-host');
|
||||
});
|
||||
|
||||
it('should allow ENV CCS_PROXY_HOST to override YAML enabled:false', () => {
|
||||
process.env.CCS_PROXY_HOST = 'env-host';
|
||||
const { config } = resolveProxyConfig([], {
|
||||
remote: { enabled: false, host: 'yaml-host' },
|
||||
});
|
||||
expect(config.mode).toBe('remote');
|
||||
expect(config.host).toBe('env-host');
|
||||
});
|
||||
|
||||
it('should force local mode with --local-proxy even when YAML enabled:true', () => {
|
||||
const { config } = resolveProxyConfig(['--local-proxy'], {
|
||||
remote: { enabled: true, host: 'yaml-host' },
|
||||
});
|
||||
expect(config.mode).toBe('local');
|
||||
expect(config.forceLocal).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProxyFlags', () => {
|
||||
it('should return true when proxy flags are present', () => {
|
||||
expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true);
|
||||
expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true);
|
||||
expect(hasProxyFlags(['--local-proxy'])).toBe(true);
|
||||
expect(hasProxyFlags(['--remote-only'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no proxy flags are present', () => {
|
||||
expect(hasProxyFlags([])).toBe(false);
|
||||
expect(hasProxyFlags(['--verbose', '--help'])).toBe(false);
|
||||
expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Proxy Detector Port Validation Tests
|
||||
*
|
||||
* Tests for detectRunningProxy with invalid port inputs.
|
||||
* Verifies the fix handles undefined, null, and invalid ports gracefully.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { detectRunningProxy } from '../../../src/cliproxy/proxy-detector';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config-generator';
|
||||
|
||||
describe('Proxy Detector Port Validation', () => {
|
||||
describe('detectRunningProxy with invalid ports', () => {
|
||||
it('handles undefined port (uses default)', async () => {
|
||||
// TypeScript allows this at runtime via any/unknown
|
||||
const result = await detectRunningProxy(undefined as unknown as number);
|
||||
// Should not throw, should use default port
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles NaN port (uses default)', async () => {
|
||||
const result = await detectRunningProxy(NaN);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles negative port (uses default)', async () => {
|
||||
const result = await detectRunningProxy(-1);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles zero port (uses default)', async () => {
|
||||
const result = await detectRunningProxy(0);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles port > 65535 (uses default)', async () => {
|
||||
const result = await detectRunningProxy(70000);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles valid port correctly', async () => {
|
||||
const result = await detectRunningProxy(CLIPROXY_DEFAULT_PORT);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
expect(typeof result.verified).toBe('boolean');
|
||||
});
|
||||
|
||||
it('handles variant port correctly', async () => {
|
||||
const result = await detectRunningProxy(8318);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRunningProxy default parameter', () => {
|
||||
it('works without port argument (uses default)', async () => {
|
||||
const result = await detectRunningProxy();
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.running).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,403 +0,0 @@
|
||||
/**
|
||||
* Quota Caching Integration Tests
|
||||
*
|
||||
* Tests for quota response caching behavior across providers:
|
||||
* - Cache hit/miss scenarios
|
||||
* - Cache invalidation patterns
|
||||
* - TTL expiration behavior
|
||||
* - Provider isolation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import {
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
clearQuotaCache,
|
||||
getQuotaCacheStats,
|
||||
QUOTA_CACHE_TTL_MS,
|
||||
} from '../../../src/cliproxy/quota-response-cache';
|
||||
import { shouldCacheQuotaResult } from '../../../src/web-server/routes/cliproxy-stats-routes';
|
||||
import type { GeminiCliQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota-types';
|
||||
|
||||
describe('Quota Caching Integration', () => {
|
||||
beforeEach(() => {
|
||||
clearQuotaCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearQuotaCache();
|
||||
});
|
||||
|
||||
describe('GeminiCliQuotaResult caching', () => {
|
||||
const createGeminiQuota = (
|
||||
remainingPercent: number,
|
||||
options: Partial<GeminiCliQuotaResult> = {}
|
||||
): GeminiCliQuotaResult => ({
|
||||
success: true,
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-flash-series::combined',
|
||||
label: 'Gemini Flash Series',
|
||||
tokenType: null,
|
||||
remainingFraction: remainingPercent / 100,
|
||||
remainingPercent,
|
||||
resetTime: null,
|
||||
modelIds: ['gemini-3-flash-preview'],
|
||||
},
|
||||
],
|
||||
projectId: 'test-project-123',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'test@example.com',
|
||||
...options,
|
||||
});
|
||||
|
||||
it('should cache successful Gemini quota result', () => {
|
||||
const quota = createGeminiQuota(75);
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
|
||||
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', 'user@example.com');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.success).toBe(true);
|
||||
expect(cached?.buckets[0].remainingPercent).toBe(75);
|
||||
});
|
||||
|
||||
it('should NOT cache quota with needsReauth flag', () => {
|
||||
const quota = createGeminiQuota(0, {
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
error: 'Token expired',
|
||||
});
|
||||
|
||||
// In real usage, we would not cache reauth results
|
||||
// This test verifies the data structure
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', 'user@example.com');
|
||||
expect(cached?.needsReauth).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve all Gemini bucket fields through cache', () => {
|
||||
const quota = createGeminiQuota(50, {
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-pro-series::input',
|
||||
label: 'Gemini Pro Series',
|
||||
tokenType: 'input',
|
||||
remainingFraction: 0.5,
|
||||
remainingPercent: 50,
|
||||
resetTime: '2026-01-30T12:00:00Z',
|
||||
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', 'user@example.com');
|
||||
|
||||
expect(cached?.buckets[0].tokenType).toBe('input');
|
||||
expect(cached?.buckets[0].resetTime).toBe('2026-01-30T12:00:00Z');
|
||||
expect(cached?.buckets[0].modelIds).toContain('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexQuotaResult caching', () => {
|
||||
const createCodexQuota = (
|
||||
primaryUsed: number,
|
||||
secondaryUsed?: number,
|
||||
options: Partial<CodexQuotaResult> = {}
|
||||
): CodexQuotaResult => ({
|
||||
success: true,
|
||||
windows: [
|
||||
{
|
||||
label: 'Primary',
|
||||
usedPercent: primaryUsed,
|
||||
remainingPercent: 100 - primaryUsed,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
},
|
||||
...(secondaryUsed !== undefined
|
||||
? [
|
||||
{
|
||||
label: 'Secondary',
|
||||
usedPercent: secondaryUsed,
|
||||
remainingPercent: 100 - secondaryUsed,
|
||||
resetAfterSeconds: 86400,
|
||||
resetAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
planType: 'plus',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'test@example.com',
|
||||
...options,
|
||||
});
|
||||
|
||||
it('should cache successful Codex quota result', () => {
|
||||
const quota = createCodexQuota(30, 10);
|
||||
setCachedQuota('codex', 'user@example.com', quota);
|
||||
|
||||
const cached = getCachedQuota<CodexQuotaResult>('codex', 'user@example.com');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.success).toBe(true);
|
||||
expect(cached?.windows).toHaveLength(2);
|
||||
expect(cached?.windows[0].usedPercent).toBe(30);
|
||||
});
|
||||
|
||||
it('should preserve planType through cache', () => {
|
||||
const quota = createCodexQuota(20, undefined, { planType: 'team' });
|
||||
setCachedQuota('codex', 'user@example.com', quota);
|
||||
|
||||
const cached = getCachedQuota<CodexQuotaResult>('codex', 'user@example.com');
|
||||
expect(cached?.planType).toBe('team');
|
||||
});
|
||||
|
||||
it('should handle Codex quota with code review limits', () => {
|
||||
const quota: CodexQuotaResult = {
|
||||
success: true,
|
||||
windows: [
|
||||
{
|
||||
label: 'Primary',
|
||||
usedPercent: 25,
|
||||
remainingPercent: 75,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: null,
|
||||
},
|
||||
{
|
||||
label: 'Code Review (Primary)',
|
||||
usedPercent: 80,
|
||||
remainingPercent: 20,
|
||||
resetAfterSeconds: 1800,
|
||||
resetAt: null,
|
||||
},
|
||||
],
|
||||
planType: 'plus',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'user@example.com',
|
||||
};
|
||||
|
||||
setCachedQuota('codex', 'user@example.com', quota);
|
||||
const cached = getCachedQuota<CodexQuotaResult>('codex', 'user@example.com');
|
||||
|
||||
expect(cached?.windows).toHaveLength(2);
|
||||
const codeReview = cached?.windows.find((w) => w.label.includes('Code Review'));
|
||||
expect(codeReview?.usedPercent).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-provider isolation', () => {
|
||||
it('should isolate Gemini and Codex cache for same email', () => {
|
||||
const geminiQuota: GeminiCliQuotaResult = {
|
||||
success: true,
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-flash::combined',
|
||||
label: 'Flash',
|
||||
tokenType: null,
|
||||
remainingFraction: 0.9,
|
||||
remainingPercent: 90,
|
||||
resetTime: null,
|
||||
modelIds: [],
|
||||
},
|
||||
],
|
||||
projectId: 'proj',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'shared@example.com',
|
||||
};
|
||||
|
||||
const codexQuota: CodexQuotaResult = {
|
||||
success: true,
|
||||
windows: [
|
||||
{
|
||||
label: 'Primary',
|
||||
usedPercent: 10,
|
||||
remainingPercent: 90,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: null,
|
||||
},
|
||||
],
|
||||
planType: 'plus',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'shared@example.com',
|
||||
};
|
||||
|
||||
setCachedQuota('gemini', 'shared@example.com', geminiQuota);
|
||||
setCachedQuota('codex', 'shared@example.com', codexQuota);
|
||||
|
||||
const cachedGemini = getCachedQuota<GeminiCliQuotaResult>('gemini', 'shared@example.com');
|
||||
const cachedCodex = getCachedQuota<CodexQuotaResult>('codex', 'shared@example.com');
|
||||
|
||||
expect(cachedGemini?.buckets).toBeDefined();
|
||||
expect(cachedCodex?.windows).toBeDefined();
|
||||
expect((cachedGemini as unknown as CodexQuotaResult).windows).toBeUndefined();
|
||||
expect((cachedCodex as unknown as GeminiCliQuotaResult).buckets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow invalidating one provider without affecting others', () => {
|
||||
setCachedQuota('gemini', 'user@example.com', { success: true, buckets: [] } as never);
|
||||
setCachedQuota('codex', 'user@example.com', { success: true, windows: [] } as never);
|
||||
setCachedQuota('agy', 'user@example.com', { success: true, quotas: [] } as never);
|
||||
|
||||
invalidateQuotaCache('gemini', 'user@example.com');
|
||||
|
||||
expect(getCachedQuota('gemini', 'user@example.com')).toBeNull();
|
||||
expect(getCachedQuota('codex', 'user@example.com')).not.toBeNull();
|
||||
expect(getCachedQuota('agy', 'user@example.com')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache TTL behavior', () => {
|
||||
it('should use 2-minute TTL by default', () => {
|
||||
expect(QUOTA_CACHE_TTL_MS).toBe(120000);
|
||||
});
|
||||
|
||||
it('should allow custom TTL on retrieval', () => {
|
||||
setCachedQuota('gemini', 'user@example.com', { success: true } as never);
|
||||
|
||||
// With very long TTL, should find it
|
||||
expect(getCachedQuota('gemini', 'user@example.com', 10000000)).not.toBeNull();
|
||||
|
||||
// With 0 TTL, should be expired
|
||||
expect(getCachedQuota('gemini', 'user@example.com', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('should clean up expired entries lazily on access', async () => {
|
||||
setCachedQuota('gemini', 'user1@example.com', { id: 1 });
|
||||
setCachedQuota('gemini', 'user2@example.com', { id: 2 });
|
||||
|
||||
expect(getQuotaCacheStats().size).toBe(2);
|
||||
|
||||
// Access with very short TTL to trigger expiration cleanup
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
getCachedQuota('gemini', 'user1@example.com', 5);
|
||||
|
||||
// Only user1 entry should be deleted (the one we accessed)
|
||||
expect(getQuotaCacheStats().size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state caching', () => {
|
||||
it('should cache failed quota results for visibility', () => {
|
||||
const failedQuota: GeminiCliQuotaResult = {
|
||||
success: false,
|
||||
buckets: [],
|
||||
projectId: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Rate limited',
|
||||
accountId: 'user@example.com',
|
||||
};
|
||||
|
||||
setCachedQuota('gemini', 'user@example.com', failedQuota);
|
||||
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', 'user@example.com');
|
||||
|
||||
expect(cached?.success).toBe(false);
|
||||
expect(cached?.error).toBe('Rate limited');
|
||||
});
|
||||
|
||||
it('should preserve error message through cache round-trip', () => {
|
||||
const errorQuota: CodexQuotaResult = {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'API error: 503',
|
||||
accountId: 'user@example.com',
|
||||
};
|
||||
|
||||
setCachedQuota('codex', 'user@example.com', errorQuota);
|
||||
const cached = getCachedQuota<CodexQuotaResult>('codex', 'user@example.com');
|
||||
|
||||
expect(cached?.error).toBe('API error: 503');
|
||||
});
|
||||
|
||||
it('should cache stable auth and workspace failures', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
error: 'Token expired',
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 402,
|
||||
error: 'Workspace deactivated (HTTP 402)',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip transient failures marked retryable or temporary by status', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
retryable: true,
|
||||
error: 'Rate limited - try again later',
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 429,
|
||||
error: 'Rate limited - try again later',
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
httpStatus: 503,
|
||||
error: 'Codex quota service unavailable (HTTP 503)',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect explicit non-retryable failures even without message pattern matches', () => {
|
||||
expect(
|
||||
shouldCacheQuotaResult({
|
||||
success: false,
|
||||
retryable: false,
|
||||
error: 'Unknown upstream error',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('high-volume scenarios', () => {
|
||||
it('should handle 50+ accounts efficiently', () => {
|
||||
const numAccounts = 50;
|
||||
const providers = ['gemini', 'codex', 'agy'];
|
||||
|
||||
// Populate cache
|
||||
for (let i = 0; i < numAccounts; i++) {
|
||||
for (const provider of providers) {
|
||||
setCachedQuota(provider, `user${i}@example.com`, {
|
||||
success: true,
|
||||
id: `${provider}-${i}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.size).toBe(numAccounts * providers.length);
|
||||
|
||||
// Verify random access
|
||||
const cached = getCachedQuota<{ id: string }>('codex', 'user25@example.com');
|
||||
expect(cached?.id).toBe('codex-25');
|
||||
});
|
||||
|
||||
it('should handle rapid cache updates', () => {
|
||||
const iterations = 100;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
setCachedQuota('gemini', 'user@example.com', { iteration: i });
|
||||
}
|
||||
|
||||
const cached = getCachedQuota<{ iteration: number }>('gemini', 'user@example.com');
|
||||
expect(cached?.iteration).toBe(iterations - 1);
|
||||
expect(getQuotaCacheStats().size).toBe(1); // Only one entry, updated 100 times
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
async function loadAntigravityQuotaTestExports() {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const mod = await import(`../../../src/cliproxy/quota-fetcher?agy-quota-fetcher=${moduleId}`);
|
||||
return mod.__testExports;
|
||||
}
|
||||
|
||||
describe('Antigravity quota failure metadata', () => {
|
||||
it('marks 403 failures as not entitled', async () => {
|
||||
const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports();
|
||||
|
||||
const result = buildAntigravityFailure(403, 'forbidden');
|
||||
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 failures as rate limited', async () => {
|
||||
const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports();
|
||||
|
||||
const result = buildAntigravityFailure(429, 'rate limited');
|
||||
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves entitlement evidence when project lookup fails before quota fetch', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(`../../../src/cliproxy/quota-fetcher?agy-early=${moduleId}`);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-failure-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
project_id: 'project-x',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'forbidden' } }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('provider_unavailable');
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cloudaicompanionProject: { id: 'project-x' },
|
||||
paidTier: { id: 'g1-pro-tier' },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entitlement).toMatchObject({
|
||||
normalizedTier: 'pro',
|
||||
rawTierId: 'g1-pro-tier',
|
||||
rawTierLabel: 'Pro',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,695 +0,0 @@
|
||||
/**
|
||||
* Claude Quota Fetcher Unit Tests
|
||||
*
|
||||
* Covers Claude quota parsing and auth/token edge cases.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
buildClaudeQuotaWindows,
|
||||
buildClaudeCoreUsageSummary,
|
||||
fetchClaudeQuota,
|
||||
fetchAllClaudeQuotas,
|
||||
} from '../../../src/cliproxy/quota-fetcher-claude';
|
||||
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
||||
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
function createClaudeAccount(
|
||||
accountId: string,
|
||||
tokenPayload: Record<string, unknown>,
|
||||
tokenPrefix: 'claude' | 'anthropic' = 'claude'
|
||||
): void {
|
||||
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
const sanitized = sanitizeEmail(accountId);
|
||||
const tokenFile = `${tokenPrefix}-${sanitized}.json`;
|
||||
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload, null, 2));
|
||||
fs.writeFileSync(
|
||||
path.join(cliproxyDir, 'accounts.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
providers: {
|
||||
claude: {
|
||||
default: accountId,
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
email: accountId,
|
||||
tokenFile,
|
||||
createdAt: '2026-02-20T00:00:00.000Z',
|
||||
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-quota-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Claude Quota Fetcher', () => {
|
||||
describe('buildClaudeQuotaWindows', () => {
|
||||
it('parses restrictions array payload', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
restrictions: [
|
||||
{
|
||||
rateLimitType: 'five_hour',
|
||||
utilization: 0.4,
|
||||
resetsAt: '2026-02-28T10:00:00Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day',
|
||||
utilization: 0.8,
|
||||
resetsAt: '2026-03-06T10:00:00Z',
|
||||
status: 'allowed_warning',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].rateLimitType).toBe('five_hour');
|
||||
expect(windows[0].remainingPercent).toBe(60);
|
||||
expect(windows[1].rateLimitType).toBe('seven_day');
|
||||
expect(windows[1].remainingPercent).toBe(20);
|
||||
});
|
||||
|
||||
it('parses object-map restrictions payload', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
restrictions: {
|
||||
five_hour: {
|
||||
utilization: 0.25,
|
||||
resetsAt: '2026-02-28T10:00:00Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
seven_day_opus: {
|
||||
utilization: 0.9,
|
||||
resetsAt: '2026-03-06T10:00:00Z',
|
||||
status: 'allowed_warning',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(windows.map((window) => window.rateLimitType)).toEqual(
|
||||
expect.arrayContaining(['five_hour', 'seven_day_opus'])
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps utilization ratio into 0..1', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
restrictions: [
|
||||
{
|
||||
rateLimitType: 'five_hour',
|
||||
utilization: 150,
|
||||
status: 'allowed',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day',
|
||||
utilization: -25,
|
||||
status: 'allowed',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].utilization).toBe(1);
|
||||
expect(windows[0].remainingPercent).toBe(0);
|
||||
expect(windows[1].utilization).toBe(0);
|
||||
expect(windows[1].remainingPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('parses direct single restriction payload shape', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
rateLimitType: 'five_hour',
|
||||
utilization: 0.6,
|
||||
status: 'allowed',
|
||||
resetsAt: '2026-02-28T10:00:00Z',
|
||||
});
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].rateLimitType).toBe('five_hour');
|
||||
expect(windows[0].remainingPercent).toBe(40);
|
||||
});
|
||||
|
||||
it('parses OAuth usage payload keyed by window name', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
five_hour: {
|
||||
utilization: 39,
|
||||
resets_at: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
seven_day_sonnet: {
|
||||
utilization: 9,
|
||||
resets_at: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 5000,
|
||||
used_credits: 1200,
|
||||
utilization: 0.24,
|
||||
},
|
||||
});
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].rateLimitType).toBe('five_hour');
|
||||
expect(windows[0].remainingPercent).toBe(61);
|
||||
expect(windows[1].rateLimitType).toBe('seven_day_sonnet');
|
||||
expect(windows[1].remainingPercent).toBe(91);
|
||||
});
|
||||
|
||||
it('parses future OAuth usage windows without hardcoded keys', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
seven_day_haiku: {
|
||||
utilization: 16,
|
||||
resets_at: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].rateLimitType).toBe('seven_day_haiku');
|
||||
expect(windows[0].label).toBe('Seven Day Haiku');
|
||||
expect(windows[0].remainingPercent).toBe(84);
|
||||
});
|
||||
|
||||
it('treats OAuth usage utilization as percent (regression for issue: Sonnet weekly shown as 0% when 1% used)', () => {
|
||||
const windows = buildClaudeQuotaWindows({
|
||||
five_hour: { utilization: 34.0, resets_at: '2026-04-27T06:50:01Z' },
|
||||
seven_day: { utilization: 8.0, resets_at: '2026-04-27T18:00:00Z' },
|
||||
seven_day_sonnet: { utilization: 1.0, resets_at: '2026-04-27T18:00:00Z' },
|
||||
});
|
||||
const sonnet = windows.find((w) => w.rateLimitType === 'seven_day_sonnet');
|
||||
expect(sonnet?.usedPercent).toBe(1);
|
||||
expect(sonnet?.remainingPercent).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClaudeCoreUsageSummary', () => {
|
||||
it('selects most restrictive weekly window', () => {
|
||||
const summary = buildClaudeCoreUsageSummary([
|
||||
{
|
||||
rateLimitType: 'five_hour',
|
||||
label: 'Session limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.35,
|
||||
usedPercent: 35,
|
||||
remainingPercent: 65,
|
||||
resetAt: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day',
|
||||
label: 'Weekly limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.45,
|
||||
usedPercent: 45,
|
||||
remainingPercent: 55,
|
||||
resetAt: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_opus',
|
||||
label: 'Opus limit',
|
||||
status: 'allowed_warning',
|
||||
utilization: 0.85,
|
||||
usedPercent: 85,
|
||||
remainingPercent: 15,
|
||||
resetAt: '2026-03-06T12:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
|
||||
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
|
||||
expect(summary.weekly?.remainingPercent).toBe(15);
|
||||
});
|
||||
|
||||
it('considers oauth/cowork weekly windows in core summary', () => {
|
||||
const summary = buildClaudeCoreUsageSummary([
|
||||
{
|
||||
rateLimitType: 'five_hour',
|
||||
label: 'Session limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.35,
|
||||
usedPercent: 35,
|
||||
remainingPercent: 65,
|
||||
resetAt: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_oauth_apps',
|
||||
label: 'OAuth apps limit',
|
||||
status: 'allowed_warning',
|
||||
utilization: 0.92,
|
||||
usedPercent: 92,
|
||||
remainingPercent: 8,
|
||||
resetAt: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_cowork',
|
||||
label: 'Cowork limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.4,
|
||||
usedPercent: 40,
|
||||
remainingPercent: 60,
|
||||
resetAt: '2026-03-06T12:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
|
||||
expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps');
|
||||
expect(summary.weekly?.remainingPercent).toBe(8);
|
||||
});
|
||||
|
||||
it('does not duplicate weekly window into fiveHour when only weekly exists', () => {
|
||||
const summary = buildClaudeCoreUsageSummary([
|
||||
{
|
||||
rateLimitType: 'seven_day',
|
||||
label: 'Weekly limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.45,
|
||||
usedPercent: 45,
|
||||
remainingPercent: 55,
|
||||
resetAt: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.fiveHour).toBeNull();
|
||||
expect(summary.weekly?.rateLimitType).toBe('seven_day');
|
||||
});
|
||||
|
||||
it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => {
|
||||
const summary = buildClaudeCoreUsageSummary([
|
||||
{
|
||||
rateLimitType: 'five_hour',
|
||||
label: 'Session limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.2,
|
||||
usedPercent: 20,
|
||||
remainingPercent: 80,
|
||||
resetAt: '2026-02-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_opus',
|
||||
label: 'Opus limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.6,
|
||||
usedPercent: 60,
|
||||
remainingPercent: 40,
|
||||
resetAt: '2026-03-06T12:00:00Z',
|
||||
},
|
||||
{
|
||||
rateLimitType: 'seven_day_sonnet',
|
||||
label: 'Sonnet limit',
|
||||
status: 'allowed',
|
||||
utilization: 0.6,
|
||||
usedPercent: 60,
|
||||
remainingPercent: 40,
|
||||
resetAt: '2026-03-06T10:00:00Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchClaudeQuota', () => {
|
||||
it('fetches and normalizes Claude OAuth usage response', async () => {
|
||||
createClaudeAccount('claude-main@example.com', {
|
||||
access_token: 'claude-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock((url: string, options?: RequestInit) => {
|
||||
expect(url).toBe('https://api.anthropic.com/api/oauth/usage');
|
||||
expect(options?.method).toBe('GET');
|
||||
expect(options?.headers).toMatchObject({
|
||||
Authorization: 'Bearer claude-token',
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
});
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 39,
|
||||
resets_at: '2026-03-01T01:00:00Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 75,
|
||||
resets_at: '2026-03-07T01:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-main@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.accountId).toBe('claude-main@example.com');
|
||||
expect(result.windows).toHaveLength(2);
|
||||
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(61);
|
||||
expect(result.coreUsage?.weekly?.remainingPercent).toBe(25);
|
||||
|
||||
const all = await fetchAllClaudeQuotas();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].account).toBe('claude-main@example.com');
|
||||
expect(all[0].quota.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns needsReauth on empty 401 OAuth usage responses', async () => {
|
||||
createClaudeAccount(
|
||||
'claude-auth@example.com',
|
||||
{
|
||||
access_token: 'expired-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'anthropic',
|
||||
},
|
||||
'anthropic'
|
||||
);
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-auth@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('Authentication');
|
||||
});
|
||||
|
||||
it('surfaces nested OAuth usage 401 messages', async () => {
|
||||
createClaudeAccount(
|
||||
'claude-oauth-nested-message@example.com',
|
||||
{
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
},
|
||||
'claude'
|
||||
);
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'OAuth session expired.',
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-nested-message@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('OAuth session expired.');
|
||||
});
|
||||
|
||||
it('surfaces root-level OAuth usage 401 messages', async () => {
|
||||
createClaudeAccount('claude-oauth-root-message@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: 'OAuth session expired.',
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-root-message@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('OAuth session expired.');
|
||||
});
|
||||
|
||||
it('surfaces plain-text OAuth usage 401 messages', async () => {
|
||||
createClaudeAccount('claude-oauth-plaintext@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(new Response('OAuth session expired.', { status: 401 }))
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('OAuth session expired.');
|
||||
});
|
||||
|
||||
it('keeps non-matching 401 payloads in the reauth path', async () => {
|
||||
createClaudeAccount('claude-auth-other-401@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Token revoked.',
|
||||
},
|
||||
}),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-auth-other-401@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toContain('Token revoked.');
|
||||
});
|
||||
|
||||
it('treats 404 OAuth usage responses as failures', async () => {
|
||||
createClaudeAccount('claude-usage-404@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-usage-404@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('fails fast when auth file has no token', async () => {
|
||||
createClaudeAccount('claude-missing@example.com', {
|
||||
access_token: ' ',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 })));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-missing@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Auth file not found');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('treats missing expiry as not expired', async () => {
|
||||
createClaudeAccount('claude-no-expiry@example.com', {
|
||||
access_token: 'no-expiry-token',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-no-expiry@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('retries once on transient 500 then succeeds', async () => {
|
||||
createClaudeAccount('claude-retry@example.com', {
|
||||
access_token: 'retry-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
return Promise.resolve(new Response('', { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 40,
|
||||
resets_at: '2026-03-01T01:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-retry@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(attempt).toBe(2);
|
||||
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
||||
});
|
||||
|
||||
it('retries once after AbortError and succeeds', async () => {
|
||||
createClaudeAccount('claude-timeout@example.com', {
|
||||
access_token: 'timeout-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
return Promise.reject(abortError);
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
seven_day: {
|
||||
utilization: 30,
|
||||
resets_at: '2026-03-07T01:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-timeout@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(attempt).toBe(2);
|
||||
expect(result.coreUsage?.weekly?.remainingPercent).toBe(70);
|
||||
});
|
||||
|
||||
it('falls back to alternate auth file when preferred file is invalid JSON', async () => {
|
||||
const accountId = 'claude-fallback@example.com';
|
||||
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
const sanitized = sanitizeEmail(accountId);
|
||||
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid');
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, `anthropic-${sanitized}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
access_token: 'valid-anthropic-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'anthropic',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(cliproxyDir, 'accounts.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
providers: {
|
||||
claude: {
|
||||
default: accountId,
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
email: accountId,
|
||||
tokenFile: `anthropic-${sanitized}.json`,
|
||||
createdAt: '2026-02-20T00:00:00.000Z',
|
||||
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
global.fetch = mock((_url: string, options?: RequestInit) => {
|
||||
expect(options?.headers).toMatchObject({
|
||||
Authorization: 'Bearer valid-anthropic-token',
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota(accountId);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,782 +0,0 @@
|
||||
/**
|
||||
* Codex Quota Fetcher Unit Tests
|
||||
*
|
||||
* Tests for Codex quota window parsing and transformation logic
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
let moduleVersion = 0;
|
||||
let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexQuotaWindows;
|
||||
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
|
||||
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
||||
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
||||
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
|
||||
|
||||
function createCodexAccount(
|
||||
accountId: string,
|
||||
tokenPayload: Record<string, unknown>,
|
||||
tokenFile = `codex-${accountId.replace(/[@.]/g, '_')}.json`
|
||||
): void {
|
||||
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
moduleVersion += 1;
|
||||
mock.restore();
|
||||
|
||||
const configGenerator = await import(
|
||||
`../../../src/cliproxy/config-generator?codex-config-generator=${moduleVersion}`
|
||||
);
|
||||
const accountManager = await import(
|
||||
`../../../src/cliproxy/account-manager?codex-account-manager=${moduleVersion}`
|
||||
);
|
||||
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
||||
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
||||
({ registerAccount } = accountManager);
|
||||
|
||||
({
|
||||
buildCodexQuotaWindows,
|
||||
buildCodexCoreUsageSummary,
|
||||
fetchCodexQuota,
|
||||
getUnknownCodexWindowLabels,
|
||||
} = await import(`../../../src/cliproxy/quota-fetcher-codex?codex-fetcher=${moduleVersion}`));
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
global.fetch = originalFetch;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Codex Quota Fetcher', () => {
|
||||
describe('buildCodexQuotaWindows', () => {
|
||||
it('should parse snake_case API response', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 25,
|
||||
reset_after_seconds: 3600,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 50,
|
||||
reset_after_seconds: 86400,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].label).toBe('Primary');
|
||||
expect(windows[0].usedPercent).toBe(25);
|
||||
expect(windows[0].remainingPercent).toBe(75);
|
||||
expect(windows[0].resetAfterSeconds).toBe(3600);
|
||||
expect(windows[1].label).toBe('Secondary');
|
||||
expect(windows[1].usedPercent).toBe(50);
|
||||
});
|
||||
|
||||
it('should parse camelCase API response', () => {
|
||||
const response = {
|
||||
rateLimit: {
|
||||
primaryWindow: {
|
||||
usedPercent: 30,
|
||||
resetAfterSeconds: 7200,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].usedPercent).toBe(30);
|
||||
expect(windows[0].resetAfterSeconds).toBe(7200);
|
||||
});
|
||||
|
||||
it('should handle code review rate limits', () => {
|
||||
const response = {
|
||||
code_review_rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 80,
|
||||
reset_after_seconds: 1800,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].label).toBe('Code Review (Primary)');
|
||||
expect(windows[0].usedPercent).toBe(80);
|
||||
});
|
||||
|
||||
it('should clamp usedPercent to 0-100 range', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 150, // Over 100
|
||||
reset_after_seconds: null,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: -20, // Negative
|
||||
reset_after_seconds: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows[0].usedPercent).toBe(100);
|
||||
expect(windows[0].remainingPercent).toBe(0);
|
||||
expect(windows[1].usedPercent).toBe(0);
|
||||
expect(windows[1].remainingPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle null reset_after_seconds', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 10,
|
||||
reset_after_seconds: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows[0].resetAfterSeconds).toBeNull();
|
||||
expect(windows[0].resetAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate resetAt from positive seconds', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 10,
|
||||
reset_after_seconds: 3600, // 1 hour
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const before = Date.now();
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
const after = Date.now();
|
||||
|
||||
expect(windows[0].resetAt).not.toBeNull();
|
||||
const resetTime = new Date(windows[0].resetAt!).getTime();
|
||||
expect(resetTime).toBeGreaterThanOrEqual(before + 3600000);
|
||||
expect(resetTime).toBeLessThanOrEqual(after + 3600000);
|
||||
});
|
||||
|
||||
it('should not calculate resetAt for zero or negative seconds', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 10,
|
||||
reset_after_seconds: 0,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 20,
|
||||
reset_after_seconds: -100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows[0].resetAt).toBeNull();
|
||||
expect(windows[1].resetAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty array for empty response', () => {
|
||||
const windows = buildCodexQuotaWindows({});
|
||||
expect(windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array for missing rate limit', () => {
|
||||
const response = {
|
||||
plan_type: 'plus',
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
expect(windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle missing window data gracefully', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: undefined,
|
||||
secondary_window: null,
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response as never);
|
||||
expect(windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should default usedPercent to 0 when missing', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
reset_after_seconds: 3600,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows[0].usedPercent).toBe(0);
|
||||
expect(windows[0].remainingPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('should attach category and cadence metadata to standard usage windows', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 5, reset_after_seconds: 18000 },
|
||||
secondary_window: { used_percent: 25, reset_after_seconds: 604800 },
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].category).toBe('usage');
|
||||
expect(windows[0].cadence).toBe('5h');
|
||||
expect(windows[0].featureLabel).toBeUndefined();
|
||||
expect(windows[1].category).toBe('usage');
|
||||
expect(windows[1].cadence).toBe('weekly');
|
||||
});
|
||||
|
||||
it('should mark code review windows with code-review category and Code Review feature label', () => {
|
||||
const response = {
|
||||
code_review_rate_limit: {
|
||||
primary_window: { used_percent: 12, reset_after_seconds: 1800 },
|
||||
secondary_window: { used_percent: 60, reset_after_seconds: 604800 },
|
||||
},
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(2);
|
||||
expect(windows[0].category).toBe('code-review');
|
||||
expect(windows[0].cadence).toBe('5h');
|
||||
expect(windows[0].featureLabel).toBe('Code Review');
|
||||
expect(windows[1].category).toBe('code-review');
|
||||
expect(windows[1].cadence).toBe('weekly');
|
||||
expect(windows[1].featureLabel).toBe('Code Review');
|
||||
});
|
||||
|
||||
it('should parse additional_rate_limits entries (e.g. GPT-5.3 Codex Spark)', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 0, reset_after_seconds: 18000 },
|
||||
secondary_window: { used_percent: 1, reset_after_seconds: 254493 },
|
||||
},
|
||||
code_review_rate_limit: null,
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: 'GPT-5.3-Codex-Spark',
|
||||
metered_feature: 'codex_bengalfox',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 0, reset_after_seconds: 18000 },
|
||||
secondary_window: { used_percent: 1, reset_after_seconds: 254493 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
// 2 standard usage windows + 2 additional windows.
|
||||
expect(windows).toHaveLength(4);
|
||||
|
||||
const additionalWindows = windows.filter((w) => w.category === 'additional');
|
||||
expect(additionalWindows).toHaveLength(2);
|
||||
|
||||
const sparkPrimary = additionalWindows.find((w) => w.cadence === '5h');
|
||||
const sparkSecondary = additionalWindows.find((w) => w.cadence === 'weekly');
|
||||
|
||||
expect(sparkPrimary).toBeDefined();
|
||||
expect(sparkPrimary?.featureLabel).toBe('GPT-5.3-Codex-Spark');
|
||||
expect(sparkPrimary?.label).toBe('GPT-5.3-Codex-Spark (Primary)');
|
||||
expect(sparkPrimary?.usedPercent).toBe(0);
|
||||
expect(sparkPrimary?.remainingPercent).toBe(100);
|
||||
|
||||
expect(sparkSecondary).toBeDefined();
|
||||
expect(sparkSecondary?.featureLabel).toBe('GPT-5.3-Codex-Spark');
|
||||
expect(sparkSecondary?.label).toBe('GPT-5.3-Codex-Spark (Secondary)');
|
||||
expect(sparkSecondary?.usedPercent).toBe(1);
|
||||
expect(sparkSecondary?.remainingPercent).toBe(99);
|
||||
});
|
||||
|
||||
it('should handle additional_rate_limits set to null without breaking', () => {
|
||||
const response = {
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
additional_rate_limits: null,
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].category).toBe('usage');
|
||||
expect(windows.find((w) => w.category === 'additional')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept camelCase additionalRateLimits and rateLimit fields', () => {
|
||||
const response = {
|
||||
additionalRateLimits: [
|
||||
{
|
||||
limitName: 'Custom-Feature',
|
||||
rateLimit: {
|
||||
primaryWindow: { usedPercent: 50, resetAfterSeconds: 3600 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const windows = buildCodexQuotaWindows(response);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].category).toBe('additional');
|
||||
expect(windows[0].cadence).toBe('5h');
|
||||
expect(windows[0].featureLabel).toBe('Custom-Feature');
|
||||
expect(windows[0].usedPercent).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCodexCoreUsageSummary', () => {
|
||||
it('extracts 5h and weekly windows from labeled usage windows', () => {
|
||||
const windows = buildCodexQuotaWindows({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 35,
|
||||
reset_after_seconds: 18000,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 60,
|
||||
reset_after_seconds: 604800,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const summary = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
expect(summary.fiveHour?.label).toBe('Primary');
|
||||
expect(summary.fiveHour?.remainingPercent).toBe(65);
|
||||
expect(summary.fiveHour?.resetAfterSeconds).toBe(18000);
|
||||
expect(summary.weekly?.label).toBe('Secondary');
|
||||
expect(summary.weekly?.remainingPercent).toBe(40);
|
||||
expect(summary.weekly?.resetAfterSeconds).toBe(604800);
|
||||
});
|
||||
|
||||
it('falls back to shortest and longest reset windows when labels are unknown', () => {
|
||||
const windows = [
|
||||
{
|
||||
label: 'Window A',
|
||||
usedPercent: 20,
|
||||
remainingPercent: 80,
|
||||
resetAfterSeconds: 18000,
|
||||
resetAt: '2026-02-15T15:00:00Z',
|
||||
},
|
||||
{
|
||||
label: 'Window B',
|
||||
usedPercent: 45,
|
||||
remainingPercent: 55,
|
||||
resetAfterSeconds: 604800,
|
||||
resetAt: '2026-02-21T10:00:00Z',
|
||||
},
|
||||
{
|
||||
label: 'Code Review (Primary)',
|
||||
usedPercent: 10,
|
||||
remainingPercent: 90,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: '2026-02-15T11:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const summary = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
expect(summary.fiveHour?.label).toBe('Window A');
|
||||
expect(summary.fiveHour?.resetAfterSeconds).toBe(18000);
|
||||
expect(summary.weekly?.label).toBe('Window B');
|
||||
expect(summary.weekly?.resetAfterSeconds).toBe(604800);
|
||||
});
|
||||
|
||||
it('returns null summaries when no windows are available', () => {
|
||||
const summary = buildCodexCoreUsageSummary([]);
|
||||
expect(summary.fiveHour).toBeNull();
|
||||
expect(summary.weekly).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes additional and code-review windows from the core usage summary', () => {
|
||||
const windows = buildCodexQuotaWindows({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 35, reset_after_seconds: 18000 },
|
||||
secondary_window: { used_percent: 60, reset_after_seconds: 604800 },
|
||||
},
|
||||
code_review_rate_limit: {
|
||||
primary_window: { used_percent: 70, reset_after_seconds: 1800 },
|
||||
},
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: 'GPT-5.3-Codex-Spark',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 90, reset_after_seconds: 100 },
|
||||
secondary_window: { used_percent: 95, reset_after_seconds: 7200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
// Should pick the 'usage' windows, NOT the Spark windows even though they
|
||||
// have shorter reset cadences.
|
||||
expect(summary.fiveHour?.label).toBe('Primary');
|
||||
expect(summary.fiveHour?.resetAfterSeconds).toBe(18000);
|
||||
expect(summary.weekly?.label).toBe('Secondary');
|
||||
expect(summary.weekly?.resetAfterSeconds).toBe(604800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnknownCodexWindowLabels', () => {
|
||||
it('returns unknown labels and de-duplicates them', () => {
|
||||
const labels = getUnknownCodexWindowLabels([
|
||||
{
|
||||
label: 'Window A',
|
||||
usedPercent: 1,
|
||||
remainingPercent: 99,
|
||||
resetAfterSeconds: 10,
|
||||
resetAt: null,
|
||||
},
|
||||
{
|
||||
label: 'Window A',
|
||||
usedPercent: 2,
|
||||
remainingPercent: 98,
|
||||
resetAfterSeconds: 20,
|
||||
resetAt: null,
|
||||
},
|
||||
{
|
||||
label: 'Primary',
|
||||
usedPercent: 3,
|
||||
remainingPercent: 97,
|
||||
resetAfterSeconds: 30,
|
||||
resetAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(labels).toEqual(['Window A']);
|
||||
});
|
||||
|
||||
it('returns empty array when all labels are recognized', () => {
|
||||
const labels = getUnknownCodexWindowLabels([
|
||||
{
|
||||
label: 'Primary',
|
||||
usedPercent: 10,
|
||||
remainingPercent: 90,
|
||||
resetAfterSeconds: 100,
|
||||
resetAt: null,
|
||||
},
|
||||
{
|
||||
label: 'Code Review (Secondary)',
|
||||
usedPercent: 20,
|
||||
remainingPercent: 80,
|
||||
resetAfterSeconds: 200,
|
||||
resetAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(labels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCodexQuota failure mapping', () => {
|
||||
function createValidCodexAccount(
|
||||
email: string,
|
||||
accountId = `workspace-${email}`,
|
||||
tokenFile?: string
|
||||
): void {
|
||||
createCodexAccount(email, {
|
||||
access_token: 'test-token',
|
||||
account_id: accountId,
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
email,
|
||||
type: 'codex',
|
||||
}, tokenFile);
|
||||
}
|
||||
|
||||
it('maps deactivated workspace 402 responses to structured metadata', async () => {
|
||||
createValidCodexAccount('workspace@example.com', 'workspace-123');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'deactivated_workspace' } }), {
|
||||
status: 402,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('workspace@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(402);
|
||||
expect(result.errorCode).toBe('deactivated_workspace');
|
||||
expect(result.error).toContain('Workspace deactivated');
|
||||
expect(result.actionHint).toContain('active ChatGPT workspace');
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('maps 401 responses to reauth-required metadata', async () => {
|
||||
createValidCodexAccount('reauth@example.com', 'workspace-reauth');
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('reauth@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(401);
|
||||
expect(result.errorCode).toBe('reauth_required');
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.actionHint).toContain('ccs cliproxy auth codex');
|
||||
});
|
||||
|
||||
it('uses the registry token file for duplicate-email Codex accounts', async () => {
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-team',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
|
||||
);
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-free',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
);
|
||||
|
||||
registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const freeAccount = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
const fetchSpy = mock((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'free',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const result = await fetchCodexQuota(freeAccount.id);
|
||||
const requestInit = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
const headers = new Headers(requestInit?.headers);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
|
||||
});
|
||||
|
||||
it('does not guess a duplicate-email Codex auth file when the registry entry is missing', async () => {
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-team',
|
||||
'codex-legacy-slot-a.json'
|
||||
);
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-free',
|
||||
'codex-legacy-slot-b.json'
|
||||
);
|
||||
|
||||
const fetchSpy = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'free',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const result = await fetchCodexQuota('kaidu.kd@gmail.com#04a0f049-team');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('auth_file_missing');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps 403 responses to forbidden metadata', async () => {
|
||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'quota_api_forbidden' } }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('forbidden@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(403);
|
||||
expect(result.errorCode).toBe('quota_api_forbidden');
|
||||
expect(result.isForbidden).toBe(true);
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('maps 429 responses to retryable rate-limit metadata', async () => {
|
||||
createValidCodexAccount('rate-limit@example.com', 'workspace-rate-limit');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'rate_limited' } }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('rate-limit@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(429);
|
||||
expect(result.errorCode).toBe('rate_limited');
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.actionHint).toContain('Retry');
|
||||
});
|
||||
|
||||
it('maps 5xx responses to retryable provider-unavailable metadata', async () => {
|
||||
createValidCodexAccount('outage@example.com', 'workspace-outage');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ detail: { code: 'upstream_failure' } }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('outage@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(503);
|
||||
expect(result.errorCode).toBe('upstream_failure');
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.error).toContain('service unavailable');
|
||||
});
|
||||
|
||||
it('maps unknown upstream statuses to a non-retryable structured error', async () => {
|
||||
createValidCodexAccount('teapot@example.com', 'workspace-teapot');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('{"message":"Strange upstream response"}', {
|
||||
status: 418,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('teapot@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(418);
|
||||
expect(result.errorCode).toBe('unknown_upstream_error');
|
||||
expect(result.retryable).toBe(false);
|
||||
expect(result.error).toBe('Strange upstream response');
|
||||
});
|
||||
|
||||
it('sanitizes and truncates raw upstream error detail before returning it', async () => {
|
||||
createValidCodexAccount('sanitized@example.com', 'workspace-sanitized');
|
||||
const leakedToken = 'secret-token-value-123';
|
||||
const oversizedMessage = 'x'.repeat(400);
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: 'Upstream failure',
|
||||
access_token: leakedToken,
|
||||
extra: oversizedMessage,
|
||||
}),
|
||||
{
|
||||
status: 418,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('sanitized@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorDetail).toBeDefined();
|
||||
expect(result.errorDetail).not.toContain(leakedToken);
|
||||
expect(result.errorDetail).toContain('[redacted]');
|
||||
expect(result.errorDetail?.endsWith('...[truncated]')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits raw HTML upstream bodies from the returned error detail', async () => {
|
||||
createValidCodexAccount('html@example.com', 'workspace-html');
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('<html><body>bad gateway</body></html>', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await fetchCodexQuota('html@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorDetail).toBe('[HTML error response omitted]');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* GitHub Copilot (GHCP) Quota Fetcher Unit Tests
|
||||
*
|
||||
* Covers normalization and token extraction edge cases.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
normalizeGhcpSnapshot,
|
||||
extractGhcpAccessToken,
|
||||
fetchGhcpQuota,
|
||||
} from '../../../src/cliproxy/quota-fetcher-ghcp';
|
||||
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
function createGhcpAccount(
|
||||
accountId: string,
|
||||
tokenPayload: Record<string, unknown>,
|
||||
tokenFile = `${accountId}.json`
|
||||
): void {
|
||||
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(cliproxyDir, 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
|
||||
fs.writeFileSync(
|
||||
path.join(cliproxyDir, 'accounts.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
providers: {
|
||||
ghcp: {
|
||||
default: accountId,
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
nickname: accountId,
|
||||
tokenFile,
|
||||
createdAt: '2026-02-20T00:00:00.000Z',
|
||||
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ghcp-quota-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GHCP Quota Fetcher', () => {
|
||||
describe('normalizeGhcpSnapshot', () => {
|
||||
it('handles missing/undefined raw data', () => {
|
||||
const snapshot = normalizeGhcpSnapshot();
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
reported: false,
|
||||
entitlement: 0,
|
||||
remaining: 0,
|
||||
used: 0,
|
||||
percentRemaining: 0,
|
||||
percentUsed: 100,
|
||||
unlimited: false,
|
||||
overageCount: 0,
|
||||
overagePermitted: false,
|
||||
quotaId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks upstream-provided snapshots as reported', () => {
|
||||
const snapshot = normalizeGhcpSnapshot({
|
||||
entitlement: 100,
|
||||
remaining: 80,
|
||||
});
|
||||
|
||||
expect(snapshot.reported).toBe(true);
|
||||
});
|
||||
|
||||
it('treats unknown upstream snapshot objects as unreported', () => {
|
||||
const empty = normalizeGhcpSnapshot({});
|
||||
const quotaIdOnly = normalizeGhcpSnapshot({ quota_id: 'chat' });
|
||||
const remainingOnly = normalizeGhcpSnapshot({ remaining: 80 });
|
||||
|
||||
for (const snapshot of [empty, quotaIdOnly, remainingOnly]) {
|
||||
expect(snapshot.reported).toBe(false);
|
||||
expect(snapshot.percentRemaining).toBe(0);
|
||||
expect(snapshot.percentUsed).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks percent-only snapshots as reported', () => {
|
||||
const snapshot = normalizeGhcpSnapshot({
|
||||
percent_remaining: 70,
|
||||
});
|
||||
|
||||
expect(snapshot.reported).toBe(true);
|
||||
expect(snapshot.percentRemaining).toBe(70);
|
||||
expect(snapshot.percentUsed).toBe(30);
|
||||
});
|
||||
|
||||
it('treats unlimited snapshots as 100% remaining', () => {
|
||||
const snapshot = normalizeGhcpSnapshot({
|
||||
unlimited: true,
|
||||
entitlement: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
expect(snapshot.reported).toBe(true);
|
||||
expect(snapshot.percentRemaining).toBe(100);
|
||||
expect(snapshot.percentUsed).toBe(0);
|
||||
expect(snapshot.unlimited).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps percent_remaining to 0-100 range', () => {
|
||||
const above = normalizeGhcpSnapshot({
|
||||
entitlement: 100,
|
||||
remaining: 80,
|
||||
percent_remaining: 140,
|
||||
});
|
||||
const below = normalizeGhcpSnapshot({
|
||||
entitlement: 100,
|
||||
remaining: 80,
|
||||
percent_remaining: -15,
|
||||
});
|
||||
|
||||
expect(above.percentRemaining).toBe(100);
|
||||
expect(above.percentUsed).toBe(0);
|
||||
expect(below.percentRemaining).toBe(0);
|
||||
expect(below.percentUsed).toBe(100);
|
||||
});
|
||||
|
||||
it('calculates percentRemaining when API does not provide it', () => {
|
||||
const snapshot = normalizeGhcpSnapshot({
|
||||
entitlement: 80,
|
||||
remaining: 20,
|
||||
});
|
||||
|
||||
expect(snapshot.entitlement).toBe(80);
|
||||
expect(snapshot.remaining).toBe(20);
|
||||
expect(snapshot.used).toBe(60);
|
||||
expect(snapshot.percentRemaining).toBe(25);
|
||||
expect(snapshot.percentUsed).toBe(75);
|
||||
});
|
||||
|
||||
it('handles non-finite entitlement values safely', () => {
|
||||
const snapshot = normalizeGhcpSnapshot({
|
||||
entitlement: Number.POSITIVE_INFINITY,
|
||||
remaining: 25,
|
||||
});
|
||||
|
||||
expect(snapshot.entitlement).toBe(0);
|
||||
expect(snapshot.remaining).toBe(25);
|
||||
expect(snapshot.used).toBe(0);
|
||||
expect(snapshot.percentRemaining).toBe(0);
|
||||
expect(snapshot.percentUsed).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGhcpAccessToken', () => {
|
||||
it('extracts from top-level access_token', () => {
|
||||
const token = extractGhcpAccessToken({
|
||||
access_token: ' top-level-token ',
|
||||
});
|
||||
expect(token).toBe('top-level-token');
|
||||
});
|
||||
|
||||
it('extracts from nested token.access_token', () => {
|
||||
const token = extractGhcpAccessToken({
|
||||
token: {
|
||||
access_token: 'nested-token',
|
||||
},
|
||||
});
|
||||
expect(token).toBe('nested-token');
|
||||
});
|
||||
|
||||
it('returns null for empty/whitespace tokens', () => {
|
||||
const emptyTopLevel = extractGhcpAccessToken({ access_token: ' ' });
|
||||
const emptyNested = extractGhcpAccessToken({
|
||||
token: { access_token: ' ' },
|
||||
});
|
||||
|
||||
expect(emptyTopLevel).toBeNull();
|
||||
expect(emptyNested).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGhcpQuota', () => {
|
||||
it('fetches and normalizes quota for a valid account token', async () => {
|
||||
createGhcpAccount('ghcp-main', { access_token: 'top-level-token' });
|
||||
|
||||
global.fetch = mock((url: string, options?: RequestInit) => {
|
||||
expect(url).toBe('https://api.github.com/copilot_internal/user');
|
||||
expect(options?.method).toBe('GET');
|
||||
expect(options?.headers).toEqual({
|
||||
Accept: 'application/json',
|
||||
Authorization: 'token top-level-token',
|
||||
'User-Agent': 'GitHubCopilotChat/0.26.7',
|
||||
'x-github-api-version': '2025-04-01',
|
||||
});
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
copilot_plan: 'business',
|
||||
quota_reset_date: '2026-02-28T00:00:00Z',
|
||||
quota_snapshots: {
|
||||
premium_interactions: { entitlement: 1000, remaining: 900 },
|
||||
chat: { entitlement: 500, remaining: 100, percent_remaining: 20 },
|
||||
completions: { entitlement: 250, remaining: 125 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchGhcpQuota('ghcp-main');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.accountId).toBe('ghcp-main');
|
||||
expect(result.planType).toBe('business');
|
||||
expect(result.quotaResetDate).toBe('2026-02-28T00:00:00Z');
|
||||
expect(result.snapshots.premiumInteractions.percentRemaining).toBe(90);
|
||||
expect(result.snapshots.chat.percentRemaining).toBe(20);
|
||||
expect(result.snapshots.completions.percentRemaining).toBe(50);
|
||||
});
|
||||
|
||||
it('returns needsReauth on 401/403 responses', async () => {
|
||||
createGhcpAccount('ghcp-auth', { access_token: 'token-auth' });
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchGhcpQuota('ghcp-auth');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.needsReauth).toBe(true);
|
||||
expect(result.error).toBe('Authentication expired or invalid');
|
||||
});
|
||||
|
||||
it('fails fast when token file has no valid access token', async () => {
|
||||
createGhcpAccount('ghcp-missing-token', { access_token: ' ' });
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 })));
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const result = await fetchGhcpQuota('ghcp-missing-token');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No access token in auth file');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* Runtime Quota Monitor Unit Tests
|
||||
*
|
||||
* Tests the quota monitor lifecycle:
|
||||
* - startQuotaMonitor / stopQuotaMonitor behavior
|
||||
* - No-op conditions for unsupported providers, manual mode, disabled config
|
||||
* - Idempotent stopQuotaMonitor
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
startQuotaMonitor,
|
||||
stopQuotaMonitor,
|
||||
clearQuotaCache,
|
||||
} from '../../../src/cliproxy/quota-manager';
|
||||
import { __testExports as executorTestExports } from '../../../src/cliproxy/executor';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
let origCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-monitor-'));
|
||||
origCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
clearQuotaCache(); // Clean cache between tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopQuotaMonitor(); // Clean up any active timers
|
||||
clearQuotaCache();
|
||||
if (origCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = origCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Runtime Quota Monitor', () => {
|
||||
describe('resolveRuntimeQuotaMonitorProviders', () => {
|
||||
it('should monitor the default provider for non-composite sessions', () => {
|
||||
expect(executorTestExports.resolveRuntimeQuotaMonitorProviders('codex', [])).toEqual([
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should monitor every unique managed provider in composite sessions', () => {
|
||||
expect(
|
||||
executorTestExports.resolveRuntimeQuotaMonitorProviders('claude', [
|
||||
'codex',
|
||||
'qwen',
|
||||
'gemini',
|
||||
'codex',
|
||||
])
|
||||
).toEqual(['codex', 'gemini']);
|
||||
});
|
||||
|
||||
it('should skip unsupported non-composite providers', () => {
|
||||
expect(executorTestExports.resolveRuntimeQuotaMonitorProviders('qwen', [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startQuotaMonitor', () => {
|
||||
it('should accept all quota-supported providers without throwing', () => {
|
||||
for (const provider of ['agy', 'claude', 'codex', 'gemini', 'ghcp'] as const) {
|
||||
expect(() => {
|
||||
startQuotaMonitor(provider, 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore unsupported providers without throwing', () => {
|
||||
expect(() => {
|
||||
startQuotaMonitor('qwen', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept agy provider without throwing', () => {
|
||||
// Setup config
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false, // Disabled to avoid actual polling
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should start independent monitors for distinct managed provider accounts', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const fakeTimer = {
|
||||
unref: () => fakeTimer,
|
||||
ref: () => fakeTimer,
|
||||
hasRef: () => false,
|
||||
refresh: () => fakeTimer,
|
||||
} as unknown as ReturnType<typeof setTimeout>;
|
||||
const observedDelays: Array<number | undefined> = [];
|
||||
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => {
|
||||
observedDelays.push(timeout);
|
||||
void handler;
|
||||
return fakeTimer;
|
||||
}) as typeof globalThis.setTimeout;
|
||||
|
||||
try {
|
||||
startQuotaMonitor('codex', 'codex@example.com');
|
||||
startQuotaMonitor('gemini', 'gemini@example.com');
|
||||
startQuotaMonitor('codex', 'codex@example.com');
|
||||
} finally {
|
||||
stopQuotaMonitor();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
expect(observedDelays).toEqual([300_000, 300_000]);
|
||||
});
|
||||
|
||||
it('should be no-op when config missing or no quota_management', () => {
|
||||
// No config file — should not throw
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle manual mode gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'manual',
|
||||
runtime_monitor: {
|
||||
enabled: true,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle disabled monitor gracefully', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopQuotaMonitor', () => {
|
||||
it('should be idempotent', () => {
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should complete safely when called without prior start', () => {
|
||||
// No prior startQuotaMonitor call
|
||||
expect(() => {
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple start/stop cycles', () => {
|
||||
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'unified-config.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
quota_management: {
|
||||
mode: 'auto',
|
||||
runtime_monitor: {
|
||||
enabled: false,
|
||||
normal_interval_seconds: 300,
|
||||
critical_interval_seconds: 60,
|
||||
warn_threshold: 20,
|
||||
exhaustion_threshold: 5,
|
||||
cooldown_minutes: 5,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
startQuotaMonitor('agy', 'test@gmail.com');
|
||||
stopQuotaMonitor();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Quota Response Cache Unit Tests
|
||||
*
|
||||
* Tests for in-memory quota caching with TTL expiration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import {
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
invalidateProviderCache,
|
||||
clearQuotaCache,
|
||||
getQuotaCacheStats,
|
||||
QUOTA_CACHE_TTL_MS,
|
||||
} from '../../../src/cliproxy/quota-response-cache';
|
||||
|
||||
interface TestQuota {
|
||||
success: boolean;
|
||||
buckets: { label: string; remainingPercent: number }[];
|
||||
}
|
||||
|
||||
describe('Quota Response Cache', () => {
|
||||
beforeEach(() => {
|
||||
clearQuotaCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearQuotaCache();
|
||||
});
|
||||
|
||||
describe('setCachedQuota and getCachedQuota', () => {
|
||||
it('should store and retrieve quota data', () => {
|
||||
const quota: TestQuota = {
|
||||
success: true,
|
||||
buckets: [{ label: 'Flash', remainingPercent: 80 }],
|
||||
};
|
||||
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com');
|
||||
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached?.success).toBe(true);
|
||||
expect(cached?.buckets[0].remainingPercent).toBe(80);
|
||||
});
|
||||
|
||||
it('should return null for non-existent cache entry', () => {
|
||||
const cached = getCachedQuota('gemini', 'nonexistent@example.com');
|
||||
expect(cached).toBeNull();
|
||||
});
|
||||
|
||||
it('should isolate cache entries by provider', () => {
|
||||
const geminiQuota: TestQuota = { success: true, buckets: [] };
|
||||
const codexQuota = { success: true, windows: [] };
|
||||
|
||||
setCachedQuota('gemini', 'user@example.com', geminiQuota);
|
||||
setCachedQuota('codex', 'user@example.com', codexQuota);
|
||||
|
||||
const cached1 = getCachedQuota<TestQuota>('gemini', 'user@example.com');
|
||||
const cached2 = getCachedQuota<{ windows: unknown[] }>('codex', 'user@example.com');
|
||||
|
||||
expect(cached1?.buckets).toBeDefined();
|
||||
expect(cached2?.windows).toBeDefined();
|
||||
});
|
||||
|
||||
it('should isolate cache entries by account', () => {
|
||||
const quota1: TestQuota = { success: true, buckets: [{ label: 'A', remainingPercent: 50 }] };
|
||||
const quota2: TestQuota = { success: true, buckets: [{ label: 'B', remainingPercent: 90 }] };
|
||||
|
||||
setCachedQuota('gemini', 'user1@example.com', quota1);
|
||||
setCachedQuota('gemini', 'user2@example.com', quota2);
|
||||
|
||||
const cached1 = getCachedQuota<TestQuota>('gemini', 'user1@example.com');
|
||||
const cached2 = getCachedQuota<TestQuota>('gemini', 'user2@example.com');
|
||||
|
||||
expect(cached1?.buckets[0].label).toBe('A');
|
||||
expect(cached2?.buckets[0].label).toBe('B');
|
||||
});
|
||||
|
||||
it('should update existing cache entry', () => {
|
||||
const quota1: TestQuota = { success: true, buckets: [{ label: 'X', remainingPercent: 30 }] };
|
||||
const quota2: TestQuota = { success: true, buckets: [{ label: 'X', remainingPercent: 70 }] };
|
||||
|
||||
setCachedQuota('gemini', 'user@example.com', quota1);
|
||||
setCachedQuota('gemini', 'user@example.com', quota2);
|
||||
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com');
|
||||
expect(cached?.buckets[0].remainingPercent).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache TTL expiration', () => {
|
||||
it('should return data within TTL', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
|
||||
// Immediately retrieve (well within TTL)
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com');
|
||||
expect(cached).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for expired cache with custom TTL', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
|
||||
// Request with 0ms TTL (effectively expired immediately)
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com', 0);
|
||||
expect(cached).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for expired cache entry', async () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
|
||||
// Wait briefly and use very short TTL
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com', 5);
|
||||
expect(cached).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete expired entries on access', async () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
|
||||
// First access should find it
|
||||
const stats1 = getQuotaCacheStats();
|
||||
expect(stats1.size).toBe(1);
|
||||
|
||||
// Access with short TTL should expire and delete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
getCachedQuota('gemini', 'user@example.com', 5);
|
||||
|
||||
// Entry should be deleted
|
||||
const stats2 = getQuotaCacheStats();
|
||||
expect(stats2.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateQuotaCache', () => {
|
||||
it('should invalidate specific account cache', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
setCachedQuota('gemini', 'other@example.com', quota);
|
||||
|
||||
invalidateQuotaCache('gemini', 'user@example.com');
|
||||
|
||||
expect(getCachedQuota('gemini', 'user@example.com')).toBeNull();
|
||||
expect(getCachedQuota('gemini', 'other@example.com')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should be safe to call on non-existent entry', () => {
|
||||
// Should not throw
|
||||
invalidateQuotaCache('gemini', 'nonexistent@example.com');
|
||||
expect(getCachedQuota('gemini', 'nonexistent@example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateProviderCache', () => {
|
||||
it('should invalidate all accounts for a provider', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user1@example.com', quota);
|
||||
setCachedQuota('gemini', 'user2@example.com', quota);
|
||||
setCachedQuota('codex', 'user1@example.com', quota);
|
||||
|
||||
invalidateProviderCache('gemini');
|
||||
|
||||
expect(getCachedQuota('gemini', 'user1@example.com')).toBeNull();
|
||||
expect(getCachedQuota('gemini', 'user2@example.com')).toBeNull();
|
||||
expect(getCachedQuota('codex', 'user1@example.com')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should be safe to call for non-existent provider', () => {
|
||||
// Should not throw
|
||||
invalidateProviderCache('nonexistent');
|
||||
expect(getQuotaCacheStats().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearQuotaCache', () => {
|
||||
it('should clear all cache entries', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user1@example.com', quota);
|
||||
setCachedQuota('gemini', 'user2@example.com', quota);
|
||||
setCachedQuota('codex', 'user@example.com', quota);
|
||||
setCachedQuota('agy', 'user@example.com', quota);
|
||||
|
||||
const statsBefore = getQuotaCacheStats();
|
||||
expect(statsBefore.size).toBe(4);
|
||||
|
||||
clearQuotaCache();
|
||||
|
||||
const statsAfter = getQuotaCacheStats();
|
||||
expect(statsAfter.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuotaCacheStats', () => {
|
||||
it('should return correct cache size', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user1@example.com', quota);
|
||||
setCachedQuota('codex', 'user2@example.com', quota);
|
||||
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.size).toBe(2);
|
||||
});
|
||||
|
||||
it('should return cache entry keys', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('gemini', 'user@example.com', quota);
|
||||
setCachedQuota('codex', 'other@example.com', quota);
|
||||
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.entries).toContain('gemini:user@example.com');
|
||||
expect(stats.entries).toContain('codex:other@example.com');
|
||||
});
|
||||
|
||||
it('should return empty stats for empty cache', () => {
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.size).toBe(0);
|
||||
expect(stats.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QUOTA_CACHE_TTL_MS constant', () => {
|
||||
it('should be 2 minutes (120000ms)', () => {
|
||||
expect(QUOTA_CACHE_TTL_MS).toBe(2 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache key generation', () => {
|
||||
it('should handle special characters in account IDs', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
const accountWithPlus = 'user+tag@example.com';
|
||||
const accountWithDots = 'first.last@example.com';
|
||||
|
||||
setCachedQuota('gemini', accountWithPlus, quota);
|
||||
setCachedQuota('gemini', accountWithDots, quota);
|
||||
|
||||
expect(getCachedQuota('gemini', accountWithPlus)).not.toBeNull();
|
||||
expect(getCachedQuota('gemini', accountWithDots)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty strings gracefully', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
setCachedQuota('', '', quota);
|
||||
|
||||
// Should still work, even if unusual
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.entries).toContain(':');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent access patterns', () => {
|
||||
it('should handle rapid set/get operations', () => {
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
|
||||
// Simulate rapid updates
|
||||
for (let i = 0; i < 100; i++) {
|
||||
setCachedQuota('gemini', 'user@example.com', {
|
||||
...quota,
|
||||
buckets: [{ label: `iter-${i}`, remainingPercent: i }],
|
||||
});
|
||||
}
|
||||
|
||||
const cached = getCachedQuota<TestQuota>('gemini', 'user@example.com');
|
||||
expect(cached?.buckets[0].label).toBe('iter-99');
|
||||
});
|
||||
|
||||
it('should handle multiple providers simultaneously', () => {
|
||||
const providers = ['gemini', 'codex', 'agy'];
|
||||
const accounts = ['user1@example.com', 'user2@example.com'];
|
||||
const quota: TestQuota = { success: true, buckets: [] };
|
||||
|
||||
// Set cache for all combinations
|
||||
for (const provider of providers) {
|
||||
for (const account of accounts) {
|
||||
setCachedQuota(provider, account, {
|
||||
...quota,
|
||||
buckets: [{ label: `${provider}-${account}`, remainingPercent: 50 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stats = getQuotaCacheStats();
|
||||
expect(stats.size).toBe(6);
|
||||
|
||||
// Verify all entries exist
|
||||
for (const provider of providers) {
|
||||
for (const account of accounts) {
|
||||
const cached = getCachedQuota<TestQuota>(provider, account);
|
||||
expect(cached?.buckets[0].label).toBe(`${provider}-${account}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { fetchRemoteAuthStatus } from '../../../src/cliproxy/remote-auth-fetcher';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
|
||||
describe('remote-auth-fetcher', () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
const remoteTarget: ProxyTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
authToken: 'token',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('includes provider on each remote account', async () => {
|
||||
global.fetch = mock((url: string) => {
|
||||
expect(url).toBe('https://remote.example.com:8317/v0/management/auth-files');
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
id: 'acc-codex',
|
||||
name: 'codex-main',
|
||||
type: 'oauth',
|
||||
provider: 'codex',
|
||||
email: 'codex@example.com',
|
||||
status: 'active',
|
||||
source: 'file',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchRemoteAuthStatus(remoteTarget);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.provider).toBe('codex');
|
||||
expect(result[0]?.accounts).toHaveLength(1);
|
||||
expect(result[0]?.accounts[0]?.provider).toBe('codex');
|
||||
expect(result[0]?.accounts[0]?.email).toBe('codex@example.com');
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { CodexReasoningProxy } from '../../../src/cliproxy/codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../../../src/cliproxy/tool-sanitization-proxy';
|
||||
|
||||
const SELF_SIGNED_KEY = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGH5Akfta94U5O
|
||||
X7LZIeoRN9pOKiXuqiTnIrOh/dW/RZJp1Fg0M+t+Gv/WFu/KOMrUwpsPZ8ectOCy
|
||||
cwdv+ME/+4RlAiGmALLinlDqCPv0Z8gRiZpfEeOinzrnBR26fphtebdlRF4Xy0rI
|
||||
QcnMsC6Wr1V484CP5iM6GAdVP+gXoYINV2VtooXS/ViptmxLMqMqhjDhz6ZUfEP4
|
||||
XmQU73eM8/OaQSRIqJx1UyByERjgz0m/ySHAStS/IIKyjZVB9i0iZtwqO8Jl63ib
|
||||
rzxKehVNDB0iCW9BawpQx6tqoO/hXKIUNPQaBQ8GfLwM7lCvGVSNuPLsB5mf1V65
|
||||
6UqvNVEfAgMBAAECggEAQ6Nm7G65FV4kA7G8N1DSvkoZJ3JQPOitbJN2TCmCnag8
|
||||
0hCChF/tV8IT8Z0nBBzbckN1+I5voVpHE+Udduav4w4VJv7RSgEXETMHYL7sdYYw
|
||||
0XvuzeInms9Npq0idxbdJxLUv6fVj74Bq9h58n17ikoVnhNToszo3d5yMnJ85APP
|
||||
tTHPJrv4oQYcrYfxksuL3RRqy4VpH/E8zA2AlakbuxfrwcqJ/kqvHCBFd/l3qfnf
|
||||
U06ZqxZDYvZW9GYMvao+e2XLxT44A50EsTH0C3XIMsGGRVO+4ZzhbUBSDOgxx9pl
|
||||
Lg75MrpKpFZSGO5EqjT1SX4E3eZijerHaqBS3P5sQQKBgQDnJgYyToRUq3Fb4pHF
|
||||
XelW5cVg93XA/XtFWcx9ndwGRtwFYVs+b63MhILGGyDzsDzsmUjyNK013ldMTp0F
|
||||
6TpMUFsc0mpyf63khNUyw7J/le447zZgwAEv/IaDf0CepCfJ149a7Y7jbZPG2R43
|
||||
ldFOxV1t8ERIiZnDd4iZjG9O7wKBgQDbbJMT8p6CGws5ZqKu0DYRbWoPvSFvMt3K
|
||||
f5rXKl+PhhSOkljYXAzCi2Cjp/s/fU5cirtxf/If7ZqfR+VJwrJyzLvS/P4JDahI
|
||||
HOiTulauN7QWmgHCifmHGX33Rwupo2ctUDXvMmrN53tLTKkQJVKODMmnlaQ8SDfu
|
||||
CVTjyQgg0QKBgQDeND52s+YgYuNJeme5fMrof1+cFKc9TC/pfibHhy5RVmMCRRHq
|
||||
1n8UATqZ6NBnkr8ujziBpcPw6fmv4E6wtQEXBZRhA6HSygzHhi5Ra7E3V1E3qDX1
|
||||
Ef7SO4av+G+NUa7yKOeleIMI/Hi4ClYzBzG78J0dJ4Ds3mJTdqB5Q3hpPwKBgGSR
|
||||
8R+Vdl0/s/dNOYdSW1XFhnVMRITJFar4rams/Du2QrnODfasyhRo+ZRJK/k/n6j+
|
||||
1UcCU6Gar+INe+RA3rWLbRMZbf4MSnMy/M6o+43CbkJluCTIRJxNbNTntUq9YE7+
|
||||
kpndy+IwFOzAaETENoBxEARFrA8NMsVhdY0p2RXhAoGBAMKLDojNYSoF+Wp2nsSJ
|
||||
Ahdwyrsk4eJSiFqr2g4wI1Ehhk/mOtYrDlmZZWQ3i0FC2xmAXDUKrBNgcNqoWvJg
|
||||
tIXz8cRN0dmAMNeAjJ4dCBFnGUAV9D6KdUHPqhLhlMHpX3uc7h1/ya6/9gfzZNC5
|
||||
EQfxhCAaAvIvuZUSu4hJvM5U
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
const SELF_SIGNED_CERT = `-----BEGIN CERTIFICATE-----
|
||||
MIIDJTCCAg2gAwIBAgIUHdVZVRUzHman7tQL4dHoBEiRYkcwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyNDAyMzEwNFoXDTM2MDQy
|
||||
MTAyMzEwNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAxh+QJH7WveFOTl+y2SHqETfaTiol7qok5yKzof3Vv0WS
|
||||
adRYNDPrfhr/1hbvyjjK1MKbD2fHnLTgsnMHb/jBP/uEZQIhpgCy4p5Q6gj79GfI
|
||||
EYmaXxHjop865wUdun6YbXm3ZUReF8tKyEHJzLAulq9VePOAj+YjOhgHVT/oF6GC
|
||||
DVdlbaKF0v1YqbZsSzKjKoYw4c+mVHxD+F5kFO93jPPzmkEkSKicdVMgchEY4M9J
|
||||
v8khwErUvyCCso2VQfYtImbcKjvCZet4m688SnoVTQwdIglvQWsKUMeraqDv4Vyi
|
||||
FDT0GgUPBny8DO5QrxlUjbjy7AeZn9VeuelKrzVRHwIDAQABo28wbTAdBgNVHQ4E
|
||||
FgQUBqHCiP54ZRWvDOanxmdG9ZORLCYwHwYDVR0jBBgwFoAUBqHCiP54ZRWvDOan
|
||||
xmdG9ZORLCYwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
|
||||
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIdjGd8iL83z+6tv0cglBBXwJb8LZQT0
|
||||
u++ArvAgjxEiCjojfKYyRkfKPPAxaidKZ2MLZMGnrxEg2vCBeTvmpR57HE8VL67N
|
||||
Jijusc0dD3dRCqQHY7IgyxJELN4scSAzAes/LJiP3GgSvqt+RN/ltkJkXMCdeOoV
|
||||
61S8jUD36NeqOPLkbCUwr2gqRfnzt27BH+9LCUufg2VlV1fBkIImqn37h80/HZtc
|
||||
q7VFWuoYwwxog/mqnr9LtIzrfEzrB4V5lf4ZF9ZWtjSC0z++y2RFX2WFnFRLT1Xh
|
||||
JpvCX2cYs9gOGe/Ite2Qt35tJNZYDm4UJlCb2EJHQJsDnB4rqvNFRb0=
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
function listen(server: http.Server | https.Server): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve((server.address() as AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: http.Server | https.Server): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const fallback = setTimeout(resolve, 100);
|
||||
server.close(() => {
|
||||
clearTimeout(fallback);
|
||||
resolve();
|
||||
});
|
||||
server.closeAllConnections?.();
|
||||
server.closeIdleConnections?.();
|
||||
});
|
||||
}
|
||||
|
||||
describe('remote proxy chain regression', () => {
|
||||
it('lets the local Codex reasoning proxy connect directly to self-signed HTTPS upstreams', async () => {
|
||||
let forwardedPath = '';
|
||||
let forwardedReasoningEffort: unknown;
|
||||
const upstream = https.createServer(
|
||||
{ key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT },
|
||||
(req, res) => {
|
||||
forwardedPath = req.url || '';
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
forwardedReasoningEffort = parsed.reasoning?.effort;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] }));
|
||||
});
|
||||
}
|
||||
);
|
||||
const upstreamPort = await listen(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `https://localhost:${upstreamPort}`,
|
||||
allowSelfSigned: true,
|
||||
stripPathPrefix: '/api/provider/codex',
|
||||
defaultEffort: 'medium',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-5.4', messages: [] }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] });
|
||||
expect(forwardedPath).toBe('/v1/messages');
|
||||
expect(forwardedReasoningEffort).toBe('xhigh');
|
||||
} finally {
|
||||
proxy.stop();
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes Codex through tool sanitization to a self-signed remote HTTPS proxy', async () => {
|
||||
let forwardedPath = '';
|
||||
let forwardedReasoningEffort: unknown;
|
||||
const upstream = https.createServer(
|
||||
{ key: SELF_SIGNED_KEY, cert: SELF_SIGNED_CERT },
|
||||
(req, res) => {
|
||||
forwardedPath = req.url || '';
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
forwardedReasoningEffort = parsed.reasoning?.effort;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] }));
|
||||
});
|
||||
}
|
||||
);
|
||||
const upstreamPort = await listen(upstream);
|
||||
const toolProxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: `https://localhost:${upstreamPort}`,
|
||||
allowSelfSigned: true,
|
||||
warnOnSanitize: false,
|
||||
});
|
||||
let codexProxy: CodexReasoningProxy | null = null;
|
||||
|
||||
try {
|
||||
const toolPort = await toolProxy.start();
|
||||
codexProxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${toolPort}`,
|
||||
stripPathPrefix: '/api/provider/codex',
|
||||
defaultEffort: 'medium',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
},
|
||||
});
|
||||
const codexPort = await codexProxy.start();
|
||||
const response = await fetch(`http://127.0.0.1:${codexPort}/api/provider/codex/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.4',
|
||||
messages: [],
|
||||
tools: [{ name: 'read', input_schema: {}, cache_control: { type: 'ephemeral' } }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ content: [{ type: 'text', text: 'ok' }] });
|
||||
expect(forwardedPath).toBe('/v1/messages');
|
||||
expect(forwardedReasoningEffort).toBe('xhigh');
|
||||
} finally {
|
||||
codexProxy?.stop();
|
||||
toolProxy.stop();
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Unit tests for remote-proxy-client module
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
type RemoteProxyClientConfig,
|
||||
type RemoteProxyStatus,
|
||||
} from '../../../src/cliproxy/remote-proxy-client';
|
||||
|
||||
// We test the module's type exports and error handling logic
|
||||
// Actual HTTP calls are not mocked in this unit test - use integration tests for that
|
||||
|
||||
describe('remote-proxy-client', () => {
|
||||
describe('type exports', () => {
|
||||
it('should export RemoteProxyClientConfig interface', () => {
|
||||
// Type-level test - ensure the interface shape is correct
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
authToken: 'test-token',
|
||||
timeout: 2000,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
expect(config.host).toBe('localhost');
|
||||
expect(config.port).toBe(8317);
|
||||
expect(config.protocol).toBe('http');
|
||||
});
|
||||
|
||||
it('should export RemoteProxyStatus interface', () => {
|
||||
// Success case
|
||||
const successStatus: RemoteProxyStatus = {
|
||||
reachable: true,
|
||||
latencyMs: 50,
|
||||
};
|
||||
expect(successStatus.reachable).toBe(true);
|
||||
expect(successStatus.latencyMs).toBe(50);
|
||||
|
||||
// Error case
|
||||
const errorStatus: RemoteProxyStatus = {
|
||||
reachable: false,
|
||||
error: 'Connection refused',
|
||||
errorCode: 'CONNECTION_REFUSED',
|
||||
};
|
||||
expect(errorStatus.reachable).toBe(false);
|
||||
expect(errorStatus.error).toBe('Connection refused');
|
||||
expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteProxyErrorCode', () => {
|
||||
it('should define expected error codes', () => {
|
||||
const validCodes = [
|
||||
'CONNECTION_REFUSED',
|
||||
'TIMEOUT',
|
||||
'AUTH_FAILED',
|
||||
'DNS_FAILED',
|
||||
'NETWORK_UNREACHABLE',
|
||||
'UNKNOWN',
|
||||
];
|
||||
|
||||
// Type-level test - ensure error codes can be used
|
||||
const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' };
|
||||
const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' };
|
||||
const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' };
|
||||
const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' };
|
||||
const status5: RemoteProxyStatus = { reachable: false, errorCode: 'DNS_FAILED' };
|
||||
const status6: RemoteProxyStatus = { reachable: false, errorCode: 'NETWORK_UNREACHABLE' };
|
||||
|
||||
expect(validCodes).toContain(status1.errorCode);
|
||||
expect(validCodes).toContain(status2.errorCode);
|
||||
expect(validCodes).toContain(status3.errorCode);
|
||||
expect(validCodes).toContain(status4.errorCode);
|
||||
expect(validCodes).toContain(status5.errorCode);
|
||||
expect(validCodes).toContain(status6.errorCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config validation', () => {
|
||||
it('should require host and port', () => {
|
||||
const minimalConfig: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
expect(minimalConfig.host).toBeDefined();
|
||||
expect(minimalConfig.port).toBeDefined();
|
||||
expect(minimalConfig.protocol).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow optional fields', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
authToken: 'secret',
|
||||
timeout: 5000,
|
||||
allowSelfSigned: true,
|
||||
};
|
||||
expect(config.authToken).toBe('secret');
|
||||
expect(config.timeout).toBe(5000);
|
||||
expect(config.allowSelfSigned).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept http and https protocols', () => {
|
||||
const httpConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const httpsConfig: RemoteProxyClientConfig = {
|
||||
host: 'localhost',
|
||||
port: 8317,
|
||||
protocol: 'https',
|
||||
};
|
||||
expect(httpConfig.protocol).toBe('http');
|
||||
expect(httpsConfig.protocol).toBe('https');
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check URL construction', () => {
|
||||
// CLIProxyAPI uses root endpoint for liveness checks (no /health endpoint)
|
||||
it('should construct correct health check URL pattern using /', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`;
|
||||
expect(expectedUrl).toBe('http://192.168.1.100:8317/');
|
||||
});
|
||||
|
||||
it('should construct HTTPS URL when protocol is https', () => {
|
||||
const config: RemoteProxyClientConfig = {
|
||||
host: 'secure.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
};
|
||||
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`;
|
||||
expect(expectedUrl).toBe('https://secure.example.com:443/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('port defaults by protocol', () => {
|
||||
// Document expected default ports based on protocol
|
||||
// HTTP: 8317 (CLIProxyAPI default for local/dev scenarios)
|
||||
// HTTPS: 443 (standard SSL port for production remote servers)
|
||||
|
||||
it('should document HTTP default port as 8317', () => {
|
||||
// HTTP connections default to CLIProxyAPI port 8317
|
||||
// This matches local development scenarios
|
||||
const expectedHttpDefault = 8317;
|
||||
expect(expectedHttpDefault).toBe(8317);
|
||||
});
|
||||
|
||||
it('should document HTTPS default port as 443', () => {
|
||||
// HTTPS connections default to standard SSL port 443
|
||||
// This matches production remote server scenarios
|
||||
const expectedHttpsDefault = 443;
|
||||
expect(expectedHttpsDefault).toBe(443);
|
||||
});
|
||||
|
||||
it('should allow port to be optional in config', () => {
|
||||
// Port is optional - when undefined, defaults based on protocol
|
||||
const configWithoutPort: RemoteProxyClientConfig = {
|
||||
host: 'example.com',
|
||||
protocol: 'https',
|
||||
// port is intentionally undefined
|
||||
};
|
||||
expect(configWithoutPort.port).toBeUndefined();
|
||||
expect(configWithoutPort.protocol).toBe('https');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* Remote Token Uploader Tests
|
||||
*
|
||||
* Tests for uploadTokenToRemote and related functions.
|
||||
* Uses mock fetch to verify API interactions.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { mockFetch, restoreFetch, getCapturedFetchRequests } from '../../mocks';
|
||||
|
||||
describe('remote-token-uploader', () => {
|
||||
let tempDir: string;
|
||||
let tempTokenFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory and token file
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
tempTokenFile = path.join(tempDir, 'test-token.json');
|
||||
fs.writeFileSync(
|
||||
tempTokenFile,
|
||||
JSON.stringify({
|
||||
type: 'gemini',
|
||||
email: 'test@example.com',
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
|
||||
// Clean up temp files
|
||||
try {
|
||||
fs.unlinkSync(tempTokenFile);
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('uploadTokenToRemote', () => {
|
||||
it('should upload token file successfully', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/auth-files/,
|
||||
method: 'POST',
|
||||
response: { status: 'ok', id: 'uploaded-123' },
|
||||
},
|
||||
]);
|
||||
|
||||
const url = 'http://127.0.0.1:8317/v0/management/auth-files';
|
||||
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
|
||||
const fileName = path.basename(tempTokenFile);
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([tokenContent], { type: 'application/json' });
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-mgmt-key',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result).toHaveProperty('status', 'ok');
|
||||
expect(result).toHaveProperty('id', 'uploaded-123');
|
||||
|
||||
// Verify request was captured correctly
|
||||
const captured = getCapturedFetchRequests();
|
||||
expect(captured.length).toBe(1);
|
||||
expect(captured[0].url).toContain('/v0/management/auth-files');
|
||||
expect(captured[0].method).toBe('POST');
|
||||
// Headers should be captured (case-preserved from plain object)
|
||||
expect(captured[0].headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
});
|
||||
|
||||
it('should handle upload failure gracefully', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/auth-files/,
|
||||
method: 'POST',
|
||||
response: { error: 'Unauthorized' },
|
||||
status: 401,
|
||||
},
|
||||
]);
|
||||
|
||||
const url = 'http://127.0.0.1:8317/v0/management/auth-files';
|
||||
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([tokenContent], { type: 'application/json' }), 'test.json');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(false);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle connection timeout', async () => {
|
||||
// Test that delay mechanism works (simulates network latency)
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/auth-files/,
|
||||
method: 'POST',
|
||||
delay: 100, // Simulate slow response
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const url = 'http://127.0.0.1:8317/v0/management/auth-files';
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
|
||||
// Verify delay was applied (allow 5ms tolerance for system timing variance)
|
||||
expect(elapsedTime).toBeGreaterThanOrEqual(95);
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle connection refused', async () => {
|
||||
// Try to connect to a port with nothing listening
|
||||
const url = 'http://127.0.0.1:59999/v0/management/auth-files';
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
// Should not reach here in most cases
|
||||
} catch (error) {
|
||||
// Connection refused is expected
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemoteUploadEnabled', () => {
|
||||
it('should return false when not in remote mode', () => {
|
||||
// Test the logic directly
|
||||
const target = { isRemote: false, authToken: 'token' };
|
||||
const result = target.isRemote && Boolean(target.authToken);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when remote but no auth', () => {
|
||||
const target = { isRemote: true, authToken: undefined, managementKey: undefined };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when remote with authToken', () => {
|
||||
const target = { isRemote: true, authToken: 'token', managementKey: undefined };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when remote with managementKey', () => {
|
||||
const target = { isRemote: true, authToken: undefined, managementKey: 'mgmt-key' };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer managementKey over authToken', () => {
|
||||
const target = { isRemote: true, authToken: 'auth', managementKey: 'mgmt' };
|
||||
const key = target.managementKey ?? target.authToken;
|
||||
expect(key).toBe('mgmt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token file validation', () => {
|
||||
it('should reject invalid JSON', async () => {
|
||||
// Create invalid token file
|
||||
const invalidTokenFile = path.join(tempDir, 'invalid.json');
|
||||
fs.writeFileSync(invalidTokenFile, 'not valid json {{{');
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(invalidTokenFile, 'utf-8');
|
||||
JSON.parse(content);
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('JSON');
|
||||
} finally {
|
||||
fs.unlinkSync(invalidTokenFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing file', () => {
|
||||
try {
|
||||
fs.readFileSync('/nonexistent/path/token.json', 'utf-8');
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('ENOENT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipart/form-data construction', () => {
|
||||
it('should construct valid FormData with file field', () => {
|
||||
const tokenContent = JSON.stringify({ type: 'test', token: 'abc' });
|
||||
const fileName = 'oauth-token.json';
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([tokenContent], { type: 'application/json' });
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
// FormData should have the file
|
||||
expect(formData.has('file')).toBe(true);
|
||||
|
||||
const file = formData.get('file') as File;
|
||||
expect(file).toBeDefined();
|
||||
expect(file.name).toBe(fileName);
|
||||
expect(file.type).toContain('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization header', () => {
|
||||
it('should use Bearer token format', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { status: 'ok' } }]);
|
||||
|
||||
const authToken = 'my-secret-token-123';
|
||||
await fetch('http://127.0.0.1:8317/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
const captured = getCapturedFetchRequests();
|
||||
expect(captured.length).toBe(1);
|
||||
// Headers case-preserved from plain object
|
||||
expect(captured[0].headers['Authorization']).toBe(`Bearer ${authToken}`);
|
||||
});
|
||||
|
||||
it('should not send Authorization when no token', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { status: 'ok' } }]);
|
||||
|
||||
await fetch('http://127.0.0.1:8317/test', {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
const captured = getCapturedFetchRequests();
|
||||
expect(captured.length).toBe(1);
|
||||
// No authorization header should be present (check both cases)
|
||||
expect(captured[0].headers['Authorization']).toBeUndefined();
|
||||
expect(captured[0].headers['authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response parsing', () => {
|
||||
it('should accept status: ok response', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { status: 'ok' } }]);
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8317/test', { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
const isSuccess = result.status === 'ok' || result.success || result.id;
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept success: true response', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { success: true } }]);
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8317/test', { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
const isSuccess = result.status === 'ok' || result.success || result.id;
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept id in response', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { id: 'file-abc123' } }]);
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8317/test', { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
// result.id is truthy when present
|
||||
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect error response', async () => {
|
||||
mockFetch([{ url: /\/test/, method: 'POST', response: { error: 'Invalid token format' } }]);
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8317/test', { method: 'POST' });
|
||||
const result = (await response.json()) as {
|
||||
status?: string;
|
||||
success?: boolean;
|
||||
id?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Error response: none of the success indicators are present
|
||||
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
|
||||
expect(isSuccess).toBe(false);
|
||||
expect(result.error).toBe('Invalid token format');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
|
||||
async function loadRoutingHttpModule() {
|
||||
return import(
|
||||
`../../../src/cliproxy/routing-strategy-http?test=${Date.now()}-${Math.random()}`
|
||||
) as Promise<typeof import('../../../src/cliproxy/routing-strategy-http')>;
|
||||
}
|
||||
|
||||
describe('routing-strategy-http', () => {
|
||||
it('builds the local management URL for routing strategy reads', async () => {
|
||||
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
|
||||
const target: ProxyTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
|
||||
expect(getCliproxyRoutingManagementUrl(target)).toBe(
|
||||
'http://127.0.0.1:8317/v0/management/routing/strategy'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the remote management URL for routing strategy writes', async () => {
|
||||
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
|
||||
const target: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
allowSelfSigned: true,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
expect(getCliproxyRoutingManagementUrl(target)).toBe(
|
||||
'https://proxy.example.com:443/v0/management/routing/strategy'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('cliproxy routing strategy service', () => {
|
||||
let tempHome = '';
|
||||
let scopedConfigDir = '';
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let runWithScopedConfigDir: <T>(ccsDir: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
let routingTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http' as const,
|
||||
isRemote: false,
|
||||
};
|
||||
let responseFactory: (() => Promise<Response>) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-'));
|
||||
scopedConfigDir = path.join(tempHome, '.ccs');
|
||||
routingTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
responseFactory = null;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_DIR = scopedConfigDir;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
async function withScopedConfig<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
return await runWithScopedConfigDir(scopedConfigDir, fn);
|
||||
}
|
||||
|
||||
async function loadRoutingModule() {
|
||||
mock.module('../../../src/cliproxy/routing-strategy-http', () => ({
|
||||
getCliproxyRoutingTarget: () => routingTarget,
|
||||
fetchCliproxyRoutingResponse: () => {
|
||||
if (!responseFactory) {
|
||||
throw new Error('routing unavailable');
|
||||
}
|
||||
return responseFactory();
|
||||
},
|
||||
getRoutingErrorMessage: async (response: Response, fallback: string) => {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return body?.error || fallback;
|
||||
},
|
||||
}));
|
||||
|
||||
return import(`../../../src/cliproxy/routing-strategy?test=${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
it('normalizes canonical and shorthand strategy values', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
|
||||
expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin');
|
||||
expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin');
|
||||
expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first');
|
||||
expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first');
|
||||
expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the saved local default when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = { strategy: 'fill-first' };
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxyRoutingState();
|
||||
|
||||
expect(state.strategy).toBe('fill-first');
|
||||
expect(state.source).toBe('config');
|
||||
expect(state.target).toBe('local');
|
||||
expect(state.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the local startup default even when the live proxy is down', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
|
||||
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.strategy).toBe('fill-first');
|
||||
|
||||
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const persisted = loadUnifiedConfig();
|
||||
expect(persisted?.cliproxy?.routing?.strategy).toBe('fill-first');
|
||||
});
|
||||
});
|
||||
|
||||
it('reads and writes remote strategy without mutating the local default', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
let methodCount = 0;
|
||||
responseFactory = async () => {
|
||||
methodCount += 1;
|
||||
return new Response(JSON.stringify({ strategy: 'fill-first' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const readState = await mod.readCliproxyRoutingState();
|
||||
const writeState = await mod.applyCliproxyRoutingStrategy('fill-first');
|
||||
|
||||
expect(readState.strategy).toBe('fill-first');
|
||||
expect(readState.target).toBe('remote');
|
||||
expect(writeState.applied).toBe('live');
|
||||
expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin');
|
||||
expect(methodCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes session-affinity booleans and TTL values', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled(true)).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('on')).toBe(true);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('false')).toBe(false);
|
||||
expect(mod.normalizeCliproxySessionAffinityEnabled('maybe')).toBeNull();
|
||||
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('1h')).toBe('1h');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('2h30m')).toBe('2h30m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl(' 15m ')).toBe('15m');
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('0s')).toBeNull();
|
||||
expect(mod.normalizeCliproxySessionAffinityTtl('tomorrow')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads saved local session-affinity settings when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cliproxy) {
|
||||
config.cliproxy.routing = {
|
||||
strategy: 'round-robin',
|
||||
session_affinity: true,
|
||||
session_affinity_ttl: '2h30m',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.enabled).toBe(true);
|
||||
expect(state.ttl).toBe('2h30m');
|
||||
expect(state.source).toBe('config');
|
||||
expect(state.target).toBe('local');
|
||||
expect(state.manageable).toBe(true);
|
||||
expect(state.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists local session-affinity settings even when live CLIProxy is unavailable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '2h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.ttl).toBe('2h');
|
||||
|
||||
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const persisted = loadUnifiedConfig();
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity).toBe(true);
|
||||
expect(persisted?.cliproxy?.routing?.session_affinity_ttl).toBe('2h');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not claim live session-affinity application just because local CLIProxy is reachable', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '30m',
|
||||
});
|
||||
|
||||
expect(result.reachable).toBe(true);
|
||||
expect(result.applied).toBe('config-only');
|
||||
expect(result.message).toContain('does not verify live selector state yet');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports remote session-affinity management as unsupported', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
responseFactory = async () =>
|
||||
new Response(JSON.stringify({ strategy: 'round-robin' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.target).toBe('remote');
|
||||
expect(state.manageable).toBe(false);
|
||||
expect(state.reachable).toBe(true);
|
||||
expect(state.enabled).toBeUndefined();
|
||||
|
||||
const result = await mod.applyCliproxySessionAffinitySettings({
|
||||
enabled: true,
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('unsupported');
|
||||
expect(result.manageable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unsupported remote session-affinity as unreachable when remote routing probe fails', async () => {
|
||||
await withScopedConfig(async () => {
|
||||
routingTarget = {
|
||||
host: 'remote.example.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
isRemote: true,
|
||||
};
|
||||
responseFactory = null;
|
||||
|
||||
const mod = await loadRoutingModule();
|
||||
const state = await mod.readCliproxySessionAffinityState();
|
||||
|
||||
expect(state.source).toBe('unsupported');
|
||||
expect(state.reachable).toBe(false);
|
||||
expect(state.message).toContain('not reachable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,575 +0,0 @@
|
||||
/**
|
||||
* Schema Sanitizer Unit Tests
|
||||
*
|
||||
* Tests for Gemini-compatible tool input_schema sanitization.
|
||||
* Verifies that only Gemini-supported fields are preserved.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../src/cliproxy/schema-sanitizer';
|
||||
|
||||
describe('sanitizeInputSchema', () => {
|
||||
test('preserves Gemini-supported properties', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'The name' },
|
||||
age: { type: 'number', minimum: 0, maximum: 150 },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(0);
|
||||
expect(result.removedPaths).toEqual([]);
|
||||
expect(result.schema).toEqual(schema);
|
||||
});
|
||||
|
||||
test('removes non-standard properties at top level', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
cornerRadius: 8,
|
||||
fillColor: '#ffffff',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('cornerRadius');
|
||||
expect(result.removedPaths).toContain('fillColor');
|
||||
expect(result.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('removes non-standard properties from nested properties', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
style: {
|
||||
type: 'object',
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 12,
|
||||
properties: {
|
||||
color: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('properties.style.fontFamily');
|
||||
expect(result.removedPaths).toContain('properties.style.fontSize');
|
||||
expect(result.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
style: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('removes non-standard properties from array items', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
gap: 10,
|
||||
padding: 20,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('items.gap');
|
||||
expect(result.removedPaths).toContain('items.padding');
|
||||
expect(result.schema).toEqual({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves anyOf and sanitizes nested schemas', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{ type: 'string', customProp: 'remove' },
|
||||
{ type: 'number', anotherCustom: 123 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.schema).toEqual({
|
||||
type: 'object',
|
||||
anyOf: [{ type: 'string' }, { type: 'number' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('removes oneOf and allOf (not supported by Gemini)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
oneOf: [{ type: 'string' }, { type: 'number' }],
|
||||
allOf: [{ required: ['name'] }],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('oneOf');
|
||||
expect(result.removedPaths).toContain('allOf');
|
||||
expect(result.schema).toEqual({ type: 'object' });
|
||||
});
|
||||
|
||||
test('handles deeply nested structures', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
level1: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
level2: {
|
||||
type: 'object',
|
||||
uiHint: 'remove-me',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(1);
|
||||
expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint');
|
||||
});
|
||||
|
||||
test('strips "examples" field (issue #155)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute',
|
||||
examples: ['ls -la', 'git status'],
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in ms',
|
||||
examples: [5000, 10000],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('properties.command.examples');
|
||||
expect(result.removedPaths).toContain('properties.timeout.examples');
|
||||
expect(result.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'The command to execute' },
|
||||
timeout: { type: 'number', description: 'Timeout in ms' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps "example" (singular) but strips "examples" (plural)', () => {
|
||||
const schema = {
|
||||
type: 'string',
|
||||
example: 'hello',
|
||||
examples: ['hello', 'world'],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(1);
|
||||
expect(result.removedPaths).toContain('examples');
|
||||
expect(result.schema).toEqual({ type: 'string', example: 'hello' });
|
||||
});
|
||||
|
||||
test('strips Gemini-unsupported JSON Schema metadata fields', () => {
|
||||
const schema = {
|
||||
$id: 'https://example.com/schema',
|
||||
$schema: 'https://json-schema.org/draft-07/schema#',
|
||||
title: 'Test Schema',
|
||||
description: 'A test schema',
|
||||
type: 'string',
|
||||
examples: ['example1', 'example2'],
|
||||
deprecated: false,
|
||||
readOnly: true,
|
||||
$comment: 'Internal comment',
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
// $id, $schema, examples, deprecated, readOnly, $comment = 6 removed
|
||||
expect(result.removedCount).toBe(6);
|
||||
expect(result.removedPaths).toContain('$id');
|
||||
expect(result.removedPaths).toContain('$schema');
|
||||
expect(result.removedPaths).toContain('examples');
|
||||
expect(result.removedPaths).toContain('deprecated');
|
||||
expect(result.removedPaths).toContain('readOnly');
|
||||
expect(result.removedPaths).toContain('$comment');
|
||||
expect(result.schema).toEqual({
|
||||
title: 'Test Schema',
|
||||
description: 'A test schema',
|
||||
type: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
test('strips $defs, $ref, definitions (not supported by Gemini)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
$defs: {
|
||||
address: {
|
||||
type: 'object',
|
||||
properties: { street: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
home: { $ref: '#/$defs/address' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(2);
|
||||
expect(result.removedPaths).toContain('$defs');
|
||||
expect(result.removedPaths).toContain('properties.home.$ref');
|
||||
expect(result.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
home: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('strips additionalProperties (not supported by Gemini)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(1);
|
||||
expect(result.removedPaths).toContain('additionalProperties');
|
||||
});
|
||||
|
||||
test('strips const, not, if/then/else (not supported by Gemini)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
const: 'fixed',
|
||||
not: { type: 'number' },
|
||||
if: { properties: { type: { const: 'foo' } } },
|
||||
then: { required: ['foo'] },
|
||||
else: { required: ['bar'] },
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(5);
|
||||
expect(result.removedPaths).toContain('const');
|
||||
expect(result.removedPaths).toContain('not');
|
||||
expect(result.removedPaths).toContain('if');
|
||||
expect(result.removedPaths).toContain('then');
|
||||
expect(result.removedPaths).toContain('else');
|
||||
expect(result.schema).toEqual({ type: 'object' });
|
||||
});
|
||||
|
||||
test('strips number/array/object validation fields unsupported by Gemini', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
score: {
|
||||
type: 'number',
|
||||
exclusiveMinimum: 0,
|
||||
exclusiveMaximum: 100,
|
||||
multipleOf: 5,
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
uniqueItems: true,
|
||||
contains: { type: 'string' },
|
||||
additionalItems: false,
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
writeOnly: true,
|
||||
definitions: { addr: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedPaths).toContain('properties.score.exclusiveMinimum');
|
||||
expect(result.removedPaths).toContain('properties.score.exclusiveMaximum');
|
||||
expect(result.removedPaths).toContain('properties.score.multipleOf');
|
||||
expect(result.removedPaths).toContain('properties.tags.uniqueItems');
|
||||
expect(result.removedPaths).toContain('properties.tags.contains');
|
||||
expect(result.removedPaths).toContain('properties.tags.additionalItems');
|
||||
expect(result.removedPaths).toContain('properties.meta.writeOnly');
|
||||
expect(result.removedPaths).toContain('properties.meta.definitions');
|
||||
expect(result.removedCount).toBe(8);
|
||||
});
|
||||
|
||||
test('preserves example/default with complex values without recursion', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
example: ['value1', 'value2'],
|
||||
default: { nested: { deep: { unsupportedKey: true } } },
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(0);
|
||||
expect(result.schema.example).toEqual(['value1', 'value2']);
|
||||
expect(result.schema.default).toEqual({ nested: { deep: { unsupportedKey: true } } });
|
||||
});
|
||||
|
||||
test('handles empty properties and anyOf edge cases', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
anyOf: [],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(0);
|
||||
expect(result.schema.properties).toEqual({});
|
||||
expect(result.schema.anyOf).toEqual([]);
|
||||
});
|
||||
|
||||
test('preserves all Gemini-supported fields', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
format: 'date',
|
||||
title: 'Test',
|
||||
description: 'A test',
|
||||
nullable: true,
|
||||
example: { name: 'test' },
|
||||
default: {},
|
||||
enum: ['a', 'b'],
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name'],
|
||||
minProperties: 1,
|
||||
maxProperties: 10,
|
||||
items: { type: 'string' },
|
||||
minItems: 0,
|
||||
maxItems: 100,
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
pattern: '^[a-z]+$',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
anyOf: [{ type: 'string' }],
|
||||
propertyOrdering: ['name'],
|
||||
};
|
||||
|
||||
const result = sanitizeInputSchema(schema);
|
||||
|
||||
expect(result.removedCount).toBe(0);
|
||||
expect(result.removedPaths).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles empty schema', () => {
|
||||
const result = sanitizeInputSchema({});
|
||||
|
||||
expect(result.removedCount).toBe(0);
|
||||
expect(result.schema).toEqual({});
|
||||
});
|
||||
|
||||
test('handles null input gracefully', () => {
|
||||
const result = sanitizeInputSchema(null as any);
|
||||
expect(result.schema).toBeNull();
|
||||
expect(result.removedCount).toBe(0);
|
||||
});
|
||||
|
||||
test('handles undefined input gracefully', () => {
|
||||
const result = sanitizeInputSchema(undefined as any);
|
||||
expect(result.schema).toBeUndefined();
|
||||
expect(result.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeToolSchemas', () => {
|
||||
test('sanitizes multiple tools and tracks per-tool changes', () => {
|
||||
const tools = [
|
||||
{
|
||||
name: 'clean_tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'figma_tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
cornerRadius: 8,
|
||||
fillColor: '#000',
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'design_tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
fontWeight: 'bold',
|
||||
properties: { text: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = sanitizeToolSchemas(tools);
|
||||
|
||||
expect(result.totalRemoved).toBe(3);
|
||||
expect(result.removedByTool).toHaveLength(2);
|
||||
expect(result.removedByTool[0].name).toBe('figma_tool');
|
||||
expect(result.removedByTool[0].removed).toContain('cornerRadius');
|
||||
expect(result.removedByTool[0].removed).toContain('fillColor');
|
||||
expect(result.removedByTool[1].name).toBe('design_tool');
|
||||
expect(result.removedByTool[1].removed).toContain('fontWeight');
|
||||
|
||||
// Verify schemas are actually sanitized
|
||||
const figmaTool = result.tools.find((t) => t.name === 'figma_tool');
|
||||
expect(figmaTool?.input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
});
|
||||
});
|
||||
|
||||
test('strips examples from Claude Code tool schemas (issue #155)', () => {
|
||||
const tools = [
|
||||
{
|
||||
name: 'Bash',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute',
|
||||
examples: ['ls -la', 'git status', 'npm install'],
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in ms',
|
||||
examples: [5000, 30000],
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Read',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'File path to read',
|
||||
examples: ['/src/index.ts', '/package.json'],
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = sanitizeToolSchemas(tools);
|
||||
|
||||
expect(result.totalRemoved).toBe(3);
|
||||
expect(result.removedByTool).toHaveLength(2);
|
||||
|
||||
// Verify examples stripped from Bash tool
|
||||
const bashTool = result.tools.find((t) => t.name === 'Bash');
|
||||
expect(bashTool?.input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'The command to execute' },
|
||||
timeout: { type: 'number', description: 'Timeout in ms' },
|
||||
},
|
||||
required: ['command'],
|
||||
});
|
||||
|
||||
// Verify examples stripped from Read tool
|
||||
const readTool = result.tools.find((t) => t.name === 'Read');
|
||||
expect(readTool?.input_schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'File path to read' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
});
|
||||
});
|
||||
|
||||
test('handles tools without input_schema', () => {
|
||||
const tools = [{ name: 'simple_tool', description: 'No schema' }, { name: 'another_tool' }];
|
||||
|
||||
const result = sanitizeToolSchemas(tools);
|
||||
|
||||
expect(result.totalRemoved).toBe(0);
|
||||
expect(result.removedByTool).toHaveLength(0);
|
||||
expect(result.tools).toEqual(tools);
|
||||
});
|
||||
|
||||
test('returns unmodified tools when all schemas are valid', () => {
|
||||
const tools = [
|
||||
{
|
||||
name: 'valid_tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'number', minimum: 0 } },
|
||||
required: ['value'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = sanitizeToolSchemas(tools);
|
||||
|
||||
expect(result.totalRemoved).toBe(0);
|
||||
expect(result.removedByTool).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles tools without input_schema (skips null tools)', () => {
|
||||
const tools = [
|
||||
{ name: 'simple_tool', description: 'No schema' },
|
||||
{ name: 'valid', input_schema: { type: 'object' } },
|
||||
];
|
||||
const result = sanitizeToolSchemas(tools);
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('simple_tool');
|
||||
expect(result.tools[1].name).toBe('valid');
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { ensureCliproxyService } from '../../../src/cliproxy/service-manager';
|
||||
|
||||
describe('ensureCliproxyService', () => {
|
||||
it('fails fast without attempting a runtime install when the local binary is missing', async () => {
|
||||
// Inject stubs via the deps seam instead of bun's `mock.module()`. The
|
||||
// module-level mock approach is process-wide and was previously leaking
|
||||
// stubbed binary-manager / stats-fetcher modules into unrelated test
|
||||
// suites (notably cliproxy-stats-routes-*) that import them transitively.
|
||||
const ensureBinaryCalls: Array<unknown> = [];
|
||||
|
||||
const result = await ensureCliproxyService(8317, false, {
|
||||
ensureBinaryFn: async (_verbose, options) => {
|
||||
ensureBinaryCalls.push(options);
|
||||
throw new Error(
|
||||
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
},
|
||||
detectRunningProxyFn: async () => ({ running: false, verified: false }),
|
||||
configNeedsRegenerationFn: () => false,
|
||||
withStartupLockFn: async (fn) => await fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
error:
|
||||
'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.',
|
||||
});
|
||||
expect(ensureBinaryCalls).toEqual([
|
||||
{
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,435 +0,0 @@
|
||||
/**
|
||||
* Session Tracker Port-Specific Tests
|
||||
*
|
||||
* Tests for per-port session tracking in session-tracker.ts.
|
||||
* Verifies port-specific session files (sessions-{port}.json) and cleanup.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = testHome;
|
||||
delete process.env.CCS_DIR;
|
||||
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
cleanupOrphanedSessions,
|
||||
stopProxy,
|
||||
getProxyStatus,
|
||||
getSessionLockPath,
|
||||
deleteSessionLockForPort,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
|
||||
const { setGlobalConfigDir } = require('../../../dist/utils/config-manager');
|
||||
|
||||
describe('Session Tracker Port-Specific', function () {
|
||||
const variantPort1 = 8318;
|
||||
const variantPort2 = 8319;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process.
|
||||
process.env.CCS_HOME = testHome;
|
||||
delete process.env.CCS_DIR;
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
// Create test directories
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
|
||||
// Clean up any existing session files
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
// Clean up session files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
setGlobalConfigDir(undefined);
|
||||
});
|
||||
|
||||
describe('Session Lock Path', function () {
|
||||
it('returns sessions.json for default port', function () {
|
||||
const lockPath = getSessionLockPath();
|
||||
assert.ok(lockPath.endsWith('sessions.json'));
|
||||
assert.ok(!lockPath.includes('sessions-'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Port-Specific Session Files', function () {
|
||||
it('creates sessions-{port}.json for variant ports', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.ok(fs.existsSync(lockPath), `Should create sessions-${variantPort1}.json`);
|
||||
});
|
||||
|
||||
it('creates sessions.json for default port', function () {
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
assert.ok(fs.existsSync(lockPath), 'Should create sessions.json for default port');
|
||||
});
|
||||
|
||||
it('keeps separate session files for different ports', function () {
|
||||
// Register sessions on different ports
|
||||
registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort2, process.pid);
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// All three should exist
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort1}.json`)),
|
||||
'Should have port 8318 sessions'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort2}.json`)),
|
||||
'Should have port 8319 sessions'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cliproxyDir, 'sessions.json')),
|
||||
'Should have default port sessions'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerSession with Port', function () {
|
||||
it('stores correct port in session lock file', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.port, variantPort1);
|
||||
});
|
||||
|
||||
it('stores correct PID in session lock file', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
});
|
||||
|
||||
it('appends to existing sessions array for same port', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort1, process.pid);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.sessions.length, 2);
|
||||
assert.ok(lock.sessions.includes(session1));
|
||||
assert.ok(lock.sessions.includes(session2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterSession with Port', function () {
|
||||
it('removes session from port-specific file', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort1, process.pid);
|
||||
|
||||
// Unregister first session with port
|
||||
unregisterSession(session1, variantPort1);
|
||||
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], session2);
|
||||
});
|
||||
|
||||
it('deletes lock file when last session removed', function () {
|
||||
const session = registerSession(variantPort1, process.pid);
|
||||
|
||||
const shouldKill = unregisterSession(session, variantPort1);
|
||||
|
||||
assert.strictEqual(shouldKill, true);
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('returns true when last session', function () {
|
||||
const session = registerSession(variantPort1, process.pid);
|
||||
const shouldKill = unregisterSession(session, variantPort1);
|
||||
assert.strictEqual(shouldKill, true);
|
||||
});
|
||||
|
||||
it('returns false when sessions remain', function () {
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const shouldKill = unregisterSession(session1, variantPort1);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
});
|
||||
|
||||
it('searches default port for backward compat (fallback)', function () {
|
||||
// Register on default port
|
||||
const session = registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// Unregister without port (should search default)
|
||||
const shouldKill = unregisterSession(session);
|
||||
|
||||
assert.strictEqual(shouldKill, true);
|
||||
const lockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingProxy with Port', function () {
|
||||
it('returns lock for running proxy on specified port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.notStrictEqual(lock, null);
|
||||
assert.strictEqual(lock.port, variantPort1);
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
});
|
||||
|
||||
it('returns null if lock file missing', function () {
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('returns null if port mismatch', function () {
|
||||
// Create lock with different port number in file
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: 9999, // Wrong port
|
||||
pid: process.pid,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('cleans up stale lock if PID not running', function () {
|
||||
// Create lock with dead PID
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(variantPort1);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSessionLockForPort', function () {
|
||||
it('removes sessions-{port}.json for specified port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
assert.ok(fs.existsSync(lockPath));
|
||||
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('does nothing if file does not exist', function () {
|
||||
// Should not throw
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
});
|
||||
|
||||
it('does not affect other port sessions', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
registerSession(variantPort2, process.pid);
|
||||
|
||||
deleteSessionLockForPort(variantPort1);
|
||||
|
||||
const lock1Path = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
const lock2Path = path.join(cliproxyDir, `sessions-${variantPort2}.json`);
|
||||
|
||||
assert.strictEqual(fs.existsSync(lock1Path), false);
|
||||
assert.ok(fs.existsSync(lock2Path));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOrphanedSessions with Port', function () {
|
||||
it('deletes lock if PID not running', function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('keeps lock if PID still running', function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: process.pid, // Our process - running
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(variantPort1);
|
||||
assert.ok(fs.existsSync(lockPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProxy with Port', function () {
|
||||
it('stops proxy on specified port', async function () {
|
||||
// Create lock with dead PID (we can't actually stop a real process in tests)
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await stopProxy(variantPort1);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.ok(result.error.includes('not running'));
|
||||
});
|
||||
|
||||
it('cleans up session lock after stop', async function () {
|
||||
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
port: variantPort1,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
await stopProxy(variantPort1);
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
it('handles already-stopped proxy gracefully', async function () {
|
||||
const result = await stopProxy(variantPort1);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyStatus with Port', function () {
|
||||
it('returns correct status for variant port', function () {
|
||||
registerSession(variantPort1, process.pid);
|
||||
|
||||
const status = getProxyStatus(variantPort1);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.port, variantPort1);
|
||||
assert.strictEqual(status.pid, process.pid);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
});
|
||||
|
||||
it('returns not running for empty variant port', function () {
|
||||
const status = getProxyStatus(variantPort1);
|
||||
assert.strictEqual(status.running, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Variant Sessions', function () {
|
||||
it('manages multiple variant ports independently', function () {
|
||||
// Start sessions on different ports
|
||||
const session1 = registerSession(variantPort1, process.pid);
|
||||
const session2 = registerSession(variantPort2, process.pid);
|
||||
|
||||
// Both should be tracked
|
||||
assert.strictEqual(getProxyStatus(variantPort1).running, true);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Unregister one should not affect other
|
||||
unregisterSession(session1, variantPort1);
|
||||
|
||||
assert.strictEqual(getProxyStatus(variantPort1).running, false);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(session2, variantPort2);
|
||||
});
|
||||
|
||||
it('allows same session workflow on different ports', function () {
|
||||
// Simulate concurrent variant usage
|
||||
const port1Session1 = registerSession(variantPort1, process.pid);
|
||||
const port1Session2 = registerSession(variantPort1, process.pid);
|
||||
const port2Session1 = registerSession(variantPort2, process.pid);
|
||||
|
||||
assert.strictEqual(getProxyStatus(variantPort1).sessionCount, 2);
|
||||
assert.strictEqual(getProxyStatus(variantPort2).sessionCount, 1);
|
||||
|
||||
// Unregister from port1
|
||||
const shouldKill1 = unregisterSession(port1Session1, variantPort1);
|
||||
assert.strictEqual(shouldKill1, false); // Still has session2
|
||||
|
||||
const shouldKill2 = unregisterSession(port1Session2, variantPort1);
|
||||
assert.strictEqual(shouldKill2, true); // Last session on port1
|
||||
|
||||
// Port2 should still be running
|
||||
assert.strictEqual(getProxyStatus(variantPort2).running, true);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(port2Session1, variantPort2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '../../..');
|
||||
const SESSION_TRACKER_URL = pathToFileURL(
|
||||
path.join(REPO_ROOT, 'src/cliproxy/session-tracker.ts')
|
||||
).href;
|
||||
|
||||
function withScopedSessionTrackerHome<T>(run: (tempHome: string) => T): T {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
|
||||
try {
|
||||
return run(tempHome);
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runSessionTrackerScenario(
|
||||
tempHome: string,
|
||||
targets: string[]
|
||||
): {
|
||||
running: boolean;
|
||||
target?: string;
|
||||
sessionCount?: number;
|
||||
} {
|
||||
const script = `
|
||||
import {
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getProxyStatus,
|
||||
} from ${JSON.stringify(SESSION_TRACKER_URL)};
|
||||
|
||||
const port = 28317;
|
||||
const sessionIds = [];
|
||||
for (const target of ${JSON.stringify(targets)}) {
|
||||
sessionIds.push(registerSession(port, process.pid, undefined, undefined, target));
|
||||
}
|
||||
|
||||
const status = getProxyStatus(port);
|
||||
for (const sessionId of sessionIds) {
|
||||
unregisterSession(sessionId, port);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
running: status.running,
|
||||
target: status.target ?? null,
|
||||
sessionCount: status.sessionCount ?? null,
|
||||
}));
|
||||
`;
|
||||
|
||||
const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`);
|
||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_HOME: tempHome,
|
||||
CCS_DIR: '',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`child session-tracker scenario failed: ${JSON.stringify({
|
||||
command: `${process.execPath} ${scriptPath}`,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error?.message ?? null,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
const lines = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return JSON.parse(lines.at(-1) || '{}') as {
|
||||
running: boolean;
|
||||
target?: string;
|
||||
sessionCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe('session-tracker target metadata', () => {
|
||||
it('returns single target when all sessions share same target', () => {
|
||||
withScopedSessionTrackerHome((tempHome) => {
|
||||
const status = runSessionTrackerScenario(tempHome, ['droid', 'droid']);
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.target).toBe('droid');
|
||||
expect(status.sessionCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns mixed when active sessions use different targets', () => {
|
||||
withScopedSessionTrackerHome((tempHome) => {
|
||||
const status = runSessionTrackerScenario(tempHome, ['claude', 'droid']);
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.target).toBe('mixed');
|
||||
expect(status.sessionCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,458 +0,0 @@
|
||||
/**
|
||||
* Session Tracker Tests
|
||||
*
|
||||
* Tests for multi-instance CLIProxy session management.
|
||||
* Verifies reference counting and cleanup behavior.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = testHome;
|
||||
delete process.env.CCS_DIR;
|
||||
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getSessionCount,
|
||||
hasActiveSessions,
|
||||
cleanupOrphanedSessions,
|
||||
stopProxy,
|
||||
getProxyStatus,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const { setGlobalConfigDir } = require('../../../dist/utils/config-manager');
|
||||
|
||||
describe('Session Tracker', function () {
|
||||
const testPort = 18317;
|
||||
let sessionLockPath;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process.
|
||||
process.env.CCS_HOME = testHome;
|
||||
delete process.env.CCS_DIR;
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
// Create test directories
|
||||
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
// Use port-specific session file for non-default ports
|
||||
sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`);
|
||||
|
||||
// Clean up any existing lock files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
// Clean up lock files
|
||||
try {
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('sessions')) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
setGlobalConfigDir(undefined);
|
||||
});
|
||||
|
||||
describe('getExistingProxy', function () {
|
||||
it('should return null when no lock file exists', function () {
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return null when port does not match', function () {
|
||||
// Create lock with different port
|
||||
const lock = {
|
||||
port: 9999,
|
||||
pid: process.pid, // Use current process as it's definitely running
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return lock when proxy is healthy', function () {
|
||||
// Create lock with current process (guaranteed to be running)
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.notStrictEqual(result, null);
|
||||
assert.strictEqual(result.port, testPort);
|
||||
assert.strictEqual(result.pid, process.pid);
|
||||
assert.deepStrictEqual(result.sessions, ['session1']);
|
||||
});
|
||||
|
||||
it('should return null and cleanup when proxy is dead', function () {
|
||||
// Create lock with non-existent PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Very unlikely to exist
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
|
||||
// Lock file should be cleaned up
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerSession', function () {
|
||||
it('should create new lock file for first session', function () {
|
||||
const sessionId = registerSession(testPort, process.pid);
|
||||
|
||||
assert.ok(sessionId, 'should return session ID');
|
||||
assert.ok(fs.existsSync(sessionLockPath), 'should create lock file');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.port, testPort);
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], sessionId);
|
||||
});
|
||||
|
||||
it('should add session to existing lock', function () {
|
||||
// Register first session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
|
||||
// Register second session
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
|
||||
assert.notStrictEqual(session1, session2, 'should generate unique IDs');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.sessions.length, 2);
|
||||
assert.ok(lock.sessions.includes(session1));
|
||||
assert.ok(lock.sessions.includes(session2));
|
||||
});
|
||||
|
||||
it('should create new lock if PID differs', function () {
|
||||
// Create lock with different PID
|
||||
const oldLock = {
|
||||
port: testPort,
|
||||
pid: 12345,
|
||||
sessions: ['old-session'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(oldLock));
|
||||
|
||||
// Register with new PID
|
||||
const sessionId = registerSession(testPort, process.pid);
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterSession', function () {
|
||||
it('should return true when no lock exists', function () {
|
||||
const result = unregisterSession('nonexistent', testPort);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should remove session and return false when others remain', function () {
|
||||
// Register two sessions
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister first
|
||||
const shouldKill = unregisterSession(session1, testPort);
|
||||
|
||||
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], session2);
|
||||
});
|
||||
|
||||
it('should return true and delete lock when last session', function () {
|
||||
// Register single session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister it
|
||||
const shouldKill = unregisterSession(session1, testPort);
|
||||
|
||||
assert.strictEqual(shouldKill, true, 'should kill - last session');
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
|
||||
});
|
||||
|
||||
it('should handle unregistering non-existent session gracefully', function () {
|
||||
// Register a session
|
||||
registerSession(testPort, process.pid);
|
||||
|
||||
// Try to unregister wrong session
|
||||
const shouldKill = unregisterSession('wrong-session-id', testPort);
|
||||
|
||||
// Should return false since a session still exists
|
||||
assert.strictEqual(shouldKill, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCount (default port)', function () {
|
||||
it('should return 0 when no lock exists', function () {
|
||||
assert.strictEqual(getSessionCount(), 0);
|
||||
});
|
||||
|
||||
it('should return correct count (uses getProxyStatus for port-specific)', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveSessions (default port)', function () {
|
||||
it('should return false when no lock exists', function () {
|
||||
assert.strictEqual(hasActiveSessions(), false);
|
||||
});
|
||||
|
||||
it('should return true when sessions exist on default port', function () {
|
||||
// Note: hasActiveSessions() checks default port only
|
||||
// For port-specific checks, use getProxyStatus(port).running
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).running, true);
|
||||
});
|
||||
|
||||
it('should return false and cleanup when proxy is dead', function () {
|
||||
// Create lock with dead PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
// getProxyStatus will clean up stale lock
|
||||
const status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOrphanedSessions', function () {
|
||||
it('should do nothing when no lock exists', function () {
|
||||
cleanupOrphanedSessions(testPort);
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should not cleanup when port differs', function () {
|
||||
const lock = {
|
||||
port: 9999,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
// Should still exist (different port)
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||
});
|
||||
|
||||
it('should cleanup when proxy is dead', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should not cleanup when proxy is alive', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid, // Current process - alive
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProxy', function () {
|
||||
it('should return error when no lock exists', async function () {
|
||||
const result = await stopProxy(testPort);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
||||
});
|
||||
|
||||
it('should cleanup stale lock when proxy is not running', async function () {
|
||||
// Create lock with dead PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Very unlikely to exist
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = await stopProxy(testPort);
|
||||
assert.strictEqual(result.stopped, false);
|
||||
assert.ok(result.error.includes('not running'));
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should return pid and session count on success', function () {
|
||||
// Register a session with current process
|
||||
registerSession(testPort, process.pid);
|
||||
|
||||
// Note: We can't actually test killing our own process,
|
||||
// but we can verify the structure is correct before it attempts kill
|
||||
const status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.pid, process.pid);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyStatus', function () {
|
||||
it('should return not running when no lock exists', function () {
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(result.port, undefined);
|
||||
assert.strictEqual(result.pid, undefined);
|
||||
});
|
||||
|
||||
it('should return full status when proxy is running', function () {
|
||||
const startedAt = new Date().toISOString();
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid, // Current process - alive
|
||||
sessions: ['session1', 'session2'],
|
||||
startedAt,
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, true);
|
||||
assert.strictEqual(result.port, testPort);
|
||||
assert.strictEqual(result.pid, process.pid);
|
||||
assert.strictEqual(result.sessionCount, 2);
|
||||
assert.strictEqual(result.startedAt, startedAt);
|
||||
});
|
||||
|
||||
it('should cleanup and return not running when proxy is dead', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getProxyStatus(testPort);
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should return correct session count after registrations', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
let status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
status = getProxyStatus(testPort);
|
||||
assert.strictEqual(status.sessionCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-session scenario', function () {
|
||||
it('should handle complete multi-terminal workflow', function () {
|
||||
// Terminal 1 starts - first session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
// Terminal 2 starts - joins existing
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
// Terminal 3 starts - joins existing
|
||||
const session3 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
|
||||
|
||||
// Terminal 1 exits - should NOT kill proxy
|
||||
let shouldKill = unregisterSession(session1, testPort);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
|
||||
|
||||
// Terminal 3 exits - should NOT kill proxy
|
||||
shouldKill = unregisterSession(session3, testPort);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
|
||||
|
||||
// Terminal 2 exits - SHOULD kill proxy (last session)
|
||||
shouldKill = unregisterSession(session2, testPort);
|
||||
assert.strictEqual(shouldKill, true);
|
||||
assert.strictEqual(getProxyStatus(testPort).running, false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Unit tests for skip-local-auth functionality when using remote proxy with auth token
|
||||
*
|
||||
* When --proxy-host and --proxy-auth-token are provided together, the system should
|
||||
* skip local OAuth checks because the remote proxy handles authentication.
|
||||
*
|
||||
* Implementation uses: const remoteAuthToken = proxyConfig.authToken?.trim();
|
||||
* const skipLocalAuth = useRemoteProxy && !!remoteAuthToken;
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Helper to compute skipLocalAuth exactly as the implementation does
|
||||
* Mirrors logic from cliproxy-executor.ts lines 503-505
|
||||
*/
|
||||
function computeSkipLocalAuth(
|
||||
useRemoteProxy: boolean,
|
||||
proxyConfig: { authToken?: string | null }
|
||||
): boolean {
|
||||
const remoteAuthToken = proxyConfig.authToken?.trim();
|
||||
return useRemoteProxy && !!remoteAuthToken;
|
||||
}
|
||||
|
||||
describe('skip-local-auth logic', () => {
|
||||
describe('skipLocalAuth flag determination', () => {
|
||||
it('should skip local auth when both useRemoteProxy and authToken are truthy', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: 'test-token-123' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when useRemoteProxy is false', () => {
|
||||
const useRemoteProxy = false;
|
||||
const proxyConfig = { authToken: 'test-token-123' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when authToken is undefined', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: undefined };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when authToken is empty string', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: '' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when both are falsy', () => {
|
||||
const useRemoteProxy = false;
|
||||
const proxyConfig = { authToken: undefined };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authToken edge cases', () => {
|
||||
it('should NOT skip local auth when authToken is whitespace-only', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: ' ' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when authToken is tabs and newlines', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: '\t\n\r' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT skip local auth when authToken is null', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: null };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip local auth when authToken has leading/trailing whitespace but valid content', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: ' valid-token-123 ' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip local auth when authToken contains special characters', () => {
|
||||
const useRemoteProxy = true;
|
||||
const proxyConfig = { authToken: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test' };
|
||||
|
||||
const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig);
|
||||
|
||||
expect(skipLocalAuth).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth check bypass scenarios', () => {
|
||||
it('should document that OAuth is skipped for remote proxy with auth', () => {
|
||||
// This test documents the expected behavior:
|
||||
// When using remote proxy with auth token, the remote server
|
||||
// already has its own OAuth sessions, so local OAuth is unnecessary
|
||||
const scenario = {
|
||||
useRemoteProxy: true,
|
||||
authToken: 'bearer-token',
|
||||
providerRequiresOAuth: true,
|
||||
};
|
||||
|
||||
const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken;
|
||||
const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth;
|
||||
|
||||
expect(skipLocalAuth).toBeTruthy();
|
||||
expect(shouldTriggerLocalOAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should document that OAuth runs when no remote proxy', () => {
|
||||
const scenario = {
|
||||
useRemoteProxy: false,
|
||||
authToken: undefined,
|
||||
providerRequiresOAuth: true,
|
||||
};
|
||||
|
||||
const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken;
|
||||
const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth;
|
||||
|
||||
expect(skipLocalAuth).toBeFalsy();
|
||||
expect(shouldTriggerLocalOAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should document that OAuth runs when remote proxy has no auth token', () => {
|
||||
// Edge case: remote proxy configured but no auth token
|
||||
// This should fall back to local OAuth
|
||||
const scenario = {
|
||||
useRemoteProxy: true,
|
||||
authToken: undefined,
|
||||
providerRequiresOAuth: true,
|
||||
};
|
||||
|
||||
const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken;
|
||||
const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth;
|
||||
|
||||
expect(skipLocalAuth).toBeFalsy();
|
||||
expect(shouldTriggerLocalOAuth).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight quota check bypass', () => {
|
||||
it('should skip preflight for agy provider when using remote proxy with auth', () => {
|
||||
const provider = 'agy';
|
||||
const skipLocalAuth = true;
|
||||
|
||||
const shouldRunPreflight = provider === 'agy' && !skipLocalAuth;
|
||||
|
||||
expect(shouldRunPreflight).toBe(false);
|
||||
});
|
||||
|
||||
it('should run preflight for agy provider when using local mode', () => {
|
||||
const provider = 'agy';
|
||||
const skipLocalAuth = false;
|
||||
|
||||
const shouldRunPreflight = provider === 'agy' && !skipLocalAuth;
|
||||
|
||||
expect(shouldRunPreflight).toBe(true);
|
||||
});
|
||||
|
||||
it('should not run preflight for non-agy providers regardless of mode', () => {
|
||||
const providers = ['gemini', 'codex', 'ghcp', 'kiro'];
|
||||
|
||||
for (const provider of providers) {
|
||||
const shouldRunPreflight = provider === 'agy' && !false;
|
||||
expect(shouldRunPreflight).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('model configuration bypass', () => {
|
||||
it('should skip model config when using remote proxy with auth', () => {
|
||||
const supportsModelConfig = true;
|
||||
const skipLocalAuth = true;
|
||||
|
||||
const shouldConfigureModel = supportsModelConfig && !skipLocalAuth;
|
||||
|
||||
expect(shouldConfigureModel).toBe(false);
|
||||
});
|
||||
|
||||
it('should run model config when using local mode', () => {
|
||||
const supportsModelConfig = true;
|
||||
const skipLocalAuth = false;
|
||||
|
||||
const shouldConfigureModel = supportsModelConfig && !skipLocalAuth;
|
||||
|
||||
expect(shouldConfigureModel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broken model warning behavior', () => {
|
||||
it('should show broken model warning in BOTH remote and local modes', () => {
|
||||
// Updated behavior: warnings always shown (with different messaging for remote)
|
||||
// Remote users need to know about broken models too
|
||||
const currentModel = 'some-broken-model';
|
||||
const isModelBroken = true;
|
||||
|
||||
// Warning should show regardless of skipLocalAuth
|
||||
const shouldWarnRemote = currentModel && isModelBroken; // skipLocalAuth=true
|
||||
const shouldWarnLocal = currentModel && isModelBroken; // skipLocalAuth=false
|
||||
|
||||
expect(shouldWarnRemote).toBe(true);
|
||||
expect(shouldWarnLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('should show different message for remote vs local mode', () => {
|
||||
const skipLocalAuth = true;
|
||||
const currentModel = 'some-broken-model';
|
||||
const isModelBroken = true;
|
||||
|
||||
// When remote: "Note: Model may be overridden by remote proxy configuration."
|
||||
// When local: "Run ccs <provider> --config to change model."
|
||||
const remoteMessage = skipLocalAuth
|
||||
? 'Note: Model may be overridden by remote proxy configuration.'
|
||||
: 'Run "ccs provider --config" to change model.';
|
||||
|
||||
expect(remoteMessage).toContain('remote proxy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CI/CD workflow scenarios', () => {
|
||||
it('should enable headless CI workflow with remote proxy', () => {
|
||||
// Simulate GitHub Actions workflow configuration
|
||||
const workflowConfig = {
|
||||
headless: true,
|
||||
proxyHost: 'proxy.example.com',
|
||||
proxyPort: 443,
|
||||
proxyProtocol: 'https',
|
||||
proxyAuthToken: 'github-secret-token',
|
||||
remoteOnly: true,
|
||||
};
|
||||
|
||||
// Determine if this configuration should skip local OAuth
|
||||
const useRemoteProxy = !!workflowConfig.proxyHost;
|
||||
const skipLocalAuth = useRemoteProxy && !!workflowConfig.proxyAuthToken;
|
||||
|
||||
expect(useRemoteProxy).toBe(true);
|
||||
expect(skipLocalAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should require local OAuth when no proxy configured', () => {
|
||||
// Simulate local development without proxy
|
||||
const localConfig = {
|
||||
headless: false,
|
||||
proxyHost: undefined,
|
||||
proxyAuthToken: undefined,
|
||||
};
|
||||
|
||||
const useRemoteProxy = !!localConfig.proxyHost;
|
||||
const skipLocalAuth = useRemoteProxy && !!localConfig.proxyAuthToken;
|
||||
|
||||
expect(useRemoteProxy).toBe(false);
|
||||
expect(skipLocalAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type {
|
||||
CliproxyManagementAuthFile,
|
||||
CliproxyRequestDetail,
|
||||
CliproxyUsageApiResponse,
|
||||
} from '../../../src/cliproxy/stats-fetcher';
|
||||
import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer';
|
||||
|
||||
function createDetail(overrides: Partial<CliproxyRequestDetail> = {}): CliproxyRequestDetail {
|
||||
return {
|
||||
timestamp: '2025-03-26T10:00:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 'shared-auth-index',
|
||||
tokens: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
failed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInternallyBucketedUsage(
|
||||
details: CliproxyRequestDetail[]
|
||||
): CliproxyUsageApiResponse {
|
||||
return {
|
||||
usage: {
|
||||
total_requests: details.length,
|
||||
success_count: details.filter((detail) => !detail.failed).length,
|
||||
failure_count: details.filter((detail) => detail.failed).length,
|
||||
apis: {
|
||||
'ccs-internal-managed': {
|
||||
total_requests: details.length,
|
||||
models: {
|
||||
'gpt-5': {
|
||||
total_requests: details.length,
|
||||
details,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCliproxyStatsFromUsageResponse', () => {
|
||||
it('keeps duplicate emails isolated by provider', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
usage: {
|
||||
total_requests: 5,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 3,
|
||||
models: {
|
||||
'gpt-5': {
|
||||
total_requests: 3,
|
||||
details: [
|
||||
{
|
||||
timestamp: '2026-03-26T10:00:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-26T10:01:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 7,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 19,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-26T10:02:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 8,
|
||||
output_tokens: 2,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 10,
|
||||
},
|
||||
failed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
total_requests: 2,
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
total_requests: 2,
|
||||
details: [
|
||||
{
|
||||
timestamp: '2026-03-26T11:00:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 20,
|
||||
output_tokens: 10,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 30,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-26T11:01:00.000Z',
|
||||
source: 'shared@example.com',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 14,
|
||||
output_tokens: 6,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 20,
|
||||
},
|
||||
failed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.accountStats['codex:shared@example.com']).toMatchObject({
|
||||
accountKey: 'codex:shared@example.com',
|
||||
provider: 'codex',
|
||||
source: 'shared@example.com',
|
||||
successCount: 2,
|
||||
failureCount: 1,
|
||||
totalTokens: 44,
|
||||
lastUsedAt: '2026-03-26T10:02:00.000Z',
|
||||
});
|
||||
expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({
|
||||
accountKey: 'gemini:shared@example.com',
|
||||
provider: 'gemini',
|
||||
source: 'shared@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 1,
|
||||
totalTokens: 50,
|
||||
lastUsedAt: '2026-03-26T11:01:00.000Z',
|
||||
});
|
||||
expect(stats.successCount).toBe(3);
|
||||
expect(stats.failureCount).toBe(2);
|
||||
expect(stats.requestsByProvider).toEqual({ codex: 3, gemini: 2 });
|
||||
});
|
||||
|
||||
it('resolves canonical providers from auth_index when usage is internally bucketed', () => {
|
||||
const usage = createInternallyBucketedUsage([
|
||||
createDetail({ auth_index: 'codex-1' }),
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
auth_index: 'gemini-1',
|
||||
tokens: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 7,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 19,
|
||||
},
|
||||
}),
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:02:00.000Z',
|
||||
auth_index: 'agy-1',
|
||||
tokens: {
|
||||
input_tokens: 8,
|
||||
output_tokens: 2,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 10,
|
||||
},
|
||||
failed: true,
|
||||
}),
|
||||
]);
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 'codex-1', provider: 'codex', email: 'shared@example.com' },
|
||||
{ auth_index: 'gemini-1', provider: 'gemini-cli', email: 'shared@example.com' },
|
||||
{ auth_index: 'agy-1', provider: 'antigravity', email: 'shared@example.com' },
|
||||
];
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
|
||||
|
||||
expect(stats.accountStats['codex:shared@example.com']).toMatchObject({
|
||||
provider: 'codex',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({
|
||||
provider: 'gemini',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['agy:shared@example.com']).toMatchObject({
|
||||
provider: 'agy',
|
||||
successCount: 0,
|
||||
failureCount: 1,
|
||||
});
|
||||
expect(stats.requestsByProvider).toEqual({ codex: 1, gemini: 1, agy: 1 });
|
||||
});
|
||||
|
||||
it('falls back to the usage provider when auth_index lookup cannot resolve a provider', () => {
|
||||
const usage = createInternallyBucketedUsage([createDetail({ auth_index: 'codex-1' })]);
|
||||
const scenarios: Array<{ label: string; authFiles: CliproxyManagementAuthFile[] }> = [
|
||||
{ label: 'empty authFiles', authFiles: [] },
|
||||
{
|
||||
label: 'missing auth_index match',
|
||||
authFiles: [{ auth_index: 'other-auth-index', provider: 'codex', email: 'shared@example.com' }],
|
||||
},
|
||||
{
|
||||
label: 'matching auth_index without provider metadata',
|
||||
authFiles: [{ auth_index: 'codex-1', email: 'shared@example.com' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles: scenario.authFiles });
|
||||
|
||||
expect(stats.accountStats['ccs-internal-managed:shared@example.com'], scenario.label).toMatchObject({
|
||||
provider: 'ccs-internal-managed',
|
||||
source: 'shared@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.requestsByProvider, scenario.label).toEqual({ 'ccs-internal-managed': 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to auth-file source metadata when detail source is blank and supports mixed auth_index outcomes', () => {
|
||||
const usage = createInternallyBucketedUsage([
|
||||
createDetail({ source: ' ', auth_index: 'codex-1' }),
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
source: 'unmatched@example.com',
|
||||
auth_index: 'missing-auth-index',
|
||||
}),
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:02:00.000Z',
|
||||
source: ' ',
|
||||
auth_index: 'providerless-auth-index',
|
||||
failed: true,
|
||||
}),
|
||||
]);
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 'codex-1', provider: 'codex', email: 'fallback@example.com' },
|
||||
{ auth_index: 'providerless-auth-index', email: 'providerless-fallback@example.com' },
|
||||
];
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
|
||||
|
||||
expect(stats.accountStats['codex:fallback@example.com']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'fallback@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['ccs-internal-managed:unmatched@example.com']).toMatchObject({
|
||||
provider: 'ccs-internal-managed',
|
||||
source: 'unmatched@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['ccs-internal-managed:providerless-fallback@example.com']).toMatchObject({
|
||||
provider: 'ccs-internal-managed',
|
||||
source: 'providerless-fallback@example.com',
|
||||
successCount: 0,
|
||||
failureCount: 1,
|
||||
});
|
||||
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
|
||||
});
|
||||
|
||||
it('derives duplicate-email Codex account ids from auth file metadata', () => {
|
||||
const usage = createInternallyBucketedUsage([
|
||||
createDetail({ auth_index: 'codex-team', source: 'kaidu.kd@gmail.com' }),
|
||||
createDetail({
|
||||
auth_index: 'codex-free',
|
||||
source: 'kaidu.kd@gmail.com',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
failed: true,
|
||||
}),
|
||||
]);
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{
|
||||
auth_index: 'codex-team',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
},
|
||||
{
|
||||
auth_index: 'codex-free',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-kaidu.kd@gmail.com-free.json',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
|
||||
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#04a0f049-team']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#04a0f049-team',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#free']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#free',
|
||||
successCount: 0,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseThinkingOverride } from '../../../src/cliproxy/executor/thinking-arg-parser';
|
||||
|
||||
describe('parseThinkingOverride', () => {
|
||||
it('parses --thinking with separate value', () => {
|
||||
const out = parseThinkingOverride(['--thinking', 'high']);
|
||||
expect(out.value).toBe('high');
|
||||
expect(out.sourceFlag).toBe('--thinking');
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses --thinking=<value> inline', () => {
|
||||
const out = parseThinkingOverride(['--thinking=xhigh']);
|
||||
expect(out.value).toBe('xhigh');
|
||||
expect(out.sourceFlag).toBe('--thinking');
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses --effort alias', () => {
|
||||
const out = parseThinkingOverride(['--effort', 'medium']);
|
||||
expect(out.value).toBe('medium');
|
||||
expect(out.sourceFlag).toBe('--effort');
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses integer values as numbers', () => {
|
||||
const out = parseThinkingOverride(['--thinking', '8192']);
|
||||
expect(out.value).toBe(8192);
|
||||
expect(out.sourceFlag).toBe('--thinking');
|
||||
});
|
||||
|
||||
it('returns first occurrence and tracks duplicates across aliases', () => {
|
||||
const out = parseThinkingOverride(['--effort=high', '--thinking', 'xhigh']);
|
||||
expect(out.value).toBe('high');
|
||||
expect(out.sourceFlag).toBe('--effort');
|
||||
expect(out.duplicateDisplays).toEqual(['--thinking xhigh']);
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for missing separate value', () => {
|
||||
const out = parseThinkingOverride(['--effort', '--verbose']);
|
||||
expect(out.error).toEqual({ flag: '--effort', form: 'separate' });
|
||||
});
|
||||
|
||||
it('returns error for missing inline value', () => {
|
||||
const out = parseThinkingOverride(['--thinking=']);
|
||||
expect(out.error).toEqual({ flag: '--thinking', form: 'inline' });
|
||||
});
|
||||
|
||||
it('keeps first valid value when later duplicate is missing separate value', () => {
|
||||
const out = parseThinkingOverride(['--effort', 'high', '--thinking']);
|
||||
expect(out.value).toBe('high');
|
||||
expect(out.sourceFlag).toBe('--effort');
|
||||
expect(out.error).toBeUndefined();
|
||||
expect(out.duplicateDisplays).toEqual(['--thinking <missing-value>']);
|
||||
});
|
||||
|
||||
it('keeps first valid value when later duplicate is missing inline value', () => {
|
||||
const out = parseThinkingOverride(['--thinking=medium', '--effort=']);
|
||||
expect(out.value).toBe('medium');
|
||||
expect(out.sourceFlag).toBe('--thinking');
|
||||
expect(out.error).toBeUndefined();
|
||||
expect(out.duplicateDisplays).toEqual(['--effort=<missing-value>']);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ThinkingConfig } from '../../../src/config/unified-config-types';
|
||||
import {
|
||||
buildThinkingStartupStatus,
|
||||
parseEnvThinkingOverride,
|
||||
resolveRuntimeThinkingOverride,
|
||||
shouldDisableCodexReasoning,
|
||||
} from '../../../src/cliproxy/executor/thinking-override-resolver';
|
||||
|
||||
const baseConfig: ThinkingConfig = {
|
||||
mode: 'auto',
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
show_warnings: true,
|
||||
};
|
||||
|
||||
describe('thinking-override-resolver', () => {
|
||||
it('parses env thinking values with CLI-compatible integer handling', () => {
|
||||
expect(parseEnvThinkingOverride(undefined)).toBeUndefined();
|
||||
expect(parseEnvThinkingOverride(' ')).toBeUndefined();
|
||||
expect(parseEnvThinkingOverride('8192')).toBe(8192);
|
||||
expect(parseEnvThinkingOverride(' OFF ')).toBe('off');
|
||||
expect(parseEnvThinkingOverride('bogus')).toBeUndefined();
|
||||
expect(parseEnvThinkingOverride('-1')).toBeUndefined();
|
||||
expect(parseEnvThinkingOverride('100001')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves runtime priority as flag > env', () => {
|
||||
expect(resolveRuntimeThinkingOverride('high', 'low')).toEqual({
|
||||
thinkingOverride: 'high',
|
||||
thinkingSource: 'flag',
|
||||
});
|
||||
expect(resolveRuntimeThinkingOverride(undefined, 'xhigh')).toEqual({
|
||||
thinkingOverride: 'xhigh',
|
||||
thinkingSource: 'env',
|
||||
});
|
||||
expect(resolveRuntimeThinkingOverride(undefined, 'invalid')).toEqual({
|
||||
thinkingOverride: undefined,
|
||||
thinkingSource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables codex reasoning for off aliases regardless of case', () => {
|
||||
expect(shouldDisableCodexReasoning(baseConfig, 'OFF')).toBe(true);
|
||||
expect(
|
||||
shouldDisableCodexReasoning(
|
||||
{
|
||||
...baseConfig,
|
||||
mode: 'off',
|
||||
},
|
||||
'high'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDisableCodexReasoning(
|
||||
{
|
||||
...baseConfig,
|
||||
mode: 'manual',
|
||||
override: 'off',
|
||||
},
|
||||
undefined
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('builds startup status from effective precedence instead of raw config mode', () => {
|
||||
const offConfig: ThinkingConfig = { ...baseConfig, mode: 'off' };
|
||||
|
||||
expect(buildThinkingStartupStatus(offConfig, 'high', 'env')).toEqual({
|
||||
thinkingLabel: 'high',
|
||||
sourceLabel: 'env: CCS_THINKING',
|
||||
});
|
||||
|
||||
expect(buildThinkingStartupStatus(offConfig, undefined, undefined)).toEqual({
|
||||
thinkingLabel: 'off',
|
||||
sourceLabel: 'config: off',
|
||||
});
|
||||
|
||||
expect(buildThinkingStartupStatus(baseConfig, 'off', 'flag', '--effort off')).toEqual({
|
||||
thinkingLabel: 'off',
|
||||
sourceLabel: 'flag: --effort off',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Thinking Validator Unit Tests
|
||||
*
|
||||
* Tests for thinking budget validation logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
validateThinking,
|
||||
THINKING_LEVEL_BUDGETS,
|
||||
VALID_THINKING_LEVELS,
|
||||
THINKING_OFF_VALUES,
|
||||
THINKING_BUDGET_MIN,
|
||||
THINKING_BUDGET_MAX,
|
||||
THINKING_BUDGET_DEFAULT_MIN,
|
||||
} from '../../../src/cliproxy/thinking-validator';
|
||||
|
||||
describe('Thinking Validator', () => {
|
||||
describe('Constants', () => {
|
||||
it('should export valid budget bounds', () => {
|
||||
expect(THINKING_BUDGET_MIN).toBe(0);
|
||||
expect(THINKING_BUDGET_MAX).toBe(100000);
|
||||
expect(THINKING_BUDGET_DEFAULT_MIN).toBe(512);
|
||||
});
|
||||
|
||||
it('should export valid thinking levels', () => {
|
||||
expect(VALID_THINKING_LEVELS).toContain('minimal');
|
||||
expect(VALID_THINKING_LEVELS).toContain('low');
|
||||
expect(VALID_THINKING_LEVELS).toContain('medium');
|
||||
expect(VALID_THINKING_LEVELS).toContain('high');
|
||||
expect(VALID_THINKING_LEVELS).toContain('xhigh');
|
||||
expect(VALID_THINKING_LEVELS).toContain('max');
|
||||
expect(VALID_THINKING_LEVELS).toContain('auto');
|
||||
});
|
||||
|
||||
it('should export level budget mappings', () => {
|
||||
expect(THINKING_LEVEL_BUDGETS.minimal).toBe(512);
|
||||
expect(THINKING_LEVEL_BUDGETS.low).toBe(1024);
|
||||
expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192);
|
||||
expect(THINKING_LEVEL_BUDGETS.high).toBe(24576);
|
||||
expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768);
|
||||
// `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos
|
||||
expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh);
|
||||
});
|
||||
|
||||
it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => {
|
||||
// Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh.
|
||||
const result = validateThinking('claude', 'claude-opus-4-7', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('max');
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
|
||||
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
|
||||
// User input "max" should map down to xhigh rather than be rejected.
|
||||
const result = validateThinking('codex', 'gpt-5.4', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('xhigh');
|
||||
});
|
||||
|
||||
it('should export off values', () => {
|
||||
expect(THINKING_OFF_VALUES).toContain('off');
|
||||
expect(THINKING_OFF_VALUES).toContain('none');
|
||||
expect(THINKING_OFF_VALUES).toContain('disabled');
|
||||
expect(THINKING_OFF_VALUES).toContain('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Off values', () => {
|
||||
it('should handle "off" value', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'off');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('off');
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle "none" value', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'none');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('off');
|
||||
});
|
||||
|
||||
it('should handle "disabled" value', () => {
|
||||
const result = validateThinking('agy', 'claude-sonnet-4-20250514', 'disabled');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('off');
|
||||
});
|
||||
|
||||
it('should handle "0" string as off', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', '0');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('off');
|
||||
});
|
||||
|
||||
it('should be case-insensitive for off values', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'OFF');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Named levels (levels-type models like Gemini)', () => {
|
||||
it('should accept valid level names', () => {
|
||||
const levels = ['low', 'medium', 'high'];
|
||||
for (const level of levels) {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', level);
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be case-insensitive for level names', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'HIGH');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should map numeric budgets to closest level for level-type models', () => {
|
||||
// Level-type models convert budget to closest named level
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 8192);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(typeof result.value).toBe('string'); // Should be a level name
|
||||
expect(result.warning).toContain('Mapped');
|
||||
});
|
||||
|
||||
it('caps unsupported higher standard levels to the highest supported model level', () => {
|
||||
const result = validateThinking('codex', 'gpt-5-mini', 'xhigh');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('high');
|
||||
expect(result.warning).toContain('mapped to "high"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Budget-type models (like Claude via agy)', () => {
|
||||
// Claude models via agy use budget-type thinking
|
||||
const budgetModel = 'claude-sonnet-4-5-thinking';
|
||||
|
||||
it('should accept valid numeric budget', () => {
|
||||
const result = validateThinking('agy', budgetModel, 8192);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe(8192);
|
||||
});
|
||||
|
||||
it('should accept numeric string budget', () => {
|
||||
const result = validateThinking('agy', budgetModel, '8192');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe(8192);
|
||||
});
|
||||
|
||||
it('should reject negative budgets', () => {
|
||||
const result = validateThinking('agy', budgetModel, -100);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.warning).toContain('Negative');
|
||||
});
|
||||
|
||||
it('should clamp excessively high budgets', () => {
|
||||
const result = validateThinking('agy', budgetModel, 999999999);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toContain('Clamped');
|
||||
});
|
||||
|
||||
it('should reject partial numeric parses like "123abc"', () => {
|
||||
const result = validateThinking('agy', budgetModel, '123abc');
|
||||
// Should either fail or find closest level match, not parse as 123
|
||||
expect(result.value).not.toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto value', () => {
|
||||
it('should accept auto value', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'auto');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown models', () => {
|
||||
it('should pass through value for unknown models with warning', () => {
|
||||
const result = validateThinking('gemini', 'unknown-model-xyz', 'high');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warning).toContain('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle whitespace in input', () => {
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', ' high ');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty string by passing through for unknown', () => {
|
||||
// Empty string on known model goes to level/budget validation
|
||||
const result = validateThinking('gemini', 'gemini-3-pro-preview', '');
|
||||
// For level-type models, empty string will try to match levels
|
||||
expect(result.valid).toBeDefined(); // Just check it doesn't crash
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* Unit tests for tool-name-mapper module
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { ToolNameMapper } = require('../../../dist/cliproxy/tool-name-mapper');
|
||||
|
||||
describe('Tool Name Mapper', () => {
|
||||
describe('registerTools', () => {
|
||||
it('returns unchanged tools when all names are valid', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'valid_tool', description: 'A valid tool' },
|
||||
{ name: 'another_tool', description: 'Another tool' },
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'valid_tool');
|
||||
assert.strictEqual(result[1].name, 'another_tool');
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('sanitizes long tool names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const longName = 'a'.repeat(100);
|
||||
const tools = [{ name: longName, description: 'Long name tool' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name.length, 64);
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
|
||||
it('sanitizes duplicate segment names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'foo__bar__bar', description: 'Duplicate segments' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'foo__bar');
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
|
||||
it('preserves other tool properties', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{
|
||||
name: 'foo__bar__bar',
|
||||
description: 'Test tool',
|
||||
input_schema: { type: 'object' },
|
||||
custom_field: 'preserved',
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].description, 'Test tool');
|
||||
assert.deepStrictEqual(result[0].input_schema, { type: 'object' });
|
||||
assert.strictEqual(result[0].custom_field, 'preserved');
|
||||
});
|
||||
|
||||
it('handles empty tools array', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const result = mapper.registerTools([]);
|
||||
|
||||
assert.deepStrictEqual(result, []);
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('handles mixed valid and invalid names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'valid_tool' },
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'another_valid' },
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'valid_tool');
|
||||
assert.strictEqual(result[1].name, 'foo__bar');
|
||||
assert.strictEqual(result[2].name, 'another_valid');
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
assert.strictEqual(mapper.getChangeCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreToolUse', () => {
|
||||
it('restores sanitized names in tool_use blocks', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].name, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('leaves non-tool_use blocks unchanged', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'text', text: 'Hello world' },
|
||||
{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].type, 'text');
|
||||
assert.strictEqual(result[0].text, 'Hello world');
|
||||
assert.strictEqual(result[1].name, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('leaves unknown tool names unchanged', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'unknown_tool', input: {} },
|
||||
];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(result[0].name, 'unknown_tool');
|
||||
});
|
||||
|
||||
it('handles empty content array', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const result = mapper.restoreToolUse([]);
|
||||
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
it('handles tool_use without name property', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [{ type: 'tool_use', id: 'call_1', input: {} }];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
// Should return unchanged
|
||||
assert.deepStrictEqual(result[0], content[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreName', () => {
|
||||
it('restores a single sanitized name', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const result = mapper.restoreName('foo__bar');
|
||||
|
||||
assert.strictEqual(result, 'foo__bar__bar');
|
||||
});
|
||||
|
||||
it('returns input for unknown names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const result = mapper.restoreName('unknown');
|
||||
|
||||
assert.strictEqual(result, 'unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
it('returns false when no sanitization occurred', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'valid_tool' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
});
|
||||
|
||||
it('returns true when sanitization occurred', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChanges', () => {
|
||||
it('returns empty array when no changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'valid_tool' }]);
|
||||
|
||||
assert.deepStrictEqual(mapper.getChanges(), []);
|
||||
});
|
||||
|
||||
it('returns list of all changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'baz__qux__qux' },
|
||||
]);
|
||||
|
||||
const changes = mapper.getChanges();
|
||||
|
||||
assert.strictEqual(changes.length, 2);
|
||||
assert.strictEqual(changes[0].original, 'foo__bar__bar');
|
||||
assert.strictEqual(changes[0].sanitized, 'foo__bar');
|
||||
assert.strictEqual(changes[1].original, 'baz__qux__qux');
|
||||
assert.strictEqual(changes[1].sanitized, 'baz__qux');
|
||||
});
|
||||
|
||||
it('returns a copy (not the internal array)', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const changes1 = mapper.getChanges();
|
||||
const changes2 = mapper.getChanges();
|
||||
|
||||
assert.notStrictEqual(changes1, changes2);
|
||||
assert.deepStrictEqual(changes1, changes2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangeCount', () => {
|
||||
it('returns 0 when no changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
assert.strictEqual(mapper.getChangeCount(), 0);
|
||||
});
|
||||
|
||||
it('returns correct count after sanitization', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'valid_tool' },
|
||||
{ name: 'baz__qux__qux' },
|
||||
]);
|
||||
|
||||
assert.strictEqual(mapper.getChangeCount(), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('resets all state', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), true);
|
||||
|
||||
mapper.clear();
|
||||
|
||||
assert.strictEqual(mapper.hasChanges(), false);
|
||||
assert.deepStrictEqual(mapper.getChanges(), []);
|
||||
assert.strictEqual(mapper.restoreName('foo__bar'), 'foo__bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Unit tests for tool-name-sanitizer module
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const {
|
||||
isValidToolName,
|
||||
removeDuplicateSegments,
|
||||
smartTruncate,
|
||||
sanitizeToolName,
|
||||
GEMINI_MAX_TOOL_NAME_LENGTH,
|
||||
} = require('../../../dist/cliproxy/tool-name-sanitizer');
|
||||
|
||||
describe('Tool Name Sanitizer', () => {
|
||||
describe('GEMINI_MAX_TOOL_NAME_LENGTH', () => {
|
||||
it('should be 64', () => {
|
||||
assert.strictEqual(GEMINI_MAX_TOOL_NAME_LENGTH, 64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidToolName', () => {
|
||||
it('returns true for valid short names', () => {
|
||||
assert.strictEqual(isValidToolName('my_tool'), true);
|
||||
assert.strictEqual(isValidToolName('a'), true);
|
||||
assert.strictEqual(isValidToolName('_underscore'), true);
|
||||
assert.strictEqual(isValidToolName('Tool123'), true);
|
||||
});
|
||||
|
||||
it('returns true for names with valid special characters', () => {
|
||||
assert.strictEqual(isValidToolName('my.tool'), true);
|
||||
assert.strictEqual(isValidToolName('my:tool'), true);
|
||||
assert.strictEqual(isValidToolName('my-tool'), true);
|
||||
assert.strictEqual(isValidToolName('my_tool.v1:test-123'), true);
|
||||
});
|
||||
|
||||
it('returns true for exactly 64 character names', () => {
|
||||
const name64 = 'a'.repeat(64);
|
||||
assert.strictEqual(isValidToolName(name64), true);
|
||||
});
|
||||
|
||||
it('returns false for names exceeding 64 characters', () => {
|
||||
const name65 = 'a'.repeat(65);
|
||||
assert.strictEqual(isValidToolName(name65), false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
assert.strictEqual(isValidToolName(''), false);
|
||||
});
|
||||
|
||||
it('returns false for names starting with number', () => {
|
||||
assert.strictEqual(isValidToolName('123start'), false);
|
||||
assert.strictEqual(isValidToolName('0tool'), false);
|
||||
});
|
||||
|
||||
it('returns false for names with invalid characters', () => {
|
||||
assert.strictEqual(isValidToolName('has space'), false);
|
||||
assert.strictEqual(isValidToolName('has@symbol'), false);
|
||||
assert.strictEqual(isValidToolName('has#hash'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDuplicateSegments', () => {
|
||||
it('removes consecutive duplicate segments', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('foo__bar__bar'), 'foo__bar');
|
||||
assert.strictEqual(removeDuplicateSegments('gitmcp__x__x'), 'gitmcp__x');
|
||||
});
|
||||
|
||||
it('removes multiple consecutive duplicates', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('a__b__b__b'), 'a__b');
|
||||
});
|
||||
|
||||
it('handles non-consecutive duplicates (keeps them)', () => {
|
||||
// Only consecutive duplicates are removed
|
||||
assert.strictEqual(removeDuplicateSegments('a__b__a'), 'a__b__a');
|
||||
});
|
||||
|
||||
it('returns unchanged when no duplicates', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('foo__bar__baz'), 'foo__bar__baz');
|
||||
assert.strictEqual(removeDuplicateSegments('no_dupes'), 'no_dupes');
|
||||
});
|
||||
|
||||
it('handles single segment', () => {
|
||||
assert.strictEqual(removeDuplicateSegments('single'), 'single');
|
||||
});
|
||||
|
||||
it('handles real-world MCP tool name with duplicates', () => {
|
||||
const input = 'gitmcp__plus-pro-components__plus-pro-components';
|
||||
const expected = 'gitmcp__plus-pro-components';
|
||||
assert.strictEqual(removeDuplicateSegments(input), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smartTruncate', () => {
|
||||
it('returns unchanged for short names', () => {
|
||||
assert.strictEqual(smartTruncate('short', 64), 'short');
|
||||
assert.strictEqual(smartTruncate('a'.repeat(64), 64), 'a'.repeat(64));
|
||||
});
|
||||
|
||||
it('truncates long names with hash suffix', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
const result = smartTruncate(longName, 64);
|
||||
|
||||
assert.strictEqual(result.length, 64);
|
||||
assert.ok(result.includes('_')); // Has underscore separator
|
||||
});
|
||||
|
||||
it('produces deterministic output', () => {
|
||||
const name = 'a'.repeat(100);
|
||||
const result1 = smartTruncate(name, 64);
|
||||
const result2 = smartTruncate(name, 64);
|
||||
|
||||
assert.strictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('produces different hashes for different inputs', () => {
|
||||
const result1 = smartTruncate('a'.repeat(100), 64);
|
||||
const result2 = smartTruncate('b'.repeat(100), 64);
|
||||
|
||||
assert.notStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('respects custom maxLen', () => {
|
||||
const result = smartTruncate('a'.repeat(50), 30);
|
||||
assert.strictEqual(result.length, 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeToolName', () => {
|
||||
it('returns unchanged for valid names without duplicates', () => {
|
||||
const result = sanitizeToolName('valid_name');
|
||||
assert.deepStrictEqual(result, { sanitized: 'valid_name', changed: false });
|
||||
});
|
||||
|
||||
it('returns unchanged for 64-char valid names without duplicates', () => {
|
||||
const name64 = 'a'.repeat(64);
|
||||
const result = sanitizeToolName(name64);
|
||||
assert.deepStrictEqual(result, { sanitized: name64, changed: false });
|
||||
});
|
||||
|
||||
it('removes duplicate segments', () => {
|
||||
const result = sanitizeToolName('foo__bar__bar');
|
||||
assert.strictEqual(result.sanitized, 'foo__bar');
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('truncates names exceeding 64 chars', () => {
|
||||
const longName = 'a'.repeat(100);
|
||||
const result = sanitizeToolName(longName);
|
||||
|
||||
assert.strictEqual(result.sanitized.length, 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('handles combined dedupe + truncate', () => {
|
||||
// Create a name that needs both dedupe and truncate
|
||||
const segment = 'very_long_segment_name_here';
|
||||
const duplicated = `prefix__${segment}__${segment}__${segment}`;
|
||||
const result = sanitizeToolName(duplicated);
|
||||
|
||||
assert.ok(result.sanitized.length <= 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('sanitizes real-world problematic MCP tool name', () => {
|
||||
const problematic = 'gitmcp__plus-pro-components__plus-pro-components';
|
||||
const result = sanitizeToolName(problematic);
|
||||
|
||||
assert.ok(result.sanitized.length <= 64);
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(result.sanitized, 'gitmcp__plus-pro-components');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,299 +0,0 @@
|
||||
/**
|
||||
* Variant Port Allocation Tests
|
||||
*
|
||||
* Tests for port allocation logic in variant-config-adapter.ts.
|
||||
* Verifies unique port assignment (8318-8417) for CLIProxy variants.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Variant Port Allocation', function () {
|
||||
let configPath;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config file
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Constants', function () {
|
||||
it('VARIANT_PORT_BASE equals CLIPROXY_DEFAULT_PORT + 1 (8318)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_BASE, CLIPROXY_DEFAULT_PORT + 1);
|
||||
assert.strictEqual(VARIANT_PORT_BASE, 8318);
|
||||
});
|
||||
|
||||
it('VARIANT_PORT_MAX_OFFSET equals 100 (ports 8318-8417)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Basic Allocation', function () {
|
||||
it('returns VARIANT_PORT_BASE (8318) when no variants exist', function () {
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, VARIANT_PORT_BASE);
|
||||
assert.strictEqual(port, 8318);
|
||||
});
|
||||
|
||||
it('returns next available port when some ports used', function () {
|
||||
// Create variant on first port
|
||||
const settingsPath = path.join(testHome, '.ccs', 'test1.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test1', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
|
||||
it('skips used ports and returns first gap', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variants on ports 8318 and 8320 (leaving 8319 as gap)
|
||||
const settingsPath1 = path.join(ccsDir, 'test1.settings.json');
|
||||
const settingsPath2 = path.join(ccsDir, 'test2.settings.json');
|
||||
fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
|
||||
fs.writeFileSync(settingsPath2, JSON.stringify({ env: {} }));
|
||||
|
||||
saveVariantLegacy('test1', 'gemini', settingsPath1, undefined, 8318);
|
||||
saveVariantLegacy('test2', 'gemini', settingsPath2, undefined, 8320);
|
||||
|
||||
// Should return 8319 (the gap)
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
|
||||
it('allocates sequential ports for multiple variants', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8318 + i);
|
||||
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Boundary Conditions', function () {
|
||||
it('returns last port (8417) when 99 ports used', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 99 variants (ports 8318-8416)
|
||||
for (let i = 0; i < 99; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8417); // Last available port
|
||||
});
|
||||
|
||||
it('throws when all 100 ports exhausted', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants (ports 8318-8417)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => getNextAvailablePort(),
|
||||
/Port limit reached/,
|
||||
'Should throw error when all ports exhausted'
|
||||
);
|
||||
});
|
||||
|
||||
it('error message includes variant count and recovery hint', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
try {
|
||||
getNextAvailablePort();
|
||||
assert.fail('Should have thrown error');
|
||||
} catch (err) {
|
||||
assert.ok(err.message.includes('100/100'), 'Should include variant count');
|
||||
assert.ok(
|
||||
err.message.includes('ccs cliproxy remove'),
|
||||
'Should include recovery hint'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Port Reuse', function () {
|
||||
it('reuses port after variant deletion', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant on port 8318
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
// Next port should be 8319
|
||||
let nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8319);
|
||||
|
||||
// Remove the variant
|
||||
removeVariantFromLegacyConfig('test');
|
||||
|
||||
// Now 8318 should be available again
|
||||
nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8318);
|
||||
});
|
||||
|
||||
it('allocates lowest available port (not most recently freed)', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 3 variants on ports 8318, 8319, 8320
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
|
||||
}
|
||||
|
||||
// Remove middle variant (port 8319)
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
|
||||
// Next allocation should use 8319 (the gap), not 8321
|
||||
const nextPort = getNextAvailablePort();
|
||||
assert.strictEqual(nextPort, 8319);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailablePort - Legacy Variant Handling', function () {
|
||||
it('ignores variants without port field in usage calculation', function () {
|
||||
// Create variant without port (legacy format)
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy_variant: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// Should return first port since legacy variant has no port
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8318);
|
||||
});
|
||||
|
||||
it('handles mixed legacy and modern variants', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: path.join(ccsDir, 'legacy.settings.json'),
|
||||
// No port field
|
||||
},
|
||||
modern: {
|
||||
provider: 'gemini',
|
||||
settings: path.join(ccsDir, 'modern.settings.json'),
|
||||
port: 8318,
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// Should skip 8318 (used by modern) and return 8319
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, 8319);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVariantsFromConfig', function () {
|
||||
it('returns empty object when no variants exist', function () {
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.deepStrictEqual(variants, {});
|
||||
});
|
||||
|
||||
it('includes port field for each variant', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.test.port, 8318);
|
||||
assert.strictEqual(variants.test.provider, 'gemini');
|
||||
});
|
||||
|
||||
it('returns undefined port for legacy variants', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
assert.strictEqual(variants.legacy.provider, 'gemini');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,501 +0,0 @@
|
||||
/**
|
||||
* Variant Port Edge Case Tests
|
||||
*
|
||||
* Tests for edge cases and error handling in variant port isolation.
|
||||
* Covers port exhaustion, race conditions, stale session cleanup,
|
||||
* legacy migration, permission errors, and config corruption.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
cleanupOrphanedSessions,
|
||||
deleteSessionLockForPort,
|
||||
getProxyStatus,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const {
|
||||
deleteConfigForPort,
|
||||
configExists,
|
||||
generateConfig,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('Variant Port Edge Cases', function () {
|
||||
let configPath;
|
||||
let cliproxyDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up config and session files
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('Port Exhaustion', function () {
|
||||
it('throws after 100 variants created', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
||||
}
|
||||
|
||||
assert.throws(() => getNextAvailablePort(), /Port limit reached/);
|
||||
});
|
||||
|
||||
it('error message shows 100/100 and recovery hint', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
|
||||
}
|
||||
|
||||
try {
|
||||
getNextAvailablePort();
|
||||
assert.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
assert.ok(err.message.includes('100/100'));
|
||||
assert.ok(err.message.includes('ccs cliproxy remove'));
|
||||
}
|
||||
});
|
||||
|
||||
it('frees port after variant removal', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant on port 8318
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE);
|
||||
|
||||
// Next should be 8319
|
||||
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1);
|
||||
|
||||
// Remove variant
|
||||
removeVariantFromLegacyConfig('test');
|
||||
|
||||
// 8318 should be free again
|
||||
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stale Session Cleanup', function () {
|
||||
it('cleans orphaned session lock on variant delete', function () {
|
||||
const port = 8318;
|
||||
|
||||
// Create session file
|
||||
registerSession(port, process.pid);
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath));
|
||||
|
||||
// Simulate variant delete cleanup
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('cleans stale lock when PID not running', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// getExistingProxy should clean it up
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy Variant Migration', function () {
|
||||
it('variant without port gets undefined in listing', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
});
|
||||
|
||||
it('legacy variant does not block port allocation', function () {
|
||||
// Create legacy variant without port
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field - doesn't count toward port usage
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
// First port should still be available
|
||||
const port = getNextAvailablePort();
|
||||
assert.strictEqual(port, VARIANT_PORT_BASE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Corruption', function () {
|
||||
it('handles malformed sessions-{port}.json gracefully', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write invalid JSON
|
||||
fs.writeFileSync(sessionPath, '{ invalid json }');
|
||||
|
||||
// getExistingProxy should return null, not throw
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('handles missing config-{port}.yaml gracefully', function () {
|
||||
const port = 8318;
|
||||
// configExists should return false, not throw
|
||||
assert.strictEqual(configExists(port), false);
|
||||
});
|
||||
|
||||
it('handles sessions file with missing required fields', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write JSON missing required fields
|
||||
fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 }));
|
||||
|
||||
// getExistingProxy should return null (invalid structure)
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
|
||||
it('handles sessions file with wrong data types', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write JSON with wrong types
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port: 'not-a-number',
|
||||
pid: 'also-not-a-number',
|
||||
sessions: 'not-an-array',
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup on Crash', function () {
|
||||
it('getExistingProxy cleans stale lock if PID dead', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID (simulating crashed proxy)
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('cleanupOrphanedSessions removes stale lock', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
cleanupOrphanedSessions(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('variant delete cleans session lock even if proxy crashed', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Create lock with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// deleteSessionLockForPort is called during variant removal
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variant Lifecycle Integration', function () {
|
||||
it('creates variant with unique port and separate files', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const port = getNextAvailablePort();
|
||||
|
||||
// Create variant
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Generate config
|
||||
generateConfig('gemini', port);
|
||||
|
||||
// Verify files exist
|
||||
assert.ok(configExists(port));
|
||||
|
||||
// Start session
|
||||
const sessionId = registerSession(port, process.pid);
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.port, port);
|
||||
|
||||
// Clean up session
|
||||
unregisterSession(sessionId, port);
|
||||
});
|
||||
|
||||
it('removes variant and cleans up all port files', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const port = 8318;
|
||||
|
||||
// Create variant
|
||||
const settingsPath = path.join(ccsDir, 'test.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Generate config and session
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
|
||||
// Verify files exist
|
||||
assert.ok(configExists(port));
|
||||
assert.strictEqual(getProxyStatus(port).running, true);
|
||||
|
||||
// Remove variant (simulating full cleanup)
|
||||
removeVariantFromLegacyConfig('test');
|
||||
deleteConfigForPort(port);
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
// Verify cleanup
|
||||
assert.strictEqual(configExists(port), false);
|
||||
assert.strictEqual(getProxyStatus(port).running, false);
|
||||
});
|
||||
|
||||
it('port reuse after deletion does not have stale data', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
|
||||
// Create variant A
|
||||
const settingsA = path.join(ccsDir, 'variantA.settings.json');
|
||||
fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } }));
|
||||
const portA = getNextAvailablePort();
|
||||
saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA);
|
||||
generateConfig('gemini', portA);
|
||||
registerSession(portA, process.pid);
|
||||
|
||||
// Remove variant A with full cleanup
|
||||
removeVariantFromLegacyConfig('variantA');
|
||||
deleteConfigForPort(portA);
|
||||
deleteSessionLockForPort(portA);
|
||||
|
||||
// Create variant B - should get same port
|
||||
const settingsB = path.join(ccsDir, 'variantB.settings.json');
|
||||
fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } }));
|
||||
const portB = getNextAvailablePort();
|
||||
assert.strictEqual(portB, portA); // Port should be reused
|
||||
|
||||
// New session should start fresh
|
||||
saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB);
|
||||
generateConfig('gemini', portB);
|
||||
const sessionB = registerSession(portB, process.pid);
|
||||
|
||||
const status = getProxyStatus(portB);
|
||||
assert.strictEqual(status.running, true);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(sessionB, portB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Concurrent Variants', function () {
|
||||
it('creates 3 variants with different ports', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
ports.push(port);
|
||||
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Verify all ports are different
|
||||
const uniquePorts = new Set(ports);
|
||||
assert.strictEqual(uniquePorts.size, 3);
|
||||
|
||||
// Verify sequential assignment
|
||||
assert.strictEqual(ports[0], VARIANT_PORT_BASE);
|
||||
assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1);
|
||||
assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2);
|
||||
});
|
||||
|
||||
it('each has separate config file', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
}
|
||||
|
||||
// Verify separate config files
|
||||
for (const port of ports) {
|
||||
assert.ok(configExists(port), `Config for port ${port} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
it('each has separate sessions file when running', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
registerSession(port, process.pid);
|
||||
}
|
||||
|
||||
// Verify separate session files
|
||||
for (const port of ports) {
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const port of ports) {
|
||||
deleteSessionLockForPort(port);
|
||||
}
|
||||
});
|
||||
|
||||
it('removing one does not affect others', function () {
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
// Create 3 variants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
registerSession(ports[i], process.pid);
|
||||
}
|
||||
|
||||
// Remove middle variant
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
deleteConfigForPort(ports[1]);
|
||||
deleteSessionLockForPort(ports[1]);
|
||||
|
||||
// Verify others still exist
|
||||
assert.ok(configExists(ports[0]));
|
||||
assert.ok(!configExists(ports[1])); // Removed
|
||||
assert.ok(configExists(ports[2]));
|
||||
|
||||
assert.strictEqual(getProxyStatus(ports[0]).running, true);
|
||||
assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed
|
||||
assert.strictEqual(getProxyStatus(ports[2]).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(ports[0]);
|
||||
deleteSessionLockForPort(ports[2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,579 +0,0 @@
|
||||
/**
|
||||
* Variant Port Isolation Integration Tests
|
||||
*
|
||||
* Tests for PR #184: feat(cliproxy): add variant port isolation
|
||||
* Maps directly to the PR test plan:
|
||||
* - [x] Create multiple variants with `ccs cliproxy create`
|
||||
* - [x] Verify each variant gets unique port in config
|
||||
* - [x] Run multiple variants concurrently
|
||||
* - [x] Verify `ccs cliproxy list` shows port column
|
||||
* - [x] Remove variant and verify cleanup of port-specific files
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getNextAvailablePort,
|
||||
VARIANT_PORT_BASE,
|
||||
VARIANT_PORT_MAX_OFFSET,
|
||||
listVariantsFromConfig,
|
||||
saveVariantLegacy,
|
||||
removeVariantFromLegacyConfig,
|
||||
} = require('../../../dist/cliproxy/services/variant-config-adapter');
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getProxyStatus,
|
||||
deleteSessionLockForPort,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const {
|
||||
generateConfig,
|
||||
configExists,
|
||||
deleteConfigForPort,
|
||||
getConfigPathForPort,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} = require('../../../dist/cliproxy/config-generator');
|
||||
|
||||
describe('PR #184: Variant Port Isolation Integration', function () {
|
||||
let configPath;
|
||||
let cliproxyDir;
|
||||
let ccsDir;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
ccsDir = path.join(testHome, '.ccs');
|
||||
cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Start with empty config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up all test files
|
||||
try {
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
const files = fs.readdirSync(cliproxyDir);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(cliproxyDir, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Create multiple variants with ccs cliproxy create
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Create multiple variants', function () {
|
||||
it('creates 3 variants with unique ports via createVariant flow', function () {
|
||||
const variants = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: { VARIANT_ID: i } }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
generateConfig('gemini', port);
|
||||
variants.push({ name: `variant${i}`, port });
|
||||
}
|
||||
|
||||
// Verify all variants created
|
||||
const storedVariants = listVariantsFromConfig();
|
||||
assert.strictEqual(Object.keys(storedVariants).length, 3);
|
||||
|
||||
// Verify unique ports
|
||||
const ports = variants.map((v) => v.port);
|
||||
const uniquePorts = new Set(ports);
|
||||
assert.strictEqual(uniquePorts.size, 3, 'All variants should have unique ports');
|
||||
|
||||
// Verify sequential port assignment
|
||||
assert.strictEqual(variants[0].port, VARIANT_PORT_BASE);
|
||||
assert.strictEqual(variants[1].port, VARIANT_PORT_BASE + 1);
|
||||
assert.strictEqual(variants[2].port, VARIANT_PORT_BASE + 2);
|
||||
});
|
||||
|
||||
it('handles maximum variant creation (100 variants)', function () {
|
||||
// Create 100 variants
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Verify 100 variants exist
|
||||
const storedVariants = listVariantsFromConfig();
|
||||
assert.strictEqual(Object.keys(storedVariants).length, 100);
|
||||
|
||||
// Verify port exhaustion error
|
||||
assert.throws(
|
||||
() => getNextAvailablePort(),
|
||||
/Port limit reached.*100\/100/,
|
||||
'Should throw port limit error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Verify each variant gets unique port in config
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Verify unique port in config', function () {
|
||||
it('each variant has port stored in config', function () {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.strictEqual(
|
||||
variants[`variant${i}`].port,
|
||||
VARIANT_PORT_BASE + i,
|
||||
`Variant ${i} should have port ${VARIANT_PORT_BASE + i}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('port persists across config reload', function () {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, 'persistent.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('persistent', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Simulate reload by reading config directly
|
||||
const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(rawConfig.cliproxy.persistent.port, port);
|
||||
|
||||
// Verify via listVariantsFromConfig
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.persistent.port, port);
|
||||
});
|
||||
|
||||
it('port-specific config.yaml has correct port value', function () {
|
||||
const port = 8320;
|
||||
generateConfig('gemini', port);
|
||||
|
||||
const configContent = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
|
||||
assert.ok(configContent.includes(`port: ${port}`), 'Config should contain correct port');
|
||||
});
|
||||
|
||||
it('different ports have different config files', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
}
|
||||
|
||||
// Verify each has separate file
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8318.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8319.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8320.yaml')));
|
||||
|
||||
// Verify each has correct port in content
|
||||
for (const port of ports) {
|
||||
const content = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
|
||||
assert.ok(content.includes(`port: ${port}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Run multiple variants concurrently
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Run multiple variants concurrently', function () {
|
||||
it('registers sessions on 3 different ports simultaneously', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
const sessions = [];
|
||||
|
||||
// Start all 3 variants "concurrently"
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
const sessionId = registerSession(port, process.pid);
|
||||
sessions.push({ port, sessionId });
|
||||
}
|
||||
|
||||
// Verify all 3 are running
|
||||
for (const { port } of sessions) {
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.running, true, `Port ${port} should be running`);
|
||||
assert.strictEqual(status.port, port);
|
||||
assert.strictEqual(status.sessionCount, 1);
|
||||
}
|
||||
|
||||
// Verify separate session files exist
|
||||
for (const port of ports) {
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const { sessionId, port } of sessions) {
|
||||
unregisterSession(sessionId, port);
|
||||
}
|
||||
});
|
||||
|
||||
it('concurrent sessions on same variant port accumulate', function () {
|
||||
const port = 8318;
|
||||
generateConfig('gemini', port);
|
||||
|
||||
const session1 = registerSession(port, process.pid);
|
||||
const session2 = registerSession(port, process.pid);
|
||||
const session3 = registerSession(port, process.pid);
|
||||
|
||||
const status = getProxyStatus(port);
|
||||
assert.strictEqual(status.sessionCount, 3);
|
||||
|
||||
// Unregister one by one
|
||||
unregisterSession(session1, port);
|
||||
assert.strictEqual(getProxyStatus(port).sessionCount, 2);
|
||||
|
||||
unregisterSession(session2, port);
|
||||
assert.strictEqual(getProxyStatus(port).sessionCount, 1);
|
||||
|
||||
unregisterSession(session3, port);
|
||||
assert.strictEqual(getProxyStatus(port).running, false);
|
||||
});
|
||||
|
||||
it('stopping one variant does not affect others', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
for (const port of ports) {
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
}
|
||||
|
||||
// Stop middle variant
|
||||
deleteSessionLockForPort(8319);
|
||||
|
||||
// Verify others still running
|
||||
assert.strictEqual(getProxyStatus(8318).running, true);
|
||||
assert.strictEqual(getProxyStatus(8319).running, false);
|
||||
assert.strictEqual(getProxyStatus(8320).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(8318);
|
||||
deleteSessionLockForPort(8320);
|
||||
});
|
||||
|
||||
it('each variant has isolated session tracking', function () {
|
||||
// Create 2 variants
|
||||
const port1 = 8318;
|
||||
const port2 = 8319;
|
||||
|
||||
generateConfig('gemini', port1);
|
||||
generateConfig('gemini', port2);
|
||||
|
||||
// Register different session counts
|
||||
const p1s1 = registerSession(port1, process.pid);
|
||||
const p1s2 = registerSession(port1, process.pid);
|
||||
const p2s1 = registerSession(port2, process.pid);
|
||||
|
||||
assert.strictEqual(getProxyStatus(port1).sessionCount, 2);
|
||||
assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
|
||||
|
||||
// Unregister from port1 - should not affect port2
|
||||
unregisterSession(p1s1, port1);
|
||||
assert.strictEqual(getProxyStatus(port1).sessionCount, 1);
|
||||
assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
|
||||
|
||||
// Clean up
|
||||
unregisterSession(p1s2, port1);
|
||||
unregisterSession(p2s1, port2);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Verify ccs cliproxy list shows port column
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Verify list shows port column', function () {
|
||||
it('listVariantsFromConfig returns port for each variant', function () {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
|
||||
// Verify port field exists and is correct for each
|
||||
assert.strictEqual(variants.variant0.port, 8318);
|
||||
assert.strictEqual(variants.variant1.port, 8319);
|
||||
assert.strictEqual(variants.variant2.port, 8320);
|
||||
});
|
||||
|
||||
it('legacy variants without port return undefined', function () {
|
||||
// Create legacy variant without port field
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy_variant: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/settings.json',
|
||||
// No port field
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy_variant.port, undefined);
|
||||
assert.strictEqual(variants.legacy_variant.provider, 'gemini');
|
||||
});
|
||||
|
||||
it('mixed legacy and modern variants show correct ports', function () {
|
||||
const config = {
|
||||
profiles: {},
|
||||
cliproxy: {
|
||||
legacy: {
|
||||
provider: 'gemini',
|
||||
settings: '/path/to/legacy.json',
|
||||
// No port
|
||||
},
|
||||
modern: {
|
||||
provider: 'codex',
|
||||
settings: '/path/to/modern.json',
|
||||
port: 8318,
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(config));
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.strictEqual(variants.legacy.port, undefined);
|
||||
assert.strictEqual(variants.modern.port, 8318);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// PR Test Plan: Remove variant and verify cleanup of port-specific files
|
||||
// ==========================================================================
|
||||
describe('Test Plan: Remove variant and verify cleanup', function () {
|
||||
it('removes variant config entry', function () {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, 'to-remove.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('to-remove', 'gemini', settingsPath, undefined, port);
|
||||
|
||||
// Verify exists
|
||||
let variants = listVariantsFromConfig();
|
||||
assert.ok('to-remove' in variants);
|
||||
|
||||
// Remove
|
||||
const removed = removeVariantFromLegacyConfig('to-remove');
|
||||
assert.strictEqual(removed.port, port);
|
||||
|
||||
// Verify gone
|
||||
variants = listVariantsFromConfig();
|
||||
assert.ok(!('to-remove' in variants));
|
||||
});
|
||||
|
||||
it('deleteConfigForPort removes config-{port}.yaml', function () {
|
||||
const port = 8318;
|
||||
generateConfig('gemini', port);
|
||||
assert.ok(configExists(port));
|
||||
|
||||
deleteConfigForPort(port);
|
||||
assert.strictEqual(configExists(port), false);
|
||||
});
|
||||
|
||||
it('deleteSessionLockForPort removes sessions-{port}.json', function () {
|
||||
const port = 8318;
|
||||
registerSession(port, process.pid);
|
||||
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
assert.ok(fs.existsSync(sessionPath));
|
||||
|
||||
deleteSessionLockForPort(port);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('full variant removal cleans config, session, and config files', function () {
|
||||
const port = 8318;
|
||||
const settingsPath = path.join(ccsDir, 'full-cleanup.settings.json');
|
||||
|
||||
// Create variant with all files
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('full-cleanup', 'gemini', settingsPath, undefined, port);
|
||||
generateConfig('gemini', port);
|
||||
registerSession(port, process.pid);
|
||||
|
||||
// Verify all files exist
|
||||
assert.ok(listVariantsFromConfig()['full-cleanup']);
|
||||
assert.ok(configExists(port));
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)));
|
||||
|
||||
// Full cleanup (simulating removeVariant behavior)
|
||||
removeVariantFromLegacyConfig('full-cleanup');
|
||||
deleteConfigForPort(port);
|
||||
deleteSessionLockForPort(port);
|
||||
|
||||
// Verify all cleaned
|
||||
assert.ok(!listVariantsFromConfig()['full-cleanup']);
|
||||
assert.strictEqual(configExists(port), false);
|
||||
assert.strictEqual(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)), false);
|
||||
});
|
||||
|
||||
it('removing one variant does not affect others', function () {
|
||||
const ports = [8318, 8319, 8320];
|
||||
|
||||
// Create 3 variants
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
|
||||
generateConfig('gemini', ports[i]);
|
||||
registerSession(ports[i], process.pid);
|
||||
}
|
||||
|
||||
// Remove middle variant
|
||||
removeVariantFromLegacyConfig('variant1');
|
||||
deleteConfigForPort(8319);
|
||||
deleteSessionLockForPort(8319);
|
||||
|
||||
// Verify others intact
|
||||
const variants = listVariantsFromConfig();
|
||||
assert.ok('variant0' in variants);
|
||||
assert.ok(!('variant1' in variants));
|
||||
assert.ok('variant2' in variants);
|
||||
|
||||
assert.ok(configExists(8318));
|
||||
assert.strictEqual(configExists(8319), false);
|
||||
assert.ok(configExists(8320));
|
||||
|
||||
assert.strictEqual(getProxyStatus(8318).running, true);
|
||||
assert.strictEqual(getProxyStatus(8319).running, false);
|
||||
assert.strictEqual(getProxyStatus(8320).running, true);
|
||||
|
||||
// Clean up remaining
|
||||
deleteSessionLockForPort(8318);
|
||||
deleteSessionLockForPort(8320);
|
||||
});
|
||||
|
||||
it('freed port can be reused after deletion', function () {
|
||||
// Create variant on first port
|
||||
const port1 = getNextAvailablePort();
|
||||
const settingsPath1 = path.join(ccsDir, 'first.settings.json');
|
||||
fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy('first', 'gemini', settingsPath1, undefined, port1);
|
||||
generateConfig('gemini', port1);
|
||||
|
||||
// Next port should be port1 + 1
|
||||
const port2 = getNextAvailablePort();
|
||||
assert.strictEqual(port2, port1 + 1);
|
||||
|
||||
// Remove first variant
|
||||
removeVariantFromLegacyConfig('first');
|
||||
deleteConfigForPort(port1);
|
||||
|
||||
// port1 should now be available again
|
||||
const reusedPort = getNextAvailablePort();
|
||||
assert.strictEqual(reusedPort, port1, 'Freed port should be reusable');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Edge Cases for Port Isolation
|
||||
// ==========================================================================
|
||||
describe('Edge Cases', function () {
|
||||
it('default port (8317) uses sessions.json not sessions-8317.json', function () {
|
||||
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
|
||||
|
||||
// Default port uses sessions.json
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'sessions.json')));
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(cliproxyDir, `sessions-${CLIPROXY_DEFAULT_PORT}.json`)),
|
||||
false
|
||||
);
|
||||
|
||||
deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('default port (8317) uses config.yaml not config-8317.yaml', function () {
|
||||
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(cliproxyDir, 'config.yaml')));
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(cliproxyDir, `config-${CLIPROXY_DEFAULT_PORT}.yaml`)),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('port range is 8318-8417 (100 ports)', function () {
|
||||
assert.strictEqual(VARIANT_PORT_BASE, 8318);
|
||||
assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
|
||||
|
||||
// First variant port
|
||||
assert.strictEqual(getNextAvailablePort(), 8318);
|
||||
|
||||
// Create all 100
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const port = getNextAvailablePort();
|
||||
const settingsPath = path.join(ccsDir, `v${i}.settings.json`);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
|
||||
saveVariantLegacy(`v${i}`, 'gemini', settingsPath, undefined, port);
|
||||
}
|
||||
|
||||
// Last port should be 8417
|
||||
const lastVariant = listVariantsFromConfig()['v99'];
|
||||
assert.strictEqual(lastVariant.port, 8417);
|
||||
});
|
||||
|
||||
it('handles stale session files from crashed processes', function () {
|
||||
const port = 8318;
|
||||
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
|
||||
|
||||
// Write stale session with dead PID
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
JSON.stringify({
|
||||
port,
|
||||
pid: 999999999, // Non-existent PID
|
||||
sessions: ['stale-session'],
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
// getExistingProxy should detect and clean up
|
||||
const lock = getExistingProxy(port);
|
||||
assert.strictEqual(lock, null);
|
||||
assert.strictEqual(fs.existsSync(sessionPath), false);
|
||||
});
|
||||
|
||||
it('gracefully handles missing session file on unregister', function () {
|
||||
// Should not throw
|
||||
const result = unregisterSession('nonexistent-session', 8318);
|
||||
assert.strictEqual(result, true); // No file = should kill
|
||||
});
|
||||
|
||||
it('gracefully handles missing config file on delete', function () {
|
||||
// Should not throw
|
||||
deleteConfigForPort(9999);
|
||||
assert.strictEqual(configExists(9999), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* Unit tests for single-variant provider/model update behavior.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
updateVariant,
|
||||
validateProviderBackend,
|
||||
} from '../../../src/cliproxy/services/variant-service';
|
||||
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
describe('updateVariant - provider/model consistency', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-variant-update-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
|
||||
CUSTOM_FLAG: 'keep-me',
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] },
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'config.yaml'),
|
||||
`version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
backend: plus
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
demo:
|
||||
provider: gemini
|
||||
settings: ${settingsPath}
|
||||
port: 8318
|
||||
`,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setConfiguredBackend(backend: 'original' | 'plus'): void {
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const current = fs.readFileSync(configPath, 'utf-8');
|
||||
fs.writeFileSync(configPath, current.replace(/backend: (original|plus)/, `backend: ${backend}`));
|
||||
}
|
||||
|
||||
it('rejects provider change without model update', () => {
|
||||
const result = updateVariant('demo', { provider: 'codex' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Changing provider requires model update');
|
||||
});
|
||||
|
||||
it('rejects denylisted AGY 4.5 model updates', () => {
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'agy',
|
||||
model: 'claude-sonnet-4.5',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('denylist');
|
||||
});
|
||||
|
||||
it('reports plus-only providers as requiring the optional plus backend', () => {
|
||||
setConfiguredBackend('original');
|
||||
|
||||
const error = validateProviderBackend('ghcp');
|
||||
expect(error).toContain('`backend: plus`');
|
||||
expect(error).toContain('kaitranntt/CLIProxyAPIPlus');
|
||||
});
|
||||
|
||||
it('leaves the settings file unchanged when a plus-only provider update is rejected', () => {
|
||||
setConfiguredBackend('original');
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
const before = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'ghcp',
|
||||
model: 'gpt-5.4-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('updates provider and regenerates provider-specific core env in same settings file', () => {
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.1-codex-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.provider).toBe('codex');
|
||||
expect(result.variant?.model).toBe('gpt-5.4-mini');
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
hooks: { PreToolUse: unknown[] };
|
||||
};
|
||||
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex');
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(settings.env.CUSTOM_FLAG).toBe('keep-me');
|
||||
expect(settings.hooks.PreToolUse.length).toBe(1);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.cliproxy?.variants?.demo?.provider).toBe('codex');
|
||||
});
|
||||
|
||||
it('preserves codex sonnet alias on model-only updates', () => {
|
||||
const toCodex = updateVariant('demo', {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.3-codex',
|
||||
});
|
||||
expect(toCodex.success).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
|
||||
const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' });
|
||||
expect(modelOnly.success).toBe(true);
|
||||
|
||||
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('version-checker stale cache fallback', () => {
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempHome = '';
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-version-checker-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a stale latest-version cache when GitHub lookup fails', async () => {
|
||||
const {
|
||||
getVersionCachePath,
|
||||
writeInstalledVersion,
|
||||
} = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
|
||||
const { checkForUpdates } = await import('../../../src/cliproxy/binary/version-checker');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
writeInstalledVersion(plusBinDir, '6.6.80');
|
||||
fs.writeFileSync(
|
||||
getVersionCachePath('plus'),
|
||||
JSON.stringify({
|
||||
latestVersion: '6.9.23-0',
|
||||
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', {
|
||||
fetchLatestVersionFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.latestVersion).toBe('6.9.23-0');
|
||||
expect(result.currentVersion).toBe('6.6.80');
|
||||
expect(result.hasUpdate).toBe(true);
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a stale release-list cache when GitHub list lookup fails', async () => {
|
||||
const { getVersionListCachePath } = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types');
|
||||
const { fetchAllVersions } = await import('../../../src/cliproxy/binary/version-checker');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
getVersionListCachePath('plus'),
|
||||
JSON.stringify({
|
||||
versions: ['6.9.23-0', '6.9.22-0', '6.9.19-0'],
|
||||
latestStable: '6.9.23-0',
|
||||
latest: '6.9.23-0',
|
||||
checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await fetchAllVersions(false, 'plus', {
|
||||
fetchJsonFn: async () => {
|
||||
throw new Error('GitHub API error: HTTP 403');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.versions).toEqual(['6.9.23-0', '6.9.22-0', '6.9.19-0']);
|
||||
expect(result.latestStable).toBe('6.9.23-0');
|
||||
expect(result.latest).toBe('6.9.23-0');
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('skips update lookups when runtime startup prefers the installed binary', async () => {
|
||||
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
|
||||
const { ensureBinary } = await import('../../../src/cliproxy/binary/lifecycle');
|
||||
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary');
|
||||
|
||||
// Verify the contract via dependency injection rather than mock.module().
|
||||
// bun's mock.module() is process-wide and is NOT undone by mock.restore(),
|
||||
// which previously leaked a stubbed version-checker into unrelated test
|
||||
// files (cliproxy-stats-routes-*) that transitively import it.
|
||||
let checkForUpdatesCalls = 0;
|
||||
const checkForUpdatesSpy = async () => {
|
||||
checkForUpdatesCalls += 1;
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.8.2-0',
|
||||
latestVersion: '6.8.2-0',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const binaryPath = await ensureBinary({
|
||||
version: '6.8.2-0',
|
||||
releaseUrl: 'https://example.com/releases/download',
|
||||
binPath: plusBinDir,
|
||||
maxRetries: 1,
|
||||
verbose: false,
|
||||
forceVersion: false,
|
||||
skipAutoUpdate: true,
|
||||
allowInstall: true,
|
||||
backend: 'plus',
|
||||
checkForUpdatesFn: checkForUpdatesSpy,
|
||||
});
|
||||
|
||||
expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus')));
|
||||
expect(checkForUpdatesCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { handleCleanupCommand } from '../../../src/commands/cleanup-command';
|
||||
import { getCliproxyDir } from '../../../src/cliproxy/config-generator';
|
||||
import { getCliproxyDir } from '../../../src/cliproxy/config/config-generator';
|
||||
import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging';
|
||||
|
||||
describe('cleanup command', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver';
|
||||
import type { AuthStatus } from '../../../../src/cliproxy/auth-handler';
|
||||
import type { AuthStatus } from '../../../../src/cliproxy/auth/auth-handler';
|
||||
import {
|
||||
ModuleKind,
|
||||
ScriptTarget,
|
||||
@@ -67,14 +67,14 @@ describe('image-analysis-runtime-status circular dependency regression', () => {
|
||||
|
||||
const module = { exports: {} as Record<string, unknown> };
|
||||
const requireMap: Record<string, unknown> = {
|
||||
'../../cliproxy/auth-handler': authHandlerModule,
|
||||
'../../cliproxy/remote-proxy-client': {
|
||||
'../../cliproxy/auth/auth-handler': authHandlerModule,
|
||||
'../../cliproxy/services/remote-proxy-client': {
|
||||
checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }),
|
||||
},
|
||||
'../../cliproxy/remote-auth-fetcher': {
|
||||
'../../cliproxy/services/remote-auth-fetcher': {
|
||||
fetchRemoteAuthStatus: async () => [],
|
||||
},
|
||||
'../../cliproxy/proxy-target-resolver': {
|
||||
'../../cliproxy/proxy/proxy-target-resolver': {
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
@@ -86,7 +86,7 @@ describe('image-analysis-runtime-status circular dependency regression', () => {
|
||||
getProviderDisplayName: () => 'GitHub Copilot (OAuth)',
|
||||
isCLIProxyProvider: () => true,
|
||||
},
|
||||
'../../cliproxy/stats-fetcher': {
|
||||
'../../cliproxy/services/stats-fetcher': {
|
||||
isCliproxyRunning: async () => true,
|
||||
},
|
||||
'../../config/unified-config-types': {
|
||||
|
||||
Reference in new issue
Block a user