To test signed-in pages for hydration errors, add a scenario for each kind of
user. A scenario sets the browser's locale, timezone, viewport and sign-in
state. Its login runs once; the resulting cookies and storage are reused
for the pages in that scenario.
What a scenario is
Without scenarios, the tool uses one scenario named default. It uses your
machine's locale and timezone and a light color scheme. Add more scenarios
when your users see the app under different conditions.
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" },
],
});The report names the scenario for each page. To test just one, pass its name:
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 cover several combinations without listing each scenario by hand, use the environment matrix.
Test signed-in pages for hydration errors
Hydration problems can hide behind a login—for example, a dashboard date or
an admin table that reads localStorage. Choose one way to sign in:
cookies: pass a session cookie when your test environment can issue one. No browser login is needed.storageState: reuse a Playwright session saved withcontext.storageState({ path }).login: sign in through the app. This runs once per scenario, not once for every page.
An authorization entry in headers works too.
This example separates public, customer and admin pages. Replace the order ID and selectors with values from your app:
import { defineConfig } from "hydration-proof";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running hydration-proof`);
return value;
}
export default defineConfig({
routes: {
dynamic: { "/account/orders/[id]": ["your-stable-test-order-id"] },
},
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", requiredEnv("TEST_USER_EMAIL"));
await page.fill("#password", requiredEnv("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: requiredEnv("ADMIN_SESSION") }],
},
],
});Set the credentials in the environment before running the test:
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 signed-in route ends on the login page, it gets an
HP9010 warning. Check that login waits for the
redirect, the cookie belongs to this domain and the session has not expired.
Run with --headed --workers 1 to watch the sign-in. If the route should
redirect, set expectRedirect instead; see routes.
Split public, customer and admin pages
Use include and exclude to choose each scenario's routes. * matches one
path segment and ** matches any number. A route no scenario includes is not
tested; keep a guest scenario for public pages.
Mock browser requests
mocks return fixed responses to browser requests. Use them when live data
would make a page change between test runs:
import { defineConfig } from "hydration-proof";
export default defineConfig({
scenarios: [
{
name: "default",
mocks: [{ url: "**/api/metrics", body: { signups: 12, revenue: 3400 } }],
},
],
});url accepts a glob or a RegExp. Object bodies are sent as JSON. Mocks affect
browser requests only; they do not replace data fetched by the server while
rendering. They also disable the browser's HTTP cache.
| 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. |