Files
web-terminal/test/manager.test.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

1360 lines
52 KiB
TypeScript

/**
* T13 tests — session/manager.ts (session table lifecycle).
*
* ARCHITECTURE §3.5 + the cross-validated fixes:
* - M3: reapIdle uses now - max(detachedAt, lastOutputAt) > idleTtlMs
* (not just detachedAt, so active sessions with recent output are not reaped).
* - M4: createSession throwing propagates out of handleAttach (not swallowed).
* - L1: attach hitting an already-exited session → replay buffer + resend exit + remove.
* - L2: the onExit callback injected into createSession removes the session from the table.
*
* node-pty is mocked so spawn returns a MockIPty — no real PTY/shell is started.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type {
Config,
Dims,
NotifyService,
Session,
StatusTelemetry,
WebSocketLike,
} from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
// ── node-pty mock ───────────────────────────────────────────────────────────
// All createSession calls ultimately call spawn; we intercept at the node-pty
// boundary so the session module works exactly as in production, but no real
// shell is ever spawned.
let nextPty: MockIPty = createMockPty();
let spawnShouldThrow: Error | null = null;
vi.mock('node-pty', () => ({
spawn: (_file: string, _args: string[], _options: unknown) => {
if (spawnShouldThrow) throw spawnShouldThrow;
return nextPty;
},
}));
// Import AFTER vi.mock so the module under test binds to the mocked spawn.
const { createSessionManager } = await import('../src/session/manager.js');
// ── Helpers ──────────────────────────────────────────────────────────────────
const CFG: Config = {
port: 3000,
bindHost: '0.0.0.0',
shellPath: '/bin/zsh',
homeDir: '/home/tester',
idleTtlMs: 10_000, // 10 s for easy arithmetic in tests
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
maxSessions: 50,
maxMsgsPerSec: 2000,
permTimeoutMs: 300_000,
reapIntervalMs: 60_000,
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
// v0.6 project manager
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
// v0.7 Walk-away Workbench
vapidPublicKey: undefined,
vapidPrivateKey: undefined,
vapidSubject: undefined,
pushStorePath: '/home/tester/.web-terminal-push-subs.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
maxFanoutLanes: 6,
defaultPermissionMode: 'default',
allowAutoMode: false,
// W2 inject queue
queueEnabled: true,
queueMaxItems: 10,
queueItemMaxBytes: 4096,
queueSettleMs: 1500,
};
const DIMS: Dims = { cols: 80, rows: 24 };
/** A telemetry sample for B2 tests. */
function makeTelemetry(at = 1_000): StatusTelemetry {
return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at };
}
/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */
function createMockNotify() {
const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {});
const service: NotifyService = {
isEnabled: () => true,
notify,
};
return { service, notify };
}
interface MockWs extends WebSocketLike {
readyState: number;
sent: string[];
closed: boolean;
}
function createMockWs(readyState: number = WS_OPEN): MockWs {
const sent: string[] = [];
return {
readyState,
sent,
closed: false,
send(data: string) {
sent.push(data);
},
close() {
this.closed = true;
this.readyState = 3; // CLOSED
},
};
}
function parseSent(ws: MockWs) {
return ws.sent.map((s) => JSON.parse(s));
}
beforeEach(() => {
nextPty = createMockPty();
spawnShouldThrow = null;
});
// ── handleAttach: null sessionId → new session ────────────────────────────────
describe('handleAttach — null sessionId', () => {
it('creates a new session and returns it', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(session).toBeDefined();
expect(session.meta.id).toBeTruthy();
expect(session.clients.has(ws)).toBe(true);
});
it('stores the session so get() returns it', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(mgr.get(session.meta.id)).toBe(session);
});
it('propagates spawn failure without swallowing it (M4)', () => {
spawnShouldThrow = new Error('spawn failed: /no/such/shell ENOENT');
const mgr = createSessionManager(CFG);
const ws = createMockWs();
expect(() => mgr.handleAttach(ws, null, DIMS, 1_000)).toThrow(/spawn failed/);
});
});
// ── handleAttach: hit live session ────────────────────────────────────────────
describe('handleAttach — hit live session', () => {
it('JOINS the new ws and returns the same session (multi-device share)', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
const ws2 = createMockWs();
const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000);
expect(session2).toBe(session);
// Both clients share the session now.
expect(session.clients.has(ws1)).toBe(true);
expect(session.clients.has(ws2)).toBe(true);
});
it('does NOT kick the previously attached ws (v0.4 sharing)', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
const ws2 = createMockWs();
mgr.handleAttach(ws2, id, DIMS, 2_000);
// The old ws must stay open — devices mirror the session.
expect(ws1.closed).toBe(false);
expect(session.clients.size).toBe(2);
});
it('broadcasts live output to ALL joined clients', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const ws2 = createMockWs();
mgr.handleAttach(ws2, session.meta.id, DIMS, 2_000);
ws1.sent.length = 0;
ws2.sent.length = 0;
(session.pty as MockIPty).emitData('to everyone');
expect(parseSent(ws1).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
expect(parseSent(ws2).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
});
it('replays the buffer to the new ws', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
// Produce some output while ws1 is attached.
(session.pty as MockIPty).emitData('hello world');
const ws2 = createMockWs();
mgr.handleAttach(ws2, id, DIMS, 2_000);
const msgs = parseSent(ws2);
const outputMsgs = msgs.filter((m) => m.type === 'output');
expect(outputMsgs.some((m: { data: string }) => m.data.includes('hello world'))).toBe(true);
});
});
// ── handleAttach: hit already-exited session (L1) ─────────────────────────────
describe('handleAttach — hit already-exited session (L1)', () => {
it('sends the buffer snapshot to the new ws', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
// Build some output, then detach ws1, then simulate PTY exit.
(session.pty as MockIPty).emitData('last output before exit');
mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach
// Force detach by patching directly (simulates WS close event).
session.clients.clear();
session.detachedAt = 3_000;
// Now PTY exits while detached — session preserved (L1).
(session.pty as MockIPty).emitExit(0);
// session.exitedAt is set, session still in table because onExit in detach
// scenario keeps session (manager's onExit callback removes from table only if
// invoked — but per L2, after exit while attached ws exists the callback removes,
// while L1 says detach-then-exit: we must keep for replay).
// Actually per ARCHITECTURE: the injected onExit IS called by session.ts after exit.
// But the L1 scenario means: exitedAt!=null, session is in table until re-attach.
// We re-insert it manually to simulate the correct state from ARCHITECTURE §4.4:
// "session is preserved (buffer + exitedAt)" waiting for re-attach.
// The key thing to test is that when a NEW ws comes with this session's id,
// the manager replays the buffer + sends exit + removes from table.
// For this test: we need the session to still be in the map with exitedAt set.
// The manager's injected onExit should NOT remove from table in this case —
// But wait: per ARCHITECTURE §3.5 L2: "PTY 退出: injected onExit → 从表删除"
// And L1: "detach后exit → 保留buffer+exitedAt, 等用户回来attach"
// These seem contradictory. Let me re-read:
//
// ARCHITECTURE §3.5 constraint: "PTY 退出: createSession 内部 onExit → 置 exitedAt →
// 有 ws 则发 exit → 调 manager 注入的回调从表移除 (L2)"
// ARCHITECTURE §3.5 L1: "detach 后才退出(无 ws 可通知, L1): 不立即丢弃会话, 保留
// buffer + exitedAt, 等用户带旧 id 回来 attach 时回放 + 补发 exit"
//
// Resolution: L2 says "从表移除" — but L1 says "不立即丢弃". So L2 applies when
// attachedWs is NOT null (ws is attached), and L1 applies when detached.
// The manager's injected onExit callback should:
// - Only remove from table if attachedWs != null at exit time (ws was attached)
// - Keep in table if detached at exit time (for L1 replay on reconnect)
//
// But PLAN.md T13 Steps says: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)"
// And the task prompt says: "L2: onExit 从会话表删除"
// And also: "命中已退出会话(exitedAt!=null) → 回放+补发exit后从表移除(L1)"
//
// So the behavior is:
// - When PTY exits with ws attached: onExit removes from table immediately (L2).
// - When PTY exits while detached (L1): onExit does NOT remove; session stays.
// The session is removed when the next attach hits it and sees exitedAt!=null.
//
// This means the manager's onExit callback must check attachedWs: if null, keep.
// If ws attached, remove.
//
// Actually re-reading ARCHITECTURE §3.5 more carefully:
// The constraint says: "PTY 退出: session 内部 onExit → 置 exitedAt → 有 ws 则发 exit
// → 调 manager 注入的回调从表移除 (L2: session 不持有 manager, 退出事件靠注入回调跨层冒泡)"
//
// This says "调 manager 注入的回调从表移除" — it calls the callback and removes.
// But L1 says "不立即丢弃". These are the same callback situation.
//
// Looking at PLAN T13 instruction: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)"
// And the task prompt: "命中已退出 → 回放+补发exit+移除 → 从表移除(L1)"
//
// The reconciliation: L2's callback ALWAYS removes the session from the table when
// called. L1 means: when the session exits while detached, the session stays in the
// table BECAUSE the onExit in the detach case should NOT remove it immediately.
//
// Wait, but the task prompt says "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)"
// unconditionally. And L1 says "保留 buffer + exitedAt".
//
// The only way to reconcile: the onExit callback should NOT remove when detached.
// The callback should check session.attachedWs (which is null when detached):
// if (session.attachedWs !== null) → remove from table (session was live)
// if (session.attachedWs === null) → keep in table (for L1 replay)
//
// This matches ARCHITECTURE §4.4: "[在 detach 状态下退出] pty.onExit → 置 exitedAt/exitCode
// → attachedWs 为 null, exit 暂不投递 → 会话保留(buffer + exitedAt)"
// And the session.ts onExit flow: sets exitedAt, sends exit if ws, calls manager onExit.
//
// So the manager's onExit MUST only remove when ws was attached. When detached,
// it should keep the session for L1 replay.
//
// CONCLUSION: The injected onExit callback in the manager checks session.attachedWs:
// - non-null → ws existed when exit fired → remove from table (L2)
// - null → detached-then-exit → keep in table for L1 replay
// At this point, session.exitedAt should be set, and session should still be in table.
expect(session.exitedAt).not.toBeNull();
// Now a new ws comes back with the same session id:
const ws2 = createMockWs();
// Need to re-add session to map manually IF the onExit removed it.
// Let's just test the flow by checking what actually happens:
const result = mgr.handleAttach(ws2, id, DIMS, 5_000);
// The manager should have replayed the buffer to ws2.
const msgs = parseSent(ws2);
const outputMsgs = msgs.filter((m: { type: string }) => m.type === 'output');
expect(outputMsgs.some((m: { data: string }) => m.data.includes('last output before exit'))).toBe(true);
});
it('sends exit message to the new ws after replay (L1)', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
// Detach by simulating WS close.
session.clients.clear();
session.detachedAt = 2_000;
// PTY exits while detached.
(session.pty as MockIPty).emitExit(42);
expect(session.exitedAt).not.toBeNull();
expect(session.exitCode).toBe(42);
// New ws comes back.
const ws2 = createMockWs();
mgr.handleAttach(ws2, id, DIMS, 5_000);
const msgs = parseSent(ws2);
const exitMsgs = msgs.filter((m: { type: string }) => m.type === 'exit');
expect(exitMsgs).toHaveLength(1);
expect(exitMsgs[0]).toMatchObject({ type: 'exit', code: 42 });
});
it('removes the exited session from the table after replay (L1)', () => {
const mgr = createSessionManager(CFG);
const ws1 = createMockWs();
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
const id = session.meta.id;
// Detach and exit.
session.clients.clear();
session.detachedAt = 2_000;
(session.pty as MockIPty).emitExit(0);
// Reconnect triggers L1 replay + removal.
const ws2 = createMockWs();
mgr.handleAttach(ws2, id, DIMS, 5_000);
// Session must be gone from the table now.
expect(mgr.get(id)).toBeUndefined();
});
});
// ── handleAttach: session not found → new session ─────────────────────────────
describe('handleAttach — session not found', () => {
it('creates a new session when the given id does not exist', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const unknownId = '00000000-0000-4000-8000-000000000000';
const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000);
expect(session).toBeDefined();
// The returned session has a NEW id (not the bogus one).
expect(session.meta.id).not.toBe(unknownId);
expect(session.clients.has(ws)).toBe(true);
});
it('the newly created session is stored in the table', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const unknownId = '00000000-0000-4000-8000-000000000001';
const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000);
expect(mgr.get(session.meta.id)).toBe(session);
// Old unknown id is NOT stored.
expect(mgr.get(unknownId)).toBeUndefined();
});
});
// ── get ───────────────────────────────────────────────────────────────────────
describe('get', () => {
it('returns undefined for unknown ids', () => {
const mgr = createSessionManager(CFG);
expect(mgr.get('00000000-0000-4000-8000-000000000002')).toBeUndefined();
});
it('returns the session for a known id', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(mgr.get(session.meta.id)).toBe(session);
});
});
// ── reapIdle (M3) ─────────────────────────────────────────────────────────────
describe('reapIdle (M3)', () => {
it('does not reap a session that is still attached', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
// Not detached, so detachedAt = null → not eligible.
const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1);
expect(reaped).toBe(0);
expect(mgr.get(session.meta.id)).toBeDefined();
});
it('does not reap a detached session that received output recently (M3)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
const id = session.meta.id;
// Detach at t=2000.
session.clients.clear();
session.detachedAt = 2_000;
// Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt.
(session.pty as MockIPty).emitData('fresh output');
// Force the lastOutputAt to a specific value we can assert against.
session.lastOutputAt = 5_000;
// Now reap at t = 5000 + idleTtl - 1 → not yet expired relative to lastOutputAt.
const reaped = mgr.reapIdle(5_000 + CFG.idleTtlMs - 1);
expect(reaped).toBe(0);
expect(mgr.get(id)).toBeDefined();
expect((session.pty as MockIPty).killed).toBe(false);
});
it('reaps a detached session whose max(detachedAt, lastOutputAt) + idleTtl has passed (M3)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
const id = session.meta.id;
// Detach at t=2000; no output after detach.
session.clients.clear();
session.detachedAt = 2_000;
session.lastOutputAt = 1_500; // before detach → max = 2000
// Reap at t = 2000 + idleTtl + 1 → expired.
const reaped = mgr.reapIdle(2_000 + CFG.idleTtlMs + 1);
expect(reaped).toBe(1);
expect(mgr.get(id)).toBeUndefined();
expect((session.pty as MockIPty).killed).toBe(true);
});
it('reaps multiple idle sessions and returns the count', () => {
const mgr = createSessionManager(CFG);
// Session A — will be reaped.
nextPty = createMockPty();
const wsA = createMockWs();
const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000);
sessionA.clients.clear();
sessionA.detachedAt = 1_000;
sessionA.lastOutputAt = 1_000;
// Session B — will be reaped.
nextPty = createMockPty();
const wsB = createMockWs();
const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000);
sessionB.clients.clear();
sessionB.detachedAt = 1_000;
sessionB.lastOutputAt = 1_000;
const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1);
expect(reaped).toBe(2);
expect(mgr.get(sessionA.meta.id)).toBeUndefined();
expect(mgr.get(sessionB.meta.id)).toBeUndefined();
});
it('does not reap if detachedAt is null (still attached / never detached)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
// detachedAt is null — session is still live.
const reaped = mgr.reapIdle(99_999_999);
expect(reaped).toBe(0);
expect(mgr.get(session.meta.id)).toBeDefined();
});
});
// ── list (v0.4 multi-device discovery) ────────────────────────────────────────
describe('list', () => {
it('returns live sessions with client counts, newest first', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const a = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
nextPty = createMockPty();
const b = mgr.handleAttach(createMockWs(), null, DIMS, 2_000);
// a second device joins session b → clientCount 2
mgr.handleAttach(createMockWs(), b.meta.id, DIMS, 2_500);
const list = mgr.list();
expect(list.map((s) => s.id)).toEqual([b.meta.id, a.meta.id]); // newest first
expect(list.find((s) => s.id === b.meta.id)?.clientCount).toBe(2);
expect(list.find((s) => s.id === a.meta.id)?.clientCount).toBe(1);
expect(list.every((s) => s.exited === false)).toBe(true);
});
it('returns an empty array when there are no sessions', () => {
const mgr = createSessionManager(CFG);
expect(mgr.list()).toEqual([]);
});
});
// ── shutdown ──────────────────────────────────────────────────────────────────
describe('shutdown', () => {
it('kills all sessions and clears the table', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws1 = createMockWs();
const s1 = mgr.handleAttach(ws1, null, DIMS, 1_000);
nextPty = createMockPty();
const ws2 = createMockWs();
const s2 = mgr.handleAttach(ws2, null, DIMS, 1_000);
mgr.shutdown();
expect((s1.pty as MockIPty).killed).toBe(true);
expect((s2.pty as MockIPty).killed).toBe(true);
// Table is cleared.
expect(mgr.get(s1.meta.id)).toBeUndefined();
expect(mgr.get(s2.meta.id)).toBeUndefined();
});
});
// ── maxSessions DoS cap (Sec H1) ──────────────────────────────────────────────
describe('handleAttach — session cap', () => {
it('throws once the table is at cfg.maxSessions (new-session path)', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 2 };
const mgr = createSessionManager(cappedCfg);
nextPty = createMockPty();
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
nextPty = createMockPty();
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Third new session must be refused (reuses the M4 exit(-1) path in server).
nextPty = createMockPty();
expect(() => mgr.handleAttach(createMockWs(), null, DIMS, 1_000)).toThrow(/limit/i);
});
it('also caps the not-found path (unknown id → would create)', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 1 };
const mgr = createSessionManager(cappedCfg);
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const unknown = '00000000-0000-4000-8000-000000000099';
nextPty = createMockPty();
expect(() => mgr.handleAttach(createMockWs(), unknown, DIMS, 1_000)).toThrow(/limit/i);
});
it('JOINing an existing session does NOT count against the cap', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 1 };
const mgr = createSessionManager(cappedCfg);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// A second device joining the same session must be allowed at the cap.
expect(() => mgr.handleAttach(createMockWs(), s.meta.id, DIMS, 2_000)).not.toThrow();
expect(s.clients.size).toBe(2);
});
});
// ── killById (manage page) ────────────────────────────────────────────────────
describe('killById', () => {
it('closes all clients, kills the PTY, and removes the session', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const ok = mgr.killById(s.meta.id);
expect(ok).toBe(true);
expect(a.closed).toBe(true);
expect(b.closed).toBe(true);
expect((s.pty as MockIPty).killed).toBe(true);
expect(mgr.get(s.meta.id)).toBeUndefined();
});
it('returns false for an unknown id', () => {
const mgr = createSessionManager(CFG);
expect(mgr.killById('00000000-0000-4000-8000-0000000000aa')).toBe(false);
});
});
// ── handleHookEvent (H2/H3 status push) ───────────────────────────────────────
describe('handleHookEvent', () => {
it('sets claudeStatus and broadcasts a status frame to all clients', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
a.sent.length = 0;
b.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
expect(s.claudeStatus).toBe('waiting');
for (const ws of [a, b]) {
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'waiting', detail: 'Bash', pending: true });
}
});
it('omits detail/pending when not supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'idle');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toEqual({ type: 'status', status: 'idle' });
});
it('is a no-op for an unknown session id', () => {
const mgr = createSessionManager(CFG);
expect(() => mgr.handleHookEvent('00000000-0000-4000-8000-0000000000bb', 'working')).not.toThrow();
});
});
// ── L2: injected onExit removes session when ws is attached at exit time ───────
describe('onExit removes session from table when ws attached at exit time (L2)', () => {
it('session is removed from the table when PTY exits while ws is attached', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
const id = session.meta.id;
// PTY exits while ws is still attached.
(session.pty as MockIPty).emitExit(0);
// The manager's onExit should have removed the session.
expect(mgr.get(id)).toBeUndefined();
});
it('session is NOT removed from table when PTY exits while detached (kept for L1 replay)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const session = mgr.handleAttach(ws, null, DIMS, 1_000);
const id = session.meta.id;
// Detach first.
session.clients.clear();
session.detachedAt = 2_000;
// Then PTY exits.
(session.pty as MockIPty).emitExit(0);
// Session must stay in table for L1 replay.
expect(mgr.get(id)).toBeDefined();
expect(session.exitedAt).not.toBeNull();
});
});
// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ───────────────────────
describe('handleHookEvent — timeline append (A4)', () => {
it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(1);
expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' });
expect(typeof s.timeline[0]?.at).toBe('number');
});
it('replaces the timeline array immutably (never mutates the previous one)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const before = s.timeline;
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).not.toBe(before);
expect(before).toHaveLength(0);
});
it('does NOT append when timeline is disabled', () => {
const mgr = createSessionManager({ ...CFG, timelineEnabled: false });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(0);
});
it('does NOT append when eventClass is omitted', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
expect(s.timeline).toHaveLength(0);
});
it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X');
expect(s.timeline).toHaveLength(0);
});
it('evicts oldest events past cfg.timelineMax', () => {
const mgr = createSessionManager({ ...CFG, timelineMax: 3 });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
for (let i = 0; i < 5; i += 1) {
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`);
}
expect(s.timeline).toHaveLength(3);
expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']);
});
});
describe('handleHookEvent — gate broadcast (B4)', () => {
it('broadcasts the status frame with gate when supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({
type: 'status',
status: 'waiting',
detail: 'ExitPlanMode',
pending: true,
gate: 'plan',
});
});
it('omits gate when not supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).not.toHaveProperty('gate');
});
});
// ── handleHookEvent — W1 preview broadcast ────────────────────────────────────
describe('handleHookEvent — preview broadcast (W1)', () => {
it('puts the preview arg on the broadcast status frame (deep equal)', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
a.sent.length = 0;
b.sent.length = 0;
const preview = { kind: 'command', text: 'ls -la' } as const;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool', undefined, undefined, preview);
for (const ws of [a, b]) {
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status.preview).toEqual(preview);
}
});
it('omits the preview key when no preview is supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true, 'tool');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).not.toHaveProperty('preview');
});
});
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
describe('handleStatusLine', () => {
it('stores telemetry on the session and broadcasts it to all clients', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
a.sent.length = 0;
b.sent.length = 0;
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
expect(s.telemetry).toBe(telemetry);
for (const ws of [a, b]) {
const msg = parseSent(ws).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } });
}
});
it('is a no-op for an unknown session id', () => {
const mgr = createSessionManager(CFG);
expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow();
});
});
// ── handleStatusLine — W3(b) cost-budget latch ────────────────────────────────
describe('handleStatusLine — cost-budget one-shot latch (W3 b)', () => {
const BUDGET_CFG: Config = { ...CFG, costBudgetUsd: 1 };
it('does NOT fire below the threshold and leaves the latch unset', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 });
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('fires exactly once with (session, "budget") on crossing, then latches', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Below → nothing.
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 0.5 });
expect(notify).not.toHaveBeenCalled();
// Crosses the threshold → fire once, latch set.
mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(s, 'budget');
expect(s.budgetNotified).toBe(true);
// Further over-budget frames do NOT re-fire (latch, never re-armed).
mgr.handleStatusLine(s.meta.id, { at: 3, costUsd: 2 });
mgr.handleStatusLine(s.meta.id, { at: 4, costUsd: 5 });
expect(notify).toHaveBeenCalledTimes(1);
});
it('never fires when the budget is 0 (disabled)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, costBudgetUsd: 0 }, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1, costUsd: 999 });
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('does not fire when a telemetry frame carries no cost', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, { at: 1 }); // no costUsd
expect(notify).not.toHaveBeenCalled();
expect(s.budgetNotified).toBe(false);
});
it('still broadcasts telemetry even when the latch fires (existing frame is the warning)', () => {
const { service } = createMockNotify();
const mgr = createSessionManager(BUDGET_CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleStatusLine(s.meta.id, { at: 2, costUsd: 1.2 });
const telemetry = parseSent(ws).find((m) => m.type === 'telemetry');
expect(telemetry).toBeDefined();
});
});
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
describe('handleAttach — late-join replay (M3)', () => {
it('sends the current telemetry to a device joining a live session', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, makeTelemetry());
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const msg = parseSent(b).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } });
});
it('sends the current status to a late-joining device', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working');
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const status = parseSent(b).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'working' });
});
it('does NOT send a telemetry frame when the session has no telemetry yet', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false);
});
});
// ── sweepStuck (A5) ───────────────────────────────────────────────────────────
describe('sweepStuck (A5)', () => {
it('marks a silent live session stuck, broadcasts, and notifies once', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000
ws.sent.length = 0;
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('stuck');
expect(s.stuckNotified).toBe(true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'stuck' });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(s, 'stuck');
});
it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
// still attached: clients.size === 1, detachedAt === null
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.detachedAt).toBeNull();
expect(s.claudeStatus).toBe('stuck');
expect(notify).toHaveBeenCalledTimes(1);
});
it('alerts at most once per silent round (AC-A5.1)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999);
expect(notify).toHaveBeenCalledTimes(1);
expect(s.claudeStatus).toBe('stuck');
});
it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(1);
// Output recovery (session.ts onData) re-arms the flag + status.
(s.pty as MockIPty).emitData('back to work');
expect(s.stuckNotified).toBe(false);
expect(s.claudeStatus).toBe('working');
s.lastOutputAt = 100_000; // pin to a known cursor
mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(2);
expect(s.claudeStatus).toBe('stuck');
});
it('skips idle sessions', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.claudeStatus = 'idle';
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('idle');
expect(notify).not.toHaveBeenCalled();
});
it('skips already-exited sessions (kept in table after detach-then-exit)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.clients.clear();
s.detachedAt = 2_000;
(s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1)
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('does not mark a session whose output is within the TTL window', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckAlert is false', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckTtlMs is 0', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(99_999_999);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('swallows a notifyService rejection (logs via console.error, never throws)', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const notify = vi.fn(async () => {
throw new Error('push transport down');
});
const service: NotifyService = { isEnabled: () => true, notify };
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
await Promise.resolve();
await Promise.resolve();
expect(s.claudeStatus).toBe('stuck');
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('works without an injected notifyService (broadcasts, no throw)', () => {
const mgr = createSessionManager(CFG); // no notifyService
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
expect(s.claudeStatus).toBe('stuck');
expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true);
});
});
// ── list() carries telemetry (B2 thumbnail wall) ──────────────────────────────
describe('list — telemetry field', () => {
it('includes the latest telemetry for each session', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBe(telemetry);
});
it('reports null telemetry before the first statusLine report', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBeNull();
});
});
// ── list() carries lastOutputAt (T-iOS-37 unread watermark) ───────────────────
// The runtime record (Session.lastOutputAt, refreshed on every pty.onData — M3)
// is serialized into LiveSessionInfo so clients can compute an unread dot
// (lastOutputAt > local last-seen). Additive OPTIONAL field: existing consumers
// (preview grid, projects panel) are unaffected.
describe('list — lastOutputAt field (T-iOS-37)', () => {
it('includes lastOutputAt matching the session runtime record after output flows', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Output flows → session.ts pty.onData refreshes the runtime record with
// Date.now(); list() must serialize exactly that value.
const before = Date.now();
(s.pty as MockIPty).emitData('hello from claude');
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(s.lastOutputAt).toBeGreaterThanOrEqual(before);
expect(entry?.lastOutputAt).toBe(s.lastOutputAt);
});
it('serializes a pinned runtime value verbatim (epoch ms, no transformation)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Pin the runtime record to a known cursor (same style as the M3 reap tests).
s.lastOutputAt = 123_456;
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.lastOutputAt).toBe(123_456);
});
it('equals spawn time for a fresh session before any output (createSession inits lastOutputAt = now)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// createSession initializes lastOutputAt: now (src/session/session.ts) —
// a fresh session's watermark IS its spawn time, never absent/zero.
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.lastOutputAt).toBe(1_000);
});
it('keeps the existing LiveSessionInfo shape intact (additive optional field)', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry).toMatchObject({
id: s.meta.id,
createdAt: 1_000,
clientCount: 1,
status: 'unknown',
exited: false,
cols: 80,
rows: 24,
telemetry: null,
});
});
});
// ── W2: inject-queue methods (enqueueFollowup / drainOne / clearQueue) ────────
describe('W2 inject queue — enqueueFollowup', () => {
it('appends and returns {ok,length}, broadcasting {type:queue,length} to clients', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
const r1 = mgr.enqueueFollowup(s.meta.id, 'echo one\r');
expect(r1).toEqual({ ok: true, length: 1 });
expect(s.queue).toEqual(['echo one\r']);
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
const r2 = mgr.enqueueFollowup(s.meta.id, 'echo two\r');
expect(r2).toEqual({ ok: true, length: 2 });
expect(s.queue).toEqual(['echo one\r', 'echo two\r']);
});
it('caps at queueMaxItems → {ok:false,reason:full}, no broadcast, queue unchanged', () => {
const mgr = createSessionManager({ ...CFG, queueMaxItems: 2 });
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
ws.sent.length = 0;
const r = mgr.enqueueFollowup(s.meta.id, 'c');
expect(r).toEqual({ ok: false, reason: 'full' });
expect(s.queue).toEqual(['a', 'b']); // immutable: unchanged
expect(parseSent(ws).some((m) => m.type === 'queue')).toBe(false); // no broadcast
});
it('unknown id → {ok:false,reason:unknown}', () => {
const mgr = createSessionManager(CFG);
expect(mgr.enqueueFollowup('11111111-1111-4111-8111-111111111111', 'x')).toEqual({
ok: false,
reason: 'unknown',
});
});
it('exited session → {ok:false,reason:exited}', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
s.exitedAt = 2_000;
expect(mgr.enqueueFollowup(s.meta.id, 'x')).toEqual({ ok: false, reason: 'exited' });
});
});
describe('W2 inject queue — drainOne', () => {
it('pops the head, writes it to the PTY, shrinks the queue, and broadcasts depth', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'HEAD\r');
mgr.enqueueFollowup(s.meta.id, 'TAIL\r');
ws.sent.length = 0;
const pty = s.pty as MockIPty;
pty.writes.length = 0;
const drained = mgr.drainOne(s.meta.id);
expect(drained).toBe('HEAD\r');
expect(pty.writes).toEqual(['HEAD\r']); // byte-identical inject
expect(s.queue).toEqual(['TAIL\r']); // length now 1
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
});
it('empty queue → null, no PTY write', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const pty = s.pty as MockIPty;
pty.writes.length = 0;
expect(mgr.drainOne(s.meta.id)).toBeNull();
expect(pty.writes).toEqual([]);
});
it('exited session with items queued → null (double-guards L4), no write', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'x');
s.exitedAt = 2_000;
const pty = s.pty as MockIPty;
pty.writes.length = 0;
expect(mgr.drainOne(s.meta.id)).toBeNull();
expect(pty.writes).toEqual([]);
});
it('unknown id → null', () => {
const mgr = createSessionManager(CFG);
expect(mgr.drainOne('11111111-1111-4111-8111-111111111111')).toBeNull();
});
});
describe('W2 inject queue — clearQueue', () => {
it('empties the queue, broadcasts length 0, returns true', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
ws.sent.length = 0;
expect(mgr.clearQueue(s.meta.id)).toBe(true);
expect(s.queue).toEqual([]);
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 0)).toBe(true);
});
it('unknown id → false', () => {
const mgr = createSessionManager(CFG);
expect(mgr.clearQueue('11111111-1111-4111-8111-111111111111')).toBe(false);
});
});
describe('W2 inject queue — list() surfaces queueLength', () => {
it('reports the pending depth per session', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.queueLength).toBe(2);
});
});