import { afterEach, describe, expect, it, vi } from 'vitest' import { PluginHookParticipantGoneError, runPluginHookChain, type PluginHookChainInput } from './plugin-hook-chain' import { runPluginWorkerHookHandlers, type PluginHookHandler } from './plugin-worker-hooks' import { terminalSendHookPayloadSchema } from '../events/plugin-hook-payloads' const send = { terminalId: 'term_1', text: 'ls', enter: true, inputKind: 'driving' } const read = (event: unknown) => terminalSendHookPayloadSchema.parse(event) /** Each plugin is a list of worker handlers, run as its worker would run them. */ function chain( plugins: Record, overrides: Partial = {} ) { const logs: string[] = [] const outcome = runPluginHookChain({ event: 'terminal.send', payload: send, participants: Object.keys(plugins), invoke: (pluginKey, payload) => runPluginWorkerHookHandlers(plugins[pluginKey] ?? [], payload), log: (pluginKey, line) => void logs.push(`${pluginKey}: ${line}`), now: () => 0, ...overrides }) return { outcome, logs } } afterEach(() => { vi.useRealTimers() }) describe('runPluginHookChain', () => { it('carries a changed event to the next plugin, in order', async () => { const seen: string[] = [] const { outcome } = chain({ 'a.one': [(event, next) => next({ ...read(event), text: 'ls -la' })], 'a.two': [ (event, next) => { seen.push(read(event).text) return next({ ...read(event), enter: false }) } ] }) await expect(outcome).resolves.toEqual({ kind: 'continue', payload: { ...send, text: 'ls -la', enter: false } }) expect(seen).toEqual(['ls -la']) }) it('keeps fields the hook does not let plugins change', async () => { const { outcome } = chain({ 'a.one': [(event, next) => next({ ...read(event), terminalId: 'term_other' })] }) await expect(outcome).resolves.toEqual({ kind: 'continue', payload: send }) }) it('stops at a deny, so later plugins never run', async () => { const later = vi.fn() const { outcome } = chain({ 'a.one': [() => ({ deny: 'blocked' })], 'a.two': [later] }) await expect(outcome).resolves.toEqual({ kind: 'deny', pluginKey: 'a.one', reason: 'blocked' }) expect(later).not.toHaveBeenCalled() }) it('treats a throw as passing and logs plugin, event and duration', async () => { const { outcome, logs } = chain( { 'a.one': [ () => { throw new Error('boom') } ], 'a.two': [(event, next) => next({ ...read(event), text: 'pwd' })] }, { now: vi.fn().mockReturnValueOnce(10).mockReturnValue(42) } ) await expect(outcome).resolves.toEqual({ kind: 'continue', payload: { ...send, text: 'pwd' } }) expect(logs).toEqual(['a.one: hook terminal.send skipped after 32ms: boom']) }) it('carries on past a plugin that misses its deadline', async () => { vi.useFakeTimers() const { outcome, logs } = chain( { 'a.slow': [], 'a.next': [] }, { invoke: (pluginKey, payload) => pluginKey === 'a.slow' ? new Promise(() => undefined) : runPluginWorkerHookHandlers([() => ({ deny: 'second' })], payload), deadlineMs: 2_000 } ) await vi.advanceTimersByTimeAsync(2_000) await expect(outcome).resolves.toEqual({ kind: 'deny', pluginKey: 'a.next', reason: 'second' }) expect(logs).toEqual(['a.slow: hook terminal.send skipped after 0ms: timed out after 2000ms']) }) it('ignores results of observe-only hooks', async () => { const created = { workspaceId: 'w', kind: 'folder', path: '/tmp/w', branch: '' } const { outcome } = chain( { 'a.one': [() => ({ deny: 'no' })] }, { event: 'workspace.create.after', payload: created } ) await expect(outcome).resolves.toEqual({ kind: 'continue', payload: created }) }) it('runs one worker’s handlers in registration order', async () => { const decision = await runPluginWorkerHookHandlers( [ (event, next) => next({ ...read(event), text: 'a' }), (event, next) => next({ ...read(event), text: `${read(event).text}b` }) ], send ) expect(decision).toEqual({ decision: 'continue', payload: { ...send, text: 'ab' } }) }) it('refuses when a fail-closed plugin throws, misses its deadline or answers nonsense', async () => { const failsClosed = () => true const thrown = chain( { 'a.guard': [ () => { throw new Error('status bar down') } ] }, { failsClosed } ) await expect(thrown.outcome).resolves.toEqual({ kind: 'deny', pluginKey: 'a.guard', reason: 'a.guard failed and denies on error: status bar down' }) expect(thrown.logs[0]).toMatch(/denied after 0ms \(onError: deny\)/) const nonsense = chain( {}, { participants: ['a.guard'], invoke: async () => 'nope', failsClosed } ) await expect(nonsense.outcome).resolves.toMatchObject({ kind: 'deny', pluginKey: 'a.guard' }) }) it('refuses when a fail-closed plugin returns a malformed denial', async () => { const failsClosed = () => true const tooLong = chain({ 'a.guard': [() => ({ deny: 'x'.repeat(501) })] }, { failsClosed }) await expect(tooLong.outcome).resolves.toMatchObject({ kind: 'deny', pluginKey: 'a.guard' }) expect(tooLong.logs[0]).toMatch(/denied after 0ms \(onError: deny\): malformed result/) const wrongShape = chain({ 'a.guard': [() => ({ deny: 7 })] }, { failsClosed }) await expect(wrongShape.outcome).resolves.toMatchObject({ kind: 'deny', pluginKey: 'a.guard' }) }) it('passes a fail-closed plugin that is gone, and observe-only hooks', async () => { const gone = chain( {}, { participants: ['a.guard'], invoke: async () => { throw new PluginHookParticipantGoneError('the plugin is no longer enabled') }, failsClosed: () => true } ) await expect(gone.outcome).resolves.toEqual({ kind: 'continue', payload: send }) const created = { workspaceId: 'w', kind: 'folder', path: '/w', branch: '' } const observe = chain( {}, { event: 'workspace.create.after', payload: created, participants: ['a.guard'], invoke: async () => { throw new Error('boom') }, failsClosed: () => true } ) await expect(observe.outcome).resolves.toEqual({ kind: 'continue', payload: created }) }) })