1
0

client.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. import { Context, Service } from '@deepseek-ai/cordis'
  2. import type { Fiber } from '@deepseek-ai/cordis'
  3. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  4. import { z } from 'zod'
  5. import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
  6. import type {
  7. InvocationDescriptor,
  8. TypeRTClientRemote,
  9. TypeRTContext,
  10. TypeRTRemoteScopeApi,
  11. TypeRTRemoteNamespace,
  12. } from '@deepseek-ai/dsh-type-meta'
  13. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  14. import type { ClientRemote } from '../src/client/index.ts'
  15. import { apply, inject } from '../src/client/index.ts'
  16. declare module '@deepseek-ai/cordis' {
  17. interface Events {
  18. /**
  19. * Test-only forwarded Host event.
  20. * @param namespace - marker payload recorded by listeners.
  21. */
  22. 'fixture/changed'(namespace: string): void
  23. /**
  24. * Test-only forwarded Host event nobody subscribes to.
  25. * @param count - marker payload never observed.
  26. */
  27. 'fixture/idle'(count: number): void
  28. /**
  29. * Test-only event the Host assembly does not forward.
  30. * @param flag - marker payload never delivered.
  31. */
  32. 'fixture/unselected'(flag: boolean): void
  33. }
  34. }
  35. declare module '@deepseek-ai/dsh-type-meta' {
  36. interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
  37. interface TypeRTContextMap {
  38. fixture: TypeRTContext<string>
  39. }
  40. interface TypeRTRemoteMap {
  41. 'goals/create': (
  42. agentId: string,
  43. request: { readonly objective: string },
  44. signal?: AbortSignal,
  45. ) => Promise<{ readonly ref: string }>
  46. }
  47. interface TypeRTRemoteScopeMap {
  48. 'fixture:goals/create': (
  49. request: { readonly objective: string },
  50. signal?: AbortSignal,
  51. ) => Promise<{ readonly ref: string }>
  52. 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
  53. }
  54. interface TypeRTRemoteNamespaceMap {
  55. goals: TypeRTRemoteNamespace<'goals'>
  56. }
  57. }
  58. type FixtureContext = Omit<Context, 'remote'> & {
  59. readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
  60. }
  61. // Compile-time contract of `$on`: the key face is the forwarding selection and
  62. // the listener signature is the owning package's own Cordis declaration.
  63. function remoteEventContracts(remote: ClientRemote): void {
  64. remote.$on('fixture/changed', (namespace) => { void namespace })
  65. // @ts-expect-error -- declared in Events but outside the forwarding selection.
  66. remote.$on('fixture/unselected', () => {})
  67. // @ts-expect-error -- not declared in Events at all.
  68. remote.$on('fixture/absent', () => {})
  69. // @ts-expect-error -- the listener signature comes from the event declaration.
  70. remote.$on('fixture/changed', (count: number) => { void count })
  71. }
  72. void remoteEventContracts
  73. const idSchema = z.string().min(1)
  74. const requestSchema = z.object({ objective: z.string().min(1) })
  75. const createResultSchema = z.object({ ref: z.string().min(1) })
  76. const renameResultSchema = z.object({ renamed: z.boolean() })
  77. function directDescriptor(): InvocationDescriptor {
  78. return {
  79. id: '@fixture/goals#goals/create',
  80. service: 'goals',
  81. namespace: 'goals',
  82. method: 'create',
  83. invocation: { kind: 'direct' },
  84. scope: { context: 'fixture', wire: 'agentId' },
  85. parameters: [{
  86. name: 'agent',
  87. wire: 'agentId',
  88. source: 'lookup',
  89. lookup: 'fixture',
  90. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  91. }, {
  92. name: 'request',
  93. wire: 'request',
  94. source: 'json',
  95. codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
  96. }],
  97. cancellation: { parameter: 'signal' },
  98. result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
  99. }
  100. }
  101. function contextDescriptor(): InvocationDescriptor {
  102. return {
  103. id: '@fixture/goals#goals/rename',
  104. service: 'goals',
  105. namespace: 'goals',
  106. method: 'rename',
  107. invocation: {
  108. kind: 'context',
  109. context: 'fixture',
  110. wire: 'agentId',
  111. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  112. },
  113. parameters: [{
  114. name: 'request',
  115. wire: 'request',
  116. source: 'json',
  117. codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
  118. }],
  119. result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
  120. }
  121. }
  122. async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
  123. const { ctx } = await benchFiber(call)
  124. return ctx
  125. }
  126. async function benchFiber(
  127. call: ConnectionHandle['rpc']['call'],
  128. ): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
  129. const ctx = new Context()
  130. await ctx.plugin(TypertRegistry)
  131. ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
  132. const client = ctx.plugin({ inject, apply })
  133. await client
  134. return { ctx, client }
  135. }
  136. describe('Client TypeRT API', () => {
  137. it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
  138. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  139. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  140. const ctx = await bench(call)
  141. const businessGoals = { owner: 'host business service' }
  142. const disposeBusinessGoals = ctx.provide('goals', businessGoals)
  143. const assembly = ctx.plugin(Object.assign(
  144. (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
  145. { inject: ['remote'] },
  146. ))
  147. await assembly
  148. const retained = ctx.remote.goals.create
  149. await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
  150. expect(call).toHaveBeenCalledWith(
  151. '/api',
  152. 'goals/create',
  153. { args: { agentId: 'agent-1', request: { objective: 'ship' } } },
  154. expect.any(AbortSignal),
  155. )
  156. const callerAbort = new AbortController()
  157. await expect(ctx.remote.goals.create(
  158. 'agent-1',
  159. { objective: 'cancel me' },
  160. callerAbort.signal,
  161. )).resolves.toEqual({ ref: 'goal-1' })
  162. const combinedSignal = call.mock.calls.at(-1)?.[3]
  163. expect(combinedSignal).toBeInstanceOf(AbortSignal)
  164. expect(combinedSignal).not.toBe(callerAbort.signal)
  165. const cancellation = new Error('caller cancelled')
  166. callerAbort.abort(cancellation)
  167. expect(combinedSignal?.aborted).toBe(true)
  168. expect(combinedSignal?.reason).toBe(cancellation)
  169. await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
  170. call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
  171. await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
  172. await assembly.dispose()
  173. expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
  174. expect(ctx.get('remote.goals')).toBeUndefined()
  175. expect(ctx.get('goals')).toBe(businessGoals)
  176. expect(ctx.typert.remotes.list()).toEqual([])
  177. await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
  178. disposeBusinessGoals()
  179. })
  180. it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
  181. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  182. .mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
  183. const ctx = await bench(call)
  184. const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
  185. ctx.typert.contexts.registerClient('fixture', {
  186. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  187. })
  188. const assembly = ctx.plugin(Object.assign(
  189. (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
  190. { inject: ['remote'] },
  191. ))
  192. await assembly
  193. await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
  194. expect(call).toHaveBeenCalledWith(
  195. '/api',
  196. 'goals/create',
  197. { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
  198. expect.any(AbortSignal),
  199. )
  200. await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
  201. .rejects.toThrow('expected 2 business argument(s)')
  202. await assembly.dispose()
  203. expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
  204. expect(ctx.get('remote.goals')).toBeUndefined()
  205. })
  206. it('uses the caller Context identity for scoped namespace methods', async () => {
  207. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  208. .mockResolvedValue({ ok: true, value: { renamed: true } })
  209. const ctx = await bench(call)
  210. const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
  211. ctx.typert.contexts.registerClient('fixture', {
  212. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  213. })
  214. const assembly = ctx.plugin(Object.assign(
  215. (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
  216. { inject: ['remote'] },
  217. ))
  218. await assembly
  219. await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
  220. expect(call).toHaveBeenCalledWith(
  221. '/api',
  222. 'goals/rename',
  223. { args: { agentId: 'agent-2', request: { objective: 'land' } } },
  224. expect.any(AbortSignal),
  225. )
  226. await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
  227. .rejects.toThrow('requires a "fixture" Context')
  228. await assembly.dispose()
  229. expect(ctx.get('remote.goals')).toBeUndefined()
  230. })
  231. it('rejects weak descriptors and namespace collisions before registration', async () => {
  232. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  233. const weak: InvocationDescriptor = {
  234. ...directDescriptor(),
  235. result: { mode: 'src-json' },
  236. }
  237. await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
  238. .rejects.toThrow('has no strict codec')
  239. await expect(ctx.remote.$mount({
  240. package: '@fixture/conflict',
  241. descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
  242. })).rejects.toThrow('conflicts with the Remote service')
  243. expect(ctx.typert.remotes.list()).toEqual([])
  244. })
  245. it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
  246. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  247. .mockResolvedValue({ ok: true, value: { renamed: true } })
  248. const ctx = await bench(call)
  249. const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
  250. ctx.typert.contexts.registerClient('fixture', {
  251. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  252. })
  253. const direct = directDescriptor()
  254. const context = contextDescriptor()
  255. await expect(ctx.remote.$mount({
  256. package: '@fixture/direct-duplicates',
  257. descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
  258. })).rejects.toThrow('repeats direct method')
  259. await expect(ctx.remote.$mount({
  260. package: '@fixture/scoped-duplicates',
  261. descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
  262. })).rejects.toThrow('repeats scoped method')
  263. const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
  264. await expect(ctx.remote.$mount({
  265. package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
  266. })).rejects.toThrow('direct method goals/create is already mounted')
  267. await disposeDirect()
  268. const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
  269. await expect(ctx.remote.$mount({
  270. package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
  271. })).rejects.toThrow('scoped method goals/rename is already mounted')
  272. await expect(ctx.remote.$mount({
  273. package: '@fixture/service-method-conflict',
  274. descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
  275. })).rejects.toThrow('conflicts with its namespace service')
  276. const scopedService = ctx.get('remote.goals') as unknown as object
  277. Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
  278. await expect(ctx.remote.$mount({
  279. package: '@fixture/service-own-property-conflict',
  280. descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
  281. })).rejects.toThrow('conflicts with its namespace service')
  282. Reflect.deleteProperty(scopedService, 'custom')
  283. await disposeScoped()
  284. const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
  285. await expect(ctx.remote.$mount({
  286. package: '@fixture/context-property-conflict',
  287. descriptors: [{ ...context, namespace: 'typert' }],
  288. })).rejects.toThrow('conflicts with an existing Remote namespace')
  289. await disposeRemoteTypert()
  290. const disposeMultipleScoped = await ctx.remote.$mount({
  291. package: '@fixture/multiple-scoped',
  292. descriptors: [directDescriptor(), contextDescriptor()],
  293. })
  294. await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
  295. expect(call).toHaveBeenLastCalledWith(
  296. '/api',
  297. 'goals/rename',
  298. { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
  299. expect.any(AbortSignal),
  300. )
  301. await disposeMultipleScoped()
  302. })
  303. it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
  304. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  305. const { scope: _scope, ...first } = directDescriptor()
  306. const second: InvocationDescriptor = {
  307. ...first,
  308. id: '@fixture/goals#goals/archive',
  309. method: 'archive',
  310. }
  311. const defineProperty = Object.defineProperty
  312. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  313. if (key === 'archive') throw new Error('fixture later-descriptor failure')
  314. return defineProperty(target, key, attributes)
  315. })
  316. try {
  317. await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
  318. .rejects.toThrow('fixture later-descriptor failure')
  319. } finally {
  320. spy.mockRestore()
  321. }
  322. expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
  323. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  324. const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
  325. expect(ctx.remote.goals.create).toBeTypeOf('function')
  326. expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
  327. await retry()
  328. })
  329. it('rolls back a direct projection when its scoped projection fails to install', async () => {
  330. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  331. const disposeContext = await ctx.remote.$mount({
  332. package: '@fixture/context-anchor',
  333. descriptors: [contextDescriptor()],
  334. })
  335. const namespace = ctx.get('remote.goals') as unknown as {
  336. installScoped: (...args: unknown[]) => void
  337. readonly create?: unknown
  338. }
  339. const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
  340. throw new Error('fixture scoped projection failure')
  341. })
  342. try {
  343. await expect(ctx.remote.$mount({
  344. package: '@fixture/direct-projection-failure',
  345. descriptors: [directDescriptor()],
  346. })).rejects.toThrow('fixture scoped projection failure')
  347. } finally {
  348. installScoped.mockRestore()
  349. }
  350. expect(namespace.create).toBeUndefined()
  351. await disposeContext()
  352. })
  353. it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
  354. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  355. const direct = directDescriptor()
  356. const context = contextDescriptor()
  357. await expect(ctx.remote.$mount({
  358. package: '@fixture/weak-parameter',
  359. descriptors: [{
  360. ...direct,
  361. parameters: direct.parameters.map((parameter, index) => index === 0
  362. ? { ...parameter, codec: { mode: 'src-json' } }
  363. : parameter),
  364. }],
  365. })).rejects.toThrow('has no strict codec')
  366. await expect(ctx.remote.$mount({
  367. package: '@fixture/weak-context',
  368. descriptors: [{
  369. ...context,
  370. invocation: { ...context.invocation, codec: { mode: 'src-json' } },
  371. } as InvocationDescriptor],
  372. })).rejects.toThrow('has no strict codec')
  373. await expect(ctx.remote.$mount({
  374. package: '@fixture/malformed-scope',
  375. descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
  376. })).rejects.toThrow('scope must select its only lookup parameter')
  377. await expect(ctx.remote.$mount({
  378. package: '@fixture/ambiguous-scope',
  379. descriptors: [{
  380. ...direct,
  381. parameters: [...direct.parameters, {
  382. name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
  383. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  384. }],
  385. }],
  386. })).rejects.toThrow('scope must select its only lookup parameter')
  387. })
  388. it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
  389. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  390. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  391. const ctx = await bench(call)
  392. const descriptor = directDescriptor()
  393. const dispose = await ctx.remote.$mount({
  394. package: '@fixture/goals',
  395. descriptors: [descriptor, contextDescriptor()],
  396. })
  397. const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
  398. const goals = (ctx as FixtureContext).remote.goals
  399. const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
  400. await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
  401. await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
  402. .rejects.toThrow('got 4')
  403. await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
  404. await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
  405. .rejects.toThrow('expected 2 business argument(s)')
  406. await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
  407. .rejects.toThrow('no Client Context binder')
  408. ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
  409. await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
  410. ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
  411. ctx.set('connection', undefined)
  412. await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
  413. await dispose()
  414. })
  415. it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
  416. let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
  417. const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
  418. resolveCall = resolve
  419. })
  420. const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
  421. const ctx = await bench(call)
  422. const { scope: _scope, ...first } = directDescriptor()
  423. const second: InvocationDescriptor = {
  424. ...first,
  425. id: '@fixture/goals#goals/archive',
  426. method: 'archive',
  427. }
  428. const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
  429. const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
  430. await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
  431. await dispose()
  432. resolveCall({ ok: true, value: { ref: 'goal-1' } })
  433. await expect(invocation).rejects.toThrow('withdrawn during invocation')
  434. expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
  435. })
  436. it('fails a method obtained from a withdrawn namespace getter', async () => {
  437. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  438. const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
  439. const namespace = ctx.get('remote.goals') as unknown as object
  440. const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
  441. await dispose()
  442. expect(getWithdrawn).toBeTypeOf('function')
  443. const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise<unknown>
  444. expect(() => withdrawn('agent-1', { objective: 'ship' }))
  445. .toThrow('Remote method is no longer mounted')
  446. })
  447. it('preserves a __proto__ wire parameter as an own named argument', async () => {
  448. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  449. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  450. const ctx = await bench(call)
  451. const { scope: _scope, ...base } = directDescriptor()
  452. const descriptor: InvocationDescriptor = {
  453. ...base,
  454. id: '@fixture/goals#goals/prototype',
  455. method: 'prototype',
  456. parameters: [{
  457. name: 'value',
  458. wire: '__proto__',
  459. source: 'json',
  460. codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
  461. }],
  462. }
  463. const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
  464. const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
  465. await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
  466. const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
  467. expect(Object.getPrototypeOf(payload.args)).toBeNull()
  468. expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
  469. expect(payload.args.__proto__).toBe('wire-value')
  470. await dispose()
  471. })
  472. it('rolls back Remote registration when namespace Service startup fails', async () => {
  473. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  474. const defineProperty = Object.defineProperty
  475. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  476. if (key === Service.tracker) throw new Error('fixture namespace startup failure')
  477. return defineProperty(target, key, attributes)
  478. })
  479. try {
  480. await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
  481. .rejects.toThrow('fixture namespace startup failure')
  482. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  483. } finally {
  484. spy.mockRestore()
  485. }
  486. const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
  487. expect(ctx.remote.goals.create).toBeTypeOf('function')
  488. await retry()
  489. })
  490. it('withdraws a fresh direct namespace when its first method fails to install', async () => {
  491. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  492. const defineProperty = Object.defineProperty
  493. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  494. if (key === 'create') throw new Error('fixture direct method installation failure')
  495. return defineProperty(target, key, attributes)
  496. })
  497. try {
  498. await expect(ctx.remote.$mount({
  499. package: '@fixture/direct-method-failure',
  500. descriptors: [directDescriptor()],
  501. })).rejects.toThrow('fixture direct method installation failure')
  502. } finally {
  503. spy.mockRestore()
  504. }
  505. expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
  506. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  507. const retry = await ctx.remote.$mount({
  508. package: '@fixture/direct-method-retry',
  509. descriptors: [directDescriptor()],
  510. })
  511. expect(ctx.remote.goals.create).toBeTypeOf('function')
  512. await retry()
  513. })
  514. it('withdraws a fresh scoped Service when its first method fails to install', async () => {
  515. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  516. const defineProperty = Object.defineProperty
  517. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  518. if (key === 'rename') throw new Error('fixture scoped installation failure')
  519. return defineProperty(target, key, attributes)
  520. })
  521. try {
  522. await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
  523. .rejects.toThrow('fixture scoped installation failure')
  524. } finally {
  525. spy.mockRestore()
  526. }
  527. expect(ctx.get('remote.goals')).toBeUndefined()
  528. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  529. const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
  530. expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
  531. await retry()
  532. })
  533. it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
  534. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  535. const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
  536. expect(ctx.get('remote.goals')).toBeDefined()
  537. await dispose()
  538. expect(ctx.get('remote.goals')).toBeUndefined()
  539. const replacement = { owner: 'replacement' }
  540. const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
  541. expect(ctx.get('remote.goals')).toBe(replacement)
  542. await disposeReplacement()
  543. })
  544. it('throws RPC failures with the structured error as its cause', async () => {
  545. const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
  546. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
  547. await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
  548. let failure: unknown
  549. try {
  550. await ctx.remote.goals.create('agent-1', { objective: 'ship' })
  551. } catch (error) {
  552. failure = error
  553. }
  554. expect(failure).toBeInstanceOf(Error)
  555. if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
  556. expect(failure.message).toContain('internal: host failed')
  557. expect(failure.cause).toBe(rpcError)
  558. })
  559. it('owns each $on subscription in the calling fiber', async () => {
  560. const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
  561. const seen: string[] = []
  562. const subscriber = ctx.plugin(Object.assign(
  563. (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
  564. { inject: ['remote'] },
  565. ))
  566. await subscriber
  567. ctx.remote.$dispatch('fixture/changed', ['settings'])
  568. expect(seen).toEqual(['settings'])
  569. await subscriber.dispose()
  570. ctx.remote.$dispatch('fixture/changed', ['after fiber disposal'])
  571. expect(seen).toEqual(['settings'])
  572. await client.dispose()
  573. expect(ctx.get('remote')).toBeUndefined()
  574. })
  575. it('isolates a throwing listener from the rest of the same event', async () => {
  576. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  577. const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  578. const seen: string[] = []
  579. const disposeFirst = ctx.remote.$on('fixture/changed', () => {
  580. throw new Error('fixture listener failure')
  581. })
  582. ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
  583. try {
  584. ctx.remote.$dispatch('fixture/changed', ['credentials'])
  585. expect(seen).toEqual(['credentials'])
  586. expect(consoleError).toHaveBeenCalledWith(
  587. 'client api: Remote event "fixture/changed" listener threw:',
  588. expect.any(Error),
  589. )
  590. disposeFirst()
  591. ctx.remote.$dispatch('fixture/changed', ['commands'])
  592. expect(seen).toEqual(['credentials', 'commands'])
  593. expect(consoleError).toHaveBeenCalledTimes(1)
  594. } finally {
  595. consoleError.mockRestore()
  596. }
  597. })
  598. it('contains an async listener whose promise rejects', async () => {
  599. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  600. const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  601. const seen: string[] = []
  602. // The declared return is void, so nobody awaits an async listener: the
  603. // rejection has to be contained here or it escapes as an unhandled one.
  604. ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises
  605. ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
  606. try {
  607. ctx.remote.$dispatch('fixture/changed', ['credentials'])
  608. await Promise.resolve()
  609. await Promise.resolve()
  610. expect(seen).toEqual(['credentials'])
  611. expect(consoleError).toHaveBeenCalledWith(
  612. 'client api: Remote event "fixture/changed" listener threw:',
  613. expect.any(Error),
  614. )
  615. } finally {
  616. consoleError.mockRestore()
  617. }
  618. })
  619. it('retires only its own registration when one listener subscribes twice', async () => {
  620. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  621. const seen: string[] = []
  622. // One function object, two registrations. A table keyed by listener identity
  623. // stores it once, so the first frame would reach it once instead of twice
  624. // and either disposer would silence both.
  625. const listener = (namespace: string): void => { seen.push(namespace) }
  626. const disposeFirst = ctx.remote.$on('fixture/changed', listener)
  627. ctx.remote.$on('fixture/changed', listener)
  628. ctx.remote.$dispatch('fixture/changed', ['both'])
  629. expect(seen).toEqual(['both', 'both'])
  630. // The surviving registration keeps receiving after its twin retires.
  631. disposeFirst()
  632. ctx.remote.$dispatch('fixture/changed', ['survivor'])
  633. expect(seen).toEqual(['both', 'both', 'survivor'])
  634. // Disposing twice is inert: the record is already gone, so the second call
  635. // must not splice the surviving twin out from under its own owner.
  636. disposeFirst()
  637. ctx.remote.$dispatch('fixture/changed', ['still here'])
  638. expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
  639. })
  640. it('separates the consumer verb from the carrier handoff', () => {
  641. expectTypeOf<ClientRemote>().toHaveProperty('$on')
  642. // The carrier owning the frame sink calls this; a consumer subscribes instead.
  643. expectTypeOf<ClientRemote>().toHaveProperty('$dispatch')
  644. })
  645. it('drops a forwarded event nobody subscribes to', async () => {
  646. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  647. const seen: string[] = []
  648. ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
  649. ctx.remote.$dispatch('fixture/idle', [1])
  650. expect(seen).toEqual([])
  651. })
  652. })