# Scenarios and sign-in

> Test signed-in pages for hydration errors with scenarios: locale, timezone, theme and viewport presets, session cookies, storage state, a login and API mocks.

Source: https://hydration.jscrate.dev/docs/scenarios
Last updated: 2026-09-18

To test signed-in pages for hydration errors, give each identity its own
scenario. A scenario is a browser environment (locale, timezone, color scheme,
viewport, cookies, storage) that every route it covers is tested in. A `login`
runs once per scenario, and the cookies and storage it leaves behind are reused
for every page.

## What a scenario is

Without a `scenarios` option, there is one scenario named `default`. It uses
this machine's locale and timezone and a light color scheme, so a server started
on the same machine renders with the same values.

List scenarios to test the environments your users have:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  scenarios: [
    { name: "default" },
    { name: "dark-mobile", colorScheme: "dark", viewport: "mobile" },
    { name: "karachi", locale: "ur-PK", timezoneId: "Asia/Karachi" },
  ],
});
```

Each scenario name is shown in reports. `--scenario <name>` tests only that
scenario, and the flag can be repeated:

```bash
npx hydration-proof test --scenario dark-mobile
```

## Locale, timezone, theme and viewport

| Option          | Values                                                                                                         |
| --------------- | -------------------------------------------------------------------------------------------------------------- |
| `locale`        | A BCP 47 locale such as `"de-DE"`. It also sets the `Accept-Language` header                                   |
| `timezoneId`    | An IANA timezone such as `"Asia/Karachi"`                                                                      |
| `colorScheme`   | `"light"` (default), `"dark"` or `"no-preference"`                                                             |
| `reducedMotion` | `"reduce"` or `"no-preference"`                                                                                |
| `viewport`      | `{ width, height }`, or a preset: `"mobile"` (390×844, touch), `"tablet"` (820×1180) or `"desktop"` (1280×800) |

To test many combinations without writing each one, use the
[environment matrix](https://hydration.jscrate.dev/docs/environment-matrix): it expands every scenario into
combinations of locales, timezones, themes, viewports and browsers.

## Test signed-in pages for hydration errors

Hydration problems hide behind logins: a dashboard that renders a date, an admin
table that reads `localStorage`. There are three ways to sign a scenario in,
cheapest first:

1. **`cookies`:** when the app accepts a session value you can mint for tests.
   There is no browser work, so it costs nothing.
2. **`storageState`:** a file Playwright wrote with
   `context.storageState({ path })`. Use it when you already have a Playwright
   login.
3. **`login`:** a real sign-in through the form. It runs once per scenario, not
   once per page, and whatever cookies and storage it leaves behind are reused.

An `authorization` entry in `headers` works too.

This config tests public, customer and admin pages in one run:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  routes: {
    dynamic: { "/account/orders/[id]": ["1001"] },
  },

  scenarios: [
    // Everything that is not behind a login
    { name: "guest", exclude: ["/account/**", "/admin/**"] },

    // A real sign-in, once, then reused for every page of this scenario
    {
      name: "customer",
      include: ["/account/**"],
      login: async ({ page, baseUrl }) => {
        await page.goto(`${baseUrl}/login`);
        await page.fill("#email", process.env.TEST_USER_EMAIL ?? "");
        await page.fill("#password", process.env.TEST_USER_PASSWORD ?? "");
        await page.click("button[type=submit]");
        await page.waitForURL("**/account");
      },
    },

    // A session the test environment hands out: no browser work at all
    {
      name: "admin",
      include: ["/admin/**"],
      cookies: [{ name: "session", value: process.env.ADMIN_SESSION ?? "" }],
    },
  ],
});
```

Run it with the credentials in the environment:

```sh
TEST_USER_EMAIL=… TEST_USER_PASSWORD=… ADMIN_SESSION=… npx hydration-proof test
```

### How the login runs

- `login` receives `{ page, baseUrl }`: a Playwright page in a browser context
  with the scenario's settings, and the URL of the tested app.
- It runs once per run, after the app is up and after `hooks.setup`.
- A login that throws stops the run with exit code `2` and the error message.
- Cookies without a `domain` apply to the tested app.
- Keep credentials in environment variables, not in the config file, which gets
  committed.

### When a signed-in page ends on the login page

If a page that should be signed in ends on another URL, it gets an
[HP9010](https://hydration.jscrate.dev/docs/issues/hp9010) warning. That usually means the sign-in did not
work: the login did not wait for the page after submitting
(`page.waitForURL`), the session cookie belongs to another domain, or the
cookie or storage state has expired. Run with `--headed --workers 1` to watch
it. If a route is meant to redirect, set `expectRedirect` on it (see
[routes](https://hydration.jscrate.dev/docs/routes)).

## Split public, customer and admin pages

`include` and `exclude` decide which routes a scenario covers, with the same
globs as `routes` (`*` is one segment, `**` any number). A route that no
scenario matches is not tested, so keep one scenario as the default for public
pages, as `guest` is above.

## Mock browser requests

`mocks` answer requests the browser makes with fixed data, so pages that show
changing numbers render the same on every run:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  scenarios: [
    {
      name: "admin",
      cookies: [{ name: "session", value: process.env.ADMIN_SESSION ?? "" }],
      // Admin pages often show data that changes between requests; pin it
      mocks: [{ url: "**/api/metrics", body: { signups: 12, revenue: 3400 } }],
    },
  ],
});
```

`url` is a glob or a RegExp, and object bodies are sent as JSON. Only browser
requests are answered: requests your server makes while rendering are not
affected. Mocks turn the browser's HTTP cache off.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `url` (required) | `string \| RegExp` | — | URL glob (`**` and `*`) or RegExp of browser requests to answer. |
| `method` | `string` | — | Only answer requests with this HTTP method. |
| `status` | `number` | `200` | Response status. |
| `headers` | `Record<string, string>` | — | Response headers. |
| `body` | `unknown` | — | Response body; objects are sent as JSON. |

## Seed data before the pages load

To create users or seed a database, use `hooks.setup` in the
[config](https://hydration.jscrate.dev/docs/configuration#hooks). It runs once per build mode, after the app
is up and before the scenario logins, and may return a teardown function. Sign-in
itself belongs in a scenario's `login`: there is no global setup hook.

## Fixed clock and random values

`clock` fixes the browser time for `Date.now()` and `new Date()`, and
`randomSeed` seeds `Math.random()` and `crypto.getRandomValues()` in the
browser. Both are diagnostic: they make the client values repeatable between
runs, but the server keeps its real clock, so time-dependent and random output
is still found. To prove that the clock or randomness causes a finding, use
[probes](https://hydration.jscrate.dev/docs/probes).

## More scenario options

- `headers`: extra request headers
- `localStorage` and `sessionStorage`: entries written before any page script
  runs
- `initScripts`: code run before page scripts
- `query`: query parameters added to every URL, such as `{ currency: "EUR" }`
- `browser`: `"chromium"`, `"firefox"` or `"webkit"` for this scenario
- `network`: `"fast-3g"`, `"slow-3g"` or a custom profile
- `cpu`: slow JavaScript down by a factor (Chromium only)
- `cache`: `"warm"` loads each page once before testing it, like a returning
  visitor (the default is `"cold"`)

## All scenario options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `string` | — | Unique name, shown in reports and used by `--scenario`. |
| `locale` | `string` | this machine's locale | BCP 47 locale for the browser (also sets Accept-Language). |
| `timezoneId` | `string` | this machine's timezone | IANA timezone for the browser, e.g. `Asia/Karachi`. |
| `colorScheme` | `ColorScheme` | `"light"` | Emulated `prefers-color-scheme`: `"light"`, `"dark"` or `"no-preference"`. |
| `reducedMotion` | `"reduce" \| "no-preference"` | — | Emulated `prefers-reduced-motion`. |
| `viewport` | `ViewportOption` | — | Viewport size as `{ width, height }`, or a preset: `"mobile"` (390×844, touch), `"tablet"` (820×1180) or `"desktop"` (1280×800). |
| `userAgent` | `string` | — | User agent string for the browser context. |
| `storageState` | `string` | — | Playwright storage state file (cookies and localStorage), e.g. a logged-in user. |
| `cookies` | `CookieConfig[]` | — | Cookies set before every page load, e.g. a session cookie for a signed-in user. |
| `headers` | `Record<string, string>` | — | Extra request headers. |
| `localStorage` | `Record<string, string>` | — | `localStorage` entries set before page scripts run. |
| `sessionStorage` | `Record<string, string>` | — | `sessionStorage` entries set before page scripts run. |
| `initScripts` | `string[]` | — | Scripts run before page scripts (code strings). |
| `login` | `(context: LoginContext) => Promise<void>` | — | Sign in once before this scenario's pages are tested. The cookies and storage it leaves behind are used for every page. |
| `mocks` | `MockConfig[]` | — | Answers for browser requests (API fixtures). Server-side requests are not affected. |
| `include` | `string[]` | — | Only test routes matching these globs in this scenario. |
| `exclude` | `string[]` | — | Skip routes matching these globs in this scenario. |
| `query` | `Record<string, string>` | — | Query parameters added to every URL (e.g. `{ currency: 'EUR' }`). |
| `browser` | `BrowserName` | `browser.name` | Browser for this scenario. |
| `network` | `NetworkProfile` | — | Throttle the network: `"fast-3g"`, `"slow-3g"` or `{ downloadKbps, uploadKbps, latencyMs }`. Chromium throttles natively; Firefox and WebKit delay subresources instead. |
| `cpu` | `number` | — | Slow down JavaScript by this factor (Chromium only). |
| `cache` | `CacheState` | `cold` | `warm` loads the page once before testing it, like a returning visitor. |
| `clock` | `string \| number` | — | Fixed browser time (ISO string or epoch ms) for `Date.now()` / `new Date()`. A diagnostic option: the server keeps its real clock, so time-dependent output is still found. |
| `randomSeed` | `number` | — | Seed for `Math.random()` and `crypto.getRandomValues()` in the browser (diagnostic, like `clock`). |

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `string` | — | Cookie name. |
| `value` (required) | `string` | — | Cookie value. |
| `domain` | `string` | the tested origin | Cookie domain. |
| `path` | `string` | `"/"` | Cookie path. |
| `httpOnly` | `boolean` | — | Hide the cookie from page scripts. |
| `secure` | `boolean` | — | Send the cookie over HTTPS only. |
| `sameSite` | `"Strict" \| "Lax" \| "None"` | — | The `SameSite` attribute. |

## Related

- [Test locales and timezones with the environment matrix](https://hydration.jscrate.dev/docs/environment-matrix)
- [Prove what causes a mismatch](https://hydration.jscrate.dev/docs/probes)
- [Choose which routes are tested](https://hydration.jscrate.dev/docs/routes)
- [Keep secrets out of reports](https://hydration.jscrate.dev/docs/security)
- [Configuration reference](https://hydration.jscrate.dev/docs/configuration)
