Prechádzať zdrojové kódy

feat(test-support): assemble real clients with native test fixtures

imccyu 3 týždňov pred
rodič
commit
627c0f741e
31 zmenil súbory, kde vykonal 2008 pridanie a 185 odobranie
  1. 1 0
      packages/client/ui-permission-presets/package.json
  2. 7 4
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  3. 1 0
      packages/client/ui-settings-models/package.json
  4. 30 45
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  5. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  6. 1 3
      packages/test-support/client-runtime/README.md
  7. 1 3
      packages/test-support/client-runtime/README.zh.md
  8. 24 3
      packages/test-support/client-runtime/package.json
  9. 138 0
      packages/test-support/client-runtime/src/assembly/bundle-roster.ts
  10. 14 0
      packages/test-support/client-runtime/src/assembly/index.ts
  11. 80 0
      packages/test-support/client-runtime/src/assembly/modules.ts
  12. 45 0
      packages/test-support/client-runtime/src/assembly/remote-default-responses.ts
  13. 87 0
      packages/test-support/client-runtime/src/assembly/remote-proxies.ts
  14. 163 0
      packages/test-support/client-runtime/src/assembly/roster.ts
  15. 361 0
      packages/test-support/client-runtime/src/assembly/test-client.ts
  16. 60 0
      packages/test-support/client-runtime/src/assembly/vitest.ts
  17. 0 2
      packages/test-support/client-runtime/src/index.ts
  18. 0 85
      packages/test-support/client-runtime/src/settings-remote.ts
  19. 195 0
      packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts
  20. 37 0
      packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts
  21. 105 0
      packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts
  22. 22 0
      packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts
  23. 93 0
      packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts
  24. 112 0
      packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts
  25. 110 0
      packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts
  26. 178 0
      packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts
  27. 64 0
      packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts
  28. 0 37
      packages/test-support/client-runtime/tests/remote.client.spec.ts
  29. 26 1
      packages/test-support/client-runtime/tsconfig.json
  30. 48 0
      pnpm-lock.yaml
  31. 3 0
      tsconfig.base.json

+ 1 - 0
packages/client/ui-permission-presets/package.json

@@ -46,6 +46,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",

+ 7 - 4
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -9,11 +9,13 @@
  * its Settings row and invalidates that row on host settings changes.
  */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
+import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
@@ -40,8 +42,9 @@ async function bench() {
   const locale = new LocaleRuntime(ctx)
   locale.setLocale('en')
   ctx.provide('locale', locale)
-  const settingsRemote = scriptedSettingsRemote()
-  const remote = new TestRemote(ctx, { settings: settingsRemote.settings })
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
+  onTestFinished(() => { mock.assertNoUnmatched() })
+  const remote = new TestRemote(ctx, { settings: mock.remote.settings })
   ctx.slots.register({
     name: 'root',
     children: {

+ 1 - 0
packages/client/ui-settings-models/package.json

@@ -44,6 +44,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",

+ 30 - 45
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -1,10 +1,12 @@
 /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
+import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
+import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
 import {
@@ -19,7 +21,8 @@ import { apply as hostApply } from '../src/index.ts'
 // so browser-language detection never runs and a fresh LocaleRuntime opens on
 // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
 
-async function bench(isLoopback = true, settings?: object, services: object = {}) {
+async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) {
+  onTestFinished(() => { mock.assertNoUnmatched() })
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const locale = new LocaleRuntime(ctx)
@@ -37,10 +40,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {}
       discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })),
       ...services,
     },
-    // Without a settings face the mirror's reads fail and stay contained; the
-    // Models join itself never fetches until a section actually loads. The real
-    // ui-settings apply also provides the settingsSchema service.
-    settings: settings ?? scriptedSettingsRemote().settings,
+    settings: mock.remote.settings,
   })
   // The fixed Host facts the settings provider reads its persistence from.
   remote.$host = { home: undefined, isLoopback }
@@ -259,25 +259,18 @@ describe('pushed invalidations', () => {
   it('welcome state follows the shared mirror across document commits', async () => {
     // The welcome notice derives from its settings scope: a document commit
     // reaches it through the mirror's one refresh, with no routing here.
-    const acknowledgement = { current: undefined as string | undefined }
-    const settings = {
-      describe: vi.fn(() => Promise.resolve({
-        ok: true as const,
-        value: {
-          writable: true,
-          hasDocument: false,
-          namespaces: [{
-            ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
-            schema: {},
-            value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current },
-            applies: 'live' as const,
-            secrets: [],
-            revision: 0,
-          }],
-        },
-      })),
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const namespace = {
+      ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+      schema: {},
+      value: {},
+      applies: 'live' as const,
+      secrets: [],
+      revision: 0,
     }
-    const b = await bench(true, settings)
+    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    mock.remote.settings.describe.mockResolvedValue(ok(document))
+    const b = await bench(true, mock)
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = b.slots.entries('settings.onboarding')
@@ -290,7 +283,10 @@ describe('pushed invalidations', () => {
     await vi.waitFor(() => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
     })
-    acknowledgement.current = WELCOME_NOTICE_VERSION
+    mock.remote.settings.describe.mockResolvedValue(ok({
+      ...document,
+      namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }],
+    }))
     b.remote.emit('settings/document-updated', ['ui-onboarding', 1])
     await vi.waitFor(() => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
@@ -298,24 +294,13 @@ describe('pushed invalidations', () => {
   })
 
   it('joins the refreshed mirror view on a settings invalidation', async () => {
-    let revision = 1
-    const describe = vi.fn(() => Promise.resolve({
-      ok: true as const,
-      value: {
-        writable: true,
-        hasDocument: false,
-        namespaces: [{
-          ns: 'llm-test',
-          schema: {},
-          value: {},
-          applies: 'live' as const,
-          secrets: [],
-          revision,
-        }],
-      },
-    }))
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 }
+    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    const describe = mock.remote.settings.describe
+    describe.mockResolvedValue(ok(document))
     const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
-    const b = await bench(true, { describe }, { listProviders })
+    const b = await bench(true, mock, { listProviders })
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = b.slots.entries('settings.section')
@@ -327,8 +312,8 @@ describe('pushed invalidations', () => {
     await injected.controller.load()
     expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
 
-    revision = 2
-    b.remote.emit('settings/document-updated', ['llm-test', revision])
+    describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] }))
+    b.remote.emit('settings/document-updated', ['llm-test', 2])
 
     await vi.waitFor(() => {
       expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)

+ 2 - 2
packages/test-support/client-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md
-README.md: 61426ccb8002032508feb3c9dbd26718d676177d
-README.zh.md: e462f42c1d6ac7a3c7951e65ae23a424679057b0
+README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb
+README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 3
packages/test-support/client-runtime/README.md


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 3
packages/test-support/client-runtime/README.zh.md


+ 24 - 3
packages/test-support/client-runtime/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-test-runtime",
-  "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs",
+  "description": "Browser test runtimes: a jsdom slot bench with test-owned Session and Workspace doubles, and a whole-client tier that boots the web roster through the production bootClient over an endpoint-named Remote mock",
   "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
@@ -25,13 +25,20 @@
   "dependencies": {
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
+    "js-yaml": "^4.2.0",
     "vitest": "^4.1.8"
   },
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-hmr": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -39,18 +46,28 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-api-gateway": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-base": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-hmr": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -59,12 +76,16 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-web-app": "workspace:^",
+    "@types/js-yaml": "^4.0.9",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
-    "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },

+ 138 - 0
packages/test-support/client-runtime/src/assembly/bundle-roster.ts

@@ -0,0 +1,138 @@
+/**
+ * The browser roster of a `dsh --profile`, read from its bundle patch files
+ * the way the launcher composes them: each bundle's `dsh.bundle.patch` list is
+ * parsed with the include plugin's YAML dialect (`entryListSchema`) and
+ * composed by its `applyEntryPatches`; every enabled row whose package
+ * declares `dsh.client.platform === 'web'` becomes a roster row carrying that
+ * declaration's `inject` and `immediately`; rows nested in Loader groups count
+ * like the Loader counts them, a disabled group disabling every row beneath
+ * it. A patch that matches nothing
+ * throws here where the launcher warns. Nothing is copied from the bundles: a
+ * bundle change is visible at the next import. Node only — the
+ * whole-client tier runs under vitest, and this is the one place it reads the
+ * repository.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster
+ */
+import { existsSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client'
+import * as yaml from 'js-yaml'
+import { ClientRoster, type ClientRosterRow } from './roster.ts'
+
+/** The `web` profile's bundle layers, in the order `dsh --profile web` applies them (app-boot `PROFILE_TEMPLATES.web`). */
+export const WEB_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
+
+interface PackageManifest {
+  name?: unknown
+  dsh?: { bundle?: { patch?: unknown }; client?: unknown }
+}
+
+/** One bundle: where its package.json is (plugin names resolve from there) and its parsed patch list. */
+interface BundleLayer {
+  readonly manifestPath: string
+  readonly patches: PatchOptions[]
+}
+
+/**
+ * Compose the browser roster of `bundles`, applied in order.
+ * @param bundles - bundle package names in application order.
+ * @param anchor - file whose package resolution locates the bundles; default this package.
+ * @returns the roster in composition order, one row per package.
+ * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list
+ * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression.
+ */
+export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster {
+  const layers = bundles.map(name => readLayer(name, anchor))
+  const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => {
+    throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`)
+  })
+  const anchors = layers.map(layer => layer.manifestPath)
+  const rows: ClientRosterRow[] = []
+  const seen = new Set<string>()
+  for (const { entry, disabled } of flattenGroups(entries)) {
+    const name = exactPackageSpecifier(entry.name)
+    if (name === undefined || disabled === true || seen.has(name)) continue
+    seen.add(name)
+    const manifestPath = locateManifest(anchors, name)
+    if (manifestPath === undefined) {
+      throw new Error(`client-test-runtime: cannot resolve plugin package ${name} from ${bundles.join(', ')}`)
+    }
+    const manifest = readManifest(manifestPath)
+    if (manifest.name !== name) {
+      throw new Error(`client-test-runtime: ${manifestPath} names ${JSON.stringify(manifest.name)}, expected ${name}`)
+    }
+    const declaration = parseDshClient(name, manifest.dsh?.client)
+    if (declaration === undefined || declaration.platform !== 'web') continue
+    if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') {
+      throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`)
+    }
+    rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true })
+  }
+  return ClientRoster.of(rows)
+}
+
+function readLayer(bundle: string, anchor: string): BundleLayer {
+  const manifestPath = locateManifest([anchor], bundle)
+  if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`)
+  const patch = readManifest(manifestPath).dsh?.bundle?.patch
+  if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`)
+  const file = join(dirname(manifestPath), patch)
+  const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema })
+  if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`)
+  return { manifestPath, patches: parsed as PatchOptions[] }
+}
+
+/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */
+interface FlatEntry {
+  readonly entry: EntryOptions
+  readonly disabled: unknown
+}
+
+/**
+ * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's
+ * `disabled` disables every row beneath it.
+ * @param entries - composed entries, possibly nested.
+ * @param inherited - the enclosing group's `disabled` when set.
+ * @returns the plugin rows.
+ */
+function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] {
+  const rows: FlatEntry[] = []
+  for (const entry of entries) {
+    const own = (entry as { disabled?: unknown }).disabled
+    const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own
+    if (entry.group === true && Array.isArray(entry.config)) {
+      rows.push(...flattenGroups(entry.config as EntryOptions[], disabled))
+      continue
+    }
+    rows.push({ entry, disabled })
+  }
+  return rows
+}
+
+function readManifest(path: string): PackageManifest {
+  return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
+}
+
+/** Locate `<name>/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */
+function locateManifest(anchors: readonly string[], name: string): string | undefined {
+  for (const anchor of anchors) {
+    for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) {
+      const candidate = join(searchPath, name, 'package.json')
+      if (existsSync(candidate)) return candidate
+    }
+  }
+  return undefined
+}
+
+/** The include plugin's `%C` placeholders, filled the way the launcher prints them. */
+function describe(message: string, args: readonly unknown[]): string {
+  let index = 0
+  return message.replace(/%C/g, () => JSON.stringify(args[index++]))
+}
+
+/** The `web` profile's browser roster, composed from its bundles at import. */
+export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES)

+ 14 - 0
packages/test-support/client-runtime/src/assembly/index.ts

@@ -0,0 +1,14 @@
+/**
+ * Whole-client tier entry (deep import only:
+ * `@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts`). Kept out of
+ * the package root so slot-tier specs do not load the assembly machinery.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly
+ */
+export { ClientRoster } from './roster.ts'
+export type { AssemblyPlan, ClientPluginModule, ClientRosterRow } from './roster.ts'
+export { TestClient } from './test-client.ts'
+export type { TestClientOptions } from './test-client.ts'
+export { remoteDefaultResponses } from './remote-default-responses.ts'
+export { bundleRoster, webApp } from './bundle-roster.ts'
+export { createClientTest } from './vitest.ts'
+export type { ClientTestFixtures } from './vitest.ts'

+ 80 - 0
packages/test-support/client-runtime/src/assembly/modules.ts

@@ -0,0 +1,80 @@
+/**
+ * In-process module arrival: import every roster row's `/client` module (or
+ * the plan's replacement) and register each as a pre-arrived factory on a
+ * production `ClientModuleSystem`, so neither the Loader's `internal.import`
+ * nor a stage-one `prefetch` ever fetches a bundle.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/modules
+ */
+import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
+import { createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
+import type { ClientModuleLoader, ClientModuleLoaderTarget, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import type { AssemblyPlan, ClientPluginModule } from './roster.ts'
+
+/** The bootstrap row: always this process's static namespace, never a dynamic import or a `provide` replacement. */
+export const MODULES_PACKAGE = '@deepseek-ai/dsh-client-modules'
+
+/**
+ * Resolve each roster row to its plugin module: `plan.provide[name]` when
+ * present, otherwise a `/client` import resolved by the repository's tsconfig
+ * path aliases under Vitest. The bootstrap row is the
+ * statically imported `@deepseek-ai/dsh-client-modules/client` namespace.
+ * @param plan - validated plan.
+ * @returns package name → module, in roster order.
+ * @throws {Error} when an import fails (the package name prefixes the original message) or the bootstrap row is provided.
+ */
+export async function loadPluginModules(plan: AssemblyPlan): Promise<ReadonlyMap<string, ClientPluginModule>> {
+  const modules = new Map<string, ClientPluginModule>()
+  for (const { name } of plan.roster.rows) {
+    const provided = plan.provide?.[name]
+    if (name === MODULES_PACKAGE) {
+      if (provided !== undefined) {
+        throw new Error(`client-test-runtime: ${MODULES_PACKAGE} is the bootstrap module and cannot be provided`)
+      }
+      modules.set(name, modulesClient)
+      continue
+    }
+    modules.set(name, provided ?? await importClient(name))
+  }
+  return modules
+}
+
+async function importClient(name: string): Promise<ClientPluginModule> {
+  let namespace: unknown
+  try {
+    namespace = await import(/* @vite-ignore */ `${name}/client`)
+  } catch (error) {
+    throw new Error(`client-test-runtime: cannot import ${name}/client: ${String(error)}`, { cause: error })
+  }
+  return namespace as ClientPluginModule
+}
+
+/**
+ * Build the production module system over queued factories returning the
+ * loaded namespaces. The bootstrap row uses `bootstrapModule`; `staticModules`
+ * is empty because namespaces already hold their own imports. Missing factories
+ * reject through `loadBundle` without fetching.
+ * @param graph - raw boot graph from `graphFromRoster`; `createClientModuleSystem` parses it.
+ * @param modules - loaded plugin modules keyed by package name.
+ * @returns module system to install as `loader.internal`; its `manifest` is the parsed graph.
+ */
+export function createInProcessModules(
+  graph: WebBootGraph,
+  modules: ReadonlyMap<string, ClientPluginModule>,
+): ClientModuleLoader {
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue',
+    pendingQueue: [],
+    /* v8 ignore next -- construction replaces this sink before draining the prefilled queue. */
+    load: () => { throw new Error('client-test-runtime: module facade is not initialized') },
+    create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: modulesClient }, options),
+  }
+  for (const [id, namespace] of modules) {
+    if (id === MODULES_PACKAGE) continue
+    target.pendingQueue.push({ id, factory: () => namespace as unknown as Record<string, unknown> })
+  }
+  return target.create({
+    boot: graph,
+    staticModules: {},
+    loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)),
+  })
+}

+ 45 - 0
packages/test-support/client-runtime/src/assembly/remote-default-responses.ts

@@ -0,0 +1,45 @@
+/**
+ * Default responses for every Remote endpoint the web assembly calls while
+ * booting and rendering with no sessions, no workspaces, and default settings.
+ * The comment above each row names the plugin that calls it; endpoints boot
+ * never touches stay absent so a new call fails loud. `$events` is built into
+ * `RemoteMock`.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses
+ */
+import { ok, openStream, type RemoteTable } from '@deepseek-ai/dsh-remote-mock'
+
+/** Default responses of the boot-time Remote endpoints; a spec loads it first and layers its own table on top. */
+export const remoteDefaultResponses: RemoteTable = {
+  unary: {
+    // api-session-controller `sessions.handleConnected()` on `connection/reset`.
+    'session/list': ok({ items: [] }),
+    // ui-settings `mirror.ensure()` at apply and again on `connection/reset`.
+    'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }),
+    // ui-model-selection `ModelDirectoryResolver` constructor.
+    'session/modelCatalog': ok({
+      default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+      routableProviders: [],
+      groups: [],
+      failures: [],
+    }),
+    // ui-agent-preset hero chip and header label on first mount.
+    'agentPresets/list': ok({ presets: [], authorable: false }),
+    // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`.
+    'dynamicCordisRunner/syncInspectManifest': ok(null),
+    // ui-cordis inventory at apply and on `connection/reset`.
+    'dynamicCordisRunner/inventory': ok([]),
+    // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes.
+    'credentials/describe': ok({}),
+  },
+  // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss.
+  streams: [
+    // api-session-controller `SessionEventStream.follow` when a Session opens.
+    'session/follow',
+  ],
+  stream: {
+    // api-session-controller client `apply`: the control stream's opening baseline, then open.
+    'session/control': openStream([{ type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }]),
+    // api-workspace-controller client `apply`: the follow stream's opening baseline, then open.
+    'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]),
+  },
+}

+ 87 - 0
packages/test-support/client-runtime/src/assembly/remote-proxies.ts

@@ -0,0 +1,87 @@
+/**
+ * `remote.<ns>` services for the whole-client tier, without the generated
+ * Remote clients. Cordis resolves `ctx.remote.<ns>` to whichever service is
+ * registered under `remote.<ns>` (vendor cordis `utils.ts`, traceable get), so
+ * the carrier provides one Proxy per namespace: `remote.<ns>.<method>(...args)`
+ * calls the endpoint `<ns>/<method>` over the roster's own Connection with the
+ * positional `args`, as a stream when the mock registered a stream script for
+ * it and as a unary call otherwise. A unary answer is returned unchanged and a
+ * unary rejection is folded the way the generated client folds a carrier
+ * throw (`gateway/internal`, or `gateway/cancelled` once the caller's signal
+ * aborted), so product code that never awaits a rejection sees none; stream
+ * items and failures pass through as the stream yields them.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-proxies
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import { cancelledFailure, carrierFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import type { ClientPluginModule } from './roster.ts'
+
+/** The assembly row the proxies stand in for; its generated clients exist only in built `lib/`. */
+export const REMOTES_PACKAGE = '@deepseek-ai/dsh-api-remotes'
+
+const PREFIX = 'remote.'
+
+/**
+ * Namespaces to provide: every `remote.<ns>` a roster module injects, plus the
+ * namespace of every endpoint the mock has a rule for.
+ * @param modules - loaded roster modules.
+ * @param mock - the spec's mock.
+ * @returns sorted namespace names.
+ */
+export function remoteNamespacesOf(modules: Iterable<ClientPluginModule>, mock: RemoteMock): readonly string[] {
+  const names = new Set<string>()
+  for (const module of modules) {
+    for (const service of injectNames(module.inject)) if (service.startsWith(PREFIX)) names.add(service.slice(PREFIX.length))
+  }
+  for (const endpoint of mock.endpoints()) {
+    const slash = endpoint.indexOf('/')
+    if (slash > 0 && !endpoint.startsWith('$')) names.add(endpoint.slice(0, slash))
+  }
+  return [...names].sort()
+}
+
+function injectNames(inject: ClientPluginModule['inject']): readonly string[] {
+  if (inject === undefined) return []
+  if (Array.isArray(inject)) return inject as readonly string[]
+  return Object.keys(inject)
+}
+
+/**
+ * Plugin providing the namespace proxies; `TestClient.start` mounts it before the Loader rows.
+ * @param namespaces - namespaces to provide.
+ * @param mock - the spec's mock, asked for each endpoint's mode.
+ * @returns the plugin.
+ */
+export function remoteProxiesPlugin(namespaces: readonly string[], mock: RemoteMock): ClientPluginModule {
+  return {
+    inject: ['connection'],
+    apply(ctx: Context) {
+      const connection = ctx.get('connection') as ConnectionHandle
+      for (const namespace of namespaces) ctx.provide(`${PREFIX}${namespace}`, namespaceProxy(namespace, connection, mock))
+    },
+  }
+}
+
+function namespaceProxy(namespace: string, connection: ConnectionHandle, mock: RemoteMock): object {
+  return new Proxy(Object.create(null) as Record<string, unknown>, {
+    get: (_target, property) => {
+      // No `then`: awaiting the namespace object itself must not call a method.
+      if (typeof property !== 'string' || property === 'then') return undefined
+      return (...values: readonly unknown[]): unknown => {
+        const endpoint = `${namespace}/${property}`
+        const args = [...values]
+        const signal = args.at(-1) instanceof AbortSignal ? (args.pop() as AbortSignal) : undefined
+        if (mock.modeOf(endpoint) === 'stream') {
+          const open = connection.rpc.open
+          /* v8 ignore next -- the mock transport always supplies openStream, so the Connection carrier exposes open. */
+          if (open === undefined) throw new Error(`client-test-runtime: ${endpoint} is a stream but the carrier has no in-process opener`)
+          return open('/api', endpoint, { args }, signal ?? new AbortController().signal)
+        }
+        return connection.rpc.call('/api', endpoint, { args }, signal).catch((error: unknown) =>
+          (signal?.aborted === true ? cancelledFailure(endpoint, error) : carrierFailure(endpoint, error)))
+      }
+    },
+  })
+}

+ 163 - 0
packages/test-support/client-runtime/src/assembly/roster.ts

@@ -0,0 +1,163 @@
+/**
+ * Client roster: the ordered package-name rows a whole-client test boots, and
+ * the plan that annotates one with the rows the test provides itself. `webApp`
+ * and `bundleRoster` (`./bundle-roster.ts`) read rosters from the bundle patch
+ * files; a spec may also build one inline with {@link ClientRoster.of}.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/roster
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import { PLATFORM_MODULES } from '@deepseek-ai/dsh-client-web/src/platform.ts'
+
+/** One browser plugin row as `dsh.client` declares it, keyed by package name. */
+export interface ClientRosterRow {
+  /** Package name (== manifest entry id == Loader entry name). */
+  readonly name: string
+  /** Package-name dependency edges from `dsh.client.inject` ([] when absent). */
+  readonly inject: readonly string[]
+  /** Stage-one prefetch mark from `dsh.client.immediately` (false when absent). */
+  readonly immediately: boolean
+}
+
+/** Revision stamped on every synthesized row and batch; nothing is fetched by it. */
+const LOCAL_REV = 'local'
+
+/**
+ * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch
+ * holding every row, `rev: 'local'`, placeholder `/plugins/<name>/client.js`
+ * URLs, since every module is seeded in process and never fetched. Validation
+ * stays with the production `parseBootManifest` inside the module system:
+ * duplicate names and an empty roster are rejected there, not here.
+ * @param rows - roster rows in composition order.
+ * @returns the unparsed graph, as `createClientModuleSystem` consumes it.
+ */
+export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph {
+  const entries: WebBootEntry[] = rows.map(row => ({
+    id: row.name,
+    url: `/plugins/${row.name}/client.js`,
+    rev: LOCAL_REV,
+    ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}),
+    ...(row.immediately ? { immediately: true } : {}),
+  }))
+  return {
+    rev: LOCAL_REV,
+    entries,
+    batches: [{
+      phase: 'application',
+      url: '/plugins/local.js',
+      rev: LOCAL_REV,
+      entries: entries.map(entry => entry.id),
+    }],
+  }
+}
+
+/** Module names the shell seeds before any row loads; an inject edge to one of them is satisfied without a row. */
+const PLATFORM_SEED: ReadonlySet<string> = new Set(PLATFORM_MODULES)
+
+/** Immutable, name-addressable roster. */
+export class ClientRoster {
+  /**
+   * Build a roster from rows; duplicate names throw.
+   * @param rows - roster rows in composition order.
+   * @returns roster.
+   */
+  static of(rows: readonly ClientRosterRow[]): ClientRoster {
+    const seen = new Set<string>()
+    const duplicates = new Set<string>()
+    for (const { name } of rows) {
+      if (seen.has(name)) duplicates.add(name)
+      seen.add(name)
+    }
+    if (duplicates.size > 0) {
+      throw new Error(`client-test-runtime: duplicate roster rows: ${[...duplicates].join(', ')}`)
+    }
+    return new ClientRoster(Object.freeze([...rows]))
+  }
+
+  private constructor(readonly rows: readonly ClientRosterRow[]) {}
+
+  /**
+   * Keep only `names`, preserving roster order; an unknown name throws with the roster listed.
+   * @param names - package names to keep.
+   * @returns sub-roster.
+   */
+  pick(names: readonly string[]): ClientRoster {
+    const keep = this.known(names, 'pick')
+    return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name))))
+  }
+
+  /**
+   * The named rows plus every row they inject, transitively, in roster order: the rows a spec needs to boot the
+   * named plugins as the bundle composes them. The shell's platform modules (`PLATFORM_MODULES`, seeded statically
+   * rather than loaded as rows) end the walk. An unknown name throws with the roster listed; a row injecting any
+   * other package outside the roster throws, since the bundle itself would not boot.
+   * @param names - package names whose dependency cone to keep.
+   * @returns sub-roster.
+   */
+  closure(names: readonly string[]): ClientRoster {
+    this.known(names, 'closure')
+    const byName = new Map(this.rows.map(row => [row.name, row]))
+    const keep = new Set<string>()
+    const visit = (name: string, from: string | undefined): void => {
+      if (keep.has(name) || PLATFORM_SEED.has(name)) return
+      const row = byName.get(name)
+      if (row === undefined) {
+        throw new Error(`client-test-runtime: ${String(from)} injects ${name}, which is outside the roster`)
+      }
+      keep.add(name)
+      for (const dependency of row.inject) visit(dependency, name)
+    }
+    for (const name of names) visit(name, undefined)
+    return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name))))
+  }
+
+  /**
+   * Drop `names`; an unknown name throws with the roster listed.
+   * @param names - package names to drop.
+   * @returns sub-roster.
+   */
+  without(names: readonly string[]): ClientRoster {
+    const drop = this.known(names, 'without')
+    return new ClientRoster(Object.freeze(this.rows.filter(row => !drop.has(row.name))))
+  }
+
+  private known(names: readonly string[], operation: string): ReadonlySet<string> {
+    const rostered = this.rows.map(row => row.name)
+    const unknown = names.filter(name => !rostered.includes(name))
+    if (unknown.length > 0) {
+      throw new Error(
+        `client-test-runtime: ${operation}() names outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`,
+      )
+    }
+    return new Set(names)
+  }
+}
+
+/** The module face the Loader materializes for one client plugin row. */
+export interface ClientPluginModule {
+  apply(ctx: Context, config?: unknown): unknown
+  readonly inject?: readonly string[] | Readonly<Record<string, unknown>>
+  readonly Config?: unknown
+}
+
+/** What to boot and what the test supplies itself. */
+export interface AssemblyPlan {
+  readonly roster: ClientRoster
+  /** Row replacements by package name (the test's own implementation of that row). Names outside the roster throw. */
+  readonly provide?: Readonly<Record<string, ClientPluginModule>>
+}
+
+/**
+ * Validate a plan against its roster.
+ * @param plan - plan to check.
+ * @throws {Error} naming any `provide` key outside the roster.
+ */
+export function assertPlan(plan: AssemblyPlan): void {
+  const rostered = plan.roster.rows.map(row => row.name)
+  const unknown = Object.keys(plan.provide ?? {}).filter(name => !rostered.includes(name))
+  if (unknown.length > 0) {
+    throw new Error(
+      `client-test-runtime: provide names rows outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`,
+    )
+  }
+}

+ 361 - 0
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -0,0 +1,361 @@
+/**
+ * Whole-client test carrier: boots an {@link AssemblyPlan} through the
+ * production `bootClient` over an in-process module table, with a
+ * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`.
+ * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client
+ */
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client'
+import type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts'
+import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts'
+import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { act } from '@testing-library/react'
+import { createInProcessModules, loadPluginModules } from './modules.ts'
+import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts'
+import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts'
+
+/** Carrier options. */
+export interface TestClientOptions {
+  /**
+   * Mount `uiRenderer` into an element (a fresh `document.body` child when `true`); requires jsdom and a roster
+   * that provides `uiRenderer`. Default false.
+   */
+  readonly mount?: boolean | HTMLElement
+  /** Wait for `ctx.connection.state === 'connected'` before returning. Default true. */
+  readonly awaitConnected?: boolean
+  /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */
+  readonly connectTimeoutMs?: number
+}
+
+/** Page global the connection plugin reads its carrier from. */
+interface TransportGlobal {
+  __DSH_TRANSPORT__?: ClientTransportHooks
+}
+
+const transportGlobal = globalThis as TransportGlobal
+
+/**
+ * Process globals every live client in this worker shares: the transport the
+ * `connection` plugin reads at apply, and the jsdom shims. The first holder
+ * installs them and remembers what was there; the last release removes the
+ * shims and restores the transport. A holder installs its transport for its
+ * own boot and for each rebuild it runs, both under the worker's boot turn, so
+ * overlapping clients and out-of-order disposal neither clobber a booting or
+ * rebuilding client nor leak into later tests.
+ */
+class SharedGlobals {
+  private holders = 0
+  private previousTransport: ClientTransportHooks | undefined
+  private removeShims: (() => void) | undefined
+
+  /**
+   * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold.
+   * @param transport - carrier the next `connection` apply reads.
+   */
+  install(transport: ClientTransportHooks): void {
+    transportGlobal.__DSH_TRANSPORT__ = transport
+  }
+
+  /**
+   * Hold the globals with `transport` installed.
+   * @param transport - carrier the next boot reads.
+   * @returns the release for this holder.
+   */
+  acquire(transport: ClientTransportHooks): () => void {
+    if (this.holders === 0) {
+      this.previousTransport = transportGlobal.__DSH_TRANSPORT__
+      this.removeShims = installJsdomShims()
+    }
+    this.holders += 1
+    this.install(transport)
+    return () => {
+      this.holders -= 1
+      if (this.holders > 0) return
+      this.removeShims?.()
+      this.removeShims = undefined
+      if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__
+      else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport
+      this.previousTransport = undefined
+    }
+  }
+}
+
+const sharedGlobals = new SharedGlobals()
+
+/**
+ * Boots and entry rebuilds run one at a time per worker: the transport global
+ * must stay the acting client's until its `connection` row applies.
+ */
+let bootTurn: Promise<unknown> = Promise.resolve()
+
+/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */
+function takeTurn(work: () => Promise<void>): Promise<void> {
+  const turn = bootTurn.then(work)
+  bootTurn = turn.catch(() => undefined)
+  return turn
+}
+
+/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */
+const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
+
+/**
+ * Browser globals jsdom lacks that roster plugins touch at apply or mount:
+ * client-hmr opens an `EventSource`, layout components observe element size.
+ * Inert stand-ins, installed only where the global is absent.
+ */
+const JSDOM_SHIMS: Readonly<Record<string, unknown>> = {
+  EventSource: class {
+    addEventListener(): void {}
+    close(): void {}
+  },
+  ResizeObserver: class {
+    observe(): void {}
+    disconnect(): void {}
+  },
+}
+
+/** Install a shim for each absent global; the disposer deletes exactly those. */
+function installJsdomShims(): () => void {
+  const globals = globalThis as Record<string, unknown>
+  const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined)
+  for (const name of installed) globals[name] = JSDOM_SHIMS[name]
+  return () => { for (const name of installed) Reflect.deleteProperty(globals, name) }
+}
+
+/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */
+function toError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+function messageOf(error: unknown): string {
+  return toError(error).message
+}
+
+function connectionOf(ctx: Context): ConnectionHandle {
+  const connection = ctx.get('connection') as ConnectionHandle | undefined
+  if (connection === undefined) {
+    throw new Error('client-test-runtime: the roster provides no `connection` service')
+  }
+  return connection
+}
+
+/** Mount point plus whether `start` created it (and so removes it on dispose). */
+interface MountPoint {
+  readonly element: HTMLElement | undefined
+  readonly owned: boolean
+}
+
+function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint {
+  if (mount === undefined || mount === false) return { element: undefined, owned: false }
+  if (typeof document === 'undefined') {
+    throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec')
+  }
+  if (mount === true) {
+    const element = document.createElement('div')
+    document.body.appendChild(element)
+    return { element, owned: true }
+  }
+  return { element: mount, owned: false }
+}
+
+/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */
+async function settle(fn: () => Promise<void>): Promise<void> {
+  if (typeof document === 'undefined') {
+    await fn()
+    return
+  }
+  await act(async () => { await fn() })
+}
+
+async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise<void> {
+  const { state } = connectionOf(ctx)
+  if (state.getSnapshot() === 'connected') return
+  await new Promise<void>((resolve, reject) => {
+    const timer = setTimeout(() => {
+      unsubscribe()
+      const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`)
+      const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`)
+      reject(new Error(
+        `client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; `
+        + `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`,
+      ))
+    }, timeoutMs)
+    const unsubscribe = state.subscribe(() => {
+      if (state.getSnapshot() !== 'connected') return
+      clearTimeout(timer)
+      unsubscribe()
+      resolve()
+    })
+  })
+}
+
+/** A booted client under test. */
+export class TestClient {
+  /**
+   * Load the roster's modules, then, holding this worker's boot turn, install
+   * the mock as the Connection carrier and the jsdom shims and boot through
+   * `bootClient` over the synthesized boot graph; afterwards optionally mount
+   * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is
+   * dropped from the roster: its generated Remote clients exist only in built
+   * `lib/`, and the `remote.<ns>` services the roster injects (plus the
+   * namespaces the mock has rules for at this point) are provided as
+   * contract-free proxies over the same Connection instead; a `provide` entry
+   * for that row is refused. On any failure the context is disposed, an owned
+   * mount removed, and this client's hold on the globals released before the
+   * original error is rethrown.
+   * @param plan - roster and annotations.
+   * @param mock - Remote mock answering every Gateway call.
+   * @param options - mount and readiness options.
+   * @returns the booted client.
+   */
+  static async start(
+    plan: AssemblyPlan,
+    mock: RemoteMock,
+    options: TestClientOptions = {},
+  ): Promise<TestClient> {
+    assertPlan(plan)
+    if (plan.provide?.[REMOTES_PACKAGE] !== undefined) {
+      throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote.<ns> services are the tier's proxies`)
+    }
+    const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE)
+      ? plan.roster.without([REMOTES_PACKAGE])
+      : plan.roster
+    const ctx = new Context()
+    const transport: ClientTransportHooks = { rpc: mock.rpc }
+    let mountPoint: MountPoint = { element: undefined, owned: false }
+    let release: (() => void) | undefined
+    const restore = (): void => {
+      if (mountPoint.owned) mountPoint.element?.remove()
+      release?.()
+    }
+    try {
+      const modules = await loadPluginModules({ ...plan, roster })
+      mountPoint = resolveMountPoint(options.mount)
+      await takeTurn(async () => {
+        release = sharedGlobals.acquire(transport)
+        const system = createInProcessModules(graphFromRoster(roster.rows), modules)
+        ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin)
+        await bootClient({ ctx, modules: system, manifest: system.manifest })
+      })
+      if (mountPoint.element !== undefined) {
+        if (ctx.get('uiRenderer') === undefined) {
+          throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`')
+        }
+        await mountClient(ctx, mountPoint.element)
+      }
+      if (options.awaitConnected !== false) {
+        await awaitConnected(ctx, mock, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
+      }
+    } catch (error) {
+      try {
+        await settle(() => ctx.fiber.dispose())
+      } catch {
+        // The boot failure is the error to report; a half-built tree failing to dispose adds nothing to it.
+      } finally {
+        restore()
+      }
+      throw error
+    }
+    return new TestClient(ctx, mock, mountPoint.element, restore)
+  }
+
+  private disposing: Promise<void> | undefined
+
+  private constructor(
+    readonly ctx: Context,
+    readonly mock: RemoteMock,
+    readonly container: HTMLElement | undefined,
+    private readonly restore: () => void,
+  ) {}
+
+  /** The carrier this client's `connection` row reads when it applies. */
+  private get transport(): ClientTransportHooks {
+    return { rpc: this.mock.rpc }
+  }
+
+  /** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */
+  get connection(): ConnectionHandle {
+    return connectionOf(this.ctx)
+  }
+
+  /** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */
+  async flush(): Promise<void> {
+    await settle(() => Promise.resolve())
+  }
+
+  /**
+   * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild
+   * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its
+   * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no
+   * entries and the lookup throws before anything is installed.
+   * @param name - package name of the row.
+   */
+  async reload(name: string): Promise<void> {
+    const entry = this.entryOf(name)
+    await takeTurn(async () => {
+      sharedGlobals.install(this.transport)
+      await tearDownEntryFiber(entry)
+      await entry.refresh()
+      await this.ctx.loader.await()
+    })
+  }
+
+  /**
+   * Remove one Loader entry.
+   * @param name - package name of the row.
+   */
+  async unload(name: string): Promise<void> {
+    await this.ctx.loader.remove(this.entryOf(name).id)
+  }
+
+  /**
+   * Dispose the plugin tree, then drop an owned mount and release this
+   * client's hold on the shared globals even when the tree fails to dispose,
+   * then `mock.assertNoUnmatched()` last so its failure is the test's reason
+   * without skipping the cleanup; when both the tree and the check fail, one
+   * error carries both messages. The first call owns the teardown and reports
+   * its failure; every later call waits for that teardown and resolves.
+   */
+  async dispose(): Promise<void> {
+    if (this.disposing !== undefined) {
+      await this.disposing.catch(() => undefined)
+      return
+    }
+    this.disposing = this.teardown()
+    await this.disposing
+  }
+
+  private async teardown(): Promise<void> {
+    let failure: Error | undefined
+    try {
+      await settle(() => this.ctx.fiber.dispose())
+    } catch (error) {
+      failure = toError(error)
+    } finally {
+      this.restore()
+    }
+    if (failure === undefined) {
+      this.mock.assertNoUnmatched()
+      return
+    }
+    try {
+      this.mock.assertNoUnmatched()
+    } catch (unmatched) {
+      throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure })
+    }
+    throw failure
+  }
+
+  private entryOf(name: string): Entry {
+    const entries = [...this.ctx.loader.entries()]
+    const entry = entries.find(candidate => candidate.options.name === name)
+    if (entry === undefined) {
+      throw new Error(
+        `client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`,
+      )
+    }
+    return entry
+  }
+}

+ 60 - 0
packages/test-support/client-runtime/src/assembly/vitest.ts

@@ -0,0 +1,60 @@
+/** Test-scoped Remote mock and lazy client boot, owned by native Vitest fixtures. */
+import { test, type TestAPI } from 'vitest'
+import { RemoteMock, type MockedRemote } from '@deepseek-ai/dsh-remote-mock'
+import type { AssemblyPlan } from './roster.ts'
+import { remoteDefaultResponses } from './remote-default-responses.ts'
+import { TestClient, type TestClientOptions } from './test-client.ts'
+
+/** Per-test controls; configure the mock before awaiting `start()`. */
+export interface ClientTestFixtures {
+  /** Fresh mock with the assembly's default responses already loaded. */
+  mock: RemoteMock
+  /** Namespace proxy backed by the same native mocks used by the Connection carrier. */
+  remote: MockedRemote
+  /** Await the one client owned by this test; rejects after the fixture closes. */
+  start: () => Promise<TestClient>
+}
+
+/**
+ * Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one
+ * promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection.
+ * Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned.
+ * @param plan - roster and replacement modules, shared as configuration rather than as a running client.
+ * @param options - mount and readiness settings passed to `TestClient.start`.
+ * @returns Vitest's test function with test-scoped `mock` and `start` fixtures.
+ */
+export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI<ClientTestFixtures> {
+  return test.extend<ClientTestFixtures>({
+    remote: async ({ mock }, use) => {
+      await use(mock.remote)
+    },
+    mock: async ({}, use) => {
+      const mock = RemoteMock.create().load(remoteDefaultResponses)
+      try {
+        await use(mock)
+      } finally {
+        mock.assertNoUnmatched()
+      }
+    },
+    start: async ({ mock }, use) => {
+      let pending: Promise<TestClient> | undefined
+      let closed = false
+      const start = (): Promise<TestClient> => {
+        if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed'))
+        if (pending === undefined) {
+          pending = TestClient.start(plan, mock, options)
+          // Startup belongs to the caller; keep a rejection handler attached until teardown can await it.
+          void pending.catch(() => undefined)
+        }
+        return pending
+      }
+      try {
+        await use(start)
+      } finally {
+        closed = true
+        const client = await pending?.catch(() => undefined)
+        await client?.dispose()
+      }
+    },
+  })
+}

+ 0 - 2
packages/test-support/client-runtime/src/index.ts

@@ -45,8 +45,6 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot
 export { FixtureSession, TestSessions } from './sessions.ts'
 export { stubSettingsScope } from './settings-scope.ts'
 export type { StubSettingsScope } from './settings-scope.ts'
-export { scriptedSettingsRemote } from './settings-remote.ts'
-export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts'
 export { TestWorkspaces } from './workspaces.ts'
 export { RemoteError, TestRemote } from './remote.ts'
 export {

+ 0 - 85
packages/test-support/client-runtime/src/settings-remote.ts

@@ -1,85 +0,0 @@
-/** Test double for the `settings` Remote namespace a bench's plugins inject. */
-import { vi } from 'vitest'
-
-/** The minimum a scripted namespace view carries for the double's own bookkeeping. */
-export interface ScriptedNamespace {
-  /** Namespace key the write addresses. */
-  ns: string
-}
-
-/** One scripted `settings` namespace face plus the controls a bench drives it with. */
-export interface ScriptedSettingsRemote<View extends ScriptedNamespace> {
-  /**
-   * The namespace face handed to `TestRemote` as `settings`. A plugin injecting
-   * `remote.settings` unparks on it, which is what most benches need; the
-   * describe answer is the same one the shared mirror would read.
-   */
-  settings: {
-    describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }>
-    update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-    replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-    mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise<
-      | { ok: true; value: View }
-      | { ok: false; error: { code: string; message: string; details: object } }
-    >
-  }
-  /** Spy behind `settings.update`, for argument assertions. */
-  update: ReturnType<typeof vi.fn>
-  /** Spy behind `settings.replace`, for argument assertions. */
-  replace: ReturnType<typeof vi.fn>
-  /** Spy behind `settings.mutate`, for argument assertions. */
-  mutate: ReturnType<typeof vi.fn>
-  /**
-   * Replace what the next describe answers with, as a Host commit would.
-   * @param namespaces - the namespace views to serve from now on.
-   */
-  publish(namespaces: readonly View[]): void
-}
-
-/**
- * Build a scripted `settings` Remote namespace for a bench. Each write answers
- * with the addressed namespace unchanged, so a bench that only needs its
- * plugins to activate scripts nothing; one asserting a write reads the
- * corresponding spy or replaces the face.
- * @param namespaces - namespace views the first describe answers with.
- * @param options - deployment facts the describe answer reports.
- * @returns the face and its controls.
- */
-export function scriptedSettingsRemote<View extends ScriptedNamespace>(
-  namespaces: readonly View[] = [],
-  options: { writable?: boolean; hasDocument?: boolean } = {},
-): ScriptedSettingsRemote<View> {
-  let served = namespaces
-  const writable = options.writable ?? true
-  const hasDocument = options.hasDocument ?? false
-  const answer = (ns: string) => {
-    const view = served.find(candidate => candidate.ns === ns)
-    return Promise.resolve(view === undefined
-      ? {
-        ok: false as const,
-        error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } },
-      }
-      : { ok: true as const, value: view })
-  }
-  const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns))
-  const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns))
-  const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns))
-  return {
-    settings: {
-      describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }),
-      update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
-      replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
-      mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision),
-    },
-    update,
-    replace,
-    mutate,
-    publish(next) { served = next },
-  }
-}

+ 195 - 0
packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts

@@ -0,0 +1,195 @@
+/** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts'
+import { afterAll, describe, expect, it } from 'vitest'
+import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
+import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts'
+
+describe('webApp (the real web profile)', () => {
+  it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => {
+    expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
+    const names = webApp.rows.map(row => row.name)
+    expect(new Set(names).size).toBe(names.length)
+    const known = new Set([...names, ...Object.keys(getStaticModules())])
+    const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`))
+    expect(dangling).toEqual([])
+    expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows)
+  })
+
+  it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => {
+    const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name))
+    expect(immediate.has(MODULES_PACKAGE)).toBe(true)
+    expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true)
+    expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject)
+      .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'])
+    const names = webApp.rows.map(row => row.name)
+    expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general')
+    expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only
+    expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled
+    expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath
+  })
+})
+
+/** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */
+class Scratch {
+  readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-'))
+  readonly anchor = join(this.root, 'app', 'package.json')
+
+  constructor() {
+    mkdirSync(join(this.root, 'app'), { recursive: true })
+    writeFileSync(this.anchor, JSON.stringify({ name: 'app' }))
+  }
+
+  pkg(name: string, manifest: Record<string, unknown>, files: Record<string, string> = {}): void {
+    const dir = join(this.root, 'app', 'node_modules', name)
+    mkdirSync(dir, { recursive: true })
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest }))
+    for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content)
+  }
+
+  bundle(name: string, patch: string): void {
+    this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch })
+  }
+
+  web(name: string, client: Record<string, unknown> = {}): void {
+    this.pkg(name, { dsh: { client: { platform: 'web', ...client } } })
+  }
+
+  roster(bundles: readonly string[]): readonly string[] {
+    return bundleRoster(bundles, this.anchor).rows.map(row => row.name)
+  }
+}
+
+describe('bundleRoster on a scratch installation', () => {
+  const scratch = new Scratch()
+  afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) })
+
+  it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => {
+    scratch.web('@t/a', { inject: ['@t/b'], immediately: true })
+    scratch.web('@t/b')
+    scratch.web('@t/c')
+    scratch.web('plain')
+    scratch.pkg('@t/host', { dsh: {} })
+    scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } })
+    scratch.bundle('@t/base', `
+- insert:
+    - id: a
+      name: '@t/a'
+      config:
+        root: !!js process.cwd()
+    - id: host
+      name: '@t/host'
+      disabled: !!js process.platform === 'win32'
+    - id: node
+      name: '@t/node'
+    - id: c
+      name: '@t/c'
+    - id: sub
+      name: '@t/a/extra'
+    - id: builtin
+      name: 'cordis:group'
+`)
+    scratch.bundle('@t/web', `
+- id: c
+  disabled: true
+- insert:
+    - id: b
+      name: '@t/b'
+    - id: b-again
+      name: '@t/b'
+    - id: plain
+      name: plain
+    - id: off
+      name: '@t/off'
+      disabled: true
+`)
+    const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor)
+    expect(roster.rows).toEqual([
+      { name: '@t/a', inject: ['@t/b'], immediately: true },
+      { name: '@t/b', inject: [], immediately: false },
+      { name: 'plain', inject: [], immediately: false },
+    ])
+    expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c'])
+  })
+
+  it('descends into Loader groups and lets a disabled group disable every row beneath it', () => {
+    scratch.web('@t/grouped')
+    scratch.web('@t/grouped-off')
+    scratch.web('@t/nested')
+    scratch.bundle('@t/groups', `
+- insert:
+    - id: on
+      name: cordis:group
+      group: true
+      config:
+        - id: grouped
+          name: '@t/grouped'
+        - id: inner
+          name: cordis:group
+          group: true
+          config:
+            - id: nested
+              name: '@t/nested'
+    - id: off
+      name: cordis:group
+      group: true
+      disabled: true
+      config:
+        - id: grouped-off
+          name: '@t/grouped-off'
+`)
+    expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested'])
+  })
+
+  it('refuses a browser row whose disabled value is a !!js expression', () => {
+    scratch.web('@t/maybe')
+    scratch.bundle('@t/maybe-bundle', `
+- insert:
+    - id: maybe
+      name: '@t/maybe'
+      disabled: !!js process.platform === 'win32'
+`)
+    expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate')
+  })
+
+  it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => {
+    expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from')
+    scratch.pkg('@t/no-patch', { dsh: {} })
+    expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in')
+    scratch.bundle('@t/not-a-list', 'insert: []\n')
+    expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches')
+  })
+
+  it('fails loud on a patch that does not apply as written', () => {
+    scratch.bundle('@t/dangling', `
+- id: nowhere
+  disabled: true
+`)
+    expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found')
+  })
+
+  it('fails loud on an enabled row whose package does not resolve or names another package', () => {
+    scratch.bundle('@t/unresolved', `
+- insert:
+    - id: ghost
+      name: '@t/ghost'
+`)
+    expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved')
+    scratch.bundle('@t/builtin', `
+- insert:
+    - id: fs
+      name: fs
+`)
+    // A Node builtin name has no resolution paths at all.
+    expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin')
+    scratch.pkg('@t/alias', { name: '@t/real' })
+    scratch.bundle('@t/misnamed', `
+- insert:
+    - id: alias
+      name: '@t/alias'
+`)
+    expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias')
+  })
+})

+ 37 - 0
packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts

@@ -0,0 +1,37 @@
+/** Real bundle dependency closures activate their requested client plugins without extra roster rows. */
+import { FiberState } from '@deepseek-ai/cordis'
+import { describe, expect, vi } from 'vitest'
+import { createClientTest, webApp } from '../src/assembly/index.ts'
+
+const HMR = '@deepseek-ai/dsh-client-hmr'
+const MODULES = '@deepseek-ai/dsh-client-modules'
+const SESSIONS = '@deepseek-ai/dsh-api-session-controller'
+const FILE_UPLOAD = '@deepseek-ai/dsh-client-file-upload'
+const hmrRoster = webApp.closure([HMR])
+const sessionRoster = webApp.closure([SESSIONS])
+const hmrTest = createClientTest({ roster: hmrRoster }, { awaitConnected: false })
+const sessionTest = createClientTest({ roster: sessionRoster })
+
+describe('bundle dependency closures', () => {
+  hmrTest('activates HMR with its declared module provider and no Connection', async ({ start }) => {
+    expect(hmrRoster.rows.map(row => row.name)).toContain(MODULES)
+    const client = await start()
+    const entry = [...client.ctx.loader.entries()].find(row => row.options.name === HMR)
+    expect(entry?.fiber?.state).toBe(FiberState.ACTIVE)
+    expect(client.ctx.get('modules')).toBeDefined()
+    expect(client.ctx.get('connection')).toBeUndefined()
+  }, 60_000)
+
+  sessionTest('activates Session Controller with its declared file-upload provider', async ({ mock, start }) => {
+    expect(sessionRoster.rows.map(row => row.name)).toContain(FILE_UPLOAD)
+    const client = await start()
+    const entry = [...client.ctx.loader.entries()].find(row => row.options.name === SESSIONS)
+    expect(entry?.fiber?.state).toBe(FiberState.ACTIVE)
+    expect(client.ctx.get('fileUpload')).toBeDefined()
+    await vi.waitFor(() => {
+      expect(client.ctx.sessions.list.getSnapshot().phase).toBe('ready')
+      expect(mock.log.calls('session/list')).toHaveLength(1)
+      expect(mock.log.streams('session/control')).toHaveLength(1)
+    })
+  }, 60_000)
+})

+ 105 - 0
packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts

@@ -0,0 +1,105 @@
+/**
+ * In-process module arrival: dynamic `/client` imports resolve through the
+ * repository path aliases, provided rows replace them, the bootstrap row is the
+ * static namespace, and the resulting module system serves the vendored Loader
+ * without ever loading a bundle.
+ */
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import * as modulesClient from '@deepseek-ai/dsh-client-modules/client'
+import { parseBootManifest } from '@deepseek-ai/dsh-client-modules/client'
+import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client'
+import * as uiRenderer from '@deepseek-ai/dsh-client-ui-renderer/client'
+import * as typertRegistry from '@deepseek-ai/dsh-typert-registry/client'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { ClientRoster } from '../src/assembly/index.ts'
+import { MODULES_PACKAGE, createInProcessModules, loadPluginModules } from '../src/assembly/modules.ts'
+
+const RENDERER = '@deepseek-ai/dsh-client-ui-renderer'
+const TYPERT = '@deepseek-ai/dsh-typert-registry'
+const BRAND = '@deepseek-ai/dsh-client-ui-brand-official'
+const MISSING = '@deepseek-ai/dsh-client-does-not-exist'
+
+const row = (name: string, immediately = false) => ({ name, inject: [], immediately })
+
+function graph(names: readonly string[]): WebBootGraph {
+  return {
+    rev: 'local',
+    entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })),
+    batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }],
+  }
+}
+
+describe('loadPluginModules', () => {
+  it('imports real rows dynamically, takes provided rows from the plan, and pins the bootstrap row', async () => {
+    const provided = { apply: () => {} }
+    const roster = ClientRoster.of([row(TYPERT), row(MODULES_PACKAGE), row(BRAND), row(RENDERER)])
+    const modules = await loadPluginModules({ roster, provide: { [BRAND]: provided } })
+    expect([...modules.keys()]).toEqual([TYPERT, MODULES_PACKAGE, BRAND, RENDERER])
+    expect(modules.get(TYPERT)).toBe(typertRegistry)
+    expect(modules.get(RENDERER)).toBe(uiRenderer)
+    expect(modules.get(MODULES_PACKAGE)).toBe(modulesClient)
+    expect(modules.get(BRAND)).toBe(provided)
+  })
+
+  it('prefixes the package name when a /client import fails', async () => {
+    const roster = ClientRoster.of([row(MISSING)])
+    await expect(loadPluginModules({ roster })).rejects.toThrow(
+      new RegExp(`^client-test-runtime: cannot import ${MISSING}/client: .+`),
+    )
+  })
+
+  it('refuses a provided bootstrap row', async () => {
+    const roster = ClientRoster.of([row(MODULES_PACKAGE)])
+    await expect(loadPluginModules({ roster, provide: { [MODULES_PACKAGE]: { apply: () => {} } } }))
+      .rejects.toThrow(`${MODULES_PACKAGE} is the bootstrap module and cannot be provided`)
+  })
+})
+
+describe('createInProcessModules', () => {
+  it('serves import() and prefetch() from registered factories and never loads a bundle', async () => {
+    const roster = ClientRoster.of([row(MODULES_PACKAGE, true), row(TYPERT, true), row(RENDERER)])
+    const modules = await loadPluginModules({ roster })
+    const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules)
+
+    await expect(system.prefetch(TYPERT)).resolves.toBeUndefined()
+    await expect(system.prefetch(RENDERER)).resolves.toBeUndefined()
+    await expect(system.prefetch(MODULES_PACKAGE)).resolves.toBeUndefined()
+    expect(system.loadCache.has(TYPERT)).toBe(false)
+    expect(system.loadCache.has(RENDERER)).toBe(false)
+    await expect(system.import(RENDERER, '', {})).resolves.toBe(uiRenderer)
+    await expect(system.import(`${TYPERT}/client`, '', {})).resolves.toBe(typertRegistry)
+    await expect(system.import(MODULES_PACKAGE, '', {})).resolves.toBe(modulesClient)
+    expect(system.loadCache.get(TYPERT)?.exports).toBe(typertRegistry)
+    expect(system.loadCache.get(RENDERER)?.exports).toBe(uiRenderer)
+    await expect(system.import('@deepseek-ai/dsh-client-unknown', '', {})).rejects.toThrow('cannot resolve')
+  })
+
+  it('rejects loudly instead of fetching when a graph row has no loaded module', async () => {
+    const system = createInProcessModules(graph(['ghost']), new Map())
+    await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
+    await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)')
+  })
+
+  it('exposes the graph parsed by the production validator as its manifest', () => {
+    const raw = graph(['a', 'b'])
+    expect(createInProcessModules(raw, new Map()).manifest).toEqual(parseBootManifest(raw))
+    expect(() => createInProcessModules({ ...raw, batches: [] }, new Map())).toThrow('belongs to no initial-load batch')
+  })
+
+  it('boots the vendored Loader over the in-process table with every entry active', async () => {
+    const roster = ClientRoster.of([row(TYPERT, true), row(RENDERER, true), row(MODULES_PACKAGE, true)])
+    const modules = await loadPluginModules({ roster })
+    const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules)
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await ctx.plugin(Loader)
+    ctx.loader.internal = system as never
+    await Promise.all(roster.rows.map(r => ctx.loader.create({ name: r.name })))
+    await ctx.loader.await()
+    expect(ctx.get('typert')).toBeDefined()
+    expect(ctx.get('slots')).toBeDefined()
+    expect(ctx.get('modules')).toBe(system)
+    expect([...ctx.loader.entries()].every(entry => entry.fiber !== undefined)).toBe(true)
+  })
+})

+ 22 - 0
packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts

@@ -0,0 +1,22 @@
+/** assertPlan: provide keys must name roster rows. */
+import { describe, expect, it } from 'vitest'
+import { assertPlan } from '../src/assembly/roster.ts'
+import { ClientRoster } from '../src/assembly/roster.ts'
+
+const roster = ClientRoster.of([
+  { name: 'a', inject: [], immediately: false },
+  { name: 'b', inject: [], immediately: false },
+])
+const plugin = { apply: () => {} }
+
+describe('assertPlan', () => {
+  it('accepts a bare roster and provided roster rows', () => {
+    expect(() => { assertPlan({ roster }) }).not.toThrow()
+    expect(() => { assertPlan({ roster, provide: { a: plugin, b: plugin } }) }).not.toThrow()
+  })
+
+  it('rejects provide keys outside the roster and lists the roster', () => {
+    expect(() => { assertPlan({ roster, provide: { a: plugin, zz: plugin, yy: plugin } }) })
+      .toThrow('provide names rows outside the roster: zz, yy; roster: a, b')
+  })
+})

+ 93 - 0
packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts

@@ -0,0 +1,93 @@
+/** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */
+import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import type { ClientPluginModule } from '../src/assembly/index.ts'
+import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts'
+
+/** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
+
+type RemoteFace = Record<string, Record<string, (...args: unknown[]) => unknown>>
+
+async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
+  const items: unknown[] = []
+  for await (const item of source) items.push(item)
+  return items
+}
+
+describe('remoteNamespacesOf', () => {
+  it('collects remote.<ns> injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => {
+    const modules: ClientPluginModule[] = [
+      { apply() {}, inject: ['slots', 'remote.settings', 'remote'] },
+      { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } },
+      { apply() {} },
+    ]
+    const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream())
+    expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace'])
+  })
+})
+
+describe('remote proxies over a booted client', () => {
+  async function booted(configure: (mock: RemoteMock) => void = () => {}) {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    configure(mock)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote }
+  }
+
+  it('drops the api-remotes row and answers ctx.remote.<ns>.<method> with the mock value, logging positional args', async () => {
+    const { client, mock, remote } = await booted((m) => {
+      m.unary('session/rename', ok({ title: 'renamed', seq: 3 }))
+      m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
+    })
+    expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes')
+    await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } })
+    await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal))
+      .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } })
+    expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]])
+    expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]])
+    expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined()
+    expect((remote.session as unknown as Record<symbol, unknown>)[Symbol.toStringTag]).toBeUndefined()
+  }, 60_000)
+
+  it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const remote = (client.ctx as unknown as { remote: RemoteFace }).remote
+    await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({
+      ok: false,
+      error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string },
+    })
+    expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }])
+    await expect(client.dispose()).rejects.toThrow('session/search (unary)')
+  })
+
+  it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => {
+    const { mock, remote } = await booted((m) => {
+      m.unary('session/rename', () => Promise.reject(new Error('wire down')))
+      m.unary('session/cancel', () => new Promise(() => {}))
+    })
+    await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({
+      ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' },
+    })
+    const controller = new AbortController()
+    const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise<unknown>
+    controller.abort()
+    await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
+    expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed'])
+    expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending'])
+  })
+
+  it('opens registered streams and hands their items and failures through unchanged', async () => {
+    const { remote } = await booted((m) => {
+      m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }]))
+      m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) }))
+    })
+    const signal = new AbortController().signal
+    await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable<unknown>))
+      .resolves.toEqual([{ type: 'a' }, { type: 'b' }])
+    await expect(drain(remote.session!.control!({}) as AsyncIterable<unknown>)).rejects.toThrow('flap')
+  })
+})

+ 112 - 0
packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts

@@ -0,0 +1,112 @@
+/** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */
+import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client'
+import { describe, expect, it } from 'vitest'
+import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts'
+import { MODULES_PACKAGE } from '../src/assembly/modules.ts'
+
+const ROWS: readonly ClientRosterRow[] = [
+  { name: MODULES_PACKAGE, inject: [], immediately: true },
+  { name: '@x/a', inject: [], immediately: false },
+  { name: '@x/b', inject: ['@x/a'], immediately: true },
+]
+
+describe('graphFromRoster', () => {
+  it('synthesizes one application batch over every row with placeholder URLs', () => {
+    expect(graphFromRoster(ROWS)).toEqual({
+      rev: 'local',
+      entries: [
+        { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true },
+        { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' },
+        { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true },
+      ],
+      batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }],
+    })
+  })
+
+  it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => {
+    const manifest = parseBootManifest(graphFromRoster(ROWS))
+    expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately })))
+    expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js'])
+    const row = ROWS[1]!
+    expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"')
+    expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array')
+  })
+
+  it('imports seeded rows without reaching the bundle transport', async () => {
+    const loaded: string[] = []
+    const pendingQueue: ClientBundleRegistration[] = []
+    const target: ClientModuleLoaderTarget = {
+      mode: 'queue',
+      pendingQueue,
+      load: (registration) => { pendingQueue.push(registration) },
+      create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options),
+    }
+    const a = { apply() {} }
+    const b = { apply() {} }
+    const modules = target.create({
+      boot: graphFromRoster(ROWS),
+      staticModules: { '@x/a': a, '@x/b': b },
+      loadBundle: async (url) => { loaded.push(url) },
+    })
+    await expect(modules.import('@x/a')).resolves.toBe(a)
+    await expect(modules.import('@x/b')).resolves.toBe(b)
+    expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS)))
+    expect(loaded).toEqual([])
+  })
+})
+
+const A = { name: 'a', inject: [], immediately: true }
+const B = { name: 'b', inject: ['a'], immediately: false }
+const C = { name: 'c', inject: ['b'], immediately: false }
+
+describe('ClientRoster', () => {
+  it('freezes a copy of the rows in composition order', () => {
+    const rows = [A, B, C]
+    const roster = ClientRoster.of(rows)
+    expect(roster.rows).toEqual([A, B, C])
+    expect(roster.rows).not.toBe(rows)
+    expect(Object.isFrozen(roster.rows)).toBe(true)
+    rows.push(A)
+    expect(roster.rows).toHaveLength(3)
+  })
+
+  it('refuses duplicate names, listing each duplicate once', () => {
+    expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b')
+  })
+
+  it('pick keeps roster order regardless of the requested order', () => {
+    const roster = ClientRoster.of([A, B, C])
+    expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c'])
+    expect(roster.pick([]).rows).toEqual([])
+    expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true)
+  })
+
+  it('without drops the named rows', () => {
+    const roster = ClientRoster.of([A, B, C])
+    expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c'])
+    expect(roster.without(['a', 'b', 'c']).rows).toEqual([])
+  })
+
+  it('closure keeps the named rows and everything they inject, transitively, in roster order', () => {
+    const D = { name: 'd', inject: [], immediately: false }
+    const roster = ClientRoster.of([A, D, B, C])
+    expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c'])
+    expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b'])
+    expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a'])
+    expect(roster.closure([]).rows).toEqual([])
+    expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true)
+  })
+
+  it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => {
+    const roster = ClientRoster.of([A, B])
+    expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b')
+    expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b')
+    expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b')
+    expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster')
+  })
+
+  it('closure treats the platform seed modules as satisfied without a row', () => {
+    const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false }
+    expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded'])
+  })
+})

+ 110 - 0
packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts

@@ -0,0 +1,110 @@
+/** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */
+import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client'
+import { RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+
+/** The Gateway client and what it injects: the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry'])
+const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks }
+
+describe('TestClient (node environment)', () => {
+  it('boots the api roster, connects, and flushes without a DOM', async () => {
+    expect(typeof document).toBe('undefined')
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    await client.flush()
+  }, 60_000)
+
+  it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
+    const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
+      await dispose()
+      // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures.
+      return Promise.reject('teardown boom')
+    })
+    onTestFinished(async () => {
+      failure.mockRestore()
+      try { await dispose() } finally { await client.dispose() }
+    })
+    await expect(client.dispose()).rejects.toThrow('teardown boom')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+    await client.dispose() // idempotent after a failed teardown
+  })
+
+  it('rethrows a row that fails to apply, releases the globals, and lets the next boot take its turn', async () => {
+    const failing = { apply(): void { throw new Error('apply boom') } }
+    await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create()))
+      .rejects.toThrow(/apply boom|typert-registry/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+  })
+
+  it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => {
+    const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes'])
+    await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create()))
+      .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote.<ns> services are the tier\'s proxies')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber)
+    const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => {
+      await dispose()
+      throw new Error('teardown boom')
+    })
+    onTestFinished(async () => {
+      failure.mockRestore()
+      try { await dispose() } finally { await client.dispose() }
+    })
+    await mock.dispatch('nowhere/call', []).catch(() => undefined)
+    await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+  })
+
+  it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    const first = client.dispose()
+    const second = client.dispose()
+    await second
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    await first
+  })
+
+  it('refuses to mount without a DOM before installing the transport', async () => {
+    await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true }))
+      .rejects.toThrow('mount requires a DOM')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => {
+    await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create()))
+      .rejects.toThrow('provides no `connection` service')
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('skips readiness on request and restores a pre-existing transport on dispose', async () => {
+    const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) }
+    globals.__DSH_TRANSPORT__ = previous
+    onTestFinished(() => { delete globals.__DSH_TRANSPORT__ })
+    const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false })
+    expect(globals.__DSH_TRANSPORT__).not.toBe(previous)
+    expect(client.ctx.get('typert')).toBeDefined()
+    expect(() => client.connection).toThrow('provides no `connection` service')
+    await client.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBe(previous)
+  })
+})

+ 178 - 0
packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts

@@ -0,0 +1,178 @@
+// @vitest-environment jsdom
+/**
+ * TestClient over the web profile's roster read from its bundles: production
+ * `bootClient` over in-process modules, every Remote call answered by a
+ * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload,
+ * unload, and fail-loud teardown.
+ */
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts'
+import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts'
+
+/** The Gateway client and what it injects: the Typert registry and the Connection. */
+const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+/** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */
+const SIDEBAR_SETTINGS = 'sidebar.settings' as never
+const BRAND = '@deepseek-ai/dsh-client-ui-brand-official'
+const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown }
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
+
+async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise<TestClient> {
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
+  const client = await TestClient.start(plan, mock, options)
+  onTestFinished(() => client.dispose())
+  return client
+}
+
+describe('TestClient (jsdom)', () => {
+  it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => {
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: webApp }, mock, { mount: true })
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    expect(mock.log.streams('$events')).toHaveLength(1)
+    const container = client.container!
+    expect(document.body.contains(container)).toBe(true)
+    expect(container.childElementCount).toBeGreaterThan(0)
+    expect(globals.__DSH_TRANSPORT__).toBeDefined()
+    expect(globals.EventSource).toBeDefined()
+    expect(globals.ResizeObserver).toBeDefined()
+    await client.dispose()
+    expect(document.body.contains(container)).toBe(false)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+    expect(globals.ResizeObserver).toBeUndefined()
+    await client.dispose()
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('leaves a pre-existing global alone and removes only the shims it installed', async () => {
+    const existing = { existing: true }
+    vi.stubGlobal('ResizeObserver', existing)
+    onTestFinished(() => { vi.unstubAllGlobals() })
+    const client = await started({ roster: API_ROSTER })
+    expect(globals.ResizeObserver).toBe(existing)
+    expect(globals.EventSource).toBeDefined()
+    await client.dispose()
+    expect(globals.ResizeObserver).toBe(existing)
+    expect(globals.EventSource).toBeUndefined()
+  })
+
+  it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => {
+    const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 }))
+    const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 }))
+    const [a, b] = await Promise.all([
+      TestClient.start({ roster: API_ROSTER }, mockA),
+      TestClient.start({ roster: API_ROSTER }, mockB),
+    ])
+    onTestFinished(() => b.dispose())
+    const rename = async (client: TestClient): Promise<unknown> =>
+      (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise<unknown> } } }).remote.session.rename({ sessionId: 's', title: 't' })
+    await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
+    await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } })
+    expect(mockA.log.calls('session/rename')).toHaveLength(1)
+    expect(mockB.log.calls('session/rename')).toHaveLength(1)
+    // A rebuilt connection row reads its own mock even after another client installed the transport last.
+    await a.reload('@deepseek-ai/dsh-client-connection')
+    await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') })
+    await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } })
+    expect(mockA.log.calls('session/rename')).toHaveLength(2)
+    expect(mockB.log.calls('session/rename')).toHaveLength(1)
+    await a.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBeDefined()
+    expect(globals.EventSource).toBeDefined()
+    await b.dispose()
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+  })
+
+  it('boots the api subset without a mount and exposes the typed Remote', async () => {
+    const client = await started({ roster: API_ROSTER })
+    expect(client.container).toBeUndefined()
+    expect(client.ctx.remote.session).toBeDefined()
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+  })
+
+  it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => {
+    const before = document.body.childElementCount
+    await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true }))
+      .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`')
+    expect(document.body.childElementCount).toBe(before)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('creates no mount element when the roster cannot be loaded', async () => {
+    const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }])
+    const before = document.body.childElementCount
+    await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow()
+    expect(document.body.childElementCount).toBe(before)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+  })
+
+  it('mounts into a caller-supplied element and leaves it in place on dispose', async () => {
+    const host = document.createElement('main')
+    document.body.appendChild(host)
+    const mock = RemoteMock.create().load(remoteDefaultResponses)
+    const client = await TestClient.start({ roster: webApp }, mock, { mount: host })
+    expect(client.container).toBe(host)
+    expect(host.childElementCount).toBeGreaterThan(0)
+    await client.dispose()
+    expect(document.body.contains(host)).toBe(true)
+    host.remove()
+  })
+
+  it('boots with a provided row in place of the real plugin', async () => {
+    const apply = vi.fn()
+    const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true })
+    expect(apply).toHaveBeenCalledOnce()
+    expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true)
+  })
+
+  it('reload rebuilds the declaring entry; unload collapses it', async () => {
+    const client = await started({ roster: webApp }, { mount: true })
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
+    await client.reload(SIDEBAR)
+    await client.flush()
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1)
+    await client.unload(SIDEBAR)
+    await client.flush()
+    expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0)
+    await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`)
+  })
+
+  it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => {
+    const mock = RemoteMock.create()
+    const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose())
+    await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/)
+  })
+
+  it('waits through a carrier flap for the connection to become ready', async () => {
+    let opens = 0
+    const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => {
+      opens += 1
+      const first = opens === 1
+      setTimeout(() => {
+        if (first) stream.fail(new Error('flap'))
+        // Branded on the Gateway side; the test mints a plain string.
+        else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } })
+      }, 20)
+    })
+    const client = await TestClient.start({ roster: API_ROSTER }, mock)
+    onTestFinished(() => client.dispose())
+    expect(client.connection.state.getSnapshot()).toBe('connected')
+    expect(mock.log.streams('$events')).toHaveLength(2)
+  })
+
+  it('reports the log when the connection never becomes ready', async () => {
+    // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock
+    // logs as unmatched, while $events never sends ready.
+    const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller'])
+    const mock = RemoteMock.create().stream('$events', openStream([]))
+    await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 }))
+      .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/)
+    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect(globals.EventSource).toBeUndefined()
+  })
+})

+ 64 - 0
packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts

@@ -0,0 +1,64 @@
+/** Native fixture cleanup remains active after assertion failure and without a started client. */
+import { afterAll, describe, expect } from 'vitest'
+import { ok } from '@deepseek-ai/dsh-remote-mock'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createClientTest, webApp, type TestClient } from '../src/assembly/index.ts'
+
+const test = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
+const clients: TestClient[] = []
+const expired: (() => Promise<TestClient>)[] = []
+
+afterAll(async () => {
+  expect(clients).toHaveLength(2)
+  for (const client of clients) {
+    expect(client.ctx.get('loader')).toBeUndefined()
+    expect(client.mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled'])
+  }
+  for (const start of expired) {
+    await expect(start()).rejects.toThrow('after its test fixture closed')
+  }
+  expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+})
+
+describe('createClientTest', () => {
+  test('allows response configuration before boot and shares concurrent starts', async ({ mock, remote, start }) => {
+    expired.push(start)
+    expect(remote).toBe(mock.remote)
+    remote.session.rename.mockResolvedValueOnce(ok({ title: 'test title', seq: 1 }))
+    const first = start()
+    const second = start()
+    expect(second).toBe(first)
+    const client = await first
+    clients.push(client)
+    expect(client.mock).toBe(mock)
+    expect(mock.log.streams('$events')).toHaveLength(1)
+    await expect(client.ctx.remote.session.rename({ sessionId: 's' as SessionId, title: 't' }))
+      .resolves.toEqual(ok({ title: 'test title', seq: 1 }))
+    expect(remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 's', title: 't' })
+  })
+
+  test.fails('disposes its client even when the test assertion fails', async ({ start }) => {
+    expired.push(start)
+    clients.push(await start())
+    expect.fail('fixture cleanup negative control')
+  })
+
+  test('does not boot an unused start fixture', ({ mock, start }) => {
+    expired.push(start)
+    expect(mock.log.streams()).toEqual([])
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+  })
+
+  test('owns a fresh mock when only the mock fixture is requested', ({ mock }) => {
+    expect(mock.log.calls()).toEqual([])
+    expect(mock.log.streams()).toEqual([])
+    expect(mock.endpoints()).not.toContain('session/rename')
+  })
+})
+
+const missingConnection = createClientTest({ roster: webApp.pick(['@deepseek-ai/dsh-typert-registry']) })
+missingConnection('keeps a rejected startup with its caller and releases its globals', async ({ start }) => {
+  expired.push(start)
+  await expect(start()).rejects.toThrow('provides no `connection` service')
+  expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
+})

+ 0 - 37
packages/test-support/client-runtime/tests/remote.client.spec.ts

@@ -6,7 +6,6 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it } from 'vitest'
 import { TestRemote } from '../src/remote.ts'
-import { scriptedSettingsRemote } from '../src/settings-remote.ts'
 
 describe('TestRemote', () => {
   it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
@@ -60,39 +59,3 @@ describe('TestRemote', () => {
     await ctx.fiber.dispose()
   })
 })
-
-describe('scriptedSettingsRemote', () => {
-  it('serves, writes, and replaces its scripted namespace list', async () => {
-    const first = { ns: 'first', revision: 1 }
-    const second = { ns: 'second', revision: 2 }
-    const remote = scriptedSettingsRemote([first])
-
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: true, hasDocument: false, namespaces: [first] },
-    })
-    await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first })
-    await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({
-      ok: false,
-      error: { code: 'settings/rejected', details: { ns: 'missing' } },
-    })
-    await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first })
-    expect(remote.update).toHaveBeenCalledWith('first', {}, undefined)
-    expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined)
-    expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined)
-
-    remote.publish([second])
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: true, hasDocument: false, namespaces: [second] },
-    })
-  })
-
-  it('reports explicit deployment facts', async () => {
-    const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true })
-    await expect(remote.settings.describe()).resolves.toEqual({
-      ok: true,
-      value: { writable: false, hasDocument: true, namespaces: [] },
-    })
-  })
-})

+ 26 - 1
packages/test-support/client-runtime/tsconfig.json

@@ -2,7 +2,8 @@
   "extends": "../../../tsconfig.base.client.json",
   "compilerOptions": {
     "rootDir": "src",
-    "outDir": "lib/types"
+    "outDir": "lib/types",
+    "types": ["client-build-environment", "node"]
   },
   "include": [
     "src"
@@ -11,6 +12,15 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../../../vendor/include"
+    },
+    {
+      "path": "../../api/gateway/tsconfig.client.json"
+    },
     {
       "path": "../../api/session-controller/tsconfig.client.json"
     },
@@ -55,6 +65,21 @@
     },
     {
       "path": "../../subagent/subagent"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../client/modules"
+    },
+    {
+      "path": "../../client/web"
+    },
+    {
+      "path": "../remote-mock"
+    },
+    {
+      "path": "../../client/hmr"
     }
   ]
 }

+ 48 - 0
pnpm-lock.yaml

@@ -3261,6 +3261,9 @@ importers:
       '@deepseek-ai/dsh-permission-presets':
         specifier: workspace:^
         version: link:../../interaction/permission-presets
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -3655,6 +3658,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-util-values':
         specifier: workspace:^
         version: link:../../util/values
@@ -9989,6 +9995,9 @@ importers:
       '@testing-library/react':
         specifier: ^16.3.2
         version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      js-yaml:
+        specifier: ^4.2.0
+        version: 4.3.1
       vitest:
         specifier: ^4.1.8
         version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -9996,6 +10005,18 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-api-gateway':
+        specifier: workspace:^
+        version: link:../../api/gateway
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
@@ -10005,9 +10026,21 @@ importers:
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
+      '@deepseek-ai/dsh-base':
+        specifier: workspace:^
+        version: link:../../bundle/base
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-hmr':
+        specifier: workspace:^
+        version: link:../../client/hmr
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../../client/locale
+      '@deepseek-ai/dsh-client-modules':
+        specifier: workspace:^
+        version: link:../../client/modules
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
@@ -10032,15 +10065,30 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../../client/ui-slots
+      '@deepseek-ai/dsh-client-web':
+        specifier: workspace:^
+        version: link:../../client/web
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../remote-mock
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../../subagent/subagent
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
+      '@deepseek-ai/dsh-web-app':
+        specifier: workspace:^
+        version: link:../../bundle/web-app
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31

+ 3 - 0
tsconfig.base.json

@@ -249,6 +249,9 @@
       // util/ folders are role-named without the util- prefix their npm names carry,
       // so these aliases stay hand-written like dsh-util-crypto above.
       "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"],
+      // session-log-export lives under session-query/, so the generated aliases map only its
+      // bare name; the browser half needs this explicit subpath for source-mode dynamic imports.
+      "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"],
       // BEGIN generated package aliases — pnpm run gen-tsconfig-paths
       "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"],
       "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"],

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov