Files
orca/src/shared/plugins/workers/plugin-hook-chain.test.ts
T
OrcaWinandOrcaWin c0d3d2e0db test: remove 13 low-value suites and redundant cases (#40138)
* Remove low-value file, runtime and chat test cases

* test: remove low-value shared contract and timing suites

* test: remove leaking browser IPC mock suite

* test: isolate legacy relay bridge transport fixture

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-11-08 21:51:31 -08:00

186 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PluginHookHandler[]>,
overrides: Partial<PluginHookChainInput> = {}
) {
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 })
})
})