Merge remote-tracking branch 'origin/master' into feat/session-inherited-boundary

This commit is contained in:
Hypatia May
2026-07-30 15:14:16 +08:00
56 changed files with 330 additions and 268 deletions
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b
2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1
2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c
@@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up:
|---|---|---|---|
| 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](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
| 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](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `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/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
@@ -34,6 +34,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes
| 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.
@@ -46,7 +47,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes
## 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. The accepted cost is that inter-tier discipline is upheld by review rather than a machine gate and every new app snapshot must avoid unstable layout or clock output.
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
@@ -56,4 +57,4 @@ Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gu
| 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 (the original ruling) | Once deferred as "components are consumables, revisit after the redo"; overturned by the user on 2026-07-20 — **the jsdom mainline enters coverage** (no browser infrastructure in CI is the decisive reason, playwright demoted to a local enhancement), the RTL dependencies entered devDependencies, the first spec landed |
| 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](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) |
@@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
|---|---|---|---|
| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**`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 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md) | `apps/web/tests/*.snapshot.ts``apps/web/tests/smoke-{fixture,real}.e2e.ts``apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.md) | `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/fold-adapter)随 2 层同包 tests/ 零假体直测。
@@ -34,6 +34,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
| 基础 | `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 层浏览器全集:双级 smokefixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 |
| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 |
| 门禁 | `pnpm run test:coverage` | 全仓 gatehost 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 |
**浏览器脚本与 vitest 的分工**Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。
@@ -46,7 +47,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
## Consequences
各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。接受的代价是层间纪律由评审而非机器门禁维持,而且每个新的应用快照都必须避开不稳定的布局或时钟输出。
各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。层间纪律由评审负责,而 Linux CI 通过机器门禁确保浏览器预期输出的新鲜度。每个新的应用快照都必须避开不稳定的布局或时钟输出。
## Alternatives considered
@@ -56,4 +57,4 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
| verify 脚本迁 vitest | 有序剧本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×NPASS/FAIL 流式输出正是 agent 定位接口 |
| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 |
| GUI 包独立 vitest config(曾设计 vitest.gui.config.ts | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config |
| hooks/组件层暂缓单测(原裁决) | 曾以「组件是耗材、等重做后再议」暂缓;2026-07-20 用户改判——**jsdom 主线进覆盖率**(CI 无浏览器基建是决定性理由,playwright 降级为本地增强),RTL 依赖入 devDeps、首个 spec 已落 |
| hooks/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.md) |
@@ -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/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798
2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b
2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9
2026-07-26-ci-failover-runbook.zh.md: fdce40ffac5036cb4caf8eb86d20b7bb5bae4fc8
@@ -14,7 +14,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
### What the in-house pool is
`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity.
`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
### Switch (any repository writer, ~1 minute, no merge)
@@ -14,7 +14,7 @@ Status: implemented
### 自有池是什么
`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过
`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件
### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并)
@@ -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-07-24-web-gui-browser-e2e-lane.md
2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154
2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1
2026-07-24-web-gui-browser-e2e-lane.md: 97be6d5d70d12f783e2c80b1bcd546cfa4582ca8
2026-07-24-web-gui-browser-e2e-lane.zh.md: f55b4f8011cc42aa4e0f8c11d9900525a565e1a7
@@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions.
### Expected outputs
@@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e
### CI stance
The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix).
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
## Prior art
@@ -76,12 +76,11 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
## Testing
`pnpm run test:web` runs the lane keylessly. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh` rewrites aria goldens keylessly. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position.
`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position.
## Deferred
- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors.
- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`).
- **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses.
- **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering.
- **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one.
@@ -89,4 +88,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
## Consequences
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided.
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff.
@@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;只有 `settings-chrome.e2e.ts` 不预设该存储项,以覆盖默认中文状态及双向切换。
### 预期输出
@@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
### CI 立场
车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外
## 业界先例
@@ -76,12 +76,11 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
## Testing
`pnpm run test:web` 无密钥运行该车道。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh` 则无密钥重写 aria 预期输出。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。
`pnpm run test:web` 构建并无密钥运行该车道`test:web:built` 基于现有构建产物运行`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。
## 暂缓
- **Web 头类别钉住**web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。
- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。
- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。
- **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。
- **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。
@@ -89,4 +88,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
## 后果
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-07-30-web-browser-snapshot-ci-gate.md
2026-07-30-web-browser-snapshot-ci-gate.md: 3f87bb0f3d936bcee7ba7c3d84ae808c6ede1a97
2026-07-30-web-browser-snapshot-ci-gate.zh.md: af563f0e2a1c20f7b53d371e97e41b7ffa52a1d1
@@ -0,0 +1,35 @@
# Agent Note: Required CI gate for web browser expected outputs
Status: implemented
English | [中文](2026-07-30-web-browser-snapshot-ci-gate.zh.md)
## Problem
The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs only under the local `pnpm run test:web` command, and PR CI does not compare `apps/web/tests/snapshots/**/*.expected.md`. A PR that changes user-visible web output can therefore remain green when its expected outputs are not refreshed; when any later branch explicitly runs `DSH_SNAPSHOT=refresh`, it backfills the earlier change and produces a diff unrelated to that branch. Ordinary local runs already default to read-only replay, so the gap is mandatory enforcement at the PR level, not a ban on writes in refresh mode.
## Decision
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions.
Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds and the full consumer aggregate at 114.97 seconds. The gate scheduler starts it as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
## Alternatives considered
**Continue requiring only local runs.** Rejected: execution depends on developer memory, which is precisely why stale goldens drift across PRs, and cannot guarantee that the PR introducing a behavior change carries its own expected-output diff.
**Run CI in `refresh` mode and then check the working tree.** Rejected: checking after writing turns the assertion mechanism into a generator; if the working-tree check is wired incorrectly, it can turn a regression into a passing expected-output update. Replay compares the existing goldens directly and has a smaller failure surface.
**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already consumes the same built-tree artifact and is part of the unified required verdict.
**Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain.
## Consequences
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; built-artifact reuse and the browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
@@ -0,0 +1,35 @@
# Agent Note: Web 浏览器预期输出的必需 CI 门禁
Status: implemented
[English](2026-07-30-web-browser-snapshot-ci-gate.md) | 中文
## 问题
[无密钥 Web 浏览器 e2e 车道](2026-07-24-web-gui-browser-e2e-lane.md)只由本地 `pnpm run test:web` 运行,PR CI 不比较 `apps/web/tests/snapshots/**/*.expected.md`。因此,改变用户可见 Web 输出的 PR 可以在漏刷预期输出时保持绿色;后来任意分支显式运行 `DSH_SNAPSHOT=refresh`,都会替前序变更补账并产生与本分支无关的 diff。普通本地运行已经默认使用只读 replay,缺口是 PR 级的强制执行,而不是禁止 refresh 写入。
## 决策
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts``test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`CI 永不以 `record``refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 和包的 `lib/` 目录放进 built-tree 产物,消费方 job 复用该产物而不重复全仓构建。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。
本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
一次自托管消费方运行中,`web-snapshot` 实测耗时 112.15 秒,完整消费方聚合实测耗时 114.97 秒。gate 调度器会在 `built-package-invariants` 成功后立即启动它,并发运行彼此独立的 gate,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
## 曾考虑的替代方案
**继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带 expected diff。
**让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接线失效就会把回归更新成绿色;replay 直接比较已有 golden,失败面更小。
**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux consumer job 已消费同一 built-tree artifact,并已被统一的 required verdict 聚合。
**用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实 client plugin bundle 组合;它保留为快速的下层反馈,不能替代 assembled browser chain。
## 后果
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium,并串行运行一轮浏览器场景;built artifact 复用与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。
+39 -3
View File
@@ -96,7 +96,7 @@ jobs:
- name: Pack built tree
run: >-
tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
apps/*/lib packages/*/*/lib vendor/*/lib
apps/*/lib apps/web/dist packages/*/*/lib vendor/*/lib
- uses: actions/upload-artifact@v7
with:
@@ -224,6 +224,16 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
# Pull requests restore the cache produced by serial-linux on master;
# they do not pay compression and upload on the required path.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install dependencies and prepare bubblewrap
run: |
pnpm install --frozen-lockfile &
@@ -237,6 +247,16 @@ jobs:
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- name: Install Playwright Chromium and hosted dependencies
if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium
# The persistent VM image owns Playwright's Linux system packages; do
# not mutate the shared host with apt on every failover run.
- name: Install Playwright Chromium on the failover VM
if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium
- name: Run compatibility, snapshot, and artifact gates
run: pnpm run check:ci:consumers
@@ -448,9 +468,20 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
# Master produces the hosted Chromium cache restored by pull requests.
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium and system dependencies
run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium
- name: Prepare bubblewrap (unrestrict userns)
run: bash scripts/prepare-ci-bubblewrap.sh
@@ -463,7 +494,7 @@ jobs:
DSH_OXLINT_THREADS: '1'
DSH_PUBLINT_CONCURRENCY: '1'
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
run: pnpm run check:ci
run: pnpm run check:ci:linux-primary
# Hot-standby drill for the in-house self-hosted pool: every master move
# re-runs the complete unsharded aggregate on the persistent 64-core VM,
@@ -505,6 +536,11 @@ jobs:
- name: Install (immutable)
run: pnpm install --frozen-lockfile
# The persistent VM image owns Playwright's Linux system packages; this
# step also proves that browser provisioning remains usable for failover.
- name: Install Playwright Chromium
run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium
- name: Prepare bubblewrap (unrestrict userns)
run: bash scripts/prepare-ci-bubblewrap.sh
@@ -517,7 +553,7 @@ jobs:
DSH_OXLINT_THREADS: '1'
DSH_PUBLINT_CONCURRENCY: '1'
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
run: pnpm run check:ci
run: pnpm run check:ci:linux-primary
serial-macos:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+2 -2
View File
@@ -18,7 +18,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
@@ -44,7 +44,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+2 -2
View File
@@ -12,7 +12,7 @@ import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
@@ -62,7 +62,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
@@ -40,7 +40,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await appFrame(page).waitFor({ timeout: 30_000 })
+2 -2
View File
@@ -20,7 +20,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -43,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+2 -2
View File
@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -90,7 +90,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+3 -3
View File
@@ -12,7 +12,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
@@ -40,7 +40,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -75,7 +75,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
await page.getByRole('button', {
name: '选择模型,当前 deepseek-v4-flash',
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
+2 -2
View File
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
@@ -56,7 +56,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
slotErrors = []
page.on('console', (message) => {
+2 -2
View File
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -44,7 +44,7 @@ describe('web e2e: resident question composer round trip', () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+2 -2
View File
@@ -16,7 +16,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
@@ -63,7 +63,7 @@ describe('web e2e: queue row actions', () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+3 -3
View File
@@ -18,7 +18,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
@@ -43,7 +43,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -106,7 +106,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
await page.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash',
name: 'Select model, current DeepSeek-V4-Flash',
}).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+3 -3
View File
@@ -17,7 +17,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
@@ -49,7 +49,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -125,7 +125,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
// This scenario deliberately leaves the LLM seam open to prove zero
// model calls. History still restores the selected id, but no catalog
// adapter exists to provide its presentation name.
name: '选择模型,当前 deepseek-v4-flash',
name: 'Select model, current deepseek-v4-flash',
}).waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
+2 -2
View File
@@ -67,7 +67,7 @@ import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
@@ -275,7 +275,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
browser = await chromium.launch()
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
// the list with room to spare.
page = await browser.newPage({ viewport: { width: 1680, height: 800 } })
page = await newEnglishPage(browser, 800)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -17,7 +17,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
@@ -80,7 +80,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
scaffold = await launchWebScaffold({})
await seedSkills(scaffold.workspaceCwd)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+2 -2
View File
@@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
@@ -376,7 +376,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
)
baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
page.on('pageerror', e => pageErrors.push(String(e)))
await page.goto(baseUrl, { waitUntil: 'load' })
}, 120_000)
@@ -11,16 +11,10 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
@@ -37,16 +31,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok
@@ -11,16 +11,10 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to:":
- img
- img
- text: "Think The user wants me to:"
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
@@ -29,11 +23,6 @@
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button [expanded]:
- img
- text: Mount temporary Plugin typescript
@@ -43,11 +32,6 @@
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
@@ -61,16 +45,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok
@@ -11,18 +11,14 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- img
- text: Bash Echo the test string
- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK
- button "复制"
- text: WEB_E2E_OK
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
- img
- img
@@ -32,16 +28,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 99% Input 15.7K tok · Output 111 tok
@@ -17,9 +17,9 @@
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "设置":
- button "Settings":
- img
- text: 设置
- text: Settings
- text: Let's start building
- button "Choose workspace":
- img
@@ -28,12 +28,8 @@
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
@@ -11,7 +11,6 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
@@ -21,16 +20,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 21 tok
@@ -11,23 +11,19 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- paragraph: partial
- text: 已停止
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}} 0 tokens · 1 turns · 1 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
@@ -11,16 +11,11 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
@@ -11,7 +11,6 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
@@ -21,16 +20,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 79 tok
@@ -16,11 +16,6 @@
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- img
- text: Read
- button "a.txt"
@@ -36,16 +31,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 deepseek-v4-flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
@@ -11,16 +11,10 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
@@ -34,16 +28,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 95% Input 8.6K tok · Output 180 tok
@@ -2,11 +2,11 @@
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- button "Previous question" [disabled]:
- img
- button "下一题" [disabled]:
- button "Next question" [disabled]:
- img
- button "放弃整组问题":
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue":
@@ -15,9 +15,9 @@
- radio "Green":
- text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- img
- button "其他,请填写自定义答案":
- button "Other — enter a custom answer":
- img
- text: 其他,请填写自定义答案
- text: Other — enter a custom answer
- status
- button "跳过本题"
- button "提交" [disabled]
- button "Skip this question"
- button "Submit" [disabled]
@@ -25,11 +25,11 @@
- img
- button "取消编辑":
- img
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"
@@ -19,11 +19,11 @@
- img
- button "删除排队消息":
- img
- textbox "给智能体发消息"
- textbox "Message the agent"
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "选择模型,当前 DeepSeek-V4-Flash":
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Stop generating"
@@ -15,11 +15,6 @@
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- img
- text: Read
- button "a.txt"
@@ -35,16 +30,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 deepseek-v4-flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
@@ -1,3 +1,3 @@
- listbox "Trigger suggestions":
- text: 技能
- text: Skills
- option "policy-shared Available to both model and user invocation" [selected]
@@ -11,29 +11,23 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps"
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- text: 1 / 1
- button "上一题" [disabled]:
- button "Previous question" [disabled]:
- img
- button "下一题" [disabled]:
- button "Next question" [disabled]:
- img
- button "放弃整组问题":
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Yes":
@@ -42,9 +36,9 @@
- radio "No":
- text: 2 No
- img
- button "其他,请填写自定义答案":
- button "Other — enter a custom answer":
- img
- text: 其他,请填写自定义答案
- text: Other — enter a custom answer
- status
- button "跳过本题"
- button "提交" [disabled]
- button "Skip this question"
- button "Submit" [disabled]
@@ -11,16 +11,10 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button "复制":
- img
- button "在新对话中分支":
- img
- text: {{clock}}
- button:
- img
- img
@@ -34,16 +28,13 @@
- img
- button "在新对话中分支":
- img
- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 156 tok
@@ -1,10 +1,10 @@
- dialog "选择工作区目录":
- heading "选择工作区目录" [level=2]
- dialog "Select Workspace Directory":
- heading "Select Workspace Directory" [level=2]
- navigation:
- button "主目录"
- button "Home"
- img
- button "browse-golden"
- button "编辑路径"
- button "Edit path"
- list:
- listitem:
- button "alpha":
@@ -16,8 +16,8 @@
- img
- text: beta
- img
- button "新建文件夹":
- button "New folder":
- img
- text: 新建文件夹
- button "取消"
- button "打开"
- text: New folder
- button "Cancel"
- button "Open"
+2 -2
View File
@@ -23,7 +23,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -67,7 +67,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+16 -1
View File
@@ -2,13 +2,28 @@
import { existsSync, mkdirSync } from 'node:fs'
import { createServer } from 'node:net'
import { fileURLToPath } from 'node:url'
import type { Page } from 'playwright'
import type { Browser, Page } from 'playwright'
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/**
* Open the standard browser-test page with English selected before client
* boot. This keeps role locators and goldens deterministic across localized
* component migrations; the settings locale scenario deliberately bypasses
* this helper to cover the product's default Chinese state.
* @param browser - Playwright browser owning the page.
* @param height - Viewport height; width is fixed to the lane baseline.
* @returns the initialized page.
*/
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
const page = await browser.newPage({ viewport: { width: 1680, height } })
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
return page
}
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
export function requireDist(): void {
if (!existsSync(DIST_INDEX)) {
+13 -13
View File
@@ -16,7 +16,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url))
// The seed is another scenario's committed fixture, reused read-only: this
@@ -42,12 +42,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(path)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: '打开' }).click()
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(path)
await dialog.getByLabel('Edit path').press('Enter')
await dialog.getByRole('button', { name: 'Open' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
@@ -72,7 +72,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -363,15 +363,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(staged)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: 'Edit path' }).click()
await dialog.getByLabel('Edit path').fill(staged)
await dialog.getByLabel('Edit path').press('Enter')
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
await dialog.getByRole('button', { name: '取消' }).click()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
} finally {
if (realHome === undefined) delete process.env.HOME
@@ -406,7 +406,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// card; no aria role — text anchors are the stable selector).
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
// Leaving the anchor closes it with no delay.
await page.getByRole('button', { name: '设置' }).hover()
await page.getByRole('button', { name: 'Settings' }).hover()
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+2 -2
View File
@@ -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 docs/testing.md
testing.md: 04bd7782fa4328b6b693f13f60f4e33b463f8a18
testing.zh.md: 5712fd8ce7b0cd46ebeb237bdd12c6c572ebe3de
testing.md: fd4879158d7aa1f4726043b0e519f1d2827f41ec
testing.zh.md: bd78afb4fa29e8cc505bae12701f1651f7ba7b5e
+1 -1
View File
@@ -10,7 +10,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped.
- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web composition replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); record/refresh semantics and the deferred CI browser decision: [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). [Runs `build` first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md): plugin CSS ships per plugin.
- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS.
Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge.
+1 -1
View File
@@ -10,7 +10,7 @@
- **覆盖率门禁**`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。
- **真实 API e2e**`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY``PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。
- **快照**`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **Web 浏览器快照**豁免门禁的 `pnpm run test:web`):真实 chromium 在进程内 web 组装之上回放已录制 fixture,与会话区 aria 预期输出比对(`apps/web/tests/snapshots/`);`DSH_SNAPSHOT=record`/`refresh` 的语义与暂缓的 CI 浏览器决策见 [web e2e 车道 Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)[先跑 `build`](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)插件 CSS 按插件分别发布
- **Web 浏览器快照**`pnpm run test:web`;必需的 Linux PRPull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。
签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。
+3 -1
View File
@@ -30,10 +30,12 @@
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
"migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts",
"test:web": "npm run build && vitest run --config vitest.web.config.ts",
"test:web": "npm run build && npm run test:web:built",
"test:web:built": "vitest run --config vitest.web.config.ts",
"test:gui": "vitest run packages/client packages/host",
"check:all": "tsx scripts/run-gates.ts check-all",
"check:ci": "tsx scripts/run-gates.ts ci-primary",
"check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary",
"check:ci:static": "tsx scripts/run-gates.ts ci-static",
"check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
+3 -3
View File
@@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures.
3. **Before a PR**use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
@@ -97,5 +97,5 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
+22 -3
View File
@@ -57,6 +57,7 @@ function withEnv<T>(name: string, value: string | undefined, action: () => T): T
describe('gate graph validation', () => {
it.each([
'ci-primary',
'ci-linux-primary',
'ci-static',
'ci-lint',
'ci-coverage',
@@ -135,17 +136,18 @@ describe('Oxlint gate', () => {
})
describe('Node 24 consumer graph', () => {
it('owns the seven-command pool and orders restored-artifact consumers', () => {
it('owns the eight-command pool and orders restored-artifact consumers', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
workers: 7,
workers: 8,
source: 'ci-consumers gate count',
})
expect(subject.map(item => item.id)).toEqual([
'lint-and-duplication',
'node-compat',
'snapshot',
'web-snapshot',
'publint',
'node-next-types',
'built-package-invariants',
@@ -154,10 +156,27 @@ describe('Node 24 consumer graph', () => {
expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined()
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
for (const id of ['snapshot', 'web-snapshot', 'node-next-types', 'built-bin-smoke']) {
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
}
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
})
})
})
describe('Linux primary graph', () => {
it('adds the same compare-only web gate after built client artifacts', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
const web = subject.find(item => item.id === 'web-snapshot')
expect(web).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
needs: ['built-package-invariants'],
})
})
})
+15 -1
View File
@@ -13,6 +13,7 @@ import { performance } from 'node:perf_hooks'
/** A named aggregate exposed by the gate runner. */
export type Mode =
| 'ci-primary'
| 'ci-linux-primary'
| 'ci-static'
| 'ci-lint'
| 'ci-coverage'
@@ -97,6 +98,7 @@ async function main(args: string[]): Promise<number> {
function parseMode(raw: string | undefined): Mode {
switch (raw) {
case 'ci-primary':
case 'ci-linux-primary':
case 'ci-static':
case 'ci-lint':
case 'ci-coverage':
@@ -112,7 +114,7 @@ function parseMode(raw: string | undefined): Mode {
return raw
default:
throw new Error(
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
)
}
}
@@ -190,6 +192,8 @@ export function gatesForMode(selected: Mode): Gate[] {
switch (selected) {
case 'ci-primary':
return ciPrimaryGates()
case 'ci-linux-primary':
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
case 'ci-static':
return ciStaticGates()
case 'ci-lint':
@@ -331,6 +335,7 @@ function ciConsumerGates(): Gate[] {
}),
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
snapshotGate(restoredBuild),
webSnapshotGate(restoredBuild),
pnpmScript('publint', 'publint'),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
@@ -341,6 +346,15 @@ function ciConsumerGates(): Gate[] {
]
}
function webSnapshotGate(needs: string[]): Gate {
return pnpmScript('web-snapshot', 'test:web:built', {
label: 'web browser snapshot',
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },
needs,
})
}
function ciWindowsBlockingGates(): Gate[] {
return [
pnpmScript('windows-build', 'build', { label: 'build' }),
+4 -8
View File
@@ -1,14 +1,10 @@
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
// Web browser lane (GUI, gate-exempt — not part of the CI sequence yet): real
// host entry points, built-client interaction snapshots, and the replayed
// keyless e2e scenarios, outside the unit/e2e includes. Real-model cases
// self-skip without DEEPSEEK_API_KEY; fixture branches and replay stay
// keyless and deterministic.
// TODO(ci-browser): running this lane in CI requires chromium provisioning
// and reverses the no-browser-in-CI ruling — staged criteria in
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md.
// Web browser lane: real host entry points, built-client interaction snapshots,
// and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI
// pins DSH_SNAPSHOT=replay and compares committed goldens; record/refresh remain
// explicit local workflows. Real-model cases self-skip without DEEPSEEK_API_KEY.
try {
// Node >= 21.7 native; throws when the file does not exist.
process.loadEnvFile(new URL('.env', import.meta.url).pathname)