test(hooks): poll for detached-hook effects instead of a fixed sleep (flake fix)
The bridge tests that drive observe-only emit listeners (session-start, subagent/start, subagent/end) fire their hook on a detached `.then` the test cannot await. They waited a fixed 50-80ms, which flaked under the full test:coverage run's heavy parallel load (transform ~400s): the sleep expired before the async hook completed, so the injected context / marker file / warn call had not landed. Replace each fixed sleep with a `waitFor(predicate)` poll that retries until the observable effect appears (5s deadline) — "async state is not synchronous state": wait for the signal that actually fires, not a guessed duration. No behavior change; the same assertions, made robust to scheduling.
This commit is contained in:
@@ -64,6 +64,20 @@ function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `predicate` until it returns true or the deadline passes. Detached
|
||||
* emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
|
||||
* await directly; polling for the observable EFFECT is robust under load, where a
|
||||
* single fixed sleep flakes ("async state is not synchronous state").
|
||||
*/
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
|
||||
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
|
||||
@@ -239,8 +253,11 @@ describe('hooks-claude bridge — SessionStart', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
// session-start fires async; wait a tick for the inject before sending.
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
// session-start fires async (detached .then → agent.inject); wait for the
|
||||
// injected context/message to actually land before sending, rather than a
|
||||
// fixed sleep that flakes under load.
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -274,10 +291,11 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher' })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher', stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
// Both hooks run async (detached .then); let them settle.
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
|
||||
// Both hooks run async (detached .then); poll for their marker files rather
|
||||
// than a fixed sleep that flakes under load.
|
||||
const { existsSync } = await import('node:fs')
|
||||
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
|
||||
expect(existsSync(startMarker)).toBe(true)
|
||||
expect(existsSync(stopMarker)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -45,6 +45,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
|
||||
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
|
||||
@@ -177,7 +186,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x'), agentType: 'r' })
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
|
||||
@@ -193,7 +202,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') })
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
})
|
||||
@@ -238,7 +247,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) // no agentType
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -307,7 +316,6 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so the
|
||||
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
@@ -438,7 +446,7 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
const original = agent.inject.bind(agent)
|
||||
let threw = false
|
||||
agent.inject = (() => { threw = true; throw new Error('inject boom') })
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
await waitFor(() => threw)
|
||||
expect(threw).toBe(true)
|
||||
agent.inject = original
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
|
||||
@@ -36,6 +36,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
}
|
||||
|
||||
describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
|
||||
@@ -66,7 +75,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
|
||||
})
|
||||
@@ -148,7 +158,6 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
|
||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
@@ -174,7 +183,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
// A completed turn proves session-start already ran; the clean no-output hook
|
||||
// injected nothing, so no context/message exists.
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
|
||||
})
|
||||
@@ -187,7 +197,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.inject = (() => { throw new Error('inject boom') })
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
|
||||
})
|
||||
|
||||
@@ -343,7 +353,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
await waitFor(() => events(agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user