Files
ccs/tests/unit/cliproxy/variant-port-allocation.test.js
T
kaitranntt 0bcaf4bc68 feat(cliproxy): add variant port isolation for concurrent proxy instances
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.
2025-12-24 04:15:04 -05:00

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');
});
});
});