Crystallize a spec
When the agent finishes a flow, it calls crystallize_spec and Hover writes a standard @playwright/test file under __vibe_tests__/ — plain Playwright, no Hover runtime imports, no AI in the loop at test time.
What lands on disk
crystallize_spec(name, description?) translates the grounded actions the agent performed since the last crystallize into __vibe_tests__/<slug>.spec.ts. The translation is deterministic — no LLM writes the code — so the selector that drove each action is exactly the one saved.
The generated file has two layers.
1. A JSDoc header with a plain-English description of what the test does — readable by QA / PMs who don't know the Playwright API:
/**
* 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. The test body — each recorded grounded action becomes a block-scoped step with a visibility prelude before the interaction:
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();
});
Each grounded target (click_control / fill_control / select_control / check_control / assert_visible) maps to its getByRole / getByLabel / getByTestId / getByText call — no LLM at code-emit time. page.goto is page-level (no element) and stays a one-liner.
Visibility prelude — catches "still a button, now hidden behind a kebab menu"
Why the block-scoped { const el = …; await expect(el).toBeVisible(); await el.<action>; } shape? Playwright's locators default to "visible OR attached", so a button that drifted into a closed <details> / kebab menu / drawer is still in the role tree. Without the prelude, a .click() would silently fire on a hidden element — or time out with a generic actionability flake — even though the user flow has degraded.
Asserting visibility before each interaction surfaces the drift as a clean Locator expected to be visible failure with the offending selector in the message. Applied uniformly to click / dblclick / hover / fill / selectOption.
The emit table lives at packages/core/src/specs/writeSpec.ts. The FAQ entry goes deeper into what the prelude does and doesn't catch.
Selector strategy
Hover's central design choice: semantic selectors over markup selectors.
| Preference | Example |
|---|---|
✅ getByRole('button', { name: 'Submit' }) | Survives layout changes, CSS rewrites |
✅ getByLabel('Email') | Survives input nesting, wrapper additions |
✅ getByTestId('count') | Stable contract between dev and test |
❌ locator('.btn-primary') | Breaks when classes change |
❌ locator('div > div:nth-child(2)') | Breaks when DOM nests differently |
Because the agent acts through grounded tools, this isn't a hope — the grounded target is the selector, enforced in code. See packages/core/src/specs/writeSpec.ts for the per-step translation table.
When selectors do break
Sometimes the UI changes enough that the semantic selector itself goes stale — a button renamed Sign in, a label refactored, a role swapped. The saved spec turns red. You have three options:
- Self-heal — run
/mcp__hover__heal <spec>and the agent replays the spec against the live app, finds the broken step, and re-grounds just that step (record == replay preserved). The fastest path when a selector drifted. - Hand-edit — open the
.spec.ts, change the selector. The file is plain@playwright/testand yours to own; fast if you know exactly what changed. - Re-crystallize — re-run
/mcp__hover__test_app <flow>and let the agent re-author the flow against the current UI.
Self-heal today is local and on-demand (mode A) — you trigger it in your agent. Automatic, on-failure healing driven by a red CI run is planned (see the roadmap). CI itself stays deterministic and free. The FAQ covers the trade-offs in depth.
CI is plain Playwright
The saved spec has no import { hover } line, no Hover dependency, no agent dependency. Run it with:
npx playwright test __vibe_tests__
Hover's whole product is built around making this true. The agent runs only once — at authoring time. After that, Playwright owns the file.