runner.client.spec.ts 21 KB

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