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.

PreferenceExample
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:

  1. 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.
  2. Hand-edit — open the .spec.ts, change the selector. The file is plain @playwright/test and yours to own; fast if you know exactly what changed.
  3. 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.