Files
deepseek-harness/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
creatixchu cb754a0319 feat(locale): derive the initial Settings language from the browser
A first visit resolved to Chinese regardless of the browser: LocaleService
read `dsh.locale` and fell straight back to `zh` when nothing was stored,
ignoring the languages the browser already states it reads.

The initial locale now resolves through three ordered sources — the persisted
preference, then `navigator` (first entry of the ordered language list whose
primary subtag names a shipped locale, so `zh-Hans-CN` -> zh and `en-GB` ->
en), then `FALLBACK_LOCALE`. An explicit choice still wins and nothing writes
the detected locale back to storage, so "has the user chosen?" stays a
question only the stored value answers.

Specs asserting the shipped Chinese copy now state the browser they assume:
the web e2e scenarios open their page with `locale: ZH_BROWSER_LOCALE`, and
package specs pin it through the new `pinBrowserLanguages` test helper.
`settings-chrome.e2e.ts` gains an English-browser scenario as the
assembled-app proof.
2026-07-31 15:26:46 +08:00

18 KiB
Raw Blame History

Agent Note: Web GUI 的无密钥浏览器 e2e 车道

Status: implemented

English | 中文

问题

Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSEServer-Sent Events流 → toFetchHandler/apiproxy → host 端的 agent loop智能体循环、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。GUI 测试体系覆盖第 1 层Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 FixtureApiClient——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 DEEPSEEK_API_KEY 和真实模型,因此不确定、在无密钥 CI 中自行跳过。docs/testing.md 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACPAgent Client Protocol、headless stream-json 与 TUI 三个文本记录transcript表面web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处fixture测试前置数据客户端短路掉的 wire 承载链。

决策

pnpm run test:web 携带 apps/web/tests/ 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 @deepseek-ai/dsh-llm-replay 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久世界状态则使用进程内断言。配套的产品契约包括 dsh-llm-replay 的节奏控制、消费检查与已校验的索引式覆写 patch跨包的 dsh-llm 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 llm-retry,以处理瞬态模型失败。

Scaffoldapps/web/tests/scaffold.ts

一个普通的共享 fixture 模块(测试政策认可的形态),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 dsh-llm-replaydsh-acp-snapshotdsh-session-persistence-jsonl 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。

launchWebScaffold() 通过 vendored Loader 的 include 机制,从交付的 apps/cli/config/base.cordis.ymlapps/cli/config/web.cordis.yml 启动真实 web 组合——与 AppCLIEntrydsh web 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP cordis.snapshot.yml 模式的进程内表达:临时 persistenceRoot;每个主机级 skill-local 根目录(dshHomeagentsHomebundledSkillDir)都钉在临时工作区下并禁用监听,因为环境 skill技能目录是模型可见输入禁用 workspace-context(录制的 fixture 不得嵌入本仓库的 AGENTS.md禁用 session-title-llm其发后不管的标题调用会与循环争抢会话的回放游标webserver 行钉到端口 0 加已构建 dist无密钥模式下禁用 llm-deepseek。patch 的 id 一旦不再匹配任何行boot 扫描会大声失败而不是漂移。boot 在临时工作区 chdir 下运行,使 api-gateway 的 process.cwd() 会话默认值、工具 cwd 与 fixture 一致;dsh web bin 自身的胶水argv、profile json、AppCLIEntry仍由 smoke-real.e2e.ts 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose资源释放再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。

无密钥的模型替换 = 禁用适配器行的 patch 加 installLlmReplay 在停稳的根 ctx 上以提供方目录providers-catalog模式填充开放的 seam——绝不用 catch-all适配器行被禁用后不存在任何适配器catch-all 会让 resolveModelInfo 无路由可走,compact-basic 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k contextWindow 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 ReplayHandle。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。

seedSession() 通过真实持久化 API 播种冷会话——一次性 Context 挂载 SessionStore + SessionPersistenceJsonl 指向 host 的根目录,create() + append(),一次 utimes 回拨保证侧栏顺序确定(semantic-checkpoint.snapshot.ts 先例——绝不裸写文件因此播种器对桶哈希、文件名编码、压缩一无所知host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 turn/end 结尾——未闭合的最终轮次会被恢复resume的崩溃修复改写

确定性规则

回放模式下浏览器断言的屏障栈按序1host 侧 await agent.whenIdle() 加超时,以进程内 turn/end 为锚——空闲翻转发生在持久化落盘之后一次等待同时覆盖轮次完成与持久性2浏览器安定轮询流式输出节点已卸载、最终文本可见。录制模式下日志采收在 whenIdle() 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 turn/end 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发文件轮询被禁止NFS 上慢,且被 whenIdle 取代);networkidle 被彻底禁止SSE 流保持打开时它永不解析)。

不做单次瞬态 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 模块局部类名锚点。常规场景在客户端启动前设置 dsh.locale=en,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 zh-CN 浏览器,因为客户端的初始 locale 由 navigator 推导(由浏览器推导初始 locale),而 settings-chrome.e2e.ts 还额外覆盖双向切换与英文浏览器默认态。

预期输出

具有稳定所属区域的场景会为每个不同的用户可见状态提交一份规范化的 ariaSnapshot();跨区域的工作区管理状态则使用语义 DOM 断言和权威的 host 状态检查。UUID、cwd、工作区目录名与时长等易变内容会归一为稳定 token采集过程持续轮询直到连续两次规范化读取结果相同。Role 与文本锚点继续充当可评审预期输出周围的语义防线,并直接覆盖跨区域状态。世界状态断言使用根上下文的会话事件,而不是第二份提交的日志预期输出,因为 ACP、headless 与 TUI 套件已经通过同一循环和持久化钉住持久化日志表面。refresh 是预期输出的唯一写入者;回放模式下缺少预期输出时,测试会连同重新生成命令一起失败。

类型检查平面切分是结构性的host scaffold、其支持模块以及每个启动或检查 host 组合的 web spec 都会从注册在 client 侧的 apps/web 工程中排除,并逐文件纳入 tsconfig.host.json。一个程序不能同时持有 Cordis Context 合并的两侧。

模式与 fixture

DSH_SNAPSHOT 选择 replay默认无密钥、record带密钥或 refresh无密钥。发起提示的 spec 将所有模式共用的驱动步骤与仅供 replay/refresh 使用的断言分开record 模式驱动真实输入框,采收内存中的会话 header 与事件,脱敏请求头,并 token 化当次运行的会话、cwd 与 RPC 标识。随后一次无密钥 refresh 重新生成 aria 预期输出。每条提示词都会与 fixture 中录制的 user/message 核对;每个场景目录都采用封闭清单,其中每个 JSONL 都是脱敏不动点。Web fixture 全部脱敏请求头且不钉任何 header 类别;见「暂缓」。

覆盖契约

该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、whenIdle() 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。

CI 立场

根据浏览器快照 CI 决策,该车道是 Linux 拉取请求必需的只比较门禁。node 24 / snapshots and artifacts 消费方任务在消费方独立构建中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium恢复以操作系统和锁文件为键的缓存并用 DSH_SNAPSHOT=replay 运行该车道。这是有意的平面切分host 与 spec 使用 tsx 源码启动契约,浏览器则消费 apps/web/dist 和包的 lib/client.js 产物,因此门禁依赖 built-package-invariants 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX并继续置于 Windows 和 macOS 矩阵之外。

业界先例

调研了 AI 聊天/agent web UI 与 mock 层LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlitPlaywright HAR/route、MSW、Polly/nock、WireMock、aimock。自有后端的应用的主流成熟架构是真实后端 seam 后放一个进程内伪造/回放模型下游全部真实LibreChat 的 LIBRECHAT_TEST_RUN_HOOK 伪模型ai-chatbot 的 MockLanguageModelV3 + simulateReadableStreamcontinue 的脚本化 mock 提供方类)——这正是 dsh-llm-replay 已然所是。浏览器层 SSE 拦截无法检验增量渲染(route.fulfill 一次性交付整个响应体playwright#33564且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现LibreChat 默认 10ms 附慢速档ai-chatbot 500msCI 里的真实模型会腐烂open-webui 的套件长出 120 秒超时先被禁用后被删除会话在持久化层以受控时间戳播种LibreChat 直插回拨时间的 Mongo 文档langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixtureaimock与前端层 socket 历史发射OpenHands MSW——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。

曾考虑的替代方案

浏览器网络层 SSE 拦截(page.route)。 已否决:route.fulfill 无法流式输出,增量 token 渲染无从检验,且服务端 SSE/背压/关闭路径——两起已实证 P0 的藏身处——完全失测。

DEEPSEEK_BASE_URL 处的 mock HTTP 提供方。 作为本车道机制已否决仅保留给既有的工作区探针冒烟fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。

扩展 ?fixture 客户端。 已否决:分层纪律——FixtureApiClient 的存在意义就是脱离服务器测试客户端 shellclient API seam 以下按构造即失测。

用占位 DEEPSEEK_API_KEY + 回放拦截替代禁用适配器行。 尽管零组合改动且树内有两处先例仍被否决:它用谎言满足 llm-deepseek 的快速失败密钥检查还留下一个挂载却被拦截的死适配器禁用行ACP overlay 的同款做法)是诚实的无密钥,并在最早可解析点快速失败。

packages/support/web-snapshot 包 + defineWebSnapshotSuite 工厂。 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且除受门禁的包已导出的辅助工具与本地 scaffold 外,这些场景专用交互尚未形成稳定的无浏览器契约。出现第二个 web 形态消费方,或被证实重复的生命周期代码确立该契约后,再重新考虑。

第二份提交的规范化会话日志预期输出。 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。

DSH_SNAPSHOT 回放分支拉起 dsh web bin。 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 apps/cli/config/base.cordis.ymlapps/cli/config/web.cordis.yml;只剩 argv、profile JSON 和 AppCLIEntry 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。

为可测试性改 wire 协议。 已否决:契约已有第一等的无密钥同构 seamInProcessApiClient(toFetchHandler(api))),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。

以真实模型浏览器测试充当无密钥车道。 已否决按构造即不确定被调研的前车之鉴open-webui长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。

客户端 data-dsh-busy 安定信号。 暂缓host 侧 whenIdle 屏障配合稳定 DOM 轮询,足以覆盖当前场景。第一次安定轮询抖动,或必要状态在 DOM 中不可观察时,再重新考虑。

Testing

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 显式选择回放模式。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。dsh-llm-replay 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。

暂缓

  • Web 头类别钉住web fixture 处处 token 化 {{system}}/{{tools}},没有场景钉住 web 组合的提示词/工具 schemaTODO(web-header-pin)——scaffold 的 recordFixture JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。
  • 恢复后追问场景:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。
  • Web 错误表面:客户端不消费任何 agent/error分片前的失败也没有可冻结的部分输出因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 errorFIXME(web-error-surface) 标记了待 UI 长出错误渲染后断言可见错误文本的位置。
  • 输入框 steering 手势:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steerTODO(web-steer-composer) 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。
  • 拖拽会话重排workspace.insertSessionBefore 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。

后果

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 的 PRPull Request持有相应的预期输出 diff。