fix: address review report across security, architecture, quality, tests
Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
@@ -35,6 +35,16 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── mock tmux availability so resolveUseTmux's auto-detect is deterministic ──
|
||||
const mockTmuxAvailable = vi.fn<[], boolean>()
|
||||
mockTmuxAvailable.mockReturnValue(false)
|
||||
vi.mock('../src/session/tmux.js', () => ({
|
||||
tmuxAvailable: () => mockTmuxAvailable(),
|
||||
tmuxName: (id: string) => `web_${id}`,
|
||||
hasSession: () => false,
|
||||
killSession: () => undefined,
|
||||
}))
|
||||
|
||||
// Import config AFTER mock is set up (dynamic import ensures mock applies)
|
||||
const { loadConfig } = await import('../src/config.js')
|
||||
|
||||
@@ -270,6 +280,93 @@ describe('loadConfig — allowedOrigins (M1)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── MAX_SESSIONS + new operational constants ──────────────────────────────────
|
||||
describe('loadConfig — session/rate/timer constants', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('defaults maxSessions to 50', () => {
|
||||
expect(loadConfig({}).maxSessions).toBe(50)
|
||||
})
|
||||
|
||||
it('reads MAX_SESSIONS from env', () => {
|
||||
expect(loadConfig({ MAX_SESSIONS: '20' }).maxSessions).toBe(20)
|
||||
})
|
||||
|
||||
it('throws for a non-integer MAX_SESSIONS', () => {
|
||||
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => {
|
||||
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
|
||||
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
|
||||
})
|
||||
|
||||
it('defaults permTimeoutMs / reapIntervalMs / previewBytes and reads overrides', () => {
|
||||
const def = loadConfig({})
|
||||
expect(def.permTimeoutMs).toBe(5 * 60_000)
|
||||
expect(def.reapIntervalMs).toBe(60_000)
|
||||
expect(def.previewBytes).toBe(24 * 1024)
|
||||
const over = loadConfig({ PERM_TIMEOUT_MS: '1000', REAP_INTERVAL_MS: '2000', PREVIEW_BYTES: '4096' })
|
||||
expect(over.permTimeoutMs).toBe(1000)
|
||||
expect(over.reapIntervalMs).toBe(2000)
|
||||
expect(over.previewBytes).toBe(4096)
|
||||
})
|
||||
})
|
||||
|
||||
// ── resolveUseTmux branches (USE_TMUX env) ────────────────────────────────────
|
||||
describe('loadConfig — resolveUseTmux', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
mockTmuxAvailable.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it.each(['1', 'true', 'on', 'ON', ' True '])('forces tmux ON for %j', (v) => {
|
||||
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['0', 'false', 'off'])('forces tmux OFF for %j', (v) => {
|
||||
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-detects (unset) → true when tmux is available', () => {
|
||||
mockTmuxAvailable.mockReturnValue(true)
|
||||
expect(loadConfig({}).useTmux).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-detects (unset / "auto") → false when tmux is unavailable', () => {
|
||||
mockTmuxAvailable.mockReturnValue(false)
|
||||
expect(loadConfig({}).useTmux).toBe(false)
|
||||
expect(loadConfig({ USE_TMUX: 'auto' }).useTmux).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── ALLOWED_ORIGINS scheme validation (M1) ────────────────────────────────────
|
||||
describe('loadConfig — ALLOWED_ORIGINS scheme validation', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('rejects non-http(s) scheme entries (file:, javascript:, bare host)', () => {
|
||||
const cfg = loadConfig({
|
||||
ALLOWED_ORIGINS: 'file:///etc/passwd,javascript:alert(1),notaurl,http://ok.local:3000',
|
||||
})
|
||||
expect(cfg.allowedOrigins).toContain('http://ok.local:3000')
|
||||
expect(cfg.allowedOrigins.some((o) => o.startsWith('file:'))).toBe(false)
|
||||
expect(cfg.allowedOrigins.some((o) => o.startsWith('javascript:'))).toBe(false)
|
||||
expect(cfg.allowedOrigins).not.toContain('notaurl')
|
||||
})
|
||||
|
||||
it('keeps valid https entries', () => {
|
||||
const cfg = loadConfig({ ALLOWED_ORIGINS: 'https://phone.local:3000' })
|
||||
expect(cfg.allowedOrigins).toContain('https://phone.local:3000')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Immutability ──────────────────────────────────────────────────────────────
|
||||
describe('loadConfig — returned object is frozen', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSessionMeta } from '../src/http/history.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── mock node:fs/promises + node:os ──────────────────────────────────────────
|
||||
// Hoisted by vitest; history.ts binds to these mocks.
|
||||
const mockReaddir = vi.fn()
|
||||
const mockStat = vi.fn()
|
||||
const mockOpen = vi.fn()
|
||||
const mockHomedir = vi.fn(() => '/home/tester')
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: (...a: unknown[]) => mockReaddir(...a),
|
||||
stat: (...a: unknown[]) => mockStat(...a),
|
||||
open: (...a: unknown[]) => mockOpen(...a),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>()
|
||||
return { ...actual, default: { ...actual.default, homedir: () => mockHomedir() } }
|
||||
})
|
||||
|
||||
const { parseSessionMeta, listSessions } = await import('../src/http/history.js')
|
||||
|
||||
// ── parseSessionMeta (pure) ───────────────────────────────────────────────────
|
||||
describe('parseSessionMeta', () => {
|
||||
it('extracts cwd and the first user prompt (string content)', () => {
|
||||
const jsonl = [
|
||||
@@ -31,3 +53,71 @@ describe('parseSessionMeta', () => {
|
||||
expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── listSessions (fs traversal, async) ────────────────────────────────────────
|
||||
describe('listSessions', () => {
|
||||
function mockFile(text: string) {
|
||||
const buf = Buffer.from(text, 'utf8')
|
||||
return {
|
||||
read: vi.fn(async (b: Buffer, off: number, len: number) => {
|
||||
const n = buf.copy(b, off, 0, Math.min(len, buf.length))
|
||||
return { bytesRead: n }
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockReaddir.mockReset()
|
||||
mockStat.mockReset()
|
||||
mockOpen.mockReset()
|
||||
mockHomedir.mockReturnValue('/home/tester')
|
||||
})
|
||||
|
||||
it('returns [] when the projects root is missing', async () => {
|
||||
mockReaddir.mockRejectedValueOnce(new Error('ENOENT'))
|
||||
expect(await listSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('lists .jsonl sessions newest-first with parsed cwd/preview/project', async () => {
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce(['projA']) // root
|
||||
.mockResolvedValueOnce(['old.jsonl', 'new.jsonl', 'ignore.txt']) // projA
|
||||
mockStat.mockImplementation(async (p: string) => ({
|
||||
mtimeMs: p.endsWith('new.jsonl') ? 2000 : 1000,
|
||||
}))
|
||||
mockOpen.mockImplementation(async (p: string) =>
|
||||
p.endsWith('new.jsonl')
|
||||
? mockFile(JSON.stringify({ type: 'user', cwd: '/work/newdir', message: { role: 'user', content: 'newest task' } }))
|
||||
: mockFile(JSON.stringify({ type: 'user', cwd: '/work/olddir', message: { role: 'user', content: 'older task' } })),
|
||||
)
|
||||
|
||||
const out = await listSessions()
|
||||
expect(out.map((s) => s.id)).toEqual(['new', 'old']) // newest first
|
||||
expect(out[0]).toMatchObject({ id: 'new', cwd: '/work/newdir', project: 'newdir', preview: 'newest task' })
|
||||
// .txt is skipped
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('skips a project subdir that cannot be read', async () => {
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce(['good', 'bad'])
|
||||
.mockResolvedValueOnce(['s.jsonl']) // good
|
||||
.mockRejectedValueOnce(new Error('EACCES')) // bad
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 })
|
||||
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/g', message: { role: 'user', content: 'hi' } })))
|
||||
|
||||
const out = await listSessions()
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]!.id).toBe('s')
|
||||
})
|
||||
|
||||
it('respects the limit', async () => {
|
||||
mockReaddir.mockResolvedValueOnce(['p']).mockResolvedValueOnce(['a.jsonl', 'b.jsonl', 'c.jsonl'])
|
||||
mockStat.mockImplementation(async (p: string) => ({ mtimeMs: p.charCodeAt(p.length - 7) }))
|
||||
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: 'x' } })))
|
||||
|
||||
const out = await listSessions(2)
|
||||
expect(out).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,9 +227,9 @@ function waitForMessage(
|
||||
// ── Test suite ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('startServer — integration', () => {
|
||||
// Suppress stray MaxListeners warnings: each startServer call adds SIGINT/SIGTERM/
|
||||
// uncaughtException handlers. Tests use separate server instances per test.
|
||||
process.setMaxListeners(50)
|
||||
// NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
|
||||
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
|
||||
// it registered, so repeated startServer()/close() cycles don't accumulate.
|
||||
|
||||
let port: number
|
||||
let cfg: Config
|
||||
@@ -730,4 +730,151 @@ describe('startServer — integration', () => {
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
|
||||
// ── Signal-handler leak fix (CQ H1) ────────────────────────────────────────
|
||||
it('does not leak SIGINT listeners across startServer()/close() cycles', async () => {
|
||||
const before = process.listenerCount('SIGINT')
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = await getFreePort()
|
||||
const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh'))
|
||||
await new Promise<void>((r) => setTimeout(r, 30))
|
||||
await h.close()
|
||||
}
|
||||
expect(process.listenerCount('SIGINT')).toBe(before)
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before)
|
||||
})
|
||||
|
||||
// ── GET /live-sessions (route, no PTY needed when empty) ────────────────────
|
||||
it('GET /live-sessions returns an array (empty when none running)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as unknown
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
})
|
||||
|
||||
// ── GET /live-sessions/:id/preview — unknown id → 404 ───────────────────────
|
||||
it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
// ── Security headers present on responses ───────────────────────────────────
|
||||
it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
|
||||
expect(res.headers.get('x-frame-options')).toBe('DENY')
|
||||
expect(res.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(res.headers.get('content-security-policy')).toContain("default-src 'self'")
|
||||
})
|
||||
|
||||
// ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ───────────────────
|
||||
it('DELETE /live-sessions → 403 with a foreign Origin', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: { Origin: 'http://evil.example' },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { killed: number }
|
||||
expect(typeof body.killed).toBe('number')
|
||||
})
|
||||
|
||||
it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => {
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
|
||||
{ method: 'DELETE', headers: { Origin: 'http://evil.example' } },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => {
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
|
||||
{ method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } },
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
// ── Hook side-channel endpoints (route-level, no PTY needed) ────────────────
|
||||
it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
|
||||
body: JSON.stringify({ hook_event_name: 'Stop' }),
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
|
||||
it('POST /hook with an unparseable event body → 400', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, // no session header / no event name
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
|
||||
body: JSON.stringify({ tool_name: 'Bash' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
// ── Per-connection WS rate limit (Sec M3) ───────────────────────────────────
|
||||
// Fires BEFORE attach, so we can flood pre-attach frames without a real PTY.
|
||||
// A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a
|
||||
// flood (frames are dropped, not closed).
|
||||
it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => {
|
||||
const rlPort = await getFreePort()
|
||||
const rlCfg = loadConfig({
|
||||
PORT: String(rlPort),
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
|
||||
ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`,
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '0',
|
||||
MAX_MSGS_PER_SEC: '5',
|
||||
})
|
||||
const rlServer = startServer(rlCfg)
|
||||
await new Promise<void>((r) => setTimeout(r, 80))
|
||||
try {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${rlPort}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
|
||||
// Flood 50 invalid frames in one tick — far over the 5/s cap.
|
||||
for (let i = 0; i < 50; i++) ws.send('not-json')
|
||||
|
||||
// The connection must NOT be closed by the rate limiter.
|
||||
let closed = false
|
||||
ws.once('close', () => {
|
||||
closed = true
|
||||
})
|
||||
await new Promise<void>((r) => setTimeout(r, 200))
|
||||
expect(closed).toBe(false)
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN)
|
||||
|
||||
ws.close()
|
||||
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||
} finally {
|
||||
await rlServer.close()
|
||||
await new Promise<void>((r) => setTimeout(r, 50))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,11 @@ const CFG: Config = {
|
||||
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: [],
|
||||
};
|
||||
@@ -509,6 +514,105 @@ describe('shutdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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', () => {
|
||||
|
||||
171
test/preview-grid.test.ts
Normal file
171
test/preview-grid.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/preview-grid.test.ts — shared launcher/manage preview helpers.
|
||||
*
|
||||
* xterm Terminal is stubbed; fetch is mocked. Covers the DRY core extracted in
|
||||
* Phase 4: formatting, the card factory, fit scaling, and the preview/list
|
||||
* fetch helpers (best-effort, no throw on failure).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { LiveSessionInfo } from '../src/types.js'
|
||||
|
||||
class FakeTerminal {
|
||||
cols = 80
|
||||
rows = 24
|
||||
open = vi.fn()
|
||||
reset = vi.fn()
|
||||
resize = vi.fn()
|
||||
dispose = vi.fn()
|
||||
write = vi.fn((_d: string, cb?: () => void) => cb?.())
|
||||
}
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
|
||||
|
||||
const mod = await import('../public/preview-grid.js')
|
||||
const {
|
||||
el,
|
||||
relTime,
|
||||
statusText,
|
||||
sessionName,
|
||||
makePreviewCard,
|
||||
updatePreviewCard,
|
||||
fitThumb,
|
||||
fetchPreview,
|
||||
renderPreview,
|
||||
loadPreviewInto,
|
||||
fetchLiveSessions,
|
||||
} = mod
|
||||
|
||||
function session(over: Partial<LiveSessionInfo> = {}): LiveSessionInfo {
|
||||
return {
|
||||
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
createdAt: Date.now() - 5000,
|
||||
clientCount: 0,
|
||||
status: 'idle',
|
||||
exited: false,
|
||||
cwd: '/work/proj',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting helpers', () => {
|
||||
it('el builds an element with class + text', () => {
|
||||
const n = el('div', 'x', 'hi')
|
||||
expect(n.className).toBe('x')
|
||||
expect(n.textContent).toBe('hi')
|
||||
})
|
||||
|
||||
it('relTime renders s/m/h/d buckets', () => {
|
||||
const now = Date.now()
|
||||
expect(relTime(now)).toMatch(/^\d+s$/)
|
||||
expect(relTime(now - 120_000)).toBe('2m')
|
||||
expect(relTime(now - 2 * 3600_000)).toBe('2h')
|
||||
expect(relTime(now - 3 * 86400_000)).toBe('3d')
|
||||
})
|
||||
|
||||
it('statusText maps each status', () => {
|
||||
expect(statusText('working')).toContain('working')
|
||||
expect(statusText('waiting')).toContain('waiting')
|
||||
expect(statusText('idle')).toContain('idle')
|
||||
expect(statusText('unknown')).toBe('·')
|
||||
})
|
||||
|
||||
it('sessionName uses last cwd segment, else short id', () => {
|
||||
expect(sessionName(session({ cwd: '/a/b/cool' }))).toBe('cool')
|
||||
expect(sessionName(session({ cwd: null }))).toBe('aaaaaaaa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePreviewCard / updatePreviewCard', () => {
|
||||
it('builds a card and fires onOpen when the thumb is clicked (button variant)', () => {
|
||||
const onOpen = vi.fn()
|
||||
const card = makePreviewCard(session(), { onOpen })
|
||||
card.thumb.click()
|
||||
expect(onOpen).toHaveBeenCalledWith(session().id)
|
||||
// The Open control is a <button> when no openHref is given.
|
||||
expect(card.el.querySelector('button.mg-open')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('uses an <a href> Open + extra actions when configured (manage variant)', () => {
|
||||
const extra = el('button', 'mg-kill', 'Kill')
|
||||
const card = makePreviewCard(session(), {
|
||||
onOpen: vi.fn(),
|
||||
openHref: (id) => `/?join=${id}`,
|
||||
extraActions: () => [extra],
|
||||
})
|
||||
const open = card.el.querySelector('a.mg-open') as HTMLAnchorElement | null
|
||||
expect(open?.getAttribute('href')).toBe(`/?join=${session().id}`)
|
||||
expect(card.el.contains(extra)).toBe(true)
|
||||
})
|
||||
|
||||
it('updatePreviewCard refreshes status + watch in place', () => {
|
||||
const card = makePreviewCard(session(), { onOpen: vi.fn() })
|
||||
updatePreviewCard(card, session({ status: 'working', clientCount: 3 }))
|
||||
expect(card.status.textContent).toContain('working')
|
||||
expect(card.watch.textContent).toBe('👁 3')
|
||||
expect(card.watch.className).toContain('live')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitThumb', () => {
|
||||
it('no-ops when the inner element has no measured size', () => {
|
||||
const card = makePreviewCard(session(), { onOpen: vi.fn() })
|
||||
// jsdom offsetWidth/Height are 0 → no transform applied.
|
||||
fitThumb(card, 320, 200)
|
||||
expect(card.inner.style.transform).toBe('')
|
||||
})
|
||||
|
||||
it('scales and clamps height when inner has a size', () => {
|
||||
const card = makePreviewCard(session(), { onOpen: vi.fn() })
|
||||
Object.defineProperty(card.inner, 'offsetWidth', { value: 640, configurable: true })
|
||||
Object.defineProperty(card.inner, 'offsetHeight', { value: 400, configurable: true })
|
||||
fitThumb(card, 320, 200)
|
||||
expect(card.inner.style.transform).toBe('scale(0.5)')
|
||||
expect(card.thumb.style.height).toBe('200px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch helpers', () => {
|
||||
it('fetchPreview returns parsed JSON on 200', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'hi' }) })))
|
||||
expect(await fetchPreview('x')).toMatchObject({ id: 'x', data: 'hi' })
|
||||
})
|
||||
|
||||
it('fetchPreview returns null on non-ok / throw', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
|
||||
expect(await fetchPreview('x')).toBeNull()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
|
||||
expect(await fetchPreview('x')).toBeNull()
|
||||
})
|
||||
|
||||
it('renderPreview writes the cleared tail and resizes when dims differ', () => {
|
||||
const card = makePreviewCard(session(), { onOpen: vi.fn() })
|
||||
renderPreview(card, { id: 'x', cols: 100, rows: 30, data: 'screen' }, 320, 200)
|
||||
expect(card.term.resize).toHaveBeenCalledWith(100, 30)
|
||||
expect(card.term.write).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loadPreviewInto fetches then renders (no throw on failure)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'd' }) })))
|
||||
const card = makePreviewCard(session(), { onOpen: vi.fn() })
|
||||
await expect(loadPreviewInto('x', card, 320, 200)).resolves.toBeUndefined()
|
||||
expect(card.term.write).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetchLiveSessions returns [] on failure and array on success', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => [session()] })))
|
||||
expect(await fetchLiveSessions()).toHaveLength(1)
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('x') }))
|
||||
expect(await fetchLiveSessions()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -117,10 +117,9 @@ describe('parseClientMessage — valid messages', () => {
|
||||
if (r.ok) expect(r.message).toEqual({ type: 'reject' })
|
||||
})
|
||||
|
||||
it('parses blur (v0.4, no payload — withdraw size vote)', () => {
|
||||
it('rejects blur (removed in the min→latest-writer pivot)', () => {
|
||||
const b = parseClientMessage(JSON.stringify({ type: 'blur' }))
|
||||
expect(b.ok).toBe(true)
|
||||
if (b.ok) expect(b.message).toEqual({ type: 'blur' })
|
||||
expect(b.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock('node-pty', () => ({
|
||||
}));
|
||||
|
||||
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
||||
const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } =
|
||||
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } =
|
||||
await import('../src/session/session.js');
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -46,6 +46,11 @@ const CFG: Config = {
|
||||
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: [],
|
||||
};
|
||||
@@ -241,18 +246,6 @@ describe('attachWs', () => {
|
||||
expect(s.pty.rows).toBe(50);
|
||||
});
|
||||
|
||||
it('clearClientDims (tab hidden) does NOT resize — the active device keeps its size', () => {
|
||||
const s = newSession();
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
setClientDims(s, a, 200, 50);
|
||||
setClientDims(s, b, 90, 30); // b is using it now → 90x30
|
||||
|
||||
clearClientDims(s, a); // a's tab hidden elsewhere → just forget a's dims
|
||||
// PTY unchanged: b is still the active viewer at 90x30.
|
||||
expect(s.pty.cols).toBe(90);
|
||||
expect(s.pty.rows).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
||||
|
||||
348
test/tabs.test.ts
Normal file
348
test/tabs.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/tabs.test.ts — frontend TabApp unit tests.
|
||||
*
|
||||
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
|
||||
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
|
||||
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
|
||||
* `null as unknown as TerminalSession` hole is gone).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Mock TerminalSession ──────────────────────────────────────────────────────
|
||||
let constructed = 0
|
||||
class FakeTerminalSession {
|
||||
el: HTMLDivElement
|
||||
id: string | null
|
||||
status = 'connecting'
|
||||
claudeStatus = 'unknown'
|
||||
cwd: string | null = null
|
||||
pendingApproval = false
|
||||
connect = vi.fn()
|
||||
dispose = vi.fn()
|
||||
show = vi.fn()
|
||||
hide = vi.fn()
|
||||
applyTheme = vi.fn()
|
||||
refit = vi.fn()
|
||||
send = vi.fn()
|
||||
approve = vi.fn()
|
||||
reject = vi.fn()
|
||||
findNext = vi.fn()
|
||||
findPrevious = vi.fn()
|
||||
clearSearch = vi.fn()
|
||||
// Captured callbacks so tests can drive status/title/activity events.
|
||||
cbs: {
|
||||
onClaudeStatus?: (s: string, d?: string) => void
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
}
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
sessionId: string | null
|
||||
onClaudeStatus?: (s: string, d?: string) => void
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
}) {
|
||||
constructed += 1
|
||||
this.id = opts.sessionId
|
||||
this.el = document.createElement('div')
|
||||
this.cbs = opts
|
||||
FakeTerminalSession.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
|
||||
|
||||
// ── Mock the launcher (records visibility) ────────────────────────────────────
|
||||
const launcherVisible = { value: false }
|
||||
const mountLauncher = vi.fn(() => ({
|
||||
setVisible: (v: boolean) => {
|
||||
launcherVisible.value = v
|
||||
},
|
||||
refresh: vi.fn(),
|
||||
}))
|
||||
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
|
||||
|
||||
// settings.js is light but imports nothing heavy; let it load for real.
|
||||
|
||||
const { TabApp } = await import('../public/tabs.js')
|
||||
|
||||
function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
|
||||
const paneHost = document.createElement('div')
|
||||
const tabBar = document.createElement('div')
|
||||
document.body.append(paneHost, tabBar)
|
||||
return { paneHost, tabBar }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
constructed = 0
|
||||
FakeTerminalSession.instances = []
|
||||
launcherVisible.value = false
|
||||
document.body.replaceChildren()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('TabApp — v0.5 launcher chooser', () => {
|
||||
it('starts with NO auto-created tab and shows the launcher', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
// No TerminalSession constructed on boot (no auto tab).
|
||||
expect(constructed).toBe(0)
|
||||
expect(launcherVisible.value).toBe(true)
|
||||
})
|
||||
|
||||
it('newTab() creates a tab and hides the launcher', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
expect(constructed).toBe(1)
|
||||
expect(launcherVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('closing the last tab returns to the launcher (no auto-blank tab)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
expect(launcherVisible.value).toBe(false)
|
||||
app.closeTab(0)
|
||||
// Back to the chooser, and no new tab was auto-created.
|
||||
expect(launcherVisible.value).toBe(true)
|
||||
expect(constructed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => {
|
||||
it('the constructed session is real and used immediately (connect called)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
// The entry's session must be the constructed FakeTerminalSession with connect() called.
|
||||
const snap = app.snapshot()
|
||||
expect(snap).toHaveLength(1)
|
||||
// sendToActive routes to a real session (would throw if session were null).
|
||||
expect(() => app.sendToActive('x')).not.toThrow()
|
||||
})
|
||||
|
||||
it('openSession joins by id and focuses it', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession('11111111-1111-4111-8111-111111111111')
|
||||
expect(constructed).toBe(1)
|
||||
expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('openSession re-focuses an already-open session instead of duplicating', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
const id = '22222222-2222-4222-8222-222222222222'
|
||||
app.openSession(id)
|
||||
app.openSession(id)
|
||||
expect(constructed).toBe(1) // not duplicated
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — multi-tab lifecycle', () => {
|
||||
it('renders a tab bar with one .tab per tab plus the add button', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
expect(tabBar.querySelectorAll('.tab')).toHaveLength(2)
|
||||
expect(tabBar.querySelector('.tab-add')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('activate() switches the active tab (show/hide on sessions)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
app.activate(0)
|
||||
const snap = app.snapshot()
|
||||
expect(snap.find((t) => t.active)?.idx).toBe(0)
|
||||
})
|
||||
|
||||
it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
app.closeTab(1)
|
||||
expect(app.snapshot()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applySettings re-themes every tab without throwing', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('findInActive / clearActiveSearch route to the active session', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
expect(() => {
|
||||
app.findInActive('q', 'next')
|
||||
app.findInActive('q', 'prev')
|
||||
app.clearActiveSearch()
|
||||
app.refitActive()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('newTabForResume opens a tab seeded with a resume command', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
|
||||
expect(constructed).toBe(1)
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('snapshot reflects per-tab connection + claude status fields', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const snap = app.snapshot()
|
||||
expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — DOM interactions on the tab bar', () => {
|
||||
function pointerdown(elm: Element): void {
|
||||
elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
}
|
||||
|
||||
it('pointerdown on a tab activates it', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
const tabs = tabBar.querySelectorAll('.tab')
|
||||
pointerdown(tabs[0]!)
|
||||
expect(app.snapshot().find((t) => t.active)?.idx).toBe(0)
|
||||
})
|
||||
|
||||
it('clicking the × close button closes that tab', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
const close = tabBar.querySelector('.tab-close') as HTMLButtonElement
|
||||
close.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clicking the + add button opens a new tab', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
const add = tabBar.querySelector('.tab-add') as HTMLButtonElement
|
||||
add.click()
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('middle-click (auxclick button 1) closes a tab', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
const tab = tabBar.querySelector('.tab') as HTMLElement
|
||||
tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 }))
|
||||
expect(app.snapshot()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('double-click the label enters rename mode, Enter commits a custom title', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const label = tabBar.querySelector('.tab-label') as HTMLElement
|
||||
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
||||
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
|
||||
expect(input).not.toBeNull()
|
||||
input.value = 'My Tab'
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
expect(app.snapshot()[0]!.title).toBe('My Tab')
|
||||
})
|
||||
|
||||
it('activate() out of range is ignored', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
expect(() => {
|
||||
app.activate(99)
|
||||
app.activate(-1)
|
||||
app.focusTab(0)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('sendToActive / clearActiveSearch with no active tab do not throw', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
// No tab open (launcher visible).
|
||||
expect(() => {
|
||||
app.sendToActive('x')
|
||||
app.clearActiveSearch()
|
||||
app.refitActive()
|
||||
}).not.toThrow()
|
||||
expect(app.activeSessionId()).toBeNull()
|
||||
})
|
||||
|
||||
it('Escape in rename mode cancels without committing', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const label = tabBar.querySelector('.tab-label') as HTMLElement
|
||||
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
||||
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
|
||||
input.value = 'discard me'
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
// Title falls back to the auto/default, not the typed value.
|
||||
expect(app.snapshot()[0]!.title).not.toBe('discard me')
|
||||
})
|
||||
|
||||
it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const fake = FakeTerminalSession.instances[0]!
|
||||
// Drive the captured callbacks like the real TerminalSession would.
|
||||
expect(() => {
|
||||
fake.cbs.onTitle?.('myproj')
|
||||
fake.cbs.onStatus?.('connected')
|
||||
fake.cbs.onActivity?.()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => {
|
||||
const NotificationMock = vi.fn()
|
||||
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
|
||||
vi.stubGlobal('Notification', NotificationMock)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // tab 0 (active)
|
||||
app.newTab() // tab 1 (active now)
|
||||
// tab 0 is in the background; fire its onClaudeStatus 'waiting'.
|
||||
const bg = FakeTerminalSession.instances[0]!
|
||||
bg.claudeStatus = 'waiting'
|
||||
bg.cbs.onClaudeStatus?.('waiting')
|
||||
expect(NotificationMock).toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('drag-and-drop reorders tabs', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.newTab()
|
||||
const tabs = tabBar.querySelectorAll('.tab')
|
||||
// jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex,
|
||||
// so plain drag events (no dataTransfer) still drive the reorder path.
|
||||
tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true }))
|
||||
tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true }))
|
||||
tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true }))
|
||||
tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true }))
|
||||
expect(app.snapshot()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
458
test/terminal-session.test.ts
Normal file
458
test/terminal-session.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* test/terminal-session.test.ts — frontend TerminalSession unit tests.
|
||||
*
|
||||
* Runs under jsdom with a mock WebSocket and a stubbed xterm Terminal so we can
|
||||
* exercise the reconnect state machine, buildWsUrl scheme selection, the
|
||||
* disposed-guard on the initialInput timer (Phase 1#4 regression), hide()
|
||||
* cleanup, and status handling — without a real browser or server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// ── Stub xterm + addons (heavy DOM/canvas deps we don't need here) ────────────
|
||||
class FakeTerminal {
|
||||
options: Record<string, unknown> = {}
|
||||
cols = 80
|
||||
rows = 24
|
||||
parser = { registerOscHandler: vi.fn() }
|
||||
loadAddon = vi.fn()
|
||||
open = vi.fn()
|
||||
write = vi.fn()
|
||||
focus = vi.fn()
|
||||
dispose = vi.fn()
|
||||
private dataCbs: Array<(d: string) => void> = []
|
||||
onData = (cb: (d: string) => void) => {
|
||||
this.dataCbs.push(cb)
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
onTitleChange = vi.fn(() => ({ dispose: vi.fn() }))
|
||||
emitData(d: string): void {
|
||||
for (const cb of this.dataCbs) cb(d)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: class {
|
||||
fit = vi.fn()
|
||||
},
|
||||
}))
|
||||
vi.mock('@xterm/addon-search', () => ({
|
||||
SearchAddon: class {
|
||||
findNext = vi.fn()
|
||||
findPrevious = vi.fn()
|
||||
clearDecorations = vi.fn()
|
||||
},
|
||||
}))
|
||||
vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
|
||||
|
||||
// ── Mock WebSocket ────────────────────────────────────────────────────────────
|
||||
class MockWebSocket {
|
||||
static OPEN = 1
|
||||
static instances: MockWebSocket[] = []
|
||||
static last(): MockWebSocket {
|
||||
return MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
}
|
||||
url: string
|
||||
readyState = 0 // CONNECTING
|
||||
sent: string[] = []
|
||||
private listeners: Record<string, Array<(e: unknown) => void>> = {}
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
MockWebSocket.instances.push(this)
|
||||
}
|
||||
addEventListener(type: string, cb: (e: unknown) => void): void {
|
||||
;(this.listeners[type] ??= []).push(cb)
|
||||
}
|
||||
send(data: string): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
close(): void {
|
||||
this.readyState = 3
|
||||
this.fire('close', {})
|
||||
}
|
||||
// test helpers
|
||||
fire(type: string, e: unknown): void {
|
||||
for (const cb of this.listeners[type] ?? []) cb(e)
|
||||
}
|
||||
openIt(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.fire('open', {})
|
||||
}
|
||||
message(data: string): void {
|
||||
this.fire('message', { data })
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeObserver is absent in jsdom.
|
||||
class FakeResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(_cb: () => void) {}
|
||||
}
|
||||
|
||||
// ── Wire globals, then import the module under test ───────────────────────────
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = []
|
||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const { TerminalSession } = await import('../public/terminal-session.js')
|
||||
|
||||
function setLocation(protocol: string, host: string): void {
|
||||
vi.stubGlobal('location', { protocol, host })
|
||||
}
|
||||
|
||||
describe('buildWsUrl scheme selection (M6)', () => {
|
||||
it('uses ws:// on http pages', () => {
|
||||
setLocation('http:', 'lan:3000')
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
expect(MockWebSocket.last().url).toBe('ws://lan:3000/term')
|
||||
})
|
||||
|
||||
it('uses wss:// on https pages (Tailscale/TLS, avoids mixed content)', () => {
|
||||
setLocation('https:', 'host.ts.net')
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
expect(MockWebSocket.last().url).toBe('wss://host.ts.net/term')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attach handshake', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('sends attach{sessionId:null} on open for a fresh session', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null })
|
||||
})
|
||||
|
||||
it('persists the assigned id from the attached frame', () => {
|
||||
const onSessionId = vi.fn()
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
MockWebSocket.last().message(JSON.stringify({ type: 'attached', sessionId: 'sid-1' }))
|
||||
expect(onSessionId).toHaveBeenCalledWith('sid-1')
|
||||
expect(s.id).toBe('sid-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('status frame (H3 pending approval)', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('sets pendingApproval / claudeStatus from a status frame', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
|
||||
expect(s.claudeStatus).toBe('waiting')
|
||||
expect(s.pendingApproval).toBe(true)
|
||||
expect(s.pendingTool).toBe('Bash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnect backoff', () => {
|
||||
beforeEach(() => {
|
||||
setLocation('http:', 'lan:3000')
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('doubles the delay each attempt and caps at 30s', () => {
|
||||
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const delays: number[] = []
|
||||
const origSetTimeout = globalThis.setTimeout
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => {
|
||||
delays.push(ms ?? 0)
|
||||
return origSetTimeout(fn, 0)
|
||||
}) as typeof setTimeout)
|
||||
|
||||
// Simulate repeated close→reconnect cycles.
|
||||
for (let i = 0; i < 7; i++) {
|
||||
MockWebSocket.last().fire('close', {})
|
||||
vi.runOnlyPendingTimers()
|
||||
}
|
||||
|
||||
// First delay 1000, then 2000, 4000 … capped at 30000.
|
||||
expect(delays[0]).toBe(1000)
|
||||
expect(delays[1]).toBe(2000)
|
||||
expect(delays[2]).toBe(4000)
|
||||
expect(Math.max(...delays)).toBeLessThanOrEqual(30_000)
|
||||
expect(delays[delays.length - 1]).toBe(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialInput timer respects dispose (Phase 1#4 regression)', () => {
|
||||
beforeEach(() => {
|
||||
setLocation('http:', 'lan:3000')
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('does NOT send the initial command if disposed before the timer fires', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'claude --resume x\r' })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
|
||||
ws.sent.length = 0 // drop attach/resize frames
|
||||
|
||||
s.dispose() // dispose BEFORE the 700ms initial-input timer fires
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
// No input frame should have been sent after dispose.
|
||||
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
|
||||
expect(inputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('DOES send the initial command when not disposed', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'echo hi\r' })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
|
||||
ws.sent.length = 0
|
||||
|
||||
vi.advanceTimersByTime(800)
|
||||
|
||||
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
|
||||
expect(inputs).toContainEqual({ type: 'input', data: 'echo hi\r' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hide() does not send a blur frame (removed in latest-writer pivot)', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('hide() sends nothing and never emits type:"blur"', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.sent.length = 0
|
||||
s.hide()
|
||||
const blur = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'blur')
|
||||
expect(blur).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose() tears down cleanly', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('closes the WS and does not reconnect afterwards', () => {
|
||||
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
const countBefore = MockWebSocket.instances.length
|
||||
|
||||
s.dispose()
|
||||
// A close after dispose must NOT trigger a new connection.
|
||||
ws.fire('close', {})
|
||||
expect(MockWebSocket.instances.length).toBe(countBefore)
|
||||
})
|
||||
|
||||
it('connect() is a no-op after dispose', () => {
|
||||
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
|
||||
s.dispose()
|
||||
s.connect()
|
||||
expect(MockWebSocket.instances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('output + activity + send paths', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function connected(opts: { onActivity?: () => void } = {}) {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), ...opts })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('writes output to the terminal and fires onActivity for a hidden pane', () => {
|
||||
const onActivity = vi.fn()
|
||||
const { s, ws } = connected({ onActivity })
|
||||
// pane starts display:none (hidden) → output triggers activity
|
||||
ws.message(JSON.stringify({ type: 'output', data: 'hello' }))
|
||||
expect(onActivity).toHaveBeenCalled()
|
||||
// show() flips display to block → no further activity on next output
|
||||
s.show()
|
||||
onActivity.mockClear()
|
||||
ws.message(JSON.stringify({ type: 'output', data: 'more' }))
|
||||
expect(onActivity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('send() routes keyboard bytes as an input frame', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.sent.length = 0
|
||||
s.send('ls\r')
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'input', data: 'ls\r' })
|
||||
})
|
||||
|
||||
it('approve()/reject() send their frames and clear pendingApproval', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
|
||||
ws.sent.length = 0
|
||||
s.approve()
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve' })
|
||||
expect(s.pendingApproval).toBe(false)
|
||||
s.reject()
|
||||
expect(JSON.parse(ws.sent[1]!)).toEqual({ type: 'reject' })
|
||||
})
|
||||
|
||||
it('ignores malformed JSON frames without throwing', () => {
|
||||
const { ws } = connected()
|
||||
expect(() => ws.message('{not json')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exit handling', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('sets status to exited and writes the exit line', () => {
|
||||
const onStatus = vi.fn()
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), onStatus })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.message(JSON.stringify({ type: 'exit', code: 0 }))
|
||||
expect(s.status).toBe('exited')
|
||||
expect(onStatus).toHaveBeenCalledWith('exited')
|
||||
})
|
||||
|
||||
it('exit with a reason includes it in the rendered line', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
expect(() => ws.message(JSON.stringify({ type: 'exit', code: -1, reason: 'spawn failed' }))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search + theme passthrough', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('findNext/findPrevious/clearSearch are callable', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(() => {
|
||||
s.findNext('x')
|
||||
s.findPrevious('x')
|
||||
s.clearSearch()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('applyTheme updates terminal options', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(() => s.applyTheme({ background: '#000' }, 14)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('attach with cwd (M6 "new tab here")', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('includes cwd in the attach frame for a fresh session', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), cwd: '/work/here' })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null, cwd: '/work/here' })
|
||||
})
|
||||
|
||||
it('omits cwd on a reconnect to an existing sessionId', () => {
|
||||
const s = new TerminalSession({ sessionId: '44444444-4444-4444-8444-444444444444', onSessionId: vi.fn(), cwd: '/x' })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({
|
||||
type: 'attach',
|
||||
sessionId: '44444444-4444-4444-8444-444444444444',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exit → press Enter reconnects with a fresh session', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('Enter after exit closes the old WS and opens a new connection (sessionId reset)', () => {
|
||||
const s = new TerminalSession({ sessionId: '55555555-5555-4555-8555-555555555555', onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws1 = MockWebSocket.last()
|
||||
ws1.openIt()
|
||||
ws1.message(JSON.stringify({ type: 'exit', code: 0 }))
|
||||
|
||||
const before = MockWebSocket.instances.length
|
||||
// The exit handler registers a term.onData listener; pressing Enter ('\r')
|
||||
// triggers reconnect. Drive it via the fake terminal's data callback.
|
||||
;(s as unknown as { term: { emitData(d: string): void } }).term.emitData('\r')
|
||||
expect(MockWebSocket.instances.length).toBe(before + 1)
|
||||
expect(s.id).toBeNull() // fresh session id on reconnect
|
||||
})
|
||||
})
|
||||
|
||||
describe('show()/refit() re-fit when visible', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('show() makes the pane visible and focuses without throwing', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
expect(() => s.show()).not.toThrow()
|
||||
expect(s.el.style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('refit() is a no-op while hidden, runs when visible', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
expect(() => s.refit()).not.toThrow() // hidden → no-op
|
||||
s.show()
|
||||
expect(() => s.refit()).not.toThrow() // visible → fits
|
||||
})
|
||||
|
||||
it('safefit returns null on NaN dims → no resize frame sent (display:none guard, §9)', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
// Make the fake terminal report NaN cols (simulating fit() while hidden).
|
||||
;(s as unknown as { term: { cols: number; rows: number } }).term.cols = NaN
|
||||
ws.sent.length = 0
|
||||
s.show()
|
||||
const resizes = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'resize')
|
||||
expect(resizes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('status frame WITHOUT pending leaves pendingApproval false', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
MockWebSocket.last().openIt()
|
||||
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'working' }))
|
||||
expect(s.claudeStatus).toBe('working')
|
||||
expect(s.pendingApproval).toBe(false)
|
||||
expect(s.pendingTool).toBeUndefined()
|
||||
})
|
||||
|
||||
it('send() while the WS is closed is a no-op (guarded)', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.readyState = 3 // CLOSED
|
||||
ws.sent.length = 0
|
||||
s.send('x')
|
||||
expect(ws.sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
70
test/tmux.test.ts
Normal file
70
test/tmux.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* test/tmux.test.ts (H1) — thin tmux CLI wrappers.
|
||||
*
|
||||
* execFileSync is mocked so no real tmux binary runs: every wrapper is
|
||||
* best-effort and must NOT throw (a throwing exec → false / no-op).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockExec = vi.fn()
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: (...a: unknown[]) => mockExec(...a),
|
||||
}))
|
||||
|
||||
const { tmuxAvailable, tmuxName, hasSession, killSession } = await import('../src/session/tmux.js')
|
||||
|
||||
beforeEach(() => {
|
||||
mockExec.mockReset()
|
||||
})
|
||||
|
||||
describe('tmuxName', () => {
|
||||
it('prefixes the session id with web_', () => {
|
||||
expect(tmuxName('abc')).toBe('web_abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tmuxAvailable', () => {
|
||||
it('returns true when `tmux -V` succeeds', () => {
|
||||
mockExec.mockReturnValue(Buffer.from('tmux 3.4'))
|
||||
expect(tmuxAvailable()).toBe(true)
|
||||
expect(mockExec).toHaveBeenCalledWith('tmux', ['-V'], { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
it('returns false when tmux is missing (exec throws)', () => {
|
||||
mockExec.mockImplementation(() => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(tmuxAvailable()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSession', () => {
|
||||
it('returns true when has-session exits 0', () => {
|
||||
mockExec.mockReturnValue(Buffer.from(''))
|
||||
expect(hasSession('web_x')).toBe(true)
|
||||
expect(mockExec).toHaveBeenCalledWith('tmux', ['has-session', '-t', 'web_x'], { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
it('returns false when has-session throws (no such session)', () => {
|
||||
mockExec.mockImplementation(() => {
|
||||
throw new Error("can't find session")
|
||||
})
|
||||
expect(hasSession('web_gone')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('killSession', () => {
|
||||
it('invokes tmux kill-session for the name', () => {
|
||||
mockExec.mockReturnValue(Buffer.from(''))
|
||||
killSession('web_x')
|
||||
expect(mockExec).toHaveBeenCalledWith('tmux', ['kill-session', '-t', 'web_x'], { stdio: 'ignore' })
|
||||
})
|
||||
|
||||
it('swallows errors when the session is already gone', () => {
|
||||
mockExec.mockImplementation(() => {
|
||||
throw new Error('no session')
|
||||
})
|
||||
expect(() => killSession('web_gone')).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user