Merge pull request #184 from kaitranntt/kai/feat/variant-port-isolation

feat(cliproxy): add variant port isolation
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-25 05:19:06 -05:00
commit 459d730029
24 files changed
+2578 -244

No files matched your search

@@ -0,0 +1,280 @@
/**
* 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,
getConfigPath,
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('getConfigPath', function () {
it('returns path for default port', function () {
const configPath = getConfigPath();
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}`));
});
});
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}`);
}
});
});
});
@@ -0,0 +1,420 @@
/**
* 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)}`
);
process.env.CCS_HOME = testHome;
const {
getExistingProxy,
registerSession,
unregisterSession,
cleanupOrphanedSessions,
stopProxy,
getProxyStatus,
getSessionLockPath,
deleteSessionLockForPort,
} = require('../../../dist/cliproxy/session-tracker');
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
describe('Session Tracker Port-Specific', function () {
const variantPort1 = 8318;
const variantPort2 = 8319;
let cliproxyDir;
beforeEach(function () {
// 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 () {
// 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
}
delete process.env.CCS_HOME;
});
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);
});
});
});
+59 -39
View File
@@ -28,23 +28,39 @@ const {
describe('Session Tracker', function () {
const testPort = 18317;
let sessionLockPath;
let cliproxyDir;
beforeEach(function () {
// Create test directories
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
// Use port-specific session file for non-default ports
sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`);
// Clean up any existing lock file
if (fs.existsSync(sessionLockPath)) {
fs.unlinkSync(sessionLockPath);
// 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 () {
// Clean up lock file
if (fs.existsSync(sessionLockPath)) {
fs.unlinkSync(sessionLockPath);
// 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
}
});
@@ -164,7 +180,7 @@ describe('Session Tracker', function () {
describe('unregisterSession', function () {
it('should return true when no lock exists', function () {
const result = unregisterSession('nonexistent');
const result = unregisterSession('nonexistent', testPort);
assert.strictEqual(result, true);
});
@@ -174,7 +190,7 @@ describe('Session Tracker', function () {
const session2 = registerSession(testPort, process.pid);
// Unregister first
const shouldKill = unregisterSession(session1);
const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
@@ -188,7 +204,7 @@ describe('Session Tracker', function () {
const session1 = registerSession(testPort, process.pid);
// Unregister it
const shouldKill = unregisterSession(session1);
const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, true, 'should kill - last session');
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
@@ -199,38 +215,40 @@ describe('Session Tracker', function () {
registerSession(testPort, process.pid);
// Try to unregister wrong session
const shouldKill = unregisterSession('wrong-session-id');
const shouldKill = unregisterSession('wrong-session-id', testPort);
// Should return false since a session still exists
assert.strictEqual(shouldKill, false);
});
});
describe('getSessionCount', function () {
describe('getSessionCount (default port)', function () {
it('should return 0 when no lock exists', function () {
assert.strictEqual(getSessionCount(), 0);
});
it('should return correct count', function () {
it('should return correct count (uses getProxyStatus for port-specific)', function () {
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 3);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
});
});
describe('hasActiveSessions', function () {
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 and proxy running', function () {
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(hasActiveSessions(), true);
assert.strictEqual(getProxyStatus(testPort).running, true);
});
it('should return false and cleanup when proxy is dead', function () {
@@ -243,7 +261,9 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
assert.strictEqual(hasActiveSessions(), false);
// getProxyStatus will clean up stale lock
const status = getProxyStatus(testPort);
assert.strictEqual(status.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
@@ -300,7 +320,7 @@ describe('Session Tracker', function () {
describe('stopProxy', function () {
it('should return error when no lock exists', async function () {
const result = await stopProxy();
const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
@@ -315,7 +335,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = await stopProxy();
const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.ok(result.error.includes('not running'));
assert.strictEqual(fs.existsSync(sessionLockPath), false);
@@ -327,7 +347,7 @@ describe('Session Tracker', function () {
// 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();
const status = getProxyStatus(testPort);
assert.strictEqual(status.running, true);
assert.strictEqual(status.pid, process.pid);
assert.strictEqual(status.sessionCount, 1);
@@ -336,7 +356,7 @@ describe('Session Tracker', function () {
describe('getProxyStatus', function () {
it('should return not running when no lock exists', function () {
const result = getProxyStatus();
const result = getProxyStatus(testPort);
assert.strictEqual(result.running, false);
assert.strictEqual(result.port, undefined);
assert.strictEqual(result.pid, undefined);
@@ -352,7 +372,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
const result = getProxyStatus(testPort);
assert.strictEqual(result.running, true);
assert.strictEqual(result.port, testPort);
assert.strictEqual(result.pid, process.pid);
@@ -369,22 +389,22 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
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();
let status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 1);
registerSession(testPort, process.pid);
status = getProxyStatus();
status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 2);
registerSession(testPort, process.pid);
status = getProxyStatus();
status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 3);
});
});
@@ -393,30 +413,30 @@ describe('Session Tracker', function () {
it('should handle complete multi-terminal workflow', function () {
// Terminal 1 starts - first session
const session1 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 starts - joins existing
const session2 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 starts - joins existing
const session3 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 3);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
// Terminal 1 exits - should NOT kill proxy
let shouldKill = unregisterSession(session1);
let shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 exits - should NOT kill proxy
shouldKill = unregisterSession(session3);
shouldKill = unregisterSession(session3, testPort);
assert.strictEqual(shouldKill, false);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 exits - SHOULD kill proxy (last session)
shouldKill = unregisterSession(session2);
shouldKill = unregisterSession(session2, testPort);
assert.strictEqual(shouldKill, true);
assert.strictEqual(getSessionCount(), 0);
assert.strictEqual(getProxyStatus(testPort).running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
@@ -0,0 +1,299 @@
/**
* 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');
});
});
});
@@ -0,0 +1,501 @@
/**
* 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]);
});
});
});
@@ -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);
});
});
});