mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
Add comprehensive test suite mapping to PR #184 test plan: - Create multiple variants with unique ports (8318-8417) - Verify port persistence in config and separate config-{port}.yaml - Run multiple variants concurrently with isolated sessions - Verify list shows port column for each variant - Remove variant cleans config, session, and port-specific files 25 new tests covering edge cases: - Port exhaustion (100 variant max) - Port reuse after deletion - Legacy variants without port field - Stale session cleanup for dead PIDs - Default port special handling (sessions.json)
580 lines
20 KiB
JavaScript
580 lines
20 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|