Procházet zdrojové kódy

test(web): derive assembled graph from bundle config

imccyu před 1 měsícem
rodič
revize
d419b722bb

+ 107 - 36
apps/web/tests/assembled-boot.ts

@@ -8,52 +8,117 @@
 // Keyless and deterministic: the fixture is the fake server, so nothing here
 // reaches a model or the network.
 import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
 import { act, cleanup } from '@testing-library/react'
 import { afterEach, beforeEach, vi } from 'vitest'
-import { injectBootManifest } from '@deepseek-ai/dsh-client-modules'
+import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
 import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
 import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
-/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
-const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
-  { id: '@deepseek-ai/dsh-client-modules', bundlePath: 'packages/client/modules/lib/client.js', url: '/plugins/modules.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
-  // The settings domain base: the only provider of ctx.settingsScope, which the
-  // locale and ui-theme rows below inject for their preference rows. Without it
-  // both stay pending and ui-layout never activates, so nothing renders.
-  { id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-gateway'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-renderer', bundlePath: 'packages/client/ui-renderer/lib/client.js', url: '/plugins/ui-renderer.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-layout'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-attachment', bundlePath: 'packages/client/ui-attachment/lib/client.js', url: '/plugins/ui-attachment.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
+interface AssembledPlugin extends WebBootEntry {
+  /** Absolute path to the built client artifact declared by this package. */
+  bundlePath: string
+}
+
+interface ClientPackageManifest {
+  name?: string
+  exports?: Record<string, string | { default?: string }>
+  dsh?: {
+    client?: {
+      platform?: string
+      inject?: string[]
+      external?: string[]
+      immediately?: boolean
+    }
+  }
+}
+
+interface ComposedEntry {
+  name?: unknown
+  disabled?: unknown
+}
+
+interface BootComposition {
+  loadOverlayPatches(binName: string, file: string): unknown[]
+  composeEntries(layers: readonly unknown[][]): ComposedEntry[]
+}
+
+const REPO_ROOT = process.cwd()
+const BUNDLE_LAYERS = [
+  {
+    manifest: join(REPO_ROOT, 'packages/bundle/base/package.json'),
+    patch: join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml'),
+  },
   {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    bundlePath: 'packages/client/ui-workspace/lib/client.js',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
+    manifest: join(REPO_ROOT, 'packages/bundle/web-app/package.json'),
+    patch: join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml'),
   },
-  { id: '@deepseek-ai/dsh-session-log-export', bundlePath: 'packages/session-query/session-log-export/lib/client.js', url: '/plugins/session-log-download.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-commands', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
+] as const
+const bundleResolvers = BUNDLE_LAYERS.map(layer => createRequire(layer.manifest))
+const webBundleResolver = bundleResolvers[1]
+if (webBundleResolver === undefined) throw new Error('assembled boot: web bundle resolver missing')
+const appBoot = await import(pathToFileURL(webBundleResolver.resolve('@deepseek-ai/dsh-app-boot')).href) as unknown as BootComposition
+
+function resolvePackageManifest(specifier: string): string | undefined {
+  for (const require of bundleResolvers) {
+    try {
+      return require.resolve(`${specifier}/package.json`)
+    } catch {
+      continue
+    }
+  }
+  return undefined
+}
+
+function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): string {
+  const declared = pkg.exports?.['./client']
+  const relative = typeof declared === 'string' ? declared : declared?.default
+  if (relative === undefined) {
+    throw new Error(`assembled boot: ${pkg.name ?? packagePath} declares dsh.client without a ./client export`)
+  }
+  return resolve(dirname(packagePath), relative)
+}
+
+/** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
+function loadAssembledPlugins(): readonly AssembledPlugin[] {
+  const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
+    appBoot.loadOverlayPatches('assembled boot', layer.patch)))
+  const plugins = new Map<string, AssembledPlugin>()
+  for (const entry of entries) {
+    if (entry.disabled === true || typeof entry.name !== 'string') continue
+    const packagePath = resolvePackageManifest(entry.name)
+    if (packagePath === undefined) continue
+    const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as ClientPackageManifest
+    const declaration = pkg.dsh?.client
+    if (declaration?.platform !== 'web') continue
+    if (pkg.name !== entry.name) {
+      throw new Error(`assembled boot: ${entry.name} resolved package ${pkg.name ?? '<unnamed>'}`)
+    }
+    plugins.set(entry.name, {
+      id: entry.name,
+      bundlePath: resolveClientExport(packagePath, pkg),
+      url: `/plugins/${entry.name}/client.js?rev=fx`,
+      rev: 'fx',
+      ...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
+      ...(declaration.external === undefined ? {} : { external: declaration.external }),
+      ...(declaration.immediately === true ? { immediately: true } : {}),
+    })
+  }
+  return orderByModuleGraph([...plugins.values()]).map(({ id }) => {
+    const plugin = plugins.get(id)
+    /* v8 ignore next -- orderByModuleGraph returns the input row identities */
+    if (plugin === undefined) throw new Error(`assembled boot: ordered unknown client package ${id}`)
+    return plugin
+  })
+}
+
+const PLUGINS = loadAssembledPlugins()
 
 const bundles = new Map(PLUGINS.map(plugin => [
   plugin.url,
-  readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
+  readFileSync(plugin.bundlePath, 'utf8'),
 ]))
 
 interface FixtureWindow extends Window {
@@ -67,6 +132,11 @@ class ResizeObserverStub {
   unobserve(): void {}
 }
 
+class EventSourceStub {
+  addEventListener(): void {}
+  close(): void {}
+}
+
 const win = window as FixtureWindow
 let unmount: (() => Promise<void>) | undefined
 
@@ -88,6 +158,7 @@ export function installAssembledBootEnv(): void {
     Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
     document.title = 'DeepSeek Harness'
     vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    vi.stubGlobal('EventSource', EventSourceStub)
     vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
       setTimeout(() => { callback(0) }, 0) as unknown as number)
     vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })

+ 6 - 3
apps/web/tests/built-boot.snapshot.ts

@@ -43,9 +43,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
   await waitFor(() => {
     expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
   }, { timeout: 10_000 })
-  // Resolve the resident approval so the ordinary composer bar (which owns
-  // ContextMeter) resumes without replacing the session shell. This minimal
-  // boot graph intentionally does not mount the separate question UI plugin.
+  // The generated bundle roster mounts the question UI before the approval UI.
+  // Skip the resident fixture's three questions, then resolve its approval so
+  // the ordinary composer bar (which owns ContextMeter) resumes.
+  for (let index = 0; index < 3; index += 1) {
+    fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' }))
+  }
   fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
 
   // The fixture mirrors all three token-meter projections, so the assembled

+ 7 - 5
apps/web/tests/image-display.snapshot.ts

@@ -142,10 +142,11 @@ it('accepts pasted images into the composer rail in order and removes them', asy
       getData: () => '',
     },
   })
-  const toast = await screen.findByRole('alert')
-  expect(toast.textContent).toContain('Only PNG, JPG, WebP, and GIF images are supported')
+  const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported'
+  const toast = await screen.findByText(unsupportedMessage)
+  expect(toast.closest('[role="alert"]')).not.toBeNull()
   await waitFor(() => {
-    expect(screen.queryByRole('alert')).toBeNull()
+    expect(screen.queryByText(unsupportedMessage)).toBeNull()
   }, { timeout: 6_000 })
 })
 
@@ -190,8 +191,9 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
       getData: () => '',
     },
   })
-  const banner = await screen.findByRole('alert')
-  expect(banner.textContent).toContain('A message can include up to 20 images')
+  const limitMessage = 'A message can include up to 20 images'
+  const banner = await screen.findByText(limitMessage)
+  expect(banner.closest('[role="alert"]')).not.toBeNull()
   const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
   expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
 })