import { type CaptureMetadata, type LiveRegionCandidate } from './capture.js'; import { type Finding } from './diff.js'; import { type LinkMatch } from './crawl.js'; import type { Page } from '@playwright/test'; import { type StateRecipe } from './state-recipes.js'; /** * A surface is one deterministic page state worth certifying: a route plus * the interactions that reach the state, captured at one viewport width per * @media band of its stylesheets. */ export type Surface = { /** Capture file name prefix; must be unique. */ key: string; /** * Navigate and drive the page to the state. Only reach the state — StyleProof * settles it for you (waits out in-flight data and fonts, freezes animations) * before reading, so you don't hand-roll `networkidle`/`fonts.ready` waits here. */ go: (page: Page) => Promise; /** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */ ignore?: string[]; /** * Viewport widths to sweep — one per @media band, so breakpoint rules are verified * too. OMIT to detect the app's real breakpoints from the loaded CSSOM and sweep one * width per band automatically (no config); detection fails loudly if a stylesheet * is cross-origin/unreadable rather than guess. Set it explicitly to pin the sweep * or to cover a JS-only (`matchMedia`) breakpoint that has no CSS `@media` rule. */ widths?: number[]; /** Viewport height: a number, or a function of the width (default 800). */ height?: number | ((width: number) => number); /** * Optional deterministic states of this same surface. Each variant becomes its * own capture (`-@`), so base/head compare loading to * loading and loaded to loaded instead of treating live UI as one fuzzy state. */ variants?: SurfaceVariant[]; /** * First-class live product states for this surface. Use this for loading, * loaded, empty, error, streaming, etc. StyleProof records them as live-state * variants so reports and diagnostics can explain why the capture was split. */ liveStates?: SurfaceLiveState[]; /** * Independent interaction state recipes for this surface (hover / focus / press / * click). Each recipe becomes its own capture (`-@`) * after the parent `go`, never as multi-step choreography. Validated and * key-sorted via {@link parseStateRecipes} at expansion time — unsafe declared * labels and invalid selectors fail closed before browser tests register. */ stateRecipes?: StateRecipe[]; /** * Opt in to automatically opening visible click-triggered popups after the base * surface is captured. Captures persistent dialogs, popovers, menus, listboxes, * tooltips, and open data-state overlays as `-popup-XX`. */ popups?: boolean | PopupCaptureOptions; }; export type SurfaceVariant = { /** Capture key suffix, joined as `-`. */ key: string; /** * Seed the state before the parent surface navigates: route mocks, fixture data, * localStorage/sessionStorage, feature flags, etc. */ setup?: (page: Page) => Promise; /** Drive or assert the variant after the parent surface reaches its base state. */ go?: (page: Page) => Promise; /** Extra ignored selectors for this variant, appended to the parent surface's ignore list. */ ignore?: string[]; /** Override the parent viewport widths for this variant. */ widths?: number[]; /** Override the parent viewport height for this variant. */ height?: number | ((width: number) => number); }; export type SurfaceLiveState = SurfaceVariant; export type PopupCaptureOptions = { /** Enable/disable popup discovery for this surface or capture run. */ enabled?: boolean; /** Max visible trigger controls to try per surface/width (default 20). */ max?: number; /** CSS selector for visible controls to click. */ triggers?: string; /** CSS selector for visible popup/overlay roots that mean a click opened state. */ overlays?: string; /** Max ms to wait for a clicked control to reveal an overlay (default 750). */ timeoutMs?: number; }; export type DefineOptions = { surfaces: Surface[]; /** * The full set of surface keys the app knows it has — its route/view/state * universe, typically derived from registries (e.g. routes plus modal/menu * flows). Include expanded variant keys such as `dashboard-dialog-open` when * those states must be certified. * When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not * gated on a capture dir) that fails if any expected key is neither captured (a * surface) nor in `exclude`. This is what stops a newly added route from * shipping uncaptured: the gate can only diff what a spec lists, so without this * a forgotten surface is silently invisible. Omit to opt out (no guard). */ expected?: string[]; /** * Expected keys deliberately NOT captured, each mapped to the reason — a visible, * reviewed opt-out ledger. Keeps the coverage guard green for known gaps without * letting them hide: an entry whose key isn't in `expected` (a renamed/removed * route) also fails the guard, so the ledger can't rot. */ exclude?: Record; /** * Output directory label. Convention: drive it from an env var so the same * spec captures `before`, `after`, or a CI label — and skips entirely when * unset, keeping the spec inert during normal test runs. */ dir: string | undefined; /** Base output directory (default `__stylemaps__` next to the invoking spec's CWD). */ baseDir?: string; /** * Also save a full-page screenshot per capture (default true). The report * generator crops these to show changed regions side by side; captures * without screenshots still diff, but produce text-only reports. */ screenshots?: boolean; /** * Replay a baseline run's recorded responses so a before/after diff reflects * code, not live-data drift. When set (or via STYLEPROOF_REPLAY_FROM), each * surface replays `/@.har` for requests matching * `replayUrl`; otherwise the run RECORDS that HAR into its own dir for the * comparison run to replay. Only data URLs are intercepted, so the app's own * JS/CSS still load live — the captured run renders ITS code against the * baseline's data. This is what makes captures deterministic with no per-repo * fixtures: record once on the base, replay on the head. */ replayFrom?: string; /** * URL glob for the data boundary to record/replay (default `**\/api/**`, or * STYLEPROOF_REPLAY_URL). Requests outside it (JS/CSS/fonts/images) always * load live so the captured code actually runs. */ replayUrl?: string; /** * Freeze `Date.now()`/`new Date()` to a fixed instant so time-derived styling * (relative-age classes, "stale > 1h" flags) can't drift between runs. Timers * keep running, so settling/polling still works. Default true. * * Two clocks are covered: the BROWSER clock (pinned here per page), and the * SPEC PROCESS clock — `styleproof-map` sets `STYLEPROOF_FREEZE_SPEC_CLOCK=1` * so that importing `styleproof` pins Node's `Date` before the spec's own * module body runs. A fixture stamped `new Date().toISOString()` at module * level is therefore identical on the base and head captures instead of * leaking each run's wall clock into the rendered page (which surfaces as * phantom text-width diffs the in-run self-check cannot see — both of its * captures share one process and therefore one stamp). `freezeClock: false` * restores the real spec-process clock at define time. */ freezeClock?: boolean; /** Fixed instant for the frozen clock (default `2025-01-01T00:00:00Z`). */ clockTime?: string | number | Date; /** * Capture each surface twice and fail if the computed styles differ — proves the * capture is deterministic (catches a replay gap falling through to the live * backend, or unseeded client randomness) instead of letting it surface as a * phantom change on an unrelated diff. * * Defaults ON for the RECORDING run and OFF for the REPLAY run: live nondeterminism * surfaces while recording against the real backend, whereas the replay run renders * against the recorded HAR and is deterministic by construction — so self-checking it * just doubles the work. `STYLEPROOF_SELFCHECK=1` forces it on for both; pass * `selfCheck` explicitly to override. */ selfCheck?: boolean; /** * Per-surface capture ceiling in milliseconds (default 300000 — 5 minutes; * STYLEPROOF_SURFACE_TIMEOUT_MS overrides when unset). Covers one surface at * one width, navigate through self-check. On breach the capture fails LOUDLY, * naming the surface and the phase in flight (navigate / settle / capture / * self-check), so one stuck surface can't silently consume the whole job * budget. Paired with the progress heartbeat — every completed surface logs * `styleproof: surface 17/41 (factory@1280) captured in 42.1s (self-check * 12.3s)` — a slow capture run stays distinguishable from a hung one. * Automatic popup captures run after the timed window (each popup interaction * is already bounded by `popups.timeoutMs`). */ surfaceTimeoutMs?: number; /** * Run the generated capture tests in PARALLEL across Playwright workers * (default true). Every capture test is independent, so parallel is safe and * ~workers× faster on a multi-surface spec — even when the project config * pins `fullyParallel: false`. Set false ONLY for a spec file whose OTHER * tests read the captured maps in file order (an in-file assertion suite). */ parallel?: boolean; /** * Opt-in content layer (default OFF). Record each element's own rendered text * so the report's optional content section can surface copy changes (run * `styleproof-report --include-content`). Advisory only — never gates. See * `CaptureOptions.captureText` and the README's "Optional: content layer". */ captureText?: boolean; /** * Opt-in React layer (default OFF). Record the component + sanitized props that * rendered each element so the report can name `Button (variant=primary)`. * Advisory only — never gates. See `CaptureOptions.captureComponent`. */ captureComponent?: boolean; /** * Opt-in automatic popup/modal capture for every surface. Existing suites keep * their exact capture set unless this is enabled. */ popups?: boolean | PopupCaptureOptions; /** * Opt-in inventory guard (default OFF). Harvest each surface's navigable * affordances — route links, `role=tab`/`menuitem`, button-only nav — into * `StyleMap.inventory`, so `styleproof-diff` fails when a nav item / route the UI * used to offer disappears (acknowledge intentional removals in * `styleproof.inventory.json`). Additive; ignored by the certification style diff. * See `docs/inventory-guard.md`. */ inventory?: boolean; /** * Data-residue guard. During capture, any request matching the data boundary * (`replayUrl`, default `**\/api/**`) that FAILS — a network error or a 4xx/5xx — * means the captured state renders that endpoint's FALLBACK branch, so states driven * by its real responses are uncaptured and unproven (issue #205). Such a failure is * ALWAYS named on stderr and recorded on the capture (`StyleMap.dataResidue`) so the * diff/report can surface it. `'gate'` (the default) makes an UNACKNOWLEDGED failing * endpoint block `styleproof-diff` (exit 1); acknowledge intentional ones in * `styleproof.data-residue.json` (`key -> reason`). `'warn'` is the explicit opt-out — * failures are still named + recorded but never block. A capture with no failing data * request is byte-identical either way. A 2xx that merely wasn't fixtured is NEVER * flagged (recording legitimately records live 2xx). */ dataResidue?: 'warn' | 'gate'; }; /** Self-check / nondeterminism failures must never be tolerated (#276). */ export declare function isSelfCheckCaptureFailure(message: string): boolean; export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string; type ResolvedPopupCaptureOptions = Required; export declare function resolvePopupCaptureOptions(input: boolean | PopupCaptureOptions | undefined): ResolvedPopupCaptureOptions; type ExpandedSurface = Omit & { metadata?: CaptureMetadata; }; export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[]; /** The identity fields of an expanded surface a collision check needs. */ type ExpandedKeyed = { key: string; metadata?: CaptureMetadata; }; /** * Fail LOUDLY on two expanded surfaces sharing a capture key. * * The expanded key is `surface.key-variant.key` (or `surface.key-stateKey` for * state recipes), and that key is the map filename (`@.json.gz`) and * the report identity — so it's public and can't change without breaking backward * compatibility. But the `-` join is ambiguous: surface `a` + variant `b-c` and * surface `a-b` + variant `c` both expand to `a-b-c`, and the second capture would * silently overwrite the first, dropping a surface with no error. The same collision * can arise between a recipe state key and a hand-named variant/liveState. Rather * than mangle the public key format, we assert uniqueness up front and name BOTH * origins so the author can rename one — without echoing recipe selectors or other * potentially secret-bearing fields. Path-unsafe keys also fail closed before any write. */ export declare function assertUniqueExpandedKeys(surfaces: ExpandedKeyed[]): void; export { assertSafeCaptureKey, captureArtifactStem } from './surface-keys.js'; /** * Let SSE (EventSource) requests bypass HAR record/replay and reach the live * server. A long-lived stream can't round-trip through a HAR entry: recording * captures at most a truncated body, and on replay the connection aborts, so the * app drops to its no-stream fallback — a DIFFERENT but STABLE state that * settle/volatile can't catch (it isn't moving, so it reads as a real change). * Passing the stream through on BOTH record and replay keeps both sides in the * same streamed state; the data it pushes must be deterministic at capture time * (fixtures/frozen clock), same as any live region. Detected by the * `Accept: text/event-stream` header EventSource always sends. * * Registered AFTER routeFromHAR so it matches first (Playwright runs the most * recently added route first); non-stream requests `fallback()` to the HAR. */ export declare function passLiveStreams(page: Page, url: string): Promise; /** * Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no * `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING, * since the replay run renders against the recorded HAR and is deterministic by * construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1` * forces it on either way. */ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean; /** Resolve a capture output dir: an ABSOLUTE `dir` is respected as-is (a user's * `STYLEMAP_DIR=/abs/path` must not be buried under `baseDir`); a relative one * nests under `baseDir` as before. */ export declare function resolveOutputDir(baseDir: string, dir: string): string; /** * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the * default. Lets CLIs and CI redirect capture into cache/fallback dirs without * editing the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`. */ export declare function resolveBaseDir(baseDir: string | undefined, env?: string | undefined): string; /** * Whether to save full-page screenshots: explicit `screenshots` wins, else * `STYLEPROOF_SCREENSHOTS=0` turns them off. On by default so restored map bundles * can generate reviewable reports without recapturing. */ export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean; /** * The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an * unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records + * warns without gating. Single source of truth for the default, so the flip lives here. */ export declare function resolveDataResidue(mode: 'warn' | 'gate' | undefined): 'warn' | 'gate'; /** The capture settings every capturer shares (everything bar the surface set). */ type CaptureConfig = Omit; /** * The `--grep` `styleproof-map` selects the capture tests with. * * A REGEX LITERAL, deliberately, not the bare phrase. Playwright compiles a plain * `--grep` string as `new RegExp(pattern, 'gi')` — **case-insensitive** — and matches * it against the whole grep title path: file path, every enclosing describe, the test * title, and any tags. A bare `styleproof capture` therefore selects any consumer test * whose title merely MENTIONS StyleProof capture in prose. * * That is not hypothetical. A consumer spec titled * `"ci runners: the StyleProof capture fixture actually contains the lane panel"` * was swept into the capture run. Because `styleproof-ci --spec-ref` overlays the head * harness onto the BASE checkout, it then ran against the base application, asserted * head-only UI, failed, and took the entire base capture down with it — leaving 230 * surfaces with no baseline to diff against while the head capture reported success. * A gate that certifies nothing while looking green is the worst failure this tool has. * * The `/.../` form makes Playwright honour it as a regex with no flags, so matching is * case-SENSITIVE, and the `(?:^|\s)` / `(?:\s|$)` boundaries stop it matching a longer * word. It still matches both capture blocks below, including the nested * `styleproof browser build` test, because the describe title is part of every * descendant's grep title. * * Kept here, beside the `test.describe` titles it has to agree with, because the * selector and the titles were two independent string literals and nothing held them * together. Not exported from `index.ts`: this is an internal contract between the * runner and `bin/styleproof-map.mjs`, not public API. */ export declare const CAPTURE_TEST_GREP = "/(?:^|\\s)styleproof capture(?:\\s|$)/"; export declare function defineStyleMapCapture(options: DefineOptions): void; /** Options for {@link defineCrawlCapture}: where to crawl, how to filter/key the * links, and the viewport sweep — plus the shared capture settings. */ export type CrawlOptions = CaptureConfig & { /** URL to crawl for surface links (e.g. `/`). Its same-origin ``s become * the surface set. */ from: string; /** Narrow the discovered links — substring, RegExp, or predicate over the URL * (e.g. `/\?tab=/` to capture only the tab views). Default: every same-origin link. */ match?: LinkMatch; /** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */ key?: (url: URL) => string; /** Viewport widths swept for every discovered surface. Omit to auto-detect each * surface's @media breakpoints (one viewport per band) — the same zero-config * behaviour as an explicit surface with no `widths`. */ widths?: number[]; /** Viewport height per width (default 800). */ height?: number | ((width: number) => number); /** Run after navigating to each discovered link, before capture — e.g. to trigger * scroll-reveal content. The built-in font/animation/network settle always runs; * this is the app-specific hook, the crawl's parity with a hand-listed surface's `go`. */ settle?: (page: Page) => Promise; /** Selectors skipped on every surface (live regions, third-party embeds). */ ignore?: string[]; /** Deterministic variants captured for every discovered link surface. */ variants?: SurfaceVariant[]; /** First-class live product states captured for every discovered link surface. */ liveStates?: SurfaceLiveState[]; /** Independent interaction state recipes captured for every discovered link surface. */ stateRecipes?: StateRecipe[]; /** Opt-in automatic popup/modal capture for every discovered link surface. */ popups?: boolean | PopupCaptureOptions; /** Max ms to wait for the crawl root's links to render before reading them * (an SPA hydrates its nav client-side). Default 15000. */ linkTimeout?: number; /** * The full set of surface keys the app knows its nav should link to — its route * universe. When set, the crawl reconciles the DISCOVERED link set against it, both * directions: an `expected` key with no rendered link fails (nav regression), and a * rendered link with no `expected` entry fails (a new route with no owner). For a * link-crawled SPA the rendered nav IS the route universe, so this is the same * list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the * source of truth. * * Unlike the spec guard, this runs INSIDE the crawl capture test — the link set * isn't known until a browser renders the page — so it only fires when the capture * runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current * behaviour: capture what the nav links to, assert no completeness. */ expected?: string[]; /** * Expected/rendered keys deliberately not reconciled, each mapped to its reason — a * visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth * or a feature flag) and so can't be asserted present or absent on every run. An * excluded key never triggers a missing- or unexpected-link failure; an `exclude` * key in neither `expected` nor the rendered set fails, so the ledger can't rot. */ exclude?: Record; }; export declare function defineCrawlCapture(options: CrawlOptions): void;