|
|
@@ -0,0 +1,133 @@
|
|
|
+// @vitest-environment jsdom
|
|
|
+import { Context } from '@deepseek-ai/cordis'
|
|
|
+import {
|
|
|
+ createClientModuleSystem, parseBootManifest,
|
|
|
+ type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
|
|
|
+} from '@deepseek-ai/dsh-client-modules/client'
|
|
|
+import { describe, expect, it } from 'vitest'
|
|
|
+import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
|
|
|
+import { FIBER_STATE } from '../src/loader-status.ts'
|
|
|
+
|
|
|
+const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules'
|
|
|
+
|
|
|
+function graphOf(ids: readonly string[]): WebBootGraph {
|
|
|
+ const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' }))
|
|
|
+ return {
|
|
|
+ rev: 'graph',
|
|
|
+ entries,
|
|
|
+ batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Module system seeded with inline plugin modules; `loaded` records every transport call. */
|
|
|
+function modulesOf(graph: WebBootGraph, staticModules: Record<string, unknown>): { modules: ClientModuleLoader; loaded: string[] } {
|
|
|
+ const loaded: string[] = []
|
|
|
+ const pendingQueue: ClientBundleRegistration[] = []
|
|
|
+ const target: ClientModuleLoaderTarget = {
|
|
|
+ mode: 'queue',
|
|
|
+ pendingQueue,
|
|
|
+ load: (registration) => { pendingQueue.push(registration) },
|
|
|
+ create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options),
|
|
|
+ }
|
|
|
+ const modules = target.create({
|
|
|
+ boot: graph,
|
|
|
+ staticModules,
|
|
|
+ loadBundle: async (url) => { loaded.push(url) },
|
|
|
+ })
|
|
|
+ return { modules, loaded }
|
|
|
+}
|
|
|
+
|
|
|
+/** Recording progress sink. */
|
|
|
+function stateSink(): { states: Map<string, EntryStateLabel[]>; onEntryState: (name: string, state: EntryStateLabel) => void } {
|
|
|
+ const states = new Map<string, EntryStateLabel[]>()
|
|
|
+ return {
|
|
|
+ states,
|
|
|
+ onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe('bootClient', () => {
|
|
|
+ it('activates every seeded row without touching the bundle transport', async () => {
|
|
|
+ const graph = graphOf(['provider', 'consumer'])
|
|
|
+ const { modules, loaded } = modulesOf(graph, {
|
|
|
+ provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } },
|
|
|
+ consumer: { inject: ['x'], apply: () => {} },
|
|
|
+ })
|
|
|
+ const ctx = new Context()
|
|
|
+ const sink = stateSink()
|
|
|
+
|
|
|
+ await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState })
|
|
|
+
|
|
|
+ expect(loaded).toEqual([])
|
|
|
+ const consumer = sink.states.get('consumer') ?? []
|
|
|
+ expect(consumer[0]).toBe('loading')
|
|
|
+ expect(consumer.at(-1)).toBe('active')
|
|
|
+ expect(sink.states.get('provider')?.at(-1)).toBe('active')
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports a row waiting on a service the roster never provides', async () => {
|
|
|
+ const graph = graphOf(['orphan'])
|
|
|
+ const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } })
|
|
|
+ const ctx = new Context()
|
|
|
+
|
|
|
+ await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow(
|
|
|
+ 'orphan: pending (waiting for service: nothing)',
|
|
|
+ )
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
|
|
|
+ const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
|
|
|
+ const manifest = parseBootManifest(graphOf(['ghost']))
|
|
|
+ const ctx = new Context()
|
|
|
+ const sink = stateSink()
|
|
|
+
|
|
|
+ await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
|
|
|
+ /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
|
|
|
+ )
|
|
|
+ expect(sink.states.get('ghost')).toEqual(['loading'])
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('assertEntriesActive', () => {
|
|
|
+ interface FakeEntry { name: string; fiber?: { state: number; inject: Record<string, null> } }
|
|
|
+
|
|
|
+ /** Loader-shaped double: entries with scripted fiber states, services by name. */
|
|
|
+ function auditCtx(entries: readonly FakeEntry[], services: Record<string, unknown> = {}): Context {
|
|
|
+ return {
|
|
|
+ loader: {
|
|
|
+ * entries() {
|
|
|
+ for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ get: (name: string) => services[name],
|
|
|
+ } as unknown as Context
|
|
|
+ }
|
|
|
+
|
|
|
+ it('passes when every entry is active', () => {
|
|
|
+ expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('names import failures, missing services, and other non-active states', () => {
|
|
|
+ const ctx = auditCtx([
|
|
|
+ { name: 'lost' },
|
|
|
+ { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } },
|
|
|
+ { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } },
|
|
|
+ { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
|
|
|
+ ], { present: {} })
|
|
|
+
|
|
|
+ expect(() => { assertEntriesActive(ctx) }).toThrow([
|
|
|
+ 'web boot: 4 entries did not activate',
|
|
|
+ 'lost: import failed (see console for the import error)',
|
|
|
+ 'waiting: pending (waiting for services: a, b)',
|
|
|
+ 'opaque: pending (waiting for services: unknown)',
|
|
|
+ 'broken: failed',
|
|
|
+ ].join('\n'))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('uses the singular form for one failing entry', () => {
|
|
|
+ expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
|
|
|
+ })
|
|
|
+})
|