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