boot.client.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  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__ = {
  76. rev: 'graph',
  77. entries: [duplicate, duplicate],
  78. batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }],
  79. }
  80. }, 'duplicate graph entry "duplicate"')
  81. })
  82. })
  83. describe('plugin activation', () => {
  84. it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => {
  85. const container = document.createElement('div')
  86. document.body.append(container)
  87. const target = installFacade()
  88. const entries: WebBootEntry[] = [
  89. { id: 'consumer', url: '/consumer.js', rev: '1' },
  90. {
  91. id: 'runtime',
  92. url: '/runtime.js',
  93. rev: '1',
  94. external: [PROVIDER_CLIENT_ID],
  95. immediately: true,
  96. },
  97. { id: 'provider', url: '/provider.js', rev: '1' },
  98. { id: 'renderer', url: '/renderer.js', rev: '1' },
  99. ]
  100. const applicationUrl = '/application.js'
  101. win.__DSH_BOOT__ = {
  102. rev: 'graph',
  103. entries,
  104. batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }],
  105. }
  106. const loaded: string[] = []
  107. const registrations: ClientBundleRegistration[] = [
  108. {
  109. id: 'consumer',
  110. factory: require => ({
  111. apply: () => {
  112. expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
  113. },
  114. }),
  115. },
  116. {
  117. id: 'provider',
  118. factory: () => ({ apply: () => {}, marker: 'provider' }),
  119. },
  120. {
  121. id: 'runtime',
  122. factory: require => ({
  123. apply: () => {},
  124. marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
  125. }),
  126. },
  127. {
  128. id: 'renderer',
  129. factory: () => ({
  130. apply: (ctx: Context) => {
  131. ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
  132. },
  133. }),
  134. },
  135. ]
  136. transportGlobal.__DSH_TRANSPORT__ = {
  137. loadBundle: async (url) => {
  138. loaded.push(url)
  139. if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
  140. for (const registration of registrations) target.load(registration)
  141. },
  142. }
  143. const entry = new AppWebEntry(container)
  144. await entry.run()
  145. expect(loaded).toEqual([applicationUrl])
  146. await entry.dispose()
  147. })
  148. it('allows a modules-dependent row to be created before the modules row', async () => {
  149. const events: string[] = []
  150. const container = document.createElement('div')
  151. document.body.append(container)
  152. const target = installFacade()
  153. const entries: WebBootEntry[] = [
  154. { id: 'consumer', url: '/consumer.js', rev: '1' },
  155. { id: MODULES_ID, url: '/modules.js', rev: '1' },
  156. { id: 'renderer', url: '/renderer.js', rev: '1' },
  157. ]
  158. win.__DSH_BOOT__ = {
  159. rev: 'graph',
  160. entries,
  161. batches: [{
  162. phase: 'application',
  163. url: '/application.js',
  164. rev: 'batch',
  165. entries: entries.map(row => row.id),
  166. }],
  167. }
  168. const registrations = new Map<string, ClientBundleRegistration>([
  169. ['/consumer.js', {
  170. id: 'consumer',
  171. factory: () => ({
  172. inject: ['modules'],
  173. apply: (ctx: Context) => {
  174. expect(ctx.modules).toBeDefined()
  175. events.push('consumer')
  176. },
  177. }),
  178. }],
  179. ['/renderer.js', {
  180. id: 'renderer',
  181. factory: () => ({
  182. apply: (ctx: Context) => {
  183. ctx.reflect.provide('uiRenderer', {
  184. mount: (element: HTMLElement) => {
  185. events.push('mount')
  186. element.textContent = 'mounted'
  187. return () => {}
  188. },
  189. })
  190. },
  191. }),
  192. }],
  193. ])
  194. const entry = new AppWebEntry(container, {
  195. loadBundle: async (url) => {
  196. if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`)
  197. for (const registration of registrations.values()) target.load(registration)
  198. },
  199. })
  200. await entry.run()
  201. expect(target.mode).toBe('live')
  202. expect(events).toEqual(['consumer', 'mount'])
  203. expect(container.textContent).toBe('mounted')
  204. await entry.dispose()
  205. })
  206. })