Переглянути джерело

fix(client-test-runtime): isolate client instances

imccyu 3 тижнів тому
батько
коміт
a475a65bed
29 змінених файлів з 225 додано та 164 видалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  7. 2 2
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml
  8. 6 4
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md
  9. 6 4
      .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md
  10. 2 2
      packages/client/connection/README.i18n.yaml
  11. 1 1
      packages/client/connection/README.md
  12. 1 1
      packages/client/connection/README.zh.md
  13. 42 10
      packages/client/connection/src/client/index.ts
  14. 2 2
      packages/client/modules/README.i18n.yaml
  15. 1 1
      packages/client/modules/README.md
  16. 1 1
      packages/client/modules/README.zh.md
  17. 13 11
      packages/client/modules/src/client/index.ts
  18. 14 6
      packages/client/modules/tests/loader.client.spec.ts
  19. 2 2
      packages/test-support/client-runtime/README.i18n.yaml
  20. 1 1
      packages/test-support/client-runtime/README.md
  21. 1 1
      packages/test-support/client-runtime/README.zh.md
  22. 46 76
      packages/test-support/client-runtime/src/assembly/test-client.ts
  23. 6 6
      packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts
  24. 58 12
      packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts
  25. 2 2
      packages/test-support/remote-mock/README.i18n.yaml
  26. 3 3
      packages/test-support/remote-mock/README.md
  27. 3 3
      packages/test-support/remote-mock/README.zh.md
  28. 1 1
      packages/test-support/remote-mock/src/index.ts
  29. 1 2
      packages/test-support/remote-mock/src/remote-mock.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b
-2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997
+2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108
+2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -58,12 +58,12 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
 
-**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs the system, memoizes its own exports, retains the instance in its module closure, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
+**Phase one — the module face.** The injected HTML installs `window.__ModuleLoader__` in queue mode, starts preloading every application combo URL, executes every bootstrap combo URL as a blocking classic script, assigns `window.__DSH_BOOT__`, and then starts the Vite main module. The kernel calls the facade's `create()` with the raw graph and shell seeds. The facade removes and materializes the modules registration with a bootstrap `require` that rejects every external, then calls its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns the system, memoizes its own exports, and switches the same facade to live registration. The kernel then prefetches every `immediately` row in parallel. Rows in the same application combo share its execution; separate combos load independently when an immediate row, a requested dependency, or ordinary entry import reaches them. A prefetch failure is swallowed here because phase two's import retries and owns the loud failure. `immediately` remains a registration barrier, not a package identity.
 
 **Phase two — the plugin face.**
 
 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
-2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` provides the closed-over system as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
+2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry.
 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting.
 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -58,12 +58,12 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
-**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造系统、记忆化自身 exports、在模块闭包中保留该实例,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
+**第一阶段——模块面。**注入的 HTML 以 queue 模式安装 `window.__ModuleLoader__`,开始预加载所有 application combo URL,以阻塞式 classic script 依次执行所有 bootstrap combo URL,赋值 `window.__DSH_BOOT__`,然后启动 Vite 主模块。内核把原始图和外壳 seed 传给 facade 的 `create()`。Facade 移除 modules registration,用拒绝全部 external 的 bootstrap `require` 将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回系统、记忆化自身 exports,并把同一 facade 切换到 live registration。随后内核并行预取每个 `immediately` row;同一 application combo 中的 row 共享一次执行,不同 combo 会在 immediate row、被请求依赖或普通 entry import 首次触及时独立加载。预取失败在这里被吞下,因为第二阶段 import 会重试并拥有那次大声失败。`immediately` 仍是 registration barrier,不是包身份。
 
 **第二阶段——插件面。**
 
 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
-2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 把闭包中的系统提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
+2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。
 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.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/architecture/2026-08-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: 1d67c778b6a06849324dd6095a98d57dc41b94f9
-2026-08-15-client-shells-and-dynamic-packages.zh.md: db1e4e7e7b319c283ae39a94535d88d4dc71d60a
+2026-08-15-client-shells-and-dynamic-packages.md: 89f89784512b86b70ee1b9460850e6f2f951a082
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 5512d9262e979a94a65c25a6c1271b030e23ec70

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -51,7 +51,7 @@ The modules Node half injects the startup protocol into the served HTML in this
 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 5. Execute the Vite main module.
 
-The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs `ClientModuleSystem`, caches its own exports as the modules row, retains the system in a module closure, and switches the same facade to live mode. The modules client face consequently has a zero-external bootstrap requirement.
+The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns `ClientModuleSystem`, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's `internal`, and the modules plugin reads it there when it provides `ctx.modules`. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity.
 
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -51,7 +51,7 @@ Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 5. 执行 Vite 主模块。
 
-Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造 `ClientModuleSystem`、把自身 exports 缓存为 modules row、在模块闭包中保留该系统,并把同一 facade 切换到 live 模式。因此 modules client face 必须满足零 external 的自举要求。
+Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 `internal`,modules 插件从这里读取并提供 `ctx.modules`。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份。
 
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 

+ 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: 0d7ed86e4f0c8898441737c8d6a4c631586268a2
-2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5
+2026-09-06-client-assembly-test-line.md: 36f7e2ab11248628dca7aff53cae4a9c9f84e441
+2026-09-06-client-assembly-test-line.zh.md: 0ef64a97e25b062a858ddbd1796f86e59591cd51

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

@@ -18,7 +18,9 @@ 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)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory 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` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` 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 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.
 
 **`remote.<ns>` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.<ns>` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.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. Cordis resolves `ctx.remote.<ns>` to the service `remote.<ns>`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them.
 
@@ -32,7 +34,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de
 
 ## Product exports added for the tier
 
-- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional.
+- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. `installConnection(ctx, options)` installs the same production service from instance-local transport, recovery, and location inputs.
 - `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed.
 - `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier.
 - `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them.
@@ -48,7 +50,7 @@ A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the de
 
 **A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter.
 
-**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real.
+**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 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.
 
@@ -87,4 +89,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, and `TestClient` under jsdom and plain Node. Seven 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/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 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/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.

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

@@ -18,7 +18,9 @@ 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)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。
+**生产启动路径原样运行。** `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、重载和销毁都归实例所有,因此客户端可以并行启动,不通过页面全局变量协调。
+
+modules 插件激活时从自己的 `ctx.loader.internal` 读取模块系统。重载 bootstrap 行会重新发布同一个实例而不让 bootstrap 代码失效,另一个客户端的 Loader 与 `ctx.modules` 不受影响。
 
 **`remote.<ns>` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.<ns>`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote.<ns>.<method>(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `<ns>/<method>`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.<ns>` 解析到服务 `remote.<ns>`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。
 
@@ -32,7 +34,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个
 
 ## 为本档新增的产品导出
 
-- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。
+- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。`installConnection(ctx, options)` 从实例局部的 transport、recovery 与 location 输入安装同一个生产服务。
 - `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。
 - `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。
 - `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。
@@ -48,7 +50,7 @@ API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个
 
 **自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。
 
-**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。
+**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;把它传给生产 Connection 安装函数会让重试、折叠与流语义都保持真实。
 
 **第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。
 
@@ -87,4 +89,4 @@ spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远
 
 ## 测试
 
-`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`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/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: 606f27f37a80c11c105a1431858efae7b8e9d86a
-README.zh.md: d7885ad873fb1d7d82c7a613c8f66bc7356b2542
+README.md: e86d73ba24ee52ceea618ae79fd43445ed085083
+README.zh.md: daecaee091bf98a665eddb91211512bc886cea7b

+ 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 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)`; 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 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。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)。
 

+ 42 - 10
packages/client/connection/src/client/index.ts

@@ -72,10 +72,10 @@ export interface ConnectionStateSource {
 export const inject: string[] = []
 
 /**
- * Carrier override installed on the page global before plugin boot. The served
- * web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
- * different physical transport (the worker preview's postMessage tunnel)
- * provides both halves here instead of forking this plugin.
+ * Physical carrier selected when the Connection service is installed. The
+ * served web app omits it and gets HTTP + WebSocket; a shell that owns a
+ * different transport (the worker preview's postMessage tunnel) provides both
+ * halves instead of forking this plugin.
  */
 export interface ClientTransportHooks {
   /**
@@ -111,6 +111,22 @@ interface ClientTransportGlobal {
   __DSH_CONNECTION_RECOVERY__?: unknown
 }
 
+/** Browser location fields used to select fixture mode and loopback authority. */
+export interface ConnectionLocation {
+  readonly hostname: string
+  readonly search: string
+}
+
+/** Instance-local inputs for installing a Connection service. */
+export interface ConnectionInstallOptions {
+  /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
+  readonly transport?: ClientTransportHooks
+  /** Resolved reconnect timing; omitted fields use controller defaults. */
+  readonly recovery?: ConnectionRecoveryConfig
+  /** Page location; omit for a non-browser composition. */
+  readonly location?: ConnectionLocation
+}
+
 /**
  * The ctx.connection service API. API Gateway supplies generation readiness
  * and reset callbacks; Connection stays independent of downstream domain state.
@@ -182,15 +198,16 @@ function watchBrowserNetwork(controller: ConnectionController): () => void {
 }
 
 /**
- * Client plugin body: pick physical carriers by page mode and provide ctx.connection.
- * @param ctx - client cordis context.
+ * Install one Context-owned Connection service from explicit composition inputs.
+ * @param ctx - client Cordis context.
+ * @param options - physical carrier, reconnect timing, and page location.
  */
-export function apply(ctx: Context): void {
-  const pageLocation = typeof location === 'undefined' ? undefined : location
+export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void {
+  const pageLocation = options.location
   const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
-  const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
-  const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__)
+  const transport = options.transport
+  const recovery = options.recovery ?? {}
   const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
@@ -294,3 +311,18 @@ export function apply(ctx: Context): void {
   }
   ctx.provide('connection', handle)
 }
+
+/**
+ * Client plugin body: read the page composition and install its Connection service.
+ * @param ctx - client Cordis context.
+ */
+export function apply(ctx: Context): void {
+  const globals = globalThis as ClientTransportGlobal
+  const pageLocation = typeof location === 'undefined' ? undefined : location
+  const transport = globals.__DSH_TRANSPORT__
+  installConnection(ctx, {
+    ...(transport === undefined ? {} : { transport }),
+    recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__),
+    ...(pageLocation === undefined ? {} : { location: pageLocation }),
+  })
+}

+ 2 - 2
packages/client/modules/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/modules/README.md
-README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f
-README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c
+README.md: c83796fc8d6c364b9fad6d44e25316e2c2f7e7af
+README.zh.md: 9ffb68ebc07b5970edc45a54c4cfde7cdf4ef7d8

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

@@ -73,7 +73,7 @@ The Node half snapshots each client bundle and available source map before publi
 
 The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server.
 
-The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode.
+The host contributes structured index rows that inject, into `<head>`: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. The shell installs that returned system as its Loader's `internal`; the modules plugin publishes that instance as `ctx.modules`, so separate Cordis trees never select an instance through module-global state.
 
 ### Source map
 

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

@@ -73,7 +73,7 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map
 
 bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。
 
-宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。
+宿主贡献结构化 index 行,并向 `<head>` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。外壳把返回的系统装成自身 Loader 的 `internal`;modules 插件将该实例发布为 `ctx.modules`,因此不同 Cordis 树不会通过模块级全局状态选择实例。
 
 ### 源码索引
 

+ 13 - 11
packages/client/modules/src/client/index.ts

@@ -6,15 +6,15 @@
  * parser-preloads this ordinary client bundle into the pending registration
  * queue. The HTML-installed loader facade materializes this bundle and calls
  * its bootstrap export, which constructs the system and retains the same
- * exports for this package's graph row. The plugin face only enrolls that
- * pre-existing instance by providing it as `ctx.modules`.
+ * exports for this package's graph row. The plugin face enrolls the module
+ * system attached to its own Loader as `ctx.modules`.
  * @module @deepseek-ai/dsh-client-modules/client
  */
 import type { Context } from '@deepseek-ai/cordis'
 import { ClientModuleSystem } from './system.ts'
 import { parseBootManifest } from './manifest.ts'
 import type {
-  ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoaderTarget,
+  ClientBootstrapModule, ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget,
 } from './manifest.ts'
 
 export { ClientModuleSystem }
@@ -26,37 +26,39 @@ export type {
   WebBootEntry, WebBootGraph,
 } from './manifest.ts'
 
-let moduleSystem: ClientModuleSystem | undefined
-
 /**
  * Build the live module system from the HTML facade's materialized modules bundle.
  * @param target - Stable registration facade whose pending queue becomes the live sink.
  * @param bootstrapModule - This bundle's id and already-materialized exports.
  * @param options - Raw boot graph, platform seed, and optional bundle transport.
- * @returns The created module system, also published for this package's Cordis plugin face.
+ * @returns The created module system.
  */
 export function createClientModuleSystem(
   target: ClientModuleLoaderTarget,
   bootstrapModule: ClientBootstrapModule,
   options: ClientModuleCreateOptions,
 ): ClientModuleSystem {
-  moduleSystem = new ClientModuleSystem({
+  return new ClientModuleSystem({
     manifest: parseBootManifest(options.boot),
     staticModules: options.staticModules,
     registrationTarget: target,
     bootstrapModule,
     ...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }),
   })
-  return moduleSystem
 }
 
+/** Required service: the Loader whose internal module system this plugin publishes. */
+export const inject = ['loader']
+
 /**
  * Enroll the kernel-built module system as `ctx.modules`.
  * @param ctx - client root context.
  */
 export function apply(ctx: Context): void {
-  if (moduleSystem === undefined) {
-    throw new Error('client-modules: createClientModuleSystem must run before plugin boot')
+  const loader = ctx.get('loader') as { readonly internal?: unknown } | undefined
+  const modules = loader?.internal as ClientModuleLoader | undefined
+  if (modules?.version !== 'client') {
+    throw new Error('client-modules: the Loader has no client module system')
   }
-  ctx.reflect.provide('modules', moduleSystem)
+  ctx.reflect.provide('modules', modules)
 }

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

@@ -123,8 +123,8 @@ function bench(
 }
 
 describe('Cordis plugin face', () => {
-  it('rejects activation before the HTML facade creates the module system', () => {
-    expect(() => { apply(new Context()) }).toThrow('createClientModuleSystem must run before plugin boot')
+  it('rejects activation before the shell installs a client Loader internal', () => {
+    expect(() => { apply(new Context()) }).toThrow('the Loader has no client module system')
   })
 })
 
@@ -293,11 +293,19 @@ describe('bootstrap module', () => {
     expect(b.fetched).toEqual([APPLICATION_URL])
   })
 
-  it('publishes the same closed-over system when the modules Cordis plugin activates', () => {
+  it('publishes the module system attached to its own Loader', () => {
+    const a = bench([])
     const b = bench([])
-    const ctx = new Context()
-    apply(ctx)
-    expect(ctx.modules).toBe(b.loader)
+    const ctxA = new Context()
+    const ctxB = new Context()
+    ctxA.reflect.provide('loader', { internal: a.loader })
+    ctxB.reflect.provide('loader', { internal: b.loader })
+
+    apply(ctxA)
+    apply(ctxB)
+
+    expect(ctxA.modules).toBe(a.loader)
+    expect(ctxB.modules).toBe(b.loader)
   })
 
   it('rejects a second queued registration for the bootstrap id', () => {

+ 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: 20f6454012d0d8e4c78d9a0ea0df960d22a71f5a
-README.zh.md: d9c6888f17df3f37e699bb6be0f7ee44ce82d373
+README.md: 64a686f08ec2778de941bedfb70b3a1de2d542b9
+README.zh.md: a9c85ca3b281f62dc57bffdaf705ea3b33e84941

Різницю між файлами не показано, бо вона завелика
+ 1 - 1
packages/test-support/client-runtime/README.md


Різницю між файлами не показано, бо вона завелика
+ 1 - 1
packages/test-support/client-runtime/README.zh.md


+ 46 - 76
packages/test-support/client-runtime/src/assembly/test-client.ts

@@ -1,13 +1,16 @@
 /**
  * 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`.
+ * `RemoteMock` bound to that client's Connection plugin instance.
  * @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 {
+  installConnection,
+  type 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'
@@ -29,73 +32,35 @@ export interface TestClientOptions {
   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.
+ * jsdom shims shared by every live client in this worker. The first holder
+ * installs missing shims and the last release removes exactly those shims.
  */
-class SharedGlobals {
+class SharedJsdomShims {
   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.
+   * Hold the shims for one client.
    * @returns the release for this holder.
    */
-  acquire(transport: ClientTransportHooks): () => void {
+  acquire(): () => 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()
+const sharedJsdomShims = new SharedJsdomShims()
 
-/**
- * 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
-}
+const CONNECTION_PACKAGE = '@deepseek-ai/dsh-client-connection'
 
 /** 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
@@ -194,16 +159,16 @@ async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number)
 /** 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
+   * 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
    * 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
+   * mount removed, and this client's hold on the shims released before the
    * original error is rethrown.
    * @param plan - roster and annotations.
    * @param mock - Remote mock answering every Gateway call.
@@ -223,7 +188,9 @@ export class TestClient {
       ? plan.roster.without([REMOTES_PACKAGE])
       : plan.roster
     const ctx = new Context()
-    const transport: ClientTransportHooks = { rpc: mock.rpc }
+    const pageLocation = typeof location === 'undefined'
+      ? undefined
+      : { hostname: location.hostname, search: location.search }
     let mountPoint: MountPoint = { element: undefined, owned: false }
     let release: (() => void) | undefined
     const restore = (): void => {
@@ -231,14 +198,24 @@ export class TestClient {
       release?.()
     }
     try {
-      const modules = await loadPluginModules({ ...plan, roster })
+      const modules = new Map(await loadPluginModules({ ...plan, roster }))
+      const connection = modules.get(CONNECTION_PACKAGE)
+      if (connection !== undefined && plan.provide?.[CONNECTION_PACKAGE] === undefined) {
+        modules.set(CONNECTION_PACKAGE, {
+          ...connection,
+          apply: (connectionCtx) => {
+            installConnection(connectionCtx, {
+              transport: { rpc: mock.rpc },
+              ...(pageLocation === undefined ? {} : { location: pageLocation }),
+            })
+          },
+        })
+      }
       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 })
-      })
+      release = sharedJsdomShims.acquire()
+      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`')
@@ -270,11 +247,6 @@ export class TestClient {
     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)
@@ -286,20 +258,18 @@ export class TestClient {
   }
 
   /**
-   * 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.
+   * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then
+   * `entry.refresh()`. Each client's module table retains its own instance-bound
+   * Connection plugin, so reloads do not coordinate through process globals.
+   * Requires a live client: after `dispose()` the Loader holds no entries and
+   * the lookup throws before teardown.
    * @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()
-    })
+    await tearDownEntryFiber(entry)
+    await entry.refresh()
+    await this.ctx.loader.await()
   }
 
   /**
@@ -315,7 +285,7 @@ export class TestClient {
 
   /**
    * 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,
+   * client's hold on the shared jsdom shims 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

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

@@ -19,7 +19,7 @@ describe('TestClient (node environment)', () => {
     await client.flush()
   }, 60_000)
 
-  it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => {
+  it('closes the client 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)
@@ -38,7 +38,7 @@ describe('TestClient (node environment)', () => {
     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 () => {
+  it('rethrows a row that fails to apply and lets the next client boot', 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/)
@@ -84,24 +84,24 @@ describe('TestClient (node environment)', () => {
     await first
   })
 
-  it('refuses to mount without a DOM before installing the transport', async () => {
+  it('refuses to mount without a DOM without touching the page 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 () => {
+  it('fails loud without touching the page 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 () => {
+  it('skips readiness on request and leaves a pre-existing page transport untouched', 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(globals.__DSH_TRANSPORT__).toBe(previous)
     expect(client.ctx.get('typert')).toBeDefined()
     expect(() => client.connection).toThrow('provides no `connection` service')
     await client.dispose()

+ 58 - 12
packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts

@@ -2,22 +2,24 @@
 /**
  * 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,
+ * `RemoteMock` bound to that client's Connection instance, 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 type { AssemblyPlan, ClientPluginModule, 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 MODULES = '@deepseek-ai/dsh-client-modules'
 const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+const PARALLEL_PROBE = '@deepseek-ai/dsh-client-test-parallel-probe'
 /** 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 }
+const globals = globalThis as { 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
 
@@ -37,12 +39,12 @@ describe('TestClient (jsdom)', () => {
     const container = client.container!
     expect(document.body.contains(container)).toBe(true)
     expect(container.childElementCount).toBeGreaterThan(0)
-    expect(globals.__DSH_TRANSPORT__).toBeDefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeDefined()
     expect(globals.ResizeObserver).toBeDefined()
     await client.dispose()
     expect(document.body.contains(container)).toBe(false)
-    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeUndefined()
     expect(globals.ResizeObserver).toBeUndefined()
     await client.dispose()
@@ -60,7 +62,7 @@ describe('TestClient (jsdom)', () => {
     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 () => {
+  it('boots separate client instances against their own mocks and keeps shared shims 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([
@@ -74,20 +76,64 @@ describe('TestClient (jsdom)', () => {
     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('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeDefined()
     await b.dispose()
-    expect(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeUndefined()
   })
 
+  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
+      },
+    }
+    const roster = ClientRoster.of([
+      { name: MODULES, inject: [], immediately: true },
+      { name: PARALLEL_PROBE, inject: [], immediately: false },
+    ])
+    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 })
+    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())
+    if (overlapFailure !== undefined) throw overlapFailure
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('reloads the bootstrap modules row against its own Loader internal', async () => {
+    const roster = ClientRoster.of([{ name: MODULES, inject: [], immediately: true }])
+    const a = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false })
+    onTestFinished(() => a.dispose())
+    const b = await TestClient.start({ roster }, RemoteMock.create(), { awaitConnected: false })
+    onTestFinished(() => b.dispose())
+    const modulesA = a.ctx.modules
+    const modulesB = b.ctx.modules
+    expect(modulesA).not.toBe(modulesB)
+    await a.reload(MODULES)
+    expect(a.ctx.modules).toBe(modulesA)
+    expect(b.ctx.modules).toBe(modulesB)
+  })
+
   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()
@@ -100,7 +146,7 @@ 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(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
   })
 
   it('creates no mount element when the roster cannot be loaded', async () => {
@@ -108,7 +154,7 @@ 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(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
   })
 
   it('mounts into a caller-supplied element and leaves it in place on dispose', async () => {
@@ -189,7 +235,7 @@ 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(globals.__DSH_TRANSPORT__).toBeUndefined()
+    expect('__DSH_TRANSPORT__' in globalThis).toBe(false)
     expect(globals.EventSource).toBeUndefined()
   })
 })

+ 2 - 2
packages/test-support/remote-mock/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/remote-mock/README.md
-README.md: c3182b61a67aa6af799fb4fbac85083fbe805e59
-README.zh.md: 8199bc748d96df34de28d225db1289d76be6eed2
+README.md: 82979e37052aecee6ec716702f634d25c969f5ad
+README.zh.md: 201ca011410dd5a4d65b6ebdfb0c47f23742b0d3

+ 3 - 3
packages/test-support/remote-mock/README.md

@@ -26,7 +26,7 @@ English | [中文](README.zh.md)
 
 ### When to use it
 
-Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client jsdom specs through `__DSH_TRANSPORT__`, and unit specs that call `dispatch` / `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`).
+Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client specs bind `mock.rpc` to their Connection instance, while unit specs may call `mock.remote`, `dispatch`, or `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`).
 
 <a id="remote-proxy"></a>
 ### Use the Remote proxy
@@ -77,7 +77,7 @@ A failed stream rejects the consumer's next read with the given `Error`. Consume
 
 ### Connect a client
 
-`mock.rpc` is the `ClientConnectionRpc` face: install it as `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }` and the production `connection` plugin uses it in place of the HTTP caller, so every Remote call reaches `dispatch` and every stream `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream.
+`mock.rpc` is the `ClientConnectionRpc` face. Pass it as `{ transport: { rpc: mock.rpc } }` to the Connection installer, or let `TestClient` bind it to its instance, so every Remote call reaches `dispatch` and every stream reaches `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream.
 
 ### Observe and assert
 
@@ -129,7 +129,7 @@ None; this package neither assembles nor sends a provider request.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **In-process carrier only** — `rpc` serves a client in the same realm through `__DSH_TRANSPORT__.rpc`; no HTTP or WebSocket carrier for browser-lane specs is provided.
+- **In-process carrier only** — `rpc` serves a Connection instance in the same realm; no HTTP or WebSocket carrier for browser-lane specs is provided.
 - **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged.
 - **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields.
 - **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler.

+ 3 - 3
packages/test-support/remote-mock/README.zh.md

@@ -26,7 +26,7 @@ kind: "package-library"
 
 ### 何时使用
 
-当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:经 `__DSH_TRANSPORT__` 的整体 jsdom 测试,以及直接调用 `dispatch` / `open` 的单元测试。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。
+当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:整体客户端测试把 `mock.rpc` 绑定到各自的 Connection 实例,单元测试也可以直接调用 `mock.remote`、`dispatch` 或 `open`。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。
 
 <a id="remote-proxy"></a>
 ### 使用 Remote Proxy
@@ -77,7 +77,7 @@ await mock.streams.drained('session/follow')
 
 ### 接上客户端
 
-`mock.rpc` 是 `ClientConnectionRpc` 面:装成 `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }`,生产的 `connection` 插件就用它替代 HTTP 调用方,每次 Remote 调用直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。
+`mock.rpc` 是 `ClientConnectionRpc` 面。把它作为 `{ transport: { rpc: mock.rpc } }` 传给 Connection 安装函数,或让 `TestClient` 将其绑定到自身实例,每次 Remote 调用就会直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。
 
 ### 观察与断言
 
@@ -129,7 +129,7 @@ await mock.streams.drained('session/follow')
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **仅进程内载体**——`rpc` 经 `__DSH_TRANSPORT__.rpc` 服务同一 realm 的客户端;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。
+- **仅进程内载体**——`rpc` 服务同一 realm 中的 Connection 实例;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。
 - **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。
 - **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。
 - **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。

+ 1 - 1
packages/test-support/remote-mock/src/index.ts

@@ -2,7 +2,7 @@
  * Endpoint-named mock for Typert Remote traffic: a table of unary answers and
  * stream scripts keyed by `<namespace>/<method>`, scripted-stream control, a
  * carrier log, and the Connection carrier face the `connection` plugin
- * accepts through `__DSH_TRANSPORT__.rpc`. Values are whatever the test
+ * accepts through explicit installation or a page transport. Values are whatever the test
  * registers; the only declaration is whether an endpoint is unary or a
  * stream. Browser-safe: no DOM, React, or Node imports, no runtime import
  * from another harness package.

+ 1 - 2
packages/test-support/remote-mock/src/remote-mock.ts

@@ -90,8 +90,7 @@ class MissingUnaryRule extends Error {}
 
 /**
  * Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the
- * same core as the Connection carrier the `connection` plugin accepts through
- * `__DSH_TRANSPORT__.rpc`.
+ * same core as the decoded carrier accepted by the Connection installer.
  */
 export class RemoteMock {
   /**

Деякі файли не було показано, через те що забагато файлів було змінено