boot-client.client.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. // @vitest-environment jsdom
  2. import { Context } from '@deepseek-ai/cordis'
  3. import {
  4. createClientModuleSystem, parseBootManifest,
  5. type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
  6. } from '@deepseek-ai/dsh-client-modules/client'
  7. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  8. import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
  9. import { FIBER_STATE } from '../src/loader-status.ts'
  10. const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules'
  11. function graphOf(ids: readonly string[]): WebBootGraph {
  12. const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' }))
  13. return {
  14. rev: 'graph',
  15. entries,
  16. batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }],
  17. }
  18. }
  19. /** Module system seeded with inline plugin modules; `loaded` records every transport call. */
  20. function modulesOf(graph: WebBootGraph, staticModules: Record<string, unknown>): { modules: ClientModuleLoader; loaded: string[] } {
  21. const loaded: string[] = []
  22. const pendingQueue: ClientBundleRegistration[] = []
  23. const target: ClientModuleLoaderTarget = {
  24. mode: 'queue',
  25. pendingQueue,
  26. load: (registration) => { pendingQueue.push(registration) },
  27. create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options),
  28. }
  29. const modules = target.create({
  30. boot: graph,
  31. staticModules,
  32. loadBundle: async (url) => { loaded.push(url) },
  33. })
  34. return { modules, loaded }
  35. }
  36. /** Recording progress sink. */
  37. function stateSink(): { states: Map<string, EntryStateLabel[]>; onEntryState: (name: string, state: EntryStateLabel) => void } {
  38. const states = new Map<string, EntryStateLabel[]>()
  39. return {
  40. states,
  41. onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) },
  42. }
  43. }
  44. describe('bootClient', () => {
  45. it('activates every seeded row without touching the bundle transport', async () => {
  46. const graph = graphOf(['provider', 'consumer'])
  47. const { modules, loaded } = modulesOf(graph, {
  48. provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } },
  49. consumer: { inject: ['x'], apply: () => {} },
  50. })
  51. const ctx = new Context()
  52. const sink = stateSink()
  53. await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState })
  54. expect(loaded).toEqual([])
  55. const consumer = sink.states.get('consumer') ?? []
  56. expect(consumer[0]).toBe('loading')
  57. expect(consumer.at(-1)).toBe('active')
  58. expect(sink.states.get('provider')?.at(-1)).toBe('active')
  59. await ctx.fiber.dispose()
  60. })
  61. it('reports a row waiting on a service the roster never provides', async () => {
  62. const graph = graphOf(['orphan'])
  63. const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } })
  64. const ctx = new Context()
  65. await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow(
  66. 'orphan: pending (waiting for service: nothing)',
  67. )
  68. await ctx.fiber.dispose()
  69. })
  70. it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => {
  71. const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
  72. const manifest = parseBootManifest(graphOf(['ghost']))
  73. const ctx = new Context()
  74. onTestFinished(() => ctx.fiber.dispose())
  75. const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  76. onTestFinished(() => { error.mockRestore() })
  77. const sink = stateSink()
  78. await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
  79. 'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)',
  80. )
  81. expect(sink.states.get('ghost')).toEqual(['loading', 'failed'])
  82. expect(error).toHaveBeenCalledOnce()
  83. expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve'))
  84. })
  85. })
  86. describe('assertEntriesActive', () => {
  87. interface FakeEntry { name: string; fiber?: { state: number; inject: Record<string, null> } }
  88. /** Loader-shaped double: entries with scripted fiber states, services by name. */
  89. function auditCtx(entries: readonly FakeEntry[], services: Record<string, unknown> = {}): Context {
  90. return {
  91. loader: {
  92. * entries() {
  93. for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber }
  94. },
  95. },
  96. get: (name: string) => services[name],
  97. } as unknown as Context
  98. }
  99. it('passes when every entry is active', () => {
  100. expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
  101. })
  102. it('names import failures, missing services, and other non-active states', () => {
  103. const ctx = auditCtx([
  104. { name: 'lost' },
  105. { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } },
  106. { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } },
  107. { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
  108. ], { present: {} })
  109. expect(() => { assertEntriesActive(ctx) }).toThrow([
  110. 'web boot: 4 entries did not activate',
  111. 'lost: import failed (see console for the import error)',
  112. 'waiting: pending (waiting for services: a, b)',
  113. 'opaque: pending (waiting for services: unknown)',
  114. 'broken: failed',
  115. ].join('\n'))
  116. })
  117. it('uses the singular form for one failing entry', () => {
  118. expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
  119. })
  120. })