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:
Yaojia Wang
2026-06-20 18:27:45 +02:00
parent 97d57326fd
commit d22dcd24f7
30 changed files with 2900 additions and 400 deletions

View File

@@ -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(() => {

View File

@@ -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)
})
})

View File

@@ -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))
}
})
})

View File

@@ -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
View 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([])
})
})

View File

@@ -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)
})
})

View File

@@ -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
View 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)
})
})

View 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
View 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()
})
})