plugin.client.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. /**
  2. * @vitest-environment jsdom
  3. *
  4. * Plugin composition account: the dispatch family reaches the runner with its
  5. * envelope rpcId, the service face is provided for UI surfaces, a load failure
  6. * always reaches the console, the fiber owns the runner's teardown, and the
  7. * node half remains inert.
  8. */
  9. /* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
  10. import { Context } from '@deepseek-ai/cordis'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import type {
  13. ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
  14. DynamicCordisInvokeResult, SessionId,
  15. } from '@deepseek-ai/dsh-api-remotes/client'
  16. // Type-only: resolves the `ctx.remote.$on` surface.
  17. import type {} from '@deepseek-ai/dsh-api-gateway/client'
  18. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  19. import * as NodeHalf from '../src/index.ts'
  20. import * as ClientHalf from '../src/client/index.ts'
  21. const PLUGIN = 'dyn-1' as CordisDynamicPluginId
  22. const PACKAGE = 'pkg-1' as CordisDynamicPackageId
  23. const RUN = 'run-1' as CordisDynamicPluginRunId
  24. const AGENT = 's-1' as SessionId
  25. const USER_RUN = {
  26. agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true,
  27. }
  28. interface Bench {
  29. ctx: Context
  30. /** Source the host hands over for the next run. */
  31. source: { current: {
  32. code: string
  33. name: string
  34. pluginId: CordisDynamicPluginId
  35. packageId: CordisDynamicPackageId
  36. pluginRunId: CordisDynamicPluginRunId
  37. } }
  38. /** Resolutions the host received. */
  39. resolved: { requestId: string; resolution: unknown }[]
  40. /** What the namespace received. */
  41. invoked: { pluginId: CordisDynamicPluginId; pluginRunId: CordisDynamicPluginRunId; method: string; args: unknown }[]
  42. /** Answer of the next invoke call. */
  43. invokeResult: { current: DynamicCordisInvokeResult }
  44. /** Rejection the namespace throws instead of answering (the codec refusing a payload). */
  45. invokeThrow: { current: unknown }
  46. /** Render failures the namespace received, in order. */
  47. renderFailures: {
  48. agentId: string
  49. pluginId: CordisDynamicPluginId
  50. pluginRunId: CordisDynamicPluginRunId
  51. failure: unknown
  52. }[]
  53. /** Whether the namespace refuses the next render-failure report. */
  54. reportRefused: { current: boolean }
  55. /** Drive one forwarded Host event through the test-owned subscription table. */
  56. forward: (event: string, payload: object) => void
  57. /**
  58. * Report one entry crash the way the renderer's boundary does. Production calls
  59. * this from ui-renderer's boundary through the render host; a test has no React
  60. * tree, so it stands in for that caller on the same core seam.
  61. */
  62. crash: (slot: string, entry: unknown, abdicate: boolean, error: unknown) => void
  63. dispose: () => Promise<void>
  64. settle: () => Promise<void>
  65. }
  66. /** Mount the browser half over a module table and a loader standing on real fibers. */
  67. async function boot(): Promise<Bench> {
  68. const ctx = new Context()
  69. await ctx.plugin(SlotRegistry)
  70. const factories = new Map<string, () => unknown>()
  71. const fibers = new Map<string, { fiber: unknown }>()
  72. let next = 0
  73. ;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
  74. load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
  75. }
  76. ctx.reflect.provide('loader', {
  77. create: (options: { name: string }) => {
  78. const entryId = `entry-${++next}`
  79. const fiber = ctx.plugin(factories.get(options.name)?.() as Parameters<Context['plugin']>[0])
  80. // The runner reads activation failure through fiber.await(); terminate this
  81. // handle too, or a failing package also lands as an unhandled rejection.
  82. void Promise.resolve(fiber).catch(() => {})
  83. fibers.set(entryId, { fiber })
  84. return Promise.resolve(entryId)
  85. },
  86. resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
  87. remove: async (entryId: string) => {
  88. const entry = fibers.get(entryId)
  89. fibers.delete(entryId)
  90. await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
  91. },
  92. })
  93. ctx.reflect.provide('modules', { invalidate: () => {} })
  94. const invoked: Bench['invoked'] = []
  95. const invokeResult: { current: DynamicCordisInvokeResult } = { current: { ok: true, value: 'pong' } }
  96. const invokeThrow: { current: unknown } = { current: undefined }
  97. const source: Bench['source'] = { current: {
  98. code: 'return { apply(ctx) {} }',
  99. name: 'demo',
  100. pluginId: PLUGIN,
  101. packageId: PACKAGE,
  102. pluginRunId: RUN,
  103. } }
  104. const resolved: { requestId: string; resolution: unknown }[] = []
  105. const renderFailures: Bench['renderFailures'] = []
  106. const reportRefused = { current: false }
  107. // Every generated Remote method resolves to a RemoteResult: the carrier folds
  108. // its own failures into the error branch, and only an assembly fault rejects.
  109. const answered = <T>(value: T): Promise<{ ok: true; value: T }> => Promise.resolve({ ok: true as const, value })
  110. const namespace = {
  111. syncInspectManifest: () => answered(null),
  112. resolveInspectQuery: () => answered({ accepted: true }),
  113. runHostHalf: () => answered({
  114. ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [], startedHere: true,
  115. }),
  116. settleUserRun: () => answered({
  117. ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [],
  118. }),
  119. reportRenderFailure: (
  120. agentId: string,
  121. pluginId: CordisDynamicPluginId,
  122. pluginRunId: CordisDynamicPluginRunId,
  123. failure: unknown,
  124. ) => {
  125. renderFailures.push({ agentId, pluginId, pluginRunId, failure })
  126. return reportRefused.current ? Promise.reject(new Error('stream gone')) : answered(undefined)
  127. },
  128. getClientCode: () => answered(source.current),
  129. resolveRequestRun: (requestId: string, resolution: unknown) => {
  130. resolved.push({ requestId, resolution })
  131. return answered({ accepted: true })
  132. },
  133. invoke: (
  134. pluginId: CordisDynamicPluginId,
  135. pluginRunId: CordisDynamicPluginRunId,
  136. method: string,
  137. args: unknown,
  138. ) => {
  139. invoked.push({ pluginId, pluginRunId, method, args })
  140. const refusal = invokeThrow.current
  141. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is a case under test
  142. if (refusal !== undefined) return Promise.reject(refusal)
  143. return answered(invokeResult.current)
  144. },
  145. }
  146. // Minimal stand-in for the gateway's Client Remote: the fan-out under test is
  147. // this plugin's subscriptions, so registration order and delivery are all the
  148. // stub owes (api-gateway covers isolation and disposal on the real one).
  149. const listeners = new Map<string, ((...args: never[]) => void)[]>()
  150. const forward = (event: string, payload: object): void => {
  151. for (const listener of [...listeners.get(event) ?? []]) {
  152. (listener as (...args: readonly unknown[]) => void)(payload)
  153. }
  154. }
  155. const remote = {
  156. dynamicCordisRunner: namespace,
  157. $on: (event: string, listener: (...args: never[]) => void) => {
  158. const bucket = listeners.get(event) ?? []
  159. bucket.push(listener)
  160. listeners.set(event, bucket)
  161. return () => {
  162. const at = bucket.indexOf(listener)
  163. if (at >= 0) bucket.splice(at, 1)
  164. }
  165. },
  166. }
  167. ctx.reflect.provide('remote', remote)
  168. ctx.reflect.provide('remote.dynamicCordisRunner', namespace)
  169. const fiber = ctx.plugin(ClientHalf)
  170. await fiber
  171. return {
  172. ctx,
  173. source,
  174. resolved,
  175. invoked,
  176. invokeResult,
  177. invokeThrow,
  178. renderFailures,
  179. reportRefused,
  180. forward,
  181. crash: (slot, entry, abdicate, error) => {
  182. const core = (ctx.slots as unknown as {
  183. _core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void }
  184. })._core
  185. core.reportEntryError(slot, entry, error, { abdicate })
  186. },
  187. dispose: async () => { await fiber.dispose() },
  188. settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
  189. }
  190. }
  191. describe('browser half', () => {
  192. it('provides the load engine as the page run-state face', async () => {
  193. const bench = await boot()
  194. expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
  195. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
  196. })
  197. it('unloads on a forwarded withdrawal event', async () => {
  198. const bench = await boot()
  199. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  200. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
  201. bench.forward('cordis/dynamic-retract', {
  202. pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
  203. })
  204. await bench.settle()
  205. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
  206. })
  207. it('runs a host-only definition through the face without loading anything here', async () => {
  208. const bench = await boot()
  209. await bench.ctx.dynamicCordisRunner.startUserRun({ ...USER_RUN, hasClientHalf: false })
  210. // The host half is up and this page has nothing — and no failure, which is
  211. // what the surface's control promised.
  212. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
  213. expect(bench.ctx.dynamicCordisRunner.lastRunError.getSnapshot().size).toBe(0)
  214. })
  215. it('routes host.call through the namespace and unwraps the result', async () => {
  216. const bench = await boot()
  217. bench.source.current = { ...bench.source.current,
  218. code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", { a: 1 })'
  219. + '.then((value) => value, (error) => error.message) } }',
  220. }
  221. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  222. const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
  223. delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
  224. await expect(call).resolves.toBe('pong')
  225. expect(bench.invoked).toEqual([{
  226. pluginId: PLUGIN, pluginRunId: RUN, method: 'ping', args: { a: 1 },
  227. }])
  228. })
  229. it('carries an omitted host.call argument to the namespace as null', async () => {
  230. const bench = await boot()
  231. bench.source.current = { ...bench.source.current,
  232. code: 'return { apply: () => { globalThis.__dynCall = host.call("listServices") } }',
  233. }
  234. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  235. const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
  236. delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
  237. await call
  238. // `undefined` is not JSON, so the wire would refuse the call the model wrote
  239. // most naturally; the omission travels as null instead.
  240. expect(bench.invoked).toEqual([{
  241. pluginId: PLUGIN, pluginRunId: RUN, method: 'listServices', args: null,
  242. }])
  243. })
  244. it('teaches the JSON contract when the namespace refuses the payload', async () => {
  245. const bench = await boot()
  246. // What the generated codec throws for a value that is not JSON: a bare field
  247. // name, with no idea which call it belonged to or what to write instead.
  248. bench.invokeThrow.current = new Error('client api: dynamicCordisRunner/invoke rejected "args"')
  249. bench.source.current = { ...bench.source.current,
  250. code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
  251. + '.then(() => "resolved", (error) => error.message) } }',
  252. }
  253. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  254. const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
  255. delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
  256. await expect(call).resolves.toMatch(/host\.call\("ping"\) on dyn-1 did not complete: client api: .*rejected "args"/)
  257. await expect(call).resolves.toMatch(/omit it, and the handler receives null/)
  258. await expect(call).resolves.toMatch(/`return null` when there is nothing to report/)
  259. })
  260. it('stringifies a non-Error refusal into the same teaching error', async () => {
  261. const bench = await boot()
  262. bench.invokeThrow.current = 'stream gone'
  263. bench.source.current = { ...bench.source.current,
  264. code: 'return { apply: () => { globalThis.__dynCall = host.call("ping")'
  265. + '.then(() => "resolved", (error) => error.message) } }',
  266. }
  267. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  268. const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
  269. delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
  270. await expect(call).resolves.toMatch(/did not complete: stream gone/)
  271. })
  272. it('sends a render crash of its own entry to the host, and survives a refused report', async () => {
  273. const bench = await boot()
  274. bench.source.current = { ...bench.source.current,
  275. code: `return {
  276. inject: ['slots'],
  277. apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
  278. }`,
  279. }
  280. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  281. const [entry] = bench.ctx.slots.entries('root')
  282. bench.crash('root', entry, true, new Error('Cannot read properties of undefined'))
  283. expect(bench.renderFailures).toEqual([{
  284. agentId: AGENT,
  285. pluginId: PLUGIN,
  286. pluginRunId: RUN,
  287. failure: {
  288. slot: 'root',
  289. message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
  290. stack: expect.any(String),
  291. abdicated: true,
  292. },
  293. }])
  294. // The same observation also reaches the page's own surface, so a row can show
  295. // it without reading the host back.
  296. expect(bench.ctx.dynamicCordisRunner.renderFailures.getSnapshot().get(PLUGIN)).toEqual(bench.renderFailures[0]?.failure)
  297. // A report the host refuses is logged and dropped: one crash must not become
  298. // two, and nothing waits on this answer.
  299. const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
  300. bench.reportRefused.current = true
  301. bench.crash('root', entry, false, new Error('again'))
  302. await bench.settle()
  303. const complaints = logged.mock.calls.filter(call => String(call[0]).includes('reporting a render failure'))
  304. logged.mockRestore()
  305. expect(complaints).toHaveLength(1)
  306. })
  307. it('turns each routing failure code into its own teaching error', async () => {
  308. const codes = [
  309. ['plugin-not-running', /found no active Host half/],
  310. ['stale-run', /activation that has already been replaced/],
  311. ['method-not-found', /must declare it with harness\.handle\("ping", fn\)/],
  312. ['handler-error', /failed inside the host handler: boom/],
  313. ] as const
  314. for (const [code, expected] of codes) {
  315. const bench = await boot()
  316. bench.invokeResult.current = { ok: false, code, message: 'boom' }
  317. bench.source.current = { ...bench.source.current,
  318. code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
  319. + '.then(() => "resolved", (error) => error.message) } }',
  320. }
  321. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  322. const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
  323. delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
  324. await expect(call).resolves.toMatch(expected)
  325. }
  326. })
  327. it('answers a run request after the surface approves it', async () => {
  328. const bench = await boot()
  329. const request = 'rr-1' as ApprovalRequestId
  330. bench.forward('cordis/request-run', {
  331. requestId: request,
  332. agentId: AGENT,
  333. pluginId: PLUGIN,
  334. packageId: PACKAGE,
  335. mode: 'run',
  336. name: 'demo',
  337. purpose: 'show a clock',
  338. requiresApproval: true,
  339. })
  340. await bench.settle()
  341. // The event's own fields reach the activity: a surface groups the row by
  342. // session and shows the reason without a registry read.
  343. expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
  344. phase: 'awaiting-approval',
  345. requestId: request,
  346. agentId: AGENT,
  347. packageId: PACKAGE,
  348. mode: 'run',
  349. name: 'demo',
  350. purpose: 'show a clock',
  351. })
  352. await bench.ctx.dynamicCordisRunner.approve(request, false)
  353. expect(bench.resolved).toEqual([{
  354. requestId: request, resolution: { ok: true, pluginRunId: RUN },
  355. }])
  356. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
  357. expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
  358. })
  359. it('drops the affordance when another page answers the request', async () => {
  360. const bench = await boot()
  361. const request = 'rr-2' as ApprovalRequestId
  362. bench.forward('cordis/request-run', {
  363. requestId: request,
  364. agentId: AGENT,
  365. pluginId: PLUGIN,
  366. packageId: PACKAGE,
  367. mode: 'run',
  368. name: 'demo',
  369. purpose: 'p',
  370. requiresApproval: true,
  371. })
  372. await bench.settle()
  373. bench.forward('cordis/request-run-resolved', {
  374. requestId: request, outcome: 'approved',
  375. })
  376. await bench.settle()
  377. expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
  378. // Answering a settled request is a no-op, not an error.
  379. await bench.ctx.dynamicCordisRunner.approve(request, false)
  380. expect(bench.resolved).toEqual([])
  381. })
  382. it('exposes the refusal and the load observer on the face', async () => {
  383. const bench = await boot()
  384. const request = 'rr-3' as ApprovalRequestId
  385. bench.forward('cordis/request-run', {
  386. requestId: request,
  387. agentId: AGENT,
  388. pluginId: PLUGIN,
  389. packageId: PACKAGE,
  390. mode: 'run',
  391. name: 'demo',
  392. purpose: 'p',
  393. requiresApproval: true,
  394. })
  395. await bench.settle()
  396. let loads = 0
  397. const unsubscribe = bench.ctx.dynamicCordisRunner.subscribe(() => { loads++ })
  398. await bench.ctx.dynamicCordisRunner.decline(request)
  399. expect(bench.resolved).toEqual([{ requestId: request, resolution: { ok: false, reason: 'rejected' } }])
  400. expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
  401. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  402. expect(loads).toBeGreaterThan(0)
  403. unsubscribe()
  404. })
  405. it('unloads every package when its own fiber goes away', async () => {
  406. const bench = await boot()
  407. await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
  408. const runner = bench.ctx.dynamicCordisRunner
  409. await bench.dispose()
  410. await bench.settle()
  411. expect(runner.getSnapshot()).toEqual([])
  412. })
  413. })
  414. describe('node half', () => {
  415. it('contributes nothing host-side', () => {
  416. NodeHalf.apply()
  417. expect(typeof NodeHalf.apply).toBe('function')
  418. })
  419. })