feat(v0.3): H2 server — Claude Code hooks → live status side-channel
- types: ClaudeStatus + ServerMessage 'status'; Session.claudeStatus; SessionManager.handleHookEvent - session: inject WEBTERM_SESSION + WEBTERM_HOOK_URL into the spawned shell env so hooks know which tab they belong to - http/hook.ts: pure event→status mapper (PreToolUse→working, PermissionRequest/ Notification:permission_prompt→waiting, Stop/idle_prompt→idle); 6 unit tests - server: POST /hook (loopback-only, express.json 64kb) → manager pushes 'status' - fix: closeIdleConnections() on shutdown so an idle hook keep-alive socket doesn't hang close()/SIGTERM - integration ⑦ (real PTY): attach → POST /hook → ws receives status. 205 tests green.
This commit is contained in:
47
test/hook.test.ts
Normal file
47
test/hook.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseHookEvent } from '../src/http/hook.js'
|
||||
|
||||
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
|
||||
describe('parseHookEvent', () => {
|
||||
it('maps tool events to "working"', () => {
|
||||
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
|
||||
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({
|
||||
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 Notification permission_prompt to "waiting", idle_prompt to "idle"', () => {
|
||||
expect(
|
||||
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
|
||||
).toEqual({ sessionId: SID, status: 'waiting' })
|
||||
expect(
|
||||
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }),
|
||||
).toEqual({ 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',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for missing 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()
|
||||
expect(parseHookEvent(SID, 'nope')).toBeNull()
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull()
|
||||
expect(parseHookEvent(SID, {})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -545,4 +545,48 @@ describe('startServer — integration', () => {
|
||||
await waitForClose(ws2, 3_000).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
|
||||
// ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ───
|
||||
itPty(
|
||||
'⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)',
|
||||
async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${port}` },
|
||||
})
|
||||
await waitForOpen(ws, 3_000)
|
||||
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
const attached = (await waitForMessage(
|
||||
ws,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
|
||||
5_000,
|
||||
)) as Record<string, unknown>
|
||||
const sessionId = attached['sessionId'] as string
|
||||
|
||||
// Arm the status listener BEFORE firing the hook — the server pushes the
|
||||
// status frame synchronously (before the POST returns 204), so attaching
|
||||
// the listener after the fetch would race past it.
|
||||
const statusPromise = waitForMessage(
|
||||
ws,
|
||||
(m) =>
|
||||
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'status',
|
||||
5_000,
|
||||
)
|
||||
|
||||
// Simulate a Claude Code PermissionRequest hook firing for this session.
|
||||
const res = 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: 'PermissionRequest', tool_name: 'Bash' }),
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const status = (await statusPromise) as Record<string, unknown>
|
||||
expect(status['status']).toBe('waiting')
|
||||
expect(status['detail']).toBe('Bash')
|
||||
|
||||
ws.close()
|
||||
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user