client-loader.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * ClientLoader: handoff protocol (single slot, id reconciliation), DI require
  3. * with export-surface re-registration, immediately-group barrier (parallel
  4. * fetch / topology execution / full-group barrier), status store, settled,
  5. * failure modes (missing handoff, unknown dep, cycle, unload stub).
  6. */
  7. import { Context } from 'cordis'
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import { createClientLoader } from '../src/client/loader/index.ts'
  10. import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
  11. type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
  12. const win = globalThis as Win
  13. afterEach(() => {
  14. delete win.DSHClientProxy
  15. delete win.__DSH_BOOT__
  16. })
  17. interface FakeBundle {
  18. handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
  19. }
  20. interface Bench {
  21. loader: ReturnType<typeof createClientLoader>
  22. fetched: string[]
  23. executed: string[]
  24. fetchGate: Map<string, () => void>
  25. }
  26. /** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
  27. function bench(
  28. plugins: BootPluginEntry[],
  29. bundles: Record<string, FakeBundle>,
  30. opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
  31. ): Bench {
  32. const ctx = new Context()
  33. const fetched: string[] = []
  34. const executed: string[] = []
  35. const fetchGate = new Map<string, () => void>()
  36. const loader = createClientLoader({
  37. ctx,
  38. modules: opts.modules ?? { react: { marker: 'react' } },
  39. boot: { plugins },
  40. fetchBundle: (url) => {
  41. fetched.push(url)
  42. if (opts.gated?.includes(url) === true) {
  43. return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
  44. }
  45. return Promise.resolve(url)
  46. },
  47. executeBundle: (code) => {
  48. executed.push(code)
  49. const bundle = bundles[code]
  50. if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
  51. if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
  52. if (typeof bundle.handoff === 'function') {
  53. win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
  54. return
  55. }
  56. win.DSHClientProxy?.loadPlugin(bundle.handoff)
  57. },
  58. })
  59. return { loader, fetched, executed, fetchGate }
  60. }
  61. const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
  62. ({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
  63. const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
  64. handoff: require => ({
  65. apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
  66. require,
  67. ...exports,
  68. }),
  69. })
  70. describe('load chain', () => {
  71. it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
  72. const applied: string[] = []
  73. const b = bench(
  74. [entry('fake-base', [], true), entry('feature', ['fake-base'])],
  75. {
  76. '/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
  77. '/plugins/feature/client.js': {
  78. handoff: (require) => {
  79. // Later loader requires the earlier one's export surface (inject topology guarantee).
  80. const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
  81. const base = require(fakeBase) as { helper: string }
  82. expect(base.helper).toBe('base-helper')
  83. expect((require('react') as { marker: string }).marker).toBe('react')
  84. return { apply: () => { applied.push('feature') } }
  85. },
  86. },
  87. },
  88. )
  89. b.loader.start()
  90. await b.loader.settled()
  91. expect(applied).toEqual(['fake-base', 'feature'])
  92. expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
  93. expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
  94. expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
  95. })
  96. it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
  97. const b = bench(
  98. [entry('a', [], true), entry('b', ['a'], true), entry('later')],
  99. {
  100. '/plugins/a/client.js': okBundle(),
  101. '/plugins/b/client.js': okBundle(),
  102. '/plugins/later/client.js': okBundle(),
  103. },
  104. { gated: ['/plugins/a/client.js'] },
  105. )
  106. b.loader.start()
  107. await Promise.resolve()
  108. // Both early fetches are in flight before any execution; the late plugin is not fetched yet.
  109. expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
  110. expect(b.executed).toEqual([])
  111. b.fetchGate.get('/plugins/a/client.js')?.()
  112. await b.loader.settled()
  113. expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
  114. })
  115. it('orders execution by inject topology within each group', async () => {
  116. const b = bench(
  117. [entry('z-ui', ['a-base']), entry('a-base')],
  118. { '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
  119. )
  120. b.loader.start()
  121. await b.loader.settled()
  122. expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
  123. })
  124. })
  125. describe('failure modes (fail loud)', () => {
  126. it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
  127. const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
  128. b.loader.start()
  129. await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
  130. expect(b.loader.status.getSnapshot().silent).toBe('failed')
  131. })
  132. it('rejects on manifest/handoff id mismatch', async () => {
  133. const b = bench([entry('expected')], {
  134. '/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
  135. })
  136. b.loader.start()
  137. await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
  138. })
  139. it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
  140. // Sequential benches: each loader owns the window proxy, so release it between them.
  141. const fresh = <T>(build: () => T): T => {
  142. delete win.DSHClientProxy
  143. return build()
  144. }
  145. const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
  146. missing.loader.start()
  147. await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
  148. const cyclic = fresh(() => bench(
  149. [entry('p', ['q']), entry('q', ['p'])],
  150. { '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
  151. ))
  152. cyclic.loader.start()
  153. await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
  154. const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
  155. applyless.loader.start()
  156. await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
  157. const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
  158. await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
  159. expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
  160. })
  161. it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
  162. expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
  163. const b = bench([], {})
  164. expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
  165. // First bench installed the proxy; a second loader must refuse.
  166. expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
  167. })
  168. it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
  169. const b = bench(
  170. [entry('dep', [], true), entry('needy', ['dep'])],
  171. { '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
  172. )
  173. await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
  174. })
  175. it('direct load() naming an unknown inject target fails loud', async () => {
  176. const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
  177. await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
  178. })
  179. it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
  180. // The fire-and-forget prefetch swallow arm must absorb the early
  181. // rejection; the awaited load surfaces the same failure via settled().
  182. const ctx = new Context()
  183. delete win.DSHClientProxy
  184. const loader = createClientLoader({
  185. ctx,
  186. modules: {},
  187. boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
  188. fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
  189. executeBundle: () => {},
  190. })
  191. loader.start()
  192. await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
  193. })
  194. it('unload is the P-I stub', async () => {
  195. const b = bench([], {})
  196. await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
  197. })
  198. })
  199. describe('DOM default seams (stubbed globals)', () => {
  200. it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
  201. const origFetch = globalThis.fetch
  202. const appended: { textContent?: string | null }[] = []
  203. const styleTag = {
  204. attrs: {} as Record<string, string>,
  205. setAttribute(k: string, v: string) { this.attrs[k] = v },
  206. }
  207. const fakeDoc = {
  208. createElement: () => {
  209. const el = { textContent: null as string | null }
  210. return el
  211. },
  212. head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
  213. querySelectorAll: () => [styleTag],
  214. }
  215. const g = globalThis as { document?: unknown; fetch: typeof fetch }
  216. g.document = fakeDoc
  217. g.fetch = (url: URL | RequestInfo) => Promise.resolve(
  218. (typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
  219. ? new Response('x', { status: 500 })
  220. : new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
  221. )
  222. try {
  223. delete win.DSHClientProxy
  224. const ctx = new Context()
  225. const loader = createClientLoader({
  226. ctx,
  227. modules: {},
  228. boot: { plugins: [
  229. { id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
  230. { id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
  231. ] },
  232. // NO seams injected (keys omitted, not undefined — exactOptional):
  233. // the DOM defaults are under test.
  234. })
  235. const seamHandoff: ClientPluginHandoff = {
  236. id: 'seam-ok',
  237. factory: () => ({ apply: () => {} }),
  238. }
  239. // Default executeBundle only APPENDS the script element (no execution in
  240. // our fake DOM), so drive the handoff manually before load resolves it.
  241. const loadOk = loader.load('seam-ok')
  242. await Promise.resolve()
  243. ;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
  244. await loadOk
  245. expect(appended).toHaveLength(1)
  246. expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
  247. expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
  248. await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
  249. } finally {
  250. g.fetch = origFetch
  251. delete (globalThis as { document?: unknown }).document
  252. }
  253. })
  254. })
  255. describe('handoff slot protocol', () => {
  256. it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
  257. delete win.DSHClientProxy
  258. createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
  259. const proxy = (globalThis as Win).DSHClientProxy
  260. proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
  261. expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
  262. .toThrow(/overlapping loadPlugin handoff/)
  263. })
  264. })