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.tsFAQ 条目 更深入地讲了这段前置校验能抓住什么、抓不住什么。

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 变红。你有三个选择:

  1. Self-heal —— 跑 /mcp__hover__heal <spec>,agent 会把 spec 对着线上应用重放,找到坏掉的那一步,只对那一步重新落地约束(record == replay 得以保留)。selector 漂移时最快的路。
  2. 手工编辑 —— 打开 .spec.ts,改 selector。这个文件就是纯 @playwright/test,归你所有;如果你清楚到底改了什么,这很快。
  3. 重新 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 管。