<?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://blog.yjfkk.eu.org/</link><language>zh_CN</language><item><title>trace-to-test：把一次浏览器操作，编译成零 LLM 的确定性回归</title><link>https://blog.yjfkk.eu.org/posts/trace-to-test/</link><guid isPermaLink="true">https://blog.yjfkk.eu.org/posts/trace-to-test/</guid><description>UI 回归的两条老路都有硬伤——手写脚本锚点靠人挑、页面一改就整片红；让 LLM 每次现场驱动浏览器，今天过明天不过。trace-to-test 的做法是让 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>