feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

View File

@@ -445,3 +445,288 @@ describe('loadConfig — project discovery (v0.6)', () => {
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code')
})
})
// ── v0.7 Walk-away Workbench — new env vars ───────────────────────────────────
describe('loadConfig — v0.7 A1 push notifications', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: unset → both undefined', () => {
const cfg = loadConfig({})
expect(cfg.vapidPublicKey).toBeUndefined()
expect(cfg.vapidPrivateKey).toBeUndefined()
})
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: reads from env', () => {
const cfg = loadConfig({ VAPID_PUBLIC_KEY: 'pubkey123', VAPID_PRIVATE_KEY: 'privkey456' })
expect(cfg.vapidPublicKey).toBe('pubkey123')
expect(cfg.vapidPrivateKey).toBe('privkey456')
})
it('VAPID_SUBJECT: unset → undefined, reads from env', () => {
expect(loadConfig({}).vapidSubject).toBeUndefined()
expect(loadConfig({ VAPID_SUBJECT: 'mailto:admin@example.com' }).vapidSubject).toBe('mailto:admin@example.com')
})
it('PUSH_STORE_PATH: default is <homeDir>/.web-terminal-push-subs.json', () => {
const cfg = loadConfig({})
expect(cfg.pushStorePath).toBe('/home/testuser/.web-terminal-push-subs.json')
})
it('PUSH_STORE_PATH: reads from env', () => {
const cfg = loadConfig({ PUSH_STORE_PATH: '/tmp/subs.json' })
expect(cfg.pushStorePath).toBe('/tmp/subs.json')
})
it('PUSH_MAX_SUBS: default 50, reads from env', () => {
expect(loadConfig({}).pushMaxSubs).toBe(50)
expect(loadConfig({ PUSH_MAX_SUBS: '20' }).pushMaxSubs).toBe(20)
})
it('PUSH_MAX_SUBS: throws for non-integer', () => {
expect(() => loadConfig({ PUSH_MAX_SUBS: 'many' })).toThrow(/PUSH_MAX_SUBS/)
})
it('NOTIFY_DONE: default true (1), parses on/off', () => {
expect(loadConfig({}).notifyDone).toBe(true)
expect(loadConfig({ NOTIFY_DONE: '0' }).notifyDone).toBe(false)
expect(loadConfig({ NOTIFY_DONE: 'false' }).notifyDone).toBe(false)
expect(loadConfig({ NOTIFY_DONE: '1' }).notifyDone).toBe(true)
})
it('NOTIFY_DND: default false (0), parses on/off', () => {
expect(loadConfig({}).notifyDnd).toBe(false)
expect(loadConfig({ NOTIFY_DND: '1' }).notifyDnd).toBe(true)
expect(loadConfig({ NOTIFY_DND: 'on' }).notifyDnd).toBe(true)
expect(loadConfig({ NOTIFY_DND: '0' }).notifyDnd).toBe(false)
})
it('DECISION_TOKEN_TTL_MS: default = permTimeoutMs', () => {
const cfg = loadConfig({})
expect(cfg.decisionTokenTtlMs).toBe(cfg.permTimeoutMs)
})
it('DECISION_TOKEN_TTL_MS: readable from env, overrides the default', () => {
const cfg = loadConfig({ DECISION_TOKEN_TTL_MS: '120000' })
expect(cfg.decisionTokenTtlMs).toBe(120000)
})
it('DECISION_TOKEN_TTL_MS: throws for non-integer', () => {
expect(() => loadConfig({ DECISION_TOKEN_TTL_MS: 'never' })).toThrow(/DECISION_TOKEN_TTL_MS/)
})
})
describe('loadConfig — v0.7 A4 timeline + A5 stuck detection', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('TIMELINE_MAX: default 200, reads from env', () => {
expect(loadConfig({}).timelineMax).toBe(200)
expect(loadConfig({ TIMELINE_MAX: '50' }).timelineMax).toBe(50)
})
it('TIMELINE_MAX: throws for non-integer', () => {
expect(() => loadConfig({ TIMELINE_MAX: 'lots' })).toThrow(/TIMELINE_MAX/)
})
it('TIMELINE_ENABLED: default true, parses on/off', () => {
expect(loadConfig({}).timelineEnabled).toBe(true)
expect(loadConfig({ TIMELINE_ENABLED: '0' }).timelineEnabled).toBe(false)
expect(loadConfig({ TIMELINE_ENABLED: 'false' }).timelineEnabled).toBe(false)
expect(loadConfig({ TIMELINE_ENABLED: '1' }).timelineEnabled).toBe(true)
})
it('STUCK_TTL: default 600000ms (600s), reads from env in seconds', () => {
expect(loadConfig({}).stuckTtlMs).toBe(600_000)
expect(loadConfig({ STUCK_TTL: '300' }).stuckTtlMs).toBe(300_000)
expect(loadConfig({ STUCK_TTL: '0' }).stuckTtlMs).toBe(0)
})
it('STUCK_TTL: throws for non-integer', () => {
expect(() => loadConfig({ STUCK_TTL: 'soon' })).toThrow(/STUCK_TTL/)
})
it('STUCK_ALERT: default true, parses on/off', () => {
expect(loadConfig({}).stuckAlert).toBe(true)
expect(loadConfig({ STUCK_ALERT: '0' }).stuckAlert).toBe(false)
expect(loadConfig({ STUCK_ALERT: 'off' }).stuckAlert).toBe(false)
expect(loadConfig({ STUCK_ALERT: '1' }).stuckAlert).toBe(true)
})
})
describe('loadConfig — v0.7 B1 diff + B2 statusline', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('DIFF_TIMEOUT_MS: default 2000, reads from env', () => {
expect(loadConfig({}).diffTimeoutMs).toBe(2000)
expect(loadConfig({ DIFF_TIMEOUT_MS: '5000' }).diffTimeoutMs).toBe(5000)
})
it('DIFF_MAX_BYTES: default 2MB, reads from env', () => {
expect(loadConfig({}).diffMaxBytes).toBe(2 * 1024 * 1024)
expect(loadConfig({ DIFF_MAX_BYTES: '1048576' }).diffMaxBytes).toBe(1048576)
})
it('DIFF_MAX_FILES: default 300, reads from env', () => {
expect(loadConfig({}).diffMaxFiles).toBe(300)
expect(loadConfig({ DIFF_MAX_FILES: '100' }).diffMaxFiles).toBe(100)
})
it('DIFF_* vars throw for non-integer', () => {
expect(() => loadConfig({ DIFF_TIMEOUT_MS: 'fast' })).toThrow(/DIFF_TIMEOUT_MS/)
expect(() => loadConfig({ DIFF_MAX_BYTES: 'big' })).toThrow(/DIFF_MAX_BYTES/)
expect(() => loadConfig({ DIFF_MAX_FILES: 'many' })).toThrow(/DIFF_MAX_FILES/)
})
it('STATUSLINE_TTL_MS: default 30000, reads from env', () => {
expect(loadConfig({}).statuslineTtlMs).toBe(30_000)
expect(loadConfig({ STATUSLINE_TTL_MS: '60000' }).statuslineTtlMs).toBe(60_000)
})
it('STATUSLINE_TTL_MS: throws for non-integer', () => {
expect(() => loadConfig({ STATUSLINE_TTL_MS: 'long' })).toThrow(/STATUSLINE_TTL_MS/)
})
})
describe('loadConfig — v0.7 B3 worktree', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('WORKTREE_ENABLED: default true, parses on/off', () => {
expect(loadConfig({}).worktreeEnabled).toBe(true)
expect(loadConfig({ WORKTREE_ENABLED: '0' }).worktreeEnabled).toBe(false)
expect(loadConfig({ WORKTREE_ENABLED: 'false' }).worktreeEnabled).toBe(false)
})
it('WORKTREE_ROOT: default undefined, reads from env', () => {
expect(loadConfig({}).worktreeRoot).toBeUndefined()
expect(loadConfig({ WORKTREE_ROOT: '/data/worktrees' }).worktreeRoot).toBe('/data/worktrees')
})
it('WORKTREE_TIMEOUT_MS: default 10000, reads from env', () => {
expect(loadConfig({}).worktreeTimeoutMs).toBe(10_000)
expect(loadConfig({ WORKTREE_TIMEOUT_MS: '20000' }).worktreeTimeoutMs).toBe(20_000)
})
it('WORKTREE_TIMEOUT_MS: throws for non-integer', () => {
expect(() => loadConfig({ WORKTREE_TIMEOUT_MS: 'slow' })).toThrow(/WORKTREE_TIMEOUT_MS/)
})
})
describe('loadConfig — v0.7 B4 permission mode', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('DEFAULT_PERMISSION_MODE: default "default"', () => {
expect(loadConfig({}).defaultPermissionMode).toBe('default')
})
it('DEFAULT_PERMISSION_MODE: accepts all 4 valid values', () => {
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'default' }).defaultPermissionMode).toBe('default')
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'acceptEdits' }).defaultPermissionMode).toBe('acceptEdits')
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'plan' }).defaultPermissionMode).toBe('plan')
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'auto' }).defaultPermissionMode).toBe('auto')
})
it('DEFAULT_PERMISSION_MODE: throws for invalid values', () => {
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'bypassPermissions' })).toThrow(/DEFAULT_PERMISSION_MODE/)
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'dontAsk' })).toThrow(/DEFAULT_PERMISSION_MODE/)
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: '' })).toThrow(/DEFAULT_PERMISSION_MODE/)
})
it('ALLOW_AUTO_MODE: default false, parses on/off', () => {
expect(loadConfig({}).allowAutoMode).toBe(false)
expect(loadConfig({ ALLOW_AUTO_MODE: '1' }).allowAutoMode).toBe(true)
expect(loadConfig({ ALLOW_AUTO_MODE: 'true' }).allowAutoMode).toBe(true)
expect(loadConfig({ ALLOW_AUTO_MODE: '0' }).allowAutoMode).toBe(false)
})
})
describe('loadConfig — v0.7 L5 permTimeoutMs validation', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('PERM_TIMEOUT_MS=0 throws (must be > 0 for --max-time integration)', () => {
expect(() => loadConfig({ PERM_TIMEOUT_MS: '0' })).toThrow(/PERM_TIMEOUT_MS/)
})
it('PERM_TIMEOUT_MS=1 is valid (positive integer)', () => {
expect(loadConfig({ PERM_TIMEOUT_MS: '1' }).permTimeoutMs).toBe(1)
})
})
describe('loadConfig — v0.7 SEC-C5 VAPID key confidentiality', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('vapidPrivateKey field name is distinct (caller must avoid logging it)', () => {
// The private key must never appear in logs. Config stores the raw value
// under "vapidPrivateKey" — callers that log config should skip this field.
const cfg = loadConfig({ VAPID_PRIVATE_KEY: 'super-secret-key' })
expect(cfg.vapidPrivateKey).toBe('super-secret-key')
// Verify the field name so callers can explicitly skip it in log output
expect(Object.keys(cfg)).toContain('vapidPrivateKey')
})
})
describe('loadConfig — v0.7 all new fields present in frozen result', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('returned object is frozen and contains all 21 new v0.7 fields', () => {
const cfg = loadConfig({})
expect(Object.isFrozen(cfg)).toBe(true)
// A1 push
expect(cfg).toHaveProperty('vapidPublicKey')
expect(cfg).toHaveProperty('vapidPrivateKey')
expect(cfg).toHaveProperty('vapidSubject')
expect(cfg).toHaveProperty('pushStorePath')
expect(cfg).toHaveProperty('pushMaxSubs')
expect(cfg).toHaveProperty('notifyDone')
expect(cfg).toHaveProperty('notifyDnd')
expect(cfg).toHaveProperty('decisionTokenTtlMs')
// A4 timeline
expect(cfg).toHaveProperty('timelineMax')
expect(cfg).toHaveProperty('timelineEnabled')
// A5 stuck
expect(cfg).toHaveProperty('stuckTtlMs')
expect(cfg).toHaveProperty('stuckAlert')
// B1 diff
expect(cfg).toHaveProperty('diffTimeoutMs')
expect(cfg).toHaveProperty('diffMaxBytes')
expect(cfg).toHaveProperty('diffMaxFiles')
// B2 statusline
expect(cfg).toHaveProperty('statuslineTtlMs')
// B3 worktree
expect(cfg).toHaveProperty('worktreeEnabled')
expect(cfg).toHaveProperty('worktreeRoot')
expect(cfg).toHaveProperty('worktreeTimeoutMs')
// B4 permission mode
expect(cfg).toHaveProperty('defaultPermissionMode')
expect(cfg).toHaveProperty('allowAutoMode')
})
})

483
test/diff.test.ts Normal file
View File

@@ -0,0 +1,483 @@
// @vitest-environment jsdom
/**
* test/diff.test.ts — N-diff-ui: diff viewer render-only frontend module
*
* Covers: normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff,
* mountDiffViewer (working/staged toggle, truncated warning, empty state).
*
* Security: SEC-H4 — all diff content via textContent, zero innerHTML.
* Accept: AC-B1.3, AC-B1.4
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { DiffResult, DiffFile, DiffLine, DiffHunk } from '../src/types.js'
// ── module import ─────────────────────────────────────────────────────────────
const {
normalizeDiffResult,
fetchDiff,
renderDiffFile,
renderDiff,
mountDiffViewer,
} = await import('../public/diff.js')
// ── helpers ───────────────────────────────────────────────────────────────────
function makeLine(kind: DiffLine['kind'], text: string): DiffLine {
return { kind, text }
}
function makeHunk(header: string, lines: DiffLine[] = []): DiffHunk {
return { header, lines }
}
function makeFile(over: Partial<DiffFile> = {}): DiffFile {
return {
oldPath: 'src/foo.ts',
newPath: 'src/foo.ts',
status: 'modified',
added: 2,
removed: 1,
binary: false,
hunks: [makeHunk('@@ -1,3 +1,4 @@', [
makeLine('context', ' line1'),
makeLine('removed', '-old'),
makeLine('added', '+new'),
makeLine('added', '+extra'),
])],
...over,
}
}
function makeResult(over: Partial<DiffResult> = {}): DiffResult {
return {
files: [makeFile()],
staged: false,
truncated: false,
...over,
}
}
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
// ── normalizeDiffResult ───────────────────────────────────────────────────────
describe('normalizeDiffResult', () => {
it('returns null for null input', () => {
expect(normalizeDiffResult(null)).toBeNull()
})
it('returns null for non-object input', () => {
expect(normalizeDiffResult('string')).toBeNull()
expect(normalizeDiffResult(42)).toBeNull()
expect(normalizeDiffResult(undefined)).toBeNull()
})
it('returns null when files is not an array', () => {
expect(normalizeDiffResult({ files: 'bad', staged: false, truncated: false })).toBeNull()
})
it('returns null when staged is not a boolean', () => {
expect(normalizeDiffResult({ files: [], staged: 'yes', truncated: false })).toBeNull()
})
it('returns null when truncated is not a boolean', () => {
expect(normalizeDiffResult({ files: [], staged: false, truncated: 'yes' })).toBeNull()
})
it('returns a valid DiffResult for a well-formed object', () => {
const raw = makeResult()
const result = normalizeDiffResult(raw)
expect(result).not.toBeNull()
expect(result?.staged).toBe(false)
expect(result?.truncated).toBe(false)
expect(result?.files).toHaveLength(1)
})
it('filters out invalid file entries (keeps only valid ones)', () => {
const raw = {
files: [
makeFile(),
{ not: 'a file' },
null,
],
staged: false,
truncated: false,
}
const result = normalizeDiffResult(raw)
expect(result?.files).toHaveLength(1)
})
it('accepts an empty files array (empty diff)', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
expect(result).not.toBeNull()
expect(result?.files).toHaveLength(0)
})
it('returns DiffResult with staged=true when input has staged=true', () => {
const result = normalizeDiffResult({ files: [], staged: true, truncated: false })
expect(result?.staged).toBe(true)
})
it('returns DiffResult with truncated=true when input has truncated=true', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: true })
expect(result?.truncated).toBe(true)
})
})
// ── fetchDiff ─────────────────────────────────────────────────────────────────
describe('fetchDiff', () => {
it('fetches from /projects/diff with correct query params (working tree)', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult({ staged: false }),
}))
vi.stubGlobal('fetch', mockFetch)
const result = await fetchDiff('/some/repo', false)
expect(mockFetch).toHaveBeenCalledOnce()
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('/projects/diff')
expect(url).toContain(encodeURIComponent('/some/repo'))
expect(url).toContain('staged=false')
expect(result).not.toBeNull()
})
it('fetches with staged=true when staged is true', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult({ staged: true }),
}))
vi.stubGlobal('fetch', mockFetch)
await fetchDiff('/repo', true)
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('staged=true')
})
it('returns null on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
const result = await fetchDiff('/repo', false)
expect(result).toBeNull()
})
it('returns null on fetch error (network failure)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
const result = await fetchDiff('/repo', false)
expect(result).toBeNull()
})
it('returns null when server returns invalid structure', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => ({ not: 'a diff result' }),
})))
const result = await fetchDiff('/repo', false)
expect(result).toBeNull()
})
})
// ── renderDiffFile ────────────────────────────────────────────────────────────
describe('renderDiffFile', () => {
it('renders the file path as plain text (newPath)', () => {
const file = makeFile({ newPath: 'src/component.ts' })
const el = renderDiffFile(file)
expect(el.textContent).toContain('src/component.ts')
})
it('renders +/- stats badge with correct counts', () => {
const file = makeFile({ added: 5, removed: 3 })
const el = renderDiffFile(file)
const text = el.textContent ?? ''
expect(text).toContain('+5')
expect(text).toContain('-3')
})
it('renders hunk header as text', () => {
const file = makeFile({
hunks: [makeHunk('@@ -1,3 +1,4 @@', [])],
})
const el = renderDiffFile(file)
expect(el.textContent).toContain('@@ -1,3 +1,4 @@')
})
it('renders added/removed/context lines as plain text (SEC-H4)', () => {
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [
makeLine('added', '+new line'),
makeLine('removed', '-old line'),
makeLine('context', ' ctx line'),
])],
})
const el = renderDiffFile(file)
const text = el.textContent ?? ''
expect(text).toContain('+new line')
expect(text).toContain('-old line')
expect(text).toContain(' ctx line')
})
it('renders <script> content as plain text — not injected as HTML (SEC-H4)', () => {
const xssLine = '<script>alert(1)</script>'
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [
makeLine('added', xssLine),
])],
})
const el = renderDiffFile(file)
// The text should be present
expect(el.textContent).toContain(xssLine)
// But no actual <script> element should be injected
expect(el.querySelectorAll('script').length).toBe(0)
})
it('renders & as literal text (not HTML entity expanded) (SEC-H4)', () => {
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [
makeLine('context', ' a & b && c'),
])],
})
const el = renderDiffFile(file)
expect(el.textContent).toContain('a & b && c')
// innerHTML should NOT contain unescaped &
expect(el.innerHTML).not.toContain(' a & b ')
})
it('renders ANSI escape sequences as plain text (SEC-H4)', () => {
const ansi = '\x1b[32m+green\x1b[0m'
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [makeLine('added', ansi)])],
})
const el = renderDiffFile(file)
expect(el.textContent).toContain(ansi)
})
it('renders a binary file indicator (no hunks)', () => {
const file = makeFile({ binary: true, hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('binary')
})
it('renders status for renamed file (shows old + new path)', () => {
const file = makeFile({
oldPath: 'src/old.ts',
newPath: 'src/new.ts',
status: 'renamed',
hunks: [],
})
const el = renderDiffFile(file)
const text = el.textContent ?? ''
expect(text).toContain('src/old.ts')
expect(text).toContain('src/new.ts')
})
it('renders deleted file with status indicator', () => {
const file = makeFile({ status: 'deleted', hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('deleted')
})
it('renders added (new) file with status indicator', () => {
const file = makeFile({ status: 'added', hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('added')
})
it('returns an HTMLElement (not null)', () => {
expect(renderDiffFile(makeFile())).toBeInstanceOf(HTMLElement)
})
})
// ── renderDiff ────────────────────────────────────────────────────────────────
describe('renderDiff', () => {
it('renders each file from the result', () => {
const result = makeResult({
files: [
makeFile({ newPath: 'a.ts' }),
makeFile({ newPath: 'b.ts' }),
],
})
const el = renderDiff(result)
expect(el.textContent).toContain('a.ts')
expect(el.textContent).toContain('b.ts')
})
it('shows empty state message when no files changed', () => {
const result = makeResult({ files: [] })
const el = renderDiff(result)
// Should show some empty-state text
const text = el.textContent?.toLowerCase() ?? ''
expect(text).toMatch(/no (changes|diff|files)/i)
})
it('shows truncated warning when result.truncated is true', () => {
const result = makeResult({ truncated: true })
const el = renderDiff(result)
const text = el.textContent?.toLowerCase() ?? ''
expect(text).toMatch(/truncated|too large|large/i)
})
it('does NOT show truncated warning when result.truncated is false', () => {
const result = makeResult({ truncated: false })
const el = renderDiff(result)
const text = el.textContent?.toLowerCase() ?? ''
expect(text).not.toMatch(/truncated/i)
})
it('renders <script> in diff content as text not HTML (SEC-H4)', () => {
const result = makeResult({
files: [makeFile({
hunks: [makeHunk('@@ @@', [makeLine('added', '<script>evil()</script>')])],
})],
})
const el = renderDiff(result)
expect(el.textContent).toContain('<script>evil()</script>')
expect(el.querySelectorAll('script').length).toBe(0)
})
it('returns an HTMLElement', () => {
expect(renderDiff(makeResult())).toBeInstanceOf(HTMLElement)
})
})
// ── mountDiffViewer ───────────────────────────────────────────────────────────
describe('mountDiffViewer', () => {
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
it('renders into the container on mount', async () => {
const result = makeResult()
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => result,
})))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0)) // let async fetch settle
expect(container.children.length).toBeGreaterThan(0)
handle.destroy()
})
it('shows working tree by default (staged=false)', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult({ staged: false }),
}))
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('staged=false')
handle.destroy()
})
it('can switch to staged view', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult({ staged: true }),
}))
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
// Switch to staged
handle.showStaged()
await new Promise((r) => setTimeout(r, 0))
// Should have fetched twice: once working, once staged
expect(mockFetch).toHaveBeenCalledTimes(2)
const secondUrl = mockFetch.mock.calls[1]?.[0] as string
expect(secondUrl).toContain('staged=true')
handle.destroy()
})
it('can switch back to working view', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult(),
}))
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
handle.showStaged()
await new Promise((r) => setTimeout(r, 0))
handle.showWorking()
await new Promise((r) => setTimeout(r, 0))
expect(mockFetch).toHaveBeenCalledTimes(3)
const thirdUrl = mockFetch.mock.calls[2]?.[0] as string
expect(thirdUrl).toContain('staged=false')
handle.destroy()
})
it('calls onClose when the close action fires', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => makeResult(),
})))
const onClose = vi.fn()
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { onClose })
await new Promise((r) => setTimeout(r, 0))
handle.close()
expect(onClose).toHaveBeenCalledOnce()
handle.destroy()
})
it('shows error/empty state when fetch fails (does not throw)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
// Should not throw and should render something
expect(container.children.length).toBeGreaterThan(0)
handle.destroy()
})
it('destroy removes content from container', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => makeResult(),
})))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
handle.destroy()
expect(container.children.length).toBe(0)
})
it('handles empty diff gracefully (no throw)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => makeResult({ files: [] }),
})))
const container = makeContainer()
const handle = mountDiffViewer(container, '/some/repo', {})
await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined()
handle.destroy()
})
})

View File

@@ -1,42 +1,54 @@
import { describe, it, expect } from 'vitest'
import { parseHookEvent } from '../src/http/hook.js'
import type { HookEventFull } from '../src/http/hook.js'
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
describe('parseHookEvent', () => {
it('maps tool events to "working"', () => {
// ── Existing status / detail behaviour (must not regress) ───────────────────
// We use expect.objectContaining() so the new fields (at/eventClass/gate) do
// not break these assertion — they verify only the status-mapping contract.
describe('parseHookEvent — status mapping (regression guard)', () => {
it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => {
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({
sessionId: SID,
status: 'working',
})
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'working' }),
)
}
})
it('maps PermissionRequest to "waiting" with the tool name as detail', () => {
expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual(
{ sessionId: SID, status: 'waiting', detail: 'Bash' },
)
it('maps PermissionRequest to "waiting" with tool_name as detail', () => {
expect(
parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' }))
})
it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => {
it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => {
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
).toEqual({ sessionId: SID, status: 'waiting' })
parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'permission_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' }))
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }),
).toEqual({ sessionId: SID, status: 'idle' })
parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' }))
})
it('maps Stop / SessionEnd to "idle"', () => {
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' })
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({
sessionId: SID,
status: 'idle',
})
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'idle' }),
)
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'idle' }),
)
})
it('returns null for missing sessionId, bad body, or unknown event', () => {
it('returns null for missing/empty sessionId, bad body, or unknown event', () => {
expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent(SID, null)).toBeNull()
@@ -45,3 +57,161 @@ describe('parseHookEvent', () => {
expect(parseHookEvent(SID, {})).toBeNull()
})
})
// ── HookEventFull — new A4 / B4 fields ──────────────────────────────────────
describe('HookEventFull — at field (A4)', () => {
it('includes a numeric timestamp in [before, after] around the call', () => {
const before = Date.now()
const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull
const after = Date.now()
expect(ev).not.toBeNull()
expect(typeof ev.at).toBe('number')
expect(ev.at).toBeGreaterThanOrEqual(before)
expect(ev.at).toBeLessThanOrEqual(after)
})
it('is absent from null results', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' })
expect(ev).toBeNull()
})
})
describe('HookEventFull — eventClass field (A4)', () => {
const EVENTS = [
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'PermissionRequest',
'Stop',
'SessionEnd',
'Notification',
]
it('carries the raw hook_event_name for every whitelisted event', () => {
for (const e of EVENTS) {
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.eventClass).toBe(e)
}
})
it('is not present when parseHookEvent returns null', () => {
expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull()
})
})
describe('HookEventFull — toolInput passthrough (A4)', () => {
it('includes toolInput when tool_input is present in the body', () => {
const input = { path: '/src/foo.ts', content: 'hello world' }
const ev = parseHookEvent(SID, {
hook_event_name: 'PreToolUse',
tool_input: input,
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.toolInput).toBe(input) // same reference, no copy/parse
})
it('passes toolInput through as-is (does not validate inner shape)', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PostToolUse',
tool_input: 'just a string',
}) as HookEventFull
expect(ev.toolInput).toBe('just a string')
})
it('omits toolInput when tool_input is absent', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull
expect(ev).not.toBeNull()
expect('toolInput' in ev).toBe(false)
})
it('includes toolInput even when its value is null', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PostToolUse',
tool_input: null,
}) as HookEventFull
expect(ev).not.toBeNull()
expect('toolInput' in ev).toBe(true)
expect(ev.toolInput).toBeNull()
})
})
describe('HookEventFull — gate field (B4 plan-gate recognition)', () => {
it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('plan')
})
it('sets gate="tool" for PermissionRequest with any other tool name', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('tool')
})
it('sets gate="tool" for PermissionRequest with no tool_name', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('tool')
})
it('omits gate for non-PermissionRequest events', () => {
const nonPermEvents = [
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'Stop',
'SessionEnd',
'Notification',
]
for (const e of nonPermEvents) {
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
expect(ev).not.toBeNull()
expect('gate' in ev).toBe(false)
}
})
})
describe('HookEventFull — detail not set for Notification (no tool_name)', () => {
it('Notification(permission_prompt) has status=waiting but no detail', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'permission_prompt',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.status).toBe('waiting')
expect('detail' in ev).toBe(false)
})
})
describe('SEC-M7 — never throws on any body shape', () => {
const malformed = [
[],
42,
true,
undefined,
{ hook_event_name: null },
{ hook_event_name: 123 },
{ hook_event_name: 'PreToolUse', tool_input: { toString: null } },
]
it('returns null without throwing for every malformed body', () => {
for (const b of malformed) {
expect(() => parseHookEvent(SID, b)).not.toThrow()
}
})
})

348
test/http/diff.test.ts Normal file
View File

@@ -0,0 +1,348 @@
/**
* test/http/diff.test.ts (N-diff-be, B1) — read-only git diff parsing + getDiff.
*
* Two layers:
* 1. Pure parsers (parseNumstat / parseUnifiedDiff) fed canned git output — the
* deterministic core (AC-B1.5: rename/new/delete/binary/empty/untracked, +/-
* consistent with `git diff --numstat`; never throws; <script> stays text).
* 2. getDiff against a REAL throwaway git repo in os.tmpdir (AC-B1.1, L3).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
parseNumstat,
parseUnifiedDiff,
getDiff,
type NumstatEntry,
type GetDiffOptions,
} from '../../src/http/diff.js'
const execFileAsync = promisify(execFile)
const LIMITS: GetDiffOptions['cfg'] = {
diffTimeoutMs: 5000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
}
// ── parseNumstat ──────────────────────────────────────────────────────────────
describe('parseNumstat', () => {
it('parses a basic added/removed line', () => {
const m = parseNumstat('2\t1\tfoo.txt\n')
expect(m.get('foo.txt')).toEqual<NumstatEntry>({ added: 2, removed: 1, binary: false })
})
it('flags binary files (-\\t-) with zero counts', () => {
const m = parseNumstat('-\t-\timg.png\n')
expect(m.get('img.png')).toEqual<NumstatEntry>({ added: 0, removed: 0, binary: true })
})
it('keys a plain-arrow rename under both old and new paths', () => {
const m = parseNumstat('1\t1\told.txt => new.txt\n')
const entry = { added: 1, removed: 1, binary: false }
expect(m.get('old.txt')).toEqual(entry)
expect(m.get('new.txt')).toEqual(entry)
})
it('expands a braced rename path (common prefix/suffix)', () => {
const m = parseNumstat('3\t4\tsrc/{a => b}/file.js\n')
expect(m.has('src/a/file.js')).toBe(true)
expect(m.has('src/b/file.js')).toBe(true)
expect(m.get('src/b/file.js')).toEqual({ added: 3, removed: 4, binary: false })
})
it('collapses double slashes when a brace side is empty', () => {
const m = parseNumstat('1\t0\tsrc/{ => sub}/file.txt\n')
expect(m.has('src/file.txt')).toBe(true)
expect(m.has('src/sub/file.txt')).toBe(true)
})
it('returns an empty map for empty input', () => {
expect(parseNumstat('').size).toBe(0)
})
it('skips malformed lines without throwing', () => {
const m = parseNumstat('garbage\n2\t1\tok.txt\n\n')
expect(m.size).toBe(1)
expect(m.get('ok.txt')).toEqual({ added: 2, removed: 1, binary: false })
})
})
// ── parseUnifiedDiff ─────────────────────────────────────────────────────────
const MODIFIED = [
'diff --git a/foo.txt b/foo.txt',
'index e69de29..d95f3ad 100644',
'--- a/foo.txt',
'+++ b/foo.txt',
'@@ -1,2 +1,3 @@',
' keep',
'-old',
'+new',
'+added',
'',
].join('\n')
describe('parseUnifiedDiff', () => {
it('returns [] for empty / non-diff input', () => {
expect(parseUnifiedDiff('')).toEqual([])
expect(parseUnifiedDiff('not a diff at all')).toEqual([])
})
it('parses a modified file into one hunk with typed lines', () => {
const [file] = parseUnifiedDiff(MODIFIED)
expect(file).toBeDefined()
expect(file?.oldPath).toBe('foo.txt')
expect(file?.newPath).toBe('foo.txt')
expect(file?.status).toBe('modified')
expect(file?.binary).toBe(false)
expect(file?.hunks).toHaveLength(1)
expect(file?.hunks[0]?.header).toBe('@@ -1,2 +1,3 @@')
expect(file?.hunks[0]?.lines).toEqual([
{ kind: 'context', text: 'keep' },
{ kind: 'removed', text: 'old' },
{ kind: 'added', text: 'new' },
{ kind: 'added', text: 'added' },
])
})
it('counts +/- from the hunk when no numstat is supplied', () => {
const [file] = parseUnifiedDiff(MODIFIED)
expect(file?.added).toBe(2)
expect(file?.removed).toBe(1)
})
it('prefers numstat counts over hunk counts', () => {
const numstat = parseNumstat('99\t88\tfoo.txt\n')
const [file] = parseUnifiedDiff(MODIFIED, numstat)
expect(file?.added).toBe(99)
expect(file?.removed).toBe(88)
})
it('detects an added file via /dev/null source', () => {
const patch = [
'diff --git a/new.txt b/new.txt',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/new.txt',
'@@ -0,0 +1,2 @@',
'+line one',
'+line two',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.status).toBe('added')
expect(file?.newPath).toBe('new.txt')
expect(file?.added).toBe(2)
expect(file?.removed).toBe(0)
})
it('detects a deleted file via /dev/null target', () => {
const patch = [
'diff --git a/del.txt b/del.txt',
'deleted file mode 100644',
'index 1234567..0000000',
'--- a/del.txt',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-line one',
'-line two',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.status).toBe('deleted')
expect(file?.oldPath).toBe('del.txt')
expect(file?.removed).toBe(2)
})
it('detects a rename and maps numstat by the new path', () => {
const patch = [
'diff --git a/old/name.txt b/new/name.txt',
'similarity index 95%',
'rename from old/name.txt',
'rename to new/name.txt',
'index 111..222 100644',
'--- a/old/name.txt',
'+++ b/new/name.txt',
'@@ -1 +1 @@',
'-hello',
'+hello world',
'',
].join('\n')
const numstat = parseNumstat('1\t1\t{old => new}/name.txt\n')
const [file] = parseUnifiedDiff(patch, numstat)
expect(file?.status).toBe('renamed')
expect(file?.oldPath).toBe('old/name.txt')
expect(file?.newPath).toBe('new/name.txt')
expect(file?.added).toBe(1)
expect(file?.removed).toBe(1)
})
it('detects a binary file (Binary files … differ + numstat -\\t-)', () => {
const patch = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'',
].join('\n')
const numstat = parseNumstat('-\t-\timg.png\n')
const [file] = parseUnifiedDiff(patch, numstat)
expect(file?.status).toBe('binary')
expect(file?.binary).toBe(true)
expect(file?.hunks).toEqual([])
expect(file?.added).toBe(0)
expect(file?.removed).toBe(0)
})
it('parses multiple hunks and a no-newline meta marker', () => {
const patch = [
'diff --git a/m.txt b/m.txt',
'index 111..222 100644',
'--- a/m.txt',
'+++ b/m.txt',
'@@ -1,2 +1,2 @@',
' a',
'-b',
'+B',
'@@ -10,2 +10,3 @@',
' x',
'+y',
'\\ No newline at end of file',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.hunks).toHaveLength(2)
const meta = file?.hunks[1]?.lines.find((l) => l.kind === 'meta')
expect(meta?.text).toContain('No newline')
})
it('classifies an unrecognised hunk line as context (never throws)', () => {
const patch = [
'diff --git a/x.txt b/x.txt',
'--- a/x.txt',
'+++ b/x.txt',
'@@ -1 +1 @@',
'?weird line',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'context', text: '?weird line' })
})
it('preserves <script>/ANSI/backtick content verbatim as text (AC-B1.4)', () => {
const payload = '<script>alert(1)</script> `cmd` red'
const patch = [
'diff --git a/x.txt b/x.txt',
'--- a/x.txt',
'+++ b/x.txt',
'@@ -0,0 +1 @@',
'+' + payload,
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'added', text: payload })
})
it('parses several files in one patch', () => {
const patch = MODIFIED + [
'diff --git a/bar.txt b/bar.txt',
'--- a/bar.txt',
'+++ b/bar.txt',
'@@ -1 +1 @@',
'-one',
'+two',
'',
].join('\n')
const files = parseUnifiedDiff(patch)
expect(files.map((f) => f.newPath)).toEqual(['foo.txt', 'bar.txt'])
})
})
// ── getDiff (integration against a real git repo) ────────────────────────────
async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}
describe('getDiff (real git repo)', () => {
let repo: string
beforeAll(async () => {
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-diff-'))
await git(repo, 'init', '-q', '-b', 'main')
await git(repo, 'config', 'user.email', 'test@example.com')
await git(repo, 'config', 'user.name', 'Test')
await git(repo, 'config', 'commit.gpgsign', 'false')
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n')
await git(repo, 'add', '.')
await git(repo, 'commit', '-q', '-m', 'init')
})
afterAll(async () => {
await fs.rm(repo, { recursive: true, force: true })
})
it('returns structured working-tree diff with numstat-consistent counts', async () => {
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\nTWO\nthree\nfour\n')
const result = await getDiff(repo, { staged: false, cfg: LIMITS })
expect(result.staged).toBe(false)
expect(result.truncated).toBe(false)
const file = result.files.find((f) => f.newPath === 'tracked.txt')
expect(file).toBeDefined()
expect(file?.status).toBe('modified')
// changed line two + added line four = 2 added, 1 removed
expect(file?.added).toBe(2)
expect(file?.removed).toBe(1)
// revert for later tests
await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n')
})
it('lists untracked files via porcelain (L3, working tree only)', async () => {
await fs.writeFile(path.join(repo, 'fresh.txt'), 'brand new\n')
const result = await getDiff(repo, { staged: false, cfg: LIMITS })
const untracked = result.files.find((f) => f.newPath === 'fresh.txt')
expect(untracked?.status).toBe('untracked')
await fs.rm(path.join(repo, 'fresh.txt'))
})
it('does not include untracked files in the staged diff', async () => {
await fs.writeFile(path.join(repo, 'staged-only.txt'), 'queued\n')
await git(repo, 'add', 'staged-only.txt')
const result = await getDiff(repo, { staged: true, cfg: LIMITS })
expect(result.staged).toBe(true)
const file = result.files.find((f) => f.newPath === 'staged-only.txt')
expect(file?.status).toBe('added')
expect(result.files.every((f) => f.status !== 'untracked')).toBe(true)
await git(repo, 'reset', '-q')
await fs.rm(path.join(repo, 'staged-only.txt'))
})
it('marks truncated when the file count exceeds diffMaxFiles', async () => {
for (let i = 0; i < 5; i++) {
await fs.writeFile(path.join(repo, `gen-${i}.txt`), `content ${i}\n`)
}
const result = await getDiff(repo, {
staged: false,
cfg: { ...LIMITS, diffMaxFiles: 2 },
})
expect(result.truncated).toBe(true)
expect(result.files.length).toBeLessThanOrEqual(2)
for (let i = 0; i < 5; i++) await fs.rm(path.join(repo, `gen-${i}.txt`))
})
it('never throws for a non-existent / non-git path (returns empty)', async () => {
const result = await getDiff(path.join(os.tmpdir(), 'definitely-not-here-xyz'), {
staged: false,
cfg: LIMITS,
})
expect(result.files).toEqual([])
expect(result.truncated).toBe(false)
})
})

View File

@@ -0,0 +1,373 @@
/**
* test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be).
*
* Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage.
* Security: SEC-M7 — unknown + narrowing, string length limits.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { parseStatusLine } from '../../src/http/statusline.js'
import type { StatusTelemetry } from '../../src/types.js'
// Representative full Claude Code statusLine JSON body.
// Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct,
// cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
// model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate.
const FULL_BODY = {
context_window: { used_percentage: 42.5 },
cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 },
model: { display_name: 'Claude 3.5 Sonnet' },
effort: { level: 'normal' },
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
rate_limits: { fiveHourPct: 10, sevenDayPct: 20 },
}
const FIXED_NOW = 1_700_000_000_000
describe('parseStatusLine — null for non-objects', () => {
it('returns null for null', () => {
expect(parseStatusLine(null)).toBeNull()
})
it('returns null for undefined', () => {
expect(parseStatusLine(undefined)).toBeNull()
})
it('returns null for a string', () => {
expect(parseStatusLine('hello')).toBeNull()
})
it('returns null for a number', () => {
expect(parseStatusLine(42)).toBeNull()
})
it('returns null for a boolean', () => {
expect(parseStatusLine(true)).toBeNull()
})
it('returns null for an array (typeof is "object" but is not a plain object)', () => {
expect(parseStatusLine([])).toBeNull()
expect(parseStatusLine([1, 2, 3])).toBeNull()
})
})
describe('parseStatusLine — full valid body', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('maps all fields correctly from a fully-populated body', () => {
const result = parseStatusLine(FULL_BODY)
expect(result).not.toBeNull()
const t = result as StatusTelemetry
expect(t.contextUsedPct).toBe(42.5)
expect(t.costUsd).toBe(0.05)
expect(t.linesAdded).toBe(100)
expect(t.linesRemoved).toBe(50)
expect(t.model).toBe('Claude 3.5 Sonnet')
expect(t.effort).toBe('normal')
expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' })
expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 })
expect(t.at).toBe(FIXED_NOW)
})
})
describe('parseStatusLine — empty/minimal body', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns a StatusTelemetry with only at for an empty object', () => {
const result = parseStatusLine({})
expect(result).not.toBeNull()
expect(result?.at).toBe(FIXED_NOW)
expect(result?.contextUsedPct).toBeUndefined()
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
expect(result?.model).toBeUndefined()
expect(result?.effort).toBeUndefined()
expect(result?.pr).toBeUndefined()
expect(result?.rate).toBeUndefined()
})
})
describe('parseStatusLine — at timestamp', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sets at to Date.now() on each call', () => {
const t1 = 1000
const t2 = 2000
vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2)
expect(parseStatusLine({})?.at).toBe(t1)
expect(parseStatusLine({})?.at).toBe(t2)
})
})
describe('parseStatusLine — dirty number fields', () => {
it('omits contextUsedPct when used_percentage is NaN', () => {
expect(
parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct,
).toBeUndefined()
})
it('omits contextUsedPct when used_percentage is Infinity', () => {
expect(
parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct,
).toBeUndefined()
})
it('omits contextUsedPct when used_percentage is a string', () => {
expect(
parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct,
).toBeUndefined()
})
it('omits costUsd when total_cost_usd is Infinity', () => {
expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined()
})
it('omits costUsd when total_cost_usd is NaN', () => {
expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined()
})
it('omits linesAdded when total_lines_added is a string', () => {
expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined()
})
it('omits linesRemoved when total_lines_removed is null', () => {
expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined()
})
it('parses partial cost fields independently', () => {
const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } })
expect(result?.costUsd).toBe(1.5)
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
})
describe('parseStatusLine — context_window not an object', () => {
it('omits contextUsedPct when context_window is a string', () => {
expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined()
})
it('omits contextUsedPct when context_window is null', () => {
expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined()
})
it('omits contextUsedPct when context_window is a number', () => {
expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined()
})
})
describe('parseStatusLine — cost not an object', () => {
it('omits all cost fields when cost is a string', () => {
const result = parseStatusLine({ cost: 'bad' })
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
it('omits all cost fields when cost is null', () => {
const result = parseStatusLine({ cost: null })
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
})
describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => {
it('omits model when display_name exceeds 200 characters', () => {
expect(
parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model,
).toBeUndefined()
})
it('accepts model at exactly 200 characters', () => {
const model200 = 'a'.repeat(200)
expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200)
})
it('omits model when model field is not an object', () => {
expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined()
expect(parseStatusLine({ model: null })?.model).toBeUndefined()
expect(parseStatusLine({ model: 42 })?.model).toBeUndefined()
})
it('omits effort when level is a number', () => {
expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined()
})
it('omits effort when level exceeds 100 characters', () => {
expect(
parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort,
).toBeUndefined()
})
it('accepts effort at exactly 100 characters', () => {
const effort100 = 'e'.repeat(100)
expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100)
})
it('omits effort when effort field is not an object', () => {
expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined()
expect(parseStatusLine({ effort: null })?.effort).toBeUndefined()
expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined()
})
})
describe('parseStatusLine — pr parsing', () => {
it('parses pr with all fields including optional reviewState', () => {
const result = parseStatusLine({
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
})
expect(result?.pr).toEqual({
number: 42,
url: 'https://github.com/foo/bar/pull/42',
reviewState: 'pending',
})
})
it('parses pr without optional reviewState', () => {
const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } })
expect(result?.pr).toBeDefined()
expect(result?.pr?.number).toBe(5)
expect(result?.pr?.url).toBe('https://github.com/pr/5')
expect(result?.pr?.reviewState).toBeUndefined()
})
it('omits pr when pr.number is missing', () => {
expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined()
})
it('omits pr when pr.url is missing', () => {
expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined()
})
it('omits pr when both required fields are missing', () => {
expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined()
})
it('omits pr when pr is not an object', () => {
expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined()
expect(parseStatusLine({ pr: null })?.pr).toBeUndefined()
expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined()
})
it('omits pr when pr.url exceeds 2000 characters', () => {
const longUrl = 'https://example.com/' + 'x'.repeat(1990)
expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined()
})
it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => {
const result = parseStatusLine({
pr: {
number: 1,
url: 'https://github.com/pr/1',
reviewState: 'x'.repeat(101),
},
})
expect(result?.pr).toBeDefined()
expect(result?.pr?.number).toBe(1)
expect(result?.pr?.url).toBe('https://github.com/pr/1')
expect(result?.pr?.reviewState).toBeUndefined()
})
it('omits pr when pr.number is NaN', () => {
expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined()
})
})
describe('parseStatusLine — rate_limits parsing', () => {
it('parses rate_limits with both fields', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } })
expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 })
})
it('parses rate_limits with only fiveHourPct', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } })
expect(result?.rate?.fiveHourPct).toBe(25)
expect(result?.rate?.sevenDayPct).toBeUndefined()
})
it('parses rate_limits with only sevenDayPct', () => {
const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } })
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBe(75)
})
it('omits rate when rate_limits is not an object', () => {
expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined()
expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined()
expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined()
})
it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } })
// rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid
expect(result?.rate).toBeDefined()
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBeUndefined()
})
it('omits invalid sub-fields and keeps valid ones', () => {
const result = parseStatusLine({
rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 },
})
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBe(80)
})
})
describe('parseStatusLine — never throw (SEC-M7)', () => {
it('never throws for wildly malformed bodies', () => {
const malformed: unknown[] = [
null,
undefined,
42,
'string',
true,
[],
{ context_window: 'not-an-object' },
{ context_window: { used_percentage: {} } },
{ cost: null },
{ model: 42 },
{ effort: [] },
{ pr: 'not-an-object' },
{ pr: { number: 'bad', url: {} } },
{ rate_limits: 'bad' },
{ rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } },
{ pr: { number: NaN, url: '' } },
{ context_window: { used_percentage: null } },
{ cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } },
]
for (const body of malformed) {
expect(() => parseStatusLine(body)).not.toThrow()
}
})
})

View File

@@ -0,0 +1,205 @@
/**
* test/http/worktrees-create.test.ts — B3 git worktree creation (N-worktree).
*
* Covers the three new exports of src/http/worktrees.ts:
* - validateBranchName (pure: git-ref subset rejection rules, SEC-H2)
* - sanitizeBranchForDir (pure: branch → safe single dir segment)
* - computeWorktreeDir (realpath-based containment, SEC-H3/M2)
* - createWorktree (execFile, no shell, structured result, SEC-M10)
*
* Pure functions are unit-tested; createWorktree runs against real temporary
* git repos (no network) — endpoint wiring is exercised separately in
* T-server-wire's integration suite.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
validateBranchName,
sanitizeBranchForDir,
computeWorktreeDir,
createWorktree,
} from '../../src/http/worktrees.js'
const execFileP = promisify(execFile)
const TIMEOUT_MS = 10000
let gitAvailable = false
beforeAll(async () => {
try {
await execFileP('git', ['--version'])
gitAvailable = true
} catch {
gitAvailable = false
}
})
/** Create a temp git repo with one commit so `git worktree add -b` can run. */
async function makeRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-repo-'))
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir })
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir })
await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir })
return dir
}
// ── validateBranchName ─────────────────────────────────────────────────────────
describe('validateBranchName', () => {
it('accepts ordinary branch names', () => {
for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
expect(validateBranchName(ok)).toBe(true)
}
})
it('rejects empty and over-long names', () => {
expect(validateBranchName('')).toBe(false)
expect(validateBranchName('a'.repeat(251))).toBe(false)
})
it('rejects a leading dash (would be parsed as a git flag, SEC-H2)', () => {
expect(validateBranchName('-rf')).toBe(false)
expect(validateBranchName('-b')).toBe(false)
})
it('rejects path traversal and slash edge cases', () => {
for (const bad of ['../x', 'foo..bar', '/leading', 'trailing/', 'double//slash']) {
expect(validateBranchName(bad)).toBe(false)
}
})
it('rejects a trailing .lock', () => {
expect(validateBranchName('foo.lock')).toBe(false)
})
it('rejects whitespace and control characters', () => {
expect(validateBranchName('has space')).toBe(false)
expect(validateBranchName('tab\tx')).toBe(false)
expect(validateBranchName('ctrl\x01x')).toBe(false)
expect(validateBranchName('null\x00x')).toBe(false)
})
it('rejects git-forbidden punctuation ~^:?*[\\ and @{', () => {
for (const bad of ['a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'back\\slash', 'foo@{1}']) {
expect(validateBranchName(bad)).toBe(false)
}
})
})
// ── sanitizeBranchForDir ────────────────────────────────────────────────────────
describe('sanitizeBranchForDir', () => {
it('replaces slashes with dashes', () => {
expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
})
it('trims leading/trailing dashes', () => {
expect(sanitizeBranchForDir('/foo/')).toBe('foo')
expect(sanitizeBranchForDir('-foo-')).toBe('foo')
})
it('replaces filesystem-unsafe characters with a dash', () => {
expect(sanitizeBranchForDir('a b')).toBe('a-b')
expect(sanitizeBranchForDir('weird$name')).toBe('weird-name')
})
it('preserves safe characters (alnum, dot, underscore, dash)', () => {
expect(sanitizeBranchForDir('v1.2.3_rc')).toBe('v1.2.3_rc')
})
})
// ── computeWorktreeDir ──────────────────────────────────────────────────────────
describe('computeWorktreeDir', () => {
it('places the worktree under an explicit root', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
const dir = await computeWorktreeDir('/repo', 'feature', root)
expect(dir).toBe(path.join(root, 'feature'))
})
it('defaults the base to <repo>-worktrees alongside the repo', async () => {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-defbase-'))
const dir = await computeWorktreeDir(repo, 'foo')
expect(dir).toBe(path.join(path.dirname(repo), path.basename(repo) + '-worktrees', 'foo'))
})
it('throws when the sanitized segment resolves outside the root (.., M2)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-esc-'))
await expect(computeWorktreeDir('/repo', '..', root)).rejects.toThrow()
})
it('rejects a symlinked candidate that escapes the root after realpath (AC-B3.4)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-symroot-'))
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-outside-'))
// Pre-plant a symlink at the predicted candidate path pointing outside root.
await fs.symlink(outside, path.join(root, 'feature'))
await expect(computeWorktreeDir('/repo', 'feature', root)).rejects.toThrow()
})
})
// ── createWorktree (real temp repos) ────────────────────────────────────────────
describe('createWorktree', () => {
it('creates a new worktree + branch and returns its path', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await createWorktree(repo, 'feature/new', { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
expect(res.branch).toBe('feature/new')
expect(res.path).toBeDefined()
const stat = await fs.stat(res.path as string)
expect(stat.isDirectory()).toBe(true)
const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repo })
expect(stdout).toContain(res.path as string)
expect(stdout).toContain('branch refs/heads/feature/new')
})
it('rejects an invalid branch name with 400 and never runs git', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await createWorktree(repo, '../evil', { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
// No worktree was created (still just the main one).
const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo })
expect(stdout.trim().split('\n')).toHaveLength(1)
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-plain-'))
const res = await createWorktree(plain, 'feature', { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 404 for a non-absolute repo path', async () => {
const res = await createWorktree('relative/path', 'feature', { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 409 when the branch already exists', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const first = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
expect(first.ok).toBe(true)
const second = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
expect(second).toMatchObject({ ok: false, status: 409 })
expect(second.error).toBeDefined()
// Error message must not leak raw git stderr (SEC-M10).
expect(second.error).not.toContain('fatal:')
})
it('returns 400 when the computed dir escapes a symlinked root (M2)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-croot-'))
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-cout-'))
await fs.symlink(outside, path.join(root, 'feature'))
const res = await createWorktree(repo, 'feature', { worktreeRoot: root, timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
})

View File

@@ -0,0 +1,422 @@
/**
* T-server-wire — integration tests for the A1 push / lock-screen-decision wiring.
*
* Covers (against a real startServer):
* - GET /push/vapid-key → 503 when VAPID unset, {publicKey} when set
* - POST /push/subscribe → Origin guard (403), 204 on success, 400 bad body,
* per-IP rate limit (429)
* - DELETE /push/subscribe → Origin guard (403), 204 on success
* - POST /hook/decision → Origin guard (403), bad/stale token (403),
* missing fields (400), rate limit (429), and the
* positive token path resolving a held approval
* - C1 hold gate (SEC-C2) → zero clients but ≥1 subscription → /hook/permission HELD
* - WS approve mode (SEC-M5) → ALLOW_AUTO_MODE gate downgrades mode:'auto' → 'default'
*
* web-push is mocked so the push fan-out "succeeds" and we can read the signed
* payload to recover the per-decision capability token for the positive path.
*/
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
// ── web-push mock (captures every signed payload string) ───────────────────────
const { sentPayloads } = vi.hoisted(() => ({ sentPayloads: [] as string[] }))
vi.mock('web-push', () => ({
default: {
setVapidDetails: (): void => {},
sendNotification: async (_sub: unknown, payload: string): Promise<void> => {
sentPayloads.push(payload)
},
},
}))
const PTY_AVAILABLE = (() => {
try {
const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
p.kill()
return true
} catch {
return false
}
})()
const itPty = PTY_AVAILABLE ? it : it.skip
// ── helpers ────────────────────────────────────────────────────────────────────
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
const tmpFiles: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const storePath = path.join(os.tmpdir(), `webterm-push-${port}-${Date.now()}.json`)
tmpFiles.push(storePath)
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
PUSH_STORE_PATH: storePath,
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
const VAPID_ON = {
VAPID_PUBLIC_KEY: 'test-public-key',
VAPID_PRIVATE_KEY: 'test-private-key',
VAPID_SUBJECT: 'mailto:test@localhost',
}
function validSubscriptionBody(endpoint = 'https://push.example/ep-1'): string {
return JSON.stringify({ endpoint, keys: { p256dh: 'p256dh-value', auth: 'auth-value' } })
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('open timeout')), 3000)
ws.once('open', () => {
clearTimeout(t)
resolve()
})
ws.once('error', (e) => {
clearTimeout(t)
reject(e)
})
})
}
function waitForMessage(ws: WebSocket, pred: (m: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
ws.off('message', onMsg)
reject(new Error('message timeout'))
}, 5000)
const onMsg = (raw: WebSocket.RawData): void => {
let m: Record<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined)
sentPayloads.length = 0
await new Promise<void>((r) => setTimeout(r, 30))
})
// ── GET /push/vapid-key ─────────────────────────────────────────────────────────
describe('GET /push/vapid-key', () => {
it('returns 503 when VAPID is not configured', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`)
expect(res.status).toBe(503)
})
it('returns the public key when VAPID is configured', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`)
expect(res.status).toBe(200)
const body = (await res.json()) as { publicKey?: string }
expect(body.publicKey).toBe('test-public-key')
})
})
// ── POST/DELETE /push/subscribe ──────────────────────────────────────────────────
describe('POST /push/subscribe', () => {
it('rejects a foreign Origin with 403 (SEC-C4)', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
body: validSubscriptionBody(),
})
expect(res.status).toBe(403)
})
it('rejects a missing Origin with 403 (default-deny)', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: validSubscriptionBody(),
})
expect(res.status).toBe(403)
})
it('stores a valid subscription with 204 for an allowed Origin', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: validSubscriptionBody(),
})
expect(res.status).toBe(204)
})
it('rejects an invalid subscription body with 400', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ endpoint: '' }),
})
expect(res.status).toBe(400)
})
it('rate-limits more than 5 subscribe calls/min from one IP (429, SEC-H9)', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const codes: number[] = []
for (let i = 0; i < 6; i++) {
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: validSubscriptionBody(`https://push.example/ep-${i}`),
})
codes.push(res.status)
}
expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true)
expect(codes[5]).toBe(429)
})
})
describe('DELETE /push/subscribe', () => {
it('rejects a foreign Origin with 403', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }),
})
expect(res.status).toBe(403)
})
it('removes a subscription with 204 for an allowed Origin', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: validSubscriptionBody(),
})
const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }),
})
expect(res.status).toBe(204)
})
})
// ── POST /hook/decision (security paths) ─────────────────────────────────────────
describe('POST /hook/decision — guards (SEC-C1/M1/H9)', () => {
const sid = '00000000-0000-4000-8000-000000000000'
it('rejects a foreign Origin with 403', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
})
expect(res.status).toBe(403)
})
it('rejects a missing Origin with 403', async () => {
const { port } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
})
expect(res.status).toBe(403)
})
it('rejects an unknown/stale token with 403', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'not-a-real-token' }),
})
expect(res.status).toBe(403)
})
it('rejects a malformed body with 400', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ sessionId: sid }),
})
expect(res.status).toBe(400)
})
it('rate-limits more than 10 decision calls/min from one IP (429)', async () => {
const { port, origin } = await spawnServer(VAPID_ON)
const codes: number[] = []
for (let i = 0; i < 11; i++) {
const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
})
codes.push(res.status)
}
// First 10 hit the handler (403 stale token); the 11th is rate-limited.
expect(codes.slice(0, 10).every((c) => c === 403)).toBe(true)
expect(codes[10]).toBe(429)
})
})
// ── C1 hold gate + decision positive path (needs real PTY) ──────────────────────
describe('C1 hold gate + /hook/decision positive path', () => {
itPty(
'holds a permission with zero clients but ≥1 subscription, then resolves via /hook/decision',
async () => {
const { port, origin } = await spawnServer({ ...VAPID_ON, PERM_TIMEOUT_MS: '4000' })
// 1. Register a push subscription (so push is "enabled and has a target").
await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: validSubscriptionBody(),
})
// 2. Create a session via WS, then detach (zero clients, PTY alive).
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = await waitForMessage(ws, isAttached)
const sessionId = attached['sessionId'] as string
ws.close()
await new Promise<void>((r) => setTimeout(r, 150))
// 3. Fire the held permission hook (loopback). Do NOT await it.
sentPayloads.length = 0
let resolved = false
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ tool_name: 'Bash' }),
}).then(async (r) => {
resolved = true
return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } }
})
// 4. The push must have fired (carrying the capability token) and the hook
// must still be HELD (C1: it did not immediately fall through to {}).
await new Promise<void>((r) => setTimeout(r, 250))
expect(sentPayloads.length).toBe(1)
expect(resolved).toBe(false)
const token = (JSON.parse(sentPayloads[0]!) as { token?: string }).token
expect(typeof token).toBe('string')
// 5. Resolve it from a "remote" device via /hook/decision with the token.
const decRes = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ sessionId, decision: 'allow', token }),
})
expect(decRes.status).toBe(204)
const decision = await permPromise
expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
},
)
})
// ── WS approve mode + ALLOW_AUTO_MODE gate (needs real PTY) ──────────────────────
describe('WS approve with permission mode (SEC-M5)', () => {
async function approveWithMode(
autoAllowed: boolean,
mode: string,
): Promise<{ behavior?: string; mode?: string }> {
const { port, origin } = await spawnServer({ ALLOW_AUTO_MODE: autoAllowed ? '1' : '0' })
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = await waitForMessage(ws, isAttached)
const sessionId = attached['sessionId'] as string
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ tool_name: 'ExitPlanMode' }),
})
await waitForMessage(ws, (m) => m['pending'] === true)
ws.send(JSON.stringify({ type: 'approve', mode }))
const decision = (await (await permPromise).json()) as {
hookSpecificOutput?: { decision?: { behavior?: string; mode?: string } }
}
ws.close()
return decision.hookSpecificOutput?.decision ?? {}
}
itPty('keeps mode:auto when ALLOW_AUTO_MODE=1', async () => {
const d = await approveWithMode(true, 'auto')
expect(d.behavior).toBe('allow')
expect(d.mode).toBe('auto')
})
itPty('downgrades mode:auto → default when ALLOW_AUTO_MODE=0', async () => {
const d = await approveWithMode(false, 'auto')
expect(d.behavior).toBe('allow')
expect(d.mode).toBe('default')
})
itPty('passes through mode:acceptEdits unchanged', async () => {
const d = await approveWithMode(false, 'acceptEdits')
expect(d.behavior).toBe('allow')
expect(d.mode).toBe('acceptEdits')
})
})

View File

@@ -0,0 +1,217 @@
/**
* T-server-wire — integration tests for the A4 timeline, B2 statusLine ingest,
* and the GET /config/ui route.
*
* Covers (against a real startServer):
* - GET /live-sessions/:id/events → 404 unknown id; [] when TIMELINE_ENABLED=0;
* real timeline entries after a /hook POST
* - POST /hook/status (loopback) → 400 on garbage; 204 + telemetry broadcast
* - GET /config/ui → mirrors cfg.allowAutoMode
*/
import net from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
const PTY_AVAILABLE = (() => {
try {
const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
p.kill()
return true
} catch {
return false
}
})()
const itPty = PTY_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('open timeout')), 3000)
ws.once('open', () => {
clearTimeout(t)
resolve()
})
ws.once('error', (e) => {
clearTimeout(t)
reject(e)
})
})
}
function waitForMessage(ws: WebSocket, pred: (m: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
ws.off('message', onMsg)
reject(new Error('message timeout'))
}, 5000)
const onMsg = (raw: WebSocket.RawData): void => {
let m: Record<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
await new Promise<void>((r) => setTimeout(r, 30))
})
const UNKNOWN = '00000000-0000-4000-8000-000000000000'
// ── GET /live-sessions/:id/events ────────────────────────────────────────────────
describe('GET /live-sessions/:id/events (A4)', () => {
it('returns 404 for an unknown session id when the timeline is enabled', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`)
expect(res.status).toBe(404)
})
it('returns an empty array when TIMELINE_ENABLED=0 (AC-A4.6)', async () => {
const { port } = await spawnServer({ TIMELINE_ENABLED: '0' })
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
itPty('returns derived timeline entries after a /hook POST', async () => {
const { port, origin } = await spawnServer()
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = await waitForMessage(ws, isAttached)
const sessionId = attached['sessionId'] as string
const hookRes = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }),
})
expect(hookRes.status).toBe(204)
const evRes = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/events`)
expect(evRes.status).toBe(200)
const events = (await evRes.json()) as { class: string; label: string; toolName?: string }[]
expect(Array.isArray(events)).toBe(true)
const tool = events.find((e) => e.class === 'tool')
expect(tool).toBeDefined()
expect(tool!.label).toContain('Bash')
ws.close()
})
})
// ── POST /hook/status (B2) ───────────────────────────────────────────────────────
describe('POST /hook/status (B2)', () => {
it('rejects a non-object body with 400', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/hook/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': UNKNOWN },
body: JSON.stringify('not-an-object'),
})
expect(res.status).toBe(400)
})
itPty('ingests telemetry from loopback and broadcasts it to the attached client', async () => {
const { port, origin } = await spawnServer()
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = await waitForMessage(ws, isAttached)
const sessionId = attached['sessionId'] as string
const telemetryPromise = waitForMessage(ws, (m) => m['type'] === 'telemetry')
const res = await fetch(`http://127.0.0.1:${port}/hook/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({
context_window: { used_percentage: 42 },
cost: { total_cost_usd: 1.23 },
model: { display_name: 'opus' },
}),
})
expect(res.status).toBe(204)
const msg = (await telemetryPromise) as { telemetry: Record<string, unknown> }
expect(msg.telemetry['contextUsedPct']).toBe(42)
expect(msg.telemetry['costUsd']).toBe(1.23)
expect(msg.telemetry['model']).toBe('opus')
ws.close()
})
})
// ── GET /config/ui (review #4) ────────────────────────────────────────────────────
describe('GET /config/ui', () => {
it('reports allowAutoMode:false by default', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ allowAutoMode: false })
})
it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => {
const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' })
const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
expect(await res.json()).toEqual({ allowAutoMode: true })
})
})

View File

@@ -0,0 +1,203 @@
/**
* T-server-wire — integration tests for the B3 worktree-create and B1 diff routes.
*
* Covers (against a real startServer):
* - POST /projects/worktree → Origin guard (403), WORKTREE_ENABLED=0 (403),
* invalid branch (400), missing fields (400),
* real creation in a temp git repo (git-gated)
* - GET /projects/diff → 400 missing path, 404 non-git path, structured
* DiffResult for a real repo with verbatim content
*/
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { DiffResult } from '../../src/types.js'
const GIT_AVAILABLE = (() => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' })
return true
} catch {
return false
}
})()
const itGit = GIT_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
const tmpDirs: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
/** Create a temp git repo with one committed file. Returns its absolute path. */
async function makeRealRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wt-'))
tmpDirs.push(dir)
const git = (args: string[]): void => {
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
}
git(['init'])
git(['config', 'user.email', 'test@localhost'])
git(['config', 'user.name', 'Test'])
git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8')
git(['add', '.'])
git(['commit', '-m', 'init'])
return dir
}
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
await new Promise<void>((r) => setTimeout(r, 30))
})
// ── POST /projects/worktree ──────────────────────────────────────────────────────
describe('POST /projects/worktree (B3)', () => {
it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
})
expect(res.status).toBe(403)
})
it('rejects a missing Origin with 403', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
})
expect(res.status).toBe(403)
})
it('returns 403 when WORKTREE_ENABLED=0', async () => {
const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
})
expect(res.status).toBe(403)
})
it('rejects an invalid branch name with 400 (no git executed, SEC-H2)', async () => {
const { port, origin } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ path: '/tmp/x', branch: '../evil' }),
})
expect(res.status).toBe(400)
})
it('rejects a missing branch field with 400', async () => {
const { port, origin } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ path: '/tmp/x' }),
})
expect(res.status).toBe(400)
})
itGit('creates a worktree in a real git repo and returns its path', async () => {
const repo = await makeRealRepo()
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wtroot-'))
tmpDirs.push(root)
const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin },
body: JSON.stringify({ path: repo, branch: 'feature-x' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; path?: string; branch?: string }
expect(body.ok).toBe(true)
expect(body.branch).toBe('feature-x')
expect(typeof body.path).toBe('string')
// The created dir actually exists and is a worktree.
const list = execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()
expect(list).toContain(body.path!)
})
})
// ── GET /projects/diff ───────────────────────────────────────────────────────────
describe('GET /projects/diff (B1)', () => {
it('returns 400 when the path query parameter is missing', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory (SEC-H7)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
tmpDirs.push(dir)
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(dir)}`)
expect(res.status).toBe(404)
})
itGit('returns a structured DiffResult with verbatim content (AC-B1.4)', async () => {
const repo = await makeRealRepo()
// Introduce a tracked change containing a would-be XSS payload.
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\n<script>x</script>\n', 'utf8')
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}`)
expect(res.status).toBe(200)
const diff = (await res.json()) as DiffResult
expect(diff.staged).toBe(false)
expect(diff.files.length).toBeGreaterThan(0)
const flat = JSON.stringify(diff)
// The diff carries the raw text verbatim (the FE renders it inert via textContent).
expect(flat).toContain('<script>x</script>')
})
})

View File

@@ -13,7 +13,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Config, Dims, Session, WebSocketLike } from '../src/types.js';
import type {
Config,
Dims,
NotifyService,
Session,
StatusTelemetry,
WebSocketLike,
} from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
@@ -53,10 +60,53 @@ const CFG: Config = {
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
// v0.6 project manager
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
// v0.7 Walk-away Workbench
vapidPublicKey: undefined,
vapidPrivateKey: undefined,
vapidSubject: undefined,
pushStorePath: '/home/tester/.web-terminal-push-subs.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
defaultPermissionMode: 'default',
allowAutoMode: false,
};
const DIMS: Dims = { cols: 80, rows: 24 };
/** A telemetry sample for B2 tests. */
function makeTelemetry(at = 1_000): StatusTelemetry {
return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at };
}
/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */
function createMockNotify() {
const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {});
const service: NotifyService = {
isEnabled: () => true,
notify,
};
return { service, notify };
}
interface MockWs extends WebSocketLike {
readyState: number;
sent: string[];
@@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
expect(session.exitedAt).not.toBeNull();
});
});
// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ───────────────────────
describe('handleHookEvent — timeline append (A4)', () => {
it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(1);
expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' });
expect(typeof s.timeline[0]?.at).toBe('number');
});
it('replaces the timeline array immutably (never mutates the previous one)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const before = s.timeline;
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).not.toBe(before);
expect(before).toHaveLength(0);
});
it('does NOT append when timeline is disabled', () => {
const mgr = createSessionManager({ ...CFG, timelineEnabled: false });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(0);
});
it('does NOT append when eventClass is omitted', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
expect(s.timeline).toHaveLength(0);
});
it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X');
expect(s.timeline).toHaveLength(0);
});
it('evicts oldest events past cfg.timelineMax', () => {
const mgr = createSessionManager({ ...CFG, timelineMax: 3 });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
for (let i = 0; i < 5; i += 1) {
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`);
}
expect(s.timeline).toHaveLength(3);
expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']);
});
});
describe('handleHookEvent — gate broadcast (B4)', () => {
it('broadcasts the status frame with gate when supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({
type: 'status',
status: 'waiting',
detail: 'ExitPlanMode',
pending: true,
gate: 'plan',
});
});
it('omits gate when not supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).not.toHaveProperty('gate');
});
});
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
describe('handleStatusLine', () => {
it('stores telemetry on the session and broadcasts it to all clients', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
a.sent.length = 0;
b.sent.length = 0;
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
expect(s.telemetry).toBe(telemetry);
for (const ws of [a, b]) {
const msg = parseSent(ws).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } });
}
});
it('is a no-op for an unknown session id', () => {
const mgr = createSessionManager(CFG);
expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow();
});
});
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
describe('handleAttach — late-join replay (M3)', () => {
it('sends the current telemetry to a device joining a live session', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, makeTelemetry());
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const msg = parseSent(b).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } });
});
it('sends the current status to a late-joining device', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working');
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const status = parseSent(b).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'working' });
});
it('does NOT send a telemetry frame when the session has no telemetry yet', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false);
});
});
// ── sweepStuck (A5) ───────────────────────────────────────────────────────────
describe('sweepStuck (A5)', () => {
it('marks a silent live session stuck, broadcasts, and notifies once', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000
ws.sent.length = 0;
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('stuck');
expect(s.stuckNotified).toBe(true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'stuck' });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(s, 'stuck');
});
it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
// still attached: clients.size === 1, detachedAt === null
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.detachedAt).toBeNull();
expect(s.claudeStatus).toBe('stuck');
expect(notify).toHaveBeenCalledTimes(1);
});
it('alerts at most once per silent round (AC-A5.1)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999);
expect(notify).toHaveBeenCalledTimes(1);
expect(s.claudeStatus).toBe('stuck');
});
it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(1);
// Output recovery (session.ts onData) re-arms the flag + status.
(s.pty as MockIPty).emitData('back to work');
expect(s.stuckNotified).toBe(false);
expect(s.claudeStatus).toBe('working');
s.lastOutputAt = 100_000; // pin to a known cursor
mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(2);
expect(s.claudeStatus).toBe('stuck');
});
it('skips idle sessions', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.claudeStatus = 'idle';
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('idle');
expect(notify).not.toHaveBeenCalled();
});
it('skips already-exited sessions (kept in table after detach-then-exit)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.clients.clear();
s.detachedAt = 2_000;
(s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1)
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('does not mark a session whose output is within the TTL window', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckAlert is false', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckTtlMs is 0', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(99_999_999);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('swallows a notifyService rejection (logs via console.error, never throws)', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const notify = vi.fn(async () => {
throw new Error('push transport down');
});
const service: NotifyService = { isEnabled: () => true, notify };
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
await Promise.resolve();
await Promise.resolve();
expect(s.claudeStatus).toBe('stuck');
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('works without an injected notifyService (broadcasts, no throw)', () => {
const mgr = createSessionManager(CFG); // no notifyService
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
expect(s.claudeStatus).toBe('stuck');
expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true);
});
});
// ── list() carries telemetry (B2 thumbnail wall) ──────────────────────────────
describe('list — telemetry field', () => {
it('includes the latest telemetry for each session', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBe(telemetry);
});
it('reports null telemetry before the first statusLine report', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBeNull();
});
});

508
test/push.test.ts Normal file
View File

@@ -0,0 +1,508 @@
// @vitest-environment jsdom
/**
* test/push.test.ts — N-push-ui: push subscribe/permission UI
*
* Covers: PushSupportStatus states, fetchVapidKey, checkPushSupport,
* subscribePush, unsubscribePush, mountPushToggle, isPushMuted/setPushMuted.
*
* SEC-H8: isSecureContext gating.
* AC-A1.1, AC-A1.5: all support states; insecure context hidden/no crash; ≥80%.
* Review #12: localStorage mute is in-app-only (not server DND).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ── Browser API helpers ───────────────────────────────────────────────────────
function setSecureContext(secure: boolean): void {
Object.defineProperty(window, 'isSecureContext', {
value: secure,
writable: true,
configurable: true,
})
}
interface MockPushSubscription {
endpoint: string
unsubscribe: ReturnType<typeof vi.fn>
toJSON: ReturnType<typeof vi.fn>
getKey: ReturnType<typeof vi.fn>
}
function makeMockSubscription(endpoint = 'https://push.example.com/sub'): MockPushSubscription {
return {
endpoint,
unsubscribe: vi.fn().mockResolvedValue(true),
toJSON: vi.fn().mockReturnValue({ endpoint, keys: { p256dh: 'pub', auth: 'auth' } }),
getKey: vi.fn().mockReturnValue(null),
}
}
function makeMockRegistration(subscription: MockPushSubscription | null = null) {
return {
pushManager: {
getSubscription: vi.fn().mockResolvedValue(subscription),
subscribe: vi.fn().mockResolvedValue(makeMockSubscription()),
},
}
}
function setServiceWorker(registration: ReturnType<typeof makeMockRegistration> | null = null) {
Object.defineProperty(navigator, 'serviceWorker', {
value: {
getRegistration: vi.fn().mockResolvedValue(registration),
},
writable: true,
configurable: true,
})
}
function setNotification(permission: 'default' | 'granted' | 'denied') {
vi.stubGlobal('Notification', {
permission,
requestPermission: vi.fn().mockResolvedValue(permission),
})
}
function removeNotification() {
// Simulate browsers without Notification API
Object.defineProperty(window, 'Notification', {
value: undefined,
writable: true,
configurable: true,
})
}
function setPushManagerPresent(present: boolean) {
Object.defineProperty(window, 'PushManager', {
value: present ? class PushManager {} : undefined,
writable: true,
configurable: true,
})
}
function mockFetch(body: unknown, status = 200) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
}),
)
}
function mockFetchError() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
}
// ── Setup: default "all supported" environment ────────────────────────────────
beforeEach(() => {
vi.restoreAllMocks()
setSecureContext(true)
setPushManagerPresent(true)
setNotification('default')
setServiceWorker(makeMockRegistration(null))
mockFetch({ publicKey: 'dGVzdA' /* base64 "test" */ })
// Clear localStorage
try {
localStorage.clear()
} catch {
// ignore
}
})
afterEach(() => {
vi.restoreAllMocks()
})
// ── Dynamic import (after stubs are set up) ──────────────────────────────────
// We import at module level so vitest transpiles the TS file.
// The module reads globals at call-time, not at import time, so this is safe.
import {
fetchVapidKey,
checkPushSupport,
subscribePush,
unsubscribePush,
mountPushToggle,
isPushMuted,
setPushMuted,
} from '../public/push.js'
// ─────────────────────────── isPushMuted / setPushMuted ──────────────────────
describe('isPushMuted / setPushMuted (A1-FR9 in-app-only)', () => {
it('defaults to false when nothing is stored', () => {
expect(isPushMuted()).toBe(false)
})
it('returns true after setPushMuted(true)', () => {
setPushMuted(true)
expect(isPushMuted()).toBe(true)
})
it('returns false after setPushMuted(false) clears the value', () => {
setPushMuted(true)
setPushMuted(false)
expect(isPushMuted()).toBe(false)
})
})
// ─────────────────────────── fetchVapidKey ───────────────────────────────────
describe('fetchVapidKey', () => {
it('returns the publicKey string on a 200 response', async () => {
mockFetch({ publicKey: 'my-vapid-public-key' })
const key = await fetchVapidKey()
expect(key).toBe('my-vapid-public-key')
})
it('returns null when server returns 503 (push disabled)', async () => {
mockFetch({}, 503)
const key = await fetchVapidKey()
expect(key).toBeNull()
})
it('returns null on network error', async () => {
mockFetchError()
const key = await fetchVapidKey()
expect(key).toBeNull()
})
it('returns null when response body is missing publicKey', async () => {
mockFetch({ wrong: 'shape' })
const key = await fetchVapidKey()
expect(key).toBeNull()
})
it('returns null when publicKey is not a string', async () => {
mockFetch({ publicKey: 123 })
const key = await fetchVapidKey()
expect(key).toBeNull()
})
})
// ─────────────────────────── checkPushSupport ────────────────────────────────
describe('checkPushSupport — insecure-context (SEC-H8)', () => {
it("returns 'insecure-context' when window.isSecureContext is false", async () => {
setSecureContext(false)
const status = await checkPushSupport('some-vapid-key')
expect(status).toBe('insecure-context')
})
})
describe('checkPushSupport — unsupported', () => {
it("returns 'unsupported' when serviceWorker is absent", async () => {
Object.defineProperty(navigator, 'serviceWorker', {
value: undefined,
writable: true,
configurable: true,
})
const status = await checkPushSupport('some-key')
expect(status).toBe('unsupported')
})
it("returns 'unsupported' when PushManager is absent", async () => {
setPushManagerPresent(false)
const status = await checkPushSupport('some-key')
expect(status).toBe('unsupported')
})
it("returns 'unsupported' when Notification is absent", async () => {
removeNotification()
const status = await checkPushSupport('some-key')
expect(status).toBe('unsupported')
})
})
describe('checkPushSupport — vapid-missing', () => {
it("returns 'vapid-missing' when vapidKey is null", async () => {
const status = await checkPushSupport(null)
expect(status).toBe('vapid-missing')
})
})
describe('checkPushSupport — permission-denied', () => {
it("returns 'permission-denied' when Notification.permission is 'denied'", async () => {
setNotification('denied')
const status = await checkPushSupport('some-key')
expect(status).toBe('permission-denied')
})
})
describe('checkPushSupport — subscribed', () => {
it("returns 'subscribed' when SW registration has an active subscription", async () => {
setServiceWorker(makeMockRegistration(makeMockSubscription()))
const status = await checkPushSupport('some-key')
expect(status).toBe('subscribed')
})
})
describe('checkPushSupport — available', () => {
it("returns 'available' when all checks pass and no subscription exists", async () => {
// Default: secure, supported, no subscription, permission default
const status = await checkPushSupport('some-key')
expect(status).toBe('available')
})
it("returns 'available' when permission is 'granted' but no subscription yet", async () => {
setNotification('granted')
const status = await checkPushSupport('some-key')
expect(status).toBe('available')
})
it("returns 'available' when SW registration returns null", async () => {
setServiceWorker(null)
const status = await checkPushSupport('some-key')
expect(status).toBe('available')
})
it("returns 'available' when pushManager.getSubscription throws", async () => {
const reg = makeMockRegistration(null)
reg.pushManager.getSubscription.mockRejectedValue(new Error('SW error'))
setServiceWorker(reg)
const status = await checkPushSupport('some-key')
expect(status).toBe('available')
})
})
describe('checkPushSupport — priority ordering', () => {
it('insecure-context wins over unsupported', async () => {
setSecureContext(false)
setPushManagerPresent(false)
expect(await checkPushSupport('key')).toBe('insecure-context')
})
it('insecure-context wins over vapid-missing', async () => {
setSecureContext(false)
expect(await checkPushSupport(null)).toBe('insecure-context')
})
it('unsupported wins over vapid-missing', async () => {
setPushManagerPresent(false)
expect(await checkPushSupport(null)).toBe('unsupported')
})
})
// ─────────────────────────── subscribePush ───────────────────────────────────
describe('subscribePush', () => {
it('returns null when Notification.requestPermission is denied', async () => {
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('denied'),
})
mockFetch({}, 200)
const result = await subscribePush('some-key')
expect(result).toBeNull()
})
it('returns null when no SW registration is found', async () => {
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('granted'),
})
setServiceWorker(null)
const result = await subscribePush('some-key')
expect(result).toBeNull()
})
it('posts subscription to /push/subscribe and returns it on success', async () => {
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('granted'),
})
const mockSub = makeMockSubscription()
const reg = makeMockRegistration(null)
reg.pushManager.subscribe.mockResolvedValue(mockSub)
setServiceWorker(reg)
mockFetch({}, 201)
const result = await subscribePush('dGVzdA') // base64 "test"
expect(result).toBe(mockSub)
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/push/subscribe',
expect.objectContaining({ method: 'POST' }),
)
})
it('unsubscribes from browser and returns null when server rejects', async () => {
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('granted'),
})
const mockSub = makeMockSubscription()
const reg = makeMockRegistration(null)
reg.pushManager.subscribe.mockResolvedValue(mockSub)
setServiceWorker(reg)
mockFetch({}, 400)
const result = await subscribePush('dGVzdA')
expect(result).toBeNull()
expect(mockSub.unsubscribe).toHaveBeenCalled()
})
it('returns null on exception (e.g. pushManager.subscribe throws)', async () => {
vi.stubGlobal('Notification', {
permission: 'default',
requestPermission: vi.fn().mockResolvedValue('granted'),
})
const reg = makeMockRegistration(null)
reg.pushManager.subscribe.mockRejectedValue(new Error('blocked'))
setServiceWorker(reg)
const result = await subscribePush('dGVzdA')
expect(result).toBeNull()
})
})
// ─────────────────────────── unsubscribePush ─────────────────────────────────
describe('unsubscribePush', () => {
it('calls DELETE /push/subscribe and browser unsubscribe', async () => {
const mockSub = makeMockSubscription()
mockFetch({}, 204)
await unsubscribePush(mockSub as unknown as PushSubscription)
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/push/subscribe',
expect.objectContaining({ method: 'DELETE' }),
)
expect(mockSub.unsubscribe).toHaveBeenCalled()
})
it('still calls browser unsubscribe even when server DELETE fails', async () => {
const mockSub = makeMockSubscription()
mockFetchError()
await unsubscribePush(mockSub as unknown as PushSubscription)
expect(mockSub.unsubscribe).toHaveBeenCalled()
})
it('does not throw when browser unsubscribe throws', async () => {
const mockSub = makeMockSubscription()
mockSub.unsubscribe.mockRejectedValue(new Error('browser error'))
mockFetch({}, 204)
await expect(
unsubscribePush(mockSub as unknown as PushSubscription),
).resolves.toBeUndefined()
})
})
// ─────────────────────────── mountPushToggle ─────────────────────────────────
describe('mountPushToggle — vapid-missing', () => {
it('hides the container when vapid key is missing (503)', async () => {
mockFetch({}, 503) // fetchVapidKey returns null
const container = document.createElement('div')
mountPushToggle(container)
// Wait for async init
await new Promise((r) => setTimeout(r, 0))
expect(container.style.display).toBe('none')
expect(container.children.length).toBe(0)
})
})
describe('mountPushToggle — insecure-context (SEC-H8)', () => {
it('shows grayed bell with HTTPS/Tailscale hint, does not crash', async () => {
setSecureContext(false)
// fetchVapidKey might succeed, but checkPushSupport returns insecure-context
mockFetch({ publicKey: 'some-key' })
const container = document.createElement('div')
mountPushToggle(container)
await new Promise((r) => setTimeout(r, 0))
// Container should NOT be hidden
expect(container.style.display).not.toBe('none')
// Should show a hint about HTTPS
const hint = container.querySelector('.push-hint')
expect(hint).not.toBeNull()
expect(hint?.textContent).toMatch(/https|tailscale/i)
})
})
describe('mountPushToggle — available', () => {
it('renders a functional toggle button', async () => {
mockFetch({ publicKey: 'some-key' })
// No existing subscription
const container = document.createElement('div')
mountPushToggle(container)
await new Promise((r) => setTimeout(r, 0))
const btn = container.querySelector('button.push-toggle-btn')
expect(btn).not.toBeNull()
expect(btn?.getAttribute('aria-pressed')).toBe('false')
})
})
describe('mountPushToggle — subscribed', () => {
it('shows toggle as active (aria-pressed=true) when subscription exists', async () => {
mockFetch({ publicKey: 'some-key' })
setServiceWorker(makeMockRegistration(makeMockSubscription()))
const container = document.createElement('div')
mountPushToggle(container)
await new Promise((r) => setTimeout(r, 0))
const btn = container.querySelector('button.push-toggle-btn')
expect(btn).not.toBeNull()
expect(btn?.getAttribute('aria-pressed')).toBe('true')
})
})
describe('mountPushToggle — permission-denied', () => {
it('shows grayed bell with blocked hint', async () => {
setNotification('denied')
mockFetch({ publicKey: 'some-key' })
const container = document.createElement('div')
mountPushToggle(container)
await new Promise((r) => setTimeout(r, 0))
expect(container.style.display).not.toBe('none')
const hint = container.querySelector('.push-hint')
expect(hint).not.toBeNull()
})
})
describe('mountPushToggle — onChange callback', () => {
it('calls onChange with the detected status', async () => {
mockFetch({}, 503)
const onChange = vi.fn()
const container = document.createElement('div')
mountPushToggle(container, { onChange })
await new Promise((r) => setTimeout(r, 0))
expect(onChange).toHaveBeenCalledWith('vapid-missing')
})
})
describe('mountPushToggle — unsupported browser', () => {
it('shows grayed bell with unsupported hint when PushManager absent', async () => {
setPushManagerPresent(false)
mockFetch({ publicKey: 'some-key' })
const container = document.createElement('div')
mountPushToggle(container)
await new Promise((r) => setTimeout(r, 0))
expect(container.style.display).not.toBe('none')
const hint = container.querySelector('.push-hint')
expect(hint).not.toBeNull()
})
})

View File

@@ -0,0 +1,326 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
Config,
NotifyClass,
PushPayload,
PushSubscriptionRecord,
Session,
} from '../../src/types.js'
import type { SubscriptionStore } from '../../src/push/subscription-store.js'
import {
createPushService,
type PushSendOptions,
type PushSender,
} from '../../src/push/push-service.js'
/* ── fixtures ─────────────────────────────────────────────────────── */
const BASE_CFG: Config = {
port: 3000,
bindHost: '0.0.0.0',
shellPath: '/bin/zsh',
homeDir: '/home/tester',
idleTtlMs: 10_000,
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: [],
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
vapidPublicKey: 'PUBKEY',
vapidPrivateKey: 'PRIVKEY',
vapidSubject: 'mailto:admin@example.com',
pushStorePath: '/tmp/push-subs.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 600_000,
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
defaultPermissionMode: 'default',
allowAutoMode: false,
}
function cfg(overrides: Partial<Config> = {}): Config {
return { ...BASE_CFG, ...overrides }
}
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
function fakeSession(id = SID, cwd: string | null = '/home/tester/my-repo'): Session {
return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session
}
function rec(endpoint: string): PushSubscriptionRecord {
return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 }
}
interface SentCall {
record: PushSubscriptionRecord
payload: PushPayload
options: PushSendOptions
}
/** A fake sender that records calls and can be told to throw per-endpoint. */
function makeSender(failures: Record<string, number> = {}): PushSender & {
sent: SentCall[]
vapid: { subject: string; publicKey: string; privateKey: string } | null
} {
const sent: SentCall[] = []
let vapid: { subject: string; publicKey: string; privateKey: string } | null = null
return {
sent,
get vapid() {
return vapid
},
setVapid(subject, publicKey, privateKey) {
vapid = { subject, publicKey, privateKey }
},
async send(record, payload, options) {
const code = failures[record.endpoint]
if (code) {
const e = new Error('push failed') as Error & { statusCode: number }
e.statusCode = code
throw e
}
sent.push({ record, payload: JSON.parse(payload) as PushPayload, options })
},
}
}
/** A fake store backed by an in-memory array. */
function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & {
pruned: string[][]
persistCount: number
} {
let records = [...initial]
const pruned: string[][] = []
let persistCount = 0
return {
pruned,
get persistCount() {
return persistCount
},
list: () => Object.freeze([...records]),
add(r) {
records = [...records.filter((x) => x.endpoint !== r.endpoint), r]
},
remove(endpoint) {
records = records.filter((x) => x.endpoint !== endpoint)
},
prune(dead) {
pruned.push([...dead])
records = records.filter((x) => !dead.includes(x.endpoint))
},
async persist() {
persistCount++
},
}
}
beforeEach(() => {
vi.restoreAllMocks()
})
/* ── isEnabled truth table ────────────────────────────────────────── */
describe('createPushService — isEnabled', () => {
it('is true only when both VAPID keys are configured', () => {
expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true)
expect(
createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
).toBe(false)
expect(
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
).toBe(false)
expect(
createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), {
sender: makeSender(),
}).isEnabled(),
).toBe(false)
})
it('registers VAPID details with the sender when enabled', () => {
const sender = makeSender()
createPushService(cfg(), makeStore(), { sender })
expect(sender.vapid).toEqual({
subject: 'mailto:admin@example.com',
publicKey: 'PUBKEY',
privateKey: 'PRIVKEY',
})
})
it('does not register VAPID details when disabled', () => {
const sender = makeSender()
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender })
expect(sender.vapid).toBeNull()
})
it('falls back to a default subject when VAPID_SUBJECT is unset', () => {
const sender = makeSender()
createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender })
expect(sender.vapid?.subject).toMatch(/^mailto:/)
})
})
/* ── notify: short-circuits ───────────────────────────────────────── */
describe('notify — short-circuits', () => {
it('is a no-op when push is disabled', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
it('is a no-op when NOTIFY_DND is on', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => {
const sender = makeSender()
const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'done')
expect(sender.sent).toHaveLength(0)
await svc.notify(fakeSession(), 'needs-input', 'tok')
await svc.notify(fakeSession(), 'stuck')
expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck'])
})
it('is a no-op when there are no subscriptions', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(sender.sent).toHaveLength(0)
})
})
/* ── notify: payload shape ────────────────────────────────────────── */
describe('notify — payload (§3.3 PushPayload)', () => {
it('sends a minimal needs-input payload with the capability token to every subscription', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'cap-tok')
expect(sender.sent).toHaveLength(2)
const { payload } = sender.sent[0]!
expect(payload.sessionId).toBe(SID)
expect(payload.cls).toBe('needs-input')
expect(payload.token).toBe('cap-tok')
// minimal: only known keys, no raw output / secrets
expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token'])
expect(payload.detail).toBe('my-repo')
})
it('omits the token for non-needs-input classes even when one is passed', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'done', 'leaked-token')
expect(sender.sent[0]!.payload.token).toBeUndefined()
})
it('omits detail when the session has no cwd', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(SID, null), 'stuck')
expect(sender.sent[0]!.payload.detail).toBeUndefined()
})
})
/* ── notify: web-push options ─────────────────────────────────────── */
describe('notify — send options', () => {
it('uses high urgency for needs-input/stuck and low for done', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
await svc.notify(fakeSession(), 'stuck')
await svc.notify(fakeSession(), 'done')
expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low'])
})
it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => {
const sender = makeSender()
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
const topic = sender.sent[0]!.options.topic!
expect(topic).toBe(SID.replace(/-/g, ''))
expect(topic.length).toBeLessThanOrEqual(32)
expect(topic).toMatch(/^[A-Za-z0-9_-]+$/)
})
})
/* ── notify: pruning dead subscriptions ───────────────────────────── */
describe('notify — pruning', () => {
for (const code of [404, 410] as const) {
it(`prunes + persists a subscription that returns ${code}`, async () => {
const sender = makeSender({ e2: code })
const store = makeStore([rec('e1'), rec('e2'), rec('e3')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(store.pruned).toEqual([['e2']])
expect(store.persistCount).toBe(1)
// the live subscriptions still received the push
expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3'])
})
}
it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
const sender = makeSender({ e1: 500 })
const store = makeStore([rec('e1'), rec('e2')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'done')
expect(store.pruned).toEqual([])
expect(store.persistCount).toBe(0)
expect(err).toHaveBeenCalled()
expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2'])
})
it('does not persist when no subscriptions are dead', async () => {
const sender = makeSender()
const store = makeStore([rec('e1')])
const svc = createPushService(cfg(), store, { sender })
await svc.notify(fakeSession(), 'needs-input', 'tok')
expect(store.persistCount).toBe(0)
})
})
/* ── default sender (web-push) is wired without crashing ──────────── */
describe('createPushService — default sender', () => {
it('constructs with the real web-push sender when none is injected', () => {
// disabled config → setVapidDetails not called, so no real keys needed
const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore())
expect(svc.isEnabled()).toBe(false)
})
})
/* exhaustiveness guard: every NotifyClass is exercised above */
const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck']
void _classes

View File

@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { PushSubscriptionRecord } from '../../src/types.js'
import {
isValidSubscriptionRecord,
loadSubscriptionStore,
} from '../../src/push/subscription-store.js'
const dirs: string[] = []
function tmpFile(name = 'push-subs.json'): string {
const dir = mkdtempSync(join(tmpdir(), 'webterm-subs-'))
dirs.push(dir)
return join(dir, name)
}
function rec(endpoint: string, createdAt = 1000): PushSubscriptionRecord {
return { endpoint, keys: { p256dh: 'pub-' + endpoint, auth: 'auth-' + endpoint }, createdAt }
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('isValidSubscriptionRecord', () => {
it('accepts a well-formed record', () => {
expect(isValidSubscriptionRecord(rec('https://push/1'))).toBe(true)
})
it('rejects non-objects and missing/invalid fields', () => {
expect(isValidSubscriptionRecord(null)).toBe(false)
expect(isValidSubscriptionRecord('x')).toBe(false)
expect(isValidSubscriptionRecord({})).toBe(false)
expect(isValidSubscriptionRecord({ endpoint: '', keys: { p256dh: 'a', auth: 'b' }, createdAt: 1 })).toBe(false)
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a' }, createdAt: 1 })).toBe(false)
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 1, auth: 'b' }, createdAt: 1 })).toBe(false)
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' } })).toBe(false)
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' }, createdAt: NaN })).toBe(false)
})
})
describe('loadSubscriptionStore — loading', () => {
it('starts empty when the file is missing', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
expect(store.list()).toEqual([])
})
it('starts empty (and logs) on malformed JSON', () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
const path = tmpFile()
writeFileSync(path, '{ not valid json')
const store = loadSubscriptionStore(path, 50)
expect(store.list()).toEqual([])
expect(err).toHaveBeenCalled()
})
it('filters out invalid records on load', () => {
const path = tmpFile()
writeFileSync(
path,
JSON.stringify([rec('https://push/ok'), { endpoint: 123 }, { foo: 'bar' }]),
)
const store = loadSubscriptionStore(path, 50)
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/ok'])
})
it('returns empty when the JSON is not an array', () => {
const path = tmpFile()
writeFileSync(path, JSON.stringify({ endpoint: 'x' }))
expect(loadSubscriptionStore(path, 50).list()).toEqual([])
})
})
describe('SubscriptionStore — list immutability', () => {
it('returns a frozen copy that cannot be mutated', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
store.add(rec('https://push/1'))
const snapshot = store.list()
expect(Object.isFrozen(snapshot)).toBe(true)
expect(() => (snapshot as PushSubscriptionRecord[]).push(rec('https://push/2'))).toThrow()
// original store is unaffected
expect(store.list()).toHaveLength(1)
})
})
describe('SubscriptionStore — add', () => {
it('appends valid records', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
store.add(rec('https://push/1'))
store.add(rec('https://push/2'))
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2'])
})
it('throws on an invalid record (fail-fast at the boundary)', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
expect(() => store.add({ endpoint: '' } as unknown as PushSubscriptionRecord)).toThrow()
})
it('deduplicates by endpoint, replacing the prior record', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
store.add(rec('https://push/1', 1000))
store.add({ endpoint: 'https://push/1', keys: { p256dh: 'new', auth: 'new' }, createdAt: 2000 })
const list = store.list()
expect(list).toHaveLength(1)
expect(list[0]?.keys.p256dh).toBe('new')
expect(list[0]?.createdAt).toBe(2000)
})
it('enforces a FIFO cap at maxSubs, evicting the oldest', () => {
const store = loadSubscriptionStore(tmpFile(), 2)
store.add(rec('https://push/1'))
store.add(rec('https://push/2'))
store.add(rec('https://push/3'))
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2', 'https://push/3'])
})
})
describe('SubscriptionStore — remove / prune', () => {
it('removes a record by endpoint and is a no-op for unknown endpoints', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
store.add(rec('https://push/1'))
store.add(rec('https://push/2'))
store.remove('https://push/1')
store.remove('https://push/absent')
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2'])
})
it('prunes a batch of dead endpoints', () => {
const store = loadSubscriptionStore(tmpFile(), 50)
store.add(rec('https://push/1'))
store.add(rec('https://push/2'))
store.add(rec('https://push/3'))
store.prune(['https://push/1', 'https://push/3'])
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2'])
})
})
describe('SubscriptionStore — persist', () => {
it('writes the file with 0600 permissions and round-trips through load', async () => {
const path = tmpFile()
const store = loadSubscriptionStore(path, 50)
store.add(rec('https://push/1', 11))
store.add(rec('https://push/2', 22))
await store.persist()
const mode = statSync(path).mode & 0o777
expect(mode).toBe(0o600)
const onDisk = JSON.parse(readFileSync(path, 'utf8'))
expect(onDisk.map((r: PushSubscriptionRecord) => r.endpoint)).toEqual([
'https://push/1',
'https://push/2',
])
const reloaded = loadSubscriptionStore(path, 50)
expect(reloaded.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2'])
})
it('logs but does not throw when the destination is unwritable', async () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
// a path whose parent directory does not exist
const store = loadSubscriptionStore(join(tmpFile(), 'nope', 'subs.json'), 50)
store.add(rec('https://push/1'))
await expect(store.persist()).resolves.toBeUndefined()
expect(err).toHaveBeenCalled()
})
})

552
test/quick-reply.test.ts Normal file
View File

@@ -0,0 +1,552 @@
// @vitest-environment jsdom
/**
* test/quick-reply.test.ts — N-quickreply: quick-reply chips + saved-prompt palette.
*
* Feature: A3 (Walk-away Workbench)
* Owns: public/quick-reply.ts
*
* Acceptance criteria (AC-A3.x):
* AC-A3.1 Built-in chips present and send correct bytes (including \r).
* AC-A3.2 User palette CRUD is immutable; round-trip persists via localStorage.
* AC-A3.3 Chip click sends via the onSend path (same text + optional \r).
* AC-A3.4 Labels with special characters rendered as inert text (textContent),
* never injected as HTML (SEC-L3).
*
* Coverage target: ≥80% of public/quick-reply.ts
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ── import module under test ──────────────────────────────────────────────────
const mod = await import('../public/quick-reply.js')
const {
BUILT_IN_CHIPS,
addChip,
removeChip,
reorderChip,
updateChip,
loadPalette,
savePalette,
mountQuickReply,
PALETTE_KEY,
} = mod
// ── helpers ───────────────────────────────────────────────────────────────────
function makeChip(over: Partial<import('../public/quick-reply.js').Chip> = {}): import('../public/quick-reply.js').Chip {
return {
id: 'test-id',
text: 'hello',
label: 'hello',
appendEnter: true,
...over,
}
}
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
// ─────────────────────────── BUILT_IN_CHIPS ──────────────────────────────────
describe('BUILT_IN_CHIPS', () => {
it('exports exactly 6 built-in chips', () => {
expect(BUILT_IN_CHIPS).toHaveLength(6)
})
it('contains yes, continue, 1, 2, 3, Esc (in that order)', () => {
const labels = BUILT_IN_CHIPS.map((c) => c.label)
expect(labels).toContain('yes')
expect(labels).toContain('continue')
expect(labels).toContain('1')
expect(labels).toContain('2')
expect(labels).toContain('3')
expect(labels).toContain('Esc')
})
it('yes chip has appendEnter=true and text "yes"', () => {
const yes = BUILT_IN_CHIPS.find((c) => c.label === 'yes')
expect(yes).toBeDefined()
expect(yes!.appendEnter).toBe(true)
expect(yes!.text).toBe('yes')
})
it('continue chip has appendEnter=true and text "continue"', () => {
const cont = BUILT_IN_CHIPS.find((c) => c.label === 'continue')
expect(cont).toBeDefined()
expect(cont!.appendEnter).toBe(true)
expect(cont!.text).toBe('continue')
})
it('1/2/3 chips have appendEnter=true', () => {
for (const label of ['1', '2', '3']) {
const chip = BUILT_IN_CHIPS.find((c) => c.label === label)
expect(chip).toBeDefined()
expect(chip!.appendEnter).toBe(true)
}
})
it('Esc chip has appendEnter=false and text \\x1b', () => {
const esc = BUILT_IN_CHIPS.find((c) => c.label === 'Esc')
expect(esc).toBeDefined()
expect(esc!.appendEnter).toBe(false)
expect(esc!.text).toBe('\x1b')
expect(esc!.text.charCodeAt(0)).toBe(0x1b)
})
it('all built-in chips have unique ids', () => {
const ids = BUILT_IN_CHIPS.map((c) => c.id)
expect(new Set(ids).size).toBe(6)
})
it('is frozen / immutable (modification attempt does not affect original)', () => {
// Reading from a frozen array should work; appending returns new array
const extended = [...BUILT_IN_CHIPS, makeChip({ id: 'extra' })]
expect(extended).toHaveLength(7)
expect(BUILT_IN_CHIPS).toHaveLength(6)
})
})
// ─────────────────────────── addChip ─────────────────────────────────────────
describe('addChip', () => {
it('returns a new array with the chip appended', () => {
const chips = [makeChip({ id: 'a' })]
const newChip = makeChip({ id: 'b' })
const result = addChip(chips, newChip)
expect(result).toHaveLength(2)
expect(result[1]).toBe(newChip)
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' })]
const original = [...chips]
addChip(chips, makeChip({ id: 'b' }))
expect(chips).toHaveLength(original.length)
expect(chips[0]).toBe(original[0])
})
it('works on an empty array', () => {
const chip = makeChip({ id: 'first' })
const result = addChip([], chip)
expect(result).toHaveLength(1)
expect(result[0]).toBe(chip)
})
it('returns a different array reference', () => {
const chips = [makeChip()]
const result = addChip(chips, makeChip({ id: 'x' }))
expect(result).not.toBe(chips)
})
})
// ─────────────────────────── removeChip ──────────────────────────────────────
describe('removeChip', () => {
it('removes the chip with the matching id', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const c = makeChip({ id: 'c' })
const result = removeChip([a, b, c], 'b')
expect(result).toHaveLength(2)
expect(result.find((ch) => ch.id === 'b')).toBeUndefined()
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
removeChip(chips, 'a')
expect(chips).toHaveLength(2)
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'a' })]
expect(removeChip(chips, 'a')).not.toBe(chips)
})
it('no-op when id not found — length unchanged', () => {
const chips = [makeChip({ id: 'a' })]
const result = removeChip(chips, 'nonexistent')
expect(result).toHaveLength(1)
})
it('removes only the matched id when duplicates do not exist', () => {
const a = makeChip({ id: 'a', label: 'A' })
const b = makeChip({ id: 'b', label: 'B' })
const result = removeChip([a, b], 'a')
expect(result).toHaveLength(1)
expect(result[0]!.label).toBe('B')
})
})
// ─────────────────────────── reorderChip ─────────────────────────────────────
describe('reorderChip', () => {
it('moves a chip from fromIndex to toIndex', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const c = makeChip({ id: 'c' })
const result = reorderChip([a, b, c], 0, 2)
expect(result.map((ch) => ch.id)).toEqual(['b', 'c', 'a'])
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
reorderChip(chips, 0, 1)
expect(chips[0]!.id).toBe('a')
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
expect(reorderChip(chips, 0, 1)).not.toBe(chips)
})
it('moves to the same position (no-op reorder)', () => {
const a = makeChip({ id: 'a' })
const b = makeChip({ id: 'b' })
const result = reorderChip([a, b], 0, 0)
expect(result.map((ch) => ch.id)).toEqual(['a', 'b'])
})
it('handles out-of-bounds fromIndex gracefully', () => {
const chips = [makeChip({ id: 'a' })]
const result = reorderChip(chips, 99, 0)
expect(result).toHaveLength(1)
})
it('handles out-of-bounds toIndex gracefully', () => {
const chips = [makeChip({ id: 'a' })]
const result = reorderChip(chips, 0, 99)
expect(result).toHaveLength(1)
})
})
// ─────────────────────────── updateChip ──────────────────────────────────────
describe('updateChip', () => {
it('updates the chip with matching id (partial patch)', () => {
const chip = makeChip({ id: 'x', label: 'before' })
const result = updateChip([chip], 'x', { label: 'after' })
expect(result[0]!.label).toBe('after')
expect(result[0]!.id).toBe('x') // id preserved
})
it('does NOT mutate the original chip object', () => {
const chip = makeChip({ id: 'x', label: 'original' })
updateChip([chip], 'x', { label: 'changed' })
expect(chip.label).toBe('original')
})
it('does NOT mutate the original array', () => {
const chips = [makeChip({ id: 'x' })]
updateChip(chips, 'x', { label: 'new' })
expect(chips).toHaveLength(1)
})
it('returns a different array reference', () => {
const chips = [makeChip({ id: 'x' })]
expect(updateChip(chips, 'x', {})).not.toBe(chips)
})
it('leaves non-matching chips unchanged', () => {
const a = makeChip({ id: 'a', label: 'A' })
const b = makeChip({ id: 'b', label: 'B' })
const result = updateChip([a, b], 'a', { label: 'A2' })
expect(result[1]!.label).toBe('B')
expect(result[1]).toBe(b)
})
it('no-op when id not found', () => {
const chip = makeChip({ id: 'x', label: 'X' })
const result = updateChip([chip], 'nonexistent', { label: 'changed' })
expect(result[0]!.label).toBe('X')
})
it('can update appendEnter', () => {
const chip = makeChip({ id: 'x', appendEnter: true })
const result = updateChip([chip], 'x', { appendEnter: false })
expect(result[0]!.appendEnter).toBe(false)
})
})
// ─────────────────────────── loadPalette ─────────────────────────────────────
describe('loadPalette', () => {
it('returns empty array when localStorage has no key', () => {
expect(loadPalette()).toEqual([])
})
it('returns the stored chips on valid JSON', () => {
const chips = [makeChip({ id: 'p1', text: 'run tests', label: 'run tests', appendEnter: true })]
localStorage.setItem(PALETTE_KEY, JSON.stringify(chips))
const result = loadPalette()
expect(result).toHaveLength(1)
expect(result[0]!.id).toBe('p1')
expect(result[0]!.text).toBe('run tests')
})
it('does NOT throw on bad JSON', () => {
localStorage.setItem(PALETTE_KEY, 'not-valid-json{{{')
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
it('does NOT throw when localStorage contains a non-array JSON value', () => {
localStorage.setItem(PALETTE_KEY, JSON.stringify({ foo: 'bar' }))
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
it('returns empty array when stored data has items with missing fields', () => {
const bad = [{ id: 'x' }] // missing text/label/appendEnter
localStorage.setItem(PALETTE_KEY, JSON.stringify(bad))
expect(() => loadPalette()).not.toThrow()
// Either returns [] or filters invalid items
const result = loadPalette()
expect(Array.isArray(result)).toBe(true)
const valid = result.filter((c) => typeof c.id === 'string' && typeof c.text === 'string')
expect(valid.length).toBe(0)
})
it('returns empty array when stored data is null JSON', () => {
localStorage.setItem(PALETTE_KEY, 'null')
expect(loadPalette()).toEqual([])
})
it('does NOT throw when localStorage throws (simulated private mode)', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage blocked in private mode')
})
expect(() => loadPalette()).not.toThrow()
expect(loadPalette()).toEqual([])
})
})
// ─────────────────────────── savePalette ─────────────────────────────────────
describe('savePalette', () => {
it('saves chips to localStorage', () => {
const chips = [makeChip({ id: 'saved' })]
savePalette(chips)
const raw = localStorage.getItem(PALETTE_KEY)
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw!)
expect(parsed).toHaveLength(1)
expect(parsed[0].id).toBe('saved')
})
it('saves an empty array without throwing', () => {
expect(() => savePalette([])).not.toThrow()
const raw = localStorage.getItem(PALETTE_KEY)
expect(JSON.parse(raw!)).toEqual([])
})
it('does NOT throw when localStorage is unavailable', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Storage quota exceeded')
})
expect(() => savePalette([makeChip()])).not.toThrow()
})
})
// ─────────────────────────── round-trip persistence ──────────────────────────
describe('savePalette + loadPalette — round-trip', () => {
it('persists and restores a palette with multiple chips', () => {
const chips = [
makeChip({ id: 'a', text: 'run tests', label: 'run tests', appendEnter: true }),
makeChip({ id: 'b', text: '/clear', label: '/clear', appendEnter: false }),
]
savePalette(chips)
const loaded = loadPalette()
expect(loaded).toHaveLength(2)
expect(loaded[0]!.text).toBe('run tests')
expect(loaded[1]!.text).toBe('/clear')
expect(loaded[1]!.appendEnter).toBe(false)
})
it('persists appendEnter=false correctly', () => {
savePalette([makeChip({ id: 'x', appendEnter: false })])
const loaded = loadPalette()
expect(loaded[0]!.appendEnter).toBe(false)
})
})
// ─────────────────────────── mountQuickReply ─────────────────────────────────
describe('mountQuickReply — DOM', () => {
it('renders built-in chip buttons inside the container', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const buttons = container.querySelectorAll('button.qr-chip')
// All 6 built-in chips should be rendered
expect(buttons.length).toBeGreaterThanOrEqual(6)
handle.dispose()
})
it('clicking a built-in chip with appendEnter=true sends text + \\r', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
// Find the 'yes' chip button
const yesBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'yes',
)
expect(yesBtn).toBeDefined()
;(yesBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('yes\r')
handle.dispose()
})
it('clicking the Esc chip (appendEnter=false) sends \\x1b without \\r', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const escBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'Esc',
)
expect(escBtn).toBeDefined()
;(escBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('\x1b')
expect(onSend).not.toHaveBeenCalledWith('\x1b\r')
handle.dispose()
})
it('user palette chips are also rendered', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([makeChip({ id: 'u1', label: 'run tests', text: 'run tests', appendEnter: true })]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const buttons = Array.from(container.querySelectorAll('button.qr-chip'))
const found = buttons.find((b) => b.textContent === 'run tests')
expect(found).toBeDefined()
handle.dispose()
})
it('user palette chip click sends correct text', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([makeChip({ id: 'u1', label: 'commit', text: 'git commit -m', appendEnter: false })]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const commitBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'commit',
)
;(commitBtn as HTMLButtonElement).click()
expect(onSend).toHaveBeenCalledWith('git commit -m')
handle.dispose()
})
it('chip labels with <script> appear as plain text — not injected as HTML (AC-A3.4 / SEC-L3)', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([
makeChip({ id: 'xss', label: '<script>alert(1)</script>', text: 'safe', appendEnter: false }),
]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
// No <script> element should be injected into the DOM
expect(container.querySelectorAll('script').length).toBe(0)
// The label text should appear as inert text
const xssBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === '<script>alert(1)</script>',
)
expect(xssBtn).toBeDefined()
handle.dispose()
})
it('contains a + button to add a new snippet', () => {
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
const addBtn = container.querySelector('.qr-add-btn')
expect(addBtn).not.toBeNull()
handle.dispose()
})
it('dispose removes chip buttons from the container', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
expect(container.children.length).toBeGreaterThan(0)
handle.dispose()
expect(container.children.length).toBe(0)
})
it('inline editor appears when + is clicked', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
expect(container.querySelector('.qr-editor')).not.toBeNull()
handle.dispose()
})
it('inline editor cancel button removes the editor', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
const cancelBtn = container.querySelector('.qr-editor-cancel') as HTMLButtonElement
cancelBtn.click()
expect(container.querySelector('.qr-editor')).toBeNull()
handle.dispose()
})
it('saving a new chip from editor persists it to localStorage and re-renders', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
textInput.value = 'write tests'
const labelInput = container.querySelector('.qr-editor-label') as HTMLInputElement
labelInput.value = 'write tests'
const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
saveBtn.click()
// Editor should be gone
expect(container.querySelector('.qr-editor')).toBeNull()
// New chip should be in localStorage
const stored = loadPalette()
expect(stored.some((c) => c.text === 'write tests')).toBe(true)
// New chip button should appear
const newBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === 'write tests',
)
expect(newBtn).toBeDefined()
handle.dispose()
})
it('editor save with empty text is a no-op', () => {
const container = document.createElement('div')
const handle = mountQuickReply(container, { onSend: vi.fn() })
const countBefore = loadPalette().length
const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
addBtn.click()
// Leave text empty
const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
saveBtn.click()
// Editor stays open (no text to save)
const countAfter = loadPalette().length
expect(countAfter).toBe(countBefore)
handle.dispose()
})
})

View File

@@ -53,6 +53,12 @@ const CFG: Config = {
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
// v0.6 project manager fields
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
};
const DIMS: Dims = { cols: 80, rows: 24 };
@@ -407,3 +413,129 @@ describe('kill', () => {
expect((s.pty as MockIPty).killed).toBe(true);
});
});
// ── spawn env (T-spawn-env / B2) ──────────────────────────────────────────────
describe('spawn env', () => {
it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => {
newSession();
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toBe(
`http://127.0.0.1:${CFG.port}/hook/status`,
);
});
it('still injects WEBTERM_SESSION and WEBTERM_HOOK_URL (existing env vars)', () => {
const s = newSession();
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_SESSION']).toBe(s.meta.id);
expect(opts.env?.['WEBTERM_HOOK_URL']).toBe(`http://127.0.0.1:${CFG.port}/hook`);
});
it('uses port from cfg (not hardcoded) in all URL env vars', () => {
nextPty = createMockPty();
const altCfg: Config = { ...CFG, port: 4321 };
createSession(altCfg, DIMS, 1_000, () => {});
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toContain('4321');
expect(opts.env?.['WEBTERM_HOOK_URL']).toContain('4321');
});
});
// ── session initialization — v0.7 fields (T-spawn-env) ───────────────────────
describe('session v0.7 field initialization', () => {
it('initializes timeline as an empty frozen array', () => {
const s = newSession();
expect(s.timeline).toEqual([]);
expect(Object.isFrozen(s.timeline)).toBe(true);
});
it('initializes stuckNotified as false', () => {
const s = newSession();
expect(s.stuckNotified).toBe(false);
});
it('initializes telemetry as null', () => {
const s = newSession();
expect(s.telemetry).toBeNull();
});
});
// ── onData stuck re-arming (A5 / §3.4) ───────────────────────────────────────
describe('onData stuck re-arming', () => {
it('resets stuckNotified to false on any output', () => {
const s = newSession();
s.stuckNotified = true; // simulate a stuck alert having fired this round
(s.pty as MockIPty).emitData('new output');
expect(s.stuckNotified).toBe(false);
});
it('resets claudeStatus from stuck→working and broadcasts when output arrives', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'stuck';
(s.pty as MockIPty).emitData('recovered output');
expect(s.claudeStatus).toBe('working');
const msgs = received(ws);
const statusMsg = msgs.find((m) => m.type === 'status');
expect(statusMsg).toEqual({ type: 'status', status: 'working' });
});
it('does NOT emit a status message when claudeStatus is not stuck', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'working'; // not stuck
(s.pty as MockIPty).emitData('normal chunk');
const msgs = received(ws);
const statusMsgs = msgs.filter((m) => m.type === 'status');
expect(statusMsgs).toHaveLength(0);
});
it('does NOT emit a status message for idle/unknown/waiting (only stuck → working)', () => {
for (const status of ['idle', 'unknown', 'waiting'] as const) {
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, () => {});
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = status;
(s.pty as MockIPty).emitData('chunk');
const msgs = received(ws);
const statusMsgs = msgs.filter((m) => m.type === 'status');
expect(statusMsgs).toHaveLength(0);
}
});
it('broadcasts stuck reset even with no clients attached (no-op, does not throw)', () => {
const s = newSession();
// no clients attached
s.claudeStatus = 'stuck';
expect(() => (s.pty as MockIPty).emitData('silent recovery')).not.toThrow();
expect(s.claudeStatus).toBe('working');
expect(s.stuckNotified).toBe(false);
});
it('output message is still broadcast after stuck re-arm', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'stuck';
(s.pty as MockIPty).emitData('hello after stuck');
const msgs = received(ws);
const outputMsgs = msgs.filter((m) => m.type === 'output');
expect(outputMsgs).toHaveLength(1);
expect(outputMsgs[0]).toEqual({ type: 'output', data: 'hello after stuck' });
});
});

View File

@@ -0,0 +1,349 @@
import { describe, it, expect } from 'vitest';
import {
sanitizeField,
deriveClass,
deriveLabel,
makeTimelineEvent,
appendEvent,
} from '../../src/session/timeline.js';
import type { TimelineEvent } from '../../src/types.js';
/**
* N-timeline tests — src/session/timeline.ts
*
* Covers:
* - sanitizeField: control-char strip + truncation (SEC-H6)
* - deriveClass: hook name → semantic TimelineClass (§3.1)
* - deriveLabel: human-language label per event type
* - makeTimelineEvent: whitelist gate + sanitize + compose
* - appendEvent: immutability + bounded eviction (SEC-M8)
*/
// ── helpers ──────────────────────────────────────────────────────────────────
function makeEvent(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
return {
at: 1000,
class: 'tool',
label: 'ran Bash',
...overrides,
};
}
// ── sanitizeField ─────────────────────────────────────────────────────────────
describe('sanitizeField', () => {
it('returns the string unchanged when it has no control chars and is under max', () => {
expect(sanitizeField('Bash')).toBe('Bash');
});
it('strips all control characters (0x000x1f)', () => {
// NUL, LF, CR, ESC, tab should all be removed
const input = '\x00Bash\x09script\x0a\x0d\x1b[1m';
expect(sanitizeField(input)).toBe('Bashscript[1m');
});
it('strips null bytes embedded in the string', () => {
expect(sanitizeField('he\x00llo')).toBe('hello');
});
it('truncates to the default max of 200 characters', () => {
const long = 'a'.repeat(300);
const result = sanitizeField(long);
expect(result.length).toBe(200);
});
it('truncates to a custom max', () => {
const result = sanitizeField('abcdef', 3);
expect(result).toBe('abc');
});
it('handles an empty string', () => {
expect(sanitizeField('')).toBe('');
});
it('does not truncate strings exactly at the max', () => {
const exact = 'x'.repeat(200);
expect(sanitizeField(exact).length).toBe(200);
});
it('strips control chars before truncating (strip-then-truncate)', () => {
// 10 control chars + 5 regular chars; after strip only 5 remain → under max
const withControls = '\x01'.repeat(10) + 'hello';
expect(sanitizeField(withControls, 8)).toBe('hello');
});
});
// ── deriveClass ───────────────────────────────────────────────────────────────
describe('deriveClass', () => {
it('maps PreToolUse → "tool"', () => {
expect(deriveClass('PreToolUse')).toBe('tool');
});
it('maps PostToolUse → "tool"', () => {
expect(deriveClass('PostToolUse')).toBe('tool');
});
it('maps PermissionRequest → "waiting"', () => {
expect(deriveClass('PermissionRequest')).toBe('waiting');
});
it('maps Notification → "waiting"', () => {
expect(deriveClass('Notification')).toBe('waiting');
});
it('maps Stop → "done"', () => {
expect(deriveClass('Stop')).toBe('done');
});
it('maps SessionEnd → "done"', () => {
expect(deriveClass('SessionEnd')).toBe('done');
});
it('maps UserPromptSubmit → "user"', () => {
expect(deriveClass('UserPromptSubmit')).toBe('user');
});
it('returns "tool" as a safe fallback for unknown event names', () => {
expect(deriveClass('SomeUnknownEvent')).toBe('tool');
});
});
// ── deriveLabel ───────────────────────────────────────────────────────────────
describe('deriveLabel', () => {
describe('PreToolUse', () => {
it('returns a label that includes the tool name when provided', () => {
const label = deriveLabel('PreToolUse', 'Bash');
expect(label).toContain('Bash');
});
it('returns a generic label when toolName is absent', () => {
const label = deriveLabel('PreToolUse', undefined);
expect(label.length).toBeGreaterThan(0);
});
});
describe('PostToolUse', () => {
it('returns a "ran" label for execution tools (Bash)', () => {
const label = deriveLabel('PostToolUse', 'Bash');
expect(label.toLowerCase()).toContain('bash');
});
it('returns an "edit" flavour label for Edit tool', () => {
const label = deriveLabel('PostToolUse', 'Edit');
// Should be something like "edited file" or "ran Edit" — contract just says it's human
expect(label.length).toBeGreaterThan(0);
});
it('returns a generic label when toolName is absent', () => {
const label = deriveLabel('PostToolUse', undefined);
expect(label.length).toBeGreaterThan(0);
});
});
describe('waiting events', () => {
it('returns an approval-flavoured label for PermissionRequest', () => {
const label = deriveLabel('PermissionRequest', undefined);
expect(label.length).toBeGreaterThan(0);
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
});
it('returns an approval-flavoured label for Notification', () => {
const label = deriveLabel('Notification', undefined);
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
});
});
describe('done events', () => {
it('returns a "done" label for Stop', () => {
expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i);
});
it('returns a "done" label for SessionEnd', () => {
expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i);
});
});
describe('user input', () => {
it('returns a user-input label for UserPromptSubmit', () => {
const label = deriveLabel('UserPromptSubmit', undefined);
expect(label.toLowerCase()).toMatch(/user|input|prompt/);
});
});
});
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
describe('makeTimelineEvent', () => {
it('returns null for an unknown event name (whitelist guard)', () => {
expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull();
});
it('returns null for an empty string event name', () => {
expect(makeTimelineEvent('', undefined, 1000)).toBeNull();
});
it('returns a TimelineEvent for PreToolUse', () => {
const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234);
expect(ev).not.toBeNull();
expect(ev!.at).toBe(1234);
expect(ev!.class).toBe('tool');
expect(ev!.toolName).toBe('Bash');
expect(ev!.label.length).toBeGreaterThan(0);
});
it('returns a TimelineEvent for PostToolUse', () => {
const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('tool');
expect(ev!.toolName).toBe('Bash');
});
it('returns a TimelineEvent for PermissionRequest', () => {
const ev = makeTimelineEvent('PermissionRequest', undefined, 5000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('waiting');
expect(ev!.toolName).toBeUndefined();
});
it('returns a TimelineEvent for Stop', () => {
const ev = makeTimelineEvent('Stop', undefined, 7000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('done');
});
it('returns a TimelineEvent for SessionEnd', () => {
const ev = makeTimelineEvent('SessionEnd', undefined, 8000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('done');
});
it('returns a TimelineEvent for UserPromptSubmit', () => {
const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('user');
});
it('returns a TimelineEvent for Notification', () => {
const ev = makeTimelineEvent('Notification', undefined, 2000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('waiting');
});
it('sanitizes control characters in toolName (SEC-H6)', () => {
const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName).toBe('Bashscript');
});
it('truncates toolName to 200 characters (SEC-H6)', () => {
const longName = 'a'.repeat(250);
const ev = makeTimelineEvent('PostToolUse', longName, 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName!.length).toBe(200);
});
it('omits toolName from the result when toolName is undefined', () => {
const ev = makeTimelineEvent('Stop', undefined, 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName).toBeUndefined();
});
});
// ── appendEvent ───────────────────────────────────────────────────────────────
describe('appendEvent', () => {
it('appends to an empty array', () => {
const ev = makeEvent({ at: 100 });
const result = appendEvent([], ev, 10);
expect(result).toHaveLength(1);
expect(result[0]).toBe(ev);
});
it('appends to a non-empty array', () => {
const ev1 = makeEvent({ at: 100 });
const ev2 = makeEvent({ at: 200 });
const result = appendEvent([ev1], ev2, 10);
expect(result).toHaveLength(2);
expect(result[1]).toBe(ev2);
});
it('does not mutate the input array', () => {
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
const ev = makeEvent({ at: 2 });
appendEvent(original, ev, 10);
expect(original).toHaveLength(1);
});
it('returns a new array reference (immutability)', () => {
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
const ev = makeEvent({ at: 2 });
const result = appendEvent(original, ev, 10);
expect(result).not.toBe(original);
});
it('evicts the oldest events when length exceeds maxLen', () => {
const events: readonly TimelineEvent[] = [
makeEvent({ at: 1 }),
makeEvent({ at: 2 }),
makeEvent({ at: 3 }),
];
const ev = makeEvent({ at: 4 });
// maxLen = 3, so appending a 4th must evict the oldest (at=1)
const result = appendEvent(events, ev, 3);
expect(result).toHaveLength(3);
expect(result[0]!.at).toBe(2);
expect(result[2]!.at).toBe(4);
});
it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => {
const MAX = 5;
let events: readonly TimelineEvent[] = [];
for (let i = 0; i < MAX + 10; i++) {
events = appendEvent(events, makeEvent({ at: i }), MAX);
}
expect(events.length).toBeLessThanOrEqual(MAX);
});
it('keeps only the newest events when trimming (oldest are evicted first)', () => {
const MAX = 3;
let events: readonly TimelineEvent[] = [];
for (let i = 0; i < 10; i++) {
events = appendEvent(events, makeEvent({ at: i }), MAX);
}
// Should have the last 3 events: at = 7, 8, 9
expect(events.map((e) => e.at)).toEqual([7, 8, 9]);
});
it('handles maxLen of 1 (only the newest event survives)', () => {
const ev1 = makeEvent({ at: 1 });
const ev2 = makeEvent({ at: 2 });
const result = appendEvent([ev1], ev2, 1);
expect(result).toHaveLength(1);
expect(result[0]!.at).toBe(2);
});
});
// ── integration: each TimelineClass has at least one example ─────────────────
describe('TimelineClass coverage (AC-A4.4)', () => {
const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [
{ event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' },
{ event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' },
{ event: 'PermissionRequest', expectedClass: 'waiting' },
{ event: 'Notification', expectedClass: 'waiting' },
{ event: 'Stop', expectedClass: 'done' },
{ event: 'SessionEnd', expectedClass: 'done' },
{ event: 'UserPromptSubmit', expectedClass: 'user' },
];
for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) {
it(`${event} → class '${expectedClass}'`, () => {
const ev = makeTimelineEvent(event, toolName, Date.now());
expect(ev).not.toBeNull();
expect(ev!.class).toBe(expectedClass);
});
}
});

436
test/setup-hooks.test.ts Normal file
View File

@@ -0,0 +1,436 @@
/**
* test/setup-hooks.test.ts — T-hooks-installer tests
*
* Tests the exported pure functions from scripts/setup-hooks.mjs:
* - computeMaxTimeSec: L5/SEC-M4 --max-time computation
* - buildPermCommand: held-permission curl with dynamic max-time
* - buildNtfyCommand: ntfy bridge curl (SEC-C6: no token literal)
* - buildStatusLineCommand: absolute path to statusline.mjs
* - FF_EVENTS: must include PostToolUse (H1/AC-A4.5)
* - applyHooks: integration — install/remove idempotency, ntfy, statusLine
*
* Acceptance criteria covered:
* AC-A1.7 ntfy command has correct priority; settings.json has NO token literal
* AC-A4.5 FF_EVENTS contains PostToolUse
* AC-B2.1 statusLine segment: present after install, idempotent, --remove cleans
* SEC-C6 token not in settings.json/argv
* SEC-M4 --max-time > permTimeoutMs/1000
* L5 --max-time computed from PERM_TIMEOUT_MS env, not hardcoded
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — vitest handles .mjs; tsc does not check test/ files
import {
computeMaxTimeSec,
buildPermCommand,
buildNtfyCommand,
buildStatusLineCommand,
FF_EVENTS,
FF_COMMAND,
PERM_COMMAND,
MARKER,
STATUSLINE_MARKER,
applyHooks,
} from '../scripts/setup-hooks.mjs'
// Path to the scripts directory (for absolute path assertions)
const SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts')
// ── computeMaxTimeSec ────────────────────────────────────────────────────────
describe('computeMaxTimeSec', () => {
it('adds CURL_BUFFER_SEC (5) on top of ceil(permTimeoutMs/1000)', () => {
// 300000ms = 300s + 5 = 305
expect(computeMaxTimeSec(300_000)).toBe(305)
})
it('handles custom permTimeoutMs correctly', () => {
// 60000ms = 60s + 5 = 65
expect(computeMaxTimeSec(60_000)).toBe(65)
// 90001ms = ceil(90.001) = 91 + 5 = 96
expect(computeMaxTimeSec(90_001)).toBe(96)
})
it('uses default (300000ms) when permTimeoutMs is 0 (invalid)', () => {
expect(computeMaxTimeSec(0)).toBe(305)
})
it('uses default when permTimeoutMs is negative', () => {
expect(computeMaxTimeSec(-1000)).toBe(305)
})
it('uses default when called with no arguments', () => {
expect(computeMaxTimeSec()).toBe(305)
})
it('returns value > permTimeoutMs/1000 (L5/SEC-M4)', () => {
const permTimeoutMs = 180_000
const maxTime = computeMaxTimeSec(permTimeoutMs)
expect(maxTime).toBeGreaterThan(permTimeoutMs / 1000)
})
})
// ── buildPermCommand ─────────────────────────────────────────────────────────
describe('buildPermCommand', () => {
it('includes the provided maxTimeSec in --max-time', () => {
const cmd = buildPermCommand(305)
expect(cmd).toContain('--max-time 305')
})
it('does NOT contain the old hardcoded value 350', () => {
const cmd = buildPermCommand(305)
expect(cmd).not.toContain('350')
})
it('references WEBTERM_HOOK_URL/permission', () => {
const cmd = buildPermCommand(305)
expect(cmd).toContain('WEBTERM_HOOK_URL')
expect(cmd).toContain('/permission')
})
it('sends body via --data-binary @- (reads stdin)', () => {
const cmd = buildPermCommand(305)
expect(cmd).toContain('--data-binary @-')
})
})
// ── buildNtfyCommand ─────────────────────────────────────────────────────────
describe('buildNtfyCommand', () => {
it('high-priority command contains "Priority: high"', () => {
const cmd = buildNtfyCommand('high')
expect(cmd).toContain('Priority: high')
})
it('low-priority command contains "Priority: low"', () => {
const cmd = buildNtfyCommand('low')
expect(cmd).toContain('Priority: low')
})
it('uses shell variable references for URL, TOPIC, and TOKEN (SEC-C6)', () => {
const cmd = buildNtfyCommand('high')
expect(cmd).toContain('WEBTERM_NTFY_URL')
expect(cmd).toContain('WEBTERM_NTFY_TOPIC')
expect(cmd).toContain('WEBTERM_NTFY_TOKEN')
})
it('does NOT contain any literal token value (SEC-C6)', () => {
// The token must be referenced via env var, never as a literal string
const cmd = buildNtfyCommand('high')
// No hardcoded token patterns (Bearer followed by non-$ string)
expect(cmd).not.toMatch(/Bearer [^$]/)
})
it('contains MARKER (WEBTERM_HOOK_URL) for cleanup idempotency', () => {
// The no-op guard references WEBTERM_HOOK_URL, ensuring cleanup logic catches it
const cmd = buildNtfyCommand('high')
expect(cmd).toContain(MARKER) // MARKER = 'WEBTERM_HOOK_URL'
})
it('is a no-op outside web-terminal (guard: [ -n "$WEBTERM_HOOK_URL" ])', () => {
const cmd = buildNtfyCommand('low')
expect(cmd).toContain('WEBTERM_HOOK_URL')
// Command has a conditional that makes it a no-op when WEBTERM_HOOK_URL is absent
expect(cmd).toMatch(/\[ -n .+WEBTERM_HOOK_URL/)
})
it('always ends with || true (fire-and-forget, no hook failure)', () => {
expect(buildNtfyCommand('high')).toContain('|| true')
expect(buildNtfyCommand('low')).toContain('|| true')
})
})
// ── buildStatusLineCommand ────────────────────────────────────────────────────
describe('buildStatusLineCommand', () => {
it('includes the STATUSLINE_MARKER (statusline.mjs)', () => {
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
expect(cmd).toContain(STATUSLINE_MARKER) // 'statusline.mjs'
})
it('uses an absolute path to statusline.mjs', () => {
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
// Command should contain an absolute path
const scriptPath = path.join(SCRIPTS_DIR, 'statusline.mjs')
expect(cmd).toContain(scriptPath)
})
it('starts with "node " (runs the script with Node)', () => {
const cmd = buildStatusLineCommand(SCRIPTS_DIR)
expect(cmd).toMatch(/^node /)
})
})
// ── FF_EVENTS ────────────────────────────────────────────────────────────────
describe('FF_EVENTS', () => {
it('contains PostToolUse (H1/AC-A4.5)', () => {
expect(FF_EVENTS).toContain('PostToolUse')
})
it('contains PreToolUse', () => {
expect(FF_EVENTS).toContain('PreToolUse')
})
it('contains Stop and SessionEnd (DONE signals)', () => {
expect(FF_EVENTS).toContain('Stop')
expect(FF_EVENTS).toContain('SessionEnd')
})
it('contains UserPromptSubmit', () => {
expect(FF_EVENTS).toContain('UserPromptSubmit')
})
it('contains Notification', () => {
expect(FF_EVENTS).toContain('Notification')
})
})
// ── FF_COMMAND / PERM_COMMAND constants ───────────────────────────────────────
describe('FF_COMMAND', () => {
it('contains MARKER for cleanup identification', () => {
expect(FF_COMMAND).toContain(MARKER)
})
it('is a fire-and-forget (redirects to /dev/null, || true)', () => {
expect(FF_COMMAND).toContain('>/dev/null')
expect(FF_COMMAND).toContain('|| true')
})
})
describe('PERM_COMMAND (module-level constant)', () => {
it('does NOT contain the old hardcoded 350', () => {
// L5: --max-time must be computed, not a bare literal 350
expect(PERM_COMMAND).not.toContain(' 350 ')
expect(PERM_COMMAND).not.toMatch(/--max-time 350\b/)
})
it('contains --max-time with a computed numeric value', () => {
expect(PERM_COMMAND).toMatch(/--max-time \d+/)
})
it('computed --max-time > default permTimeoutMs/1000 (SEC-M4)', () => {
const match = PERM_COMMAND.match(/--max-time (\d+)/)
expect(match).toBeTruthy()
const maxTime = Number(match![1])
// Default permTimeoutMs = 300000ms = 300s; max-time must be > 300
expect(maxTime).toBeGreaterThan(300)
})
})
// ── applyHooks (integration tests) ───────────────────────────────────────────
describe('applyHooks', () => {
let tmpDir: string
let settingsPath: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'webterm-setup-hooks-test-'))
settingsPath = path.join(tmpDir, 'settings.json')
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('creates settings.json when it does not exist', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
expect(existsSync(settingsPath)).toBe(true)
})
it('installs hook entry for every FF_EVENT', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
for (const ev of FF_EVENTS) {
expect(settings.hooks[ev], `hook for ${ev} should exist`).toBeDefined()
expect(Array.isArray(settings.hooks[ev])).toBe(true)
expect(settings.hooks[ev].length).toBeGreaterThanOrEqual(1)
}
})
it('installs PostToolUse hook (H1/AC-A4.5)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks['PostToolUse']).toBeDefined()
const groups = settings.hooks['PostToolUse']
expect(groups.some((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
)).toBe(true)
})
it('installs PermissionRequest hook with computed --max-time (L5)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 60_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks['PermissionRequest']).toBeDefined()
const allCommands: string[] = settings.hooks['PermissionRequest']
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.map((h) => h.command ?? '') ?? []
)
const permCmd = allCommands.find((c) => c.includes('/permission'))
expect(permCmd).toBeDefined()
// permTimeoutMs=60000 → ceil(60)+5=65
expect(permCmd).toContain('--max-time 65')
// must NOT contain old hardcoded value
expect(permCmd).not.toContain(' 350 ')
})
it('installs statusLine handler (B2/AC-B2.1)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.statusLine).toBeDefined()
expect(settings.statusLine).toContain(STATUSLINE_MARKER)
expect(settings.statusLine).toContain(path.join(SCRIPTS_DIR, 'statusline.mjs'))
})
it('is idempotent: running twice produces same result (B2/AC-B2.1)', () => {
const opts = { settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }
applyHooks(opts)
const first = JSON.parse(readFileSync(settingsPath, 'utf8'))
applyHooks(opts)
const second = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(second).toEqual(first)
})
it('--remove removes all web-terminal hook entries (AC-B2.1)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
// All hook events added by us should be gone
for (const ev of [...FF_EVENTS, 'PermissionRequest']) {
const groups = settings.hooks?.[ev]
if (groups) {
const hasOurHook = groups.some((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
)
expect(hasOurHook).toBe(false)
}
}
})
it('--remove removes statusLine if it was ours (AC-B2.1)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.statusLine).toBeUndefined()
})
it('preserves user non-web-terminal hooks during install', () => {
const existing = { hooks: { SomeOtherEvent: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }
writeFileSync(settingsPath, JSON.stringify(existing))
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks['SomeOtherEvent']).toBeDefined()
expect(settings.hooks['SomeOtherEvent'][0].hooks[0].command).toBe('echo hi')
})
it('preserves user non-web-terminal hooks during --remove', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settingsAfterInstall = JSON.parse(readFileSync(settingsPath, 'utf8'))
// Simulate user adding their own hook
settingsAfterInstall.hooks['UserOwnEvent'] = [{ hooks: [{ type: 'command', command: 'echo mine' }] }]
writeFileSync(settingsPath, JSON.stringify(settingsAfterInstall))
applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks?.['UserOwnEvent']).toBeDefined()
})
it('preserves user statusLine if not ours', () => {
const existing = { statusLine: 'node my-custom-statusline.js' }
writeFileSync(settingsPath, JSON.stringify(existing))
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
// User's statusLine was NOT overwritten (it doesn't contain STATUSLINE_MARKER)
expect(settings.statusLine).toBe('node my-custom-statusline.js')
})
it('creates a .webterm-bak before writing when settings exists', () => {
writeFileSync(settingsPath, JSON.stringify({ hooks: {} }))
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
expect(existsSync(`${settingsPath}.webterm-bak`)).toBe(true)
})
// ── ntfy bridge tests (H3, SEC-C6) ─────────────────────────────────────────
it('without ntfy: PermissionRequest has exactly 1 hook group', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks['PermissionRequest'].length).toBe(1)
})
it('with ntfy: PermissionRequest has 2 hook groups (main + ntfy high)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
expect(settings.hooks['PermissionRequest'].length).toBe(2)
})
it('with ntfy: PermissionRequest ntfy group has high priority (AC-A1.7)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const allCommands: string[] = settings.hooks['PermissionRequest']
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.map((h) => h.command ?? '') ?? []
)
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
expect(ntfyCmd).toBeDefined()
expect(ntfyCmd).toContain('Priority: high')
})
it('with ntfy: Stop has ntfy group with low priority (AC-A1.7)', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const allCommands: string[] = settings.hooks['Stop']
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.map((h) => h.command ?? '') ?? []
)
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
expect(ntfyCmd).toBeDefined()
expect(ntfyCmd).toContain('Priority: low')
})
it('with ntfy: SessionEnd has ntfy group with low priority', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const allCommands: string[] = settings.hooks['SessionEnd']
.flatMap((g: { hooks?: Array<{ command?: string }> }) =>
g.hooks?.map((h) => h.command ?? '') ?? []
)
const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL'))
expect(ntfyCmd).toBeDefined()
expect(ntfyCmd).toContain('Priority: low')
})
it('with ntfy: settings.json contains NO token literal (SEC-C6/AC-A1.7)', () => {
// Even with ntfy enabled, the token must never appear as a literal
// We simulate having a WEBTERM_NTFY_TOKEN in env at test time; the command
// should reference it as $WEBTERM_NTFY_TOKEN, not the actual value
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const content = readFileSync(settingsPath, 'utf8')
// The token variable is referenced, not expanded — grep for Bearer pattern
// Ensure no "Bearer <actual-token>" pattern (only "Bearer $WEBTERM_NTFY_TOKEN")
expect(content).not.toMatch(/Bearer [^$"\\]/)
// The $ reference must be present
expect(content).toContain('WEBTERM_NTFY_TOKEN')
})
it('with ntfy: --remove removes ntfy hook groups too', () => {
applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
applyHooks({ settingsPath, remove: true, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR })
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
// After remove, no groups with MARKER should remain
for (const ev of Object.keys(settings.hooks ?? {})) {
const groups: Array<{ hooks?: Array<{ command?: string }> }> = settings.hooks[ev] ?? []
const hasOurHook = groups.some((g) =>
g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER))
)
expect(hasOurHook).toBe(false)
}
})
})

262
test/statusline.test.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* test/statusline.test.ts — N-statusline-script tests (B2)
*
* Tests the pure exported functions from scripts/statusline.mjs:
* - parseRaw: parse raw Claude Code statusLine JSON
* - buildSummary: format one-line status string
* - postToServer: POST to /hook/status (mocked fetch)
*
* readStdin is not tested here (it wraps process.stdin which blocks in tests).
* Integration-level acceptance (actual POST to /hook/status) is covered by V-integration.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// vitest/esbuild handles .mjs imports; tsc does not check test/ files
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { parseRaw, buildSummary, postToServer } from '../scripts/statusline.mjs'
// ── parseRaw ──────────────────────────────────────────────────────────────────
describe('parseRaw', () => {
it('returns null for invalid JSON', () => {
expect(parseRaw('not-json')).toBeNull()
expect(parseRaw('')).toBeNull()
expect(parseRaw('{')).toBeNull()
})
it('returns null for non-object JSON values', () => {
expect(parseRaw('null')).toBeNull()
expect(parseRaw('"string"')).toBeNull()
expect(parseRaw('42')).toBeNull()
expect(parseRaw('true')).toBeNull()
})
it('returns null for JSON arrays', () => {
expect(parseRaw('[]')).toBeNull()
expect(parseRaw('[1,2,3]')).toBeNull()
})
it('returns an object (not null) for empty JSON object', () => {
const result = parseRaw('{}')
expect(result).not.toBeNull()
expect(result).toEqual({ contextUsedPct: undefined, costUsd: undefined, model: undefined })
})
it('extracts contextUsedPct from context_window.used_percentage', () => {
const raw = JSON.stringify({ context_window: { used_percentage: 45.7 } })
const result = parseRaw(raw)
expect(result?.contextUsedPct).toBe(45.7)
})
it('returns undefined contextUsedPct when context_window is missing', () => {
const result = parseRaw('{}')
expect(result?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct when used_percentage is not a number', () => {
const raw = JSON.stringify({ context_window: { used_percentage: '45%' } })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct for Infinity and NaN', () => {
const raw = JSON.stringify({ context_window: { used_percentage: null } })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct when context_window is not an object', () => {
const raw = JSON.stringify({ context_window: 'bad' })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('extracts costUsd from cost.total_cost_usd', () => {
const raw = JSON.stringify({ cost: { total_cost_usd: 0.532 } })
expect(parseRaw(raw)?.costUsd).toBe(0.532)
})
it('returns undefined costUsd when cost is missing', () => {
expect(parseRaw('{}')?.costUsd).toBeUndefined()
})
it('returns undefined costUsd for non-numeric values', () => {
const raw = JSON.stringify({ cost: { total_cost_usd: 'expensive' } })
expect(parseRaw(raw)?.costUsd).toBeUndefined()
})
it('returns undefined costUsd when cost is not an object', () => {
const raw = JSON.stringify({ cost: 42 })
expect(parseRaw(raw)?.costUsd).toBeUndefined()
})
it('extracts model from model.display_name', () => {
const raw = JSON.stringify({ model: { display_name: 'claude-opus-4-5' } })
expect(parseRaw(raw)?.model).toBe('claude-opus-4-5')
})
it('extracts model from model string (fallback)', () => {
const raw = JSON.stringify({ model: 'claude-sonnet' })
expect(parseRaw(raw)?.model).toBe('claude-sonnet')
})
it('returns undefined model when model is missing', () => {
expect(parseRaw('{}')?.model).toBeUndefined()
})
it('returns undefined model for empty model string', () => {
const raw = JSON.stringify({ model: '' })
expect(parseRaw(raw)?.model).toBeUndefined()
})
it('returns undefined model when model.display_name is not a string', () => {
const raw = JSON.stringify({ model: { display_name: 42 } })
expect(parseRaw(raw)?.model).toBeUndefined()
})
it('truncates model to 100 characters', () => {
const longModel = 'a'.repeat(150)
const raw = JSON.stringify({ model: { display_name: longModel } })
expect(parseRaw(raw)?.model).toHaveLength(100)
})
it('extracts all three fields together (full statusLine JSON)', () => {
const raw = JSON.stringify({
context_window: { used_percentage: 60.0 },
cost: { total_cost_usd: 1.23, total_lines_added: 100, total_lines_removed: 20 },
model: { display_name: 'claude-opus-4-5' },
effort: { level: 'high' },
})
const result = parseRaw(raw)
expect(result).toEqual({
contextUsedPct: 60.0,
costUsd: 1.23,
model: 'claude-opus-4-5',
})
})
it('handles partial fields gracefully (only model provided)', () => {
const raw = JSON.stringify({ model: { display_name: 'claude-haiku' } })
const result = parseRaw(raw)
expect(result?.contextUsedPct).toBeUndefined()
expect(result?.costUsd).toBeUndefined()
expect(result?.model).toBe('claude-haiku')
})
it('does not throw on any input (never-throw contract)', () => {
const cases = [null, undefined, 42, [], {}, 'random', '{"a":1}']
for (const input of cases) {
expect(() => parseRaw(String(input))).not.toThrow()
}
})
})
// ── buildSummary ──────────────────────────────────────────────────────────────
describe('buildSummary', () => {
it('returns empty string for null parsed', () => {
expect(buildSummary(null)).toBe('')
})
it('returns empty string when all fields are undefined', () => {
expect(buildSummary({ contextUsedPct: undefined, costUsd: undefined, model: undefined })).toBe('')
})
it('formats contextUsedPct as ctx:XX%', () => {
expect(buildSummary({ contextUsedPct: 45.7 })).toBe('ctx:46%')
})
it('rounds contextUsedPct to nearest integer', () => {
expect(buildSummary({ contextUsedPct: 45.4 })).toBe('ctx:45%')
expect(buildSummary({ contextUsedPct: 99.9 })).toBe('ctx:100%')
expect(buildSummary({ contextUsedPct: 0 })).toBe('ctx:0%')
})
it('formats costUsd as $X.XX', () => {
expect(buildSummary({ costUsd: 0.532 })).toBe('$0.53')
expect(buildSummary({ costUsd: 1.0 })).toBe('$1.00')
expect(buildSummary({ costUsd: 12.345 })).toBe('$12.35')
})
it('formats model as plain string', () => {
expect(buildSummary({ model: 'claude-opus-4-5' })).toBe('claude-opus-4-5')
})
it('combines all fields with " · " separator', () => {
const result = buildSummary({ contextUsedPct: 45, costUsd: 0.53, model: 'claude-opus' })
expect(result).toBe('ctx:45% · $0.53 · claude-opus')
})
it('combines only present fields (ctx + model, no cost)', () => {
const result = buildSummary({ contextUsedPct: 20, model: 'claude-haiku' })
expect(result).toBe('ctx:20% · claude-haiku')
})
it('combines only present fields (cost + model, no ctx)', () => {
const result = buildSummary({ costUsd: 0.01, model: 'claude-sonnet' })
expect(result).toBe('$0.01 · claude-sonnet')
})
it('omits empty model string', () => {
expect(buildSummary({ model: '' })).toBe('')
expect(buildSummary({ costUsd: 1.0, model: '' })).toBe('$1.00')
})
})
// ── postToServer ──────────────────────────────────────────────────────────────
describe('postToServer', () => {
const mockFetch = vi.fn()
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockResolvedValue({ ok: true, status: 204 })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('calls fetch with POST method and correct URL', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
expect(mockFetch).toHaveBeenCalledOnce()
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://127.0.0.1:3000/hook/status')
expect(opts.method).toBe('POST')
})
it('sends Content-Type: application/json header', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect((opts.headers as Record<string, string>)['Content-Type']).toBe('application/json')
})
it('sends X-Webterm-Session header with the sessionId', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'my-session-id', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect((opts.headers as Record<string, string>)['X-Webterm-Session']).toBe('my-session-id')
})
it('sends the raw body verbatim', async () => {
const body = '{"context_window":{"used_percentage":50}}'
await postToServer('http://127.0.0.1:3000/hook/status', 's1', body)
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(opts.body).toBe(body)
})
it('does not throw when fetch rejects (server down)', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')).resolves.toBeUndefined()
})
it('does not throw on network timeout (AbortError)', async () => {
mockFetch.mockRejectedValue(new DOMException('Aborted', 'AbortError'))
await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}', 1)).resolves.toBeUndefined()
})
it('passes an AbortSignal to fetch', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(opts.signal).toBeDefined()
})
})

190
test/sw-push.test.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* test/sw-push.test.ts — Unit tests for public/sw-push.js pure helpers.
*
* Covers T-sw W1: buildPushNotification (all NotifyClass values) and
* resolveNotificationClick (allow / deny / body-click routing).
*
* Security: SEC-C1 (decision body carries token), SEC-M6 (tag=sessionId dedup).
* No DOM or SW globals needed — sw-push.js is intentionally pure.
*/
import { describe, it, expect } from 'vitest'
import type { PushPayload } from '../src/types.js'
// sw-push.js is a plain ES-module JS file; vitest imports it as ESM.
// We assert the expected return shapes inline.
const { buildPushNotification, resolveNotificationClick } = await import(
'../public/sw-push.js'
)
// ─────────────── helpers ───────────────────────────────────────────────────
/** Minimal notification mock matching the shape sw-push.js reads. */
function fakeNotification(data: Record<string, unknown>): Notification {
return { data } as unknown as Notification
}
// ─────────────── buildPushNotification ────────────────────────────────────
describe('buildPushNotification', () => {
it('needs-input: requireInteraction=true, Allow+Deny actions, tag=sessionId', () => {
const payload: PushPayload = {
sessionId: 'sess-001',
toolName: 'Bash',
detail: 'rm -rf /tmp/cache',
token: 'tok-aaa',
cls: 'needs-input',
}
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(typeof title).toBe('string')
expect(options.tag).toBe('sess-001') // SEC-M6
expect(options.requireInteraction).toBe(true)
const actions: Array<{ action: string; title: string }> = options.actions ?? []
expect(actions.length).toBeGreaterThanOrEqual(2)
expect(actions.some((a) => a.action === 'allow')).toBe(true)
expect(actions.some((a) => a.action === 'deny')).toBe(true)
expect(options.data).toMatchObject({
sessionId: 'sess-001',
token: 'tok-aaa',
cls: 'needs-input',
})
})
it('needs-input without detail falls back to toolName in body', () => {
const payload: PushPayload = {
sessionId: 's',
toolName: 'Edit',
token: 'tok-bbb',
cls: 'needs-input',
}
const { options } = buildPushNotification(payload)
expect(options.body ?? '').toContain('Edit')
})
it('done: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-002', cls: 'done' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-002') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-002', cls: 'done' })
})
it('done: body includes detail when provided', () => {
const payload: PushPayload = {
sessionId: 'sess-003',
detail: 'Build succeeded',
cls: 'done',
}
const { options } = buildPushNotification(payload)
expect(options.body).toContain('Build succeeded')
})
it('stuck: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-004', cls: 'stuck' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-004') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-004', cls: 'stuck' })
})
it('no detail and no toolName → body is undefined or empty', () => {
const payload: PushPayload = { sessionId: 'sess-005', cls: 'done' }
const { options } = buildPushNotification(payload)
// body should be undefined or an empty string — not a thrown error
expect(() => buildPushNotification(payload)).not.toThrow()
const body = options.body
expect(body === undefined || body === '').toBe(true)
})
})
// ─────────────── resolveNotificationClick ─────────────────────────────────
describe('resolveNotificationClick', () => {
it('allow action → decision with decision=allow and capability token (SEC-C1)', () => {
const notification = fakeNotification({
sessionId: 'sess-10',
token: 'tok-xyz',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-10')
expect(body.decision).toBe('allow')
expect(body.token).toBe('tok-xyz') // SEC-C1: token forwarded
})
it('deny action → decision with decision=deny', () => {
const notification = fakeNotification({
sessionId: 'sess-11',
token: 'tok-abc',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'deny')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-11')
expect(body.decision).toBe('deny')
expect(body.token).toBe('tok-abc')
})
it('empty action string (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-12' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-12')
})
it('undefined action (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-13' })
const result = resolveNotificationClick(notification, undefined)
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-13')
})
it('null action → focus (treated as default body click)', () => {
const notification = fakeNotification({ sessionId: 'sess-14' })
const result = resolveNotificationClick(notification, null)
expect(result.kind).toBe('focus')
expect(result.url).toContain('sess-14')
})
it('token is undefined in body when not in notification data (done notification)', () => {
// done/stuck notifications have no token in data; test edge: action='allow' still
// sends a decision body but token field is absent (server should reject gracefully)
const notification = fakeNotification({ sessionId: 'sess-15', cls: 'done' })
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.token).toBeUndefined()
})
it('sessionId is URL-encoded in focus URL (special characters safe)', () => {
const notification = fakeNotification({ sessionId: 'sess a+b' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
// encodeURIComponent encodes spaces and + signs
expect(result.url).not.toContain(' ')
})
it('missing data on notification → focus URL with empty session gracefully', () => {
// notification.data is null/undefined
const notification = { data: null } as unknown as Notification
expect(() => resolveNotificationClick(notification, '')).not.toThrow()
})
})

View File

@@ -8,7 +8,7 @@
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ── Mock TerminalSession ──────────────────────────────────────────────────────
let constructed = 0
@@ -19,6 +19,11 @@ class FakeTerminalSession {
claudeStatus = 'unknown'
cwd: string | null = null
pendingApproval = false
pendingTool: string | undefined = undefined
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
pendingGate: 'tool' | 'plan' | null = null
/** B2: latest statusLine telemetry (single source of truth). */
telemetry: unknown = null
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined
connect = vi.fn()
@@ -39,6 +44,7 @@ class FakeTerminalSession {
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
}
static instances: FakeTerminalSession[] = []
constructor(opts: {
@@ -48,6 +54,7 @@ class FakeTerminalSession {
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
[key: string]: unknown
}) {
constructed += 1
@@ -61,6 +68,37 @@ class FakeTerminalSession {
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ──────
const renderTelemetryGauge = vi.fn()
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))
const mountPushToggle = vi.fn()
vi.mock('../public/push.js', () => ({ mountPushToggle }))
const quickReply: { onSend?: (d: string) => void } = {}
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
quickReply.onSend = opts.onSend
return { dispose: vi.fn() }
})
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
const timelineDispose = vi.fn()
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
const voice: { onTranscript?: (t: string) => void; onInterim?: (t: string) => void } = {}
const voiceStart = vi.fn()
const voiceStop = vi.fn()
const createVoiceInput = vi.fn(
(onTranscript: (t: string) => void, opts?: { onInterim?: (t: string) => void }) => {
voice.onTranscript = onTranscript
voice.onInterim = opts?.onInterim
return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
},
)
const isSpeechSupported = vi.fn(() => true)
vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported }))
// ── Mock the launcher (records visibility) ────────────────────────────────────
const launcherVisible = { value: false }
const mountLauncher = vi.fn(() => ({
@@ -88,10 +126,21 @@ 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)
// #keybar must exist so the quick-reply row mounts directly above it (A3).
const keybar = document.createElement('div')
keybar.id = 'keybar'
document.body.append(paneHost, tabBar, keybar)
return { paneHost, tabBar }
}
/** Stub fetch('/config/ui') → { allowAutoMode }. */
function stubUiConfigFetch(allowAutoMode: boolean): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })),
)
}
beforeEach(() => {
constructed = 0
FakeTerminalSession.instances = []
@@ -99,6 +148,23 @@ beforeEach(() => {
projectsVisible.value = false
document.body.replaceChildren()
localStorage.clear()
renderTelemetryGauge.mockClear()
mountPushToggle.mockClear()
mountQuickReply.mockClear()
mountTimeline.mockClear()
timelineDispose.mockClear()
createVoiceInput.mockClear()
voiceStart.mockClear()
voiceStop.mockClear()
quickReply.onSend = undefined
voice.onTranscript = undefined
voice.onInterim = undefined
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') }))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('TabApp — v0.5 launcher chooser', () => {
@@ -511,3 +577,216 @@ describe('TabApp — Home (⌂) button: reach the chooser from an open session',
expect(seg.style.display).toBe('flex') // still shown, no crash
})
})
describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => {
it('mounts the 🔔 push toggle exactly once on construction (A1)', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
expect(mountPushToggle).toHaveBeenCalledTimes(1)
})
it('mounts the quick-reply row directly above #keybar (A3)', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
expect(mountQuickReply).toHaveBeenCalledTimes(1)
const qr = document.getElementById('quickreply')
expect(qr).not.toBeNull()
expect(qr?.nextElementSibling).toBe(document.getElementById('keybar'))
})
it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
quickReply.onSend?.('yes\r')
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r')
})
it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // rebuild
app.newTab() // rebuild again
expect(mountPushToggle).toHaveBeenCalledTimes(1)
expect(tabBar.querySelector('.push-host')).not.toBeNull()
})
})
describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => {
it('renders the telemetry gauge from the session.telemetry single source of truth', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 }
session.telemetry = tele
renderTelemetryGauge.mockClear()
session.cbs.onTelemetry?.(tele) // server pushed new telemetry
expect(renderTelemetryGauge).toHaveBeenCalled()
expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele)
})
it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // tab 0
app.newTab() // tab 1 active → tab 0 background
const bg = FakeTerminalSession.instances[0]!
bg.claudeStatus = 'stuck'
bg.cbs.onClaudeStatus?.('stuck')
const tab0 = tabBar.querySelectorAll('.tab')[0]!
expect(tab0.classList.contains('claude-stuck')).toBe(true)
expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠')
})
})
describe('TabApp — B4 plan-gate approval bar', () => {
function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances.at(-1)!
session.pendingApproval = true
session.pendingGate = gate
session.pendingTool = tool
session.cbs.onClaudeStatus?.('waiting')
return session
}
it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => {
openWithGate('tool', 'Bash')
const bar = document.getElementById('approvalbar')!
expect(bar.style.display).toBe('flex')
expect(bar.querySelectorAll('button')).toHaveLength(2)
})
it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => {
const session = openWithGate('plan')
const bar = document.getElementById('approvalbar')!
const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click()
expect(bar.querySelectorAll('button')).toHaveLength(3)
click(0) // Approve + Auto
expect(session.approve).toHaveBeenLastCalledWith('acceptEdits')
click(1) // Approve + Review
expect(session.approve).toHaveBeenLastCalledWith('default')
click(2) // Keep Planning
expect(session.reject).toHaveBeenCalled()
})
})
describe('TabApp — B4 permission-mode relay', () => {
it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'repo', 'claude\r', 'plan')
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe(
'claude --permission-mode plan\r',
)
})
it('openProject with default mode keeps the plain claude command (no regression)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'repo', 'claude\r', 'default')
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r')
})
it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false
expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan'])
})
it('availablePermissionModes includes "auto" once /config/ui allows it', async () => {
stubUiConfigFetch(true)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto'))
})
it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => {
localStorage.setItem('web-terminal:permission-mode', 'auto')
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false
expect(app.loadDefaultMode()).toBe('default')
})
it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.saveDefaultMode('plan')
expect(app.loadDefaultMode()).toBe('plan')
localStorage.setItem('web-terminal:permission-mode', 'garbage')
expect(app.loadDefaultMode()).toBe('default')
})
})
describe('TabApp — A4 timeline panel', () => {
const SID = '44444444-4444-4444-8444-444444444444'
it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession(SID)
const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement
expect(tlBtn).not.toBeNull()
tlBtn.click() // open
expect(mountTimeline).toHaveBeenCalledTimes(1)
expect(document.getElementById('timeline-panel')!.style.display).toBe('block')
tlBtn.click() // close
expect(timelineDispose).toHaveBeenCalled()
expect(document.getElementById('timeline-panel')!.style.display).toBe('none')
})
it('closing a tab with its timeline open disposes the polling handle', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession(SID)
;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open
expect(mountTimeline).toHaveBeenCalled()
app.closeTab(0)
expect(timelineDispose).toHaveBeenCalled()
})
})
describe('TabApp — A2 push-to-talk voice', () => {
it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.handleVoiceTrigger('start')
expect(createVoiceInput).toHaveBeenCalledTimes(1)
expect(voiceStart).toHaveBeenCalled()
expect(document.getElementById('voice-interim')!.style.display).toBe('block')
})
it('a final transcript is sent to the active session as raw input (A2-FR1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.handleVoiceTrigger('start')
voice.onTranscript?.('list the files')
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files')
})
it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.handleVoiceTrigger('start')
voice.onInterim?.('hello wor')
const overlay = document.getElementById('voice-interim')!
expect(overlay.textContent).toBe('hello wor')
app.handleVoiceTrigger('stop')
expect(voiceStop).toHaveBeenCalled()
expect(overlay.style.display).toBe('none')
})
it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => {
createVoiceInput.mockReturnValueOnce(null as never)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
expect(() => app.handleVoiceTrigger('start')).not.toThrow()
expect(voiceStart).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,305 @@
// @vitest-environment jsdom
/**
* test/telemetry-gauge.test.ts — renderTelemetryGauge + renderStatusBadge + statusText('stuck')
*
* Covers T-preview-grid W1: telemetry gauge, status badge, and statusText extension.
* Security: SEC-H5 (textContent only), SEC-L5 (PR URL https scheme guard).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { StatusTelemetry, ClaudeStatus } from '../src/types.js'
// Stub xterm so jsdom does not fail on canvas operations (same as preview-grid.test.ts)
vi.mock('@xterm/xterm', () => ({
Terminal: class FakeTerm {
cols = 80
rows = 24
open = vi.fn()
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
write = vi.fn((_d: string, cb?: () => void) => cb?.())
},
}))
const { renderTelemetryGauge, renderStatusBadge, statusText } = await import('../public/preview-grid.js')
/** Helper: build a StatusTelemetry with only `at` (everything else optional). */
function makeTelemetry(over: Partial<StatusTelemetry> = {}): StatusTelemetry {
return { at: Date.now(), ...over }
}
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
beforeEach(() => {
vi.restoreAllMocks()
})
// ─────────────────────── statusText — stuck extension ────────────────────────
describe('statusText — stuck extension', () => {
it('returns text containing ⚠ for stuck status', () => {
const text = statusText('stuck' as ClaudeStatus)
expect(text).toContain('⚠')
expect(text).toContain('stuck')
})
it('still maps existing statuses correctly (no regression)', () => {
expect(statusText('working')).toContain('working')
expect(statusText('waiting')).toContain('waiting')
expect(statusText('idle')).toContain('idle')
expect(statusText('unknown')).toBe('·')
})
})
// ─────────────────────── renderTelemetryGauge ────────────────────────────────
describe('renderTelemetryGauge', () => {
it('renders nothing for null telemetry', () => {
const c = makeContainer()
renderTelemetryGauge(c, null, 30_000)
expect(c.children.length).toBe(0)
})
it('is tolerant when telemetry has only the required at field (no optional fields)', () => {
const c = makeContainer()
expect(() => renderTelemetryGauge(c, makeTelemetry(), 30_000)).not.toThrow()
// No optional fields → nothing rendered beyond the stale logic
expect(c.children.length).toBe(0)
})
it('clears existing children on each call', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ model: 'first' }), 30_000)
renderTelemetryGauge(c, makeTelemetry({ model: 'second' }), 30_000)
const chips = c.querySelectorAll('.tg-model')
expect(chips.length).toBe(1)
expect(chips[0]?.textContent).toBe('second')
})
// ── context bar ──
it('renders a context bar when contextUsedPct is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 50 }), 30_000)
expect(c.querySelector('.tg-ctx-bar')).not.toBeNull()
expect(c.querySelector('.tg-ctx-fill')).not.toBeNull()
})
it('adds warning class on context fill when contextUsedPct > 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 85 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(true)
})
it('does NOT add warning class when contextUsedPct === 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 80 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false)
})
it('does NOT add warning class when contextUsedPct < 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 60 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false)
})
it('skips context bar when contextUsedPct is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({}), 30_000)
expect(c.querySelector('.tg-ctx-bar')).toBeNull()
})
// ── cost chip ──
it('renders cost chip with $ prefix when costUsd is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 0.1234 }), 30_000)
const cost = c.querySelector('.tg-cost')
expect(cost).not.toBeNull()
expect(cost?.textContent).toContain('$')
expect(cost?.textContent).toContain('0.1234')
})
it('skips cost chip when costUsd is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-cost')).toBeNull()
})
// ── model chip ──
it('renders model chip when model is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ model: 'claude-3-sonnet' }), 30_000)
expect(c.querySelector('.tg-model')?.textContent).toBe('claude-3-sonnet')
})
it('renders <script> model name as plain text — not injected into DOM (SEC-H5)', () => {
const c = makeContainer()
const xss = '<script>alert(1)</script>'
renderTelemetryGauge(c, makeTelemetry({ model: xss }), 30_000)
const chip = c.querySelector('.tg-model')
expect(chip?.textContent).toBe(xss)
// No actual <script> element should exist inside the container
expect(c.querySelectorAll('script').length).toBe(0)
})
it('skips model chip when model is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-model')).toBeNull()
})
// ── PR badge ──
it('renders PR badge with link text for a PR', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link).not.toBeNull()
expect(link?.textContent).toContain('42')
})
it('sets href for https PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.href).toBe('https://github.com/user/repo/pull/42')
})
it('does NOT set href for http PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 99, url: 'http://insecure.example.com/pull/99' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.textContent).toContain('99')
// href attribute should not be set (attribute absent or empty string)
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('does NOT set href for an invalid PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 1, url: 'not-a-valid-url' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('renders PR reviewState as text when present', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5', reviewState: 'APPROVED' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')?.textContent).toBe('APPROVED')
})
it('skips PR reviewState element when reviewState is absent', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')).toBeNull()
})
it('skips PR badge when pr is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-pr')).toBeNull()
})
// ── stale detection ──
it('adds tg-stale class when telemetry age exceeds staleTtlMs', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
})
it('does NOT add tg-stale class for fresh telemetry', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when subsequent call has fresh telemetry', () => {
const c = makeContainer()
// First: stale
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
// Second: fresh
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when called with null telemetry after stale', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
renderTelemetryGauge(c, null, 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
})
// ─────────────────────── renderStatusBadge ───────────────────────────────────
describe('renderStatusBadge', () => {
it('renders a badge for stuck with ⚠ (A5)', () => {
const c = makeContainer()
renderStatusBadge(c, 'stuck')
const badge = c.querySelector('.sb-badge')
expect(badge).not.toBeNull()
expect(badge?.textContent).toContain('⚠')
expect(badge?.textContent).toContain('stuck')
})
it('applies per-status CSS class for styling', () => {
const statuses: ClaudeStatus[] = ['working', 'waiting', 'idle', 'unknown', 'stuck']
for (const status of statuses) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector(`.sb-${status}`)).not.toBeNull()
}
})
it('clears the container before rendering (replaces old badge)', () => {
const c = makeContainer()
renderStatusBadge(c, 'working')
renderStatusBadge(c, 'idle')
expect(c.querySelectorAll('.sb-badge').length).toBe(1)
expect(c.querySelector('.sb-badge')?.textContent).toContain('idle')
})
it('renders each non-stuck status with appropriate text', () => {
const cases: Array<[ClaudeStatus, string]> = [
['working', 'working'],
['waiting', 'waiting'],
['idle', 'idle'],
]
for (const [status, expected] of cases) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector('.sb-badge')?.textContent).toContain(expected)
}
})
})

View File

@@ -456,3 +456,158 @@ describe('show()/refit() re-fit when visible', () => {
expect(ws.sent).toHaveLength(0)
})
})
// ── B2 telemetry frame ────────────────────────────────────────────────────────
describe('telemetry frame (B2)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected(opts: { onTelemetry?: (t: unknown) => void } = {}) {
const s = new TerminalSession({
sessionId: null,
onSessionId: vi.fn(),
onTelemetry: opts.onTelemetry as Parameters<typeof TerminalSession>[0]['onTelemetry'],
})
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('telemetry getter starts as null before any telemetry frame', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.telemetry).toBeNull()
})
it('fires onTelemetry callback and updates the telemetry getter', () => {
const onTelemetry = vi.fn()
const { s, ws } = connected({ onTelemetry })
const tel = { at: 1000, contextUsedPct: 42, costUsd: 0.5, model: 'claude-opus-4' }
ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))
expect(onTelemetry).toHaveBeenCalledOnce()
expect(onTelemetry).toHaveBeenCalledWith(tel)
expect(s.telemetry).toEqual(tel)
})
it('works without onTelemetry (callback is optional — no throw)', () => {
const { ws } = connected() // no onTelemetry
const tel = { at: 2000, costUsd: 0.1 }
expect(() => ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))).not.toThrow()
})
it('updates telemetry on subsequent frames (latest wins)', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1000, costUsd: 0.1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2000, costUsd: 0.9 } }))
expect(s.telemetry).toMatchObject({ at: 2000, costUsd: 0.9 })
})
it('onTelemetry is called once per frame (not debounced)', () => {
const onTelemetry = vi.fn()
const { ws } = connected({ onTelemetry })
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 3 } }))
expect(onTelemetry).toHaveBeenCalledTimes(3)
})
})
// ── B4 pendingGate from status frame ─────────────────────────────────────────
describe('pendingGate from status frame (B4)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('pendingGate starts as null', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.pendingGate).toBeNull()
})
it('records pendingGate="plan" from a status message with gate:"plan"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
})
it('records pendingGate="tool" from a status message with gate:"tool"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'tool' }))
expect(s.pendingGate).toBe('tool')
})
it('sets pendingGate to null when gate is absent from status', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true }))
expect(s.pendingGate).toBeNull()
})
it('clears pendingGate to null on a subsequent status without gate', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
ws.message(JSON.stringify({ type: 'status', status: 'working' }))
expect(s.pendingGate).toBeNull()
})
})
// ── B4 approve(mode?) with PermissionMode ────────────────────────────────────
describe('approve(mode?) — B4 permission-mode relay', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
ws.sent.length = 0
return { s, ws }
}
it('approve() without mode sends {type:"approve"} with NO mode field', () => {
const { s, ws } = connected()
s.approve()
const msg = JSON.parse(ws.sent[0]!)
expect(msg).toEqual({ type: 'approve' })
expect('mode' in msg).toBe(false)
})
it('approve("acceptEdits") sends {type:"approve", mode:"acceptEdits"}', () => {
const { s, ws } = connected()
s.approve('acceptEdits')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'acceptEdits' })
})
it('approve("plan") sends {type:"approve", mode:"plan"}', () => {
const { s, ws } = connected()
s.approve('plan')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'plan' })
})
it('approve("auto") sends {type:"approve", mode:"auto"}', () => {
const { s, ws } = connected()
s.approve('auto')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'auto' })
})
it('approve("default") sends {type:"approve", mode:"default"}', () => {
const { s, ws } = connected()
s.approve('default')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'default' })
})
it('approve() clears pendingApproval regardless of mode', () => {
const { s } = connected()
expect(s.pendingApproval).toBe(true)
s.approve('acceptEdits')
expect(s.pendingApproval).toBe(false)
})
})

526
test/timeline.test.ts Normal file
View File

@@ -0,0 +1,526 @@
// @vitest-environment jsdom
/**
* test/timeline.test.ts — N-timeline-ui: activity timeline panel
*
* Covers: normalizeTimelineEvent (defensive), timelineIcon (mapping),
* renderTimelineEvent (textContent/XSS), fetchTimeline (error→[]),
* mountTimeline (polling start/stop, dispose clears interval, empty state,
* newest-first, maxEvents cap).
*
* Security: SEC-H6 (label/toolName rendered via textContent, not innerHTML).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { TimelineEvent, TimelineClass } from '../src/types.js'
// Stub @xterm/xterm (pulled in transitively via preview-grid.ts import).
vi.mock('@xterm/xterm', () => ({
Terminal: class FakeTerm {
cols = 80
rows = 24
open = vi.fn()
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
write = vi.fn((_d: string, cb?: () => void) => cb?.())
},
}))
const {
normalizeTimelineEvent,
timelineIcon,
renderTimelineEvent,
fetchTimeline,
mountTimeline,
} = await import('../public/timeline.js')
// ─── helpers ────────────────────────────────────────────────────────────────
function makeEvent(over: Partial<TimelineEvent> = {}): TimelineEvent {
return {
at: Date.now(),
class: 'tool',
label: 'ran Bash',
...over,
}
}
function makeRaw(over: Record<string, unknown> = {}): unknown {
return {
at: Date.now(),
class: 'tool',
label: 'ran Bash',
...over,
}
}
// ─── normalizeTimelineEvent ──────────────────────────────────────────────────
describe('normalizeTimelineEvent', () => {
it('returns null for null input', () => {
expect(normalizeTimelineEvent(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(normalizeTimelineEvent(undefined)).toBeNull()
})
it('returns null for non-object (string)', () => {
expect(normalizeTimelineEvent('hello')).toBeNull()
})
it('returns null for non-object (number)', () => {
expect(normalizeTimelineEvent(42)).toBeNull()
})
it('returns null for array input', () => {
expect(normalizeTimelineEvent([1, 2, 3])).toBeNull()
})
it('returns null when `at` is missing', () => {
expect(normalizeTimelineEvent({ class: 'tool', label: 'hi' })).toBeNull()
})
it('returns null when `at` is a string', () => {
expect(normalizeTimelineEvent(makeRaw({ at: '2024-01-01' }))).toBeNull()
})
it('returns null when `at` is NaN', () => {
expect(normalizeTimelineEvent(makeRaw({ at: NaN }))).toBeNull()
})
it('returns null when `at` is Infinity', () => {
expect(normalizeTimelineEvent(makeRaw({ at: Infinity }))).toBeNull()
})
it('returns null when `label` is missing', () => {
expect(normalizeTimelineEvent({ at: Date.now(), class: 'tool' })).toBeNull()
})
it('returns null when `label` is a number', () => {
expect(normalizeTimelineEvent(makeRaw({ label: 42 }))).toBeNull()
})
it('returns null when `class` is missing', () => {
expect(normalizeTimelineEvent({ at: Date.now(), label: 'hi' })).toBeNull()
})
it('returns null when `class` is an unknown string', () => {
expect(normalizeTimelineEvent(makeRaw({ class: 'unknown-garbage' }))).toBeNull()
})
it('returns null when `class` is not a string', () => {
expect(normalizeTimelineEvent(makeRaw({ class: 99 }))).toBeNull()
})
it('accepts all valid TimelineClass values', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
for (const cls of classes) {
const result = normalizeTimelineEvent(makeRaw({ class: cls }))
expect(result).not.toBeNull()
expect(result?.class).toBe(cls)
}
})
it('returns a valid TimelineEvent for a well-formed input', () => {
const now = Date.now()
const result = normalizeTimelineEvent({ at: now, class: 'done', label: 'session ended' })
expect(result).toEqual({ at: now, class: 'done', label: 'session ended' })
})
it('includes toolName when it is a string', () => {
const result = normalizeTimelineEvent(makeRaw({ toolName: 'Bash' }))
expect(result?.toolName).toBe('Bash')
})
it('omits toolName when it is not a string', () => {
const result = normalizeTimelineEvent(makeRaw({ toolName: 123 }))
expect(result?.toolName).toBeUndefined()
})
it('truncates label at 500 characters', () => {
const longLabel = 'x'.repeat(600)
const result = normalizeTimelineEvent(makeRaw({ label: longLabel }))
expect(result?.label.length).toBeLessThanOrEqual(500)
})
it('truncates toolName at 200 characters', () => {
const longName = 'y'.repeat(300)
const result = normalizeTimelineEvent(makeRaw({ toolName: longName }))
expect(result?.toolName?.length).toBeLessThanOrEqual(200)
})
it('does not throw on arbitrary garbage inputs', () => {
const garbage = [
{},
[],
null,
undefined,
false,
0,
'',
{ at: null, class: {}, label: [] },
{ at: 1, class: 'tool', label: 'ok', toolName: {} },
]
for (const g of garbage) {
expect(() => normalizeTimelineEvent(g)).not.toThrow()
}
})
})
// ─── timelineIcon ────────────────────────────────────────────────────────────
describe('timelineIcon', () => {
it('returns a non-empty string for every TimelineClass', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
for (const cls of classes) {
const icon = timelineIcon(cls)
expect(typeof icon).toBe('string')
expect(icon.length).toBeGreaterThan(0)
}
})
it('maps tool to a tool-related icon', () => {
expect(timelineIcon('tool')).toBeTruthy()
})
it('maps waiting to a waiting icon', () => {
expect(timelineIcon('waiting')).toBeTruthy()
})
it('maps done to a done/check icon', () => {
const icon = timelineIcon('done')
expect(typeof icon).toBe('string')
})
it('maps stuck to a warning icon containing ⚠', () => {
const icon = timelineIcon('stuck')
expect(icon).toContain('⚠')
})
it('maps user to a user icon', () => {
expect(timelineIcon('user')).toBeTruthy()
})
it('returns distinct icons for each class', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
const icons = classes.map(timelineIcon)
const unique = new Set(icons)
expect(unique.size).toBe(classes.length)
})
})
// ─── renderTimelineEvent ─────────────────────────────────────────────────────
describe('renderTimelineEvent', () => {
it('returns an HTMLElement', () => {
const el = renderTimelineEvent(makeEvent())
expect(el).toBeInstanceOf(HTMLElement)
})
it('does NOT use innerHTML (textContent only — SEC-H6)', () => {
const ev = makeEvent({ label: '<script>alert(1)</script>' })
const row = renderTimelineEvent(ev)
// innerHTML of the row should not produce script elements
const scripts = row.querySelectorAll('script')
expect(scripts.length).toBe(0)
// The raw string should appear as text somewhere in the tree
expect(row.textContent).toContain('<script>alert(1)</script>')
})
it('renders the label text as textContent', () => {
const ev = makeEvent({ label: 'edited 3 files' })
const row = renderTimelineEvent(ev)
expect(row.textContent).toContain('edited 3 files')
})
it('renders the icon for the event class', () => {
const ev = makeEvent({ class: 'stuck' })
const row = renderTimelineEvent(ev)
expect(row.textContent).toContain('⚠')
})
it('renders a time portion in the row', () => {
const ev = makeEvent({ at: new Date('2024-06-15T14:32:00').getTime() })
const row = renderTimelineEvent(ev)
// Should contain digits forming a time pattern (HH:MM)
expect(row.textContent).toMatch(/\d{1,2}:\d{2}/)
})
it('renders HTML-special characters in label as plain text', () => {
const ev = makeEvent({ label: '<b>bold</b> & "quotes"' })
const row = renderTimelineEvent(ev)
expect(row.querySelectorAll('b').length).toBe(0)
expect(row.textContent).toContain('<b>bold</b>')
})
it('renders ANSI escape sequences in label as plain text', () => {
const ev = makeEvent({ label: '\x1b[31mred\x1b[0m text' })
const row = renderTimelineEvent(ev)
expect(row.textContent).toContain('\x1b[31mred\x1b[0m text')
})
it('renders all five classes without throwing', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
for (const cls of classes) {
expect(() => renderTimelineEvent(makeEvent({ class: cls }))).not.toThrow()
}
})
})
// ─── fetchTimeline ───────────────────────────────────────────────────────────
describe('fetchTimeline', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns [] on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('net fail')))
const result = await fetchTimeline('sess-1')
expect(result).toEqual([])
})
it('returns [] when response is not ok', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => [] }),
)
const result = await fetchTimeline('sess-1')
expect(result).toEqual([])
})
it('returns [] when response JSON is not an array', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [] }) }),
)
const result = await fetchTimeline('sess-1')
expect(result).toEqual([])
})
it('returns [] when json() rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } }),
)
const result = await fetchTimeline('sess-1')
expect(result).toEqual([])
})
it('fetches from the correct URL', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
vi.stubGlobal('fetch', mockFetch)
await fetchTimeline('my-session-id')
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('my-session-id'))
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/events'))
})
it('normalizes and returns valid events', async () => {
const now = Date.now()
const rawEvents = [
{ at: now, class: 'tool', label: 'ran Bash', toolName: 'Bash' },
{ at: now, class: 'done', label: 'session ended' },
]
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }),
)
const result = await fetchTimeline('sess-2')
expect(result.length).toBe(2)
expect(result[0]?.class).toBe('tool')
expect(result[1]?.class).toBe('done')
})
it('filters out invalid events from the response array', async () => {
const now = Date.now()
const rawEvents = [
{ at: now, class: 'tool', label: 'ok' },
{ at: 'bad', class: 'tool', label: 'ok' }, // invalid at
null,
{ at: now, class: 'invalid-class', label: 'ok' }, // invalid class
]
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }),
)
const result = await fetchTimeline('sess-3')
expect(result.length).toBe(1)
expect(result[0]?.label).toBe('ok')
})
})
// ─── mountTimeline ───────────────────────────────────────────────────────────
describe('mountTimeline', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.restoreAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
/**
* Flush the microtask queue (Promise chain) without advancing fake timers.
* Needed to let `void poll()` → `fetchTimeline()` → `render()` complete.
* Four ticks cover: poll() entry → fetch call → await response → render().
*/
async function flushPromises(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
it('does an initial fetch on mount', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-1')
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
handle.dispose()
})
it('shows empty state when no events are returned', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
const container = makeContainer()
const handle = mountTimeline(container, 'sess-empty')
await flushPromises()
expect(container.textContent).toContain('No activity')
handle.dispose()
})
it('renders events newest-first (reverses server order)', async () => {
const now = Date.now()
const events = [
{ at: now - 2000, class: 'tool', label: 'first event' },
{ at: now - 1000, class: 'tool', label: 'second event' },
{ at: now, class: 'done', label: 'done event' },
]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events }))
const container = makeContainer()
const handle = mountTimeline(container, 'sess-order')
await flushPromises()
const rows = container.querySelectorAll('.tl-row')
// Newest first: 'done event' at index 0, 'first event' at last
expect(rows[0]?.textContent).toContain('done event')
expect(rows[rows.length - 1]?.textContent).toContain('first event')
handle.dispose()
})
it('caps rendered events at maxEvents', async () => {
const now = Date.now()
const events = Array.from({ length: 10 }, (_, i) => ({
at: now + i,
class: 'tool',
label: `event ${i}`,
}))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events }))
const container = makeContainer()
const handle = mountTimeline(container, 'sess-cap', { maxEvents: 3 })
await flushPromises()
const rows = container.querySelectorAll('.tl-row')
expect(rows.length).toBe(3)
handle.dispose()
})
it('polls at the specified refreshMs interval', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-poll', { refreshMs: 1000 })
await flushPromises()
const callsAfterMount = mockFetch.mock.calls.length
// Advance by refreshMs → fire the interval once
vi.advanceTimersByTime(1000)
await flushPromises()
expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterMount)
handle.dispose()
})
it('dispose stops polling (no more fetches after dispose)', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-dispose', { refreshMs: 500 })
await flushPromises()
handle.dispose()
const callsAfterDispose = mockFetch.mock.calls.length
// Advance time — interval is cleared, no more fetches
vi.advanceTimersByTime(2000)
await flushPromises()
expect(mockFetch.mock.calls.length).toBe(callsAfterDispose)
})
it('calling dispose twice does not throw', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
const container = makeContainer()
const handle = mountTimeline(container, 'sess-double-dispose')
await flushPromises()
expect(() => {
handle.dispose()
handle.dispose()
}).not.toThrow()
})
it('returns a handle with a dispose function', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
const container = makeContainer()
const handle = mountTimeline(container, 'sess-handle')
expect(typeof handle.dispose).toBe('function')
handle.dispose()
})
it('uses the label field rendered as textContent (SEC-H6)', async () => {
const now = Date.now()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ at: now, class: 'tool', label: '<script>xss</script>' }],
}),
)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-xss')
await flushPromises()
expect(container.querySelectorAll('script').length).toBe(0)
expect(container.textContent).toContain('<script>xss</script>')
handle.dispose()
})
it('clears and re-renders on each poll cycle', async () => {
let callCount = 0
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(async () => {
callCount++
const label = `event-cycle-${callCount}`
return { ok: true, json: async () => [{ at: Date.now(), class: 'tool', label }] }
}),
)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-refresh', { refreshMs: 500 })
await flushPromises()
expect(container.textContent).toContain('event-cycle-1')
vi.advanceTimersByTime(500)
await flushPromises()
// After re-render, shows latest cycle data
expect(container.textContent).toContain('event-cycle-2')
// Only shows one set of rows (cleared before re-render)
expect(container.querySelectorAll('.tl-row').length).toBe(1)
handle.dispose()
})
})

447
test/voice.test.ts Normal file
View File

@@ -0,0 +1,447 @@
// @vitest-environment jsdom
/**
* test/voice.test.ts — Unit tests for voice.ts (N-voice, A2).
*
* Tests the Web Speech API wrapper (isSpeechSupported / createVoiceInput)
* and the keybar mic-button integration (mountKeybar opts.onVoiceTrigger).
* Runs under jsdom so window / document / TouchEvent are available; the real
* SpeechRecognition is replaced by a lightweight self-registering mock class.
*
* AC-A2.1 final transcript calls onTranscript
* AC-A2.2 touch preventDefault keeps soft keyboard hidden
* AC-A2.3 unsupported → hidden, no crash
* AC-A2.4 autoSend appends \r; off → no \r
* AC-A2.5 audio never reaches server (pure-FE — no server stub needed here)
* SEC-L2 disclosure via title attribute on mic button
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isSpeechSupported, createVoiceInput } from '../public/voice.js'
import { mountKeybar } from '../public/keybar.js'
// ── Mock SpeechRecognition ────────────────────────────────────────────────────
// Using a self-registering class pattern: each `new MockRecognition()` stores
// itself in MockRecognition.last, so tests can reference the instance created
// by createVoiceInput() without needing vi.fn() as the constructor wrapper.
/** Minimal SpeechRecognitionEvent-like shape for test usage. */
interface MockResultEvent {
resultIndex: number
results: {
length: number
[index: number]: {
isFinal: boolean
length: number
[index: number]: { transcript: string; confidence: number }
}
}
}
class MockRecognition {
/** The most-recently constructed instance — set in the constructor. */
static last: MockRecognition | null = null
continuous = false
interimResults = false
lang = ''
onresult: ((ev: MockResultEvent) => void) | null = null
onend: (() => void) | null = null
onerror: ((ev: { error: string }) => void) | null = null
// Arrow-function class fields create fresh vi.fn() per instance
readonly start = vi.fn()
readonly stop = vi.fn()
readonly abort = vi.fn()
constructor() {
MockRecognition.last = this
}
}
/** Build a single-result event for testing. */
function makeResultEvent(transcript: string, isFinal: boolean, resultIndex = 0): MockResultEvent {
const alt = { transcript, confidence: 1.0 }
const result = Object.assign([alt], { isFinal, length: 1 })
const results = Object.assign([result], { length: 1 })
return { resultIndex, results } as unknown as MockResultEvent
}
// ─────────────────────────────────────────────────────────────────────────────
// isSpeechSupported
// ─────────────────────────────────────────────────────────────────────────────
describe('isSpeechSupported', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns false when SpeechRecognition is not in window', () => {
// jsdom does not provide SpeechRecognition by default
expect(isSpeechSupported()).toBe(false)
})
it('returns true when SpeechRecognition is present in window', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
expect(isSpeechSupported()).toBe(true)
})
it('returns true when webkitSpeechRecognition is present in window', () => {
vi.stubGlobal('webkitSpeechRecognition', MockRecognition)
expect(isSpeechSupported()).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────────────────────
// createVoiceInput
// ─────────────────────────────────────────────────────────────────────────────
describe('createVoiceInput', () => {
beforeEach(() => {
MockRecognition.last = null
vi.stubGlobal('SpeechRecognition', MockRecognition)
})
afterEach(() => {
vi.unstubAllGlobals()
})
// --- null when unsupported ---
it('returns null when speech is not supported', () => {
vi.unstubAllGlobals()
const voice = createVoiceInput(vi.fn())
expect(voice).toBeNull()
})
// --- object shape ---
it('returns a VoiceInput object with required methods', () => {
const voice = createVoiceInput(vi.fn())
expect(voice).not.toBeNull()
expect(typeof voice?.start).toBe('function')
expect(typeof voice?.stop).toBe('function')
expect(typeof voice?.dispose).toBe('function')
expect(typeof voice?.isActive).toBe('function')
})
// --- isActive ---
it('starts in inactive state', () => {
const voice = createVoiceInput(vi.fn())
expect(voice?.isActive()).toBe(false)
})
it('isActive returns true after start()', () => {
const voice = createVoiceInput(vi.fn())
voice?.start()
expect(voice?.isActive()).toBe(true)
})
it('isActive returns false after stop()', () => {
const voice = createVoiceInput(vi.fn())
voice?.start()
voice?.stop()
expect(voice?.isActive()).toBe(false)
})
it('isActive returns false after natural onend fires', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
mock.onend?.()
expect(voice?.isActive()).toBe(false)
})
it('isActive returns false after onerror fires', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
mock.onerror?.({ error: 'network' })
expect(voice?.isActive()).toBe(false)
})
// --- recognition.start / stop calls ---
it('calls recognition.start() on start()', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
expect(mock.start).toHaveBeenCalledTimes(1)
})
it('does not call recognition.start() twice when already active', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
voice?.start()
expect(mock.start).toHaveBeenCalledTimes(1)
})
it('calls recognition.stop() on stop() when active', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
voice?.stop()
expect(mock.stop).toHaveBeenCalledTimes(1)
})
it('does not call recognition.stop() when not active', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.stop()
expect(mock.stop).not.toHaveBeenCalled()
})
// --- transcript callbacks ---
it('calls onTranscript with final transcript text (AC-A2.1)', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello world', true))
expect(onTranscript).toHaveBeenCalledWith('hello world')
})
it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript, { autoSend: true })
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello\r')
})
it('autoSend=false does not append \\r (AC-A2.4)', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript, { autoSend: false })
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello')
})
it('omitting autoSend does not append \\r', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello')
})
it('calls onInterim with interim transcript text', () => {
const onInterim = vi.fn()
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript, { onInterim })
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hel', false))
expect(onInterim).toHaveBeenCalledWith('hel')
expect(onTranscript).not.toHaveBeenCalled()
})
it('does not crash when interim fires but onInterim is not provided', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
const mock = MockRecognition.last!
voice?.start()
expect(() => mock.onresult?.(makeResultEvent('par', false))).not.toThrow()
expect(onTranscript).not.toHaveBeenCalled()
})
it('does not call onTranscript for interim-only results', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript, { onInterim: vi.fn() })
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('partial', false))
expect(onTranscript).not.toHaveBeenCalled()
})
// --- dispose ---
it('does not fire onTranscript after dispose()', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
const mock = MockRecognition.last!
voice?.start()
voice?.dispose()
mock.onresult?.(makeResultEvent('late transcript', true))
expect(onTranscript).not.toHaveBeenCalled()
})
it('calls recognition.abort() when disposed while active', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
voice?.dispose()
expect(mock.abort).toHaveBeenCalledTimes(1)
})
it('does not call recognition.abort() when disposed while inactive', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.dispose()
expect(mock.abort).not.toHaveBeenCalled()
})
it('dispose() is idempotent — double dispose is safe', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.start()
expect(() => { voice?.dispose(); voice?.dispose() }).not.toThrow()
expect(mock.abort).toHaveBeenCalledTimes(1)
})
it('start() after dispose() is a no-op', () => {
const voice = createVoiceInput(vi.fn())
const mock = MockRecognition.last!
voice?.dispose()
voice?.start()
expect(mock.start).not.toHaveBeenCalled()
})
// --- configuration ---
it('sets recognition.lang to the provided lang option', () => {
createVoiceInput(vi.fn(), { lang: 'ja-JP' })
expect(MockRecognition.last?.lang).toBe('ja-JP')
})
it('uses navigator.language by default', () => {
createVoiceInput(vi.fn())
expect(MockRecognition.last?.lang).toBe(navigator.language)
})
it('sets interimResults=true when onInterim is provided', () => {
createVoiceInput(vi.fn(), { onInterim: vi.fn() })
expect(MockRecognition.last?.interimResults).toBe(true)
})
it('sets interimResults=false when onInterim is not provided', () => {
createVoiceInput(vi.fn())
expect(MockRecognition.last?.interimResults).toBe(false)
})
it('uses webkitSpeechRecognition when SpeechRecognition is absent', () => {
vi.unstubAllGlobals()
// Re-create MockRecognition.last tracking
class WebkitMockRecognition extends MockRecognition {}
vi.stubGlobal('webkitSpeechRecognition', WebkitMockRecognition)
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
expect(voice).not.toBeNull()
voice?.start()
expect(MockRecognition.last?.start).toHaveBeenCalled()
vi.unstubAllGlobals()
})
})
// ─────────────────────────────────────────────────────────────────────────────
// mountKeybar voice button (onVoiceTrigger integration)
// ─────────────────────────────────────────────────────────────────────────────
describe('mountKeybar voice button', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="keybar"></div>'
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
it('does not append a mic button when speech is not supported (AC-A2.3)', () => {
// No SpeechRecognition in window by default in jsdom
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
expect(document.querySelector('[data-key="voice"]')).toBeNull()
})
it('does not append a mic button when onVoiceTrigger is not provided', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn())
expect(document.querySelector('[data-key="voice"]')).toBeNull()
})
it('appends a mic button when supported and onVoiceTrigger is provided', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
expect(document.querySelector('[data-key="voice"]')).not.toBeNull()
})
it('mic button displays 🎤 key label', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
const keyEl = document.querySelector('[data-key="voice"] .kb-key')
expect(keyEl?.textContent).toBe('🎤')
})
it('mic button has a caption label', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
const capEl = document.querySelector('[data-key="voice"] .kb-cap')
expect(capEl?.textContent).toBeTruthy()
})
it('touchstart calls onVoiceTrigger("start") and prevents default (AC-A2.2)', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
const onVoiceTrigger = vi.fn()
mountKeybar(vi.fn(), { onVoiceTrigger })
const btn = document.querySelector('[data-key="voice"]')!
const ev = new Event('touchstart', { cancelable: true, bubbles: true })
btn.dispatchEvent(ev)
expect(onVoiceTrigger).toHaveBeenCalledWith('start')
})
it('touchend calls onVoiceTrigger("stop") and prevents default', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
const onVoiceTrigger = vi.fn()
mountKeybar(vi.fn(), { onVoiceTrigger })
const btn = document.querySelector('[data-key="voice"]')!
const ev = new Event('touchend', { cancelable: true, bubbles: true })
btn.dispatchEvent(ev)
expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
})
it('mousedown calls onVoiceTrigger("start") for desktop fallback', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
const onVoiceTrigger = vi.fn()
mountKeybar(vi.fn(), { onVoiceTrigger })
const btn = document.querySelector('[data-key="voice"]')!
btn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true, bubbles: true }))
expect(onVoiceTrigger).toHaveBeenCalledWith('start')
})
it('mouseup calls onVoiceTrigger("stop") for desktop fallback', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
const onVoiceTrigger = vi.fn()
mountKeybar(vi.fn(), { onVoiceTrigger })
const btn = document.querySelector('[data-key="voice"]')!
btn.dispatchEvent(new MouseEvent('mouseup', { cancelable: true, bubbles: true }))
expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
})
it('mic button title discloses audio privacy (SEC-L2)', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
const btn = document.querySelector<HTMLElement>('[data-key="voice"]')!
expect(btn.title.toLowerCase()).toMatch(/audio/)
})
it('existing key buttons are still rendered when voice opts provided', () => {
vi.stubGlobal('SpeechRecognition', MockRecognition)
mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
expect(document.querySelector('[data-key="esc"]')).not.toBeNull()
})
it('mountKeybar works with no opts (backward compat)', () => {
expect(() => mountKeybar(vi.fn())).not.toThrow()
})
it('mountKeybar returns without error when #keybar element is absent', () => {
document.body.innerHTML = '' // no #keybar
expect(() => mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })).not.toThrow()
})
})

551
test/worktree-form.test.ts Normal file
View File

@@ -0,0 +1,551 @@
// @vitest-environment jsdom
/**
* test/worktree-form.test.ts — T-projects-ui (v0.7 Walk-away Workbench)
*
* Tests for the new features wired into public/projects.ts:
* B1: "View Diff" toggle button → mountDiffViewer inline panel
* B3: renderNewWorktreeForm — client-side branch validation + POST /projects/worktree
* A4: "Activity" section — mountTimeline per running session, dispose on onBack
*
* Security: SEC-H4 (diff textContent), SEC-L3/H6 (error/label textContent)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ProjectDetail } from '../src/types.js'
// ── Stub @xterm/xterm (transitively via preview-grid.ts) ──────────────────────
class FakeTerminal {
open = vi.fn()
dispose = vi.fn()
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// ── Stub diff viewer ───────────────────────────────────────────────────────────
const mockDiffHandle = {
showWorking: vi.fn(),
showStaged: vi.fn(),
close: vi.fn(),
destroy: vi.fn(),
}
const mockMountDiffViewer = vi.fn(() => mockDiffHandle)
vi.mock('../public/diff.js', () => ({ mountDiffViewer: mockMountDiffViewer }))
// ── Stub timeline ──────────────────────────────────────────────────────────────
const mockTimelineHandle = { dispose: vi.fn() }
const mockMountTimeline = vi.fn(() => mockTimelineHandle)
vi.mock('../public/timeline.js', () => ({ mountTimeline: mockMountTimeline }))
// ── Import AFTER mocks ─────────────────────────────────────────────────────────
const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } =
await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */
function makeHooks() {
return { onOpenProject: vi.fn(), onEnterSession: vi.fn() }
}
function makeCbs() {
return { onBack: vi.fn(), onKill: vi.fn() }
}
function makeDetail(overrides: Partial<ProjectDetail> = {}): ProjectDetail {
return {
name: 'my-repo',
path: '/home/user/my-repo',
isGit: true,
branch: 'main',
worktrees: [],
sessions: [],
hasClaudeMd: false,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn())
})
/* ── validateBranchNameClient ──────────────────────────────────────────────── */
describe('validateBranchNameClient', () => {
it('returns null for a valid simple branch name', () => {
expect(validateBranchNameClient('my-branch')).toBeNull()
expect(validateBranchNameClient('feat-123')).toBeNull()
expect(validateBranchNameClient('v0.7')).toBeNull()
})
it('returns null for namespaced branches (feature/foo)', () => {
expect(validateBranchNameClient('feature/my-feature')).toBeNull()
expect(validateBranchNameClient('v0.7/walk-away')).toBeNull()
expect(validateBranchNameClient('user/fix/bug-1')).toBeNull()
})
it('returns an error string for an empty string', () => {
const result = validateBranchNameClient('')
expect(typeof result).toBe('string')
expect(result).not.toBeNull()
})
it('returns an error for a name longer than 250 characters', () => {
expect(validateBranchNameClient('a'.repeat(251))).not.toBeNull()
})
it('returns null for a name that is exactly 250 characters', () => {
expect(validateBranchNameClient('a'.repeat(250))).toBeNull()
})
it('returns an error for a name starting with "-"', () => {
expect(validateBranchNameClient('-bad')).not.toBeNull()
})
it('returns an error for a name containing ".."', () => {
expect(validateBranchNameClient('feat..bar')).not.toBeNull()
expect(validateBranchNameClient('..feat')).not.toBeNull()
})
it('returns an error for a name ending with ".lock"', () => {
expect(validateBranchNameClient('main.lock')).not.toBeNull()
})
it('returns an error for a name with control characters', () => {
expect(validateBranchNameClient('feat\x01bar')).not.toBeNull()
expect(validateBranchNameClient('feat\x00bar')).not.toBeNull()
})
it('returns an error for a name with tab', () => {
expect(validateBranchNameClient('feat\tbar')).not.toBeNull()
})
it('returns an error for a name with spaces', () => {
expect(validateBranchNameClient('my branch')).not.toBeNull()
})
it('returns an error for a name with "~"', () => {
expect(validateBranchNameClient('feat~1')).not.toBeNull()
})
it('returns an error for a name with "^"', () => {
expect(validateBranchNameClient('feat^bar')).not.toBeNull()
})
it('returns an error for a name with ":"', () => {
expect(validateBranchNameClient('feat:bar')).not.toBeNull()
})
it('returns an error for a name with "?"', () => {
expect(validateBranchNameClient('feat?bar')).not.toBeNull()
})
it('returns an error for a name with "*"', () => {
expect(validateBranchNameClient('feat*bar')).not.toBeNull()
})
it('returns an error for a name with "["', () => {
expect(validateBranchNameClient('feat[bar')).not.toBeNull()
})
it('returns an error for a name with backslash', () => {
expect(validateBranchNameClient('feat\\bar')).not.toBeNull()
})
it('returns an error for a name with "@{"', () => {
expect(validateBranchNameClient('feat@{bar}')).not.toBeNull()
expect(validateBranchNameClient('@{bar}')).not.toBeNull()
})
it('returns an error for a name starting with "/"', () => {
expect(validateBranchNameClient('/feat')).not.toBeNull()
})
it('returns an error for a name ending with "/"', () => {
expect(validateBranchNameClient('feat/')).not.toBeNull()
})
it('returns an error for a name with "//"', () => {
expect(validateBranchNameClient('feat//bar')).not.toBeNull()
})
it('all invalid cases return a non-null string', () => {
const invalids = [
'',
'-bad',
'feat..bar',
'main.lock',
'feat\x01',
'feat\tbar',
'feat bar',
'feat~1',
'feat^x',
'feat:x',
'feat?x',
'feat*x',
'feat[x',
'feat\\x',
'feat@{x}',
'/bad',
'bad/',
'feat//bar',
]
for (const b of invalids) {
const result = validateBranchNameClient(b)
expect(typeof result).toBe('string')
}
})
})
/* ── renderNewWorktreeForm ─────────────────────────────────────────────────── */
describe('renderNewWorktreeForm', () => {
it('renders a form container with a branch input and submit button', () => {
const form = renderNewWorktreeForm(makeDetail(), makeHooks())
expect(form.querySelector('input')).not.toBeNull()
expect(form.querySelector('button')).not.toBeNull()
// Error element exists but is hidden initially
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement | null
expect(errorEl).not.toBeNull()
expect(errorEl!.style.display).toBe('none')
})
it('shows a validation error when submitted with an empty branch name', () => {
const hooks = makeHooks()
const form = renderNewWorktreeForm(makeDetail(), hooks)
;(form.querySelector('button') as HTMLButtonElement).click()
expect(fetch).not.toHaveBeenCalled()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
expect(errorEl.textContent).toBeTruthy()
})
it('shows a validation error for an invalid branch name (leading hyphen)', () => {
const hooks = makeHooks()
const form = renderNewWorktreeForm(makeDetail(), hooks)
;(form.querySelector('input') as HTMLInputElement).value = '-bad'
;(form.querySelector('button') as HTMLButtonElement).click()
expect(fetch).not.toHaveBeenCalled()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
})
it('POSTs to /projects/worktree with repoPath and branch on valid input', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ ok: true, path: '/home/user/my-repo-worktrees/feat', branch: 'feat' }),
})
vi.stubGlobal('fetch', mockFetch)
const detail = makeDetail({ path: '/home/user/my-repo' })
const form = renderNewWorktreeForm(detail, makeHooks())
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
await Promise.resolve()
await Promise.resolve()
expect(mockFetch).toHaveBeenCalledWith(
'/projects/worktree',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ repoPath: '/home/user/my-repo', branch: 'feat' }),
}),
)
})
it('calls hooks.onOpenProject with the worktree path and branch on success', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
path: '/home/user/my-repo-worktrees/feat',
branch: 'feat',
}),
})
vi.stubGlobal('fetch', mockFetch)
const hooks = makeHooks()
const form = renderNewWorktreeForm(makeDetail(), hooks)
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
// Flush async microtasks
await Promise.resolve()
await Promise.resolve()
expect(hooks.onOpenProject).toHaveBeenCalledWith(
'/home/user/my-repo-worktrees/feat',
'feat',
'claude\r',
)
})
it('falls back to repoPath/branch when response omits path/branch', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ ok: true }),
})
vi.stubGlobal('fetch', mockFetch)
const hooks = makeHooks()
const detail = makeDetail({ path: '/repo' })
const form = renderNewWorktreeForm(detail, hooks)
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
await Promise.resolve()
await Promise.resolve()
expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
})
it('shows server error message via textContent on HTTP failure', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ ok: false, error: 'Branch already exists' }),
})
vi.stubGlobal('fetch', mockFetch)
const hooks = makeHooks()
const form = renderNewWorktreeForm(makeDetail(), hooks)
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
await Promise.resolve()
await Promise.resolve()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
expect(errorEl.textContent).toBe('Branch already exists')
expect(hooks.onOpenProject).not.toHaveBeenCalled()
})
it('shows a generic error on network failure (never throws)', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
vi.stubGlobal('fetch', mockFetch)
const hooks = makeHooks()
const form = renderNewWorktreeForm(makeDetail(), hooks)
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
await Promise.resolve()
await Promise.resolve()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.textContent).toBeTruthy()
expect(hooks.onOpenProject).not.toHaveBeenCalled()
})
it('SEC-H6: error message set via textContent — <script> appears as literal text', async () => {
const xss = '<script>alert(1)</script>'
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ ok: false, error: xss }),
})
vi.stubGlobal('fetch', mockFetch)
const form = renderNewWorktreeForm(makeDetail(), makeHooks())
;(form.querySelector('input') as HTMLInputElement).value = 'feat'
;(form.querySelector('button') as HTMLButtonElement).click()
await Promise.resolve()
await Promise.resolve()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
// No <script> element in DOM — textContent was used
expect(errorEl.querySelector('script')).toBeNull()
expect(errorEl.textContent).toBe(xss)
})
it('hides the error element when the user edits the input field', () => {
const form = renderNewWorktreeForm(makeDetail(), makeHooks())
// Trigger a validation error first
;(form.querySelector('button') as HTMLButtonElement).click()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
// Simulate typing in the input
const input = form.querySelector('input') as HTMLInputElement
input.value = 'f'
input.dispatchEvent(new Event('input'))
expect(errorEl.style.display).toBe('none')
})
})
/* ── renderProjectDetail — B1: View Diff ──────────────────────────────────── */
describe('renderProjectDetail — B1 View Diff', () => {
it('shows a "View Diff" toggle button for git repos', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-diff-toggle')).not.toBeNull()
})
it('does NOT show "View Diff" button for non-git directories', () => {
const root = renderProjectDetail(
makeDetail({ isGit: false, branch: undefined }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-diff-toggle')).toBeNull()
})
it('diff panel is hidden initially', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
it('clicking "View Diff" calls mountDiffViewer with the project path', () => {
const detail = makeDetail({ path: '/home/user/proj', isGit: true })
const root = renderProjectDetail(detail, makeHooks(), makeCbs())
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
expect(mockMountDiffViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'/home/user/proj',
expect.objectContaining({ onClose: expect.any(Function) }),
)
})
it('clicking "View Diff" shows the diff panel', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
expect(panel.style.display).not.toBe('none')
})
it('the onClose callback passed to mountDiffViewer hides the panel', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { onClose?: () => void }
opts?.onClose?.()
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
it('clicking the toggle again closes the diff panel and calls destroy()', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const toggle = root.querySelector('.proj-diff-toggle') as HTMLButtonElement
toggle.click() // open
toggle.click() // close
expect(mockDiffHandle.destroy).toHaveBeenCalled()
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
})
/* ── renderProjectDetail — B3: New Worktree Form ──────────────────────────── */
describe('renderProjectDetail — B3 New Worktree Form', () => {
it('includes a worktree form for git repos', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-wt-form')).not.toBeNull()
})
it('does NOT include a worktree form for non-git directories', () => {
const root = renderProjectDetail(
makeDetail({ isGit: false, branch: undefined }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-wt-form')).toBeNull()
})
})
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
describe('renderProjectDetail — A4 Activity section', () => {
const runningSess = {
id: 'sess-1',
title: 'claude',
status: 'working' as const,
clientCount: 1,
createdAt: 1,
exited: false,
}
const exitedSess = {
id: 'sess-2',
title: 'shell',
status: 'idle' as const,
clientCount: 0,
createdAt: 2,
exited: true,
}
it('shows the activity section when at least one session is running', () => {
const root = renderProjectDetail(
makeDetail({ sessions: [runningSess] }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-activity-section')).not.toBeNull()
})
it('mounts a timeline for each running session', () => {
renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), makeCbs())
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
})
it('does NOT mount timelines for exited sessions', () => {
renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
expect(mockMountTimeline).not.toHaveBeenCalled()
})
it('mounts one timeline per running session', () => {
const sess2 = { id: 'sess-3', title: 'codex', status: 'working' as const, clientCount: 1, createdAt: 3, exited: false }
renderProjectDetail(
makeDetail({ sessions: [runningSess, sess2] }),
makeHooks(),
makeCbs(),
)
expect(mockMountTimeline).toHaveBeenCalledTimes(2)
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-3')
})
it('disposes timeline handles when onBack is clicked', () => {
const cb = makeCbs()
const root = renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), cb)
expect(mockTimelineHandle.dispose).not.toHaveBeenCalled()
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(mockTimelineHandle.dispose).toHaveBeenCalled()
expect(cb.onBack).toHaveBeenCalled()
})
it('hides the activity section when no sessions are running', () => {
const root = renderProjectDetail(makeDetail({ sessions: [] }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-activity-section')).toBeNull()
expect(mockMountTimeline).not.toHaveBeenCalled()
})
it('hides the activity section when all sessions are exited', () => {
const root = renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-activity-section')).toBeNull()
})
it('timeline collector receives handles when provided (re-render disposal)', () => {
const collector: Array<{ dispose(): void }> = []
renderProjectDetail(
makeDetail({ sessions: [runningSess] }),
makeHooks(),
makeCbs(),
collector,
)
expect(collector).toHaveLength(1)
expect(collector[0]).toBe(mockTimelineHandle)
})
it('disposes the diff handle on back when the diff panel was open', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
// Open diff
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
// Press back
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(mockDiffHandle.destroy).toHaveBeenCalled()
})
})