chore(gui): mission work logs — cordis design finalization, tool-card wire archive, incident records chore: missions chore: missions chore(gui): mission ledger — batch-2 answers, parallel dispatch state, jsdom coverage re-scope chore(gui): ledger — night-mode standing orders (self-commit small, no push, 5-min refresh) chore(gui): ledger — jsdom batches 2-4 landed (233 green), coverage probe next chore(gui): ledger — web-ui coverage probe 65%, four-tier fill plan approved chore(gui): ledger — cordis-impl B1 state after third API drop, decisions on file chore(gui): ledger — 01:32 patrol snapshot (jsdom tier-1 landed, coverage-fixer probed) chore(gui): ledger — 01:37 patrol (peer src trio landed, coverage-fixer still silent) chore(gui): ledger — 01:42 patrol (jsdom tier-2 landed, peer committed x2, coverage-fixer 2nd probe) chore(gui): ledger — coverage diagnosis complete (6-file gap list), web-ui at 91.4% chore(gui): ledger — 01:46 patrol (B1 done, jsdom tier-3 landed, coverage fix batch running) chore(gui): ledger — 01:51 patrol (jsdom tails x2 landed, B2 underway) chore(gui): ledger — 01:56 patrol (gateway.ts 337 lines, checkpoint T-7min) chore(gui): ledger — hold/pending split ruling, coverage-fixer externalize-or-restart ultimatum chore(gui): ledger — 02:01 patrol (B2 done, jsdom final arms, coverage ultimatum pending) chore(gui): ledger — 02:03 checkpoint executed (fixer2 respawn, four lanes released, three owners cold-started) chore(gui): ledger — all six lanes acked, type isolation first live proof (client closure clean) chore(gui): ledger — 02:08 patrol (all seven lanes active, wire carrier assembled) docs(gui): respond-design task checkpoint — apiproxy wire-layer recon done chore(gui): ledger — P0-2 contributor AGENTS.md landed (dd28a5019) docs(gui): respond-design checkpoint 2 — host-side recon (stub respond, frame types, approval seam, ACP answerer precedent) docs(gui): OOP debt inventory — seven territories, 2 real debts (createApiProxy, createFixtureApi), rest ruled keep-as-is docs(gui): disambiguation note on the archived i18n design task chore(gui): ledger — 02:12 patrol (exclude removed, mixed-knife incident under reconciliation) chore(gui): ledger — 02:15 wave (jsdom mission closed, OOP audit done, B3 isolation proof, mixed-knife resolved) chore(gui): ledger — 02:17 patrol (attribution reversal filed, arch-session probed) chore(gui): ledger — 02:22 patrol (B4 done, B5+B6 merged batch, arch-session deadline set) docs(gui): respond-design checkpoint 3 — client-side recon (pending map, PendingCard onRespond stub, AbstractApiClient.respond ready, bootHost missing approval mounts) docs(gui): peer carrier territory review — 2 fixes (SSE cancel leak, route-reservation guard), 1 ruling ask (RPC-log visibility), compliance ledger chore(gui): ledger — 02:27 (arch-shell respawn, territory review verdicts routed, ask-deny finding flagged) docs(gui): P1-5 respond design page complete — pending registry, wire answerer, client state machine, first-wins arbitration chore(gui): ledger — 02:31 patrol (respond design complete, B5 wire smoke green, shell knife 1 underway) docs(gui): respond design — add §0 status warning (web host ask defaults to deny), mount-behavior delta, no-timeout ruling with Config discipline chore(gui): ledger — 02:42 patrol (respond line closed pending review, B7 last piece underway) docs(gui): respond design contract review — direction pass, 2 doc fixes (settle-order contradiction, answering-state race), A/B/C compliance ledger docs(gui): respond design — contract review fixes (R1 verify-before-delete arbitration, R2 answering+resolved-frame transition, ask dual-source wording, rejected-is-ok-value note) chore(gui): ledger — 02:46 patrol (respond line final, two user decisions distilled, arch-shell deadline) docs(gui): respond review addendum — contract-gap ruling: approve plan A (ApprovalRequest.id), wire unchanged, drop plan B backscan chore(gui): ledger — cordis B7 summit: real-browser 10/10 green, user acceptance criterion proven docs(gui): respond design — contract gap #4 approved as plan A (ApprovalRequest.id), backscan fallback retired, blade 0 prepended docs(gui): respond design — final polish (owner-approval vs user-go-ahead wording, implementation handoff notes) chore(gui): ledger — 02:51 (shell-exec third respawn with operational script, respond line 4-knife final) chore(gui): ledger — 02:53 wave (respond five-knife true final, B7 closed 12/12, client.ts green) chore(gui): ledger — 02:56 patrol (cordis closeout bounced pending R1/R2/N1, shell-exec first sign of life) chore(gui): ledger — 03:01 patrol (R1/R2/N1 remediation in flight across four files) chore(gui): ledger — cordis line officially closed and archived, verified on disk (24 knives, 12/12, reviews closed) chore(gui): ledger — 03:06 patrol (shell-exec final window, lowered first-knife bar) chore(gui): ledger — 03:11 patrol (shell line iced-broken: three registries on disk) chore(gui): ledger — 03:15 patrol (quiet window, both active lanes within threshold) chore(gui): ledger — 03:20 patrol (shell five files up, api-proxy plan reported) chore(gui): ledger — 03:25 patrol (shell migration in flight with history-preserving moves, webserver green) chore(gui): ledger — 03:30 patrol (shell knife-1 in verification, api-proxy patching) chore(gui): ledger — shell knife 1 accepted (694cecc53), knife 2 released chore(gui): ledger — 03:40 patrol (knife 2 pre-move stage, cold-list spec appears) chore(gui): ledger — 03:45 patrol (rpclog moves staged, api-proxy two specs in flight) chore(gui): ledger — 03:54 patrol (knife-2 code done, coverage full-run final check) docs(gui): coverage-fixer task ledger — fixer2 takeover, per-file fix log, isolated reportsDirectory pitfall chore(gui): ledger — coverage lane closed and accepted (a19f069a5), the PR #443 CI fix knife chore(gui): ledger — 04:04 patrol (knife-2 calibration, sole active lane) chore(gui): ledger — shell knife 2 accepted (f8fb77b95), knife 3 released as final night task chore(gui): ledger — 04:19 patrol (knife-3 past half: callback chain through, ToolCallDetail up) chore(gui): ledger — night closeout summary: seven lanes closed, wake-up decision sheet chore: missions chore: missions chore: missions chore(gui): mission-local browser/probe verify scripts under missions/scripts/ The six acceptance/probe scripts move here as mission-side working material (headers and relative imports adjusted for the new location): carrier-errors, rpclog-panel, session, session-real, webserver-backpressure, webserver-hardening. chore(gui): verify-relocate mission log chore(gui): verify-relocate mission log — R1 guard addendum chore(gui): gates-continue mission log — CI-equivalent sequence all green chore(gui): VS Code 扩展体系双边设计调研报告 chore(gui): 调研追加 4.5 节——git 扩展数据面与 scope 绑定 docs(gui): web plugin system RFC — walkthrough + design notes docs(gui): RFC — restore existing SSE/POST as the v1 transport; envelope rides on it (D16) docs(gui): RFC — envelope demoted to chan-dispatch, scope out of envelope, rpc-log cut, peer deferred, scope tree is native cordis (D17-D21) docs(gui): RFC — hooks re-derived from component needs: useWatch/useAction only, useService removed; sessionHub cut, projections user-space, router rename, loader-only root (D22-D25) docs(gui): RFC — drop stale fork vocabulary (vendored cordis has Fiber only; scope = mintScope pattern), hook idempotence contract (D26-D27) docs(gui): RFC — session precision seam: plugins read scope key (host paradigm), React gets it from tree position via SlotOutlet (D28) docs(gui): RFC — domain hooks owned by plugins over framework primitives; useConversation paradigm carried over (D29) docs(gui): RFC — ctx services are the inter-plugin API (cordis proper); declarations are wire-only; get(id) returns scoped ctx (D30) docs(gui): RFC — full ctx.conversation walkthrough: root-singleton scope-sensitive service, caller-ctx scope key, get(key) as scoped ctx (D31) docs(gui): RFC — no client-side agents collection: session state machine already expresses the duality; agent resolution stays host authority (D32) docs(gui): RFC — v1 stays session-precision, no agent-level isolation; incarnation/agent-axis designs archived in ledger (D33) docs(gui): RFC walkthrough — full rewrite to final state (D16-D33 consolidated), end-to-end chain restored docs(gui): RFC — apiproxy demoted to generic channel routing; domain RPCs dissolve into owner plugins (D34) docs(gui): RFC — TS-interface-first wire contract (zod internal), conversation owns the dialogue frame with pluggable views (D35) docs(gui): RFC — page skeleton (sidebar+conversation), projects as plugin not service, nested slots via owner registries (D36) docs(gui): RFC — SlotMap declaration-merging slot model: single register API, inject-as-ownership, FC-typed registration, typed outlets (D37) docs(gui): RFC — slot props whitelist: identity, display params, materialized snapshot slices, stable UI callbacks (D38) docs(gui): RFC — end-to-end data flow: three transforms, equality protocol table, immer placement; i18n/theme kept standard (D39-D40) docs(gui): RFC — full external-injection model: props carry values + stable injected hooks; shared/client/react example rewritten (D41-D43) docs(gui): RFC final trio — modules.md (agent implementation spec), architecture.md (human walkthrough), plugins.md (business plugin inventory) docs(gui): RFC — props three-source merge (scope-standard useSession auto-injected); keyed key vs list id disambiguated (D44) docs(gui): RFC — inject comment says what it is (the React-facing props bundle); SessionHandle rename; snapshot-production story unified on buildSnapshot docs(gui): RFC architecture — full React component tree walkthrough: props three sources, slot vs plain children, hook taxonomy per node docs(gui): RFC — module map finalized (ui-slots/web-react/connection/runtime/ui-*/web); slots onChange replaced by cordis events; toolcall dimension; detail sidebar default-collapsed with toolName-keyed routing docs(gui): RFC plugins — openDetail relay chain: toolcard calls chat-view injected action, chat-view relays to conversation sidebar chore(gui): progress ledger — full archive rewrite: RFC outcome digest, open gaps, dispatch plan, cold-start entry docs(gui): RFC grill pass 1 — SlotScope axis (root/session) on declares, Gate dependency inversion, inject handle by scope, W5 acceptance list, gantt relay chain fixed docs(gui): figma analysis — sidebar/projects/sessions 区域交互视觉理解报告 docs(gui): figma 解析报告 — details 面板/多视图 tabs/未来功能区盘点 + slot 需求清单 docs(gui): figma 对话主区解析报告 — 消息流/tool calls 变体/审批接管输入框/Header tabs/视觉 token docs(gui): plugins.md rewritten from figma analysis — three-column layout, full slot reservation table, selection channel, composer-takeover approvals, phased scope docs(gui): layout dynamics ruled (drag+collapse both rails, details yields first, composer swap-panel, same-component transition); toolviews promoted to named scope-aware registry docs(gui): P-I scope locked (details minimal, dual theme, chat-view, custom toolview sample); teammate dispatch plan — 6 owners by package, dependency-driven waves, contract arbitration docs(gui): P-I api-contracts (full inter-package API spec) + dispatch plan (T0 skeleton knife, 7-dev roster, task briefs, milestones) docs(gui): api-contracts v2 — scope tree in P-I, bundle loader + per-plugin CSS isolation in P-I, agent-scoped toolviews live, zustand engine, renames (SessionProvider/ObservableSnapshot/SessionBinding), router owns all shell view-state docs(gui): services roster + progressive loading (no blocking loadAll), SlotsService as real cordis Service, renderSlot/renderSuspenseSlot duo, ui-traj teammate docs(gui): loading-chain gaps ruled — dev=rebundle no HMR, ui-primitives package, externals on globals (no import map), host injects __DSH_BOOT__ into HTML (zero round-trip) docs(gui): api-contracts v3 + dispatch v2 final — 12 packages, services merged in, progressive loader, global externals, __DSH_BOOT__ injection, 8-dev roster with convo split and ui-traj docs(gui): v3 amendments — router renamed ctx.layout, ui-trajectory has no service (pure consumer sample), wait-for-settled loading (no Suspense in P-I, ledger 6b) chore(gui): progress — pre-compact final state: v3 revision chain, 8-dev roster, T0 procedure, doc authority order docs(gui): authority banners — modules/architecture get v3 term-mapping headers, walkthrough marked as archived process doc docs(gui): cssdesign token set is THE theme source (--dsw-* variables, data-ds-dark-theme switch); recorded in contracts + progress docs(gui): architecture.md full v3 rewrite — loading chain, 12-package map, service roster, slot/inject/toolviews, data flow, component tree, perf model, all current docs(gui): contracts — UI plugins are dual-entry host plugins (node half serves client asset via ctx.webPlugins; __DSH_BOOT__ derives from it; client-closure gate back in scope) docs(gui): contracts — closure-factory bundles with DI require (no globals), package.json dshWeb declarative discovery (no serve ritual), create-then-send empty state with project picker, ancestry() for breadcrumb, unload stubbed until HMR, props.renderSlot confirmed docs(gui): dshClient declaration (inject/platform/immediately, exports./client), closure-DI require loading — synced across contracts/dispatch/modules/architecture/walkthrough chore(gui): progress — record final loading-chain rulings (dshClient declaration, closure-DI require, startSession) before compact docs(gui): architecture.md — developer-facing whole-web architecture on master baseline 6b16a67cb: what exists, what is new, no process narrative docs(gui): architecture.md — self-contained whole-web architecture: absorbs still-valid substance from the branch RFCs (host layering, four-quadrant RPC, object layer, testing tiers) under the new plugin system as the override chore(gui): progress — final pre-compact snapshot: contracts digest, apiproxy purity ruling, T0 procedure with first-action list docs(gui): api-contracts v3 §3.1 — apiproxy purity principle with three-way existing-code verdicts docs(gui): api-contracts v3 — immediately reinterpreted as static-infra group (8-package dshClient scope, boot manifest reconciliation) docs(gui): api-contracts v3 — immediately corrected to early-load dynamic group (prod shell must not rebundle); loader shell-held; bundles register their export surface into module table docs(gui): architecture — align with immediately=early-load dynamic group ruling; loader shell-held; module-table registration of loaded bundles docs(gui): T0 checklist — 12-package skeleton table, 4-cut sequence, mv/attic/rewire rules (pre-drafted, awaiting go) docs(gui): t0-checklist — pin figma-flows findings (missing font-family base vars, three alias vars behind upstream) docs(gui): dispatch v2.1 — drop cordis-web salvage wording, two-wave staffing, loader/immediately boundary updates docs(gui): progress + t0-checklist ledger — T0 landed, staffing status, execution accounting docs(gui): api-contracts v3 — arbitration round 1: renderBody deps, RootBindingProvider, flush default sync, prune current, loader subpath, config-source P-I bar docs(gui): v3 §3.2 connection 导出清单附录(rt-core 对账)+ rt-core 实现计划档案 docs(gui): progress — T1 milestone, arbitration round 1 ledger, fw-react timeout escalation docs(gui): progress rolling update — per-line battlefield state at 00:2x, mailbox-vs-contract lesson, small-batch discipline reinforced docs(gui): fw-react notes — v3 §2 complete, seven knives, T1/T2 follow-ups docs(fw-slots): archive — four packages landed, open tails logged docs(gui): progress — framework layer complete (web-react five, fw-slots four packages), T2 gated on rt-core runtime knife only docs: api-contracts docs: style-spec docs docs(gui): tsconfig convergence ruling — no host.json, root resumes host-aggregate duty, typecheck = root + client aggregates docs(gui): missions 根三份 07-18 世代档案加「已被取代」头注——指向 web-plugin-rfc 现行权威并注明新旧对应 missions docs(gui): progress rewritten for post-closeout state — wave ledger, architecture finale, teammate roster with handover notes, pending-user-command queue
29 KiB
GUI 测试体系设计(e2e + 单元测试)
命题:为当前 GUI 架构设计分层测试体系。事实基线:HEAD 11694b553 + 工作区未提交改动;四篇 gui RFC(分层/协议/web 架构/样式)为架构事实源。设计阶段不写测试代码。
读法:§A 是骨架(每层测什么/不测什么/工具/假体);§B/§C 处理两项存量资产(verify 脚本、fixture)的定位;§D 是可直接开工的落地清单;§E 是工作流接线;§F 妥协台账。文末列需用户拍板的分叉点。
§0 三个设计前提
- GUI 免门禁现状(用户已定):per-file 100% coverage、REAL-composition、doc-sync 等正式门禁 GUI 期不套用。但这不等于测试可以随便写——本设计按「转正时能平滑升格」的形状落测试,豁免的是阈值不是结构。
- 架构已给出天然测试缝,测试体系贴缝切,不造新缝:①纯函数层(lineage/partial/conversation 分类器)零依赖;②对象层(Session/Manager)依赖收口在
IApiClient单接口,可编程假体即可行为测试;③协议层有进程内同构点——InProcessApiClient(toFetchHandler(impl))不过网络但真跑 wire 序列化/zod 两级 parse/rpcId 回显/SSE 分帧,这是整个体系里最值钱的一条缝;④host impl 层依赖收口在 bootHost 的 ctx,mock LLM adapter(echo-agent 先例)即可真 core 测试。 - 两次「fixture 全绿真浏览器炸」的实证(连接风暴=桥层 req/res close 误判、桥 abort bug)定性了 fixture 的盲区:fixture 短路的恰是 wire 承载链(doFetch/SSE 分帧/node:http 桥/close 语义/真网络时序)。治理方向不是「让 fixture 更像真的」,而是①结构性缩小短路面(§C.1)②把不可 fixture 化的面下沉到 Node 层哨兵(§C.3)。
§A 分层测试策略
总表(层序 = 数据流自底向上;「假体」列写该层测试中被替换的边界,未列者一律真身):
| # | 层 | 被测物 | 测什么 | 不测什么 | 工具 | 假体策略 |
|---|---|---|---|---|---|---|
| A1 | 纯函数层 | lineage.ts / partial.ts / conversation.ts 分类器 / notifier.ts |
输入→输出全分支、引用纪律 | — | vitest(node env) | 零假体 |
| A2 | fold 适配层 | fold-adapter.ts(含 core SurfaceManager 真身) |
padding 哨兵、增量 append、节点缓存引用稳定、降级分支、六型物化 | SurfaceManager 自身正确性(core 已有覆盖) | vitest | 零假体(core surface 用真的——它就是被适配对象) |
| A3 | 对象层 | session.ts / manager.ts / connection.ts |
状态机与时序:open 缝合/去重/翻页锚定/乐观清稿/pendingBuffers 重放/重连重建/退避 | wire 形态(A4 管)、渲染 | vitest | test-local FakeApiClient(可编程响应 + deferred 控时序;见 §D.4,≠ FixtureApiClient) |
| A4 | 协议层 | AbstractApiClient + toFetchHandler(apiproxy fetch/ 两文件) |
四象限信封往返、rpcId mint/回显/校验、zod 两级 parse 拒收、SSE 分帧边界、错误分层(业务 200 vs 载体 4xx/5xx)、envelope tap 合批、unary 超时 | 业务语义(history 分页对不对是 A5 的事) | vitest,经同构点全链 | 微型脚本化 ApiProxy impl(十几行,每 case 自定义) |
| A5 | host impl 层 | api-proxy.ts(createApiProxy) |
会话语义承诺(RFC「impl 侧承诺」节):分页消息边界、隐式 resume 去重、prompt/cancel 1:1 映射、帧发射(subscribed 基线/event 透传/status 翻转) | LLM 输出内容 | vitest,真 core ctx | 手挂 ctx(agent-loop-testkit + mock LLM adapter,echo-agent 先例);只 mock LLM 一个边界 |
| A6 | 承载层 | dsh-host-webserver(bridge + static) |
连接稳定性哨兵下沉位:res-close 语义回归钉死、client abort 传播、SSE 逐 chunk 写出;static 403/SPA/mime/405 | WHATWG handler 内部(A4 管) | vitest,真 node:http + 裸 http.get |
stub fetch handler(发帧脚本) |
| A7 | hooks 层 | useConversation / useSessionList |
(本轮暂缓,见 §F.5)uSES 合同四条里可脱 React 断言的部分已在 A1/A3 覆盖(getSnapshot 缓存引用、先重建后通知) | hook 内部——它只是 uSES 接线模板 | (将来 RTL) | — |
| A8 | 组件层 | web-ui components/ | 不单测。组件是耗材(web 架构 RFC 明示「换 UI 库=重写组件目录」且已知要重做),单测是负资产 | props 渲染、样式 | — | — |
| A9 | 全链 e2e | 真浏览器 × 真页面(fixture 或真 host) | 用户可见行为与跨层集成:渲染齐全、滚动锚定、输入框回归钉、连接稳定性(浏览器侧) | 单层逻辑(下层各测各的,e2e 只兜集成缝) | playwright chromium headless 验收脚本 | fixture 模式 / 真 host 双轨(§B) |
逐层要点(只写表格放不下的判断):
A1 纯函数层是 ROI 最高的起步位:flattenLineage(孤儿降级/环 fail-soft/双层排序)、PartialAccumulator(六型 chunk + 稀疏 index 压缩 + 块级引用纪律)、toAssistantBlock(四型分类)、Notifier(微任务合批/无订阅惰性/同步 notifyNow)全是有边界条件的状态折叠逻辑,bug 藏身处,且零假体即测。
A2 用真 SurfaceManager 不 mock:FoldAdapter 的全部风险在「与 core fold 的契约耦合」(seq===下标断言、surface-eligible 判定、replace throw),mock 掉 core 就测了个寂寞。降级分支的触发要构造真能让 fold throw 的事件序(replace 指向窗口外目标);若实现时发现无法稳定构造,允许注入缝降级(见 §D.3 条目 2 的备注)。
A3 的假体是 test-local FakeApiClient,不是 FixtureApiClient。fixture 是 UI 开发资产:脚本固定(60 turns/80ms 打字机/5s gamma 翻转)、走真实时钟、语义面向「人看着像真的」。行为测试需要的是每 case 自定义响应和 deferred promise 控制的时序(history 挂起时注入 live 帧才能测 liveBuffer 缝合;vi.useFakeTimers 控退避)。两者用途正交,硬复用 fixture 会把测试时序绑死在演示脚本上。
A4 是体系的中枢:协议不变量全住基类+handler,两端各测一半都不如同构点全链测一遍——InProcessApiClient(toFetchHandler(脚本化impl)) 一条管把 mint→tap→POST→parse→回显校验→窄形吐出全跑真。这层绿了,「换载体」(将来 Electron IPC)的回归面就只剩 doFetch 切面本身。
A5 的「真 core + mock LLM」:createApiProxy 的风险全在与 core 服务(ctx.agents/ctx.sessions/事件总线)的映射语义,mock ctx 等于自证。手挂 ctx 用 mountAgentLoopTestDependencies + 内联 mock adapter(echo-agent 的 mock-llm.ts 形态,测试 harness 里十几行),prompt 后消费 mux 流断言事件序。不走 Loader/cordis.yml(REAL-composition 政策的 GUI 期豁免,入 §F.4 台账)。
A6 是两次实证 bug 的最低成本回归位:req/res close 误判 bug 在纯 Node 里 100% 可复现(裸 http.get 挂一条 SSE,坏实现在 GET body 读尽后立刻 abort)。把它钉在 Node 层意味着跑这条回归不再需要浏览器、不再需要 12 秒;浏览器侧 12s 哨兵保留(它还覆盖浏览器 fetch 语义与真网络),但不再是唯一防线。
§B verify-*.mjs 的定位与演进
结论:保留验收脚本形态,不迁 vitest;三脚本各自定位固化,增量规则收敛
理由(逐项对过 vitest 化的收益):
- 它们的价值恰在「非测试框架」形态。三脚本是 agent 自验工具+用户「改完重跑」工作流的一部分:顺序步骤即用户操作剧本(§E1-1→§E1-11 是一次真实走查),PASS/FAIL 逐行流式输出让 agent 在中途失败时立刻看到「走到哪一步断的」。vitest 化后步骤会被拆成隔离 test case——但这些步骤本质上是共享一次浏览器会话的有序剧本(§E1-5 发消息是 §E1-7 停止的前置),拆散要么靠
test.sequential+ 共享 page(形式化收益归零),要么每 case 重开浏览器重走前置(12s 哨兵 × N 的时间成本)。 - vitest 的三项收益在此场景全打折:断言体系——playwright 的 locator/waitFor 已是断言主体,
report()十行顶掉 expect;并行——浏览器剧本天然串行;watch——真浏览器 e2e 没人 watch 着跑。 - 防回归资产的地位不靠框架:脚本已有 exit code(0/1)与稳定输出格式,任何 runner(pre-push、CI、agent)都能消费。
- 迁移是纯改造成本:三脚本 ~350 行断言逻辑要逐条改写并重验时序语义(waitForSelector 的竞态注释都是踩坑记录),换来的是负收益。
三脚本定位固化
| 脚本 | 定位 | 前置 | 何时跑 |
|---|---|---|---|
verify-session.mjs |
fixture 全量 UI 走查(渲染/翻页/发送/插话/停止/切换/新建/输入框回归钉/RPC 面板交叉验证) | dsc web + dist + ?fixture |
每次改 web-runtime/web-ui 后(agent 交付前必跑) |
verify-rpclog-panel.mjs |
fixture RPC 面板专项(台账/配对/暂停/清空) | 同上 | 改 rpc-log/面板/envelope tap 后 |
verify-session-real.mjs |
真 host 抽查 + 连接稳定性哨兵(12s 请求数≤10、零 requestfailed)+ 真模型流式 | dsc web + 真 key | 改连接层/桥/handler/SSE 后;发版前 |
增量规则(防脚本无限膨胀)
- 新交互面(新面板、新对话能力)→ 新专项脚本(rpclog-panel 先例),不塞进 verify-session。
- 回归钉(修一个 bug 钉一条)→ 挂进所属脚本的回归节(§E1-11 输入框钉是先例形态:一钉一行 report,注释标 bug 编号)。
- 一次性验收(某次重构的专用检查)→ ignore 目录,不进 scripts/(既有分流纪律)。
- 每脚本头注释三行契约保持:用途、前置、运行命令;步骤标签(§E1-x/§D-x)与 report 文案一一对应——这是 agent 读输出定位失败点的接口,视为稳定面。
- 转正路径(门禁回收时):脚本形态不变,包一层 vitest e2e 壳(
it('verify-session', () => spawn 脚本断 exit 0))即可挂进 test:e2e 车道——届时也只做这一步,脚本本体永不改写成 test case。
§C fixture 与真链路的差异治理
差异的结构分析(先定性再开药)
FixtureApiClient 在协议层覆写(callUnary/openMux/openHost/respond 四虚方法直连内存 impl),被短路的面自上而下:
| 被短路面 | 住址 | 两次实证 bug 是否在此 |
|---|---|---|
| wire 序列化/zod 两级 parse/rpcId 回显校验 | AbstractApiClient.callUnary/readSse + handler | 否 |
SSE 分帧(\n\n/data: 拼接/comment 行) |
readSse + sseResponse | 否 |
| node:http↔WHATWG 桥(close 语义、abort 传播、逐 chunk 写出) | webserver bridge | 是(req-close 误判) |
| 浏览器 fetch/网络时序(重连风暴的放大器) | 真浏览器 | 是(连接风暴表现层) |
| host impl 语义差(分页算法、resume、running 判定) | api-proxy.ts vs fixture 手写对应物 | 潜在(fixture 的 pageOf 与 impl 的 paginate 是两套手写实现,已在漂移) |
药方按面分三条:C.1 收窄短路面(结构性)、C.2 语义合同双跑(断言性)、C.3 不可 fixture 化的面下沉哨兵(已在 §A6/§B 落位)。
C.1 fixture 迁移到同构管道(结构性收窄)
fixture.ts 头注释已预告此路:FixtureApiClient 从「协议层覆写」改为「InProcessApiClient over toFetchHandler(createFixtureApi())」。改造后 fixture 模式真跑 wire 序列化、zod 双向 parse、SSE 分帧、rpcId 纪律——短路面从上表五行缩到只剩后两行(node:http 桥 + 真浏览器网络),fixture 掩盖 wire 层 bug 的能力被结构性拆除(例如信封字段漏写、schema 拒收、分帧边界 bug 在 fixture 模式下将直接炸给开发者看)。
- 前提核实:
createFixtureApi()返回的就是ApiProxy形(已满足);toFetchHandler 在浏览器可跑需确认唯一 Node import(node:cryptorandomUUID)换成globalThis.crypto.randomUUID——一行改动,vite 即可 bundle。 - 代价:fixture 模式多一层 JSON 往返(每帧序列化+parse)。60 turns 历史+80ms 打字机的量级下无感;RPC 面板反而更真(现在 tap 看到的是 fixture 手工捏的全形,改后是真信封)。
- FixtureApiClient 类保留为薄壳或直接删除(boot 处
new InProcessApiClient(toFetchHandler(createFixtureApi()))),倾向后者——少一个类少一份「fixture 特有语义」的藏身处。
C.2 语义合同双跑(contract suite 跑两遍)
fixture impl 与真 impl 的行为合同用同一套断言各跑一遍钉住。合同即 RPC 协议 RFC「会话语义(impl 侧承诺)」节的可机验子集:
| 合同条目 | 断言要点 |
|---|---|
| 分页消息边界 | history(maxMessages=n) 返回窗口内 message 型事件数 ≤ n 且切口对齐消息组边界;beforeSeq 翻页与首页拼接后 seq 连续无重叠;hasMore 与切口>0 一致 |
| prompt→事件序 | queue prompt 后 mux 流依序可见 turn/start → user/message →(流式期 chunk*)→ assistant/message → turn/end |
| running 翻转 | prompt 后 running=true 帧、turn 结束后 false 帧(fixture 经 host/session-status,真 impl 经 agent/status 映射——wire 形一致即合同) |
| cancel 语义 | 运行中 cancel → turn/end reason kind ∈ {cancelled,...} + running=false;空闲 cancel 不炸 |
| session-not-found | 对不存在 id 的 history/prompt 返回 {ok:false, code:'session-not-found'} 且 details.sessionId 回显 |
| create→列表可见 | create 后 list 含新 id;host 流见 session-added |
| subscribed 基线 | 开 mux 流即收 attached session 的 subscribed 帧且 lastSeq=当前尾 seq |
落法:describe.each([fixtureApi, realApi])('ApiProxy contract', …)——fixture 侧直接 createFixtureApi(),真侧 A5 的手挂 ctx + mock LLM。跑的是 ApiProxy 接口层(C.1 完成后两者又都能再套同构管道跑一遍 wire 形)。fixture 若过不了某条合同,修 fixture 而不是放宽合同——合同的事实源是真 impl+RFC。
维护规则:改真 impl 的会话语义必须同步跑合同套件,红了either修 fixture either改合同并在 PR 说明——合同套件从此是「fixture 漂移」的机械检测器(今天 pageOf/paginate 的双实现漂移就该由它抓)。
C.3 残余差异的哨兵矩阵(结构收窄后仍不可 fixture 化的面)
| 残余面 | 哨兵 | 车道 |
|---|---|---|
| node:http 桥 close/abort/流写出 | A6 纯 Node 回归(裸 http.get 挂 SSE 12s→秒级断言 + abort 传播 case) | vitest 单测 |
| 真浏览器网络时序 | verify-session-real E2-0 十二秒哨兵(保留) | 验收脚本 |
| 真模型流式 | verify-session-real E2-3(保留) | 验收脚本 |
三条防线的分工语句(写进将来 docs):wire 形靠同构点(A4),语义靠合同双跑(C.2),承载靠 Node 哨兵(A6)+ 真浏览器抽查(verify-real)——fixture 从「全责假体」降格为「UI 开发数据源」,掩盖真 bug 的结构位被逐一填掉。
§D 单元测试落地形态
D.1 vitest 配置:独立 vitest.gui.config.ts,无 coverage
根目录 vitest.gui.config.ts:
plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })] // 与根 config 同款
test.include: ['packages/{client,host}/*/tests/**/*.spec.ts']
environment: 'node'(对象层/协议层全部可 node env 跑;hooks 层暂缓故不需 jsdom)
无 coverage 段(GUI 免门禁的机械表达:不是把阈值调低,而是不进 coverage 车道)
package.json 脚本:
"test:gui": "vitest run --config vitest.gui.config.ts"
- 文件位置守仓库惯例:包级
tests/(packages/client/web-runtime/tests/partial.spec.ts),命名.spec.ts。这使根 config 的 include 模式天然也能扫到它们——GUI 期用 test:gui 跑,转正时零搬迁(解除的只是 coverage 豁免)。 - 解析注意一条:web-runtime 源码 import
@deepseek-ai/dsh-session/surface子路径。根 tsconfig paths 若命不中(host/client 组是显式条目),gui config 补一条resolve.alias——写第一个 fold-adapter 测试时即验证。 - 时序控制约定:连接层退避用
vi.useFakeTimers+vi.spyOn(Math, 'random');unary 超时不 mockAbortSignal.timeout(fake timers 控不住),用timeoutMs构造参数给短真值(10ms)。
D.2 第一批单测清单(按 ROI 排;「断言要点」即验收标准)
T1 web-runtime/tests/partial.spec.ts — PartialAccumulator 六型(纯函数、零假体、bug 密度高)
| 测试名 | 断言要点 |
|---|---|
| block-start 四型建空块 | text/reasoning/tool-call 各建对应空块;未知 blockType → {kind:'other'} |
| text-delta 拼接 | 两次 delta 累积;prev 缺失或异型时从空串起 |
| reasoning-delta 拼接 | 同上(reasoning 支线) |
| tool-call-delta 累积 | argsRaw 逐段拼接;callId 首个 id 定死不被后续覆盖;name 后到覆盖先到(?? 语义) |
| block-end 整块替换 | 定稿块经 toAssistantBlock 整体换入(覆盖累积中间态) |
| usage/finish 返回 false | push 返回 false(不触发通知);blocks 不变 |
| 稀疏 index 压缩 | 先 block-start index=2 再 index=0:toPartial 输出压缩后连续数组,无 undefined 洞 |
| 引用纪律 | 无变更时 toPartial 恒返同一引用;一次 push 后引用更换且只换一次 |
T2 web-runtime/tests/fold-adapter.spec.ts — padding 哨兵与节点缓存
| 测试名 | 断言要点 |
|---|---|
| baseSeq>0 窗口折叠 | reset(events, baseSeq=100) 后 nodes() 输出与事件一一对应、seq 正确(哨兵不漏出) |
| 尾 append 增量 | reset 后 append 一条 → nodes 含新节点,旧节点引用不变(缓存生效) |
| 节点引用稳定 | 两次 nodes() 调用,同 seq 节点 toBe 同一对象;数组本身每次新建 |
| tool-result 回填 | 窗口内先 tool/call 后 tool/result → result 节点 call={name,argsRaw};窗口外 call → call:null |
| 六型物化 | user/assistant/steering/context/tool-result/unknown 各一条,kind 与字段映射正确 |
| 降级分支 | 构造跨窗 replace(surfaceOp:'replace' 目标 seq 在哨兵区)使 fold throw → degraded=true、输出退化为线性扫描序、后续调用稳定走降级不再 throw。若真事件序造不出 throw,允许给 FoldAdapter 注入 fold 失败缝(内部 seam),并在测试注明 |
T3 web-runtime/tests/session.spec.ts — 打开缝合与去重(体系里最值钱的行为测试;FakeApiClient 见 D.4)
| 测试名 | 断言要点 |
|---|---|
| open 尾页安装 | history 返回后 openState cold→loading→open,events/baseSeq/hasMore 就位 |
| open 幂等 | 并发两次 open() 只发一次 history 调用(openPromise 复用) |
| liveBuffer 缝合 | history 挂起期间注入 3 条 live 帧(1 条与页尾重叠)→ 就绪后仅 2 条新帧 append,重叠帧丢弃 |
| subscribed 补缝 | subscribed.lastSeq > 窗口尾且 liveBuffer 未覆盖 → 第二次 history 拉取发生 |
| live 去重 | seq ≤ 窗口尾的 session/event 丢弃,快照无变化 |
| loadOlder 前插 | beforeSeq=窗口首 seq;成功后 baseSeq 更新、节点序连续 |
| loadOlder 断层 fail-soft | 返回页尾 seq+1 ≠ baseSeq → 丢页、hasMore=false、窗口不变 |
| loadOlder 防重入 | loadingOlder 期间再调直接返回(只发一次请求) |
| chunk→partial→定稿切换 | chunk 帧后快照 partial 非空;同 turn/step 的 assistant/message 到达 → partial=null 且节点 +1(同一快照代内完成) |
| openCalls 增删 | tool/call → runningCalls 含之;tool/result → 移除 |
| pending 双域 | approval/requested 与 question/requested 各入 pending(前缀隔离);resolved 按 approvalId/questionRpcId 移除 |
| sendDraft 乐观清与恢复 | 发送即清稿;失败时草稿恢复且保住往返期间新键入(sent+typed);in-flight 重入丢弃;纯空白 no-op 零请求 |
| prompt 失败入快照 | RpcResult err → promptError{op:'send'};doFetch throw → 折叠为 internal |
| resync 重建 | 清窗口清 pending 重跑 open;cold 实例 resync 为 no-op |
T4 apiproxy/tests/client-handler.spec.ts — 信封往返(同构点全链)
| 测试名 | 断言要点 |
|---|---|
| unary 全链往返 | InProcessApiClient→toFetchHandler→脚本 impl:impl 收到窄形(rpcId 已 mint)、client 吐窄形、value 原样 |
| rpcId 回显校验 | impl 回错 rpcId → client throw 'rpcId mismatch' |
| payload 拒收 | 非法 payload → {ok:false, code:'bad-request'} 且 details.issues 非空;HTTP 仍 200 |
| method/path 不符 | 手工 POST /api/session.list 但信封 method=session.create → bad-request |
| 未知 method | POST /api/no.such → 404 → client throw transport failure(业务/载体两层不混的验证) |
| impl 抛异常 | route.invoke throw → 500 → client throw(不是 200 信封) |
| SSE 帧往返 | impl yield 3 帧 → client 依序吐窄形;: connected 注释行被跳过 |
| SSE 分帧边界 | 单 chunk 双帧、一帧跨两 chunk(自定义 doFetch 塞 ReadableStream 切割)→ 均正确重组 |
| envelope tap 合批 | 一次 unary 产生 client-request+server-response 两条、同一微任务批送达;listener throw 不影响调用结果;零订阅者时不入缓冲 |
| respond 回执 | receipt 解析;信封坏形 → {accepted:false, reason:'bad-response'} |
| unary 超时 | timeoutMs=10 + 永不 resolve 的 doFetch → reject |
T5 web-runtime/tests/lineage.spec.ts — 谱系扁平化
| 测试名 | 断言要点 |
|---|---|
| 根排序与子缩进 | roots 按 updatedAt 降序;子随父 DFS 展开、depth 递增、同级子亦降序 |
| 孤儿降级 | parentSessionId 指向不存在 id → 以 root 出现,不丢条目 |
| 环 fail-soft | a↔b 互指 → 全部条目仍输出(环成员作 root)、无死循环、console.warn 触发 |
| 自指 | parent=self → 同环处理 |
T6 web-runtime/tests/notifier.spec.ts — 合批通知原语
| 测试名 | 断言要点 |
|---|---|
| N 次 markDirty 一次 flush | 微任务后 listener 恰一次;rebuild 先于 listener(顺序探针) |
| 无订阅惰性 | 零 listener 时 flush 不 rebuild;ensureFresh 补建且只建一次 |
| notifyNow 同步 | 调用返回前 listener 已执行(控制输入光标前提) |
| 退订 | unsubscribe 后不再收通知 |
第二批(第一批绿后):
- T7
manager.spec.ts:懒建+running 同步、pendingBuffers 缓冲/重放/清空、非 pending 帧对未实例化 session 丢弃、refreshList 单飞与错误态、create 立即并表不重复、host 四帧路由(added 去重/removed 标记不销毁/status 双写/agent-error 转发)、handleConnected 只 resync 已打开实例。 - T8
connection.spec.ts:双流+describe 才算连上(onConnected 时机)、流断→abort 本代→退避重连(fake timers 验区间)、describe 失败走同一失败路径、sink throw 隔离、stop 后不再重连、stream/error 帧触发 break。 - T9
webserver/tests/bridge.spec.ts(A6 哨兵):res-close 回归钉(stub handler 记录 signal;裸 http GET SSE 路由,等 200ms 断言signal.aborted===false——坏实现秒红)、客户端断连 → signal aborted、SSE 逐 chunk 到达(两帧间延迟,首帧先于流结束可读)、static 403 编码变体/SPA 200/mime 表/405。 - T10
host/runtime/tests/api-proxy.spec.ts(A5):testkit 手挂 ctx + 内联 mock adapter;分页边界(maxMessages 计数、sourceEventSeqs 组切口、beforeSeq 窗口)、冷 session 并发 resume 去重(两并发 history 一次 resume)、prompt queue/steer 1:1 与 rpcId 进 MessageSource、cancel 未 attach → session-not-found、mux subscribed 基线+事件透传、host 流 status 翻转。 - T11
apiproxy-contract.spec.ts(C.2 合同双跑):§C.2 七条 × describe.each(fixture, real);依赖 T10 的 harness。
D.3 断言纪律(承接仓库测试文化里 GUI 期仍适用的三条)
- 验世界不验自述:Session 测试断快照与 FakeApiClient 收到的调用记录,不断内部私有位。
- 行为可换测试随换:组件重做/协议演进时改测试是预期动作,测试名描述行为不描述实现。
- 引用纪律是一等断言:
toBe(同引用)与not.toBe(换引用)在快照相关测试中与值断言同权重——这是 React.memo/uSES 的合同,破了值全对页面也炸。
D.4 FakeApiClient 形态(test-local,≠ fixture)
web-runtime/tests/fake-api.ts:实现 IApiClient,每方法一个可编程槽(默认 ok 空响应)+ 调用记录数组 + deferred() 工具(测试手握 resolve 时机以构造「history 挂起期注帧」类时序)。流方法暴露 pushMux(frame)/pushHost(frame) 手动泵。~60 行,住 tests/ 不进 src/(不是产品资产)。
§E CI/工作流集成
E.1 GUI 免门禁期的运行车道
| 车道 | 命令 | 时长量级 | 何时 |
|---|---|---|---|
| 单测 | pnpm run test:gui |
秒级、无浏览器无 server | 改 web-runtime/apiproxy/host 任意源码后随手跑 |
| fixture 验收 | node scripts/verify-session.mjs(+rpclog 按需) |
~30s,需 dsc web + dist | UI/对象层改动交付前 |
| 真链路验收 | node scripts/verify-session-real.mjs |
~1min,需真 key | 连接/桥/handler 改动交付前;阶段收尾 |
不挂 pre-commit hook(免门禁期 + 用户小步快跑分批落盘的工作流,hook 只会添堵);一切手动/agent 触发。
E.2 编码 teammate 交付前必跑矩阵(写进派工模板的「改动面→必跑」表)
| 改动面 | 必跑 |
|---|---|
| web-runtime/session/*(对象层) | test:gui + verify-session |
| apiproxy api/ 或 fetch/(契约/载体) | test:gui + verify-session + verify-session-real(wire 面动了必须过真链路) |
| host/runtime api-proxy(impl) | test:gui + verify-session-real |
| webserver | test:gui(含 T9 桥回归)+ verify-session-real |
| web-ui 组件/样式 | verify-session(+改面板则 rpclog);无单测义务 |
| fixture.ts | test:gui(合同套件 T11 落地后是主防线)+ verify-session |
agent 纪律沿用既有惯例:playwright 验收 agent 自己跑(chromium headless),失败贴 FAIL 行与截图,不留给用户手验。
E.3 转正路径(门禁回收时的升格清单,一次性做完)
- 单测并入根车道:GUI 包 tests/ 已匹配根 include,动作=补足 per-file 100%(或对 web-ui 组件目录给 justified 排除)后删
vitest.gui.config.ts。 - T11 合同套件+T10 挂
pnpm run test;A5 harness 补 Loader/cordis.yml REAL-composition 版(见 §F.2)。 - verify 三脚本包 vitest e2e 壳挂
test:e2e车道(spawn+断 exit 0;脚本本体不改写)。 - 新 seam 计划纪律恢复:新帧型/新方法在 plan 期声明各层覆盖(testing.md 既有要求)。
§F 妥协台账(三段式:妥协 → 触发条件 → 返工点/预埋)
| # | 妥协 | 触发条件 | 返工点 | 预埋(本轮就守) |
|---|---|---|---|---|
| F.1 | GUI 包免 coverage 门禁(独立 config 无阈值) | 首个 tagged release 门禁回收 | 并根 config 补 100% 或 justified 排除 | tests/ 位置+.spec.ts 命名守惯例,升格零搬迁 |
| F.2 | A5 手挂 ctx,不走 Loader/cordis.yml(REAL-composition 豁免) | dsc 作为产品 bin 进发布面 | test-only cordis.yml + Loader boot 冒烟 | bootHost 保持纯组合函数、无隐藏装配 |
| F.3 | hooks/组件层零测试 | 组件重做完成、props 契约稳定 | RTL + uSES 合同四条逐条验 | 合同四条已成文(web 架构 RFC),对照即测 |
| F.4 | C.1 fixture 同构化只设计未实施(现状仍协议层覆写) | 下次 fixture 掩盖 wire bug,或任何人动 fixture.ts | §C.1 迁移(含 handler randomUUID 平台化一行) | fixture.ts 头注释已标迁移终点;不再往 FixtureApiClient 加新语义 |
| F.5 | 合同套件仅七条可机验子集;审批/问答 pending 语义不在内(host 侧 respond 是 stub) | step2 pending 表实装 | 合同加 requested 稳定 rpcId/基线重放/resolved 收敛条目 | 帧语义已成文(RPC RFC),届时照抄 |
| F.6 | verify 脚本无 CI 车道(纯手动/agent 触发) | 门禁回收或 GUI 进 CI | E.3-3 的 vitest 壳 | exit code 与输出格式视为稳定接口 |
| F.7 | 12s 浏览器哨兵保留双份成本(T9 落地后 Node 层已秒级覆盖同 bug) | T9 绿了且再无浏览器侧独有连接故障两周 | verify-session-real 哨兵窗 12s→5s(只缩窗不删除——浏览器 fetch 语义仍是独有覆盖面) | E2-0 断言保持独立步骤可单独调窗 |
§G 设计过程 findings(非本任务修,移交实现侧)
- fixture
pageOf与 implpaginate已在漂移:切口算法不同(fixture 数满 maxMessages 后找 turn/start 边界;impl 用 sourceEventSeqs 组起点),hasMore 边界行为亦异。T11 合同套件落地即会红——届时按真 impl 修 fixture。 toFetchHandler用node:cryptorandomUUID:换globalThis.crypto.randomUUID()即浏览器可跑(C.1 前提,一行)。- webserver
RunningWebServer.port回显options.port而非实际监听端口:port=0(随机端口)时返回 0。测试想用随机端口避免冲突就会撞上;建议改读server.address().port。 AbortSignal.timeout不可被 vi fake timers 控制:D.1 已定短真值策略,写 T4 时勿踩。