@qilin-agent/client-test-runtime 0.0.0-stage → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md
5
+ /:
6
+ en: 6fd77b7a27000bf1
7
+ zh: 4a9606aa75285111
8
+ /qilin-client-test-runtime:
9
+ en: a4e8fc75cb5ebab8
10
+ zh: 85b391e9115fad68
11
+ /qilin-client-test-runtime/summary:
12
+ en: 7f54f8cd8f6bce97
13
+ zh: 90527ba60f855dc2
14
+ /qilin-client-test-runtime/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /qilin-client-test-runtime/use-this-package:
18
+ en: 12afbb217bc06678
19
+ zh: c07f001ed8bb3300
20
+ /qilin-client-test-runtime/use-this-package/setting-up-a-feature-spec:
21
+ en: 5e8a2e337ed9e83b
22
+ zh: 5f4ccfd1566e6ddf
23
+ /qilin-client-test-runtime/use-this-package/local-dom-snapshots:
24
+ en: 1abbbed6cc4cf57b
25
+ zh: ab3975178b589653
26
+ /qilin-client-test-runtime/use-this-package/scripting-remote-answers-and-failures:
27
+ en: 577272f1d63f9003
28
+ zh: a246b21b78f3ec70
29
+ /qilin-client-test-runtime/use-this-package/whole-client-tier:
30
+ en: f089ab96a088895d
31
+ zh: 31852d191de6604d
32
+ /qilin-client-test-runtime/use-this-package/roster-and-startup-behavior:
33
+ en: 592480354ca92e8e
34
+ zh: ca8dc4f067dc6fe5
35
+ /qilin-client-test-runtime/use-this-package/when-to-use-it:
36
+ en: eb2f9bc90acb7a62
37
+ zh: cb59a2134bc83106
38
+ /qilin-client-test-runtime/use-this-package/what-can-go-wrong:
39
+ en: c6bfe837a9221cf5
40
+ zh: 8d8fadea54f9e295
41
+ /qilin-client-test-runtime/understand-the-implementation:
42
+ en: 70d19a7761d06ac9
43
+ zh: 2a3e648ee3dc51c1
44
+ /qilin-client-test-runtime/understand-the-implementation/design:
45
+ en: d9d7e2f11d3dfbc9
46
+ zh: bfddde279a836900
47
+ /qilin-client-test-runtime/understand-the-implementation/source-map:
48
+ en: b77fd15bc12265db
49
+ zh: 1a14b6999978b72a
50
+ /qilin-client-test-runtime/understand-the-implementation/lifecycle:
51
+ en: b42779f364504719
52
+ zh: a10bad7ea1161388
53
+ /qilin-client-test-runtime/further-exploration:
54
+ en: 0863107ec91fd40d
55
+ zh: 8d2851a6aa3a5245
56
+ /qilin-client-test-runtime/model-experience:
57
+ en: 53b590e95981d312
58
+ zh: 326a89a5a4e15155
59
+ /qilin-client-test-runtime/model-experience/kv-cache-effect:
60
+ en: ca75c51c89c2c9b0
61
+ zh: f57cc48a1b5cbfa9
62
+ /qilin-client-test-runtime/known-limitations-and-deferred-work:
63
+ en: da5c7983453b2515
64
+ zh: 7f558354e84abff5
65
+ /qilin-client-test-runtime/known-limitations-and-deferred-work/dev-note:
66
+ en: e67f633dc6fe7773
67
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "jsdom slot test runtime for browser feature specs, for test authors exercising slots, stores, and rendering against production machinery."
3
+ kind: "package-library"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @qilin-agent/client-test-runtime
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ `SlotTestRuntime.create()` lets Vitest suites drive production slots, stores, typed Session and Workspace fixtures, and local DOM assertions in jsdom. For plugin activation, reload, reconnect, and cleanup tests, `createClientTest` starts the web profile's bundle roster with endpoint-named Remote mocks, without a business Host. Missing services and unstubbed calls fail explicitly. The whole-client fixture owns startup and disposal; the local runtime provides idempotent disposal. Use this package through `devDependencies` for client tests; it is not a product plugin.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ This package gives a browser feature spec a real runtime to mount against: create the bench, declare the slots your feature occupies, mount the feature plugin, render a slot, assert on the local view, and dispose — with no second implementation of production logic.
29
+
30
+ ### Setting up a feature spec
31
+
32
+ `SlotTestRuntime.create()` assembles the runtime, `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers become snapshot roots, `mount(plugin)` runs the feature on a real fiber, and `renderSlot(key, owner, opts?)` returns the slot-local view with scoped queries and in-place updates:
33
+
34
+ ```text
35
+ const runtime = await SlotTestRuntime.create()
36
+ await runtime.declare({ 'feature-slot': {} })
37
+ const handle = await runtime.mount(FeaturePlugin)
38
+ const view = runtime.renderSlot('feature-slot', { owner: props })
39
+ expect(view.container).toMatchSnapshot()
40
+ await runtime.dispose()
41
+ ```
42
+
43
+ `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions.
44
+
45
+ The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners.
46
+
47
+ ### Local DOM snapshots
48
+
49
+ A registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) so `.snap` files stay structural, and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead of the auto frame; `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis and is idempotent.
50
+
51
+ ### Scripting Remote answers and failures
52
+
53
+ `TestRemote` is the double for the `ctx.remote` face: it registers itself plus one service per scripted namespace so a plugin injecting `remote.<name>` unparks, drives `$on` subscriptions from an explicit test event driver, and exposes `$host` as a plain mutable field a spec assigns to script a homed or non-loopback Host. This package is also where a UI spec takes the `RemoteError` constructor as a value — the `qilin-api-remotes` facade cannot carry it, because a value import from a spec would pull that assembly's unbuilt `/remote` artifact chain.
54
+
55
+ Script a failure by the code the Host would answer with, and assert the same way production code discriminates — on `code`, never on the class:
56
+
57
+ ```text
58
+ import { RemoteError } from '@qilin-agent/client-test-runtime'
59
+
60
+ remote.goals.create.mockResolvedValue({
61
+ ok: false,
62
+ error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }),
63
+ })
64
+ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found')
65
+ ```
66
+
67
+ ### Whole-client tier
68
+
69
+ The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), binds `{ rpc: mock.rpc }` to that client's Connection module, synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it:
70
+
71
+ ```text
72
+ // @vitest-environment jsdom
73
+ import { createClientTest, webApp } from '@qilin-agent/client-test-runtime/src/assembly/index.ts'
74
+ import { ok } from '@qilin-agent/remote-mock'
75
+
76
+ const test = createClientTest({ roster: webApp }, { mount: true })
77
+ test('registers into the sidebar', async ({ remote, start }) => {
78
+ remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] }))
79
+ const client = await start()
80
+ expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1)
81
+ })
82
+ ```
83
+
84
+ `createClientTest` uses native Vitest fixtures: each test gets a fresh `mock` loaded with `remoteDefaultResponses`, a `remote` proxy equal to `mock.remote`, and a `start()` that boots only after the test configures its responses. Repeated starts share one promise; await it to observe startup errors. Fixture teardown waits for startup, disposes the client even after a failed assertion, checks missing responses, and refuses a saved `start` after the test. Use `TestClient.start` directly for independently owned clients. These fixtures isolate their own state, not page globals such as `location`.
85
+
86
+ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck.
87
+
88
+ ### Roster and startup behavior
89
+
90
+ `webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`qilin-base`, then `qilin-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `qilin.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `qilin.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@qilin-agent/api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.<ns>` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.<ns>` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test.
91
+
92
+ ### When to use it
93
+
94
+ Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is client-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only.
95
+
96
+ ### What can go wrong
97
+
98
+ - **A declared service is not provided** — `mount` fails loud with the missing names; `provide()` them first.
99
+ - **A render is attempted before `declare`** — `renderSlot` fails loud; declare the key first.
100
+ - **A spec calls an unstubbed verb on a session behavior stub** — fixture stubs fail loud by design, so a missing stub surfaces at the call site rather than silently passing.
101
+
102
+ -----
103
+
104
+ <a id="understand-the-implementation"></a>
105
+ ## Understand the implementation
106
+
107
+ <details>
108
+ <summary>Implementation internals — click to expand</summary>
109
+
110
+ This section explains the design of the bench; the observable behavior is fully covered in [Use this package](#use-this-package).
111
+
112
+ ### Design
113
+
114
+ The bench copies no production logic: it mounts the production `SlotRegistry`, production renderer, and `UiSession` adapter. `TestSessions` and `TestWorkspaces` implement the owner interfaces that features consume through Kylin, each fixture Session implements `SessionFace`, and `stubConfigForm` implements `ConfigForm`. `UiSession` derives standard renderer sources from those Controller bindings. Unstubbed `ISession` behavior fails with the missing method name.
115
+
116
+ ### Source map
117
+
118
+ | File | Role |
119
+ |---|---|
120
+ | [`src/index.ts`](src/index.ts) | `SlotTestRuntime` assembly, `TestRoot`, auto frame, `mount`/`dispose` |
121
+ | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs |
122
+ | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state |
123
+ | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `<svg>` fingerprint) |
124
+ | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export |
125
+ | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers |
126
+ | [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies |
127
+ | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` |
128
+ | [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue |
129
+ | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: instance-bound Connection, shared jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` |
130
+ | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures |
131
+ | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot |
132
+ | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.<ns>` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` |
133
+ | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` and `webApp`: the browser roster read from bundle patch files with the include plugin's own schema and patch application |
134
+
135
+ ### Lifecycle
136
+
137
+ `create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles plus the fail-loud file-upload stub. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves.
138
+
139
+ </details>
140
+
141
+ -----
142
+
143
+ <a id="further-exploration"></a>
144
+ ## Further Exploration
145
+
146
+ Read these pages when the package-level contract is not enough. They move from the bench to the production machinery it mounts and the tests that use it.
147
+
148
+ - [ui-session](../../client/ui-session/README.md) — the production adapter that derives standard Slot sources from the Controller doubles.
149
+ - [UI slots package](../../client/ui-slots/README.md) — the `SlotRegistry` contract the bench mounts.
150
+ - [UI renderer package](../../client/ui-renderer/README.md) — the renderer the bench installs.
151
+ - [Testing policy](../../../docs/testing.md) — the coverage tiers and browser snapshot lane.
152
+ - [Test-support group map](../README.md) — sibling harnesses and support packages.
153
+
154
+ -----
155
+
156
+ <a id="model-experience"></a>
157
+ ## Model Experience
158
+
159
+ None, as this package is browser-side test infrastructure; nothing here reaches a model request.
160
+
161
+ #### KV Cache effect
162
+
163
+ None; this package neither assembles nor sends a provider request.
164
+
165
+ ## Known Limitations and Deferred Work
166
+
167
+ <a id="known-limitations-and-deferred-work"></a>
168
+
169
+
170
+ These limits define how the bench is consumed. They are current package constraints, not a task backlog.
171
+
172
+ - **The whole-client tier does not run the generated Remote clients** — `remote.<ns>` proxies forward positional arguments without the generated zod validation, wire-name mapping, or scoped-identity injection; mock handlers receive those arguments directly, and the generated clients stay covered by the built-artifact e2e lanes.
173
+ - **Proxied calls bypass the Gateway client's `invoke` and `invokeStream`** — no `$mount` lifecycle check runs, stream failures are not re-marked through `normalizeConnectionStream`, and a unary rejection is folded by the proxy itself with the Gateway client's exported `carrierFailure` and `cancelledFailure`. `ctx.remote.$stream`, `$on`, and `$host` are the real Gateway client's.
174
+ - **An undeclared endpoint is dispatched as a unary call** — the proxy learns each endpoint's mode from the mock's registrations; a stream endpoint the spec neither scripts nor declares (`RemoteTable.streams`, `mock.stream(endpoint)`) is logged as a `unary` miss and product code receives a folded result rather than a failing stream. `remoteDefaultResponses` declares the roster's later-opened streams; `dispose()` fails the test either way.
175
+ - **The package's client compile program adds the `node` ambient types** so the roster reader can use `node:fs`; the slot-tier sources compile against them as well.
176
+ - **Session, Conversation, and Chat fixtures stay separate** — `sessionSnapshot` contains only Session Controller state, `conversationSnapshot` contains target-neutral Conversation state, and `chatSnapshot` contains Chat target state. Assembly tests provide Session event entries instead of adding Conversation or Chat fields to `SessionSnapshot`.
177
+
178
+ <a id="dev-note"></a>
179
+ ### Dev Note
180
+
181
+ <details>
182
+ <summary>Working context for maintainers — click to expand</summary>
183
+
184
+ None.
185
+
186
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,186 @@
1
+ ---
2
+ description: "面向浏览器功能测试的 jsdom slot 测试运行时,供测试作者针对生产机制检验 slot、存储与渲染。"
3
+ kind: "package-library"
4
+ ---
5
+
6
+ # @qilin-agent/client-test-runtime
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ `SlotTestRuntime.create()` 让 Vitest 套件在 jsdom 中驱动生产 slot、store、带类型的 Session 与 Workspace fixture,并对局部 DOM 断言。面向插件激活、重载、重连与清理的测试,`createClientTest` 使用具名端点 Remote mock 启动 web profile 的 bundle roster,无需业务 Host。缺失服务与未打桩调用会明确失败。整机 fixture 拥有启动和销毁,局部 runtime 提供幂等销毁。通过 `devDependencies` 将本包用于客户端测试;它不是产品插件。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 本包让浏览器功能测试拥有可挂载的真实运行时:创建测试台,声明你的功能所占用的 slot,挂载功能插件,渲染一个 slot,在局部视图上断言,然后 dispose(资源释放)——全程不存在生产逻辑的第二份实现。
29
+
30
+ ### 搭建功能测试
31
+
32
+ `SlotTestRuntime.create()` 组装运行时,`declare(children)` 注册一个自动 frame,其逐 key 的 `<div data-slot>` 包裹层成为快照根,`mount(plugin)` 在真实 fiber 上运行功能,`renderSlot(key, owner, opts?)` 返回带限定查询与原位更新的 slot 局部视图:
33
+
34
+ ```text
35
+ const runtime = await SlotTestRuntime.create()
36
+ await runtime.declare({ 'feature-slot': {} })
37
+ const handle = await runtime.mount(FeaturePlugin)
38
+ const view = runtime.renderSlot('feature-slot', { owner: props })
39
+ expect(view.container).toMatchSnapshot()
40
+ await runtime.dispose()
41
+ ```
42
+
43
+ `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。
44
+
45
+ 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。
46
+
47
+ ### 局部 DOM 快照
48
+
49
+ 注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`),使 `.snap` 文件只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)` 而非自动 frame;`dispose()` 沿单一轴拆除视图、功能 fiber、已铸 scope 与持久化存储状态,且幂等。
50
+
51
+ ### 脚本化 Remote 应答与失败
52
+
53
+ `TestRemote` 是 `ctx.remote` 面的替身:它把自己连同每个被脚本化的命名空间各注册一个服务,使注入 `remote.<name>` 的插件得以解除挂起;`$on` 订阅由显式的测试事件驱动器推动;`$host` 是普通可变字段,套件直接赋值即可脚本化带 home 或非 loopback 的 Host。UI 套件也在本包取用 `RemoteError` 构造器这个值——`qilin-api-remotes` facade 承载不了它,因为从套件发起的值 import 会拉起该装配尚未构建的 `/remote` 产物链。
54
+
55
+ 按 Host 会答的码来脚本化失败,并以生产代码同样的方式断言——判 `code`,绝不判类:
56
+
57
+ ```text
58
+ import { RemoteError } from '@qilin-agent/client-test-runtime'
59
+
60
+ remote.goals.create.mockResolvedValue({
61
+ ok: false,
62
+ error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }),
63
+ })
64
+ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found')
65
+ ```
66
+
67
+ ### 整体档
68
+
69
+ 上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),把 `{ rpc: mock.rpc }` 绑定到该客户端的 Connection 模块,用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它:
70
+
71
+ ```text
72
+ // @vitest-environment jsdom
73
+ import { createClientTest, webApp } from '@qilin-agent/client-test-runtime/src/assembly/index.ts'
74
+ import { ok } from '@qilin-agent/remote-mock'
75
+
76
+ const test = createClientTest({ roster: webApp }, { mount: true })
77
+ test('registers into the sidebar', async ({ remote, start }) => {
78
+ remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] }))
79
+ const client = await start()
80
+ expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1)
81
+ })
82
+ ```
83
+
84
+ `createClientTest` 使用原生 Vitest fixture:每个测试获得已加载 `remoteDefaultResponses` 的新 `mock`、等同于 `mock.remote` 的 `remote` Proxy,以及配置应答后才起机的 `start()`。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 `start` 调用。需要分别拥有多个客户端时直接用 `TestClient.start`。这些 fixture 隔离自己的状态,不隔离 `location` 等页面全局。
85
+
86
+ 两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。
87
+
88
+ ### Roster 与启动行为
89
+
90
+ `webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `qilin-base`、再 `qilin-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `qilin.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `qilin.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@qilin-agent/api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.<ns>` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。
91
+
92
+ ### 何时使用
93
+
94
+ 当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是客户端测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。
95
+
96
+ ### 可能出什么问题
97
+
98
+ - **已声明服务未提供**——`mount` 自明报错并列出缺失名称;请先用 `provide()` 提供。
99
+ - **在 `declare` 之前尝试渲染**——`renderSlot` 自明报错;请先声明该 key。
100
+ - **测试调用会话行为桩上未打桩的动词**——fixture 桩按设计自明报错,缺失的桩会在调用点浮现,而非静默通过。
101
+
102
+ -----
103
+
104
+ <a id="understand-the-implementation"></a>
105
+ ## 理解实现
106
+
107
+ <details>
108
+ <summary>实现细节——点击展开</summary>
109
+
110
+ 本节解释测试台的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。
111
+
112
+ ### 设计
113
+
114
+ 测试台不复制生产逻辑:它挂载生产 `SlotRegistry`、生产渲染器与 `UiSession` 适配器。`TestSessions` 与 `TestWorkspaces` 实现功能通过 Kylin 消费的 owner 接口,每个 fixture Session 实现 `SessionFace`,`stubConfigForm` 实现 `ConfigForm`。`UiSession` 从这些控制器绑定派生标准渲染器数据源。未 stub 的 `ISession` 行为会携缺失方法名失败。
115
+
116
+ ### 源码地图
117
+
118
+ | 文件 | 职责 |
119
+ |---|---|
120
+ | [`src/index.ts`](src/index.ts) | `SlotTestRuntime` 组装、`TestRoot`、自动 frame、`mount`/`dispose` |
121
+ | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 |
122
+ | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 |
123
+ | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`<svg>` 指纹) |
124
+ | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 |
125
+ | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 |
126
+ | [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` |
127
+ | [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` |
128
+ | [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 |
129
+ | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:绑定实例的 Connection、共享 jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` |
130
+ | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture |
131
+ | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 |
132
+ | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.<ns>` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` |
133
+ | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` 与 `webApp`:用 include 插件自己的 schema 与补丁应用从 bundle 补丁文件读出浏览器 roster |
134
+
135
+ ### 生命周期
136
+
137
+ `create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身和明确失败的文件上传替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose 功能 fiber、释放根注册、dispose 已铸 session scope 并清除持久化存储状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。
138
+
139
+ </details>
140
+
141
+ -----
142
+
143
+ <a id="further-exploration"></a>
144
+ ## 进一步探索
145
+
146
+ 当包级约定不够用时阅读以下页面。它们从测试台逐步进入它所挂载的生产机制以及使用它的测试。
147
+
148
+ - [ui-session](../../client/ui-session/README.zh.md)——从控制器替身派生标准 Slot 数据源的生产适配器。
149
+ - [UI slots 包](../../client/ui-slots/README.zh.md)——测试台挂载的 `SlotRegistry` 约定。
150
+ - [UI renderer 包](../../client/ui-renderer/README.zh.md)——测试台安装的渲染器。
151
+ - [测试策略](../../../docs/testing.zh.md)——覆盖层级与浏览器快照流水线。
152
+ - [test-support 组地图](../README.zh.md)——兄弟 harness 与支持包。
153
+
154
+ -----
155
+
156
+ <a id="model-experience"></a>
157
+ ## 模型体验
158
+
159
+ 无;本包是浏览器侧测试基础设施,不会发起任何模型请求。
160
+
161
+ #### KV Cache 影响
162
+
163
+ 无;本包既不组装也不发送提供方请求。
164
+
165
+ ## 已知限制与延期工作
166
+
167
+ <a id="known-limitations-and-deferred-work"></a>
168
+
169
+
170
+ 这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。
171
+
172
+ - **整体档不运行生成的 Remote 客户端**——`remote.<ns>` 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。
173
+ - **代理调用绕过 Gateway 客户端的 `invoke` 与 `invokeStream`**——不做 `$mount` 生命周期检查,流失败不经 `normalizeConnectionStream` 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure` 折叠。`ctx.remote.$stream`、`$on`、`$host` 是真 Gateway 客户端的。
174
+ - **未声明的端点按一元调用发出**——代理从 mock 的登记学到每个端点的模式;spec 既没给脚本也没声明(`RemoteTable.streams`、`mock.stream(endpoint)`)的流端点记为 `unary` 漏配,产品代码收到的是折叠结果而不是失败的流。`remoteDefaultResponses` 声明了 roster 启动后才打开的流;无论哪种,`dispose()` 都会让测试失败。
175
+ - **本包的 client 编译程序加了 `node` 环境类型**,好让 roster 读取器使用 `node:fs`;slot 档的源码也在这些类型下编译。
176
+ - **Session、Conversation 与 Chat fixture 保持分离**——`sessionSnapshot` 只包含 Session 控制器状态,`conversationSnapshot` 包含与目标无关的 Conversation 状态,`chatSnapshot` 包含 Chat 目标状态。组装测试提供 Session 事件条目,而不是向 `SessionSnapshot` 添加 Conversation 或 Chat 字段。
177
+
178
+ <a id="dev-note"></a>
179
+ ### 开发备注
180
+
181
+ <details>
182
+ <summary>维护者的工作上下文——点击展开</summary>
183
+
184
+ 无。
185
+
186
+ </details>