Files
deepseek-harness/packages/api/remotes/tests/remote-events.host.spec.ts
T

233 lines
7.7 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import type {
RemoteEventHostInfo,
TypertRemoteEventInvocation,
TypertRemoteEventSource,
} from '@deepseek-ai/dsh-api-gateway'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { describe, expect, it } from 'vitest'
import { apply, inject } from '../src/index.ts'
interface GatewayProbe {
source: TypertRemoteEventSource | undefined
host: RemoteEventHostInfo | undefined
removals: number
registerRemoteEvents(
source: TypertRemoteEventSource,
host: RemoteEventHostInfo,
): () => Promise<void>
}
async function setup(): Promise<{
readonly ctx: Context
readonly gateway: GatewayProbe
readonly fiber: Fiber
}> {
const ctx = new Context()
const gateway: GatewayProbe = {
source: undefined,
host: undefined,
removals: 0,
registerRemoteEvents(source, host) {
gateway.source = source
gateway.host = host
return async () => {
if (gateway.source !== source) return
gateway.source = undefined
gateway.host = undefined
gateway.removals += 1
}
},
}
ctx.reflect.provide('typertGateway', gateway)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber
return { ctx, gateway, fiber }
}
function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource {
if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source')
return gateway.source
}
function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void {
const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void
emit(event, ...args)
}
function waterfallRaw(
ctx: Context,
target: object,
event: string,
args: readonly unknown[],
next: () => Promise<unknown>,
): Promise<unknown> {
const waterfall = ctx.waterfall.bind(ctx) as unknown as (
receiver: object,
name: string,
...values: readonly unknown[]
) => Promise<unknown>
return waterfall(target, event, ...args, next)
}
function invocationOf(value: unknown): TypertRemoteEventInvocation {
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) {
throw new Error('fixture did not receive a scoped Remote Event invocation')
}
return value as TypertRemoteEventInvocation
}
describe('Remote event Host source', () => {
it('registers the Host home used by Client connection generations', async () => {
const { gateway, fiber } = await setup()
expect(gateway.host?.home).toBeTypeOf('string')
expect(gateway.host?.home.length).toBeGreaterThan(0)
await fiber.dispose()
expect(gateway.host).toBeUndefined()
})
it('gives each Client stream an independent allowlisted event queue', async () => {
const { ctx, gateway, fiber } = await setup()
const firstAbort = new AbortController()
const secondAbort = new AbortController()
const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]()
const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]()
emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1])
await expect(first.next()).resolves.toEqual({
done: false,
value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
})
await expect(second.next()).resolves.toEqual({
done: false,
value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
})
const firstDone = first.next()
firstAbort.abort(new Error('first Client disconnected'))
emitRaw(ctx, 'commands/change', [])
await expect(firstDone).resolves.toEqual({ done: true, value: undefined })
await expect(second.next()).resolves.toEqual({
done: false,
value: { event: 'commands/change', args: [] },
})
const secondDone = second.next()
secondAbort.abort(new Error('second Client disconnected'))
await expect(secondDone).resolves.toEqual({ done: true, value: undefined })
await fiber.dispose()
expect(gateway.source).toBeUndefined()
expect(gateway.removals).toBe(1)
await ctx.fiber.dispose()
})
it('rejects a non-JSON argument without poisoning the stream', async () => {
const { ctx, gateway } = await setup()
const abort = new AbortController()
const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
const pending = iterator.next()
expect(() => {
emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n])
}).toThrow('argument 1 is not lossless JSON data')
emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2])
await expect(pending).resolves.toEqual({
done: false,
value: { event: 'settings/document-updated', args: ['ui-theme', 2] },
})
const done = iterator.next()
abort.abort()
await expect(done).resolves.toEqual({ done: true, value: undefined })
const alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next())
.resolves.toEqual({ done: true, value: undefined })
await ctx.fiber.dispose()
})
it('bridges scoped waterfall result, next delegation, and rejection', async () => {
const { ctx, gateway } = await setup()
const abort = new AbortController()
const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
const agentCtx = ctx.extend()
const agent = { ctx: agentCtx }
const target = scopeTarget(ctx, agent)
const request = { questions: [], agent }
const claimed = waterfallRaw(
ctx,
target,
'user-questions/request',
[request],
() => Promise.resolve('host fallback'),
)
const claimedDispatch = invocationOf((await iterator.next()).value)
expect(claimedDispatch).toMatchObject({
event: 'user-questions/request',
request,
context: { value: agentCtx, subject: agent },
})
claimedDispatch.resolve({ kind: 'result', value: 'client answer' })
await expect(claimed).resolves.toBe('client answer')
const delegated = waterfallRaw(
ctx,
target,
'user-questions/request',
[request],
() => Promise.resolve('host fallback'),
)
const delegatedDispatch = invocationOf((await iterator.next()).value)
delegatedDispatch.resolve({ kind: 'next' })
await expect(delegated).resolves.toBe('host fallback')
const rejection = Object.assign(new Error('the user cancelled ask_user_question'), {
code: 'ASK_CANCELLED',
})
const rejected = waterfallRaw(
ctx,
target,
'user-questions/request',
[request],
() => Promise.resolve('host fallback'),
)
const rejectedAssertion = expect(rejected).rejects.toBe(rejection)
const rejectedDispatch = invocationOf((await iterator.next()).value)
rejectedDispatch.reject(rejection)
await rejectedAssertion
const done = iterator.next()
abort.abort()
await expect(done).resolves.toEqual({ done: true, value: undefined })
await ctx.fiber.dispose()
})
it('rejects a queued scoped waterfall when its source is withdrawn', async () => {
const { ctx, gateway, fiber } = await setup()
const abort = new AbortController()
const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
const delivery = iterator.next()
const agent = { ctx: ctx.extend() }
const reason = new Error('forwarded event source removed')
const pending = waterfallRaw(
ctx,
scopeTarget(ctx, agent),
'user-questions/request',
[{ questions: [], agent }],
() => Promise.resolve('host fallback'),
)
const rejected = expect(pending).rejects.toBe(reason)
abort.abort(reason)
await rejected
await expect(delivery).resolves.toEqual({ done: true, value: undefined })
await fiber.dispose()
await ctx.fiber.dispose()
})
})