boot.client.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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('leaves the loading page visible when a carrier owns fatal presentation', async () => {
  55. vi.spyOn(console, 'error').mockImplementation(() => {})
  56. const container = document.createElement('div')
  57. document.body.append(container)
  58. const entry = new AppWebEntry(container)
  59. const failure = vi.fn()
  60. await entry.run(failure)
  61. expect(failure).toHaveBeenCalledWith(new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing'))
  62. expect(container.textContent).toContain('Loading plugins')
  63. expect(container.textContent).not.toContain('Failed to load plugins')
  64. expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
  65. await entry.dispose()
  66. })
  67. it.each([false, true])('routes failed plugin activation with carrier presentation=%s', async (carrier) => {
  68. vi.spyOn(console, 'error').mockImplementation(() => {})
  69. const container = document.createElement('div')
  70. document.body.append(container)
  71. const target = installFacade()
  72. win.__DSH_BOOT__ = {
  73. rev: 'graph',
  74. entries: [{ id: 'broken', url: '/broken.js', rev: '1' }],
  75. batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: ['broken'] }],
  76. }
  77. const entry = new AppWebEntry(container, {
  78. loadBundle: async () => {
  79. target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) })
  80. },
  81. })
  82. const report = vi.fn<(reason: unknown) => void>()
  83. try {
  84. await entry.run(carrier ? report : undefined)
  85. if (carrier) {
  86. expect(report).toHaveBeenCalledOnce()
  87. expect(String(report.mock.calls[0]![0])).toContain('broken')
  88. expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
  89. expect(container.textContent).not.toContain('Failed to load plugins')
  90. } else {
  91. expect(report).not.toHaveBeenCalled()
  92. expect(container.textContent).toContain('Failed to load plugins')
  93. expect(container.textContent).toContain('broken')
  94. }
  95. } finally {
  96. await entry.dispose()
  97. }
  98. })
  99. it('renders a missing bootstrap facade', async () => {
  100. await expectBootFailure(
  101. () => { delete win.__ModuleLoader__ },
  102. 'window.__ModuleLoader__ bootstrap facade is missing',
  103. )
  104. })
  105. it('renders a create failure owned by the facade', async () => {
  106. await expectBootFailure(() => {
  107. installFacade(() => { throw new Error('facade create failed') })
  108. }, 'facade create failed')
  109. })
  110. it('renders a malformed boot manifest', async () => {
  111. await expectBootFailure(() => {
  112. installFacade()
  113. delete win.__DSH_BOOT__
  114. }, 'window.__DSH_BOOT__ is missing or not an object')
  115. })
  116. it('renders a module-system construction failure', async () => {
  117. await expectBootFailure(() => {
  118. installFacade()
  119. const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' }
  120. win.__DSH_BOOT__ = {
  121. rev: 'graph',
  122. entries: [duplicate, duplicate],
  123. batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }],
  124. }
  125. }, 'duplicate graph entry "duplicate"')
  126. })
  127. })
  128. describe('plugin activation', () => {
  129. it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => {
  130. const container = document.createElement('div')
  131. document.body.append(container)
  132. const target = installFacade()
  133. const entries: WebBootEntry[] = [
  134. { id: 'consumer', url: '/consumer.js', rev: '1' },
  135. {
  136. id: 'runtime',
  137. url: '/runtime.js',
  138. rev: '1',
  139. external: [PROVIDER_CLIENT_ID],
  140. immediately: true,
  141. },
  142. { id: 'provider', url: '/provider.js', rev: '1' },
  143. { id: 'renderer', url: '/renderer.js', rev: '1' },
  144. ]
  145. const applicationUrl = '/application.js'
  146. win.__DSH_BOOT__ = {
  147. rev: 'graph',
  148. entries,
  149. batches: [{ phase: 'application', url: applicationUrl, rev: 'batch', entries: entries.map(row => row.id) }],
  150. }
  151. const loaded: string[] = []
  152. const registrations: ClientBundleRegistration[] = [
  153. {
  154. id: 'consumer',
  155. factory: require => ({
  156. apply: () => {
  157. expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
  158. },
  159. }),
  160. },
  161. {
  162. id: 'provider',
  163. factory: () => ({ apply: () => {}, marker: 'provider' }),
  164. },
  165. {
  166. id: 'runtime',
  167. factory: require => ({
  168. apply: () => {},
  169. marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
  170. }),
  171. },
  172. {
  173. id: 'renderer',
  174. factory: () => ({
  175. apply: (ctx: Context) => {
  176. ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
  177. },
  178. }),
  179. },
  180. ]
  181. transportGlobal.__DSH_TRANSPORT__ = {
  182. loadBundle: async (url) => {
  183. loaded.push(url)
  184. if (url !== applicationUrl) throw new Error(`missing fixture batch ${url}`)
  185. for (const registration of registrations) target.load(registration)
  186. },
  187. }
  188. const entry = new AppWebEntry(container)
  189. await entry.run()
  190. expect(loaded).toEqual([applicationUrl])
  191. await entry.dispose()
  192. })
  193. it('allows a modules-dependent row to be created before the modules row', async () => {
  194. const events: string[] = []
  195. const container = document.createElement('div')
  196. document.body.append(container)
  197. const target = installFacade()
  198. const entries: WebBootEntry[] = [
  199. { id: 'consumer', url: '/consumer.js', rev: '1' },
  200. { id: MODULES_ID, url: '/modules.js', rev: '1' },
  201. { id: 'renderer', url: '/renderer.js', rev: '1' },
  202. ]
  203. win.__DSH_BOOT__ = {
  204. rev: 'graph',
  205. entries,
  206. batches: [{
  207. phase: 'application',
  208. url: '/application.js',
  209. rev: 'batch',
  210. entries: entries.map(row => row.id),
  211. }],
  212. }
  213. const registrations = new Map<string, ClientBundleRegistration>([
  214. ['/consumer.js', {
  215. id: 'consumer',
  216. factory: () => ({
  217. inject: ['modules'],
  218. apply: (ctx: Context) => {
  219. expect(ctx.modules).toBeDefined()
  220. events.push('consumer')
  221. },
  222. }),
  223. }],
  224. ['/renderer.js', {
  225. id: 'renderer',
  226. factory: () => ({
  227. apply: (ctx: Context) => {
  228. ctx.reflect.provide('uiRenderer', {
  229. mount: (element: HTMLElement) => {
  230. events.push('mount')
  231. element.textContent = 'mounted'
  232. return () => {}
  233. },
  234. })
  235. },
  236. }),
  237. }],
  238. ])
  239. const entry = new AppWebEntry(container, {
  240. loadBundle: async (url) => {
  241. if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`)
  242. for (const registration of registrations.values()) target.load(registration)
  243. },
  244. })
  245. await entry.run()
  246. expect(target.mode).toBe('live')
  247. expect(events).toEqual(['consumer', 'mount'])
  248. expect(container.textContent).toBe('mounted')
  249. await entry.dispose()
  250. })
  251. })
  252. it('draws the shared boot page before Host injections and resumes without replacing the document', async () => {
  253. const gate = Promise.withResolvers<undefined>()
  254. vi.stubGlobal('__DSH_BOOT_READY__', gate)
  255. const container = document.createElement('div')
  256. document.body.append(container)
  257. const create = vi.fn(() => { throw new Error('injections consumed') })
  258. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  259. const entry = new AppWebEntry(container)
  260. const boot = entry.run()
  261. try {
  262. const page = container.querySelector('[data-dsh-boot]')
  263. const spinner = container.querySelector('[data-dsh-boot-spinner]')
  264. expect(spinner).not.toBeNull()
  265. await Promise.resolve()
  266. expect(create).not.toHaveBeenCalled()
  267. expect(error).not.toHaveBeenCalled()
  268. installFacade(create)
  269. gate.resolve(undefined)
  270. await boot
  271. expect(create).toHaveBeenCalledOnce()
  272. expect(container.querySelector('[data-dsh-boot]')).toBe(page)
  273. expect(container.textContent).toContain('injections consumed')
  274. } finally {
  275. gate.resolve(undefined)
  276. await boot
  277. await entry.dispose()
  278. vi.unstubAllGlobals()
  279. }
  280. })