client.spec.ts 34 KB

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