<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>测试在玩AI</title><description>一个测试工程师折腾 AI 的现场记录</description><link>https://yjfkk.dev/</link><language>zh_CN</language><item><title>给 DSH 装一个 Claude Code 模式：一个 preset 插件的痛点、做法与实测</title><link>https://yjfkk.dev/posts/dsh-preset-claude-code/</link><guid isPermaLink="true">https://yjfkk.dev/posts/dsh-preset-claude-code/</guid><description>DSH（DeepSeek Harness）把「引擎」和「怎么干活」拆成了两层：模型能力之外，一个 Agent 的行为风格几乎全压在预设的那段 persona 上。官方四个预设里 standard 只有一句话，minimal 干脆把其余 prompt 段落全关掉——没有人管「别自述、先读后改、改完自证」这类工程纪律。我写了个插件 dsh-preset-claude-code，不 fork 官方、以一个新 bundle 层的身份往组合树里插一行 preset，把 Claude Code 那套提示词和工具集搬进来。过程中踩到一个很实在的坑：包「装上了」不等于「生效了」，判定标准只有一个——dump 里有没有你那一行。附一次同题对照实测：只换 persona，输出 token 1483→851。</description><pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DSH（DeepSeek Harness，npm 上的 &lt;code&gt;@deepseek-ai/dsh&lt;/code&gt;）的预设体系里，有一段配置决定了 Agent「怎么干活」——&lt;code&gt;persona&lt;/code&gt; 的 &lt;code&gt;prefix&lt;/code&gt;。官方 &lt;code&gt;standard&lt;/code&gt; 预设的这一段是这个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;prefix: You are a coding agent powered by the {{model}} model.
suffix: Your working directory is {{cwd}}.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一句话。官方 &lt;code&gt;minimal&lt;/code&gt; 预设则是另一种极端：&lt;code&gt;complete: true&lt;/code&gt;，&lt;code&gt;prefix&lt;/code&gt; 固定为 &lt;code&gt;You are a helpful software engineer assistant.&lt;/code&gt;，&lt;code&gt;includeRuntimeContext: false&lt;/code&gt;——按官方文档说法，这个 agent 身上「其他 prompt section 和面向模型的插件全部缺席」。&lt;/p&gt;
&lt;p&gt;两种我都不想要。我想要的是&lt;strong&gt;任务纪律被明文写下来&lt;/strong&gt;：别自述、先读后改、改完自己跑测试、别整文件重写。于是写了这个插件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;仓库：&lt;a href=&quot;https://github.com/ABK3528/dsh-preset-claude-code&quot;&gt;https://github.com/ABK3528/dsh-preset-claude-code&lt;/a&gt;（MIT，v1.0.1）&lt;/li&gt;
&lt;li&gt;形态：DSH bundle 插件。&lt;strong&gt;不 fork 官方、不改官方预设&lt;/strong&gt;，只是以一个新 bundle 层的身份，往组合树里插一行自己的 preset&lt;/li&gt;
&lt;li&gt;效果：新建会话的预设列表里多一个「Claude Code 模式」，&lt;code&gt;order: 0&lt;/code&gt;，排在官方 &lt;code&gt;standard&lt;/code&gt;(1) / &lt;code&gt;ptc&lt;/code&gt;(2) / &lt;code&gt;minimal&lt;/code&gt;(3) / &lt;code&gt;cordis&lt;/code&gt;(4) 之前&lt;/li&gt;
&lt;li&gt;实测（同一模型、同一道题、只换 persona 那一段）：输出 token &lt;strong&gt;1483 → 851&lt;/strong&gt;，工具调用 10 → 8，墙钟 10s → 7s，正确性不变&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话概括这件事：&lt;strong&gt;DSH 的「风格」不是模型参数，是预设里的一段提示词——所以想换风格不用换模型，装一个 preset 插件就行。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;一、我在 DSH 上遇到的四个痛点&lt;/h2&gt;
&lt;h3&gt;1.1 引擎很强，但「怎么干活」没人管&lt;/h3&gt;
&lt;p&gt;DSH 把一件事分得很清楚：模型、工具、上下文、权限都是组件，而&lt;strong&gt;一个 Agent 的行为风格几乎全压在 &lt;code&gt;persona&lt;/code&gt; 这一段上&lt;/strong&gt;。官方四个预设里，&lt;code&gt;standard&lt;/code&gt; 的人设就是上面那一句话，其余靠模型本能。&lt;/p&gt;
&lt;p&gt;于是同一道题，在默认人设下会变成这样：它先解释一遍自己准备做什么，再动手，最后交一份 1220 字节的汇报；而我要的其实只是「改完，跑通，告诉我改了什么」。（这个数字不是印象，是下面第四节的实测结果。）&lt;/p&gt;
&lt;p&gt;这不是模型能力问题，是&lt;strong&gt;提示词里没有约束&lt;/strong&gt;。Claude Code 的提示词里，「不要叙述你即将做什么」「动手前先读文件」「改完主动验证」是明文条款；在 DSH 默认预设里，这些条款不存在。&lt;/p&gt;
&lt;h3&gt;1.2 想改风格，三条路都不顺手&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;为什么不顺手&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fork 官方 bundle&lt;/td&gt;
&lt;td&gt;改动面太大，官方一升级就要重放，等于自己维护一个分叉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;直接改 &lt;code&gt;~/.dsh&lt;/code&gt; 里的预设行&lt;/td&gt;
&lt;td&gt;能生效，但没法分发、没法版本化，换台机器重来一遍&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;把 &lt;code&gt;persona&lt;/code&gt; 行挂在 preset &lt;strong&gt;外面&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;直接失败&lt;/strong&gt;：官方文档说得很明确，&lt;code&gt;dsh-persona&lt;/code&gt; 行是 scope-only 的，挂在 agent scope 之外会「与 prompt registry 自己的 &lt;code&gt;deployment:persona-prefix&lt;/code&gt; 注册相撞并 fail loud」&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第三条是我最初的做法，启动即报错。它反过来解释了这个插件的形态：&lt;strong&gt;persona 必须装进一个 preset 里，而不是挂在外面&lt;/strong&gt;——因为一个进程里的「全局人设」已经有主了，这个行的存在意义就是「针对某一个 agent 把它顶掉」。&lt;/p&gt;
&lt;h3&gt;1.3 我在多个 harness 之间来回切&lt;/h3&gt;
&lt;p&gt;我平时会在几个 CLI agent 之间切换。提示词纪律不统一，切换成本全落在人身上：这个要提醒它先读代码，那个要提醒它跑测试。把 Claude Code 那套纪律抽成 DSH 的一个预设，本质上是把「我的工作方式」从某个具体工具里解耦出来，变成一份可安装、可版本化的配置。&lt;/p&gt;
&lt;h3&gt;1.4 最坑的一点：装上了 ≠ 生效了&lt;/h3&gt;
&lt;p&gt;这个坑值得单独一节，因为它同时也是这次改动里最值钱的发现——见第三节。&lt;/p&gt;
&lt;h2&gt;二、插件的做法：不 fork，只插一行&lt;/h2&gt;
&lt;h3&gt;2.1 DSH 的组合是分层的&lt;/h3&gt;
&lt;p&gt;官方 &lt;code&gt;apps/cli/reference/README.md&lt;/code&gt; 把启动时的配置组合规则写得很清楚，配置树是这样叠出来的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/01-layering.svg&quot; alt=&quot;DSH 的配置分层与 preset 注入&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;原文：&lt;em&gt;the effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest&apos;s &lt;code&gt;dsh.profile.bundles&lt;/code&gt; list, the profile&apos;s own &lt;code&gt;cordis.patch.yml&lt;/code&gt;, the home-level &lt;code&gt;$DSH_HOME/cordis.patch.yml&lt;/code&gt; … and each &lt;code&gt;--patch&lt;/code&gt; overlay in argv order. Later layers win per row; a patch replaces the targeted row&apos;s complete &lt;code&gt;config&lt;/code&gt; value rather than deep-merging keys, and may insert new rows.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;两句话翻译过来：&lt;strong&gt;后面的层按行覆盖前面的层&lt;/strong&gt;，而&lt;strong&gt;补丁是「整行替换」，不是深合并&lt;/strong&gt;。这两点决定了插件的全部写法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;插件以「一个新的 bundle 层」的身份出现 → 官方那四个预设一行都不用动，我的 &lt;code&gt;order: 0&lt;/code&gt; 只是排在它们前面；&lt;/li&gt;
&lt;li&gt;因为 &lt;code&gt;config&lt;/code&gt; 是整行替换 → 写 preset 时必须把 &lt;code&gt;config&lt;/code&gt; 写全，别指望和官方默认值合并。这是我踩的第二个坑：第一版写了残缺的 &lt;code&gt;config&lt;/code&gt;，&lt;code&gt;--dump-config&lt;/code&gt; 一出来默认值全丢了。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.2 这个插件的实体，是一份 patch 文件&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;lib/index.js&lt;/code&gt; 只有 396 字符，就是宿主生命周期入口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export default class ClaudeCodePresetPlugin {
  static name = &apos;dsh-preset-claude-code&apos;;
  constructor(ctx, config) { this.ctx = ctx; this.config = config || {}; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正干活的是 &lt;code&gt;cordis.patch.yml&lt;/code&gt;——一份声明式补丁：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# dsh bundle patch: inserts Claude Code agent preset into DSH host composition
- insert:
    - id: preset-claude-code
      name: &apos;@deepseek-ai/dsh-agent-preset&apos;
      config:
        id: claude-code
        name: Claude Code 模式
        order: 0
        plugins:
          - id: persona
            name: &apos;@deepseek-ai/dsh-persona&apos;
            config:
              complete: true
              includeRuntimeContext: true
              suffix: Your working directory is {{cwd}}.
              prefix: &amp;gt;-
                You are Claude Code, Anthropic&apos;s official CLI ...
                # Core Operating Principles 1. Direct Action: ...
          # ... 省略：工具行与两个隔离组，见下图
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好处是简单、可审计：一个 preset 长什么样，读 YAML 就知道。代价也很直接——&lt;strong&gt;&lt;code&gt;test/validate.test.js&lt;/code&gt; 只能做静态断言&lt;/strong&gt;（检查 patch 文本里必须出现 &lt;code&gt;@deepseek-ai/dsh-persona&lt;/code&gt;、&lt;code&gt;@deepseek-ai/dsh-tool-bash&lt;/code&gt; 等字样），它验证不了「装上去之后真的生效」。1.4 那个坑就是这么漏过去的。下一步我打算把 &lt;code&gt;dsh --dump-config&lt;/code&gt; 的输出做成快照测试，让「生效」这件事也有断言。&lt;/p&gt;
&lt;h3&gt;2.3 preset 里面装了什么&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/02-preset-anatomy.svg&quot; alt=&quot;preset-claude-code 的内部结构&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;行 / 组&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;关键配置&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;persona&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;身份与行为纪律，接管 system prompt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;complete: true&lt;/code&gt;、&lt;code&gt;includeRuntimeContext: true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent-instructions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;读 &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;maxBytes: 65536&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具行&lt;/td&gt;
&lt;td&gt;bash / pwsh（按平台二选一）、fs、fs-search、jobs、skill-filesystem、tool-skill、goal、todo、ask-user、web、present&lt;/td&gt;
&lt;td&gt;&lt;code&gt;disabled: !!js process.platform === &apos;win32&apos;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;planning&lt;/code&gt; 组&lt;/td&gt;
&lt;td&gt;计划模式（动代码前先说方案）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;group: true&lt;/code&gt;、&lt;code&gt;isolate: { planMode: true }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compaction&lt;/code&gt; 组&lt;/td&gt;
&lt;td&gt;压缩、&lt;code&gt;/compact&lt;/code&gt;、工具结果剪枝&lt;/td&gt;
&lt;td&gt;&lt;code&gt;isolate: { compaction: true, toolResultPruner: true }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三个设计决定，每个都有明确的理由：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;① 为什么 &lt;code&gt;complete: true&lt;/code&gt;。&lt;/strong&gt; 官方文档说，开了它之后「prompt registry 会把这段 prefix 恢复成唯一的 section；身份、后缀、工具指引、监听器都不能再追加提示词文本」。等于把「这个 agent 是谁」完全交给这一段，所以它必须自己把该说的说完——我配 &lt;code&gt;includeRuntimeContext: true&lt;/code&gt; 把工作目录之类的运行时上下文补回来，否则连这些也会被抑制掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② 为什么工具行要一行行显式列。&lt;/strong&gt; preset 是完整组合，不是增量。想让它手里有什么牌，就写什么。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ 为什么用 &lt;code&gt;isolate&lt;/code&gt; 组。&lt;/strong&gt; planMode 与 compaction 各自隔离成组，避免和宿主重复注册同一批服务。&lt;/p&gt;
&lt;h3&gt;2.4 提示词里我都写了什么&lt;/h3&gt;
&lt;p&gt;不是照抄，是按「我在 DSH 上真正想让模型改掉的行为」列的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;直接行动&lt;/strong&gt;——不要在做之前叙述你即将做什么，直接调工具；纯文本回复（除最终交付）控制在 1–2 句；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先读后改&lt;/strong&gt;——动手前必须读文件和检索代码，不许猜文件结构、行号、函数签名；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;手术刀编辑&lt;/strong&gt;——优先精确字符串替换，而不是整文件重写；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;执行与自愈&lt;/strong&gt;——改完主动跑测试/静态检查，报错就读栈修因再验证；&lt;/li&gt;
&lt;li&gt;另外三条：&lt;strong&gt;上下文经济&lt;/strong&gt;（定向读取，别灌上下文）、&lt;strong&gt;原生斜杠工作流&lt;/strong&gt;（&lt;code&gt;/init&lt;/code&gt;、&lt;code&gt;/doctor&lt;/code&gt;、&lt;code&gt;/compact&lt;/code&gt;、&lt;code&gt;/review&lt;/code&gt;）、&lt;strong&gt;权限与安全边界&lt;/strong&gt;（破坏性操作要谨慎，严格留在工作区内）。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;三、实测一：装上去，但没生效&lt;/h2&gt;
&lt;p&gt;装好包、重启客户端、新建会话——预设列表里什么都没有。翻日志找到了这句话：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ dsh plugin --profile t1 add link:/path/to/dsh-preset-claude-code
dsh: initialized profile t1 at ~/.dsh/profiles/t1
+ dsh-preset-claude-code link:/path/to/dsh-preset-claude-code
dsh: warning: dsh-preset-claude-code declares no dsh.bundle
     — installed as a plain dependency, not a profile layer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;包确实装进了 &lt;code&gt;node_modules&lt;/code&gt;，但&lt;strong&gt;它从来没有进入组合树&lt;/strong&gt;。证据两条：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# profile manifest 里的 bundle 列表，没有我的包
$ python3 -c &quot;import json;print(json.load(open(&apos;~/.dsh/profiles/t1/package.json&apos;))[&apos;dsh&apos;])&quot;
{&apos;profile&apos;: {&apos;bundles&apos;: [&apos;@deepseek-ai/dsh-base&apos;]}}

# 组合树里搜不到我的 preset
$ dsh --profile t1 --dump-config | grep -c preset-claude-code
0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因在官方文档里写着：&lt;code&gt;dsh plugin add&lt;/code&gt; 成功之后，DSH 会拿安装结果去对账 &lt;code&gt;dsh.profile.bundles&lt;/code&gt;——&lt;strong&gt;「每个解析到声明了 &lt;code&gt;&quot;dsh&quot;: { &quot;bundle&quot;: { &quot;patch&quot;: &quot;./cordis.patch.yml&quot; } }&lt;/code&gt; 的包的依赖会加入层栈」，没声明的「只作为普通依赖保留，并给一次性警告」&lt;/strong&gt;。我的 &lt;code&gt;package.json&lt;/code&gt; 里当时没有这个字段。&lt;/p&gt;
&lt;p&gt;修法四行 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;dsh&quot;: { &quot;bundle&quot;: { &quot;patch&quot;: &quot;./cordis.patch.yml&quot; } }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再装一遍，证据反转：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ dsh plugin --profile t2 add link:/path/to/dsh-preset-claude-code-fixed
$ python3 -c &quot;...[&apos;dsh&apos;]&quot;
{&apos;profile&apos;: {&apos;bundles&apos;: [&apos;@deepseek-ai/dsh-base&apos;, &apos;dsh-preset-claude-code&apos;]}}

$ dsh --profile t2 --dump-config | grep -n &quot;preset-claude-code&quot; | head -3
362:# == dsh-preset-claude-code
363:- id: preset-claude-code
367:    name: Claude Code 模式
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（顺带一提：&lt;code&gt;--dump-config&lt;/code&gt; 会给每一行标出它来自哪个层——&lt;code&gt;# == dsh-preset-claude-code&lt;/code&gt; 就是这一行来自哪个 bundle 的证据。查「我的插件到底进没进树」，这一个命令够了。）&lt;/p&gt;
&lt;p&gt;:::warning
&lt;strong&gt;教训：插件「装上了」和「生效了」是两件事。&lt;/strong&gt; 判定标准只有一个——组合树（dump）里有没有你那一行。这个坑不是靠读文档能避免的：README 的安装说明照着 pnpm 的习惯写，&lt;code&gt;pnpm add&lt;/code&gt; 返回成功，人自然认为成功了。
:::&lt;/p&gt;
&lt;h2&gt;四、实测二：换 persona 到底值多少&lt;/h2&gt;
&lt;p&gt;「回复变短了」这种话不算结论，所以做了一次尽量只动一个变量的对照。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;设置&lt;/strong&gt;（除了 persona，全一样）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;引擎&lt;/td&gt;
&lt;td&gt;DSH &lt;code&gt;0.2.0-rc.2&lt;/code&gt;，&lt;code&gt;--profile headless&lt;/code&gt; 一次性任务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;模型&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-official&lt;/code&gt; / &lt;code&gt;deepseek-flash&lt;/code&gt;，&lt;code&gt;reasoningEffort: high&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;权限&lt;/td&gt;
&lt;td&gt;permission preset &lt;code&gt;workspace-write&lt;/code&gt;，approval &lt;code&gt;ask&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;任务&lt;/td&gt;
&lt;td&gt;仓库里一个带两个 bug 的 &lt;code&gt;mathx.py&lt;/code&gt; + 一个可执行测试脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;唯一变量&lt;/td&gt;
&lt;td&gt;agent 的 persona 前缀：&lt;strong&gt;A&lt;/strong&gt; 用 DSH 默认 &lt;code&gt;standard&lt;/code&gt; 那一句；&lt;strong&gt;B&lt;/strong&gt; 用插件的 Claude Code 前缀&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两个测量细节值得说一下，因为它们让「感觉」变成了「数」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;测试到底跑没跑&lt;/strong&gt;：测试脚本每次被执行都往 &lt;code&gt;runs.log&lt;/code&gt; 追一行。模型说「测试通过」不算数，文件里的记录才算。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;token 与工具调用&lt;/strong&gt;：从 &lt;code&gt;$DSH_HOME/sessions/**/session.v4.jsonl.zstd&lt;/code&gt;（多帧 zstd）解出事件流，统计 &lt;code&gt;assistant/message&lt;/code&gt; 的 &lt;code&gt;usage&lt;/code&gt; 和 &lt;code&gt;tool/call&lt;/code&gt; 的计数，不靠肉眼估。&lt;/li&gt;
&lt;/ul&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;A · 默认 standard persona&lt;/th&gt;
&lt;th&gt;B · Claude Code persona&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;输出 token&lt;/td&gt;
&lt;td&gt;1483&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;851（-43%）&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工具调用总数&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;├ &lt;code&gt;bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;├ &lt;code&gt;read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;├ &lt;code&gt;glob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;└ &lt;code&gt;edit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;步骤数&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;墙钟&lt;/td&gt;
&lt;td&gt;10 s&lt;/td&gt;
&lt;td&gt;7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最终答复长度&lt;/td&gt;
&lt;td&gt;1220 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;399 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;测试执行次数&lt;/td&gt;
&lt;td&gt;2（复现 + 验证）&lt;/td&gt;
&lt;td&gt;2（复现 + 验证）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结果&lt;/td&gt;
&lt;td&gt;两个 bug 都修好&lt;/td&gt;
&lt;td&gt;两个 bug 都修好&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;比 token 数更有意思的是&lt;strong&gt;工具路径变了&lt;/strong&gt;。默认人设下它用 &lt;code&gt;bash&lt;/code&gt; 去探路（5 次）；Claude Code 人设下变成先 &lt;code&gt;glob&lt;/code&gt; 找结构、再 &lt;code&gt;read&lt;/code&gt; 读文件，&lt;code&gt;bash&lt;/code&gt; 只用来跑测试（3 次）。这不只是「话少了」，是行为路径变了——从「边试边看」变成「先定位再动手」。&lt;/p&gt;
&lt;p&gt;:::note
&lt;strong&gt;边界要说清楚&lt;/strong&gt;：n=1，一道题，一次运行，样本小，只说明方向，不构成基准测试。另外这次实验是把 persona 前缀挂到 deployment 的 &lt;code&gt;system-prompt&lt;/code&gt; 行上跑的（headless 一次性任务没有预设注册表，没法选预设），所以它衡量的是&lt;strong&gt;提示词那部分的收益&lt;/strong&gt;，不等于整个 preset 的收益。要更硬的结论，需要多题、多次、多模型地跑。
:::&lt;/p&gt;
&lt;h2&gt;五、使用感受&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;先说好的：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最直观的变化是&lt;strong&gt;动手变早了&lt;/strong&gt;：默认人设下它习惯先叙述再执行，现在是直接调工具；&lt;code&gt;complete: true&lt;/code&gt; 把身份完全接管之后，这种自述倾向被压得很明显。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;改完会自己跑测试&lt;/strong&gt;，报错自己读栈、自己修、再验一遍——这是我手动提醒次数掉得最多的一项。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;plan mode 那条最实用&lt;/strong&gt;：动代码之前先把方案摆清楚、等 &lt;code&gt;exit_plan_mode&lt;/code&gt;。这段「架构师姿态」是写进 &lt;code&gt;plan-mode&lt;/code&gt; 的 &lt;code&gt;section&lt;/code&gt; 里的，所以它是提示词级的约束，不是靠模型自觉。&lt;/li&gt;
&lt;li&gt;工具集是显式列的，所以我很清楚「这个模式下模型手里有什么牌」，而不是跟着宿主走。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;再说代价和还没解决的：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;gt;-&lt;/code&gt; 折叠标量把编号列表拍平了。&lt;/strong&gt; dump 出来我的 &lt;code&gt;prefix&lt;/code&gt; 是这么一行：&lt;code&gt;# Core Operating Principles 1. Direct Action: ... 2. Codebase Grounding: ...&lt;/code&gt;——八个原则挤成一整段，标题和编号的结构信息丢了大半。这是我下一步第一件要改的事：换成 &lt;code&gt;|&lt;/code&gt;（literal block）保留换行。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;complete: true&lt;/code&gt; 是双刃剑。&lt;/strong&gt; 干净（宿主不能追加任何提示词文本）的另一面是：提示词得自己写全，漏了就是真没有。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;order: 0&lt;/code&gt; 只解决排序，不解决默认。&lt;/strong&gt; registry 的 &lt;code&gt;default&lt;/code&gt; 仍然是 &lt;code&gt;standard&lt;/code&gt;；想让它成为默认，还得改 registry 的 &lt;code&gt;default&lt;/code&gt; 配置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bundle 层的变更要重启。&lt;/strong&gt; 装/删 bundle 之后，正在跑的 profile 不会热更新（官方文档明确写了这个启动边界），只有 &lt;code&gt;cordis.patch.yml&lt;/code&gt; 的普通编辑才走热重载。桌面端就是「重启客户端」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;校验只能静态做&lt;/strong&gt;（见 2.2）。这是目前最真实的缺口：这个插件的「正确性」眼下只由一条 grep 式的断言和我的手工 dump 保证。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;一句话总结：&lt;/strong&gt; 提示词层是当前性价比最高的一档——不动模型、不动工具、不动权限，只换一段 persona，换来的行为差异可以量化。尤其对「整天在不同 agent 之间切」的人来说，把工作方式做成一份可安装的配置，比反复口头提醒可靠得多。&lt;/p&gt;
&lt;h2&gt;六、想自己写一个 preset 插件？三步&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;写 &lt;code&gt;cordis.patch.yml&lt;/code&gt;：&lt;code&gt;insert&lt;/code&gt; 一行 &lt;code&gt;@deepseek-ai/dsh-agent-preset&lt;/code&gt;，&lt;code&gt;config&lt;/code&gt; &lt;strong&gt;写全&lt;/strong&gt;（patch 是整行替换，不深合并）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;package.json&lt;/code&gt; 里声明 &lt;code&gt;dsh.bundle.patch&lt;/code&gt;，然后 &lt;code&gt;dsh plugin --profile &amp;lt;name&amp;gt; add &amp;lt;包名或本地路径&amp;gt;&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;验证：&lt;code&gt;dsh --profile &amp;lt;name&amp;gt; --dump-config | grep &amp;lt;你的 preset id&amp;gt;&lt;/code&gt;，顺手 &lt;code&gt;--dump-config-schema&lt;/code&gt; 看你的行有没有进 schema。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果只是自己用、不打算分发，还有更轻的一条路：&lt;code&gt;dsh --profile web --patch ./cordis.patch.yml&lt;/code&gt;——把它当覆盖层用，什么都不用安装。&lt;/p&gt;
&lt;h2&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;插件仓库：&lt;a href=&quot;https://github.com/ABK3528/dsh-preset-claude-code&quot;&gt;https://github.com/ABK3528/dsh-preset-claude-code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dsh&lt;/code&gt; CLI 行为参考（profile 启动、插件管理、配置 dump 的分层与覆盖规则）：&lt;a href=&quot;https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md&quot;&gt;https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;包文档：&lt;code&gt;@deepseek-ai/dsh-persona&lt;/code&gt;（&lt;code&gt;complete&lt;/code&gt; / &lt;code&gt;includeRuntimeContext&lt;/code&gt; / scope-only 的原因）、&lt;code&gt;@deepseek-ai/dsh-agent-preset-registry&lt;/code&gt;（预设注册表与 roster）&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>不写一行用例的线上巡检：一个自主爬站引擎的痛点、做法与两组实测</title><link>https://yjfkk.dev/posts/jev-inspector/</link><guid isPermaLink="true">https://yjfkk.dev/posts/jev-inspector/</guid><description>线上故障最常见的样子不是「503 挂了」，而是页面还在、按钮还能点、功能悄悄没了——接口 500，前端 catch 住，右下角弹一句「系统开小差」，探活依旧绿。手写 E2E 用例能覆盖，但维护不起。我做的这个引擎换了个思路：给一个根 URL，让它自己在真实 Chromium 里逛完整站，用四层递进去重挡住无限下钻、三级弹性回溯挡住状态污染、多标签页守护挡住内存泄漏，命中静默故障就连带截走现场图。为了验证它到底靠谱不靠谱，我自己造了一个假站点做两组实测：整站巡检 8 页、25 个动作，832 个原生元素里只碰了 25 个（3.0%），跳过 807 次重复；随后一组 2×2 对照把「静默失败能被抓到」的两个前提拆开了——四组里只有一组成功，反过来也说明这条链上有两个必须先检查的地方。</description><pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;线上故障最常见的形态，其实不是「服务挂了」。挂了反而简单——HTTP 503、探针变红、告警响，谁都知道出事了。&lt;/p&gt;
&lt;p&gt;难的是另一种：**页面还在，按钮还能点，功能悄悄没了。**一个接口返回 500，前端 &lt;code&gt;catch&lt;/code&gt; 住，右下角弹一句「系统开小差，请稍后重试」，页面本身 HTTP 200，探活绿油油。用户来投诉的时候，你的监控面板上什么都看不出来。&lt;/p&gt;
&lt;p&gt;我在公司是唯一一个 QA（对，整个测试团队只有一个人），平时测的是 AI 网关这类后台系统。这类页面按钮多、状态多、改造频繁，两种常规手段都不太趁手：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;手段&lt;/th&gt;
&lt;th&gt;真实体验&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl /health&lt;/code&gt; 探活&lt;/td&gt;
&lt;td&gt;用户能点的东西它一个都没碰过。SPA 里接口 500 而页面 200 的场景，它 100% 看不见&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;手写 Playwright / Cypress 用例&lt;/td&gt;
&lt;td&gt;写得出来，维护不起。表格里一个 &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; 被重构成 &lt;code&gt;&amp;lt;div role=&quot;button&quot;&amp;gt;&lt;/code&gt;，几十条用例一起红；加一个新字段，我得改三处选择器&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;我想要的东西很具体：&lt;strong&gt;给一个根 URL，它自己逛、自己判断有没有坏、自己出报告。我不要写选择器，也不要维护用例。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;于是做了 &lt;a href=&quot;https://github.com/ABK3528/jev-inspector&quot;&gt;jev-inspector&lt;/a&gt;：Apache-2.0，纯 Python，仓库里约 4,500 行代码（核心包 &lt;code&gt;jev_inspector/&lt;/code&gt; 2,636 行，其中爬取引擎 &lt;code&gt;crawler.py&lt;/code&gt; 950 行），&lt;strong&gt;唯一依赖是 &lt;code&gt;playwright&amp;gt;=1.40.0&lt;/code&gt;&lt;/strong&gt;，其余全部走标准库。&lt;/p&gt;
&lt;h2&gt;一、先说清楚：为什么「随便写个爬虫点链接」不行&lt;/h2&gt;
&lt;p&gt;我一开始也是这么想的，直到发现这类任务有三个死局，任何一个都能让脚本在几分钟内自己把自己弄死。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;死局一：无限下钻。&lt;/strong&gt; 一个订单列表页有 30 行，每行 2 个按钮；一个商品站有 100 万个详情页。普通爬虫会一直走下去，内存和算力都耗在同一种模板页面上，而真正关键的结算页、设置页一个都没碰到。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;死局二：状态污染与回溯失败。&lt;/strong&gt; 点一个「提交」会永久改变状态；点一个「在新标签页打开」会跳到另一个上下文；点开一个带遮罩的弹窗之后，页面上其它元素全被挡住——Playwright 会一直等「元素可点击」，等到超时为止。页面回不去，整轮巡检就卡在那儿了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;死局三：标签页泄漏。&lt;/strong&gt; 只要站里有 &lt;code&gt;target=&quot;_blank&quot;&lt;/code&gt; 或 &lt;code&gt;window.open&lt;/code&gt;，无头浏览器的标签页会不断堆积，内存几分钟内就上去，最后把宿主机拖死。&lt;/p&gt;
&lt;p&gt;这三条决定了它不是「爬虫 + 循环点击」，而是一套&lt;strong&gt;浏览器状态调度问题&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;二、它的做法：四张网&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/01-architecture.svg&quot; alt=&quot;架构分层：浏览器层、防御层、判定层与产物&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;2.1 四层递进去重（对付下钻）&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;做什么&lt;/th&gt;
&lt;th&gt;我的理解&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L1 URL 规范化&lt;/td&gt;
&lt;td&gt;去掉锚点、剔除 &lt;code&gt;utm_*&lt;/code&gt; 与时间戳、重排 query&lt;/td&gt;
&lt;td&gt;同一个页面别被算成十个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L2 路径原型配额&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把 &lt;code&gt;/items/10001&lt;/code&gt;、&lt;code&gt;/items/10002&lt;/code&gt; 抽象成 &lt;code&gt;/items/:id&lt;/code&gt;，每种模板只采样 N 页&lt;/td&gt;
&lt;td&gt;从根上斩断无限下钻，这是整套里最关键的一层&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L3 DOM 结构指纹&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;向上回溯容器，给 &lt;code&gt;tr&lt;/code&gt;、&lt;code&gt;li.card&lt;/code&gt; 这类同构元素算指纹，同一种只测第一个&lt;/td&gt;
&lt;td&gt;30 行表格不会点 60 次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L4 模型语义去重&lt;/td&gt;
&lt;td&gt;剩下真需要判断的交给判定层&lt;/td&gt;
&lt;td&gt;兜底，我这次没配 key，走的是关键词路径&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;2.2 三级弹性回溯（对付状态污染）&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/02-defense.svg&quot; alt=&quot;四层去重与三级回溯&quot; /&gt;&lt;/p&gt;
&lt;p&gt;回溯的三级是从源码里读出来的，&lt;code&gt;_apply_elastic_backtrack&lt;/code&gt; 这个方法写得很清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;L1 原位软回溯&lt;/strong&gt;：按一次 &lt;code&gt;Escape&lt;/code&gt;，然后找 &lt;code&gt;.toast&lt;/code&gt;、&lt;code&gt;.modal-backdrop&lt;/code&gt;、&lt;code&gt;button.close&lt;/code&gt;、&lt;code&gt;[aria-label=&quot;Close&quot;]&lt;/code&gt; 逐个点掉/隐藏。注意这一级是&lt;strong&gt;静默的&lt;/strong&gt;——它不写 INFO 日志，只在异常时 &lt;code&gt;debug&lt;/code&gt; 一句。也就是说「弹窗被自动关掉了」这件事在日志里没有痕迹，想确认得看截图或自己加日志。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;L2 历史回溯&lt;/strong&gt;：一旦发现 URL 漂移，&lt;code&gt;page.go_back(wait_until=&quot;domcontentloaded&quot;, timeout=4000)&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;L3 硬重置&lt;/strong&gt;：&lt;code&gt;go_back&lt;/code&gt; 之后还在错页上，就 &lt;code&gt;goto&lt;/code&gt; 回基准 URL，重新拿一个干净上下文。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.3 多标签页守护&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;BrowserContext&lt;/code&gt; 级别挂 &lt;code&gt;context.on(&quot;page&quot;)&lt;/code&gt;：所有被 &lt;code&gt;target=&quot;_blank&quot;&lt;/code&gt; 或 &lt;code&gt;window.open&lt;/code&gt; 打开的新页面都会被抓到，先在它上面做一轮 console / 4xx 检查（子页面的错误也算证据），然后&lt;strong&gt;立刻 &lt;code&gt;.close()&lt;/code&gt;&lt;/strong&gt;。主巡检页面的焦点和浏览器内存因此保持恒定。&lt;/p&gt;
&lt;h3&gt;2.4 判定层：证据链是四路，不是一路&lt;/h3&gt;
&lt;p&gt;这一点是我最看重的设计。判断「这一页坏没坏」，它同时收四种证据：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;提示类 DOM 元素的文本（&lt;code&gt;.toast&lt;/code&gt; / &lt;code&gt;.alert&lt;/code&gt; / &lt;code&gt;[role=alert]&lt;/code&gt; / &lt;code&gt;[class*=error]&lt;/code&gt; …，且要求元素真的可见）；&lt;/li&gt;
&lt;li&gt;这一轮动作之后新增的 console 日志里带 &lt;code&gt;error&lt;/code&gt; / &lt;code&gt;fail&lt;/code&gt; / &lt;code&gt;500&lt;/code&gt; / &lt;code&gt;失败&lt;/code&gt; 这类关键词的行；&lt;/li&gt;
&lt;li&gt;网络层实时拦到的所有 4xx / 5xx 响应（静态资源除外）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alert&lt;/code&gt; / &lt;code&gt;confirm&lt;/code&gt; 这类浏览器弹窗的文本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;四路汇总之后再判定，命中就写一条错误记录并&lt;strong&gt;自动截一张现场图&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error_record = {
    &quot;action&quot;: &quot;button[&apos;刷新数据&apos;] (xpath: //*[@id=\&quot;refresh-btn\&quot;])&quot;,
    &quot;detected_texts&quot;: [
        &quot;Console: [error] Failed to load resource: the server responded with a status of 500&quot;,
        &quot;Console: [error] 请求失败: HTTP 500&quot;,
        &quot;HTTP 500 Internal Server Error on http://127.0.0.1:8765/api/flaky&quot;,
    ],
    &quot;error_type&quot;: &quot;request_error&quot;,
    &quot;jev_confidence&quot;: 0.85,
    &quot;severity_score&quot;: 0.75,
    &quot;screenshot&quot;: &quot;screenshots/error_1791624769615.png&quot;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;三、实测一：我造了一个假站点，让它自己去逛&lt;/h2&gt;
&lt;p&gt;光看代码不算数。为了有个可控的靶子，我用标准库写了一个假站（&lt;code&gt;http.server&lt;/code&gt;，一共 100 多行），刻意埋了几个典型坑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;5 个页面：首页、订单列表（&lt;strong&gt;30 行 × 2 个按钮&lt;/strong&gt;）、个人资料（表单 + 下拉 + 复选）、管理后台（tab 组件）、归档（12 条同构卡片）；&lt;/li&gt;
&lt;li&gt;1 个接口稳定返回 &lt;strong&gt;500&lt;/strong&gt;，前端 &lt;code&gt;catch&lt;/code&gt; 后弹 toast「系统开小差，请稍后重试」，页面本身 200；&lt;/li&gt;
&lt;li&gt;1 个接口要 &lt;strong&gt;4 秒&lt;/strong&gt;才返回；&lt;/li&gt;
&lt;li&gt;首页一个「打开审批弹窗」按钮（遮罩型弹窗）、一个「在新标签页打开」按钮（&lt;code&gt;window.open&lt;/code&gt;）、一条指向 &lt;code&gt;example.com&lt;/code&gt; 的外链；&lt;/li&gt;
&lt;li&gt;后台两个危险按钮：「清空全部缓存」（命中默认词表里的「清空全部」）和「退出登录」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;服务端还记了一份 &lt;code&gt;requests.log&lt;/code&gt;——&lt;strong&gt;工具说自己访问了什么不算数，服务器收到什么才算数&lt;/strong&gt;，这是我做测试的习惯。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3 main.py --url http://127.0.0.1:8765/ --crawl-mode site \
  --max-pages 8 --max-depth 2 \
  --report report.json --html-report report.html --no-fail-on-error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一轮 43.99 秒跑完，结果如下（全部取自 &lt;code&gt;report.json&lt;/code&gt; 与运行日志）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;实测值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;巡检页面数&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发现 URL 数&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;站点拓扑有向边&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;执行动作数&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;遇到的原生元素&lt;/td&gt;
&lt;td&gt;832&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;去重跳过次数&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;807&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;原生元素采样率&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;独立控件指纹&lt;/td&gt;
&lt;td&gt;25 种（自测 25 种 → &lt;code&gt;element_coverage_rate&lt;/code&gt; 1.0）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL 原型&lt;/td&gt;
&lt;td&gt;6 个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;捕获错误&lt;/td&gt;
&lt;td&gt;1 条（&lt;code&gt;request_error&lt;/code&gt;，置信 0.85，严重度 0.75）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;总耗时&lt;/td&gt;
&lt;td&gt;43.99 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;它确实抓到了&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;① 那个 500 被三路证据一起钉住。&lt;/strong&gt; 上面那段 &lt;code&gt;detected_texts&lt;/code&gt; 就是原始记录：console 报了两行、网络层报了一行，外加快照一张。这是我最想要的能力——&lt;strong&gt;页面 200、探活全绿的时候，它还是把问题揪出来了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./evidence-500.png&quot; alt=&quot;工具自动截下的现场图：右上角就是它判定为错误的那句 toast&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② 危险按钮被跳过了。&lt;/strong&gt; 日志里就两行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[INFO] [inspection.engine] Skipping destructive button/action: &apos;清空全部缓存&apos;
[INFO] [inspection.engine] Skipping destructive button/action: &apos;退出登录&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整轮跑完，假站的会话没断，&lt;code&gt;requests.log&lt;/code&gt; 里也没有任何登出/清空相关的请求。对一个要在生产环境跑的巡检工具来说，这条比「多抓几个 bug」重要得多。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ 分页原型配额生效了。&lt;/strong&gt; 列表页有 &lt;code&gt;?page=2..6&lt;/code&gt; 五个分页链接，巡检把它们&lt;strong&gt;点开&lt;/strong&gt;了（&lt;code&gt;requests.log&lt;/code&gt; 里 &lt;code&gt;?page=4/5/6&lt;/code&gt; 各 1 次，点完立刻回溯），但只有 &lt;code&gt;?page=2&lt;/code&gt;、&lt;code&gt;?page=3&lt;/code&gt; 被当成页面排队巡检——因为 &lt;code&gt;/list.html&lt;/code&gt; 这个原型的配额是 3 页（含第 1 页）。&lt;code&gt;url_archetype_distribution&lt;/code&gt; 里 &lt;code&gt;127.0.0.1:8765/list.html: 3&lt;/code&gt;，其余都是 1。这就是「无限下钻」被挡住的样子。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;④ 外链弹窗被守护者接住并关掉了。&lt;/strong&gt; 点到 &lt;code&gt;example.com&lt;/code&gt; 那条 &lt;code&gt;target=&quot;_blank&quot;&lt;/code&gt; 外链时：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[INFO] [Multi-Tab Guardian] New window/tab detected: &amp;lt;Page url=&apos;https://example.com/external&apos;&amp;gt;
[INFO] [Multi-Tab Guardian] New tab loaded: https://example.com/external
[INFO] [Multi-Tab Guardian] Closed popup tab. Focus preserved on main inspection page.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一轮里，L2 回溯触发了 &lt;strong&gt;12 次&lt;/strong&gt;——其中 5 次是在订单列表页里点开分页链接（URL 变成 &lt;code&gt;?page=N&lt;/code&gt;）之后，引擎发现页面漂移、把页面拉回来的，没有一次卡死在错页上。&lt;/p&gt;
&lt;h3&gt;而这份报告里有个数字「漂亮得可疑」&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;element_coverage_rate: 1.0&lt;/code&gt;，也就是控件覆盖率 100%。&lt;strong&gt;同一个响应里，&lt;code&gt;raw_sampling_rate&lt;/code&gt; 是 0.03。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;两个数字都没错，但它们是两个口径：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;element_coverage_rate&lt;/code&gt; = 已测指纹 25 / 发现指纹 25 —— 分母是&lt;strong&gt;去重之后&lt;/strong&gt;的集合，天然容易接近 100%；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;raw_sampling_rate&lt;/code&gt; = 25 / 832 —— 分母是页面上真实存在的元素数。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说「100% 覆盖」的意思是「在它认为不同的 25 种控件里，25 种都点过了」，而不是「832 个元素我都碰过」。&lt;strong&gt;看这类报告必须两个数一起看&lt;/strong&gt;，只看前一个会得出很舒服但没用的结论。这算是我对这个项目第 1 条改进意见：报告首页应该把两个数并列展示。&lt;/p&gt;
&lt;p&gt;还有个更具体的问题：管理后台那页一共 7 个按钮（2 个 tab、3 个工具按钮、2 个危险按钮），&lt;strong&gt;实际只执行了 1 个动作&lt;/strong&gt;（点开「统计」tab）——2 个危险按钮被守卫拦下，另外 4 个被结构指纹判成同质元素跳过了，报告依然给 100%。这一页的去重记录长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;element&quot;: &quot;Tag: &amp;lt;a&amp;gt;, Text: &apos;首页&apos;, Container: &apos;standalone&apos;, Pattern: &apos;header &amp;gt; a&apos;&quot;,
 &quot;reason&quot;: &quot;Structural fingerprint matches already tested container type&quot;,
 &quot;signature&quot;: &quot;single|a|text:首页|pattern:header &amp;gt; a&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一轮 &lt;strong&gt;807 次跳过，100% 都出自 L3 结构指纹&lt;/strong&gt;（L1 URL 规范化、L2 路径原型配额这两层在这么小的站上根本没被触发）。指纹的原料是「标签 + id/文本 + 路径模式」，所以同一个容器里长得一样的一排功能按钮会被判成同一种控件——省了重复点击，也顺手漏掉了同类里的其它功能。我的想法是按「按钮文本 + 语义」再分一层，或者给关键按钮留个白名单。&lt;/p&gt;
&lt;h2&gt;四、实测二：把「静默失败」拆成 2×2&lt;/h2&gt;
&lt;p&gt;第一节那个 500 之所以抓得住，是因为它同时有 console 报错和网络 4xx/5xx。&lt;strong&gt;真正难的是三路证据都没有的那种失败&lt;/strong&gt;：纯前端状态机弹了一句文案，没有请求、没有 console、没有非 2xx。&lt;/p&gt;
&lt;p&gt;我造了第二个靶子：只有一个按钮的页面，点完 &lt;strong&gt;3 秒后&lt;/strong&gt;出现一句纯文本提示「校验失败，请联系管理员」——没有 &lt;code&gt;fetch&lt;/code&gt;、没有 &lt;code&gt;console.error&lt;/code&gt;。然后做了 2×2 对照，两个变量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;提示元素写成 &lt;code&gt;&amp;lt;div id=&quot;toast&quot;&amp;gt;&lt;/code&gt;（&lt;strong&gt;不在&lt;/strong&gt;扫描器的选择器集合里）还是 &lt;code&gt;&amp;lt;div id=&quot;toast&quot; class=&quot;toast&quot;&amp;gt;&lt;/code&gt;（&lt;strong&gt;在&lt;/strong&gt;里面）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wait_after_action&lt;/code&gt; 用默认的 &lt;code&gt;1.0&lt;/code&gt; s 还是 &lt;code&gt;5.0&lt;/code&gt; s。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结果如下（每组一轮，配置只差这两项）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;提示元素&lt;/th&gt;
&lt;th&gt;等待窗口&lt;/th&gt;
&lt;th&gt;状态&lt;/th&gt;
&lt;th&gt;捕获到的证据&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id=&quot;toast&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0 s&lt;/td&gt;
&lt;td&gt;PASSED，0 错误&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id=&quot;toast&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.0 s&lt;/td&gt;
&lt;td&gt;PASSED，0 错误&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;class=&quot;toast&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0 s&lt;/td&gt;
&lt;td&gt;PASSED，0 错误&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;class=&quot;toast&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.0 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAILED，1 错误&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;校验失败，请联系管理员&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/03-silent-failure.svg&quot; alt=&quot;静默失败的两道门：选择器命中和等待窗口缺一不可&quot; /&gt;&lt;/p&gt;
&lt;p&gt;**四组只成功了一组。**两个前提缺一不可：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;选择器得命中&lt;/strong&gt;：扫描器收的是 &lt;code&gt;.toast / .message / .alert / .notification / .error / .danger / .warning / [role=alert] / [class*=error] / [class*=alert]&lt;/code&gt;。我第一版用 &lt;code&gt;id=&quot;toast&quot;&lt;/code&gt; → 索引不到，等多久都没用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等待窗口得够长&lt;/strong&gt;：默认 &lt;code&gt;wait_after_action = 1.0&lt;/code&gt; s，而提示 3 秒后才渲染，检查那一刻元素还是空的。改成 &lt;code&gt;5.0&lt;/code&gt; s 立刻抓到。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只有第三、四组之间的差别，才能把「工具没看见」和「页面真没问题」区分开。&lt;/p&gt;
&lt;p&gt;:::warning
**教训：&lt;code&gt;PASSED&lt;/code&gt; 的含义是「这一轮我没看见故障」，不是「这一页没问题」。**静默失败捕获是有前提的，而且这些前提得自己检查。我现在接入任何站点前都会先过三件事：① 站点的提示组件用什么 class / role，在不在扫描集合里；② 关键异步操作多久才渲染结果，&lt;code&gt;wait_after_action&lt;/code&gt; 够不够；③ 哪几条业务文案是「失败」的意思，要不要补进关键词表。这三条比我第一版写的任何测试脚本都值钱。
:::&lt;/p&gt;
&lt;h2&gt;五、两件得坦白的事&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;① 判定层我这次跑的是「关键词兜底」路径。&lt;/strong&gt; 配了 &lt;code&gt;TYPESAFE_API_KEY&lt;/code&gt; 时，页面证据会被丢给 &lt;code&gt;JevClient&lt;/code&gt; 做一次模型研判；没配（或调用失败）就退化成源码里这一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;is_err = any(kw in combined_text.lower()
             for kw in [&quot;500&quot;, &quot;404&quot;, &quot;crash&quot;, &quot;error&quot;, &quot;失败&quot;, &quot;网络错误&quot;, &quot;internal server error&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，&lt;strong&gt;中文文案里没有上面这些词、网络层又没留下痕迹的话，靠兜底是认不出来的&lt;/strong&gt;。这也是我在实验二里必须先给假站的 toast 补上 &lt;code&gt;class=&quot;toast&quot;&lt;/code&gt; 的原因。想接自己的判定逻辑，正路是扩展钩子（&lt;code&gt;BaseExtension&lt;/code&gt; 提供了 &lt;code&gt;on_page_loaded&lt;/code&gt; / &lt;code&gt;on_after_action&lt;/code&gt; / &lt;code&gt;on_error_detected&lt;/code&gt; / &lt;code&gt;on_finish&lt;/code&gt; 等 7 个挂点）——不过仓库里目前只有基类，没有示例插件，这一块得自己摸索。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② README 里的测试数字和实测对不上。&lt;/strong&gt; 徽章写 &lt;code&gt;Tests-45_Passing&lt;/code&gt;，README 里写着 &lt;code&gt;Ran 45 tests in 70.24s&lt;/code&gt;；我在本地跑 &lt;code&gt;python3 -m unittest discover -p &quot;test_*.py&quot;&lt;/code&gt;，跑了两遍：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ran 36 tests in 70.318s

OK

Ran 36 tests in 69.168s

OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;耗时几乎一模一样（69~70 s vs README 的 70.24 s），&lt;strong&gt;条数差 9 个&lt;/strong&gt;。我数了两遍 &lt;code&gt;def test_&lt;/code&gt;，确实是 36 个。顺带说一句，这套测试不是打桩：里面真的起了本地 HTTP 服务、真的开了 Chromium 跑整站遍历（其中一条用例的单轮巡检就耗时 14.5 秒），这点比数字本身更让我放心。但徽章和 README 该改过来——这是我自己项目，先记在自己账上。&lt;/p&gt;
&lt;h2&gt;六、使用感受&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;先说好的：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;真的是零脚本。&lt;/strong&gt; 从 &lt;code&gt;git clone&lt;/code&gt; 到出第一份报告，我花的全部时间就是 &lt;code&gt;pip install playwright&lt;/code&gt; 加上一条命令。这对「被测系统一天改三次」的团队是刚需——巡检脚本的维护成本是个无底洞。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;产物是给人看的，不是给机器看的。&lt;/strong&gt; JSON 里有 summary、站点拓扑、逐个动作、去重记录；同时生成一份 HTML 看板，还能写 Prometheus 指标文件（我这轮就落了一份 &lt;code&gt;inspection_metrics.prom&lt;/code&gt;，&lt;code&gt;web_inspection_status{target=...} 0&lt;/code&gt;），告警模块内置了飞书/钉钉/企微/Slack 四种 webhook 格式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;它和确定性用例是互补的，不是替代关系。&lt;/strong&gt; 这点是我想清楚之后才明白的：探索式巡检负责「在我没写用例的地方高频扫一遍」，仓库里的 flow 引擎（&lt;code&gt;goto / click / fill / assert&lt;/code&gt;，带 &lt;code&gt;text_contains&lt;/code&gt;、&lt;code&gt;visible&lt;/code&gt;、&lt;code&gt;url_matches&lt;/code&gt; 断言和 SLA 时延违约判定）负责「关键链路必须准」。我的用法是 90% 探索 + 10% 关键链路确定性用例，比纯靠任何一边都稳。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;危险动作有一道闸。&lt;/strong&gt; 词表可配，命中即跳过——这是敢把它指向真实生产环境的前提。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;再说代价和没解决的：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;单站串行，站点一大就慢。&lt;/strong&gt; 8 页 44 秒，平均每页 4~5 秒；并发调度在 &lt;code&gt;scheduler.py&lt;/code&gt;（ThreadPool + SQLite 存历史 + &lt;code&gt;/health&lt;/code&gt; &lt;code&gt;/metrics&lt;/code&gt; 探口）这一层，单站内部仍是串行。大站要拆成多个候选入口并行跑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两个覆盖率要一起看&lt;/strong&gt;（见第三节），报告首页目前只突出一个，容易误导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;L3 结构指纹对「同容器内的一排功能按钮」过于激进&lt;/strong&gt;，会把还该点的按钮一起去重掉。需要白名单或自定义钩子补。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;判定层依赖关键词或外部模型&lt;/strong&gt;，中文文案要么进词表，要么走钩子；这块是目前最薄的一环。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等待窗口和动作超时是两个不同的旋钮。&lt;/strong&gt; &lt;code&gt;action_timeout_ms&lt;/code&gt;（默认 5000）管的是单次交互能不能点成功，&lt;code&gt;wait_after_action&lt;/code&gt;（默认 1.0 s）管的是点完之后给异步渲染留多久——实验二翻的就是后一个的盘。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;README 和徽章的数字要跟实测对齐&lt;/strong&gt;（上面第 ② 条）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;**一句话总结：&lt;strong&gt;这个工具的价值不在于「覆盖所有按钮」——那份 100% 的覆盖率随时可能骗你；它的价值在于&lt;/strong&gt;在我没写用例、探活也照不到的地方，它能自己逛过去，并且在页面仍然返回 200 的时候把故障钉住。**一个只有一个人的测试团队，这种「不用维护的覆盖率」比多写 50 条用例实在得多。&lt;/p&gt;
&lt;h2&gt;七、如果你也想试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/ABK3528/jev-inspector.git &amp;amp;&amp;amp; cd jev-inspector
pip install -r requirements.txt &amp;amp;&amp;amp; playwright install chromium

# 整站巡检一轮
python3 main.py --url https://your-app.com --crawl-mode site \
  --max-pages 10 --max-depth 2 --report report.json --html-report report.html

# 常驻 + 健康检查/指标端口
python3 run_inspection.py --config sites_config.sample.json --serve --port 8089
# CI 里让它失败即退出（错误数 &amp;gt; 0 时返回码 1）
python3 main.py --url https://your-app.com --fail-on-error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接入生产前，建议按第四个实验的结论先做三件小事：把站点的提示组件选择器核对一遍、把 &lt;code&gt;wait_after_action&lt;/code&gt; 调到覆盖你的异步渲染时间、把「失败」类文案补进关键词表。做完这三步，&lt;code&gt;PASSED&lt;/code&gt; 才值得信。&lt;/p&gt;
&lt;h2&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;项目仓库：&lt;a href=&quot;https://github.com/ABK3528/jev-inspector&quot;&gt;https://github.com/ABK3528/jev-inspector&lt;/a&gt;（Apache-2.0）&lt;/li&gt;
&lt;li&gt;仓库里 README 记录的公网实测（Hacker News 8 页、1,067 条拓扑边、2,128 个元素去重掉 2,104 个、44.76 s）：我&lt;strong&gt;没有复跑&lt;/strong&gt;，只作为作者自测记录引用；本文所有数字都来自我自己那两组本地实验&lt;/li&gt;
&lt;li&gt;追问某一行行为时的入口：&lt;code&gt;jev_inspector/engine/crawler.py&lt;/code&gt;（去重、回溯、判定、标签页守护都在这个文件里）、&lt;code&gt;jev_inspector/decision/jev_client.py&lt;/code&gt;（模型客户端）、&lt;code&gt;jev_inspector/extensions/base.py&lt;/code&gt;（7 个扩展钩子）&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>trace-to-test × browser-harness：把一次浏览器操作，编译成零 LLM 的确定性回归</title><link>https://yjfkk.dev/posts/trace-to-test/</link><guid isPermaLink="true">https://yjfkk.dev/posts/trace-to-test/</guid><description>UI 回归的两条老路都有硬伤——手写脚本锚点靠人挑、页面一改就整片红；让 LLM 每次现场驱动浏览器，今天过明天不过。trace-to-test 的做法是：用 browser-harness 录下一次真实操作，让 AI 只在「探索」和「编译」两个阶段介入，把结论固化成一段不依赖模型的确定性代码，并给出可归因的三态判定：这次的失败，到底是产品坏了还是测试写坏了。</description><pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;UI 回归测试一直有个尴尬的地方：&lt;strong&gt;报告是红的，但它说明不了产品是坏的还是脚本是坏的&lt;/strong&gt;。人得先花时间确认这一点，才谈得上定位。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;trace-to-test&lt;/code&gt; 是我最近写的一个框架，专门解决这件事。做法可以一句话说完：&lt;strong&gt;把一次真实的浏览器操作录下来，编译成一条声明式回归，再以零 LLM 的方式确定性重放&lt;/strong&gt;——重放结束只给你一个结论，而那个结论会区分「产品漂移」和「测试写坏」。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;仓库：&lt;a href=&quot;https://github.com/ABK3528/trace-to-test&quot;&gt;https://github.com/ABK3528/trace-to-test&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;语言：Python ≥ 3.12，机制层零业务词，可跨项目复用&lt;/li&gt;
&lt;li&gt;录制层：&lt;code&gt;browser-harness&lt;/code&gt;（CDP 直控 Chrome），可选依赖锁 &lt;code&gt;0.1.8&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;现状：v1 打通了一条 UI 竖切（录制 → 编译 → 回放），&lt;code&gt;make demo&lt;/code&gt; 一条命令可自证&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;两条老路，各自的硬伤&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;硬伤&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;手写脚本（Selenium / Playwright）&lt;/td&gt;
&lt;td&gt;锚点靠人挑。写的时候页面长什么样就照着写，页面一改整片红，而&lt;strong&gt;红的是不是产品坏了&lt;/strong&gt;没人能一眼判断&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;让 LLM 每次现场驱动浏览器&lt;/td&gt;
&lt;td&gt;不确定。同一个用例今天过明天不过，&lt;strong&gt;不可 CI&lt;/strong&gt;，而且失败原因无法归因&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这两种做法的共同问题不是「不好写」，而是&lt;strong&gt;结论不可信&lt;/strong&gt;。第一条路把「产品改文案」和「元素被删掉」混成同一个红；第二条路连「这个红可不可重复」都保证不了。&lt;/p&gt;
&lt;p&gt;还有一条常被忽略的路——&lt;strong&gt;视觉回归（像素 diff）&lt;/strong&gt;。它同样解决不了归因：像素变了到底是布局重构还是缺陷？v1 明确不做这条，只做语义锚点 + 文本/状态断言。&lt;/p&gt;
&lt;h2&gt;核心主张：AI 只进两个阶段&lt;/h2&gt;
&lt;p&gt;框架的全部设计都指向一个取舍：&lt;strong&gt;让 AI 在「探索」和「编译」两个阶段介入，把它的判断固化成一段不依赖模型的确定性代码。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/01-pipeline.svg&quot; alt=&quot;三段式流水线：录制 → 编译 → 回放&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;阶段&lt;/th&gt;
&lt;th&gt;谁来做&lt;/th&gt;
&lt;th&gt;产物&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① 录制&lt;/td&gt;
&lt;td&gt;人（或 AI）手动走一遍真实流程；浏览器驱动与录制由 &lt;code&gt;browser-harness&lt;/code&gt; 提供&lt;/td&gt;
&lt;td&gt;&lt;code&gt;events.jsonl&lt;/code&gt;：动作、坐标、URL、截图、获焦元素&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② 编译&lt;/td&gt;
&lt;td&gt;编译器 + 一次带探针的回放&lt;/td&gt;
&lt;td&gt;&lt;code&gt;workflow.json&lt;/code&gt; / &lt;code&gt;checks.json&lt;/code&gt; / &lt;code&gt;unresolved.jsonl&lt;/code&gt; / &lt;code&gt;checks.todo.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ 回放&lt;/td&gt;
&lt;td&gt;纯代码，&lt;strong&gt;零 LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;三态判定之一 + 失败点&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;回放路径上没有任何模型调用，也没有随机性来源。同一条 workflow 在 CI 上可以跑一万次，结果一致——这正是它能进 CI 的前提。&lt;/p&gt;
&lt;h3&gt;录制层是 browser-harness&lt;/h3&gt;
&lt;p&gt;链路的第一段不是本框架自己实现的：&lt;strong&gt;驱动浏览器、并把动作录下来的那一层是 &lt;a href=&quot;https://github.com/browser-use/browser-harness&quot;&gt;&lt;code&gt;browser-harness&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;（browser-use 出品的开源包）。trace-to-test 把它当&lt;strong&gt;可选依赖&lt;/strong&gt;锁在 &lt;code&gt;0.1.8&lt;/code&gt;（&lt;code&gt;uv sync --extra browser&lt;/code&gt;，不 fork、不 vendor），自己只负责编译和回放。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/07-harness-position.svg&quot; alt=&quot;browser-harness 在链路里的位置：CDP 直控 Chrome，产出录制目录&quot; /&gt;&lt;/p&gt;
&lt;p&gt;它不走 WebDriver，而是&lt;strong&gt;用一条 CDP 连接直控真实 Chrome&lt;/strong&gt;——这点对本框架很关键，因为编译期的探针回放需要在真实页面上执行 &lt;code&gt;elementFromPoint&lt;/code&gt; 这类 DOM 查询。对上层它暴露三样东西：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;模块&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;th&gt;本框架怎么用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;helpers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;goto&lt;/code&gt; / &lt;code&gt;fill_input&lt;/code&gt; / &lt;code&gt;click_at_xy&lt;/code&gt; / &lt;code&gt;press_key&lt;/code&gt; / &lt;code&gt;wait*&lt;/code&gt; 等动作&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Session&lt;/code&gt; 的每一次动作都走它&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;recorder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把动作按时间序写成录制&lt;/td&gt;
&lt;td&gt;录制模式下由 &lt;code&gt;Session&lt;/code&gt; 显式调用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;admin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;daemon 生命周期（自起 Chrome、退出回收）&lt;/td&gt;
&lt;td&gt;保证只连自己起的 Chrome + 独立 profile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一次录制的产物就是一个目录，&lt;code&gt;core/transcript/&lt;/code&gt; 只读它、不做推断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;meta.json      {name, title, started}
events.jsonl   一行一个动作：动作名 + 坐标 + URL + 截图名 + 获焦元素
0001.jpg …     每步动作之后的截图帧
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;框架与 browser-harness 的契约只有这一层。&lt;/strong&gt; 所以它升级时受影响面有限，但也不是零：&lt;code&gt;core/transcript/recording.py&lt;/code&gt; 里那份动作名集合与上游 &lt;code&gt;recorder.ACTIONS&lt;/code&gt; 逐字对齐，并有一条测试盯着——上游改格式会先让测试红，而不是让回归静默变绿。这也是这里&lt;strong&gt;锁 &lt;code&gt;0.1.8&lt;/code&gt; 而不跟最新版&lt;/strong&gt;的原因：录制格式是唯一的对外契约，升级要连带改集合和固定录制样本，值得单独一次提交。&lt;/p&gt;
&lt;p&gt;三个绕不过去的细节（都写在代码注释里）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;只有走 tracing 包装才会产生录制。&lt;/strong&gt; 直接 &lt;code&gt;import helpers&lt;/code&gt; 驱动浏览器&lt;strong&gt;不会&lt;/strong&gt;写出 &lt;code&gt;events.jsonl&lt;/code&gt;，所以 &lt;code&gt;Session&lt;/code&gt; 自己调 &lt;code&gt;recorder.observe&lt;/code&gt;；且坐标必须&lt;strong&gt;按位置参数&lt;/strong&gt;传——&lt;code&gt;recorder._details()&lt;/code&gt; 是按下标取值的，改成关键字参数坐标就变成 &lt;code&gt;null&lt;/code&gt;，编译器随即无坐标可反解。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;BU_NAME&lt;/code&gt; / &lt;code&gt;BU_CDP_URL&lt;/code&gt; 必须在 &lt;code&gt;import browser_harness&lt;/code&gt; 之前写进 &lt;code&gt;os.environ&lt;/code&gt;。&lt;/strong&gt; daemon 名是 import 时读一次的，写晚了会&lt;strong&gt;静默&lt;/strong&gt;落回默认 daemon 并挂到你自己正在用的浏览器上。框架用这两个变量把 browser-harness 关进自起的 Chrome + 独立 profile，退出时按 &lt;code&gt;user-data-dir=&lt;/code&gt; 精确回收（裸路径会误伤无关进程）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码框内容被上游遮蔽成 &lt;code&gt;•&lt;/code&gt;&lt;/strong&gt;，URL 里的凭据也会被 scrub 成 &lt;code&gt;REDACTED&lt;/code&gt;。这直接决定了「要编译的流程应当从已登录会话开始录」（详见下文已知边界）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;这条链上有两个语义鸿沟&lt;/h2&gt;
&lt;p&gt;「录制 → 回放」听起来像录像回放，实际不是。中间隔着两个语义鸿沟，这也是这个框架存在的全部理由。&lt;/p&gt;
&lt;h3&gt;鸿沟一：录制记的是「做了什么」，回归要的是「怎么找到它」&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;events.jsonl&lt;/code&gt; 里一条点击长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;helper&quot;: &quot;click_at_xy&quot;, &quot;x&quot;: 412, &quot;y&quot;: 306, &quot;url&quot;: &quot;http://127.0.0.1:8712/list&quot;, &quot;box&quot;: {...}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;坐标直接写进回归，等于把分辨率、字号、布局全锁死。所以 &lt;code&gt;core/compile/&lt;/code&gt; 的真身不是转录器，而是**「坐标 → 语义锚点」的反解器**。&lt;/p&gt;
&lt;p&gt;麻烦在于：录制里&lt;strong&gt;没有「被点中的那个元素」&lt;/strong&gt;。browser-harness 写进 &lt;code&gt;events.jsonl&lt;/code&gt; 的 &lt;code&gt;box&lt;/code&gt; 是&lt;strong&gt;当前获焦元素&lt;/strong&gt;的框，不是点击目标。想知道「当时点的是谁」，只能拿着坐标回到实时页面上重新问一次浏览器。&lt;/p&gt;
&lt;p&gt;因此编译是一次&lt;strong&gt;带探针的回放&lt;/strong&gt;：按录制顺序重放，在每个需要锚点的动作执行之前，用 &lt;code&gt;document.elementFromPoint(x, y)&lt;/code&gt;（点击类）或 &lt;code&gt;document.activeElement&lt;/code&gt;（输入/按键类）取元素快照，再交给纯函数排序器产出候选锚点。&lt;strong&gt;编译器复用回放引擎——两者是同一个引擎的两种模式&lt;/strong&gt;：回放模式断言结果，探针模式采集锚点。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/03-probe-compile.svg&quot; alt=&quot;编译期的探针回放：为什么必须再走一遍浏览器&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这也带来两条硬约束：&lt;strong&gt;编译期目标必须可达&lt;/strong&gt;，且&lt;strong&gt;视口要与录制时一致&lt;/strong&gt;（探针按录制坐标去问元素，视口变了它问到的就是另一个元素，而一切看起来都正常）。&lt;/p&gt;
&lt;p&gt;锚点的优先级是固定的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/04-anchor-priority.svg&quot; alt=&quot;锚点优先级：testid → role → text → path → xy&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;testid&lt;/code&gt; 排第一而不是文本，理由很实在：&lt;strong&gt;文案会随发布变化&lt;/strong&gt;，拿文案定位会把「按钮没了」和「按钮改叫别的了」混成一件事。所以 &lt;code&gt;role&lt;/code&gt;/&lt;code&gt;text&lt;/code&gt; 类锚点会被标上 &lt;code&gt;copy_sensitive: true&lt;/code&gt;，交给三态判定去区分——见下文。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;xy&lt;/code&gt; 只是最后兜底：用到会打 &lt;code&gt;WARN&lt;/code&gt;，并且必须在报告里单列。&lt;/p&gt;
&lt;p&gt;:::warning
&lt;strong&gt;反解不出的事件不许默默降级成坐标。&lt;/strong&gt; 解不出的动作会写成 &lt;code&gt;unresolved.jsonl&lt;/code&gt; 的一条，带上原始事件、候选锚点列表、以及&lt;strong&gt;为什么解不出&lt;/strong&gt;（例如 &lt;code&gt;value_ref_required&lt;/code&gt;、&lt;code&gt;ambiguous&lt;/code&gt;）。人补齐后重新编译。这是「录的时候能跑、换个环境就死」的唯一闸门。
:::&lt;/p&gt;
&lt;h3&gt;鸿沟二：录到的观测值不能当期望值&lt;/h3&gt;
&lt;p&gt;把「探索时看到什么」直接写成断言，等于把当下的现状固化成基线——&lt;strong&gt;如果那一刻已经有个 bug，这个 bug 就被写成期望值了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;所以编译器&lt;strong&gt;只产路径和观测快照，断言位留空&lt;/strong&gt;，标成 &lt;code&gt;TODO_ASSERT&lt;/code&gt;，并产出一份待办：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 待补断言：wf-demo-smoke-…

编译器只给路径与观测点，**期望值必须来自外部**（需求文档 / 夹具 / 手写）。
- [ ] step 4 (click `登 录`): 断言语义 —— 期望值请从需求文档/夹具取
- [ ] step 7 (click `confirm-create`): 断言语义 —— 期望值请从需求文档/夹具取
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;checks.json&lt;/code&gt; 里每条断言&lt;strong&gt;必须带 &lt;code&gt;source&lt;/code&gt; 字段&lt;/strong&gt;声明期望值出处（&lt;code&gt;spec:&lt;/code&gt; / &lt;code&gt;prd:&lt;/code&gt; / &lt;code&gt;fixture:&lt;/code&gt; / &lt;code&gt;handwritten:&lt;/code&gt;）。&lt;code&gt;core/lint/checks_lint.py&lt;/code&gt; 会拒收两类反模式并返回非零退出码：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;无断言&lt;/strong&gt;（只有一个能跑通的路径，等于没测）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;source&lt;/code&gt; 缺失或形如 &lt;code&gt;observed:*&lt;/code&gt;&lt;/strong&gt;（即抄了编译期观测值）。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;怎么读结果：三态判定&lt;/h2&gt;
&lt;p&gt;回放只给一个结论，取三态之一：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/05-three-states.svg&quot; alt=&quot;三态判定：锚点定位 → 断言 → PASS / FAIL_PRODUCT / FAIL_ANCHOR&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;th&gt;判据&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PASS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;步骤全部定位成功，且外部来源的断言全过&lt;/td&gt;
&lt;td&gt;通过&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAIL_PRODUCT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;锚点找得到，但断言值不对（&lt;code&gt;reason=assertion&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;产品&lt;/strong&gt;漂移或缺陷&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAIL_PRODUCT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;锚点失效，&lt;strong&gt;但存在近似匹配&lt;/strong&gt;（&lt;code&gt;reason=anchor_drift&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;元素还在，只是文案改了 → 产品改动&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAIL_ANCHOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;锚点找不到且无可近似匹配（&lt;code&gt;reason=missing&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;元素没了 / 结构变了 → 脚本问题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAIL_ANCHOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;锚点匹配到多个不同元素（&lt;code&gt;reason=ambiguous&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;判据不唯一 → 脚本问题&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;其中 &lt;code&gt;anchor_drift&lt;/code&gt; 这条值得单独说。&lt;code&gt;role&lt;/code&gt;/&lt;code&gt;text&lt;/code&gt; 锚点是对文案敏感的：文案一变它们就失效。如果直接判 &lt;code&gt;FAIL_ANCHOR&lt;/code&gt;，&lt;strong&gt;真实的文案改动会被误报成「测试写坏了」&lt;/strong&gt;；反过来拿坐标兜底，又退回了鸿沟一。所以这类锚点失效时会做一次归一化近似匹配（去空白、去标点、大小写折叠，相似度 ≥ 阈值即算命中）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;元素还在、只是标签变了 → 归 &lt;code&gt;FAIL_PRODUCT&lt;/code&gt;，带 &lt;code&gt;reason=anchor_drift&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;连近似匹配都没有 → 才归 &lt;code&gt;FAIL_ANCHOR&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;testid&lt;/code&gt; 锚点&lt;strong&gt;不做&lt;/strong&gt;近似匹配（它没有可比较的文本），锚点失效时直接 &lt;code&gt;FAIL_ANCHOR&lt;/code&gt;。这正是「改文案」和「摘锚点」两种场景能分开的原因。&lt;/p&gt;
&lt;p&gt;:::note
&lt;strong&gt;一个容易踩的次序问题。&lt;/strong&gt; 回放在&lt;strong&gt;第一个失败点即停&lt;/strong&gt;（后面的步骤结果不可信），所以断言挂在哪一步很重要。要证明「锚点缺失 → &lt;code&gt;FAIL_ANCHOR&lt;/code&gt;」，断言必须挂在最后一个动作之后。否则摘掉 testid 时先撞上的会是断言自己的 testid，判定被归成 &lt;code&gt;FAIL_PRODUCT&lt;/code&gt;，两种状态就分不开了。
:::&lt;/p&gt;
&lt;h2&gt;三态之外：两个「还没有结论」的标注&lt;/h2&gt;
&lt;p&gt;回放判 &lt;code&gt;FAIL_PRODUCT&lt;/code&gt; 之前，会先跑三项业务无关的自查。它们&lt;strong&gt;不构成第四态&lt;/strong&gt;，只决定「现在能不能下结论」：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/06-false-red.svg&quot; alt=&quot;假红防线：STALE_TARGET / COLD_START_FLAKE / FAIL_ENV&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;自查&lt;/th&gt;
&lt;th&gt;命中时&lt;/th&gt;
&lt;th&gt;为什么不是第四态&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;目标构建标识与本 workflow 记录的不一致&lt;/td&gt;
&lt;td&gt;&lt;code&gt;STALE_TARGET&lt;/code&gt;，不下产品结论&lt;/td&gt;
&lt;td&gt;环境没对齐，此时判产品坏或脚本坏都不成立&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首轮失败、次轮通过&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COLD_START_FLAKE&lt;/code&gt;，重跑一次再判&lt;/td&gt;
&lt;td&gt;重跑后的结果仍归入三态之一&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖的探针端点未就绪&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FAIL_ENV&lt;/code&gt;，与产品问题分开计&lt;/td&gt;
&lt;td&gt;只表示「这次没验成」，不表示产品有问题&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一句话：&lt;code&gt;FAIL_ENV&lt;/code&gt; / &lt;code&gt;STALE_TARGET&lt;/code&gt; 是**「本次未得出结论」的标注**，不是结论。遇到它们先恢复可判定条件再重跑，&lt;strong&gt;不要当产品或脚本的 bug 去改代码&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;架构：机制层和适配层的边界是硬的&lt;/h2&gt;
&lt;p&gt;框架分两层，边界不是「建议」，是 &lt;code&gt;scripts/portability_check.sh&lt;/code&gt; 会扫的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./diagrams/02-layers.svg&quot; alt=&quot;分层与业务边界：机制层零业务词，业务只准活在适配层&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;目录&lt;/th&gt;
&lt;th&gt;规矩&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;机制层&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;core/&lt;/code&gt; &lt;code&gt;checks/&lt;/code&gt; &lt;code&gt;skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;零业务词、零 agent 专属依赖。&lt;strong&gt;唯一需要保证可移植的部分&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;适配层&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adapters/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;业务只准活在这里：被测目标声明、凭据来源、期望值（oracle）来源、提示词骨架的项目实例片段&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;make portability&lt;/code&gt; 拿 &lt;code&gt;scripts/business_words.txt&lt;/code&gt; 去 grep 机制层，&lt;strong&gt;必须零命中&lt;/strong&gt;；白名单只放 &lt;code&gt;&amp;lt;target&amp;gt;&lt;/code&gt; 这类占位符，且逐条带理由——新增一条白名单等于放宽约束，得在 PR 里说明。&lt;/p&gt;
&lt;p&gt;落到你自己的项目，checklist 只有四条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 把项目专属词汇加进 &lt;code&gt;scripts/business_words.txt&lt;/code&gt;，确认机制层仍通过 &lt;code&gt;make portability&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] 在 &lt;code&gt;adapters/&lt;/code&gt; 实现本项目的 target 与 oracle 接口&lt;/li&gt;
&lt;li&gt;[ ] 把真实的探索流程放进 &lt;code&gt;adapters/instance/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;先跑通一条端到端竖切，再谈扩展&lt;/strong&gt;——不要先定接口&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后一条是我自己的教训：接口在只有一条竖切的时候定，几乎是必错。&lt;/p&gt;
&lt;h2&gt;上手：三条命令&lt;/h2&gt;
&lt;p&gt;需要 &lt;strong&gt;Python ≥ 3.12&lt;/strong&gt;、&lt;a href=&quot;https://docs.astral.sh/uv/&quot;&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt;，以及本机装好的 &lt;strong&gt;Chrome / Chromium&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/ABK3528/trace-to-test.git
cd trace-to-test
uv sync --extra browser --extra dev   # browser: browser-harness（CDP 直控本机 Chrome）；dev: pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;仓库自带一个&lt;strong&gt;零依赖的极简靶场&lt;/strong&gt;（&lt;code&gt;target_app/&lt;/code&gt;：登录页、异步列表、弹窗、暗色模式、一个可控的偶发失败端点），用来自证整条链。跑它：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;make demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会真录一遍、编译、补全，然后在四种情况下回放，最后打印判定表（下面是我本机的真实输出）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;recording saved: …/recordings/demo-smoke-… (10 frames)
compiled → demo/build/compiled  (7 steps, 2 unresolved)
  unresolved: seq 3 fill_input — value_ref_required
  unresolved: seq 4 fill_input — value_ref_required
preflight: ok (-)
✅ baseline                     PASS           reason=-
✅ copy drifted                 FAIL_PRODUCT   reason=anchor_drift
✅ testids stripped             FAIL_ANCHOR    reason=missing
✅ artifact carries no coordinates 3 click step(s), xy=[None, None, None]
✅ recorded coords are stale now with=&apos;HTML|…&apos; without=&apos;BUTTON|登 录&apos;
✅ layout shifted (120px)       PASS           reason=-

🎉 三态可证：改文案 → FAIL_PRODUCT，摘锚点 → FAIL_ANCHOR，原样 → PASS
🎉 回放走锚点：产物不带坐标，且位移 120px 后仍 PASS（坐标已失效）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前两行证明&lt;strong&gt;三态分得开&lt;/strong&gt;（改文案 → 产品问题；摘掉稳定钩子 → 测试问题）。后三行证明&lt;strong&gt;回放走的是锚点而不是坐标&lt;/strong&gt;：把靶场布局整体下移 120px，录制时的坐标全部失效，回放仍然 PASS。&lt;/p&gt;
&lt;p&gt;:::tip
&lt;strong&gt;在容器/CI 里以 root 运行时&lt;/strong&gt;，Chrome 会拒绝启动（&lt;code&gt;Running as root without --no-sandbox is not supported&lt;/code&gt;）。v0.1.0 尚未内置这个分支，在 &lt;code&gt;core/primitives/session.py&lt;/code&gt; 的 &lt;code&gt;chrome_launch_args()&lt;/code&gt; 里补一个 &lt;code&gt;os.geteuid() == 0&lt;/code&gt; 时加 &lt;code&gt;--no-sandbox&lt;/code&gt; 即可。指定浏览器用 &lt;code&gt;TTT_CHROME=&amp;lt;可执行文件路径&amp;gt;&lt;/code&gt;。
:::&lt;/p&gt;
&lt;h3&gt;编译产物长什么样&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;workflow.json&lt;/code&gt; 是纯路径，&lt;strong&gt;不含坐标&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;wf-demo-smoke-…&quot;,
  &quot;title&quot;: &quot;Login and open create dialog&quot;,
  &quot;source&quot;: { &quot;recording&quot;: &quot;demo-smoke-…&quot;, &quot;compiled_at&quot;: &quot;2026-09-30T03:27:08+00:00&quot;,
              &quot;viewport&quot;: [1440, 900], &quot;build&quot;: &quot;demo-build-1&quot; },
  &quot;steps&quot;: [
    { &quot;n&quot;: 1, &quot;action&quot;: &quot;goto&quot;,  &quot;path&quot;: &quot;/login&quot; },
    { &quot;n&quot;: 2, &quot;action&quot;: &quot;fill&quot;,  &quot;selector&quot;: &quot;#username&quot;, &quot;value_ref&quot;: &quot;env:DEMO_USERNAME&quot; },
    { &quot;n&quot;: 4, &quot;action&quot;: &quot;click&quot;, &quot;anchor&quot;: { &quot;by&quot;: &quot;role&quot;, &quot;role&quot;: &quot;button&quot;, &quot;name&quot;: &quot;登 录&quot;, &quot;copy_sensitive&quot;: true } },
    { &quot;n&quot;: 7, &quot;action&quot;: &quot;click&quot;, &quot;anchor&quot;: { &quot;by&quot;: &quot;testid&quot;, &quot;value&quot;: &quot;confirm-create&quot; } }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;value_ref: env:DEMO_USERNAME&lt;/code&gt;——&lt;code&gt;value_ref&lt;/code&gt; 只允许指向环境变量或适配层声明的凭据名，&lt;strong&gt;密码/令牌不进 workflow&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;checks.json&lt;/code&gt; 是断言的 sidecar，每条都带 &lt;code&gt;source&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ &quot;checks&quot;: [
  { &quot;id&quot;: &quot;c1&quot;, &quot;after_step&quot;: 7, &quot;kind&quot;: &quot;count&quot;,
    &quot;anchor&quot;: { &quot;by&quot;: &quot;testid&quot;, &quot;value&quot;: &quot;item-row&quot; },
    &quot;expect&quot;: &quot;3&quot;, &quot;source&quot;: &quot;spec:demo/spec.md#2-列表页&quot;,
    &quot;observed_at_compile&quot;: &quot;&quot; }
] }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;observed_at_compile&lt;/code&gt; 只供人工比对，&lt;strong&gt;永不参与回放判定&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;测试策略：&lt;code&gt;make test&lt;/code&gt; 不覆盖什么&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;make test          # 195 passed, 5 skipped in 4.75s
make portability   # ✅ portability check passed (core checks)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那 &lt;strong&gt;5 个 &lt;code&gt;skipped&lt;/code&gt; 正是「本框架的差异化没有被执行」的证据&lt;/strong&gt;——端到端用例需要真实浏览器，默认跳过，只有 &lt;code&gt;TTT_E2E=1&lt;/code&gt; 才跑。要证明框架端到端能跑通，&lt;strong&gt;跑 &lt;code&gt;make demo&lt;/code&gt;，不是 &lt;code&gt;make test&lt;/code&gt;&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;已知边界（v1 的诚实清单）&lt;/h2&gt;
&lt;p&gt;这些是设计上就知道、且写在 README 里的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;type_text&lt;/code&gt; 暂不编译&lt;/strong&gt;，探索流程请优先用 &lt;code&gt;fill_input&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;录制层锁在 &lt;code&gt;browser-harness&lt;/code&gt; 0.1.8&lt;/strong&gt;（上游已到 0.1.13，且它本身是个仍在快速演进的 agent 浏览器工具）：录制格式是唯一的对外契约，升级要连带更新动作名集合与固定录制样本；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;scroll&lt;/code&gt; 事件按设计丢弃&lt;/strong&gt;：既不产生步骤，也不记 &lt;code&gt;Unresolved&lt;/code&gt;。所以「滚动之后才点到的元素」可能编译出一个视口外的目标——要稳定就在探索脚本里先把元素滚进视口；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;xy&lt;/code&gt; 只是兜底锚点&lt;/strong&gt;，用到会打 &lt;code&gt;WARN&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v1 只覆盖 UI 轨&lt;/strong&gt;：接口生成 / 流程编排 / 契约三轨只留了骨架；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个进程同一时刻只开一个 &lt;code&gt;Session&lt;/code&gt;&lt;/strong&gt;（daemon 名进程内稳定），要并发请用不同进程；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码是已知边界&lt;/strong&gt;：录制器把密码框内容遮蔽成 &lt;code&gt;•&lt;/code&gt;，所以编译期探针会键入一串圆点、登录过不去，之后的锚点会&lt;strong&gt;诚实地&lt;/strong&gt;落进 &lt;code&gt;Unresolved&lt;/code&gt;——这是正确行为，不是缺陷。要编译的流程应当&lt;strong&gt;从已登录会话开始录&lt;/strong&gt;，或在补全阶段用 &lt;code&gt;value_ref&lt;/code&gt;（&lt;code&gt;env:&amp;lt;VAR&amp;gt;&lt;/code&gt;）把凭证接回；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;录制期点击竞态&lt;/strong&gt;：无头 Chrome 下合成点击偶尔不触发 &lt;code&gt;dialog.showModal()&lt;/code&gt;。实测在一次 14 连跑的背靠背序列里连续失败 10 次（无头、本机带载），随后又连续成功——&lt;strong&gt;失败率没有被良好刻画，别拿它当稳定概率&lt;/strong&gt;。它只发生在探索/录制阶段，回放路径不受影响。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把这些写进 README 而不是藏起来，是因为&lt;strong&gt;测试工具的边界本身就是它的一部分可信度&lt;/strong&gt;：知道它在哪会假红，才知道什么时候该信它。&lt;/p&gt;
&lt;h2&gt;后面&lt;/h2&gt;
&lt;p&gt;三条轨（接口生成 / 流程编排 / 契约）的骨架已经留位，每条轨开工时遵循同一原则：&lt;strong&gt;先有一条竖切跑通，再定该轨的接口&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果你也在被「红了但不知道是谁的错」折磨，可以先把靶场跑一遍（&lt;code&gt;make demo&lt;/code&gt;，两分钟），再看 &lt;code&gt;demo/build/final/&lt;/code&gt; 下的产物。仓库是公开的：&lt;a href=&quot;https://github.com/ABK3528/trace-to-test&quot;&gt;https://github.com/ABK3528/trace-to-test&lt;/a&gt;。设计文档在 &lt;code&gt;docs/trace-to-test-design.md&lt;/code&gt;，机制的实施计划在 &lt;code&gt;docs/trace-to-test-mechanism-plan.md&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item></channel></rss>