mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
Dashboard Stop/Update buttons failed with contradictory errors: - Stop: "No active CLIProxy session found" - Update: "CLIProxy was already running" Root cause: service-manager spawned proxy but never called registerSession(), so stopProxy() couldn't find the lock file. Changes: - service-manager.ts: Add registerSession() after spawn - session-tracker.ts: Make stopProxy() async with port-based fallback - routes.ts: Update proxy-stop endpoint to async - cliproxy-command.ts: Update handleStop() to await async stopProxy() - port-utils.ts: Fix double-escaped regex for Windows port detection - session-tracker.test.js: Update tests for async stopProxy()
424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
/**
|
|
* Session Tracker Tests
|
|
*
|
|
* Tests for multi-instance CLIProxy session management.
|
|
* Verifies reference counting and cleanup behavior.
|
|
*/
|
|
|
|
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-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
process.env.CCS_HOME = testHome;
|
|
|
|
const {
|
|
getExistingProxy,
|
|
registerSession,
|
|
unregisterSession,
|
|
getSessionCount,
|
|
hasActiveSessions,
|
|
cleanupOrphanedSessions,
|
|
stopProxy,
|
|
getProxyStatus,
|
|
} = require('../../../dist/cliproxy/session-tracker');
|
|
|
|
describe('Session Tracker', function () {
|
|
const testPort = 18317;
|
|
let sessionLockPath;
|
|
|
|
beforeEach(function () {
|
|
// Create test directories
|
|
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
|
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
|
|
|
|
// Clean up any existing lock file
|
|
if (fs.existsSync(sessionLockPath)) {
|
|
fs.unlinkSync(sessionLockPath);
|
|
}
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Clean up lock file
|
|
if (fs.existsSync(sessionLockPath)) {
|
|
fs.unlinkSync(sessionLockPath);
|
|
}
|
|
});
|
|
|
|
afterAll(function () {
|
|
// Clean up test directory
|
|
try {
|
|
fs.rmSync(testHome, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
delete process.env.CCS_HOME;
|
|
});
|
|
|
|
describe('getExistingProxy', function () {
|
|
it('should return null when no lock file exists', function () {
|
|
const result = getExistingProxy(testPort);
|
|
assert.strictEqual(result, null);
|
|
});
|
|
|
|
it('should return null when port does not match', function () {
|
|
// Create lock with different port
|
|
const lock = {
|
|
port: 9999,
|
|
pid: process.pid, // Use current process as it's definitely running
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = getExistingProxy(testPort);
|
|
assert.strictEqual(result, null);
|
|
});
|
|
|
|
it('should return lock when proxy is healthy', function () {
|
|
// Create lock with current process (guaranteed to be running)
|
|
const lock = {
|
|
port: testPort,
|
|
pid: process.pid,
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = getExistingProxy(testPort);
|
|
assert.notStrictEqual(result, null);
|
|
assert.strictEqual(result.port, testPort);
|
|
assert.strictEqual(result.pid, process.pid);
|
|
assert.deepStrictEqual(result.sessions, ['session1']);
|
|
});
|
|
|
|
it('should return null and cleanup when proxy is dead', function () {
|
|
// Create lock with non-existent PID
|
|
const lock = {
|
|
port: testPort,
|
|
pid: 999999999, // Very unlikely to exist
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = getExistingProxy(testPort);
|
|
assert.strictEqual(result, null);
|
|
|
|
// Lock file should be cleaned up
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
|
});
|
|
});
|
|
|
|
describe('registerSession', function () {
|
|
it('should create new lock file for first session', function () {
|
|
const sessionId = registerSession(testPort, process.pid);
|
|
|
|
assert.ok(sessionId, 'should return session ID');
|
|
assert.ok(fs.existsSync(sessionLockPath), 'should create lock file');
|
|
|
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
|
assert.strictEqual(lock.port, testPort);
|
|
assert.strictEqual(lock.pid, process.pid);
|
|
assert.strictEqual(lock.sessions.length, 1);
|
|
assert.strictEqual(lock.sessions[0], sessionId);
|
|
});
|
|
|
|
it('should add session to existing lock', function () {
|
|
// Register first session
|
|
const session1 = registerSession(testPort, process.pid);
|
|
|
|
// Register second session
|
|
const session2 = registerSession(testPort, process.pid);
|
|
|
|
assert.notStrictEqual(session1, session2, 'should generate unique IDs');
|
|
|
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
|
assert.strictEqual(lock.sessions.length, 2);
|
|
assert.ok(lock.sessions.includes(session1));
|
|
assert.ok(lock.sessions.includes(session2));
|
|
});
|
|
|
|
it('should create new lock if PID differs', function () {
|
|
// Create lock with different PID
|
|
const oldLock = {
|
|
port: testPort,
|
|
pid: 12345,
|
|
sessions: ['old-session'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(oldLock));
|
|
|
|
// Register with new PID
|
|
const sessionId = registerSession(testPort, process.pid);
|
|
|
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
|
assert.strictEqual(lock.pid, process.pid);
|
|
assert.strictEqual(lock.sessions.length, 1);
|
|
assert.strictEqual(lock.sessions[0], sessionId);
|
|
});
|
|
});
|
|
|
|
describe('unregisterSession', function () {
|
|
it('should return true when no lock exists', function () {
|
|
const result = unregisterSession('nonexistent');
|
|
assert.strictEqual(result, true);
|
|
});
|
|
|
|
it('should remove session and return false when others remain', function () {
|
|
// Register two sessions
|
|
const session1 = registerSession(testPort, process.pid);
|
|
const session2 = registerSession(testPort, process.pid);
|
|
|
|
// Unregister first
|
|
const shouldKill = unregisterSession(session1);
|
|
|
|
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
|
|
|
|
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
|
assert.strictEqual(lock.sessions.length, 1);
|
|
assert.strictEqual(lock.sessions[0], session2);
|
|
});
|
|
|
|
it('should return true and delete lock when last session', function () {
|
|
// Register single session
|
|
const session1 = registerSession(testPort, process.pid);
|
|
|
|
// Unregister it
|
|
const shouldKill = unregisterSession(session1);
|
|
|
|
assert.strictEqual(shouldKill, true, 'should kill - last session');
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
|
|
});
|
|
|
|
it('should handle unregistering non-existent session gracefully', function () {
|
|
// Register a session
|
|
registerSession(testPort, process.pid);
|
|
|
|
// Try to unregister wrong session
|
|
const shouldKill = unregisterSession('wrong-session-id');
|
|
|
|
// Should return false since a session still exists
|
|
assert.strictEqual(shouldKill, false);
|
|
});
|
|
});
|
|
|
|
describe('getSessionCount', function () {
|
|
it('should return 0 when no lock exists', function () {
|
|
assert.strictEqual(getSessionCount(), 0);
|
|
});
|
|
|
|
it('should return correct count', function () {
|
|
registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 1);
|
|
|
|
registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 2);
|
|
|
|
registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 3);
|
|
});
|
|
});
|
|
|
|
describe('hasActiveSessions', 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 () {
|
|
registerSession(testPort, process.pid);
|
|
assert.strictEqual(hasActiveSessions(), true);
|
|
});
|
|
|
|
it('should return false and cleanup when proxy is dead', function () {
|
|
// Create lock with dead PID
|
|
const lock = {
|
|
port: testPort,
|
|
pid: 999999999,
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
assert.strictEqual(hasActiveSessions(), false);
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
|
});
|
|
});
|
|
|
|
describe('cleanupOrphanedSessions', function () {
|
|
it('should do nothing when no lock exists', function () {
|
|
cleanupOrphanedSessions(testPort);
|
|
// Should not throw
|
|
});
|
|
|
|
it('should not cleanup when port differs', function () {
|
|
const lock = {
|
|
port: 9999,
|
|
pid: 999999999, // Dead PID
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
cleanupOrphanedSessions(testPort);
|
|
|
|
// Should still exist (different port)
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
|
});
|
|
|
|
it('should cleanup when proxy is dead', function () {
|
|
const lock = {
|
|
port: testPort,
|
|
pid: 999999999, // Dead PID
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
cleanupOrphanedSessions(testPort);
|
|
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
|
});
|
|
|
|
it('should not cleanup when proxy is alive', function () {
|
|
const lock = {
|
|
port: testPort,
|
|
pid: process.pid, // Current process - alive
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
cleanupOrphanedSessions(testPort);
|
|
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
|
});
|
|
});
|
|
|
|
describe('stopProxy', function () {
|
|
it('should return error when no lock exists', async function () {
|
|
const result = await stopProxy();
|
|
assert.strictEqual(result.stopped, false);
|
|
assert.strictEqual(result.error, 'No active CLIProxy session found');
|
|
});
|
|
|
|
it('should cleanup stale lock when proxy is not running', async function () {
|
|
// Create lock with dead PID
|
|
const lock = {
|
|
port: testPort,
|
|
pid: 999999999, // Very unlikely to exist
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = await stopProxy();
|
|
assert.strictEqual(result.stopped, false);
|
|
assert.ok(result.error.includes('not running'));
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
|
});
|
|
|
|
it('should return pid and session count on success', function () {
|
|
// Register a session with current process
|
|
registerSession(testPort, process.pid);
|
|
|
|
// 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();
|
|
assert.strictEqual(status.running, true);
|
|
assert.strictEqual(status.pid, process.pid);
|
|
assert.strictEqual(status.sessionCount, 1);
|
|
});
|
|
});
|
|
|
|
describe('getProxyStatus', function () {
|
|
it('should return not running when no lock exists', function () {
|
|
const result = getProxyStatus();
|
|
assert.strictEqual(result.running, false);
|
|
assert.strictEqual(result.port, undefined);
|
|
assert.strictEqual(result.pid, undefined);
|
|
});
|
|
|
|
it('should return full status when proxy is running', function () {
|
|
const startedAt = new Date().toISOString();
|
|
const lock = {
|
|
port: testPort,
|
|
pid: process.pid, // Current process - alive
|
|
sessions: ['session1', 'session2'],
|
|
startedAt,
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = getProxyStatus();
|
|
assert.strictEqual(result.running, true);
|
|
assert.strictEqual(result.port, testPort);
|
|
assert.strictEqual(result.pid, process.pid);
|
|
assert.strictEqual(result.sessionCount, 2);
|
|
assert.strictEqual(result.startedAt, startedAt);
|
|
});
|
|
|
|
it('should cleanup and return not running when proxy is dead', function () {
|
|
const lock = {
|
|
port: testPort,
|
|
pid: 999999999, // Dead PID
|
|
sessions: ['session1'],
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
|
|
|
const result = getProxyStatus();
|
|
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();
|
|
assert.strictEqual(status.sessionCount, 1);
|
|
|
|
registerSession(testPort, process.pid);
|
|
status = getProxyStatus();
|
|
assert.strictEqual(status.sessionCount, 2);
|
|
|
|
registerSession(testPort, process.pid);
|
|
status = getProxyStatus();
|
|
assert.strictEqual(status.sessionCount, 3);
|
|
});
|
|
});
|
|
|
|
describe('Multi-session scenario', function () {
|
|
it('should handle complete multi-terminal workflow', function () {
|
|
// Terminal 1 starts - first session
|
|
const session1 = registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 1);
|
|
|
|
// Terminal 2 starts - joins existing
|
|
const session2 = registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 2);
|
|
|
|
// Terminal 3 starts - joins existing
|
|
const session3 = registerSession(testPort, process.pid);
|
|
assert.strictEqual(getSessionCount(), 3);
|
|
|
|
// Terminal 1 exits - should NOT kill proxy
|
|
let shouldKill = unregisterSession(session1);
|
|
assert.strictEqual(shouldKill, false);
|
|
assert.strictEqual(getSessionCount(), 2);
|
|
|
|
// Terminal 3 exits - should NOT kill proxy
|
|
shouldKill = unregisterSession(session3);
|
|
assert.strictEqual(shouldKill, false);
|
|
assert.strictEqual(getSessionCount(), 1);
|
|
|
|
// Terminal 2 exits - SHOULD kill proxy (last session)
|
|
shouldKill = unregisterSession(session2);
|
|
assert.strictEqual(shouldKill, true);
|
|
assert.strictEqual(getSessionCount(), 0);
|
|
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
|
});
|
|
});
|
|
});
|