Files
web-terminal/test/setup-hooks.test.ts
Yaojia Wang d6809c65c4 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.
2026-06-30 17:42:18 +02:00

437 lines
19 KiB
TypeScript

/**
* 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)
}
})
})