Crystallize 一个 spec
当 agent 跑完一个流程时,它会调用 crystallize_spec,Hover 随即在 __vibe_tests__/ 下写出一个标准的 @playwright/test 文件 —— 纯 Playwright,不 import 任何 Hover 运行时,测试时也没有 AI 参与循环。
落到磁盘上的是什么
crystallize_spec(name, description?) 把 agent 自上次 crystallize 以来执行的那些落地约束动作翻译成 __vibe_tests__/<slug>.spec.ts。翻译是确定性的 —— 没有 LLM 在写代码 —— 所以驱动每个动作的那个 selector,就是被保存下来的那个。
生成的文件有两层。
1. 一段 JSDoc 头,用平实的自然语言描述这个测试做了什么 —— 让不懂 Playwright API 的 QA / PM 也读得懂:
/**
* Generated by Hover on 2026-06-30.
* Flow: Log in
* Verifies: a logged-in user lands on the dashboard with their name in the header.
*
* Steps:
* 1. Open /
* 2. Type "claude@sparkplay.io" into Email
* 3. Type "demo1234" into Password
* 4. Click Submit button
*
* Selectors prefer getByRole / getByLabel / getByTestId — generated from
* grounded targets read off the page snapshot, not raw CSS ids, so the
* spec survives markup changes that don't touch semantics.
*/
2. 测试主体 —— 每个录制下来的落地约束动作都变成一个块级作用域的步骤,交互之前带一段可见性前置校验:
test('Log in', async ({ page }) => {
await page.goto('/');
{
const el = page.getByRole('textbox', { name: 'Email' });
await expect(el).toBeVisible();
await el.fill('claude@sparkplay.io');
}
{
const el = page.getByRole('textbox', { name: 'Password' });
await expect(el).toBeVisible();
await el.fill('demo1234');
}
{
const el = page.getByRole('button', { name: 'Submit' });
await expect(el).toBeVisible();
await el.click();
}
await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
});
每个落地约束的 target(click_control / fill_control / select_control / check_control / assert_visible)都映射到它对应的 getByRole / getByLabel / getByTestId / getByText 调用 —— 生成代码时没有 LLM。page.goto 是页面级的(不针对元素),保持为一行。
可见性前置校验 —— 抓住"还是那个按钮,但现在被藏进了 kebab 菜单后面"
为什么要用块级作用域的 { const el = …; await expect(el).toBeVisible(); await el.<action>; } 这种写法?Playwright 的 locator 默认是"可见 OR 已挂载",所以一个漂移进了已折叠的 <details> / kebab 菜单 / 抽屉里的按钮,仍然在 role 树里。没有这段前置校验,.click() 会悄悄地作用在一个隐藏元素上 —— 或者以一个泛泛的 actionability flake 超时 —— 哪怕用户流程其实已经退化了。
在每次交互之前断言可见性,会把这种漂移暴露成一个干净的 Locator expected to be visible 失败,消息里带着那个出问题的 selector。对 click / dblclick / hover / fill / selectOption 一律如此。
生成时用的映射表在 packages/core/src/specs/writeSpec.ts。FAQ 条目 更深入地讲了这段前置校验能抓住什么、抓不住什么。
Selector 策略
Hover 的核心设计取舍:语义化 selector 优先于 markup selector。
| 偏好 | 例子 |
|---|---|
✅ getByRole('button', { name: 'Submit' }) | 扛得住布局变化、CSS 重写 |
✅ getByLabel('Email') | 扛得住 input 嵌套、外层 wrapper 的增加 |
✅ getByTestId('count') | dev 与 test 之间稳定的契约 |
❌ locator('.btn-primary') | class 一变就坏 |
❌ locator('div > div:nth-child(2)') | DOM 嵌套方式一变就坏 |
因为 agent 是通过落地约束工具来操作的,这就不是一种指望 —— 那个落地约束的 target 就是 selector,在代码层面被强制。逐步翻译的映射表见 packages/core/src/specs/writeSpec.ts。
当 selector 真的坏了
有时 UI 变化太大,连语义化 selector 本身都失效了 —— 一个按钮被改名成 Sign in、一个 label 被重构、一个 role 被替换。保存的 spec 变红。你有三个选择:
- Self-heal —— 跑
/mcp__hover__heal <spec>,agent 会把 spec 对着线上应用重放,找到坏掉的那一步,只对那一步重新落地约束(record == replay 得以保留)。selector 漂移时最快的路。 - 手工编辑 —— 打开
.spec.ts,改 selector。这个文件就是纯@playwright/test,归你所有;如果你清楚到底改了什么,这很快。 - 重新 crystallize —— 重跑
/mcp__hover__test_app <flow>,让 agent 对着当前 UI 重新撰写这个流程。
今天的 self-heal 是本地、按需的(mode A)—— 由你在 agent 里触发。由一次变红的 CI 运行驱动的、失败即自愈的自动化正在计划中(见 路线图)。CI 本身保持确定性且免费。FAQ 深入讲了其中的取舍。
CI 就是纯 Playwright
保存的 spec 没有 import { hover } 这一行,不依赖 Hover,也不依赖 agent。这样跑它:
npx playwright test __vibe_tests__
Hover 的整个产品就是围绕着让这件事成立而搭建的。agent 只跑一次 —— 在编写时。此后,这个文件归 Playwright 管。