GUI testing system — the three-tier structure
GUI 测试体系——三层结构
The GUI stack spans multiple application shapes, and within one shape multiple runtime environments (the Node host, the data protocol layer, the browser object layer, React/DOM); a single-lane test suite cannot give a meaningful signal. Every link needs effective tests of its own, plus the base capability for full-chain testing.
English
Problem
The GUI stack spans multiple application shapes, and within one shape multiple runtime environments (the Node host, the data protocol layer, the browser object layer, React/DOM); a single-lane test suite cannot give a meaningful signal. Every link needs effective tests of its own, plus the base capability for full-chain testing.
Decision
Cut along the architecture's natural test hooks into three tiers, bottom-up:
| Tier | Under test | Key technique | File location |
|---|---|---|---|
| 1 Protocol isomorphism | AbstractApiClient + toFetchHandler (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | The full chain at the isomorphic point: InProcessApiClient(toFetchHandler(脚本化 impl)) skips the network but genuinely runs the wire serialization — zero browser, pure node env | packages/host/apiproxy/tests/client-handler.spec.ts |
| 2 Object-layer orchestration | Session/SessionManager/ConnectionController (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | The "event sequence in → snapshot out" golden path: programmable fakes + deferreds controlling timing + fake timers controlling backoff | packages/client/{runtime,connection}/tests/ |
| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through dsh-llm-replay in the real in-process web assembly against conversation aria goldens (web e2e lane, required CI gate) | apps/web/tests/*.snapshot.ts, apps/web/tests/smoke-{fixture,real}.e2e.ts, apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts |
Inter-tier discipline: each tier tests its own layer, upper tiers never re-test lower ones — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
- Host and client source are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in
vitest.config.ts; component suites use per-file jsdom pragmas and Testing Library without changing Node suites. - App-owned semantic snapshots read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and
document.title, not CSS pixels or lower-layer state-machine details.
Lane map
| Scenario | Command | Content | When to run |
|---|---|---|---|
| Baseline | pnpm run test:gui | Tier 1+2 vitest (packages/client packages/host), seconds-fast, no browser, no server | Casually, after touching any GUI source |
| Semantic snapshot | DSH_EXAMPLE_MODE=lib pnpm run test:snapshot | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery |
| Browser end-to-end | pnpm run test:web | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (DSH_SNAPSHOT=record/refresh re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery |
| Browser expected-output gate | DSH_SNAPSHOT=replay pnpm run test:web:built | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request |
| Gate | pnpm run test:coverage | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window |
Division of labor between the browser scripts and vitest: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions.
Anti-regression discipline
- Every bug fix pins an assertion: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
- All-green on fixture is not done, the real wire must pass too: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (
pnpm run test:web) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement. - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
Consequences
Each lane tests its own tier: touching any GUI source gets seconds-fast test:gui feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. Inter-tier discipline remains review-owned, while Linux CI mechanically enforces browser-golden freshness. Every new app snapshot must avoid unstable layout or clock output.
Alternatives considered
| Rejected | One-line reason |
|---|---|
| Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env |
| Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface |
| Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm |
| A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; vitest run packages/client packages/host path filtering is the tight loop — zero new config |
| Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it (CI gate decision) |
中文
Problem
GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境(Node host、数据协议层、浏览器对象层、React/DOM),单一车道的测试给不了有效信号。需要对各环节都进行有效测试,并具备全链路测试的基础能力。
Decision
沿架构天然的测试钩子切分为三层,自底向上:
| 层 | 被测物 | 关键手段 | 文件落点 |
|---|---|---|---|
| 1 协议同构层 | AbstractApiClient + toFetchHandler(双向数据/rpcId/ZOD 类型/SSE(Server-Sent Events)流/合批/超时) | 同构点全链:InProcessApiClient(toFetchHandler(脚本化 impl)) 不过网络但真跑 wire 序列化——零浏览器、纯 node env | packages/host/apiproxy/tests/client-handler.spec.ts |
| 2 对象层编排 | Session/SessionManager/ConnectionController(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | 「事件序列进→快照出」黄金路径:可编程假体 + deferred 控时序 + fake timers 控退避 | packages/client/{runtime,connection}/tests/ |
| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以确定性方式驱动跨插件状态变化;另有最简 Playwright 冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 dsh-llm-replay 在真实进程内 web 组装中回放录制的会话 fixture(测试前置数据),与会话区 aria 预期输出比对(web e2e 车道、必需 CI 门禁) | apps/web/tests/*.snapshot.ts、apps/web/tests/smoke-{fixture,real}.e2e.ts、apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts |
层间纪律:各层各测各的,上层不重测下层:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)随 2 层同包 tests/ 零假体直测。
- host 与 client 源码均纳入全仓 per-file 100% 覆盖率门禁,仅排除
vitest.config.ts中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。 - 归应用所有的语义快照读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和
document.title等稳定可见状态,而不固定 CSS 像素或下层状态机细节。
车道地图
| 场景 | 命令 | 内容 | 何时跑 |
|---|---|---|---|
| 基础 | pnpm run test:gui | 1+2 层 vitest(packages/client packages/host),秒级、无浏览器、无 server | 改 GUI 任意源码后随手跑 |
| 语义快照 | DSH_EXAMPLE_MODE=lib pnpm run test:snapshot | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 |
| 浏览器端到端 | pnpm run test:web | 先重建前端 dist,再跑 3 层浏览器全集:双级冒烟测试(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(DSH_SNAPSHOT=record/refresh 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 |
| 浏览器预期输出门禁 | DSH_SNAPSHOT=replay pnpm run test:web:built | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 |
| 门禁 | pnpm run test:coverage | 全仓门禁(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR(Pull Request)窗口 |
浏览器脚本与 vitest 的分工:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。
防回归纪律
- 修一个 bug 钉一条断言:浏览器可见的 bug 钉进所属浏览器 spec(冒烟测试或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
- fixture 全绿不算完,真 wire 也要过:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(
pnpm run test:web)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host 冒烟测试仍是真模型侧的补充。 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/约定为裁),不留悬红。
Consequences
各车道各测各层:改动任意 GUI 源码后都能获得秒级 test:gui 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。层间纪律仍由评审负责,而 Linux CI 通过机器门禁确保浏览器预期输出的新鲜度。每个新的应用快照都必须避开不稳定的布局或时钟输出。
Alternatives considered
| 放弃项 | 一句话理由 |
|---|---|
| 单一 e2e(全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控;wire/对象层不变量在 node env 可毫秒级全断言 |
| verify 脚本迁 vitest | 有序脚本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent(智能体)定位接口 |
| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 |
| GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,vitest run packages/client packages/host 路径过滤即窄循环——零新 config |
| 钩子/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它(CI 门禁决策) |