Files
Codeman/test/session-manager.test.ts
T
arkonandClaude Opus 4.8 e77df131b8 fix(mobile): address review blockers on the touch-device change set
Review follow-ups on PR #111 (rebased onto master post-#112/#113):

Resize arbitration redesigned (review blocker 2): the previous
'cols < _ptyCols' guard froze a mobile-only session's PTY at the spawn
default — narrow phones rendered clipped and could never re-fit. The
guard now uses connection-scoped desktop sizing claims instead:
ws-routes registers a claim on a desktop-typed resize and releases it
on socket close (or when the same connection later reports a small
viewport), and Session.resize() ignores mobile/tablet resizes only
while at least one desktop connection holds a claim. A phone alone
fully controls its size (shrink, rows-only shrink, re-grow); a phone
glancing at a desktop-driven session can no longer reflow it.
mobile-handlers' keyboard open/close resize now declares its viewport
type so it participates in arbitration. Tests rewritten to cover
mobile-only shrink/rows-only/re-grow, claim/release lifecycle, multi-
claim behavior, and untyped legacy resizes; ws-routes test covers the
claim lifecycle over a real socket.

Solo/detached header restored (review blocker 3): index.html had
removed #soloSessionTitle and #soloRedockBtn, which _applySoloMode
still references — every detached window hit a null deref. Both are
back alongside the new mobile utility toggle.

Desktop leak fixed (review should-fix): .mobile-header-utility-toggle
had no rule outside the <=768px media queries, so the raw button
rendered on desktop. styles.css now hides it by default; the mobile/
tablet queries re-enable it.

Visual-regression baselines reverted to master (review should-fix):
the 18 contributor-machine PNGs are environment-specific (8 of the
behavioral tests already report environment-sensitive failures across
machines); re-baseline deliberately on the canonical machine instead.
The 24 behavioral keyboard/layout/tabs tests are kept as-is.

AGENTS.md trimmed to a pointer at CLAUDE.md (review should-fix) to
avoid drift between duplicated guidance.

Also dropped a dead getAttachmentHistoryForPersist stub (codex-branch
residue — no such method exists in src/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 04:07:26 +02:00

371 lines
10 KiB
TypeScript

/**
* @fileoverview Tests for SessionManager
*
* Tests session lifecycle management including creation,
* event forwarding, and state persistence.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EventEmitter } from 'node:events';
// Mock state that can be accessed by both mocks and tests
const mockState = vi.hoisted(() => ({
store: null as any,
sessions: new Map<string, any>(),
}));
// Mock the state-store
vi.mock('../src/state-store.js', () => {
class MockStateStore {
state: any = {
sessions: {},
config: { maxConcurrentSessions: 5 },
};
getConfig = vi.fn(() => this.state.config);
getSessions = vi.fn(() => this.state.sessions);
getSession = vi.fn((id: string) => this.state.sessions[id]);
setSession = vi.fn((id: string, state: any) => {
this.state.sessions[id] = state;
});
removeSession = vi.fn((id: string) => {
delete this.state.sessions[id];
});
}
const instance = new MockStateStore();
mockState.store = instance;
return {
getStore: vi.fn(() => instance),
StateStore: MockStateStore,
};
});
// Mock the Session class
vi.mock('../src/session.js', () => {
const { EventEmitter } = require('node:events');
class MockSession extends EventEmitter {
id: string;
workingDir: string;
_started = false;
_stopped = false;
constructor(options: { workingDir: string }) {
super();
this.id = `session-${Math.random().toString(36).substr(2, 9)}`;
this.workingDir = options.workingDir;
mockState.sessions.set(this.id, this);
}
async start() {
this._started = true;
return this;
}
async stop() {
this._stopped = true;
this.emit('exit');
}
toState() {
return {
id: this.id,
workingDir: this.workingDir,
status: this._stopped ? 'stopped' : this._started ? 'running' : 'pending',
pid: this._started && !this._stopped ? 12345 : null,
};
}
getEnvOverridesForPersist() {
return undefined;
}
getOutput() {
return 'mock output';
}
getError() {
return null;
}
isIdle() {
return true;
}
isBusy() {
return false;
}
async sendInput(input: string) {
// Mock implementation
}
}
return {
Session: MockSession,
};
});
// Import after mocking
import { SessionManager, getSessionManager } from '../src/session-manager.js';
describe('SessionManager', () => {
let manager: SessionManager;
beforeEach(() => {
// Reset mock state
mockState.sessions.clear();
if (mockState.store) {
mockState.store.state = {
sessions: {},
config: { maxConcurrentSessions: 5 },
};
}
// Reset mock functions
vi.clearAllMocks();
// Create fresh manager
manager = new SessionManager();
});
describe('createSession', () => {
it('should create and start a new session', async () => {
const session = await manager.createSession('/tmp/test');
expect(session).toBeDefined();
expect(session.workingDir).toBe('/tmp/test');
expect(manager.hasSession(session.id)).toBe(true);
});
it('should emit sessionStarted event', async () => {
const handler = vi.fn();
manager.on('sessionStarted', handler);
const session = await manager.createSession('/tmp/test');
expect(handler).toHaveBeenCalledWith(session);
});
it('should persist session to store', async () => {
const session = await manager.createSession('/tmp/test');
expect(mockState.store.setSession).toHaveBeenCalledWith(session.id, expect.any(Object));
});
it('should throw when max sessions reached', async () => {
mockState.store.state.config.maxConcurrentSessions = 1;
await manager.createSession('/tmp/test1');
await expect(manager.createSession('/tmp/test2')).rejects.toThrow(/Maximum concurrent sessions/);
});
it('should forward session output events', async () => {
const handler = vi.fn();
manager.on('sessionOutput', handler);
const session = await manager.createSession('/tmp/test');
session.emit('output', 'test output');
expect(handler).toHaveBeenCalledWith(session.id, 'test output');
});
it('should forward session error events', async () => {
const handler = vi.fn();
manager.on('sessionError', handler);
const session = await manager.createSession('/tmp/test');
session.emit('error', 'test error');
expect(handler).toHaveBeenCalledWith(session.id, 'test error');
});
it('should forward session completion events', async () => {
const handler = vi.fn();
manager.on('sessionCompletion', handler);
const session = await manager.createSession('/tmp/test');
session.emit('completion', 'DONE');
expect(handler).toHaveBeenCalledWith(session.id, 'DONE');
});
it('should forward session exit events', async () => {
const handler = vi.fn();
manager.on('sessionStopped', handler);
const session = await manager.createSession('/tmp/test');
session.emit('exit');
expect(handler).toHaveBeenCalledWith(session.id);
});
});
describe('stopSession', () => {
it('should stop a session by ID', async () => {
const session = await manager.createSession('/tmp/test');
await manager.stopSession(session.id);
expect((session as any)._stopped).toBe(true);
});
it('should handle non-existent session gracefully', async () => {
await expect(manager.stopSession('non-existent')).resolves.not.toThrow();
expect(manager.getSessionCount()).toBe(0);
});
it('should update stored session to stopped', async () => {
// Set up a session in store
mockState.store.state.sessions['stored-session'] = {
id: 'stored-session',
status: 'running',
pid: 12345,
};
await manager.stopSession('stored-session');
expect(mockState.store.setSession).toHaveBeenCalledWith(
'stored-session',
expect.objectContaining({
status: 'stopped',
pid: null,
})
);
});
});
describe('stopAllSessions', () => {
it('should stop all active sessions', async () => {
const session1 = await manager.createSession('/tmp/test1');
const session2 = await manager.createSession('/tmp/test2');
await manager.stopAllSessions();
expect((session1 as any)._stopped).toBe(true);
expect((session2 as any)._stopped).toBe(true);
});
});
describe('getSession', () => {
it('should return undefined for non-existent session', () => {
expect(manager.getSession('non-existent')).toBeUndefined();
});
it('should return the correct session', async () => {
const session = await manager.createSession('/tmp/test');
expect(manager.getSession(session.id)).toBe(session);
});
});
describe('getAllSessions', () => {
it('should return empty array when no sessions', () => {
expect(manager.getAllSessions()).toEqual([]);
});
it('should return all sessions', async () => {
const session1 = await manager.createSession('/tmp/test1');
const session2 = await manager.createSession('/tmp/test2');
const sessions = manager.getAllSessions();
expect(sessions).toHaveLength(2);
expect(sessions).toContain(session1);
expect(sessions).toContain(session2);
});
});
describe('getIdleSessions', () => {
it('should return sessions that are idle', async () => {
const session = await manager.createSession('/tmp/test');
const idle = manager.getIdleSessions();
expect(idle).toContain(session);
});
});
describe('getBusySessions', () => {
it('should return empty when all sessions are idle', async () => {
await manager.createSession('/tmp/test');
const busy = manager.getBusySessions();
expect(busy).toHaveLength(0);
});
});
describe('getSessionCount', () => {
it('should return 0 when no sessions', () => {
expect(manager.getSessionCount()).toBe(0);
});
it('should return correct count', async () => {
await manager.createSession('/tmp/test1');
await manager.createSession('/tmp/test2');
expect(manager.getSessionCount()).toBe(2);
});
});
describe('hasSession', () => {
it('should return false for non-existent session', () => {
expect(manager.hasSession('non-existent')).toBe(false);
});
it('should return true for existing session', async () => {
const session = await manager.createSession('/tmp/test');
expect(manager.hasSession(session.id)).toBe(true);
});
});
describe('sendToSession', () => {
it('should throw for non-existent session', async () => {
await expect(manager.sendToSession('non-existent', 'test')).rejects.toThrow(/Session non-existent not found/);
});
it('should send input to session', async () => {
const session = await manager.createSession('/tmp/test');
const sendSpy = vi.spyOn(session as any, 'sendInput');
await manager.sendToSession(session.id, 'test input');
expect(sendSpy).toHaveBeenCalledWith('test input');
});
});
describe('getSessionOutput', () => {
it('should return null for non-existent session', () => {
expect(manager.getSessionOutput('non-existent')).toBeNull();
});
it('should return session output', async () => {
const session = await manager.createSession('/tmp/test');
expect(manager.getSessionOutput(session.id)).toBe('mock output');
});
});
describe('getSessionError', () => {
it('should return null for non-existent session', () => {
expect(manager.getSessionError('non-existent')).toBeNull();
});
it('should return session error', async () => {
const session = await manager.createSession('/tmp/test');
expect(manager.getSessionError(session.id)).toBeNull();
});
});
});
describe('getSessionManager singleton', () => {
it('should return a SessionManager instance', () => {
const manager = getSessionManager();
expect(manager).toBeInstanceOf(SessionManager);
});
});