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

```ts
/**
 * 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](/docs/features/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:

```ts
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`](https://github.com/Hyperyond/Hover/blob/main/packages/core/src/specs/writeSpec.ts). The [FAQ entry](/docs/faq#my-button-is-still-in-the-dom-but-moved-behind-a-kebab-menu-does-the-spec-catch-that) 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`](https://github.com/Hyperyond/Hover/blob/main/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](/docs/features/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](/docs/faq#my-ui-changed-and-my-saved-spec-breaks-what-now) 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:

```bash
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.
