runner.client.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. /**
  2. * @vitest-environment jsdom
  3. *
  4. * Load-engine account: what `load` answers its caller (that answer is what the
  5. * run orchestration reports to the host), Plugin Run convergence against live
  6. * state, per-Plugin serialization, the three-step teardown, and each failing stage.
  7. *
  8. * The loader is stood in by real `ctx.plugin` fibers: entry creation must run the
  9. * guarded surface as a genuine plugin, or neither activation gating nor the
  10. * disposal cascade under test would be real.
  11. */
  12. /* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
  13. import { Context } from '@deepseek-ai/cordis'
  14. import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
  15. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  16. import type {
  17. CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId,
  18. } from '@deepseek-ai/dsh-api-remotes/client'
  19. import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
  20. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  21. import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts'
  22. import { DynamicCordisPackageRunner } from '../src/client/runtime.ts'
  23. import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.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. function runId(value: number): CordisDynamicPluginRunId {
  29. return `run-${value}` as CordisDynamicPluginRunId
  30. }
  31. /** One browser half as the host hands it over. */
  32. function half(overrides: Partial<DynamicCordisClientHalf> = {}): DynamicCordisClientHalf {
  33. return {
  34. pluginId: PLUGIN,
  35. packageId: PACKAGE,
  36. pluginRunId: RUN,
  37. agentId: AGENT,
  38. name: 'demo',
  39. code: 'return { apply(ctx) {} }',
  40. ...overrides,
  41. }
  42. }
  43. interface Bench {
  44. ctx: Context
  45. slots: SlotRegistry
  46. runner: DynamicCordisPackageRunner
  47. invalidated: string[]
  48. removed: string[]
  49. created: string[]
  50. invoke: ReturnType<typeof vi.fn<() => Promise<unknown>>>
  51. /** Render failures the runner sent upstream, in order. */
  52. reported: {
  53. agentId: SessionId
  54. pluginId: CordisDynamicPluginId
  55. pluginRunId: CordisDynamicPluginRunId
  56. failure: DynamicCordisRenderFailure
  57. }[]
  58. /**
  59. * Report one Slot entry or Factory crash the way the renderer's boundary does: the runner
  60. * subscribed through the supervision seam, and this calls what it registered.
  61. */
  62. crash: (slot: string, entry: unknown, error: unknown, abdicated?: boolean) => void
  63. /** Whether the runner released its subscription. */
  64. watching: () => boolean
  65. settle: () => Promise<void>
  66. }
  67. /**
  68. * Terminate the awaitable fiber handle. The runner reads activation failure
  69. * through `fiber.await()`; without a handler on the fiber itself, a deliberately
  70. * failing package would also surface as an unhandled rejection.
  71. */
  72. function seated<T>(fiber: T): T {
  73. void Promise.resolve(fiber).catch(() => {})
  74. return fiber
  75. }
  76. async function boot(): Promise<Bench> {
  77. const ctx = new Context()
  78. await ctx.plugin(SlotRegistry)
  79. const invalidated: string[] = []
  80. const removed: string[] = []
  81. const created: string[] = []
  82. const factories = new Map<string, () => unknown>()
  83. const fibers = new Map<string, { fiber: unknown }>()
  84. let next = 0
  85. ;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
  86. load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
  87. }
  88. const loader = {
  89. create: (options: { name: string }) => {
  90. created.push(options.name)
  91. const factory = factories.get(options.name)
  92. if (factory === undefined) throw new Error(`no factory for ${options.name}`)
  93. const entryId = `entry-${++next}`
  94. fibers.set(entryId, { fiber: seated(ctx.plugin(factory() as Parameters<Context['plugin']>[0])) })
  95. return Promise.resolve(entryId)
  96. },
  97. resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
  98. remove: (entryId: string) => {
  99. removed.push(entryId)
  100. const entry = fibers.get(entryId)
  101. fibers.delete(entryId)
  102. void (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
  103. },
  104. } as unknown as Loader
  105. const invoke = vi.fn<() => Promise<unknown>>(() => Promise.resolve(null))
  106. const reported: Bench['reported'] = []
  107. // The crash seam is stood in so a test can report an entry failure without a
  108. // React render, exactly as the renderer's boundary would; registrations still
  109. // go through the real service, so the entries are real.
  110. type EntryErrorListener = (slot: string, entry: unknown, error: unknown, info: { abdicated: boolean }) => void
  111. let listener: EntryErrorListener | undefined
  112. const runner = new DynamicCordisPackageRunner({
  113. ctx,
  114. loader,
  115. modules: { invalidate: (id: string) => { invalidated.push(id) } } as unknown as ClientModuleSystem,
  116. slots: {
  117. onEntryError: (fn: EntryErrorListener) => {
  118. listener = fn
  119. return () => { listener = undefined }
  120. },
  121. } as unknown as SlotRegistry,
  122. invoke,
  123. reportGuardFailure: () => {},
  124. reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
  125. reported.push({ agentId, pluginId, pluginRunId, failure })
  126. },
  127. })
  128. return {
  129. ctx,
  130. slots: ctx.slots,
  131. runner,
  132. invalidated,
  133. removed,
  134. created,
  135. invoke,
  136. reported,
  137. crash: (slot, entry, error, abdicated = true) => {
  138. if (listener === undefined) throw new Error('the runner is not watching the crash seam')
  139. listener(slot, entry, error, { abdicated })
  140. },
  141. watching: () => listener !== undefined,
  142. settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
  143. }
  144. }
  145. describe('load', () => {
  146. it('mounts a browser half through the module table and the loader, then answers active', async () => {
  147. const bench = await boot()
  148. await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
  149. expect(bench.invalidated).toEqual(['dyn/dyn-1'])
  150. expect(bench.created).toEqual(['dyn/dyn-1'])
  151. expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
  152. expect(bench.runner.getSnapshot()).toEqual([
  153. { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: [], styleCount: 0 },
  154. ])
  155. })
  156. it('projects the contributions the package made', async () => {
  157. const bench = await boot()
  158. await bench.runner.load(half({
  159. code: `return {
  160. inject: ['slots'],
  161. apply(ctx) {
  162. styles.insert('.x {}')
  163. ctx.slots.register({ name: 'root' }, () => null)
  164. },
  165. }`,
  166. }))
  167. expect(bench.runner.getSnapshot()).toEqual([
  168. { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: ['root'], styleCount: 1 },
  169. ])
  170. })
  171. it('answers from live state when the revision is already loaded here', async () => {
  172. const bench = await boot()
  173. await bench.runner.load(half())
  174. // A replayed run must not look unacknowledged, and must not reload.
  175. await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
  176. expect(bench.created).toEqual(['dyn/dyn-1'])
  177. expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
  178. })
  179. it('replays the parked services a live package still waits for', async () => {
  180. const bench = await boot()
  181. const parked = half({ code: "return { inject: ['absent'], apply() {} }" })
  182. await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
  183. await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
  184. expect(bench.created).toEqual(['dyn/dyn-1'])
  185. })
  186. it('replaces a live load when a newer revision arrives', async () => {
  187. const bench = await boot()
  188. await bench.runner.load(half())
  189. await expect(bench.runner.load(half({ pluginRunId: runId(2) }))).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
  190. expect(bench.removed).toEqual(['entry-1'])
  191. expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1', 'dyn/dyn-1'])
  192. expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
  193. expect(bench.runner.getSnapshot()[0]?.pluginRunId).toBe(runId(2))
  194. })
  195. it('loads the function form, which declares no services', async () => {
  196. const bench = await boot()
  197. await expect(bench.runner.load(half({ code: 'return (ctx) => { globalThis.__dynFnForm = true }' })))
  198. .resolves.toEqual({ ok: true, pluginRunId: RUN })
  199. expect((globalThis as { __dynFnForm?: boolean }).__dynFnForm).toBe(true)
  200. delete (globalThis as { __dynFnForm?: boolean }).__dynFnForm
  201. })
  202. it('serializes operations of one package id', async () => {
  203. const bench = await boot()
  204. const first = bench.runner.load(half())
  205. const second = bench.runner.load(half({ pluginRunId: runId(2) }))
  206. await expect(first).resolves.toEqual({ ok: true, pluginRunId: RUN })
  207. await expect(second).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
  208. expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
  209. })
  210. it('keeps the queue usable after a failed operation', async () => {
  211. const bench = await boot()
  212. const sink = (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
  213. delete (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
  214. await expect(bench.runner.load(half())).rejects.toThrow(/__ModuleLoader__ is missing/)
  215. ;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = sink
  216. await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
  217. })
  218. })
  219. describe('failure stages', () => {
  220. it('classifies a closure that will not evaluate, and leaves no styles behind', async () => {
  221. const bench = await boot()
  222. await expect(bench.runner.load(half({ code: 'styles.insert(".leak {}"); return 42' }))).resolves.toEqual({
  223. ok: false,
  224. cause: 'evaluate',
  225. message: expect.stringContaining('must `return` a plugin') as string,
  226. stack: expect.any(String),
  227. error: expect.any(Error),
  228. })
  229. const leaked = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
  230. .filter(tag => tag.textContent === '.leak {}')
  231. expect(leaked).toHaveLength(0)
  232. expect(bench.created).toEqual([])
  233. })
  234. it('classifies an apply that throws, and tears the entry down', async () => {
  235. const bench = await boot()
  236. await expect(bench.runner.load(half({ code: 'return { apply() { throw new Error("apply exploded") } }' })))
  237. .resolves.toEqual({
  238. ok: false,
  239. cause: 'activate',
  240. message: 'apply exploded',
  241. stack: expect.any(String),
  242. error: expect.any(Error),
  243. })
  244. expect(bench.removed).toEqual(['entry-1'])
  245. expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
  246. })
  247. it('stringifies a closure that rejects with a non-Error value', async () => {
  248. const bench = await boot()
  249. await expect(bench.runner.load(half({ code: 'throw "raw rejection"' })))
  250. .resolves.toEqual({ ok: false, cause: 'evaluate', message: 'raw rejection', error: 'raw rejection' })
  251. })
  252. it('classifies a loader entry that produced no fiber', async () => {
  253. const bench = await boot()
  254. const env = bench.runner as unknown as { env: { loader: { resolve: (id: string) => unknown } } }
  255. vi.spyOn(env.env.loader, 'resolve').mockReturnValue({ fiber: undefined })
  256. await expect(bench.runner.load(half())).resolves.toEqual({
  257. ok: false,
  258. cause: 'module-import',
  259. message: 'module import failed (see the browser console)',
  260. })
  261. vi.restoreAllMocks()
  262. expect(bench.removed).toEqual(['entry-1'])
  263. })
  264. it('mirrors a loaded package runtime error to the console without unloading it', async () => {
  265. const bench = await boot()
  266. const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
  267. await bench.runner.load(half({
  268. code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }',
  269. }))
  270. Reflect.apply(bench.ctx.emit.bind(bench.ctx), undefined, ['t/ping'])
  271. const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error'))
  272. logged.mockRestore()
  273. expect(mirrored).toHaveLength(1)
  274. expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
  275. })
  276. })
  277. describe('retract', () => {
  278. it('waits for plugin cleanup before invalidating its module factory', async () => {
  279. const bench = await boot()
  280. const started = Promise.withResolvers<undefined>()
  281. const release = Promise.withResolvers<undefined>()
  282. onTestFinished(() => { release.resolve(undefined) })
  283. bench.invoke.mockImplementation(() => {
  284. started.resolve(undefined)
  285. return release.promise
  286. })
  287. await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' }))
  288. bench.runner.retract(PLUGIN, RUN)
  289. await started.promise
  290. await bench.settle()
  291. expect(bench.removed).toEqual(['entry-1'])
  292. expect(bench.invalidated).toEqual(['dyn/dyn-1'])
  293. release.resolve(undefined)
  294. await bench.settle()
  295. expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
  296. })
  297. it('unloads at the named revision', async () => {
  298. const bench = await boot()
  299. await bench.runner.load(half())
  300. bench.runner.retract(PLUGIN, RUN)
  301. await bench.settle()
  302. expect(bench.removed).toEqual(['entry-1'])
  303. expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
  304. expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
  305. })
  306. it('ignores a retract of a superseded revision', async () => {
  307. const bench = await boot()
  308. await bench.runner.load(half({ pluginRunId: runId(3) }))
  309. bench.runner.retract(PLUGIN, runId(2))
  310. await bench.settle()
  311. expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
  312. })
  313. it('ignores a retract of a package this page never loaded', async () => {
  314. const bench = await boot()
  315. bench.runner.retract(PLUGIN, RUN)
  316. await bench.settle()
  317. expect(bench.removed).toEqual([])
  318. })
  319. })
  320. describe('observation and disposal', () => {
  321. it('notifies subscribers and re-derives the snapshot after each convergence', async () => {
  322. const bench = await boot()
  323. let notified = 0
  324. const unsubscribe = bench.runner.subscribe(() => { notified++ })
  325. const empty = bench.runner.getSnapshot()
  326. expect(bench.runner.getSnapshot()).toBe(empty) // stable between mutations
  327. await bench.runner.load(half())
  328. expect(notified).toBe(1)
  329. expect(bench.runner.getSnapshot()).not.toBe(empty)
  330. unsubscribe()
  331. bench.runner.retract(PLUGIN, RUN)
  332. await bench.settle()
  333. expect(notified).toBe(1)
  334. })
  335. it('unloads every live package on disposal', async () => {
  336. const bench = await boot()
  337. await bench.runner.load(half())
  338. await bench.runner.dispose()
  339. expect(bench.removed).toEqual(['entry-1'])
  340. expect(bench.runner.getSnapshot()).toEqual([])
  341. expect(bench.slots.entries('root')).toHaveLength(0)
  342. })
  343. it('routes host.call through the invoke seam it was given', async () => {
  344. const bench = await boot()
  345. await bench.runner.load(half({ code: 'return { apply: () => host.call("ping", 1) }' }))
  346. expect(bench.invoke).toHaveBeenCalledWith(PLUGIN, RUN, 'ping', 1)
  347. })
  348. })
  349. describe('render failures', () => {
  350. /** A package that seats one component in `root`, so a crash has something to name. */
  351. const CONTRIBUTOR = `return {
  352. inject: ['slots'],
  353. apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
  354. }`
  355. const FACTORY_CONTRIBUTOR = `return {
  356. inject: ['slots'],
  357. apply(ctx) { ctx.slots.registerFactory({ name: 'dynamic.factory', scope: 'root' }, () => null) },
  358. }`
  359. it('reports a crash of an entry it seated, under the session the run was for', async () => {
  360. const bench = await boot()
  361. await bench.runner.load(half({ code: CONTRIBUTOR }))
  362. const [entry] = bench.slots.entries('root')
  363. bench.crash('root', entry, new Error('Cannot read properties of undefined'))
  364. expect(bench.reported).toEqual([{
  365. agentId: AGENT,
  366. pluginId: PLUGIN,
  367. pluginRunId: RUN,
  368. failure: {
  369. slot: 'root',
  370. message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
  371. stack: expect.any(String),
  372. abdicated: true,
  373. },
  374. }])
  375. })
  376. it('carries the retirement bit as the seam reported it', async () => {
  377. const bench = await boot()
  378. await bench.runner.load(half({ code: CONTRIBUTOR }))
  379. const [entry] = bench.slots.entries('root')
  380. // A chain crash keeps its cell: the package's UI is broken, not gone, and the
  381. // author needs to be able to tell those apart.
  382. bench.crash('root', entry, new Error('boom'), false)
  383. expect(bench.reported[0]?.failure.abdicated).toBe(false)
  384. })
  385. it('reports a crash of a Factory definition it seated without retiring it', async () => {
  386. const bench = await boot()
  387. await bench.runner.load(half({ code: FACTORY_CONTRIBUTOR }))
  388. const core = (bench.slots as unknown as {
  389. _core: { factory(name: string): unknown }
  390. })._core
  391. const definition = core.factory('dynamic.factory')
  392. bench.crash('factory:dynamic.factory', definition, new Error('factory boom'), false)
  393. expect(bench.reported[0]).toMatchObject({
  394. failure: {
  395. slot: 'factory:dynamic.factory',
  396. message: 'your component in Factory "dynamic.factory" crashed while React rendered it: factory boom',
  397. abdicated: false,
  398. },
  399. })
  400. })
  401. it('ignores a crash of an entry no dynamic package seated', async () => {
  402. const bench = await boot()
  403. await bench.runner.load(half({ code: CONTRIBUTOR }))
  404. // An entry whose component was not claimed by a dynamic package is not this runner's business.
  405. bench.crash('root', { component: () => null }, new Error('boom'))
  406. bench.crash('root', { component: 'not-a-component' }, new Error('boom'))
  407. bench.crash('root', { component: null }, new Error('boom'))
  408. expect(bench.reported).toEqual([])
  409. })
  410. it('seats a package that registers an unindexable component without claiming it', async () => {
  411. const bench = await boot()
  412. // A component that is not an object has no identity to key ownership on; the
  413. // registration remains valid, while a crash on it has no attributable package.
  414. await expect(bench.runner.load(half({
  415. code: `return {
  416. inject: ['slots'],
  417. apply(ctx) {
  418. ctx.slots.register({ name: 'root' }, 'not-a-component')
  419. ctx.slots.register({ name: 'root' }, null)
  420. },
  421. }`,
  422. }))).resolves.toEqual({ ok: true, pluginRunId: RUN })
  423. for (const entry of bench.slots.entries('root')) bench.crash('root', entry, new Error('boom'))
  424. expect(bench.reported).toEqual([])
  425. })
  426. it('appends the redirect a bare crash text is missing, and never twice', async () => {
  427. const bench = await boot()
  428. await bench.runner.load(half({ code: CONTRIBUTOR }))
  429. const [entry] = bench.slots.entries('root')
  430. // Reaching the global around the closure trap (window.setInterval) crashes
  431. // with the engine's own text, which teaches nothing on its own.
  432. bench.crash('root', entry, new TypeError('window.setInterval is not a function'))
  433. const bare = bench.reported[0]?.failure.message ?? ''
  434. expect(bare).toMatch(/is not a function\n/)
  435. const timerRedirect = DYNAMIC_CLIENT_REDIRECTS.setInterval
  436. if (timerRedirect === undefined) throw new Error('setInterval redirect is missing')
  437. expect(bare).toContain(timerRedirect)
  438. // The trap's own error already carries that sentence: appending it again
  439. // would make the model read the same paragraph twice.
  440. bench.crash('root', entry, new Error(
  441. `setInterval is not available in a dynamic client half — ${timerRedirect}`,
  442. ))
  443. const trapped = bench.reported[1]?.failure.message ?? ''
  444. expect(trapped.indexOf(timerRedirect)).toBe(trapped.lastIndexOf(timerRedirect))
  445. })
  446. it('stops watching the seam when the engine is disposed', async () => {
  447. const bench = await boot()
  448. await bench.runner.load(half({ code: CONTRIBUTOR }))
  449. expect(bench.watching()).toBe(true)
  450. await bench.runner.dispose()
  451. expect(bench.watching()).toBe(false)
  452. })
  453. it('publishes the crash on the live set\'s own notification channel', async () => {
  454. const bench = await boot()
  455. await bench.runner.load(half({ code: CONTRIBUTOR }))
  456. let notified = 0
  457. let alsoNotified = 0
  458. const unsubscribe = bench.runner.subscribe(() => { notified++ })
  459. const unobserve = bench.runner.renderFailures.subscribe(() => { alsoNotified++ })
  460. const empty = bench.runner.renderFailures.getSnapshot()
  461. expect(bench.runner.renderFailures.getSnapshot()).toBe(empty) // stable between mutations
  462. const [entry] = bench.slots.entries('root')
  463. bench.crash('root', entry, new Error('boom'), false)
  464. // A surface already subscribed for load changes learns about a crash too: one
  465. // channel, two derived snapshots — and the observable's own subscribe is that
  466. // same channel, so a surface may take either handle.
  467. expect(notified).toBe(1)
  468. expect(alsoNotified).toBe(1)
  469. const published = bench.runner.renderFailures.getSnapshot().get(PLUGIN)
  470. expect(published?.slot).toBe('root')
  471. expect(published?.abdicated).toBe(false)
  472. expect(published?.message).toMatch(/boom/)
  473. unsubscribe()
  474. unobserve()
  475. })
  476. it('keeps only the latest crash per package', async () => {
  477. const bench = await boot()
  478. await bench.runner.load(half({ code: CONTRIBUTOR }))
  479. const [entry] = bench.slots.entries('root')
  480. bench.crash('root', entry, new Error('first'))
  481. bench.crash('root', entry, new Error('second'))
  482. expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
  483. expect(bench.runner.renderFailures.getSnapshot().get(PLUGIN)?.message).toMatch(/second/)
  484. })
  485. it('clears the crash when the package is retracted', async () => {
  486. const bench = await boot()
  487. await bench.runner.load(half({ code: CONTRIBUTOR }))
  488. const [entry] = bench.slots.entries('root')
  489. bench.crash('root', entry, new Error('boom'))
  490. bench.runner.retract(PLUGIN, RUN)
  491. await bench.settle()
  492. // A row must never show a failure of something that no longer renders here.
  493. expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
  494. })
  495. it('clears the crash when the package loads again', async () => {
  496. const bench = await boot()
  497. await bench.runner.load(half({ code: CONTRIBUTOR }))
  498. const [entry] = bench.slots.entries('root')
  499. bench.crash('root', entry, new Error('boom'))
  500. expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
  501. await bench.runner.load(half({ code: CONTRIBUTOR, pluginRunId: runId(2) }))
  502. expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
  503. })
  504. it('keeps the crash when a replayed run loads nothing', async () => {
  505. const bench = await boot()
  506. await bench.runner.load(half({ code: CONTRIBUTOR }))
  507. const [entry] = bench.slots.entries('root')
  508. bench.crash('root', entry, new Error('boom'))
  509. // Same revision: nothing was re-run, so the failure the page is showing is
  510. // still true of what is mounted.
  511. await bench.runner.load(half({ code: CONTRIBUTOR }))
  512. expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
  513. })
  514. })