فهرست منبع

ci: coverage/knip

imccyu 2 ماه پیش
والد
کامیت
a7699cdeb1

+ 1 - 2
docs/module-graph.md

@@ -234,7 +234,6 @@ flowchart TD
   pkg_llm --> pkg_invariants
   pkg_client_hmr --> pkg_client_modules
   pkg_client_hmr --> pkg_invariants
-  pkg_client_ui_conversation --> pkg_client_i18n
   pkg_client_ui_conversation --> pkg_client_runtime
   pkg_client_ui_conversation --> pkg_client_ui_primitives
   pkg_client_ui_conversation --> pkg_client_ui_slots
@@ -806,7 +805,7 @@ flowchart TD
 | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
 | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-i18n`](../packages/client/i18n), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |

+ 10 - 2
knip.json

@@ -583,13 +583,21 @@
       ]
     },
     "packages/client/modules": {
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
       "project": [
-        "src/**/*.ts"
+        "src/**/*.ts",
+        "tests/**/*.ts"
       ]
     },
     "packages/client/hmr": {
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
       "project": [
-        "src/**/*.ts"
+        "src/**/*.ts",
+        "tests/**/*.ts"
       ]
     }
   }

+ 13 - 0
packages/client/hmr/tests/node-half.spec.ts

@@ -0,0 +1,13 @@
+/**
+ * Node half of the HMR plugin: an empty apply placeholder (the reload driver
+ * lives in the client half) whose only contract is mounting and disposing
+ * cleanly in the host Loader.
+ */
+import { describe, expect, it } from 'vitest'
+import { apply } from '@deepseek-ai/dsh-client-hmr'
+
+describe('hmr node half', () => {
+  it('apply is a no-op host placeholder', () => {
+    expect(apply()).toBeUndefined()
+  })
+})

+ 305 - 0
packages/client/modules/tests/loader.spec.ts

@@ -0,0 +1,305 @@
+// @vitest-environment jsdom
+/**
+ * ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
+ * registers the factory), materialization on first import/require with
+ * memoization and recursive self-sequencing, the resolution branch order,
+ * shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
+ * default transport seams, and the loud failure modes (duplicate
+ * registration, cycles, table misses, double boot).
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  ClientModuleLoaderImpl, createClientModuleLoader,
+  type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
+} from '../src/index.ts'
+
+const win = globalThis as DshWindow
+
+type Factory = ClientPluginHandoff['factory']
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  delete win.__ModuleLoader__
+  delete (document as unknown as Record<string, unknown>).__realmBridge
+  for (const el of document.querySelectorAll('style, script')) el.remove()
+})
+
+const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
+
+interface Bench {
+  loader: ClientModuleLoader
+  fetched: string[]
+  gates: Map<string, () => void>
+}
+
+/**
+ * Loader over scripted bundles: fetch resolves to the row url (optionally
+ * gated on a release callback); execute registers the scripted factory
+ * through the window sink (`null` scripts a bundle that never calls load).
+ */
+function bench(
+  entries: WebBootEntry[],
+  bundles: Record<string, Factory | null> = {},
+  opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
+): Bench {
+  const fetched: string[] = []
+  const gates = new Map<string, () => void>()
+  const loader = createClientModuleLoader({
+    graph: { rev: 'test', entries },
+    staticModules: opts.seed ?? {},
+    fetchBundle: (url) => {
+      fetched.push(url)
+      if (opts.gated?.includes(url) === true) {
+        return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
+      }
+      return Promise.resolve(url)
+    },
+    executeBundle: (code) => {
+      const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
+      const factory = id === undefined ? undefined : bundles[id]
+      if (factory == null || id === undefined) return
+      win.__ModuleLoader__?.load({ id, factory })
+    },
+  })
+  return { loader, fetched, gates }
+}
+
+describe('lazy CJS arrival', () => {
+  it('prefetch fetches and executes but does not run the factory', async () => {
+    const ran: string[] = []
+    const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
+    await b.loader.prefetch('a')
+    expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
+    expect(ran).toEqual([])
+    expect(b.loader.loadCache.size).toBe(0)
+  })
+
+  it('import materializes once and memoizes the export surface', async () => {
+    const ran: string[] = []
+    const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
+    const first = await b.loader.import('a', '', {})
+    const second = await b.loader.import('a', '', {})
+    expect(first).toBe(second)
+    expect((first as { marker: string }).marker).toBe('a')
+    expect(ran).toEqual(['a'])
+    expect(b.loader.loadCache.get('a')?.id).toBe('a')
+  })
+
+  it('import without prefetch fetches, executes, and materializes in one call', async () => {
+    const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
+    const surface = await b.loader.import('a', '', {})
+    expect((surface as { marker: string }).marker).toBe('direct')
+    expect(b.fetched).toHaveLength(1)
+  })
+
+  it('concurrent callers share one in-flight arrival and materialize once', async () => {
+    const ran: string[] = []
+    const url = '/plugins/a/client.js?rev=0'
+    const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } }, { gated: [url] })
+    const first = b.loader.import('a', '', {})
+    const second = b.loader.import('a', '', {})
+    const third = b.loader.prefetch('a')
+    b.gates.get(url)?.()
+    const [s1, s2] = await Promise.all([first, second, third])
+    expect(s1).toBe(s2)
+    expect(b.fetched).toEqual([url])
+    expect(ran).toEqual(['a'])
+  })
+
+  it('prefetch after registration is a no-op without invalidate', async () => {
+    const b = bench([row('a')], { a: () => ({}) })
+    await b.loader.prefetch('a')
+    await b.loader.prefetch('a')
+    expect(b.fetched).toHaveLength(1)
+  })
+})
+
+describe('require resolution', () => {
+  it('a factory requiring a registered-but-unmaterialized module materializes it recursively', async () => {
+    const order: string[] = []
+    const b = bench([row('a'), row('b')], {
+      a: (req) => {
+        order.push('a')
+        const dep = req('b/client') as { helper: string }
+        return { got: dep.helper }
+      },
+      b: () => { order.push('b'); return { helper: 'from-b' } },
+    })
+    await b.loader.prefetch('a')
+    await b.loader.prefetch('b')
+    const surface = await b.loader.import('a', '', {})
+    expect((surface as { got: string }).got).toBe('from-b')
+    expect(order).toEqual(['a', 'b'])
+    expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
+    expect(b.loader.loadCache.has('b')).toBe(true)
+  })
+
+  it('require prefers the platform seed word over the module table', async () => {
+    const react = { marker: 'react' }
+    const b = bench([row('a')], {
+      a: (req) => ({ dep: req('react') }),
+    }, { seed: { react } })
+    const surface = await b.loader.import('a', '', {})
+    expect((surface as { dep: unknown }).dep).toBe(react)
+    expect(await b.loader.import('react', '', {})).toBe(react)
+    expect(b.loader.loadCache.has('react')).toBe(false)
+  })
+
+  it('require answers an already-materialized module from the cache', async () => {
+    let built = 0
+    const b = bench([row('a'), row('c')], {
+      a: (req) => ({ dep: req('c') }),
+      c: () => { built += 1; return { marker: 'c' } },
+    })
+    const c = await b.loader.import('c', '', {})
+    const a = await b.loader.import('a', '', {})
+    expect((a as { dep: unknown }).dep).toBe(c)
+    expect(built).toBe(1)
+  })
+
+  it('a require that misses the module table is loud', async () => {
+    const b = bench([row('a')], { a: (req) => ({ dep: req('ghost') }) })
+    await expect(b.loader.import('a', '', {})).rejects.toThrow('require("ghost") missed the module table')
+  })
+
+  it('a require cycle is fatal', async () => {
+    const b = bench([row('a'), row('b')], {
+      a: (req) => ({ dep: req('b') }),
+      b: (req) => ({ dep: req('a') }),
+    })
+    await b.loader.prefetch('b')
+    await expect(b.loader.import('a', '', {})).rejects.toThrow('require cycle through "a"')
+  })
+})
+
+describe('static registry', () => {
+  it('serves shell-own modules to import and require without any fetch', async () => {
+    const shell = { marker: 'app-shell' }
+    const b = bench([row('a'), { id: 'app-shell' }], {
+      a: (req) => ({ dep: req('app-shell') }),
+    })
+    b.loader.registerStatic('app-shell', shell)
+    await b.loader.prefetch('app-shell')
+    expect(await b.loader.import('app-shell', '', {})).toBe(shell)
+    expect(b.loader.loadCache.get('app-shell')?.styles).toEqual([])
+    expect((await b.loader.import('a', '', {}) as { dep: unknown }).dep).toBe(shell)
+    expect(b.fetched).toEqual(['/plugins/a/client.js?rev=0'])
+  })
+
+  it('duplicate static registration is loud', () => {
+    const b = bench([])
+    b.loader.registerStatic('app-shell', {})
+    expect(() => { b.loader.registerStatic('app-shell', {}) }).toThrow('registered twice')
+  })
+})
+
+describe('failure modes', () => {
+  it('duplicate factory registration is loud', () => {
+    bench([])
+    win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) })
+    expect(() => win.__ModuleLoader__?.load({ id: 'x', factory: () => ({}) }))
+      .toThrow('duplicate factory registration for "x"')
+  })
+
+  it('a bundle that never registers its id is loud', async () => {
+    const b = bench([row('a')], { a: null })
+    await expect(b.loader.import('a', '', {})).rejects.toThrow('without registering "a"')
+  })
+
+  it('an unknown import specifier is loud', async () => {
+    const b = bench([])
+    await expect(b.loader.import('nope', '', {})).rejects.toThrow('cannot resolve "nope"')
+  })
+
+  it('an unknown prefetch id is loud', async () => {
+    const b = bench([])
+    await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
+  })
+
+  it('a graph row with no url and no static registration is loud', async () => {
+    const b = bench([{ id: 'ghost' }])
+    await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
+  })
+
+  it('a duplicate graph entry is loud at construction', () => {
+    expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
+  })
+
+  it('double boot is loud', () => {
+    bench([])
+    expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
+      .toThrow('already installed (double boot?)')
+  })
+})
+
+describe('HMR reset', () => {
+  it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
+    let generation = 0
+    const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
+    const first = await b.loader.import('a', '', {})
+    b.loader.invalidate('a')
+    expect(b.loader.loadCache.has('a')).toBe(false)
+    await b.loader.prefetch('a')
+    const second = await b.loader.import('a', '', {})
+    expect(b.fetched).toHaveLength(2)
+    expect((first as { generation: number }).generation).toBe(1)
+    expect((second as { generation: number }).generation).toBe(2)
+  })
+})
+
+describe('style claiming', () => {
+  it('claims untagged style tags for the materializing plugin and inventories owned css ids', async () => {
+    const foreign = document.createElement('style')
+    foreign.setAttribute('data-plugin', 'other')
+    document.head.appendChild(foreign)
+    const b = bench([row('a')], {
+      a: () => {
+        document.head.appendChild(document.createElement('style'))
+        const tagged = document.createElement('style')
+        tagged.setAttribute('data-plugin', 'a')
+        tagged.setAttribute('data-plugin-css', 'sheet-1')
+        document.head.appendChild(tagged)
+        return {}
+      },
+    })
+    await b.loader.import('a', '', {})
+    expect(b.loader.loadCache.get('a')?.styles).toEqual(['a', 'sheet-1'])
+    expect(document.querySelectorAll('style[data-plugin="a"]')).toHaveLength(2)
+    expect(foreign.getAttribute('data-plugin')).toBe('other')
+  })
+
+  it('materialization without a document skips the style inventory', async () => {
+    const b = bench([row('a')], { a: () => ({}) })
+    vi.stubGlobal('document', undefined)
+    try {
+      await b.loader.import('a', '', {})
+    } finally {
+      vi.unstubAllGlobals()
+    }
+    expect(b.loader.loadCache.get('a')?.styles).toEqual([])
+  })
+})
+
+describe('default transport seams', () => {
+  it('fetches same-origin and executes through an inline script tag', async () => {
+    // In a browser the loader's globalThis IS the page window; vitest's jsdom
+    // evaluates <script> in a separate realm that shares only the document,
+    // so the fixture bundle restores the sink from a document bridge before
+    // using the normal calling convention.
+    const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+      + 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
+    vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
+    const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
+    ;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
+    const surface = await loader.import('dee', '', {})
+    expect((surface as { marker: string }).marker).toBe('via-script')
+    const script = [...document.querySelectorAll('script')].at(-1)
+    expect(script?.textContent).toContain('//# sourceURL=/plugins/dee/client.js?rev=0')
+  })
+
+  it('a non-ok bundle response is loud with the status', async () => {
+    vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
+    const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
+    await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
+  })
+})

+ 0 - 2
packages/client/ui-conversation/package.json

@@ -39,7 +39,6 @@
     "clsx": "^2.0.0"
   },
   "peerDependencies": {
-    "@deepseek-ai/dsh-client-i18n": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -48,7 +47,6 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-i18n": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

+ 0 - 3
pnpm-lock.yaml

@@ -619,9 +619,6 @@ importers:
         specifier: ^2.0.0
         version: 2.1.1
     devDependencies:
-      '@deepseek-ai/dsh-client-i18n':
-        specifier: workspace:^
-        version: link:../i18n
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime

+ 4 - 0
vitest.config.ts

@@ -104,6 +104,10 @@ export default defineConfig({
         'packages/client/ui-layout/src/*',
         'packages/client/web/src/*',
         'packages/host/webserver/src/*',
+        'packages/client/modules/src/loader.ts',
+        'packages/client/modules/src/index.ts',
+        'packages/client/hmr/src/index.ts',
+        'packages/client/hmr/src/client/index.ts',
         ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
         ...windowsCoverageExclusions,
       ],