Files
deepseek-harness/packages/hooks/hook-protocol/tests/runner.spec.ts
T
Tianyi Cui 0b97b2f7c1 restore hook/result durationMs — review keeps wall-clock audit timing durable
Reverses item 3 of the tighten-hook-protocol-contract RFC per review:
a persistence log is written for future readers, and hook wall-clock
runtime is audit signal (which hook made a turn slow). runHook keeps
its injected now clock and RunHookResult wrapper, the bridges pass the
measured duration through HookResultRecord, the snapshot normalizer
keeps its replay scrub, and the hook fixtures carry the field again.
The RFC records the reversal; the other three prunes stand.
2026-07-04 22:00:12 +08:00

149 lines
6.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
* two methods, so a duck-typed recorder is the right test seam — the REAL
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
* that consume this library, not here.
*/
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
bash: BashExecutor
specs: BashExecSpec[]
} {
const specs: BashExecSpec[] = []
const bash = {
resolve(request: BashExecRequest): BashExecSpec {
// Carry the request through verbatim, defaulting the required spec fields —
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
}
},
async run(spec: BashExecSpec): Promise<BashRunResult> {
specs.push(spec)
return run(spec)
},
} as unknown as BashExecutor
return { bash, specs }
}
function result(over: Partial<BashRunResult> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
...over,
}
}
const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5
describe('runHook — payload + env + stdin plumbing', () => {
it('serializes the payload to stdin (with trailing newline when requested)', async () => {
const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } }))
await runHook(bash, { command: 'my-hook.sh' }, {
payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' },
defaultTimeoutMs: 60000,
trailingNewline: true,
}, clock())
expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n')
expect(specs[0]!.command).toBe('my-hook.sh')
})
it('omits the trailing newline when trailingNewline is false (Codex)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock())
expect(specs[0]!.stdin).toBe('{"a":1}')
})
it('threads env and cwd into the request', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, {
payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
defaultTimeoutMs: 1000, trailingNewline: true,
}, clock())
expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' })
expect(specs[0]!.workdir).toBe('/work')
})
it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(3000)
})
it('falls back to the default timeout when the hook sets none', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(60000)
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
})
it('passes the abort signal through', async () => {
const controller = new AbortController()
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(specs[0]!.signal).toBe(controller.signal)
})
})
describe('runHook — outcome decoding + duration', () => {
it('decodes a clean exit with structured stdout and reports a duration', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false },
}))
const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.decision).toBe('block')
expect(output.reason).toBe('no')
expect(durationMs).toBe(5)
})
it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => {
const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } }))
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.decision).toBeUndefined()
expect(output.stderr).toBe('killed')
})
it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => {
const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.stderr).toBe('bad workdir: ENOENT')
expect(output.decision).toBeUndefined()
})
it('a non-Error rejection is stringified onto stderr', async () => {
const { bash } = recordingBash(async () => { throw 'plain string fault' })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.stderr).toBe('plain string fault')
})
it('threads expectedEventName so a mismatched hookSpecificOutput block is discarded', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0,
stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false },
}))
const { output } = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop',
}, clock())
// A PreToolUse block on a Stop hook is malformed → its decision is discarded.
expect(output.hookEventName).toBe('PreToolUse')
expect(output.decision).toBeUndefined()
})
})