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:
@@ -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
483
test/diff.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
348
test/http/diff.test.ts
Normal 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` [31mred'
|
||||
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)
|
||||
})
|
||||
})
|
||||
373
test/http/statusline.test.ts
Normal file
373
test/http/statusline.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
205
test/http/worktrees-create.test.ts
Normal file
205
test/http/worktrees-create.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
422
test/integration/push.test.ts
Normal file
422
test/integration/push.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
217
test/integration/timeline-events.test.ts
Normal file
217
test/integration/timeline-events.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
203
test/integration/worktree.test.ts
Normal file
203
test/integration/worktree.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
@@ -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
508
test/push.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
326
test/push/push-service.test.ts
Normal file
326
test/push/push-service.test.ts
Normal 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
|
||||
170
test/push/subscription-store.test.ts
Normal file
170
test/push/subscription-store.test.ts
Normal 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
552
test/quick-reply.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
349
test/session/timeline.test.ts
Normal file
349
test/session/timeline.test.ts
Normal 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 (0x00–0x1f)', () => {
|
||||
// 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
436
test/setup-hooks.test.ts
Normal 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
262
test/statusline.test.ts
Normal 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
190
test/sw-push.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
305
test/telemetry-gauge.test.ts
Normal file
305
test/telemetry-gauge.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
526
test/timeline.test.ts
Normal 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
447
test/voice.test.ts
Normal 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
551
test/worktree-form.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user