boot.client.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. // @vitest-environment jsdom
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
  4. import type {
  5. ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoaderTarget, DshWindow,
  6. WebBootEntry,
  7. } from '@deepseek-ai/dsh-client-modules/client'
  8. import { afterEach, describe, expect, it, vi } from 'vitest'
  9. import { AppWebEntry } from '../src/boot.ts'
  10. const MODULES_ID = '@deepseek-ai/dsh-client-modules'
  11. const PROVIDER_CLIENT_ID = 'provider/client'
  12. const RUNTIME_CLIENT_ID = 'runtime/client'
  13. const win = globalThis as DshWindow
  14. const transportGlobal = globalThis as {
  15. __DSH_TRANSPORT__?: { loadBundle(url: string): Promise<void> }
  16. }
  17. const moduleFace = modulesClient as unknown as Record<string, unknown>
  18. afterEach(() => {
  19. vi.restoreAllMocks()
  20. delete win.__DSH_BOOT__
  21. delete win.__ModuleLoader__
  22. delete transportGlobal.__DSH_TRANSPORT__
  23. document.body.innerHTML = ''
  24. })
  25. /** Install the stable facade shape that the Host injects before AppWebEntry runs. */
  26. function installFacade(
  27. create?: (options: ClientModuleCreateOptions) => modulesClient.ClientModuleSystem,
  28. ): ClientModuleLoaderTarget {
  29. const pendingQueue: ClientBundleRegistration[] = []
  30. const target: ClientModuleLoaderTarget = {
  31. mode: 'queue',
  32. pendingQueue,
  33. load: (registration) => { pendingQueue.push(registration) },
  34. create: create ?? (options => modulesClient.createClientModuleSystem(target, {
  35. id: MODULES_ID,
  36. exports: moduleFace,
  37. }, options)),
  38. }
  39. win.__ModuleLoader__ = target
  40. return target
  41. }
  42. async function expectBootFailure(setup: () => void, message: string): Promise<void> {
  43. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  44. const container = document.createElement('div')
  45. document.body.append(container)
  46. setup()
  47. const entry = new AppWebEntry(container)
  48. await entry.run()
  49. expect(container.textContent).toContain(message)
  50. expect(error).toHaveBeenCalledOnce()
  51. await entry.dispose()
  52. }
  53. describe('bootstrap failure rendering', () => {
  54. it('renders a missing bootstrap facade', async () => {
  55. await expectBootFailure(
  56. () => { delete win.__ModuleLoader__ },
  57. 'window.__ModuleLoader__ bootstrap facade is missing',
  58. )
  59. })
  60. it('renders a create failure owned by the facade', async () => {
  61. await expectBootFailure(() => {
  62. installFacade(() => { throw new Error('facade create failed') })
  63. }, 'facade create failed')
  64. })
  65. it('renders a malformed boot manifest', async () => {
  66. await expectBootFailure(() => {
  67. installFacade()
  68. delete win.__DSH_BOOT__
  69. }, 'window.__DSH_BOOT__ is missing or not an object')
  70. })
  71. it('renders a module-system construction failure', async () => {
  72. await expectBootFailure(() => {
  73. installFacade()
  74. const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' }
  75. win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] }
  76. }, 'duplicate graph entry "duplicate"')
  77. })
  78. })
  79. describe('plugin activation', () => {
  80. it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => {
  81. const container = document.createElement('div')
  82. document.body.append(container)
  83. const target = installFacade()
  84. const entries: WebBootEntry[] = [
  85. { id: 'consumer', url: '/consumer.js', rev: '1' },
  86. {
  87. id: 'runtime',
  88. url: '/runtime.js',
  89. rev: '1',
  90. external: [PROVIDER_CLIENT_ID],
  91. immediately: true,
  92. },
  93. { id: 'provider', url: '/provider.js', rev: '1' },
  94. { id: 'renderer', url: '/renderer.js', rev: '1' },
  95. ]
  96. win.__DSH_BOOT__ = { rev: 'graph', entries }
  97. target.load({
  98. id: 'runtime',
  99. factory: require => ({
  100. apply: () => {},
  101. marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
  102. }),
  103. })
  104. const loaded: string[] = []
  105. const registrations = new Map<string, ClientBundleRegistration>([
  106. ['/consumer.js', {
  107. id: 'consumer',
  108. factory: require => ({
  109. apply: () => {
  110. expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
  111. },
  112. }),
  113. }],
  114. ['/provider.js', {
  115. id: 'provider',
  116. factory: () => ({ apply: () => {}, marker: 'provider' }),
  117. }],
  118. ['/renderer.js', {
  119. id: 'renderer',
  120. factory: () => ({
  121. apply: (ctx: Context) => {
  122. ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
  123. },
  124. }),
  125. }],
  126. ])
  127. transportGlobal.__DSH_TRANSPORT__ = {
  128. loadBundle: async (url) => {
  129. loaded.push(url)
  130. const registration = registrations.get(url)
  131. if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
  132. target.load(registration)
  133. },
  134. }
  135. const entry = new AppWebEntry(container)
  136. await entry.run()
  137. expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js'])
  138. await entry.dispose()
  139. })
  140. it('allows a modules-dependent row to be created before the modules row', async () => {
  141. const events: string[] = []
  142. const container = document.createElement('div')
  143. document.body.append(container)
  144. const target = installFacade()
  145. const entries: WebBootEntry[] = [
  146. { id: 'consumer', url: '/consumer.js', rev: '1' },
  147. { id: MODULES_ID, url: '/modules.js', rev: '1' },
  148. { id: 'renderer', url: '/renderer.js', rev: '1' },
  149. ]
  150. win.__DSH_BOOT__ = { rev: 'graph', entries }
  151. const registrations = new Map<string, ClientBundleRegistration>([
  152. ['/consumer.js', {
  153. id: 'consumer',
  154. factory: () => ({
  155. inject: ['modules'],
  156. apply: (ctx: Context) => {
  157. expect(ctx.modules).toBeDefined()
  158. events.push('consumer')
  159. },
  160. }),
  161. }],
  162. ['/renderer.js', {
  163. id: 'renderer',
  164. factory: () => ({
  165. apply: (ctx: Context) => {
  166. ctx.reflect.provide('uiRenderer', {
  167. mount: (element: HTMLElement) => {
  168. events.push('mount')
  169. element.textContent = 'mounted'
  170. return () => {}
  171. },
  172. })
  173. },
  174. }),
  175. }],
  176. ])
  177. const entry = new AppWebEntry(container, {
  178. loadBundle: async (url) => {
  179. const registration = registrations.get(url)
  180. if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
  181. target.load(registration)
  182. },
  183. })
  184. await entry.run()
  185. expect(target.mode).toBe('live')
  186. expect(events).toEqual(['consumer', 'mount'])
  187. expect(container.textContent).toBe('mounted')
  188. await entry.dispose()
  189. })
  190. })