/** * Non-random parity probe. For each path in a list, fetch the same path * against two base URLs and surface differences in status, redirect * target, request failure, and (opt-in) response-body content. This is * the routing-bug detection mode — when random crawls find too much * third-party noise, a deterministic same-path probe across two * runtimes pinpoints where the routes actually disagree. * * Scope: HTTP request-level (status, Location, fetch failures) is * always-on. Body-content drift is opt-in via `checkBody` because the * body fetch doubles the work per probe and most consumers care first * about status. JavaScript exceptions still need a Playwright session * per probe and are deferred. */ import { type BodyDiffResult } from "./body-diff.js"; export type MismatchKind = /** HTTP status codes differ. */ "status" /** One side redirected to a different Location than the other. */ | "redirect" /** fetch() threw on one side and succeeded on the other. */ | "failure" /** * Status agreed but the response body bytes differ. Only fires when * `checkBody` is enabled in the run options. */ | "body" /** * Status agreed but one or more of the named response headers * differ. Only fires when `checkHeaders` is non-empty. */ | "header" /** * Status / body / headers all agreed (or weren't checked) but a * browser visit to one side surfaced JavaScript errors the other * side did not — uncaught exceptions, console.error, hydration * mismatches. The bug class that's invisible to HTTP-layer probes. * Only fires when `checkExceptions` is enabled. */ | "exception" /** * Right was slower than left by more than the configured budget. * Single-sample wall-clock is noisy by nature, so the threshold * (`perfDeltaMs` and/or `perfRatio`) must be set explicitly. When * `perfSamples > 1` the comparison runs against the configured * percentile of N serial samples (`perfStats[percentile]`) instead * of the single first-sample `durationMs` — same threshold flags, * fewer false positives. */ | "perf"; /** * Percentile of N-sample timings used for the perf comparison when * `perfSamples > 1`. `p95` is the SLO-standard default; `median` * smooths harder for noisy backends; `min` is best-case (closest to * a warm-cache lower bound); `p99` reserves jitter headroom for * cold-start outliers. */ export type PerfPercentile = "min" | "median" | "p95" | "p99"; /** * Distribution of N serial-sample timings for one side of a probe. * Populated by `runParity` when `perfSamples > 1`; `undefined` for * the default single-sample mode (in that case `durationMs` is the * only timing available). * * `samples` is the count of samples that actually completed — a * sample that errored after the first one doesn't contribute a * duration and is excluded. So a `perfSamples: 10` run with one * mid-run failure yields `samples: 9` here. */ export interface PerfStats { samples: number; min: number; median: number; p95: number; p99: number; } export interface SideResult { /** Final status. `null` when the fetch threw before getting a response. */ status: number | null; /** Redirect target for 3xx responses, when present. */ location?: string | null; /** Captured fetch error message when the request threw. */ error?: string; /** * Response body size in bytes. Populated only when `checkBody` is * enabled. Lets a consumer of the JSON report see at a glance how * different the two sides are without needing to refetch. */ bodyLength?: number; /** * SHA-256 of the raw body bytes, lowercase hex. Populated only when * `checkBody` is enabled. Used by `classify()` to detect drift; also * carried in the JSON report so consumers can tell whether the * mismatch reproduces across reruns (same pair of hashes → real * drift, not flakiness). */ bodyHash?: string; /** * Named response headers, lowercased and de-duplicated. Populated * only for headers listed in `checkHeaders`. A header that was * absent on a given side appears as `null`, so consumers can * distinguish "missing" from "empty". */ headers?: Record; /** * Uncaught page errors (`window.onerror` / Playwright's `pageerror` * event) captured during a browser visit. Populated only when * `checkExceptions` is enabled. */ pageErrors?: string[]; /** * `console.error` lines captured during a browser visit. Populated * only when `checkExceptions` is enabled. Console *warnings* are * deliberately ignored — chaosbringer's crawler does the same. */ consoleErrors?: string[]; /** * Wall-clock duration of the fetch, in milliseconds. Populated on * every successful probe (free — we already have the start time * before fetch). `undefined` when the probe failed before * timing could be meaningful (DNS error, connection refused). * When `perfSamples > 1` this is the FIRST sample's timing — * `perfStats` carries the distribution across all samples. */ durationMs?: number; /** * N-sample timing distribution. Set only when the run was * configured with `perfSamples > 1` and at least one sample beyond * the first completed successfully. The classifier uses * `perfStats[perfPercentile]` for the perf comparison when this is * present; otherwise it falls back to `durationMs`. */ perfStats?: PerfStats; } export interface ParityProbe { path: string; left: SideResult; right: SideResult; } export interface ParityMismatch extends ParityProbe { /** * All mismatch kinds detected for this probe, ordered by precedence * (status / failure → header → body → exception). Empty array can't * happen — a probe with no detected drift goes into `matches` * instead. * * Where a previous version exposed `kind: MismatchKind`, callers * should use `kinds[0]` for the primary signal — but `kinds` lets * a triager see ALL coexisting bugs at once (header + body + body * shape all firing on the same path). Hiding the body drift behind * the header drift was the bug. */ kinds: MismatchKind[]; /** * Localised body diff. Populated when `kinds` contains `"body"` and * both sides' bodies parsed as JSON. Carries up to ~50 path-level * entries; a truncated flag fires when the diff overflows. */ bodyDiff?: BodyDiffResult; } /** * Bumped when the report shape changes in a non-backwards-compatible * way. Downstream consumers (dashboards, CI scripts, the agent loop) * can reject reports of an unexpected version rather than silently * mis-reading a renamed field. Additive changes (new optional fields, * new `MismatchKind` values) do NOT bump this — they're behind * opt-in flags. */ export declare const PARITY_REPORT_SCHEMA_VERSION = 1; export interface ParityReport { /** Stable integer. See `PARITY_REPORT_SCHEMA_VERSION`. */ schemaVersion: number; left: string; right: string; pathsChecked: number; mismatches: ParityMismatch[]; /** Paths that agreed on every comparison. Carried so consumers can * prove which routes are stable without re-running. */ matches: ParityProbe[]; /** * The threshold + opt-in switches that produced this report. An * operator re-reading the JSON can tell at a glance which checks * were on, and which threshold a `perf` mismatch tripped against. * Re-running with a different config produces a different report — * the config is part of the result, not external state. */ config: { checkBody: boolean; checkHeaders: string[]; checkExceptions: boolean; followRedirects: boolean; timeoutMs: number; perfDeltaMs?: number; perfRatio?: number; perfSamples?: number; perfPercentile?: PerfPercentile; }; } export interface RunParityOptions { left: string; right: string; paths: string[]; /** * When `true`, fetch follows redirects and the comparison uses the * final status. When `false` (the default), `redirect: "manual"` * is used so 3xx and the Location header are compared directly — * the more sensitive mode for routing-bug detection. */ followRedirects?: boolean; /** * Per-request timeout in ms. Defaults to 10s. Applied to each side * independently; one slow side does not stall the other. */ timeoutMs?: number; /** * When `true`, the body of each response is read and compared by * SHA-256 hash. Adds one full body read per side per path, so it's * opt-in. Required to catch silent schema drift like a missing JSON * field that doesn't move the status code. */ checkBody?: boolean; /** * Header names to compare. Case-insensitive; lowercased internally * and matched against the response's header bag. When empty (the * default) no header comparison is done — headers vary too much * between requests for an unconditional check to be useful. * * Typical opt-ins: `["content-type", "cache-control", "set-cookie", * "access-control-allow-origin"]`. The list is the caller's policy; * we don't ship defaults so each consumer is forced to think about * which headers actually matter to their app. */ checkHeaders?: string[]; /** * When `true`, each path is also visited in a real browser (Chromium) * and uncaught page errors + `console.error` are recorded. The * comparison fires "exception" when the captured error sets differ * between sides — catches React hydration mismatches and other * runtime-only failures where HTTP looks identical. * * Cost is dominant: one browser visit per side per path is orders * of magnitude slower than the fetch probe. Browsers are reused * across paths via a single launch; per-path isolation comes from * a fresh `BrowserContext`. * * Playwright is loaded via dynamic import only when this is set, * so consumers that don't opt in do not pay the install cost. */ checkExceptions?: boolean; /** * Flag a `perf` mismatch when `right.durationMs - left.durationMs` * exceeds this many milliseconds. Off when unset / 0. Single-sample * wall-clock is noisy by nature — set the budget well above your * jitter floor, or run multiple sweeps and check the percentile * yourself. We don't ship N-sampling here because the right N is * caller-specific. */ perfDeltaMs?: number; /** * Flag a `perf` mismatch when `right.durationMs > left.durationMs * ratio`. * Off when unset / 0 or when either side's duration is 0 (avoids * divide-by-zero noise on instantly-cached responses). Composes with * `perfDeltaMs` via OR — either threshold tripping fires the * mismatch. */ perfRatio?: number; /** * Number of serial fetch samples per side per path. Defaults to 1 * (single-sample, the original behaviour). Set to >1 to defeat * wall-clock jitter — `perfStats` is populated with the distribution * and the perf threshold compares the configured `perfPercentile` * instead of `durationMs`. * * Samples run serially (single connection — concurrent samples would * change the timing model). The per-sample timeout is `timeoutMs`, * so worst-case wall-clock per probe is `perfSamples * timeoutMs` * per side. The first sample captures status/headers/body as * before; later samples contribute timing only. */ perfSamples?: number; /** * Which percentile of `perfStats` to compare against `perfDeltaMs` / * `perfRatio` when `perfSamples > 1`. Defaults to `"p95"` — the * SLO-standard target. Ignored when `perfSamples <= 1` because there * is no distribution to take a percentile of. */ perfPercentile?: PerfPercentile; /** Override fetch for testing. */ fetcher?: typeof fetch; /** * Override the browser launcher for testing. The default lazy-loads * `playwright.chromium.launch()`. A test can pass a fake that * produces canned `pageErrors` / `consoleErrors` per URL without * actually starting Chromium. */ browserLauncher?: () => Promise; } export interface PageLike { on(event: "pageerror", handler: (err: Error) => void): void; on(event: "console", handler: (msg: ConsoleMessageLike) => void): void; goto(url: string, opts?: { timeout?: number; waitUntil?: string; }): Promise; waitForLoadState?(state: string, opts?: { timeout?: number; }): Promise; } interface ConsoleMessageLike { type(): string; text(): string; } export interface ContextLike { newPage(): Promise; close(): Promise; } export interface BrowserLike { newContext(): Promise; close(): Promise; } export declare function runParity(opts: RunParityOptions): Promise; export {}; //# sourceMappingURL=parity.d.ts.map