diff --git a/tests/unit/cliproxy/variant-port-integration.test.js b/tests/unit/cliproxy/variant-port-integration.test.js new file mode 100644 index 00000000..bfb03828 --- /dev/null +++ b/tests/unit/cliproxy/variant-port-integration.test.js @@ -0,0 +1,579 @@ +/** + * 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); + }); + }); +});