|
|
@@ -2,18 +2,38 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
|
|
import type { Fiber } from '@deepseek-ai/cordis'
|
|
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
|
import { z } from 'zod'
|
|
|
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
+import {
|
|
|
+ apply as applyConnection,
|
|
|
+ type ConnectionGenerationSource,
|
|
|
+ type ConnectionHandle,
|
|
|
+} from '@deepseek-ai/dsh-client-connection/client'
|
|
|
import type {
|
|
|
InvocationDescriptor,
|
|
|
RemoteResult,
|
|
|
- TypertClientRemote,
|
|
|
+ TypertContextMap,
|
|
|
+ TypertContextWire,
|
|
|
TypertContext,
|
|
|
+ TypertLookup,
|
|
|
TypertRemoteScopeApi,
|
|
|
TypertRemoteNamespace,
|
|
|
} from '@deepseek-ai/dsh-typert-protocol'
|
|
|
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
|
|
import type { ClientRemote } from '../src/client/index.ts'
|
|
|
-import { apply, inject } from '../src/client/index.ts'
|
|
|
+import { apply, inject, RemoteStream } from '../src/client/index.ts'
|
|
|
+import {
|
|
|
+ RemoteStreamCarrierError,
|
|
|
+ RemoteStreamError,
|
|
|
+ RemoteStreamMuxClient,
|
|
|
+} from '../src/client/stream-client.ts'
|
|
|
+
|
|
|
+type FixtureApprovalOutcome = 'allowed' | 'unavailable'
|
|
|
+const fixtureContextTag = Symbol('fixture-context-tag')
|
|
|
+type AgentWireId = TypertContextWire<TypertContextMap['agent']>
|
|
|
+const agentId = (value: string): AgentWireId => value as AgentWireId
|
|
|
+
|
|
|
+interface FixtureAgent {
|
|
|
+ readonly agentId: string
|
|
|
+}
|
|
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
|
interface Events {
|
|
|
@@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' {
|
|
|
* @param count - marker payload never observed.
|
|
|
*/
|
|
|
'fixture/idle'(count: number): void
|
|
|
+ /**
|
|
|
+ * Test-only scoped waterfall forwarded through the existing Remote Event stream.
|
|
|
+ * @param request - JSON-safe request payload.
|
|
|
+ * @param next - delegates to the next Client listener or Host waterfall.
|
|
|
+ * @returns the claimed or delegated outcome.
|
|
|
+ */
|
|
|
+ 'fixture/approval'(
|
|
|
+ this: Context,
|
|
|
+ request: {
|
|
|
+ readonly prompt: string
|
|
|
+ readonly agent: FixtureAgent
|
|
|
+ readonly signal?: AbortSignal
|
|
|
+ },
|
|
|
+ next: () => Promise<FixtureApprovalOutcome>,
|
|
|
+ ): Promise<FixtureApprovalOutcome>
|
|
|
/**
|
|
|
* Test-only event the Host assembly does not forward.
|
|
|
* @param flag - marker payload never delivered.
|
|
|
@@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' {
|
|
|
}
|
|
|
|
|
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
- interface TypertRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
|
|
|
+ interface TypertRemoteEventSelection extends
|
|
|
+ Record<'fixture/changed' | 'fixture/idle' | 'fixture/approval', true> {}
|
|
|
|
|
|
interface TypertContextMap {
|
|
|
fixture: TypertContext<string>
|
|
|
}
|
|
|
|
|
|
+ interface TypertLookupMap {
|
|
|
+ fixture: TypertLookup<FixtureAgent, string>
|
|
|
+ }
|
|
|
+
|
|
|
interface TypertRemoteMap {
|
|
|
'probe/create': (
|
|
|
agentId: string,
|
|
|
@@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
signal?: AbortSignal,
|
|
|
) => Promise<RemoteResult<{ readonly ref: string }>>
|
|
|
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
|
|
|
+ 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable<string>
|
|
|
}
|
|
|
|
|
|
interface TypertRemoteScopeMap {
|
|
|
@@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
|
}
|
|
|
|
|
|
type FixtureContext = Omit<Context, 'remote'> & {
|
|
|
- readonly remote: TypertClientRemote & TypertRemoteScopeApi<'fixture'>
|
|
|
+ readonly remote: ClientRemote & TypertRemoteScopeApi<'fixture'>
|
|
|
}
|
|
|
|
|
|
// Compile-time contract of `$on`: the key face is the forwarding selection and
|
|
|
// the listener signature is the owning package's own Cordis declaration.
|
|
|
function remoteEventContracts(remote: ClientRemote): void {
|
|
|
remote.$on('fixture/changed', (namespace) => { void namespace })
|
|
|
+ remote.$on('fixture/approval', async function (request, next) {
|
|
|
+ expectTypeOf(this).toEqualTypeOf<Context>()
|
|
|
+ expectTypeOf(request.agent).toEqualTypeOf<Context>()
|
|
|
+ expectTypeOf(request.signal).toEqualTypeOf<AbortSignal | undefined>()
|
|
|
+ return request.prompt === '' ? next() : 'allowed'
|
|
|
+ })
|
|
|
// @ts-expect-error -- declared in Events but outside the forwarding selection.
|
|
|
remote.$on('fixture/unselected', () => {})
|
|
|
// @ts-expect-error -- not declared in Events at all.
|
|
|
@@ -155,22 +202,390 @@ function maybeDescriptor(): InvocationDescriptor {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
|
|
- const { ctx } = await benchFiber(call)
|
|
|
+function streamDescriptor(): InvocationDescriptor {
|
|
|
+ return {
|
|
|
+ id: '@fixture/probe#probe/watch',
|
|
|
+ service: 'probe',
|
|
|
+ namespace: 'probe',
|
|
|
+ method: 'watch',
|
|
|
+ mode: 'stream',
|
|
|
+ invocation: { kind: 'direct' },
|
|
|
+ parameters: [{
|
|
|
+ name: 'topic',
|
|
|
+ wire: 'topic',
|
|
|
+ source: 'json',
|
|
|
+ codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) },
|
|
|
+ }],
|
|
|
+ cancellation: { parameter: 'signal' },
|
|
|
+ result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type WebSocketGlobal = { WebSocket?: typeof WebSocket }
|
|
|
+
|
|
|
+class FakeWebSocket extends EventTarget {
|
|
|
+ static readonly CONNECTING = 0
|
|
|
+ static readonly OPEN = 1
|
|
|
+ static readonly CLOSING = 2
|
|
|
+ static readonly CLOSED = 3
|
|
|
+ static readonly sockets: FakeWebSocket[] = []
|
|
|
+ static autoOpen = true
|
|
|
+ static dispatchClose = true
|
|
|
+
|
|
|
+ readonly url: string
|
|
|
+ readonly sent: string[] = []
|
|
|
+ readonly closedWith: { readonly code?: number; readonly reason?: string }[] = []
|
|
|
+ readyState = FakeWebSocket.CONNECTING
|
|
|
+
|
|
|
+ constructor(url: string | URL) {
|
|
|
+ super()
|
|
|
+ this.url = String(url)
|
|
|
+ FakeWebSocket.sockets.push(this)
|
|
|
+ queueMicrotask(() => {
|
|
|
+ if (FakeWebSocket.autoOpen) this.open()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ open(): void {
|
|
|
+ if (this.readyState !== FakeWebSocket.CONNECTING) return
|
|
|
+ this.readyState = FakeWebSocket.OPEN
|
|
|
+ this.dispatchEvent(new Event('open'))
|
|
|
+ }
|
|
|
+
|
|
|
+ fail(): void {
|
|
|
+ this.dispatchEvent(new Event('error'))
|
|
|
+ }
|
|
|
+
|
|
|
+ send(data: string): void {
|
|
|
+ if (this.readyState !== FakeWebSocket.OPEN) throw new Error('fixture socket is not open')
|
|
|
+ this.sent.push(data)
|
|
|
+ }
|
|
|
+
|
|
|
+ close(code?: number, reason?: string): void {
|
|
|
+ this.closedWith.push({
|
|
|
+ ...(code === undefined ? {} : { code }),
|
|
|
+ ...(reason === undefined ? {} : { reason }),
|
|
|
+ })
|
|
|
+ if (this.readyState === FakeWebSocket.CLOSED) return
|
|
|
+ if (!FakeWebSocket.dispatchClose) {
|
|
|
+ this.readyState = FakeWebSocket.CLOSING
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.drop()
|
|
|
+ }
|
|
|
+
|
|
|
+ drop(): void {
|
|
|
+ if (this.readyState === FakeWebSocket.CLOSED) return
|
|
|
+ this.readyState = FakeWebSocket.CLOSED
|
|
|
+ this.dispatchEvent(new Event('close'))
|
|
|
+ }
|
|
|
+
|
|
|
+ receive(value: unknown): void {
|
|
|
+ this.receiveRaw(typeof value === 'string' ? value : JSON.stringify(value))
|
|
|
+ }
|
|
|
+
|
|
|
+ receiveRaw(data: unknown): void {
|
|
|
+ this.dispatchEvent(new MessageEvent('message', {
|
|
|
+ data,
|
|
|
+ }))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function bench(
|
|
|
+ call: ConnectionHandle['rpc']['call'],
|
|
|
+ carrier: 'in-process' | 'web' = 'in-process',
|
|
|
+): Promise<Context> {
|
|
|
+ const { ctx } = await benchFiber(call, carrier)
|
|
|
return ctx
|
|
|
}
|
|
|
|
|
|
async function benchFiber(
|
|
|
call: ConnectionHandle['rpc']['call'],
|
|
|
-): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
|
|
|
+ carrier: 'in-process' | 'web' = 'in-process',
|
|
|
+ open: NonNullable<ConnectionHandle['rpc']['open']> = () => unexpectedInProcessStream(),
|
|
|
+): Promise<{
|
|
|
+ readonly ctx: Context
|
|
|
+ readonly client: Fiber
|
|
|
+ readonly generation: GenerationHarness
|
|
|
+}> {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(TypertRegistry)
|
|
|
+ const rpc = carrier === 'web'
|
|
|
+ ? { call }
|
|
|
+ : { call, open }
|
|
|
+ const generation = new GenerationHarness()
|
|
|
+ ctx.provide('connection', {
|
|
|
+ rpc,
|
|
|
+ registerGenerationSource: generation.register,
|
|
|
+ start: () => ({ stop: () => {} }),
|
|
|
+ } as unknown as ConnectionHandle)
|
|
|
+ const client = ctx.plugin({ inject, apply })
|
|
|
+ await client
|
|
|
+ return { ctx, client, generation }
|
|
|
+}
|
|
|
+
|
|
|
+async function *unexpectedInProcessStream(): AsyncGenerator<never> {
|
|
|
+ throw new Error('fixture did not install an in-process stream')
|
|
|
+}
|
|
|
+
|
|
|
+interface GenerationRun {
|
|
|
+ readonly signal: AbortSignal
|
|
|
+ readonly ready: Promise<void>
|
|
|
+ readonly done: Promise<void>
|
|
|
+ abort(reason?: unknown): void
|
|
|
+}
|
|
|
+
|
|
|
+class GenerationHarness {
|
|
|
+ private source: ConnectionGenerationSource | undefined
|
|
|
+ private active: AbortController | undefined
|
|
|
+
|
|
|
+ readonly register = (source: ConnectionGenerationSource): (() => void) => {
|
|
|
+ if (this.source !== undefined) throw new Error('fixture generation source already registered')
|
|
|
+ this.source = source
|
|
|
+ return () => {
|
|
|
+ if (this.source !== source) return
|
|
|
+ this.source = undefined
|
|
|
+ this.active?.abort(new Error('fixture generation source removed'))
|
|
|
+ this.active = undefined
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ start(): GenerationRun {
|
|
|
+ if (this.source === undefined) throw new Error('fixture generation source is not registered')
|
|
|
+ if (this.active !== undefined) throw new Error('fixture generation is already active')
|
|
|
+ const source = this.source
|
|
|
+ const controller = new AbortController()
|
|
|
+ this.active = controller
|
|
|
+ let reportReady!: () => void
|
|
|
+ const ready = new Promise<void>((resolve) => { reportReady = resolve })
|
|
|
+ const done = Promise.resolve()
|
|
|
+ .then(() => source(controller.signal, reportReady))
|
|
|
+ .finally(() => {
|
|
|
+ if (this.active === controller) this.active = undefined
|
|
|
+ })
|
|
|
+ void done.catch(() => undefined)
|
|
|
+ return {
|
|
|
+ signal: controller.signal,
|
|
|
+ ready,
|
|
|
+ done,
|
|
|
+ abort: (reason) => { controller.abort(reason) },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ startOverlapping(): GenerationRun {
|
|
|
+ if (this.source === undefined) throw new Error('fixture generation source is not registered')
|
|
|
+ const controller = new AbortController()
|
|
|
+ let reportReady!: () => void
|
|
|
+ const ready = new Promise<void>((resolve) => { reportReady = resolve })
|
|
|
+ const done = Promise.resolve().then(() => this.source?.(controller.signal, reportReady))
|
|
|
+ .then(() => undefined)
|
|
|
+ void done.catch(() => undefined)
|
|
|
+ return {
|
|
|
+ signal: controller.signal,
|
|
|
+ ready,
|
|
|
+ done,
|
|
|
+ abort: (reason) => { controller.abort(reason) },
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function deferredReadiness(): {
|
|
|
+ readonly promise: Promise<void>
|
|
|
+ readonly resolve: () => void
|
|
|
+ readonly reject: (error: unknown) => void
|
|
|
+} {
|
|
|
+ let resolve!: () => void
|
|
|
+ let reject!: (error: unknown) => void
|
|
|
+ const promise = new Promise<void>((accept, decline) => {
|
|
|
+ resolve = accept
|
|
|
+ reject = decline
|
|
|
+ })
|
|
|
+ return { promise, resolve, reject }
|
|
|
+}
|
|
|
+
|
|
|
+async function loaderReadinessBench(readiness: Promise<unknown>): Promise<{
|
|
|
+ readonly client: Fiber
|
|
|
+ readonly start: ReturnType<typeof vi.fn<ConnectionHandle['start']>>
|
|
|
+ readonly stop: ReturnType<typeof vi.fn<() => void>>
|
|
|
+}> {
|
|
|
const ctx = new Context()
|
|
|
await ctx.plugin(TypertRegistry)
|
|
|
- ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
|
|
+ const generation = new GenerationHarness()
|
|
|
+ const stop = vi.fn<() => void>()
|
|
|
+ const start = vi.fn<ConnectionHandle['start']>(() => ({ stop }))
|
|
|
+ ctx.provide('connection', {
|
|
|
+ rpc: {
|
|
|
+ call: vi.fn<ConnectionHandle['rpc']['call']>(),
|
|
|
+ open: () => unexpectedInProcessStream(),
|
|
|
+ },
|
|
|
+ registerGenerationSource: generation.register,
|
|
|
+ start,
|
|
|
+ } as unknown as ConnectionHandle)
|
|
|
+ ctx.provide('loader', { await: () => readiness })
|
|
|
const client = ctx.plugin({ inject, apply })
|
|
|
await client
|
|
|
- return { ctx, client }
|
|
|
+ return { client, start, stop }
|
|
|
+}
|
|
|
+
|
|
|
+type EventStreamItem =
|
|
|
+ | { readonly kind: 'frame'; readonly value: unknown }
|
|
|
+ | { readonly kind: 'end' }
|
|
|
+ | { readonly kind: 'fail'; readonly error: unknown }
|
|
|
+
|
|
|
+interface EventStreamConnection {
|
|
|
+ readonly items: EventStreamItem[]
|
|
|
+ wake: (() => void) | undefined
|
|
|
}
|
|
|
|
|
|
+class RemoteEventCarrier {
|
|
|
+ readonly calls: {
|
|
|
+ readonly channel: string
|
|
|
+ readonly endpoint: string
|
|
|
+ readonly payload: unknown
|
|
|
+ readonly signal: AbortSignal
|
|
|
+ }[] = []
|
|
|
+ private readonly connections = new Set<EventStreamConnection>()
|
|
|
+ private nextClient = 1
|
|
|
+
|
|
|
+ get activeConnections(): number {
|
|
|
+ return this.connections.size
|
|
|
+ }
|
|
|
+
|
|
|
+ readonly open: NonNullable<ConnectionHandle['rpc']['open']> = (channel, endpoint, payload, signal) => {
|
|
|
+ this.calls.push({ channel, endpoint, payload, signal })
|
|
|
+ return this.iterate(signal)
|
|
|
+ }
|
|
|
+
|
|
|
+ emit(value: unknown): void {
|
|
|
+ this.feed({ kind: 'frame', value })
|
|
|
+ }
|
|
|
+
|
|
|
+ end(): void {
|
|
|
+ this.feed({ kind: 'end' })
|
|
|
+ }
|
|
|
+
|
|
|
+ fail(error: unknown): void {
|
|
|
+ this.feed({ kind: 'fail', error })
|
|
|
+ }
|
|
|
+
|
|
|
+ private feed(item: EventStreamItem): void {
|
|
|
+ for (const connection of this.connections) {
|
|
|
+ connection.items.push(item)
|
|
|
+ connection.wake?.()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async *iterate(signal: AbortSignal): AsyncGenerator {
|
|
|
+ signal.throwIfAborted()
|
|
|
+ const clientId = `event-client-${String(this.nextClient++)}`
|
|
|
+ const connection: EventStreamConnection = { items: [], wake: undefined }
|
|
|
+ this.connections.add(connection)
|
|
|
+ const abort = (): void => { connection.wake?.() }
|
|
|
+ signal.addEventListener('abort', abort, { once: true })
|
|
|
+ try {
|
|
|
+ yield { type: 'ready', clientId }
|
|
|
+ while (!signal.aborted) {
|
|
|
+ while (connection.items.length > 0) {
|
|
|
+ const item = connection.items.shift() as EventStreamItem
|
|
|
+ if (item.kind === 'end') return
|
|
|
+ if (item.kind === 'fail') throw item.error
|
|
|
+ yield item.value
|
|
|
+ }
|
|
|
+ if (signal.aborted) return
|
|
|
+ await new Promise<void>((resolve) => { connection.wake = resolve })
|
|
|
+ connection.wake = undefined
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ signal.removeEventListener('abort', abort)
|
|
|
+ this.connections.delete(connection)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function eventBench(
|
|
|
+ call: ConnectionHandle['rpc']['call'] = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ .mockResolvedValue({ ok: true, value: undefined }),
|
|
|
+): Promise<{
|
|
|
+ readonly ctx: Context
|
|
|
+ readonly client: Fiber
|
|
|
+ readonly carrier: RemoteEventCarrier
|
|
|
+ readonly generation: GenerationHarness
|
|
|
+ readonly run: GenerationRun
|
|
|
+ readonly call: ConnectionHandle['rpc']['call']
|
|
|
+}> {
|
|
|
+ const carrier = new RemoteEventCarrier()
|
|
|
+ const { ctx, client, generation } = await benchFiber(
|
|
|
+ call,
|
|
|
+ 'in-process',
|
|
|
+ carrier.open,
|
|
|
+ )
|
|
|
+ const run = generation.start()
|
|
|
+ await run.ready
|
|
|
+ return { ctx, client, carrier, generation, run, call }
|
|
|
+}
|
|
|
+
|
|
|
+function approvalFrame(eventId: string, agentId: string, prompt: string): object {
|
|
|
+ return {
|
|
|
+ type: 'waterfall',
|
|
|
+ event: 'fixture/approval',
|
|
|
+ eventId,
|
|
|
+ agentId,
|
|
|
+ request: { prompt },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe('Client Remote transport readiness', () => {
|
|
|
+ it('creates logical stream supervisors against the installed Connection', async () => {
|
|
|
+ const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ const stream = ctx.remote.$stream({
|
|
|
+ name: 'fixture stream',
|
|
|
+ open: () => unexpectedInProcessStream(),
|
|
|
+ ended: () => new Error('fixture stream ended'),
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(stream).toBeInstanceOf(RemoteStream)
|
|
|
+ await stream.dispose()
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('starts after Loader settlement and stops the owned loop on disposal', async () => {
|
|
|
+ const readiness = deferredReadiness()
|
|
|
+ const { client, start, stop } = await loaderReadinessBench(readiness.promise)
|
|
|
+ expect(start).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ readiness.resolve()
|
|
|
+ await vi.waitFor(() => { expect(start).toHaveBeenCalledTimes(1) })
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ expect(stop).toHaveBeenCalledTimes(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not start when disposal wins the Loader-settlement race', async () => {
|
|
|
+ const readiness = deferredReadiness()
|
|
|
+ const { client, start, stop } = await loaderReadinessBench(readiness.promise)
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ readiness.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+
|
|
|
+ expect(start).not.toHaveBeenCalled()
|
|
|
+ expect(stop).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leaves the transport stopped when Loader settlement rejects', async () => {
|
|
|
+ const readiness = deferredReadiness()
|
|
|
+ const { client, start, stop } = await loaderReadinessBench(readiness.promise)
|
|
|
+
|
|
|
+ readiness.reject(new Error('fixture Loader failed'))
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+
|
|
|
+ expect(start).not.toHaveBeenCalled()
|
|
|
+ await client.dispose()
|
|
|
+ expect(stop).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('Client Typert API', () => {
|
|
|
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
|
|
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
@@ -271,6 +686,7 @@ describe('Client Typert API', () => {
|
|
|
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
|
|
ctx.typert.contexts.registerClient('fixture', {
|
|
|
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
|
|
+ resolve: id => id === 'agent-2' ? agentCtx : undefined,
|
|
|
})
|
|
|
const assembly = ctx.plugin(Object.assign(
|
|
|
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
|
|
|
@@ -301,6 +717,7 @@ describe('Client Typert API', () => {
|
|
|
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
|
|
ctx.typert.contexts.registerClient('fixture', {
|
|
|
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
|
|
+ resolve: id => id === 'agent-2' ? agentCtx : undefined,
|
|
|
})
|
|
|
const assembly = ctx.plugin(Object.assign(
|
|
|
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
|
|
|
@@ -346,6 +763,7 @@ describe('Client Typert API', () => {
|
|
|
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
|
|
ctx.typert.contexts.registerClient('fixture', {
|
|
|
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
|
|
+ resolve: id => id === 'agent-remounted' ? agentCtx : undefined,
|
|
|
})
|
|
|
const direct = directDescriptor()
|
|
|
const context = contextDescriptor()
|
|
|
@@ -432,6 +850,42 @@ describe('Client Typert API', () => {
|
|
|
await retry()
|
|
|
})
|
|
|
|
|
|
+ it('rolls back earlier namespaces when a later namespace fails to install', async () => {
|
|
|
+ const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ const { scope: _scope, ...first } = directDescriptor()
|
|
|
+ const second: InvocationDescriptor = {
|
|
|
+ ...first,
|
|
|
+ id: '@fixture/archive#archive/store',
|
|
|
+ namespace: 'archive',
|
|
|
+ method: 'store',
|
|
|
+ }
|
|
|
+ const defineProperty = Object.defineProperty
|
|
|
+ const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
|
|
+ if (key === 'store') throw new Error('fixture later-namespace failure')
|
|
|
+ return defineProperty(target, key, attributes)
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ await expect(ctx.remote.$mount({
|
|
|
+ package: '@fixture/failing-namespaces',
|
|
|
+ descriptors: [first, second],
|
|
|
+ })).rejects.toThrow('fixture later-namespace failure')
|
|
|
+ } finally {
|
|
|
+ spy.mockRestore()
|
|
|
+ }
|
|
|
+
|
|
|
+ expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
|
|
+ expect((ctx.remote as unknown as Record<string, unknown>).archive).toBeUndefined()
|
|
|
+ await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
|
|
+
|
|
|
+ const retry = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/retry-namespaces',
|
|
|
+ descriptors: [first, second],
|
|
|
+ })
|
|
|
+ expect(ctx.remote.probe.create).toBeTypeOf('function')
|
|
|
+ expect((ctx.remote as unknown as Record<string, Record<string, unknown>>).archive?.store).toBeTypeOf('function')
|
|
|
+ await retry()
|
|
|
+ })
|
|
|
+
|
|
|
it('rolls back a direct projection when its scoped projection fails to install', async () => {
|
|
|
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
const disposeContext = await ctx.remote.$mount({
|
|
|
@@ -579,7 +1033,7 @@ describe('Client Typert API', () => {
|
|
|
})).rejects.toThrow('scope must select its only lookup parameter')
|
|
|
})
|
|
|
|
|
|
- it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
|
|
+ it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => {
|
|
|
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
|
|
const ctx = await bench(call)
|
|
|
@@ -599,7 +1053,7 @@ describe('Client Typert API', () => {
|
|
|
await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
|
|
|
.rejects.toThrow('expected 2 business argument(s)')
|
|
|
await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
|
|
|
- .rejects.toThrow('no Client Context binder')
|
|
|
+ .rejects.toThrow('no Client Context adapter')
|
|
|
|
|
|
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
|
|
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
|
|
@@ -640,6 +1094,28 @@ describe('Client Typert API', () => {
|
|
|
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
+ it('keeps a namespace while another contribution still owns a method', async () => {
|
|
|
+ const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ const disposeCreate = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/create-contribution',
|
|
|
+ descriptors: [directDescriptor()],
|
|
|
+ })
|
|
|
+ const disposeMaybe = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/maybe-contribution',
|
|
|
+ descriptors: [maybeDescriptor()],
|
|
|
+ })
|
|
|
+ const namespace = ctx.get('remote.probe') as unknown as Record<string, unknown>
|
|
|
+
|
|
|
+ await disposeCreate()
|
|
|
+
|
|
|
+ expect(ctx.get('remote.probe') !== undefined).toBe(true)
|
|
|
+ expect(namespace.create).toBeUndefined()
|
|
|
+ expect(namespace.maybe).toBeTypeOf('function')
|
|
|
+
|
|
|
+ await disposeMaybe()
|
|
|
+ expect(ctx.get('remote.probe')).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
it('fails a method obtained from a withdrawn namespace getter', async () => {
|
|
|
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
|
|
|
@@ -804,7 +1280,7 @@ describe('Client Typert API', () => {
|
|
|
})
|
|
|
|
|
|
it('owns each $on subscription in the calling fiber', async () => {
|
|
|
- const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ const { ctx, client, carrier } = await eventBench()
|
|
|
const seen: string[] = []
|
|
|
const subscriber = ctx.plugin(Object.assign(
|
|
|
(scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
|
|
|
@@ -812,103 +1288,1098 @@ describe('Client Typert API', () => {
|
|
|
))
|
|
|
await subscriber
|
|
|
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['settings'])
|
|
|
- expect(seen).toEqual(['settings'])
|
|
|
+ expect(carrier.calls).toEqual([expect.objectContaining({
|
|
|
+ channel: '/api', endpoint: '$events', payload: { args: {} },
|
|
|
+ })])
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['settings'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['settings']) })
|
|
|
|
|
|
await subscriber.dispose()
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['after fiber disposal'])
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['after fiber disposal'] })
|
|
|
+ await Promise.resolve()
|
|
|
expect(seen).toEqual(['settings'])
|
|
|
|
|
|
await client.dispose()
|
|
|
expect(ctx.get('remote')).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
- it('isolates a throwing listener from the rest of the same event', async () => {
|
|
|
- const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ it('isolates throwing and rejected notification listeners', async () => {
|
|
|
+ const { ctx, client, carrier } = await eventBench()
|
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
const seen: string[] = []
|
|
|
- const disposeFirst = ctx.remote.$on('fixture/changed', () => {
|
|
|
- throw new Error('fixture listener failure')
|
|
|
- })
|
|
|
+ const failingListener = (namespace: string): unknown => {
|
|
|
+ if (namespace === 'sync') throw new Error('fixture listener failure')
|
|
|
+ return Promise.reject(new Error('fixture async failure'))
|
|
|
+ }
|
|
|
+ const disposeThrowing = ctx.remote.$on('fixture/changed', failingListener)
|
|
|
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
try {
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['credentials'])
|
|
|
-
|
|
|
- expect(seen).toEqual(['credentials'])
|
|
|
- expect(consoleError).toHaveBeenCalledWith(
|
|
|
- 'client api: Remote event "fixture/changed" listener threw:',
|
|
|
- expect.any(Error),
|
|
|
- )
|
|
|
- disposeFirst()
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['commands'])
|
|
|
- expect(seen).toEqual(['credentials', 'commands'])
|
|
|
- expect(consoleError).toHaveBeenCalledTimes(1)
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['sync'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['sync']) })
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['async'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async']) })
|
|
|
+ expect(consoleError).toHaveBeenCalledTimes(2)
|
|
|
+
|
|
|
+ disposeThrowing()
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async', 'survivor']) })
|
|
|
} finally {
|
|
|
consoleError.mockRestore()
|
|
|
+ await client.dispose()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('contains an async listener whose promise rejects', async () => {
|
|
|
- const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
+ it('retires only its own registration when one listener subscribes twice', async () => {
|
|
|
+ const { ctx, client, carrier } = await eventBench()
|
|
|
+ const seen: string[] = []
|
|
|
+ const listener = (namespace: string): void => { seen.push(namespace) }
|
|
|
+ const disposeFirst = ctx.remote.$on('fixture/changed', listener)
|
|
|
+ ctx.remote.$on('fixture/changed', listener)
|
|
|
+
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['both'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['both', 'both']) })
|
|
|
+
|
|
|
+ disposeFirst()
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['both', 'both', 'survivor']) })
|
|
|
+
|
|
|
+ disposeFirst()
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['still here'] })
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
|
|
|
+ })
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the carrier handoff private', () => {
|
|
|
+ expectTypeOf<ClientRemote>().toHaveProperty('$on')
|
|
|
+ expectTypeOf<'$dispatch' extends keyof ClientRemote ? true : false>().toEqualTypeOf<false>()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops an unobserved notification and accepts a null-prototype frame', async () => {
|
|
|
+ const { ctx, client, carrier } = await eventBench()
|
|
|
const seen: string[] = []
|
|
|
- // The declared return is void, so nobody awaits an async listener: the
|
|
|
- // rejection has to be contained here or it escapes as an unhandled one.
|
|
|
- ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises
|
|
|
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
- try {
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['credentials'])
|
|
|
- await Promise.resolve()
|
|
|
- await Promise.resolve()
|
|
|
|
|
|
- expect(seen).toEqual(['credentials'])
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/idle', args: [1] })
|
|
|
+ carrier.emit(Object.assign(Object.create(null) as Record<string, unknown>, {
|
|
|
+ type: 'emit',
|
|
|
+ event: 'fixture/changed',
|
|
|
+ args: ['null prototype'],
|
|
|
+ }))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['null prototype']) })
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('delegates immediately when the Agent adapter or Context is unavailable', async () => {
|
|
|
+ const { ctx, client, carrier, call } = await eventBench()
|
|
|
+ carrier.emit(approvalFrame('event-no-adapter', 'agent-late', 'no adapter'))
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
+
|
|
|
+ const target = ctx.extend()
|
|
|
+ const resolve = vi.fn((id: unknown) => id === 'agent-found' ? target : undefined)
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-found') : undefined,
|
|
|
+ resolve,
|
|
|
+ })
|
|
|
+ carrier.emit(approvalFrame('event-missing-context', 'agent-missing', 'missing'))
|
|
|
+ carrier.emit(approvalFrame('event-no-listener', 'agent-found', 'delegate'))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(3) })
|
|
|
+ expect(resolve).toHaveBeenCalledTimes(2)
|
|
|
+ for (const eventId of ['event-no-adapter', 'event-missing-context', 'event-no-listener']) {
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ { args: { clientId: 'event-client-1', eventId, outcome: { kind: 'next' } } },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports Agent Context resolution failures and delegates', async () => {
|
|
|
+ const { ctx, client, carrier, call } = await eventBench()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: () => undefined,
|
|
|
+ resolve: () => { throw new Error('fixture Context lookup failed') },
|
|
|
+ })
|
|
|
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
+ try {
|
|
|
+ carrier.emit(approvalFrame('event-resolve-error', 'agent-error', 'resolve'))
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
|
- 'client api: Remote event "fixture/changed" listener threw:',
|
|
|
- expect.any(Error),
|
|
|
+ 'client api: Remote event "fixture/approval" listener threw:',
|
|
|
+ expect.objectContaining({ message: 'fixture Context lookup failed' }),
|
|
|
+ )
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'event-client-1',
|
|
|
+ eventId: 'event-resolve-error',
|
|
|
+ outcome: { kind: 'next' },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
)
|
|
|
} finally {
|
|
|
consoleError.mockRestore()
|
|
|
+ await client.dispose()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('retires only its own registration when one listener subscribes twice', async () => {
|
|
|
- const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ it('normalizes undefined waterfall results and rejects non-JSON results', async () => {
|
|
|
+ const { ctx, client, carrier, call } = await eventBench()
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-results') : undefined,
|
|
|
+ resolve: id => id === 'agent-results' ? target : undefined,
|
|
|
+ })
|
|
|
+ target.remote.$on('fixture/approval', async request => request.prompt === 'undefined'
|
|
|
+ ? undefined as unknown as FixtureApprovalOutcome
|
|
|
+ : Symbol('not JSON') as unknown as FixtureApprovalOutcome)
|
|
|
+
|
|
|
+ carrier.emit(approvalFrame('event-undefined', 'agent-results', 'undefined'))
|
|
|
+ carrier.emit(approvalFrame('event-invalid', 'agent-results', 'invalid'))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(2) })
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ { args: { clientId: 'event-client-1', eventId: 'event-undefined', outcome: { kind: 'result' } } },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'event-client-1',
|
|
|
+ eventId: 'event-invalid',
|
|
|
+ outcome: {
|
|
|
+ kind: 'rejected',
|
|
|
+ error: {
|
|
|
+ name: 'TypeError',
|
|
|
+ message: 'Remote event listener result is not lossless JSON data',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails the Connection generation when a result RPC is rejected', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({
|
|
|
+ ok: false,
|
|
|
+ error: { code: 'internal', message: 'fixture result rejected', details: {} },
|
|
|
+ })
|
|
|
+ const { client, carrier, run } = await eventBench(call)
|
|
|
+
|
|
|
+ carrier.emit(approvalFrame('event-result-rejected', 'agent-missing', 'respond'))
|
|
|
+
|
|
|
+ await expect(run.done).rejects.toThrow('fixture result rejected')
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('filters scoped waterfall listeners and returns the first claimed result', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ .mockResolvedValue({ ok: true, value: undefined })
|
|
|
+ const { ctx, client, carrier } = await eventBench(call)
|
|
|
+ const target = ctx.extend({
|
|
|
+ [Context.filter](candidate: Context): boolean {
|
|
|
+ const tag = (candidate as Context & { [fixtureContextTag]?: string })[fixtureContextTag]
|
|
|
+ return tag === undefined || tag === 'agent-1'
|
|
|
+ },
|
|
|
+ })
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-1') : undefined,
|
|
|
+ resolve: id => id === 'agent-1' ? target : undefined,
|
|
|
+ })
|
|
|
+ const matching = ctx.extend({ [fixtureContextTag]: 'agent-1' })
|
|
|
+ const excluded = ctx.extend({ [fixtureContextTag]: 'agent-2' })
|
|
|
const seen: string[] = []
|
|
|
- // One function object, two registrations. A table keyed by listener identity
|
|
|
- // stores it once, so the first frame would reach it once instead of twice
|
|
|
- // and either disposer would silence both.
|
|
|
- const listener = (namespace: string): void => { seen.push(namespace) }
|
|
|
- const disposeFirst = ctx.remote.$on('fixture/changed', listener)
|
|
|
- ctx.remote.$on('fixture/changed', listener)
|
|
|
+ ctx.remote.$on('fixture/approval', async function (request, next) {
|
|
|
+ expect(this).toBe(target)
|
|
|
+ expect(request.agent).toBe(target)
|
|
|
+ expect(request.signal).toBeInstanceOf(AbortSignal)
|
|
|
+ seen.push('root')
|
|
|
+ return next()
|
|
|
+ })
|
|
|
+ matching.remote.$on('fixture/approval', async (_request, next) => {
|
|
|
+ seen.push('matching-next')
|
|
|
+ return next()
|
|
|
+ })
|
|
|
+ excluded.remote.$on('fixture/approval', async () => {
|
|
|
+ seen.push('excluded')
|
|
|
+ return 'unavailable'
|
|
|
+ })
|
|
|
+ matching.remote.$on('fixture/approval', async () => {
|
|
|
+ seen.push('matching-result')
|
|
|
+ return 'allowed'
|
|
|
+ })
|
|
|
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['both'])
|
|
|
- expect(seen).toEqual(['both', 'both'])
|
|
|
+ carrier.emit(approvalFrame('event-1', 'agent-1', 'ship'))
|
|
|
|
|
|
- // The surviving registration keeps receiving after its twin retires.
|
|
|
- disposeFirst()
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['survivor'])
|
|
|
- expect(seen).toEqual(['both', 'both', 'survivor'])
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(seen).toEqual(['root', 'matching-next', 'matching-result'])
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'event-client-1',
|
|
|
+ eventId: 'event-1',
|
|
|
+ outcome: { kind: 'result', value: 'allowed' },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
|
|
|
- // Disposing twice is inert: the record is already gone, so the second call
|
|
|
- // must not splice the surviving twin out from under its own owner.
|
|
|
- disposeFirst()
|
|
|
- ctx.remote.$dispatch('fixture/changed', ['still here'])
|
|
|
- expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
|
|
|
+ it('returns a scoped listener rejection to the Host', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ .mockResolvedValue({ ok: true, value: undefined })
|
|
|
+ const { ctx, client, carrier } = await eventBench(call)
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-rejected') : undefined,
|
|
|
+ resolve: id => id === 'agent-rejected' ? target : undefined,
|
|
|
+ })
|
|
|
+ const rejection = Object.assign(new Error('the user cancelled ask_user_question'), {
|
|
|
+ name: 'UserQuestionError',
|
|
|
+ code: 'ASK_CANCELLED',
|
|
|
+ details: { questionId: 'question-1' },
|
|
|
+ })
|
|
|
+ target.remote.$on('fixture/approval', () => Promise.reject(rejection))
|
|
|
+
|
|
|
+ carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled'))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'event-client-1',
|
|
|
+ eventId: 'event-rejected',
|
|
|
+ outcome: {
|
|
|
+ kind: 'rejected',
|
|
|
+ error: {
|
|
|
+ name: 'UserQuestionError',
|
|
|
+ message: 'the user cancelled ask_user_question',
|
|
|
+ code: 'ASK_CANCELLED',
|
|
|
+ details: { questionId: 'question-1' },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ await client.dispose()
|
|
|
})
|
|
|
|
|
|
- it('separates the consumer verb from the carrier handoff', () => {
|
|
|
- expectTypeOf<ClientRemote>().toHaveProperty('$on')
|
|
|
- // The carrier owning the frame sink calls this; a consumer subscribes instead.
|
|
|
- expectTypeOf<ClientRemote>().toHaveProperty('$dispatch')
|
|
|
+ it('returns Context-filter failures as rejections', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ .mockResolvedValue({ ok: true, value: undefined })
|
|
|
+ const { ctx, client, carrier } = await eventBench(call)
|
|
|
+ const target = ctx.extend({
|
|
|
+ [Context.filter](): boolean {
|
|
|
+ throw new Error('fixture Context filter failed')
|
|
|
+ },
|
|
|
+ })
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-filter-failure') : undefined,
|
|
|
+ resolve: id => id === 'agent-filter-failure' ? target : undefined,
|
|
|
+ })
|
|
|
+ ctx.remote.$on('fixture/approval', async (_request, next) => next())
|
|
|
+
|
|
|
+ carrier.emit(approvalFrame('event-filter-failure', 'agent-filter-failure', 'filter'))
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'event-client-1',
|
|
|
+ eventId: 'event-filter-failure',
|
|
|
+ outcome: {
|
|
|
+ kind: 'rejected',
|
|
|
+ error: {
|
|
|
+ name: 'Error',
|
|
|
+ message: 'fixture Context filter failed',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+ await client.dispose()
|
|
|
})
|
|
|
|
|
|
- it('drops a forwarded event nobody subscribes to', async () => {
|
|
|
- const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ it('cancels a pending Client listener without returning a late result', async () => {
|
|
|
+ const { ctx, client, carrier, call } = await eventBench()
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-cancel') : undefined,
|
|
|
+ resolve: id => id === 'agent-cancel' ? target : undefined,
|
|
|
+ })
|
|
|
+ const entered = Promise.withResolvers<AbortSignal>()
|
|
|
+ target.remote.$on('fixture/approval', async (request) => {
|
|
|
+ const signal = request.signal as AbortSignal
|
|
|
+ entered.resolve(signal)
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
+ if (signal.aborted) resolve()
|
|
|
+ else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
|
+ })
|
|
|
+ return 'allowed'
|
|
|
+ })
|
|
|
+ carrier.emit(approvalFrame('event-cancel', 'agent-cancel', 'wait'))
|
|
|
+ const deliverySignal = await entered.promise
|
|
|
+
|
|
|
+ carrier.emit({ type: 'cancel', eventId: 'event-cancel' })
|
|
|
+ await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(call).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops a settled listener result when cancellation wins before reply', async () => {
|
|
|
+ const { ctx, client, carrier, call } = await eventBench()
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-cancel-race') : undefined,
|
|
|
+ resolve: id => id === 'agent-cancel-race' ? target : undefined,
|
|
|
+ })
|
|
|
+ const entered = Promise.withResolvers<AbortSignal>()
|
|
|
+ const release = Promise.withResolvers<undefined>()
|
|
|
+ target.remote.$on('fixture/approval', async (request) => {
|
|
|
+ entered.resolve(request.signal as AbortSignal)
|
|
|
+ await release.promise
|
|
|
+ return 'allowed'
|
|
|
+ })
|
|
|
+ carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait'))
|
|
|
+ const deliverySignal = await entered.promise
|
|
|
+
|
|
|
+ release.resolve(undefined)
|
|
|
+ carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' })
|
|
|
+ await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(call).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('cancels pending listener work when the generation ends', async () => {
|
|
|
+ const { ctx, client, carrier, run, call } = await eventBench()
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-generation') : undefined,
|
|
|
+ resolve: id => id === 'agent-generation' ? target : undefined,
|
|
|
+ })
|
|
|
+ const entered = Promise.withResolvers<AbortSignal>()
|
|
|
+ target.remote.$on('fixture/approval', async (request) => {
|
|
|
+ const signal = request.signal as AbortSignal
|
|
|
+ entered.resolve(signal)
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
+ if (signal.aborted) resolve()
|
|
|
+ else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
|
+ })
|
|
|
+ return 'allowed'
|
|
|
+ })
|
|
|
+ carrier.emit(approvalFrame('event-generation', 'agent-generation', 'wait'))
|
|
|
+ const deliverySignal = await entered.promise
|
|
|
+
|
|
|
+ run.abort(new Error('fixture generation ended'))
|
|
|
+ await expect(run.done).resolves.toBeUndefined()
|
|
|
+ expect(deliverySignal.aborted).toBe(true)
|
|
|
+ expect(call).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains a result transport failure after the generation is cancelled', async () => {
|
|
|
+ const response = Promise.withResolvers<never>()
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>(() => response.promise)
|
|
|
+ const { client, carrier, run } = await eventBench(call)
|
|
|
+ carrier.emit(approvalFrame('event-late-result', 'agent-missing', 'respond'))
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledOnce() })
|
|
|
+
|
|
|
+ run.abort(new Error('fixture generation cancelled'))
|
|
|
+ response.reject(new Error('fixture late result failure'))
|
|
|
+ await expect(run.done).resolves.toBeUndefined()
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalizes a non-Error result transport failure', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>().mockRejectedValue('fixture transport failure')
|
|
|
+ const { client, carrier, run } = await eventBench(call)
|
|
|
+
|
|
|
+ carrier.emit(approvalFrame('event-result-throw', 'agent-missing', 'respond'))
|
|
|
+
|
|
|
+ await expect(run.done).rejects.toMatchObject({
|
|
|
+ message: 'client api: Remote event result delivery failed',
|
|
|
+ cause: 'fixture transport failure',
|
|
|
+ })
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the newer generation tracked when an overlapping generation settles', async () => {
|
|
|
+ const { client, generation, run } = await eventBench()
|
|
|
+ const overlapping = generation.startOverlapping()
|
|
|
+ await overlapping.ready
|
|
|
+
|
|
|
+ run.abort(new Error('fixture older generation ended'))
|
|
|
+ await expect(run.done).resolves.toBeUndefined()
|
|
|
+ overlapping.abort(new Error('fixture newer generation ended'))
|
|
|
+ await expect(overlapping.done).resolves.toBeUndefined()
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('opens the forwarded-event stream on the browser Remote mux', async () => {
|
|
|
+ await withFakeWebSocket('https://harness.example', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ .mockResolvedValue({ ok: true, value: undefined })
|
|
|
+ const { ctx, client, generation } = await benchFiber(call, 'web')
|
|
|
+ const seen: string[] = []
|
|
|
+ const target = ctx.extend()
|
|
|
+ ctx.typert.contexts.registerClient('agent', {
|
|
|
+ identity: candidate => candidate === target ? agentId('agent-browser') : undefined,
|
|
|
+ resolve: id => id === 'agent-browser' ? target : undefined,
|
|
|
+ })
|
|
|
+ ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
+ target.remote.$on('fixture/approval', async function (request) {
|
|
|
+ expect(this).toBe(target)
|
|
|
+ expect(request.agent).toBe(this)
|
|
|
+ expect(request.signal).toBeInstanceOf(AbortSignal)
|
|
|
+ return 'allowed'
|
|
|
+ })
|
|
|
+ const run = generation.start()
|
|
|
+
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
|
|
|
+ const socket = FakeWebSocket.sockets[0]!
|
|
|
+ const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
|
|
+ expect(opened).toMatchObject({
|
|
|
+ type: 'open', endpoint: '$events', payload: { args: {} },
|
|
|
+ })
|
|
|
+ socket.receive({
|
|
|
+ type: 'item',
|
|
|
+ streamId: opened.streamId,
|
|
|
+ value: { type: 'ready', clientId: 'browser-client' },
|
|
|
+ })
|
|
|
+ await run.ready
|
|
|
+ socket.receive({
|
|
|
+ type: 'item',
|
|
|
+ streamId: opened.streamId,
|
|
|
+ value: { type: 'emit', event: 'fixture/changed', args: ['browser'] },
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['browser']) })
|
|
|
+
|
|
|
+ socket.receive({
|
|
|
+ type: 'item',
|
|
|
+ streamId: opened.streamId,
|
|
|
+ value: {
|
|
|
+ type: 'waterfall',
|
|
|
+ event: 'fixture/approval',
|
|
|
+ eventId: 'event-browser',
|
|
|
+ agentId: 'agent-browser',
|
|
|
+ request: { prompt: 'browser approval' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(socket.sent).toHaveLength(1)
|
|
|
+ expect(call).toHaveBeenCalledWith(
|
|
|
+ '/api',
|
|
|
+ '$events/result',
|
|
|
+ {
|
|
|
+ args: {
|
|
|
+ clientId: 'browser-client',
|
|
|
+ eventId: 'event-browser',
|
|
|
+ outcome: { kind: 'result', value: 'allowed' },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ expect.any(AbortSignal),
|
|
|
+ )
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('publishes the Fixture Host description after Remote events report ready', async () => {
|
|
|
+ const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
|
|
+ Object.defineProperty(globalThis, 'location', {
|
|
|
+ configurable: true,
|
|
|
+ value: { hostname: '127.0.0.1', search: '?fixture' },
|
|
|
+ })
|
|
|
+ const ctx = new Context()
|
|
|
+ try {
|
|
|
+ await ctx.plugin(TypertRegistry)
|
|
|
+ await ctx.plugin({ inject: [], apply: applyConnection })
|
|
|
+ await ctx.plugin({ inject, apply })
|
|
|
+ const connection = ctx.get('connection') as ConnectionHandle | undefined
|
|
|
+ if (connection === undefined) throw new Error('fixture Connection service is unavailable')
|
|
|
+
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture')
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
|
|
|
+ else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ null,
|
|
|
+ [],
|
|
|
+ {},
|
|
|
+ { type: 'pending' },
|
|
|
+ { type: 'ready' },
|
|
|
+ { type: 'ready', clientId: '' },
|
|
|
+ { type: 'ready', clientId: 'client', extra: true },
|
|
|
+ { type: 'emit', event: 'fixture/changed', args: ['too early'] },
|
|
|
+ ])('rejects malformed forwarded-event readiness item %#', async (opening) => {
|
|
|
+ const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *() {
|
|
|
+ yield opening
|
|
|
+ })()
|
|
|
+ const { client, generation } = await benchFiber(
|
|
|
+ vi.fn<ConnectionHandle['rpc']['call']>(),
|
|
|
+ 'in-process',
|
|
|
+ open,
|
|
|
+ )
|
|
|
+ const run = generation.start()
|
|
|
+ try {
|
|
|
+ await expect(run.done).rejects.toThrow('forwarded Remote event stream did not begin with ready')
|
|
|
+ } finally {
|
|
|
+ await client.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('propagates physical carrier failure and opens events for the replacement generation', async () => {
|
|
|
+ const { ctx, client, carrier, generation, run } = await eventBench()
|
|
|
const seen: string[] = []
|
|
|
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
+ expect(carrier.calls).toHaveLength(1)
|
|
|
+
|
|
|
+ carrier.fail(new RemoteStreamCarrierError('fixture generation lost'))
|
|
|
+ await expect(run.done).rejects.toThrow('fixture generation lost')
|
|
|
+ const replacement = generation.start()
|
|
|
+ await replacement.ready
|
|
|
+ await vi.waitFor(() => { expect(carrier.calls).toHaveLength(2) })
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['replacement'] })
|
|
|
+ await vi.waitFor(() => { expect(seen).toEqual(['replacement']) })
|
|
|
|
|
|
- ctx.remote.$dispatch('fixture/idle', [1])
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
|
|
|
+ it.each([
|
|
|
+ {
|
|
|
+ name: 'Host failure',
|
|
|
+ stop: (carrier: RemoteEventCarrier) => {
|
|
|
+ carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {}))
|
|
|
+ },
|
|
|
+ message: 'fixture Host failed',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'normal end',
|
|
|
+ stop: (carrier: RemoteEventCarrier) => { carrier.end() },
|
|
|
+ message: 'forwarded Remote event stream ended unexpectedly',
|
|
|
+ },
|
|
|
+ ])('fails the active generation after $name', async ({ stop, message }) => {
|
|
|
+ const { ctx, client, carrier, run } = await eventBench()
|
|
|
+ const seen: string[] = []
|
|
|
+ ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
+ stop(carrier)
|
|
|
+ await expect(run.done).rejects.toThrow(message)
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(carrier.calls).toHaveLength(1)
|
|
|
expect(seen).toEqual([])
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ 'not an object',
|
|
|
+ null,
|
|
|
+ [],
|
|
|
+ {},
|
|
|
+ { type: 'unknown' },
|
|
|
+ { type: 'emit', event: 'fixture/changed' },
|
|
|
+ { type: 'emit', event: 'fixture/changed', args: [], extra: true },
|
|
|
+ { type: 'emit', event: 1, args: [] },
|
|
|
+ { type: 'emit', event: '', args: [] },
|
|
|
+ { type: 'emit', event: 'fixture/changed', args: {} },
|
|
|
+ { type: 'emit', event: 'fixture/changed', args: [1n] },
|
|
|
+ { type: 'waterfall', event: 'fixture/approval', eventId: '', agentId: 'agent-1', request: {} },
|
|
|
+ { type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: '', request: {} },
|
|
|
+ {
|
|
|
+ type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { agent: null },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { signal: null },
|
|
|
+ },
|
|
|
+ { type: 'cancel', eventId: '' },
|
|
|
+ { type: 'cancel', eventId: 'event-1', extra: true },
|
|
|
+ ])('rejects malformed forwarded-event frame %# and stops that stream', async (frame) => {
|
|
|
+ const { ctx, client, carrier, run } = await eventBench()
|
|
|
+ const seen: string[] = []
|
|
|
+ ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
|
|
+ carrier.emit(frame)
|
|
|
+ await expect(run.done).rejects.toThrow('client api: invalid forwarded Remote event frame')
|
|
|
+ carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(carrier.calls).toHaveLength(1)
|
|
|
+ expect(seen).toEqual([])
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('aborts and awaits forwarded-event delivery during disposal', async () => {
|
|
|
+ const { ctx, client, carrier, run } = await eventBench()
|
|
|
+ ctx.remote.$on('fixture/changed', () => {})
|
|
|
+ expect(carrier.activeConnections).toBe(1)
|
|
|
+ const signal = carrier.calls[0]?.signal
|
|
|
+
|
|
|
+ await client.dispose()
|
|
|
+ await expect(run.done).resolves.toBeUndefined()
|
|
|
+
|
|
|
+ expect(signal?.aborted).toBe(true)
|
|
|
+ expect(carrier.activeConnections).toBe(0)
|
|
|
+ expect(ctx.get('remote')).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a generation when its Connection has been withdrawn', async () => {
|
|
|
+ const carrier = new RemoteEventCarrier()
|
|
|
+ const { ctx, client, generation } = await benchFiber(
|
|
|
+ vi.fn<ConnectionHandle['rpc']['call']>(),
|
|
|
+ 'in-process',
|
|
|
+ carrier.open,
|
|
|
+ )
|
|
|
+ ctx.set('connection', undefined)
|
|
|
+ const run = generation.start()
|
|
|
+ await expect(run.done).rejects.toThrow('$events has no active Connection')
|
|
|
+ expect(carrier.calls).toEqual([])
|
|
|
+ await client.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('guards stream iteration across mount and Connection withdrawal', async () => {
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ const ctx = await bench(call)
|
|
|
+ const firstDispose = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/stream-first', descriptors: [streamDescriptor()],
|
|
|
+ })
|
|
|
+ const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]()
|
|
|
+ await firstDispose()
|
|
|
+ await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted')
|
|
|
+
|
|
|
+ const secondDispose = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/stream-second', descriptors: [streamDescriptor()],
|
|
|
+ })
|
|
|
+ ctx.set('connection', undefined)
|
|
|
+ await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next())
|
|
|
+ .rejects.toThrow('probe/watch has no active Connection')
|
|
|
+
|
|
|
+ let release!: () => void
|
|
|
+ const released = new Promise<void>((resolve) => { release = resolve })
|
|
|
+ let markStarted!: () => void
|
|
|
+ const started = new Promise<void>((resolve) => { markStarted = resolve })
|
|
|
+ const source = async function *(): AsyncIterable<string> {
|
|
|
+ markStarted()
|
|
|
+ await released
|
|
|
+ yield 'late item'
|
|
|
+ }
|
|
|
+ ctx.set('connection', {
|
|
|
+ rpc: { call, open: () => source() },
|
|
|
+ } as unknown as ConnectionHandle)
|
|
|
+ const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]()
|
|
|
+ const pending = active.next()
|
|
|
+ await started
|
|
|
+ await secondDispose()
|
|
|
+ release()
|
|
|
+ await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('publishes a namespace only after every contributed method is installed', async () => {
|
|
|
+ const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
|
|
+ let visible: string[] | undefined
|
|
|
+ const consumer = ctx.plugin({
|
|
|
+ inject: ['remote.probe'],
|
|
|
+ apply(scope) {
|
|
|
+ const namespace = scope.get('remote.probe') as unknown as Record<string, unknown>
|
|
|
+ visible = [typeof namespace.watch, typeof namespace.archive]
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const archive: InvocationDescriptor = {
|
|
|
+ ...streamDescriptor(),
|
|
|
+ id: '@fixture/probe#probe/archive',
|
|
|
+ method: 'archive',
|
|
|
+ }
|
|
|
+
|
|
|
+ const dispose = await ctx.remote.$mount({
|
|
|
+ package: '@fixture/atomic-namespace',
|
|
|
+ descriptors: [streamDescriptor(), archive],
|
|
|
+ })
|
|
|
+ await consumer.await()
|
|
|
+
|
|
|
+ expect(visible).toEqual(['function', 'function'])
|
|
|
+ await dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalizes worker-local structural stream failures without sharing class identity', async () => {
|
|
|
+ const cases = [{
|
|
|
+ failure: Object.assign(new Error('fixture Host rejected the stream'), {
|
|
|
+ dshRemoteStreamFailure: {
|
|
|
+ kind: 'remote' as const,
|
|
|
+ code: 'fixture-rejected',
|
|
|
+ details: { retry: false },
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ assert: (error: unknown) => {
|
|
|
+ expect(error).toBeInstanceOf(RemoteStreamError)
|
|
|
+ expect(error).toMatchObject({
|
|
|
+ code: 'fixture-rejected',
|
|
|
+ message: 'fixture Host rejected the stream',
|
|
|
+ details: { retry: false },
|
|
|
+ })
|
|
|
+ },
|
|
|
+ }, {
|
|
|
+ failure: Object.assign(new Error('worker carrier stopped'), {
|
|
|
+ dshRemoteStreamFailure: { kind: 'carrier' as const },
|
|
|
+ }),
|
|
|
+ assert: (error: unknown) => {
|
|
|
+ expect(error).toBeInstanceOf(RemoteStreamCarrierError)
|
|
|
+ expect(error).toMatchObject({ message: 'worker carrier stopped' })
|
|
|
+ },
|
|
|
+ }, {
|
|
|
+ failure: 'caller abort sentinel',
|
|
|
+ assert: (error: unknown) => { expect(error).toBe('caller abort sentinel') },
|
|
|
+ }]
|
|
|
+
|
|
|
+ for (const testCase of cases) {
|
|
|
+ const open: NonNullable<ConnectionHandle['rpc']['open']> = () => (async function *(): AsyncGenerator {
|
|
|
+ throw testCase.failure
|
|
|
+ })()
|
|
|
+ const { ctx, client } = await benchFiber(
|
|
|
+ vi.fn<ConnectionHandle['rpc']['call']>(),
|
|
|
+ 'in-process',
|
|
|
+ open,
|
|
|
+ )
|
|
|
+ const dispose = await ctx.remote.$mount({ package: '@fixture/worker-stream', descriptors: [streamDescriptor()] })
|
|
|
+ try {
|
|
|
+ const error = await ctx.remote.probe.watch('failure')[Symbol.asyncIterator]().next()
|
|
|
+ .then(() => undefined, (reason: unknown) => reason)
|
|
|
+ testCase.assert(error)
|
|
|
+ } finally {
|
|
|
+ await dispose()
|
|
|
+ await client.dispose()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('multiplexes Remote streams without using the Connection RPC caller', async () => {
|
|
|
+ const originalWebSocket = globalThis.WebSocket
|
|
|
+ const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
|
|
+ ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
|
|
+ Object.defineProperty(globalThis, 'location', {
|
|
|
+ configurable: true,
|
|
|
+ value: { origin: 'https://harness.example' },
|
|
|
+ })
|
|
|
+ FakeWebSocket.sockets.length = 0
|
|
|
+ const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
|
|
+ const ctx = await bench(call, 'web')
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(1)
|
|
|
+ const dispose = await ctx.remote.$mount({ package: '@fixture/stream', descriptors: [streamDescriptor()] })
|
|
|
+ try {
|
|
|
+ const first = ctx.remote.probe.watch('alpha')[Symbol.asyncIterator]()
|
|
|
+ const firstItem = first.next()
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
|
|
|
+ const socket = FakeWebSocket.sockets[0]!
|
|
|
+ expect(socket.url).toBe('wss://harness.example/api/remote.mux')
|
|
|
+ const opened = JSON.parse(socket.sent[0]!) as { streamId: string }
|
|
|
+ expect(opened).toMatchObject({
|
|
|
+ type: 'open',
|
|
|
+ endpoint: 'probe/watch',
|
|
|
+ payload: { args: { topic: 'alpha' } },
|
|
|
+ })
|
|
|
+ socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:one' })
|
|
|
+ await expect(firstItem).resolves.toEqual({ done: false, value: 'alpha:one' })
|
|
|
+ const firstEnd = first.next()
|
|
|
+ socket.receive({ type: 'end', streamId: opened.streamId })
|
|
|
+ await expect(firstEnd).resolves.toEqual({ done: true, value: undefined })
|
|
|
+
|
|
|
+ const failed = ctx.remote.probe.watch('failure')[Symbol.asyncIterator]()
|
|
|
+ const failedItem = failed.next()
|
|
|
+ await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
|
|
+ const failedOpen = JSON.parse(socket.sent[1]!) as { streamId: string }
|
|
|
+ socket.receive({
|
|
|
+ type: 'error',
|
|
|
+ streamId: failedOpen.streamId,
|
|
|
+ error: {
|
|
|
+ code: 'lookup-unavailable',
|
|
|
+ message: 'fixture stream failed',
|
|
|
+ details: { lookup: 'missing' },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await expect(failedItem).rejects.toMatchObject({
|
|
|
+ name: 'RemoteStreamError',
|
|
|
+ code: 'lookup-unavailable',
|
|
|
+ message: 'fixture stream failed',
|
|
|
+ details: { lookup: 'missing' },
|
|
|
+ })
|
|
|
+
|
|
|
+ const abort = new AbortController()
|
|
|
+ const cancelled = ctx.remote.probe.watch('cancel', abort.signal)[Symbol.asyncIterator]()
|
|
|
+ const cancelledItem = cancelled.next()
|
|
|
+ await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) })
|
|
|
+ const cancelledOpen = JSON.parse(socket.sent[2]!) as { streamId: string }
|
|
|
+ const cancellation = new Error('caller cancelled')
|
|
|
+ socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'already queued' })
|
|
|
+ abort.abort(cancellation)
|
|
|
+ socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'after cancellation' })
|
|
|
+ await expect(cancelledItem).rejects.toBe(cancellation)
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(socket.sent.map(text => JSON.parse(text) as unknown)).toContainEqual({
|
|
|
+ type: 'cancel', streamId: cancelledOpen.streamId,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ expect(call).not.toHaveBeenCalled()
|
|
|
+ } finally {
|
|
|
+ await dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ FakeWebSocket.sockets.length = 0
|
|
|
+ FakeWebSocket.autoOpen = true
|
|
|
+ FakeWebSocket.dispatchClose = true
|
|
|
+ if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
|
|
+ else globalThis.WebSocket = originalWebSocket
|
|
|
+ if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
|
|
|
+ else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
|
|
+ }
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+describe('Remote stream client carrier lifecycle', () => {
|
|
|
+ it('connects without a logical stream, reconnects after failures, and stops permanently', async () => {
|
|
|
+ await withFakeWebSocket('https://harness.example', async () => {
|
|
|
+ FakeWebSocket.autoOpen = false
|
|
|
+ vi.useFakeTimers()
|
|
|
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
+ try {
|
|
|
+ const client = new RemoteStreamMuxClient()
|
|
|
+ client.start()
|
|
|
+ client.start()
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(1)
|
|
|
+
|
|
|
+ const failed = FakeWebSocket.sockets[0]!
|
|
|
+ failed.fail()
|
|
|
+ await vi.advanceTimersByTimeAsync(500)
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(2)
|
|
|
+
|
|
|
+ const connected = FakeWebSocket.sockets[1]!
|
|
|
+ connected.open()
|
|
|
+ await vi.advanceTimersByTimeAsync(0)
|
|
|
+ expect(connected.sent).toEqual([])
|
|
|
+ connected.fail()
|
|
|
+ await vi.advanceTimersByTimeAsync(500)
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(3)
|
|
|
+
|
|
|
+ const replacement = FakeWebSocket.sockets[2]!
|
|
|
+ replacement.open()
|
|
|
+ replacement.drop()
|
|
|
+ await vi.advanceTimersByTimeAsync(500)
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(4)
|
|
|
+
|
|
|
+ const final = FakeWebSocket.sockets[3]!
|
|
|
+ final.open()
|
|
|
+ await vi.advanceTimersByTimeAsync(0)
|
|
|
+ await client.close()
|
|
|
+ await client.close()
|
|
|
+ client.start()
|
|
|
+ await expect(client.open('feed/follow', {}, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed')
|
|
|
+ await vi.advanceTimersByTimeAsync(20_000)
|
|
|
+
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(4)
|
|
|
+ expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' })
|
|
|
+ expect(warn).toHaveBeenCalledTimes(3)
|
|
|
+
|
|
|
+ const stopping = new RemoteStreamMuxClient()
|
|
|
+ stopping.start()
|
|
|
+ const racing = FakeWebSocket.sockets[4]!
|
|
|
+ racing.open()
|
|
|
+ racing.drop()
|
|
|
+ await stopping.close()
|
|
|
+ await vi.advanceTimersByTimeAsync(20_000)
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(5)
|
|
|
+ } finally {
|
|
|
+ warn.mockRestore()
|
|
|
+ vi.useRealTimers()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => {
|
|
|
+ await withFakeWebSocket(undefined, async () => {
|
|
|
+ FakeWebSocket.autoOpen = false
|
|
|
+ const client = new RemoteStreamMuxClient()
|
|
|
+ const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]()
|
|
|
+ const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]()
|
|
|
+ const firstPending = first.next()
|
|
|
+ const secondPending = second.next()
|
|
|
+ expect(FakeWebSocket.sockets).toHaveLength(1)
|
|
|
+ const socket = FakeWebSocket.sockets[0]!
|
|
|
+ expect(socket.url).toBe('ws://dsh.internal/api/remote.mux')
|
|
|
+
|
|
|
+ socket.open()
|
|
|
+ await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
|
|
+ const streamIds = socket.sent.map(text => (JSON.parse(text) as { streamId: string }).streamId)
|
|
|
+ socket.receive({ type: 'end', streamId: streamIds[0] })
|
|
|
+ socket.receive({ type: 'end', streamId: streamIds[1] })
|
|
|
+ await expect(firstPending).resolves.toEqual({ done: true, value: undefined })
|
|
|
+ await expect(secondPending).resolves.toEqual({ done: true, value: undefined })
|
|
|
+ await client.close()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps waiters across failed attempts and contains waiter cancellation', async () => {
|
|
|
+ await withFakeWebSocket('null', async () => {
|
|
|
+ FakeWebSocket.autoOpen = false
|
|
|
+ vi.useFakeTimers()
|
|
|
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
+ try {
|
|
|
+ const closedClient = new RemoteStreamMuxClient()
|
|
|
+ const closed = closedClient.open('feed/follow', {}, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]().next()
|
|
|
+ FakeWebSocket.sockets[0]!.drop()
|
|
|
+ await vi.advanceTimersByTimeAsync(500)
|
|
|
+
|
|
|
+ const replacement = FakeWebSocket.sockets[1]!
|
|
|
+ replacement.open()
|
|
|
+ await vi.advanceTimersByTimeAsync(0)
|
|
|
+ const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string }
|
|
|
+ replacement.receive({ type: 'end', streamId })
|
|
|
+ await expect(closed).resolves.toEqual({ done: true, value: undefined })
|
|
|
+ await closedClient.close()
|
|
|
+
|
|
|
+ const disposedClient = new RemoteStreamMuxClient()
|
|
|
+ const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]().next()
|
|
|
+ FakeWebSocket.sockets[2]!.fail()
|
|
|
+ await disposedClient.close()
|
|
|
+ await expect(disposed).rejects.toThrow('Remote stream client disposed')
|
|
|
+
|
|
|
+ const abortedClient = new RemoteStreamMuxClient()
|
|
|
+ const abort = new AbortController()
|
|
|
+ const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
|
|
|
+ abort.abort('cancelled while connecting')
|
|
|
+ await expect(aborted).rejects.toBe('cancelled while connecting')
|
|
|
+ await abortedClient.close()
|
|
|
+ expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux')
|
|
|
+ } finally {
|
|
|
+ warn.mockRestore()
|
|
|
+ vi.useRealTimers()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails active streams on an invalid frame and ignores later frames', async () => {
|
|
|
+ await withFakeWebSocket('https://harness.example', async () => {
|
|
|
+ const client = new RemoteStreamMuxClient()
|
|
|
+ const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]()
|
|
|
+ const pending = stream.next()
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
|
|
|
+ const socket = FakeWebSocket.sockets[0]!
|
|
|
+ const { streamId } = JSON.parse(socket.sent[0]!) as { streamId: string }
|
|
|
+ FakeWebSocket.dispatchClose = false
|
|
|
+ socket.receiveRaw(new Uint8Array([1, 2, 3]))
|
|
|
+ socket.receive({ type: 'item', streamId, value: 'too late' })
|
|
|
+ socket.drop()
|
|
|
+
|
|
|
+ await expect(pending).rejects.toMatchObject({
|
|
|
+ name: 'RemoteStreamCarrierError', message: 'api gateway: invalid Remote stream frame',
|
|
|
+ })
|
|
|
+ expect(socket.closedWith).toContainEqual({ code: 4002, reason: 'invalid Remote stream frame' })
|
|
|
+ await client.close()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('completes a stream and drops a frame racing with cancellation', async () => {
|
|
|
+ await withFakeWebSocket('https://harness.example', async () => {
|
|
|
+ const client = new RemoteStreamMuxClient()
|
|
|
+ const completed = client.open('feed/follow', {}, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]()
|
|
|
+ const completedPending = completed.next()
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
|
|
|
+ const socket = FakeWebSocket.sockets[0]!
|
|
|
+ const completedOpen = JSON.parse(socket.sent[0]!) as { streamId: string }
|
|
|
+ socket.receive({ type: 'end', streamId: completedOpen.streamId })
|
|
|
+ await expect(completedPending).resolves.toEqual({ done: true, value: undefined })
|
|
|
+
|
|
|
+ const abort = new AbortController()
|
|
|
+ const cancelled = client.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
|
|
|
+ await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) })
|
|
|
+ const cancelledOpen = JSON.parse(socket.sent[1]!) as { streamId: string }
|
|
|
+ const reason = new Error('fixture cancellation race')
|
|
|
+ abort.abort(reason)
|
|
|
+ socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'too late' })
|
|
|
+ await expect(cancelled).rejects.toBe(reason)
|
|
|
+ await client.close()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains non-Error cancellation reasons and late socket close events', async () => {
|
|
|
+ await withFakeWebSocket('http://harness.example', async () => {
|
|
|
+ const cancelledClient = new RemoteStreamMuxClient()
|
|
|
+ const abort = new AbortController()
|
|
|
+ const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next()
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) })
|
|
|
+ abort.abort('caller cancelled')
|
|
|
+ await expect(cancelled).rejects.toThrow('caller cancelled')
|
|
|
+ await cancelledClient.close()
|
|
|
+
|
|
|
+ FakeWebSocket.dispatchClose = false
|
|
|
+ const disposedClient = new RemoteStreamMuxClient()
|
|
|
+ const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal)
|
|
|
+ [Symbol.asyncIterator]().next()
|
|
|
+ await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) })
|
|
|
+ const disposedSocket = FakeWebSocket.sockets[1]!
|
|
|
+ await disposedClient.close()
|
|
|
+ disposedSocket.receive({ type: 'end', streamId: 'stale' })
|
|
|
+ disposedSocket.drop()
|
|
|
+ await expect(disposed).rejects.toThrow('Remote stream client disposed')
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+async function withFakeWebSocket(
|
|
|
+ origin: string | undefined,
|
|
|
+ run: () => Promise<void>,
|
|
|
+): Promise<void> {
|
|
|
+ const originalWebSocket = globalThis.WebSocket
|
|
|
+ const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
|
|
+ ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
|
|
+ if (origin === undefined) Reflect.deleteProperty(globalThis, 'location')
|
|
|
+ else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } })
|
|
|
+ FakeWebSocket.sockets.length = 0
|
|
|
+ FakeWebSocket.autoOpen = true
|
|
|
+ FakeWebSocket.dispatchClose = true
|
|
|
+ try {
|
|
|
+ await run()
|
|
|
+ } finally {
|
|
|
+ FakeWebSocket.sockets.length = 0
|
|
|
+ FakeWebSocket.autoOpen = true
|
|
|
+ FakeWebSocket.dispatchClose = true
|
|
|
+ if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
|
|
+ else globalThis.WebSocket = originalWebSocket
|
|
|
+ if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location')
|
|
|
+ else Object.defineProperty(globalThis, 'location', locationDescriptor)
|
|
|
+ }
|
|
|
+}
|