plugin.client.spec.ts 19 KB

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