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:
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:
npx hydration-proof test --scenario dark-mobileLocale, 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: 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:
cookies: when the app accepts a session value you can mint for tests. There is no browser work, so it costs nothing.storageState: a file Playwright wrote withcontext.storageState({ path }). Use it when you already have a Playwright login.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:
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:
TEST_USER_EMAIL=… TEST_USER_PASSWORD=… ADMIN_SESSION=… npx hydration-proof testHow the login runs
loginreceives{ 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
2and the error message. - Cookies without a
domainapply 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 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).
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:
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* | 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. 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.
More scenario options
headers: extra request headerslocalStorageandsessionStorage: entries written before any page script runsinitScripts: code run before page scriptsquery: query parameters added to every URL, such as{ currency: "EUR" }browser:"chromium","firefox"or"webkit"for this scenarionetwork:"fast-3g","slow-3g"or a custom profilecpu: 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* | 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* | string | — | Cookie name. |
| value* | 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. |