Save as Spec
Crystallise a verified Hover session into a standard @playwright/test file under __vibe_tests__/.
What lands on disk
Pick Save as → Playwright spec on the Result card. Hover writes __vibe_tests__/<slug>.spec.ts — plain @playwright/test, no Hover runtime imports, no AI in the loop at test time.
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 Playwright API:
/**
* Generated by Hover on 2026-05-29.
* Original prompt: log in then click + 1 three times and verify the counter
* Outcome: Logged in, counter is 03.
*
* Steps:
* 1. Open /
* 2. Type "claude@sparkplay.io" into Email
* 3. Type "demo1234" into Password
* 4. Click Submit button
* 5. Click + 1 button (× 3)
*
* Expected:
* • welcome heading shows the logged-in email
* • counter reads 03
*
* Selectors prefer getByRole / getByLabel / getByTestId — generated
* from the agent's natural-language element descriptions, not raw
* CSS ids, so the spec survives markup changes that don't touch
* semantics.
*/
The Original prompt: line is load-bearing: Re-record reads it to regenerate the spec when the UI changes.
2. The test body — each captured tool call becomes a block-scoped block with a visibility prelude before the interaction:
test('login + counter', 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();
}
// … three more +1 clicks, each in its own block …
await expect(page.getByTestId('count')).toHaveText('03');
});
Element descriptions coming back from Playwright MCP ("Submit button", "Email textbox") are deterministically translated to getByRole(role, { name }) / getByLabel(name) calls — no LLM at code-emit time. page.goto and page.keyboard.press are page-level (no element) and stay one-liners.
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, getByRole('button', { name: 'Submit' }).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.
Originally pointed out as a gap in the role-only approach by an external contributor on X — fixed in the emit table at packages/core/src/specs/writeSpec.ts. The FAQ entry goes deeper into the failure modes 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 |
The Result card's "Save as Spec" path enforces this in code — see packages/core/src/specs/writeSpec.ts for the per-tool 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. Three options:
- Re-record — fastest, agent regenerates selectors from the original prompt.
- Hand-edit — open the
.spec.ts, change the selector. Faster if you know exactly what changed. - Treat as a regression — the flow itself may have legitimately changed; update the test by hand or delete and start fresh.
The FAQ covers the trade-offs in depth.
CI is plain Playwright
The saved spec has no import { hover } line, no widget dependency, no agent dependency. Run it with:
pnpm exec playwright test
Hover's whole product is built around making this true. The agent only runs once — at the moment you click Save as Spec. After that, Playwright owns the file.