/** * 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); }); }); });