Hydration Proof

Search documentation

Find a page or section

The browser environments and identities every route is tested in.

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:

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:

npx hydration-proof test --scenario dark-mobile

Locale, timezone, theme and viewport

OptionValues
localeA BCP 47 locale such as "de-DE". It also sets the Accept-Language header
timezoneIdAn 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:

  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:

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:

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

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.

OptionTypeDefaultDescription
url*string | RegExpURL glob (** and *) or RegExp of browser requests to answer.
methodstringOnly answer requests with this HTTP method.
statusnumber200Response status.
headersRecord<string, string>Response headers.
bodyunknownResponse 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 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

OptionTypeDefaultDescription
name*stringUnique name, shown in reports and used by --scenario.
localestringthis machine's localeBCP 47 locale for the browser (also sets Accept-Language).
timezoneIdstringthis machine's timezoneIANA timezone for the browser, e.g. Asia/Karachi.
colorSchemeColorScheme"light"Emulated prefers-color-scheme: "light", "dark" or "no-preference".
reducedMotion"reduce" | "no-preference"Emulated prefers-reduced-motion.
viewportViewportOptionViewport size as { width, height }, or a preset: "mobile" (390×844, touch), "tablet" (820×1180) or "desktop" (1280×800).
userAgentstringUser agent string for the browser context.
storageStatestringPlaywright storage state file (cookies and localStorage), e.g. a logged-in user.
cookiesCookieConfig[]Cookies set before every page load, e.g. a session cookie for a signed-in user.
headersRecord<string, string>Extra request headers.
localStorageRecord<string, string>localStorage entries set before page scripts run.
sessionStorageRecord<string, string>sessionStorage entries set before page scripts run.
initScriptsstring[]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.
mocksMockConfig[]Answers for browser requests (API fixtures). Server-side requests are not affected.
includestring[]Only test routes matching these globs in this scenario.
excludestring[]Skip routes matching these globs in this scenario.
queryRecord<string, string>Query parameters added to every URL (e.g. { currency: 'EUR' }).
browserBrowserNamebrowser.nameBrowser for this scenario.
networkNetworkProfileThrottle the network: "fast-3g", "slow-3g" or { downloadKbps, uploadKbps, latencyMs }. Chromium throttles natively; Firefox and WebKit delay subresources instead.
cpunumberSlow down JavaScript by this factor (Chromium only).
cacheCacheStatecoldwarm loads the page once before testing it, like a returning visitor.
clockstring | numberFixed 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.
randomSeednumberSeed for Math.random() and crypto.getRandomValues() in the browser (diagnostic, like clock).
OptionTypeDefaultDescription
name*stringCookie name.
value*stringCookie value.
domainstringthe tested originCookie domain.
pathstring"/"Cookie path.
httpOnlybooleanHide the cookie from page scripts.
securebooleanSend the cookie over HTTPS only.
sameSite"Strict" | "Lax" | "None"The SameSite attribute.