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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user