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.

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

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:

  1. Re-record — fastest, agent regenerates selectors from the original prompt.
  2. Hand-edit — open the .spec.ts, change the selector. Faster if you know exactly what changed.
  3. 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.