# 11 — Website specs (`specification.website`)
`specification.website()` tests a rendered website — its raw HTTP surface (redirects, robots.txt, headers) and its rendered surface (title, head metadata, JSON-LD, console, and full user scenarios) — through a real chromium instance. It starts the site itself, or targets one already running.
Use it when the subject under test is a browser-rendered page. For a JSON/HTTP API surface use [api](02-api.md); for a binary use [cli](04-cli.md).
## Creating the runner
Exactly one of `server` (start the site locally) or `url` (target a running site) is required — passing both, or neither, throws immediately.
```typescript
// specs/website/website.specification.ts
import { specification } from '@jterrazz/test';
import { afterAll } from 'vitest';
export const { cleanup, website } = await specification.website({
server: { command: 'node specs/fixtures/website-app/server.mjs', ready: '/' },
});
afterAll(cleanup);
```
```typescript
// targeting a deployed preview instead of starting one
export const { cleanup, website } = await specification.website({
url: 'https://preview-1234.my-site.pages.dev',
});
```
### Options
| Option | Description |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `server` | `{ command, ready?, port?, timeout? }` — start the site as a child process. Exactly one of `server` / `url` |
| `url` | Target an already-running site (deployed, preview, dev server). Exactly one of `server` / `url` |
| `external` | `'allow' \| 'block'` — cross-origin policy for `.visit()`. Default `'block'` with `server`, `'allow'` with `url` — see [Cross-origin policy](#cross-origin-policy-external) |
| `root` | **Project-root override** (rule A9): the cwd of the `server` command. Auto-discovered from the calling file when absent. Not a fixtures root |
`server` itself takes:
| `server` field | Description |
| -------------- | -------------------------------------------------------------------------------------------------- |
| `command` | Shell command that starts the site. Receives the chosen port as the `PORT` env var |
| `ready` | Path polled until it answers with any HTTP status — a 404 still counts as "listening". Default `/` |
| `port` | Fixed port. Default: a free OS-assigned port, injected as `PORT` |
| `timeout` | Readiness budget in milliseconds. Default 30 000 |
The chosen port is injected as `PORT` — the command reads it the same way it would in production. If the process never answers on `ready` within `timeout`, or exits first, `specification.website()` fails with the command's captured output attached. On teardown the child is terminated by process group (SIGTERM, escalating to SIGKILL after a 2 s grace) — the same escalation as the [cli](04-cli.md) exec adapter, so a framework's own child processes don't outlive the run.
The handle destructures to `{ website, cleanup, url }` (rule A3) — no `docker`, no `orchestrator`: a browser is not a container. `url` is the resolved base URL — the one the server started on, or the `url` option with its trailing slash trimmed.
## Two terminal actions: `.fetch()` and `.visit()`
### `.fetch(path)` — one raw HTTP exchange
`.fetch(path)` performs a single request and never follows redirects — the redirect itself is the result, not something to chase:
```typescript
test('surfaces a permanent redirect without following it', async () => {
// Given - the legacy path
const result = await website.fetch('/old');
// Then - the 308 IS the result, with its target readable
expect(result.status).toBe(308);
expect(result.location).toBe('/');
});
test('serves robots.txt as plain text', async () => {
// Given - the robots surface
const result = await website.fetch('/robots.txt');
// Then - the whole file matches one golden
expect(result.headers['content-type']).toBe('text/plain');
expect(result.body).toMatch('robots.txt');
});
```
`FetchResult` accessors:
| Member | Type | Description |
| ----------------- | --------------------- | ---------------------------------------------------------- |
| `result.status` | `number` | HTTP status — 3xx surfaces exactly as sent, never followed |
| `result.location` | `string \| undefined` | The `location` header, or undefined |
| `result.headers` | flat map | Response headers, lower-cased keys |
| `result.body` | `TextAccessor` | Raw response body — `toMatch('robots.txt')`, `.grep()` |
| `result.json` | `JsonAccessor` | Response body parsed as JSON |
### `.visit(path, scenario?)` — a rendered page
`.visit()` renders the page in a real chromium and resolves with the captured document. There is **one browser process per runner**, launched lazily on the first `.visit()` — a spec file that only calls `.fetch()` never pays the browser-launch cost. Each visit gets a fresh, isolated browser context.
```typescript
test('captures the full head surface of a rendered page', async () => {
// Given - the fixture homepage
const result = await website.visit('/');
// Then - one golden covers title, canonical, alternates, and metas
expect(result.status).toBe(200);
expect(result.head).toMatch('home.head.json');
});
```
## Visit scenarios — the When
A scenario is the interaction that happens **before** the capture — the visit's When. The capture always reflects the **final** page state, after the scenario ran:
```typescript
test('subscribes through the form and captures the final state', async () => {
// Given - a visitor on the homepage
const result = await website.visit('/', async (visitor) => {
// When - they fill the form and subscribe
await visitor.fill(field('Email'), 'visitor@site.test');
await visitor.click(button('Subscribe'));
await visitor.see(content('Thanks for subscribing'));
});
// Then - the capture reflects the page after the interaction
expect(result.content).toContain('Thanks for subscribing');
await expect(result.errors).toBeEmpty();
});
```
**No `expect()` inside a scenario (rule W1).** A scenario is pure interaction — assertions live in the Then, on the returned result. Splitting interaction from assertion keeps the setup → action → result grammar intact, and keeps scenarios replayable independent of what they're checked against.
Visitor verbs — every action auto-waits (playwright actionability); there are no sleeps anywhere in the framework:
| Verb | Description |
| ------------------------- | ----------------------------------------------------------------------------------- |
| `click(element)` | Click the element |
| `fill(element, value)` | Fill a form field |
| `press(key)` | Press a key (e.g. `'Enter'`) |
| `select(element, option)` | Select an option in a select field |
| `check(element)` | Check a checkbox or radio |
| `hover(element)` | Hover the element |
| `goto(path)` | Navigate to another path of the site under test |
| `see(element)` | **The only synchronization primitive** — retries until visible, times out otherwise |
Elements are **user-facing by construction** (rule W2) — there is no CSS/XPath surface:
| Element | Locates by |
| --------------- | ------------------------------------------------ |
| `button(name)` | accessible name, button role |
| `link(name)` | accessible name, link role |
| `field(label)` | form label |
| `heading(name)` | accessible name, heading role |
| `content(text)` | any element containing the text |
| `testId(id)` | `data-testid` — the escape hatch (rule W2 warns) |
Navigating within a scenario changes what the capture describes:
```typescript
test('navigates to another page and captures where it landed', async () => {
// Given - a visitor on the homepage
const result = await website.visit('/', async (visitor) => {
// When - they follow the articles link
await visitor.click(link('Articles'));
});
// Then - the result is the destination page
expect(result.url).toContain('/articles');
expect(result.content).toContain('All articles');
});
```
## Result surface — `PageResult`
| Member | Type | Description |
| ------------------- | --------------------- | ------------------------------------------------------------------------------- |
| `result.status` | `number` | HTTP status of the main document response |
| `result.url` | `string` | Final URL — after redirects and any scenario navigation |
| `result.title` | `TextAccessor` | `document.title` |
| `result.head` | `JsonAccessor` | `{ title, canonical, alternates, metas }` — the one-golden-per-page SEO surface |
| `result.jsonLd` | `JsonAccessor` | Every `application/ld+json` block, parsed, as one array |
| `result.meta(name)` | `string \| undefined` | Content of a named meta — `.meta('description')`, `.meta('og:image')` |
| `result.canonical` | `string \| null` | The canonical `` href, or null |
| `result.alternates` | flat map | Hreflang alternates, keyed by language code |
| `result.links` | array | `` elements of the head, in DOM order |
| `result.content` | `TextAccessor` | Rendered body text (`document.body.innerText`) |
| `result.html` | `TextAccessor` | Full serialized DOM (`document.documentElement.outerHTML`) |
| `result.console` | `TextAccessor` | Every console message, one `[type] text` line per message |
| `result.errors` | `TextAccessor` | Console messages of type `error` only |
## The `head` golden — one per page
`result.head` is the **stable, assertion-friendly projection** of the document head — title, canonical, hreflang alternates, and named metas collapsed into one object. It is the one golden a page needs for its SEO surface:
```typescript
test('exposes canonical, alternates, and named metas directly', async () => {
// Given - the fixture homepage
const result = await website.visit('/');
// Then - the accessors read the head without a golden…
expect(result.canonical).toBe('https://site.test/');
expect(result.alternates['x-default']).toBe('https://site.test/');
expect(result.meta('og:title')).toBe('Fixture — Home');
// …or snapshot the whole surface at once
expect(result.head).toMatch('home.head.json');
});
```
Structured data gets the same treatment — every `ld+json` block on the page, in one golden:
```typescript
test('parses every json-ld block into one array', async () => {
const result = await website.visit('/');
expect(result.jsonLd).toMatch('home.jsonld.json');
});
```
## Console assertions
The console splits into the full stream and the error-only stream — the same shape as `stdout`/`stderr` on a cli result:
```typescript
test('keeps a clean page silent on both streams', async () => {
// Given - the healthy homepage
const result = await website.visit('/');
// Then - no console output at all
await expect(result.console).toBeEmpty();
await expect(result.errors).toBeEmpty();
});
test('separates console errors from the full stream', async () => {
// Given - a page that logs and errors
const result = await website.visit('/noisy');
// Then - the full stream carries both, the error stream only the error
expect(result.console).toMatch('noisy.console.txt');
expect(result.errors).toContain('boom');
});
```
## Cross-origin policy (`external`)
`external: 'block'` aborts every request leaving the site under test during a `.visit()` — analytics beacons, third-party CDNs, ad scripts never fire, so a visit stays deterministic. `'allow'` lets them through.
The default follows the mode: `'block'` with `server` (you own the deployment, third-party noise is not the point), `'allow'` with `url` (a deployed site legitimately loads third-party assets). Override with the top-level `external` option when a spec needs the opposite of its mode's default.
## Setup: `.headers()`
`.headers({...})` sets HTTP headers for both terminal actions — the raw `.fetch()` exchange and the browser context behind `.visit()`. Repeated calls merge. The main use case is a User-Agent override, e.g. asserting on what an AI crawler sees:
```typescript
test('sends chain headers on the raw exchange', async () => {
// Given - an AI crawler user agent
const result = await website.headers({ 'User-Agent': 'GPTBot/1.0' }).fetch('/robots.txt');
// Then - the exchange succeeds like any other client
expect(result.status).toBe(200);
expect(result.body).toContain('Allow: /');
});
```
## Evidence on failure
When a scenario throws — an element never becomes visible, a `see()` times out — the error carries a full-page screenshot of the state the scenario died in, referenced by its temp path in the error message. The original error is never masked; the screenshot is attached evidence, not a replacement.
## Playwright — optional peer dependency
Playwright is not a hard dependency: `.fetch()`-only spec files never need it. `.visit()` imports it lazily, and needs it installed:
```bash
npm install -D playwright && npx playwright install chromium
```
Calling `.visit()` without playwright installed throws exactly that guidance — there is nothing else to search for.
Provisioning the environment is not this package's job. In CI, the shared validate workflow does it: `browsers: true` (jterrazz-actions) installs cached chromium, versioned from the caller's lockfile, between Build and Test. On a workstation, `j install` provisions `playwright-browsers` once.
## Folder layout
```
specs/website/
├── website.specification.ts # runner at the facet ROOT (rule C1)
└── /
├── .test.ts
└── expected/ # ALL expected fixtures, FLAT (*.head.json, *.jsonld.json, *.console.txt, …)
```
No `seeds/`, `requests/`, or `contracts/` — `specification.website()` has no `services` option and no request-file format; `.fetch()`/`.visit()` calls are inline, and the golden is always `expected/`.
## Pitfalls
- **Passing both `server` and `url`, or neither.** The options type makes the invalid combinations inexpressible — the compiler rejects them before anything runs.
- **Using `expect()` inside a scenario callback.** Forbidden (rule W1) — the scenario is the When; assertions belong on the result the `.visit()` promise resolves to.
- **Reaching for `testId()` as the default locator.** It exists as an escape hatch (rule W2 warns) — prefer `button`/`link`/`field`/`heading`/`content`, the same vocabulary a user's accessibility tree exposes.
- **Calling `.visit()` without playwright installed.** The error names the exact fix — `npm install -D playwright && npx playwright install chromium` — there is no silent fallback.
- **Expecting `.fetch()` to follow redirects.** It never does — the 3xx status and `location` header ARE the result; chase the target with a second `.fetch()` if the spec needs to.
- **Assuming `external` defaults the same way in both modes.** It flips with the constructor mode: `'block'` with `server`, `'allow'` with `url` — pass it explicitly to override.
- **Reading `result.head` field-by-field instead of snapshotting it.** It is designed as the one golden per page (`toMatch('home.head.json')`) — use the direct accessors (`canonical`, `alternates`, `meta()`) only for a single targeted probe.
## Related
[01 — Getting started](01-getting-started.md) · [05 — Assertions](05-assertions.md) · [06 — Tokens](06-tokens.md)