Quellcode durchsuchen

fix(client-modules): resolve the owning Loader instance

imccyu vor 3 Wochen
Ursprung
Commit
c1504a2aa6

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.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 .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
-2026-09-06-client-assembly-test-line.md: f3b43ec9a4b08ac7b3a9fc802136ae916da9eb65
-2026-09-06-client-assembly-test-line.zh.md: 95445c584e7484080610da2347a968f5202f14f8
+2026-09-06-client-assembly-test-line.md: 9778ed7fea930dadeae7d786d750f53a0a387f23
+2026-09-06-client-assembly-test-line.zh.md: 944008e493a63de278543ae5c941af95943e0f41

+ 4 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md

@@ -18,7 +18,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de
 
 **The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row.
 
-**The production boot path runs unchanged.** `TestClient.start(plan, mock)` loads each roster row's `/client` module, binds the Connection row to `installConnection(ctx, { transport: { rpc: mock.rpc } })`, registers the resulting factories through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` reference-counts inert stand-ins only where the global is absent. Module systems, Connection carriers, Remote mocks, reloads, and disposal are instance-owned, so clients start concurrently and do not coordinate through page globals.
+**The production boot path runs with one Connection substitution.** `TestClient.start(plan, mock)` loads each roster row's `/client` module, replaces the Connection row's page-global adapter with `installConnection(ctx, { transport: { rpc: mock.rpc } })`, registers the resulting factories through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` reference-counts inert stand-ins only where the global is absent. Module systems, Connection carriers, Remote mocks, reloads, and disposal are instance-owned, so clients start concurrently and do not coordinate through page globals.
 
 The modules plugin reads the module system from its own `ctx.loader.internal` when it activates. Reloading the bootstrap row republishes that same instance without invalidating its bootstrap code, while another client's Loader and `ctx.modules` remain independent.
 
@@ -52,6 +52,8 @@ The modules plugin reads the module system from its own `ctx.loader.internal` wh
 
 **A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; passing it to the production Connection installer keeps retry, folding, and stream semantics real.
 
+**A worker-level boot turn around a page-global Connection carrier.** Rejected: clients would share mutable process state, and every start or Connection reload would need serialization to install the acting client's carrier. Instance-local Connection installation lets independent plugin trees start and reload concurrently.
+
 **A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime.
 
 **Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face.
@@ -89,4 +91,4 @@ Product facts the tier surfaced and leaves as they are:
 
 ## Testing
 
-`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Eight converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.
+`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, parallel client startup, instance-local module and Connection reload, and `TestClient` under jsdom and plain Node. Seven converted specs use the whole-client tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/client/ui-message-feedback/tests/`, object-layer specs pass the `messageFeedback` RemoteMock namespace directly. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.<ns>` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state.

+ 4 - 2
.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md

@@ -18,7 +18,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个
 
 **roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。
 
-**生产启动路径原样运行。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,把 Connection 行绑定到 `installConnection(ctx, { transport: { rpc: mock.rpc } })`,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。
+**生产启动路径只替换 Connection 输入适配。** `TestClient.start(plan, mock)` 加载每个 roster 行的 `/client` 模块,用 `installConnection(ctx, { transport: { rpc: mock.rpc } })` 替换 Connection 行的页面全局适配,经生产模块 facade 的 `pendingQueue` 登记这些工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只对全局缺失的惰性替身做引用计数。模块系统、Connection 载体、Remote mock、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。
 
 modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。重载 bootstrap 行会重新发布同一个实例而不让 bootstrap 代码失效,另一个客户端的 Loader 与 `ctx.modules` 不受影响。
 
@@ -52,6 +52,8 @@ modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。
 
 **用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。
 
+**用 worker 级启动轮次包住页面全局 Connection 载体。** 否决:客户端会共享可变进程状态,每次启动或 Connection 重载都必须串行安装当事客户端的载体。实例局部的 Connection 安装让独立插件树可以并行启动和重载。
+
 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
 
 **代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。
@@ -89,4 +91,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远
 
 ## 测试
 
-`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。八条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。
+`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理、并行客户端启动、实例局部的模块与 Connection 重载,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用整机客户端档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/client/ui-message-feedback/tests/` 下,对象层 spec 直接传入 RemoteMock 的 `messageFeedback` 命名空间。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.<ns>` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。

+ 2 - 2
packages/client/connection/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/client/connection/README.md
-README.md: e86d73ba24ee52ceea618ae79fd43445ed085083
-README.zh.md: daecaee091bf98a665eddb91211512bc886cea7b
+README.md: 0010f93a7fa06055756b7f07467eb01a685cfe39
+README.zh.md: 8db4ef1f558be022af54c3b7c617124b8649e9d9

+ 1 - 1
packages/client/connection/README.md

@@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
 <a id="use-this-package"></a>
 ## Use this package
 
-The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`; non-page compositions call the same installer with an explicit carrier. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
+The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
 
 The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package).
 

+ 1 - 1
packages/client/connection/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`;非页面组合以显式载体调用同一个安装函数。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
+浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
 
 浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。
 

+ 1 - 1
packages/client/connection/src/client/index.ts

@@ -121,7 +121,7 @@ export interface ConnectionLocation {
 export interface ConnectionInstallOptions {
   /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
   readonly transport?: ClientTransportHooks
-  /** Resolved reconnect timing; omitted fields use controller defaults. */
+  /** Reconnect timing overrides; omitted fields use controller defaults. */
   readonly recovery?: ConnectionRecoveryConfig
   /** Page location; omit for a non-browser composition. */
   readonly location?: ConnectionLocation

+ 3 - 2
packages/client/modules/src/client/index.ts

@@ -11,6 +11,7 @@
  * @module @deepseek-ai/dsh-client-modules/client
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
 import { ClientModuleSystem } from './system.ts'
 import { parseBootManifest } from './manifest.ts'
 import type {
@@ -55,8 +56,8 @@ export const inject = ['loader']
  * @param ctx - client root context.
  */
 export function apply(ctx: Context): void {
-  const loader = ctx.get('loader') as { readonly internal?: unknown } | undefined
-  const modules = loader?.internal as ClientModuleLoader | undefined
+  const loader: Loader = ctx.loader
+  const modules = loader.internal as unknown as ClientModuleLoader | undefined
   if (modules?.version !== 'client') {
     throw new Error('client-modules: the Loader has no client module system')
   }

+ 6 - 2
packages/client/modules/tests/loader.client.spec.ts

@@ -123,8 +123,12 @@ function bench(
 }
 
 describe('Cordis plugin face', () => {
-  it('rejects activation before the shell installs a client Loader internal', () => {
-    expect(() => { apply(new Context()) }).toThrow('the Loader has no client module system')
+  it('rejects a Loader whose internal is absent or not a client module system', () => {
+    for (const internal of [undefined, { version: 'worker' }]) {
+      const ctx = new Context()
+      ctx.provide('loader', { internal } as never)
+      expect(() => { apply(ctx) }).toThrow('the Loader has no client module system')
+    }
   })
 })
 

+ 5 - 1
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -162,7 +162,11 @@ export class TestClient {
    * Load the roster's modules, bind this client's mock to its Connection row,
    * hold 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
+   * bound row replaces only the page-global input adapter: both paths call
+   * `installConnection`, while this path supplies the mock carrier, uses
+   * default recovery timings, and captures the current page location once for
+   * later reloads. A caller-provided Connection row remains unchanged and owns
+   * its readiness behavior. 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

+ 2 - 6
packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts

@@ -42,7 +42,6 @@ describe('TestClient (node environment)', () => {
     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())
@@ -53,7 +52,6 @@ describe('TestClient (node environment)', () => {
     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 () => {
@@ -84,16 +82,14 @@ describe('TestClient (node environment)', () => {
     await first
   })
 
-  it('refuses to mount without a DOM without touching the page transport', async () => {
+  it('refuses to mount without a DOM', 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 without touching the page transport when the roster cannot provide a connection', async () => {
+  it('fails loud 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 leaves a pre-existing page transport untouched', async () => {

+ 13 - 8
packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts

@@ -69,6 +69,7 @@ describe('TestClient (jsdom)', () => {
       TestClient.start({ roster: API_ROSTER }, mockA),
       TestClient.start({ roster: API_ROSTER }, mockB),
     ])
+    onTestFinished(() => a.dispose())
     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' })
@@ -92,11 +93,9 @@ describe('TestClient (jsdom)', () => {
   it('boots two plugin trees concurrently instead of serializing the worker', async () => {
     let arrivals = 0
     const gate = Promise.withResolvers<undefined>()
-    const overlap = Promise.withResolvers<undefined>()
     const probe: ClientPluginModule = {
       async apply() {
         arrivals += 1
-        if (arrivals === 2) overlap.resolve(undefined)
         await gate.promise
       },
     }
@@ -106,17 +105,24 @@ describe('TestClient (jsdom)', () => {
     ])
     const first = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false })
     const second = TestClient.start({ roster, provide: { [PARALLEL_PROBE]: probe } }, RemoteMock.create(), { awaitConnected: false })
+    const starts = Promise.allSettled([first, second])
     let overlapFailure: unknown
     try {
       await vi.waitFor(() => { expect(arrivals).toBe(2) }, { timeout: 5_000 })
-      await overlap.promise
     } catch (error) {
       overlapFailure = error
     } finally {
       gate.resolve(undefined)
     }
-    const clients = await Promise.all([first, second])
-    for (const client of clients) onTestFinished(() => client.dispose())
+    const results = await starts
+    for (const result of results) {
+      if (result.status === 'fulfilled') onTestFinished(() => result.value.dispose())
+    }
+    const rejected = results.find(result => result.status === 'rejected')
+    if (rejected?.status === 'rejected') {
+      const reason: unknown = rejected.reason
+      throw reason
+    }
     if (overlapFailure !== undefined) throw overlapFailure
   }, COLD_BOOT_TIMEOUT_MS)
 
@@ -129,6 +135,8 @@ describe('TestClient (jsdom)', () => {
     const modulesA = a.ctx.modules
     const modulesB = b.ctx.modules
     expect(modulesA).not.toBe(modulesB)
+    expect(modulesA).toBe(a.ctx.loader.internal)
+    expect(modulesB).toBe(b.ctx.loader.internal)
     await a.reload(MODULES)
     expect(a.ctx.modules).toBe(modulesA)
     expect(b.ctx.modules).toBe(modulesB)
@@ -146,7 +154,6 @@ describe('TestClient (jsdom)', () => {
     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('__DSH_TRANSPORT__' in globalThis).toBe(false)
   })
 
   it('creates no mount element when the roster cannot be loaded', async () => {
@@ -154,7 +161,6 @@ describe('TestClient (jsdom)', () => {
     const before = document.body.childElementCount
     await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow()
     expect(document.body.childElementCount).toBe(before)
-    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
   })
 
   it('mounts into a caller-supplied element and leaves it in place on dispose', async () => {
@@ -235,7 +241,6 @@ describe('TestClient (jsdom)', () => {
     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('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeUndefined()
   })
 })