mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
Enables running multiple CLIProxy variants simultaneously on different ports. Each variant now gets a unique port in the 18100-18199 range, allowing concurrent use of providers like Gemini + Codex + custom variants. Key changes: - Add port field to variant config schema - Implement automatic port allocation (18100 + variant index) - Support variant-specific settings paths in config generator - Display port in dashboard UI for debugging - Show all models in variant editor dropdown - Add comprehensive tests for port allocation edge cases Closes related variant isolation work.
300 lines
9.9 KiB
JavaScript
300 lines
9.9 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|