| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- /**
- * ClientLoader: handoff protocol (single slot, id reconciliation), DI require
- * with export-surface re-registration, immediately-group barrier (parallel
- * fetch / topology execution / full-group barrier), status store, settled,
- * failure modes (missing handoff, unknown dep, cycle, unload stub).
- */
- import { Context } from 'cordis'
- import { afterEach, describe, expect, it } from 'vitest'
- import { createClientLoader } from '../src/client/loader/index.ts'
- import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
- type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
- const win = globalThis as Win
- afterEach(() => {
- delete win.DSHClientProxy
- delete win.__DSH_BOOT__
- })
- interface FakeBundle {
- handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
- }
- interface Bench {
- loader: ReturnType<typeof createClientLoader>
- fetched: string[]
- executed: string[]
- fetchGate: Map<string, () => void>
- }
- /** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
- function bench(
- plugins: BootPluginEntry[],
- bundles: Record<string, FakeBundle>,
- opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
- ): Bench {
- const ctx = new Context()
- const fetched: string[] = []
- const executed: string[] = []
- const fetchGate = new Map<string, () => void>()
- const loader = createClientLoader({
- ctx,
- modules: opts.modules ?? { react: { marker: 'react' } },
- boot: { plugins },
- fetchBundle: (url) => {
- fetched.push(url)
- if (opts.gated?.includes(url) === true) {
- return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
- }
- return Promise.resolve(url)
- },
- executeBundle: (code) => {
- executed.push(code)
- const bundle = bundles[code]
- if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
- if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
- if (typeof bundle.handoff === 'function') {
- win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
- return
- }
- win.DSHClientProxy?.loadPlugin(bundle.handoff)
- },
- })
- return { loader, fetched, executed, fetchGate }
- }
- const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
- ({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
- const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
- handoff: require => ({
- apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
- require,
- ...exports,
- }),
- })
- describe('load chain', () => {
- it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
- const applied: string[] = []
- const b = bench(
- [entry('fake-base', [], true), entry('feature', ['fake-base'])],
- {
- '/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
- '/plugins/feature/client.js': {
- handoff: (require) => {
- // Later loader requires the earlier one's export surface (inject topology guarantee).
- const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
- const base = require(fakeBase) as { helper: string }
- expect(base.helper).toBe('base-helper')
- expect((require('react') as { marker: string }).marker).toBe('react')
- return { apply: () => { applied.push('feature') } }
- },
- },
- },
- )
- b.loader.start()
- await b.loader.settled()
- expect(applied).toEqual(['fake-base', 'feature'])
- expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
- expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
- expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
- })
- it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
- const b = bench(
- [entry('a', [], true), entry('b', ['a'], true), entry('later')],
- {
- '/plugins/a/client.js': okBundle(),
- '/plugins/b/client.js': okBundle(),
- '/plugins/later/client.js': okBundle(),
- },
- { gated: ['/plugins/a/client.js'] },
- )
- b.loader.start()
- await Promise.resolve()
- // Both early fetches are in flight before any execution; the late plugin is not fetched yet.
- expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
- expect(b.executed).toEqual([])
- b.fetchGate.get('/plugins/a/client.js')?.()
- await b.loader.settled()
- expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
- })
- it('orders execution by inject topology within each group', async () => {
- const b = bench(
- [entry('z-ui', ['a-base']), entry('a-base')],
- { '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
- )
- b.loader.start()
- await b.loader.settled()
- expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
- })
- })
- describe('failure modes (fail loud)', () => {
- it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
- const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
- b.loader.start()
- await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
- expect(b.loader.status.getSnapshot().silent).toBe('failed')
- })
- it('rejects on manifest/handoff id mismatch', async () => {
- const b = bench([entry('expected')], {
- '/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
- })
- b.loader.start()
- await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
- })
- it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
- // Sequential benches: each loader owns the window proxy, so release it between them.
- const fresh = <T>(build: () => T): T => {
- delete win.DSHClientProxy
- return build()
- }
- const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
- missing.loader.start()
- await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
- const cyclic = fresh(() => bench(
- [entry('p', ['q']), entry('q', ['p'])],
- { '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
- ))
- cyclic.loader.start()
- await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
- const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
- applyless.loader.start()
- await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
- const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
- await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
- expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
- })
- it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
- expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
- const b = bench([], {})
- expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
- // First bench installed the proxy; a second loader must refuse.
- expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
- })
- it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
- const b = bench(
- [entry('dep', [], true), entry('needy', ['dep'])],
- { '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
- )
- await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
- })
- it('direct load() naming an unknown inject target fails loud', async () => {
- const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
- await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
- })
- it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
- // The fire-and-forget prefetch swallow arm must absorb the early
- // rejection; the awaited load surfaces the same failure via settled().
- const ctx = new Context()
- delete win.DSHClientProxy
- const loader = createClientLoader({
- ctx,
- modules: {},
- boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
- fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
- executeBundle: () => {},
- })
- loader.start()
- await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
- })
- it('unload is the P-I stub', async () => {
- const b = bench([], {})
- await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
- })
- })
- describe('DOM default seams (stubbed globals)', () => {
- it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
- const origFetch = globalThis.fetch
- const appended: { textContent?: string | null }[] = []
- const styleTag = {
- attrs: {} as Record<string, string>,
- setAttribute(k: string, v: string) { this.attrs[k] = v },
- }
- const fakeDoc = {
- createElement: () => {
- const el = { textContent: null as string | null }
- return el
- },
- head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
- querySelectorAll: () => [styleTag],
- }
- const g = globalThis as { document?: unknown; fetch: typeof fetch }
- g.document = fakeDoc
- g.fetch = (url: URL | RequestInfo) => Promise.resolve(
- (typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
- ? new Response('x', { status: 500 })
- : new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
- )
- try {
- delete win.DSHClientProxy
- const ctx = new Context()
- const loader = createClientLoader({
- ctx,
- modules: {},
- boot: { plugins: [
- { id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
- { id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
- ] },
- // NO seams injected (keys omitted, not undefined — exactOptional):
- // the DOM defaults are under test.
- })
- const seamHandoff: ClientPluginHandoff = {
- id: 'seam-ok',
- factory: () => ({ apply: () => {} }),
- }
- // Default executeBundle only APPENDS the script element (no execution in
- // our fake DOM), so drive the handoff manually before load resolves it.
- const loadOk = loader.load('seam-ok')
- await Promise.resolve()
- ;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
- await loadOk
- expect(appended).toHaveLength(1)
- expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
- expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
- await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
- } finally {
- g.fetch = origFetch
- delete (globalThis as { document?: unknown }).document
- }
- })
- })
- describe('handoff slot protocol', () => {
- it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
- delete win.DSHClientProxy
- createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
- const proxy = (globalThis as Win).DSHClientProxy
- proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
- expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
- .toThrow(/overlapping loadPlugin handoff/)
- })
- })
|