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
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
// @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([])
|
|
})
|
|
})
|