/** * ChaosCrawler - Playwright-based chaos testing crawler */ import type { Browser, BrowserContext, Page, Route } from "playwright"; import { chromium, devices } from "playwright"; import { randomBytes } from "node:crypto"; import { mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import type { CrawlerOptions, CrawlerEvents, PageResult, PageError, ActionResult, ActionTarget, ActionWeights, PerformanceMetrics, CrawlReport, CrawlSummary, RecoveryInfo, QueueEntry, DiscoveryMetrics, DeadLinkInfo, DiscoveryMethod, SpaIssueInfo, Invariant, FaultRule, FaultInjectionStats, Fault, LifecycleFault, LifecycleFaultStats, LifecycleStage, CoverageFeedbackOptions, CoverageReport, UrlMatcher, NetworkProfile, } from "./types.js"; import { NETWORK_PROFILES, PERF_BUDGET_KEYS } from "./types.js"; import { compileLifecycleFaults, executeLifecycleAction, lifecycleFaultsAtStage, lifecycleMatchesUrl, lifecycleStatsFrom, PlaywrightLifecycleExecutor, shouldFireProbability, type CompiledLifecycleFault, } from "./lifecycle-faults.js"; import { buildRuntimeFaultsScript, compileRuntimeFaults, mergeRuntimeStats, type CompiledRuntimeFault, } from "./runtime-faults.js"; import { CoverageCollector, coverageDelta, noveltyMultiplier, summarizeCoverage, targetKey, } from "./coverage.js"; import { resolveSpaNavigationUrls, type RawSpaNavigation, } from "./spa-navigation.js"; import { Logger, createNullLogger } from "./logger.js"; import { matchesAnyPattern, matchesSpaPattern as matchesSpaPatternPure, isExternalUrl as isExternalUrlPure, escapeSelector as escapeSelectorPure, summarizePages, normalizeUrl, } from "./filters.js"; import { createRng, randomSeed, weightedPick, randomInt, type Rng } from "./random.js"; import { clusterErrors } from "./clusters.js"; import { checkPerformanceBudget } from "./budget.js"; import { networkConditionsFor } from "./network.js"; import { shardOwns } from "./shard.js"; import { fetchSitemapUrls } from "./sitemap.js"; import { ServerFaultCollector } from "./server-fault-collector.js"; import { shouldSaveArtifacts, writeFailureBundle } from "./failure-artifacts.js"; import { TRACE_FORMAT_VERSION, actionToTraceEntry, groupTrace, readTrace, writeTrace, type TraceAction, type TraceEntry, } from "./trace.js"; import { AdvisorBudget, StallTracker } from "./advisor/budget.js"; import { consultAdvisor } from "./advisor/consult.js"; import { defaultTriggerPolicy, type TriggerPolicy } from "./advisor/trigger.js"; import { REDACTED_REASONING, type ActionAdvisor, type AdvisorCandidate } from "./advisor/types.js"; import type { AdvisorPick, ReplayFidelity } from "./types.js"; import type { Driver, DriverCandidate, DriverHistoryEntry, DriverInvariantViolation, DriverPick, DriverStep, ScreenshotMode, } from "./drivers/types.js"; // Options that are opt-in with no meaningful default (HAR, storage state, // perf budget, trace, device/network) are carved out of the Required<> // type instead of inventing sentinels. const DEFAULT_OPTIONS: Required< Omit< CrawlerOptions, | "baseUrl" | "har" | "storageState" | "performanceBudget" | "traceOut" | "traceReplay" | "device" | "network" | "seedFromSitemap" | "shardIndex" | "shardCount" | "failureArtifacts" | "coverageFeedback" | "advisor" | "traceparent" | "server" | "driver" | "driverGoal" > > = { maxPages: 50, maxActionsPerPage: 5, timeout: 30000, headless: true, screenshots: false, screenshotDir: "./screenshots", excludePatterns: [], ignoreErrorPatterns: [], spaPatterns: [], viewport: { width: 1280, height: 720 }, userAgent: "", blockExternalNavigation: true, actionWeights: {}, logFile: "", logLevel: "info", logToConsole: false, enableRecovery: true, recoveryHistorySize: 20, seed: 0, // Overwritten at construction time if unset invariants: [], faultInjection: [], lifecycleFaults: [], runtimeFaults: [], }; const DEFAULT_ACTION_WEIGHTS: Required = { navigationLinks: 3, buttons: 2, inputs: 1, ariaInteractive: 2, visibleText: 1.5, scroll: 0.5, }; // Common third-party scripts to ignore in dev mode export const COMMON_IGNORE_PATTERNS = [ "cloudflareinsights\\.com", "googletagmanager\\.com", "google-analytics\\.com", "analytics\\.google\\.com", "facebook\\.net", "connect\\.facebook\\.net", "hotjar\\.com", "clarity\\.ms", "segment\\.io", "amplitude\\.com", // Generic error message from blocked resources "Failed to load resource: net::ERR_FAILED$", ]; /** * Named bundles of regex patterns for `--exclude` / `excludePatterns`. * * Real-world crawls hit the same families of third-party noise (analytics * pixels, embedded map tiles, video players) again and again. Each preset * names one such family so wrapper scripts can write `--ignore-preset * analytics,maps` instead of pasting 8 regexes. Presets compose with * `--exclude` — both are unioned before the pattern test runs. * * Keys are intentionally short and stable. Adding a new pattern to an * existing preset is backward-compatible; renaming a preset is not. */ export const IGNORE_PRESETS: Record = { // Analytics / tag managers / ad-conversion endpoints. Overlaps with // COMMON_IGNORE_PATTERNS but explicit so the preset is self-contained. analytics: [ "cloudflareinsights\\.com", "googletagmanager\\.com", "google-analytics\\.com", "analytics\\.google\\.com", "googleadservices\\.com", "doubleclick\\.net", "facebook\\.net", "connect\\.facebook\\.net", "hotjar\\.com", "clarity\\.ms", "segment\\.io", "amplitude\\.com", "mixpanel\\.com", ], // Embedded map iframes + map script/tile requests. maps: [ "maps\\.googleapis\\.com", "maps\\.google\\.com", "mapbox\\.com", "openstreetmap\\.org", "tile\\.openstreetmap\\.org", ], // Video / audio player embeds. "media-embeds": [ "youtube\\.com/embed", "youtube-nocookie\\.com", "player\\.vimeo\\.com", "vimeocdn\\.com", "soundcloud\\.com/player", "spotify\\.com/embed", ], // Chrome's Opaque Response Blocking (ORB) failures for PDFs / cross-origin // documents — generic enough to drown out real failures otherwise. "pdf-orb": [ "ERR_BLOCKED_BY_ORB", "net::ERR_BLOCKED_BY_ORB", "\\.pdf$", ], // Blocked sandboxed iframe sub-requests — common in embedded widgets. "iframe-sandbox": [ "sandbox attribute", "Blocked a frame with origin", "Refused to display .* in a frame", ], }; /** * Resolve a comma-separated preset spec into a flat list of regex * strings. Unknown preset names throw with the available keys listed — * silent fall-through would let typos drop the user's noise filter and * spam the report. */ export function resolveIgnorePresets(spec: string): string[] { const out: string[] = []; const known = Object.keys(IGNORE_PRESETS); for (const raw of spec.split(",")) { const name = raw.trim(); if (!name) continue; const preset = IGNORE_PRESETS[name]; if (!preset) { throw new Error( `unknown ignore preset "${name}". Available: ${known.join(", ")}`, ); } out.push(...preset); } return out; } /** * Parse a W3C traceparent header. Returns `null` if the value is malformed. * Used by the route handler when honouring an incoming traceparent so we * can still pass `traceId` / `spanId` to the consumer hook. * * Format: `00-{trace-id-32hex}-{span-id-16hex}-{flags-2hex}`. * * Exported for testing — consumers should not parse traceparents * themselves. */ export function parseTraceparent(value: string): { traceId: string; spanId: string } | null { // version-traceId-spanId-flags const m = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i.exec(value.trim()); if (!m) return null; return { traceId: m[2].toLowerCase(), spanId: m[3].toLowerCase() }; } /** Structural type-guard for the opaque `driver` option. */ function isDriver(v: unknown): v is Driver { return ( typeof v === "object" && v !== null && typeof (v as { name?: unknown }).name === "string" && typeof (v as { selectAction?: unknown }).selectAction === "function" ); } function describeTarget(t: ActionTarget): string { const parts: string[] = []; if (t.role) parts.push(t.role); if (t.name) parts.push(`"${t.name}"`); parts.push(`(${t.type})`); if (t.href) parts.push(`href=${t.href}`); return parts.join(" "); } export class ChaosCrawler { private options: Required; private actionWeights: Required; private events: CrawlerEvents; private logger: Logger; private browser: Browser | null = null; private context: BrowserContext | null = null; private visited: Set = new Set(); private queue: QueueEntry[] = []; private results: PageResult[] = []; private actions: ActionResult[] = []; private blockedExternalCount = 0; private startTime = 0; private baseOrigin: string; /** Actions performed on the page currently being crawled. Reset on * every new crawlPage call so the recovery dump only reports actions * from the page that actually failed. */ private currentPageActions: ActionResult[] = []; /** Last successfully loaded URL for recovery */ private lastSuccessfulUrl: string = ""; /** Recovery count for reporting */ private recoveryCount = 0; /** How many failure-artifact bundles have been written. Used as a sequence + cap. */ private failureArtifactCount = 0; /** Discovery metrics */ private discoveryMetrics: DiscoveryMetrics = { extractedLinks: 0, clickedLinks: 0, uniquePages: 0, deadLinks: [], spaIssues: [], }; /** Current page being crawled (for source tracking) */ private currentEntry: QueueEntry | null = null; /** JSONL trace entries collected when `traceOut` is set. */ private trace: TraceEntry[] = []; /** * Actions to replay on the current page. Non-null only while a replay run * is mid-flight; the action dispatcher branches on this to decide between * random-weighted and playback. */ private currentReplayActions: TraceAction[] | null = null; /** Deterministic RNG for reproducible action selection. */ private rng: Rng; /** Fault injection rules compiled once at construction time. */ private compiledFaultRules: Array<{ rule: FaultRule; pattern: RegExp; methods?: string[]; matched: number; injected: number; }> = []; /** Page-lifecycle faults compiled once at construction time. */ private compiledLifecycleFaults: CompiledLifecycleFault[] = []; /** Compiled runtime faults — installed once at context level via init script. */ private compiledRuntimeFaults: CompiledRuntimeFault[] = []; /** * Per-page lifecycle executor — created on `applyLifecycleStage` first * call for each page and dropped when the page is closed. */ private lifecycleExecutor: PlaywrightLifecycleExecutor | null = null; /** * Run-scoped key/value bag shared with every invariant via * `InvariantContext.state`. Reset on `start()` so reusing a crawler for * multiple runs doesn't leak stale state. */ private invariantState: Map = new Map(); /** Resolved coverage-feedback config (null when disabled). */ private coverageFeedback: { enabled: true; boost: number; topN: number } | null = null; /** Per-page coverage collector — recreated each new page when enabled. */ private coverageCollector: CoverageCollector | null = null; /** All V8 function fingerprints seen across the run so far. */ private globalCoverage: Set = new Set(); /** Per-page coverage delta count, in BFS visit order. */ private pageCoverageDeltas: Array<{ url: string; addedCount: number }> = []; /** Historical novelty score per `targetKey(url, selector)`. */ private targetNovelty: Map = new Map(); /** * Most recent V8 coverage snapshot taken for the current page. Per-action * deltas are computed as `take() − lastCoverageSnapshot`. Reset to an * empty set whenever a fresh page is opened. */ private lastCoverageSnapshot: Set = new Set(); /** Resolved advisor wiring (null when the option is unset). */ private advisorRuntime: { provider: ActionAdvisor; policy: TriggerPolicy; budget: AdvisorBudget; stall: StallTracker; timeoutMs: number; redactReasoning: boolean; screenshotFullPage: boolean; callsAttempted: number; callsSucceeded: number; picks: AdvisorPick[]; /** * Selector → suggestion map, populated immediately before * `performActionOnTarget` so the just-recorded ActionResult can be * stamped onto the matching trace entry. Cleared after every action * so a non-advisor action doesn't accidentally pick up stale state. */ pendingPick: { selector: string; reasoning: string; reason: AdvisorPick["reason"] } | null; } | null = null; /** Per-run replay drift counters. Populated only when `traceReplay` is set. */ private replayFidelity: ReplayFidelity | null = null; /** * Buffers server-side fault events parsed from response headers when * `options.server.mode === "remote"`. Null otherwise so the per-page * `page.on("response")` listener can be skipped in the common case. */ private readonly serverFaultCollector: ServerFaultCollector | null; /** * Set by the action loop immediately before each `performActionOnTarget` * call; cleared at the start of the next iteration. The traceparent * injection site uses `recordTraceId` to append captured trace-ids onto * this action's `traceIds[]`. */ private currentAction: ActionResult | null = null; /** Resolved driver (null when caller did not pass `options.driver`). */ private readonly driver: Driver | null; /** * Buffer of invariant violations observed since the driver's previous * step. Drained at the top of every driver-loop iteration. Empty when * no driver is configured. */ private driverPendingViolations: DriverInvariantViolation[] = []; constructor(options: CrawlerOptions, events: CrawlerEvents = {}) { validateOptions(options); // Filter out undefined values to preserve defaults const filteredOptions = Object.fromEntries( Object.entries(options).filter(([_, v]) => v !== undefined) ); this.options = { ...DEFAULT_OPTIONS, ...filteredOptions } as Required; this.serverFaultCollector = this.options.server?.mode === "remote" ? new ServerFaultCollector(this.options.server.responseHeaderPrefix ?? "x-chaos-fault") : null; this.actionWeights = { ...DEFAULT_ACTION_WEIGHTS, ...options.actionWeights }; this.events = events; this.baseOrigin = new URL(options.baseUrl).origin; this.rng = createRng(options.seed ?? randomSeed()); this.options.seed = this.rng.seed; this.compiledFaultRules = compileFaultRules(options.faultInjection); this.compiledLifecycleFaults = compileLifecycleFaults(options.lifecycleFaults); this.compiledRuntimeFaults = compileRuntimeFaults(options.runtimeFaults); if (options.coverageFeedback?.enabled) { this.coverageFeedback = { enabled: true, boost: options.coverageFeedback.boost ?? 2, topN: options.coverageFeedback.topN ?? 20, }; } this.driver = isDriver(options.driver) ? options.driver : null; if (options.advisor) { const defaults = defaultTriggerPolicy(); this.advisorRuntime = { provider: options.advisor.provider, policy: { maxCallsPerCrawl: options.advisor.maxCallsPerCrawl ?? defaults.maxCallsPerCrawl, maxCallsPerPage: options.advisor.maxCallsPerPage ?? defaults.maxCallsPerPage, noveltyStallThreshold: options.advisor.noveltyStallThreshold ?? defaults.noveltyStallThreshold, consultOnInvariantViolation: options.advisor.consultOnInvariantViolation ?? defaults.consultOnInvariantViolation, minCandidatesToConsult: options.advisor.minCandidatesToConsult ?? defaults.minCandidatesToConsult, }, budget: new AdvisorBudget(), stall: new StallTracker(), timeoutMs: options.advisor.timeoutMs ?? 8_000, redactReasoning: options.advisor.redactReasoning ?? false, screenshotFullPage: options.advisor.screenshotMode === "fullPage", callsAttempted: 0, callsSucceeded: 0, picks: [], pendingPick: null, }; } // Initialize logger if (options.logFile) { this.logger = new Logger({ logFile: options.logFile, level: options.logLevel || "info", console: options.logToConsole || false, jsonFormat: true, }); } else { this.logger = createNullLogger(); } } /** Seed used for this run (useful for reproducing failures). */ getSeed(): number { return this.rng.seed; } /** * Match drained rejections against already-captured `pageerror` entries and * reclassify them as unhandled-rejection. Rejections in Chromium fire both * the DOM event and Playwright's CDP-level pageerror, so we dedupe here. */ private reclassifyRejections( errors: PageError[], rejections: Array<{ message: string; stack?: string }>, url: string ): void { for (const rejection of rejections) { if (this.shouldIgnoreError(rejection.message)) continue; const existing = errors.find( (e) => e.type === "exception" && e.message === rejection.message ); if (existing) { existing.type = "unhandled-rejection"; continue; } const error: PageError = { type: "unhandled-rejection", message: rejection.message, stack: rejection.stack, url, timestamp: Date.now(), }; errors.push(error); this.events.onError?.(error); this.logger.logPageError(error); } } /** * Evaluate all invariants declared for the given phase on the current page. * Any invariant that returns false/throws/returns a string is recorded as * a PageError with type "invariant-violation". */ private async runInvariants( phase: "afterLoad" | "afterActions", page: Page, url: string, errors: PageError[] ): Promise { const invariants = this.options.invariants || []; for (const inv of invariants) { const when = inv.when ?? "afterActions"; if (when !== phase) continue; if (inv.urlPattern) { const re = toRegExp(inv.urlPattern); if (re && !re.test(url)) continue; if (!re) continue; // Invalid pattern — silently skip (already flagged by validateOptions). } let failureReason: string | null = null; try { const result = await inv.check({ page, url, errors, state: this.invariantState }); if (result === false) { failureReason = `invariant "${inv.name}" returned false`; } else if (typeof result === "string") { failureReason = result; } } catch (err) { failureReason = err instanceof Error ? err.message : String(err); } if (failureReason !== null) { const error: PageError = { type: "invariant-violation", message: `[${inv.name}] ${failureReason}`, invariantName: inv.name, url, timestamp: Date.now(), }; errors.push(error); this.events.onError?.(error); this.logger.logPageError(error); this.advisorRuntime?.stall.recordInvariantViolation(); if (this.driver !== null) { this.driverPendingViolations.push({ name: inv.name, message: failureReason }); } } } } /** * Pop and return SPA navigations recorded by the in-page hook since the * previous drain. Used to surface History-API routing as discovered links * the BFS queue can pick up. */ private async drainSpaNavigations(page: Page): Promise { try { return await page.evaluate(() => { // @ts-ignore - bag installed via addInitScript const bag = (window.__chaosNavigations || []) as RawSpaNavigation[]; // @ts-ignore window.__chaosNavigations = []; return bag; }); } catch { // Page may have navigated away or closed — drop and move on. return []; } } /** Pop and return any unhandled promise rejections captured since last call. */ private async drainRejections(page: Page): Promise> { try { return await page.evaluate(() => { // @ts-ignore const bag = (window.__chaosRejections || []) as Array<{ message: string; stack?: string }>; // @ts-ignore window.__chaosRejections = []; return bag; }); } catch { // Page may have navigated away; drop rejections rather than throwing. return []; } } /** Get the logger instance for external use */ getLogger(): Logger { return this.logger; } /** Record an action for the current page's recovery dump. */ private addToHistory(action: ActionResult): void { this.currentPageActions.push(action); if (this.currentPageActions.length > this.options.recoveryHistorySize) { this.currentPageActions.shift(); } } /** Actions performed on the current page (for recovery diagnostics). */ getRecentActions(): ActionResult[] { return [...this.currentPageActions]; } /** Create recovery info from current state */ private createRecoveryInfo(failedUrl: string, error: string): RecoveryInfo { return { failedUrl, error, recoveredTo: this.lastSuccessfulUrl, recentActions: this.getRecentActions(), timestamp: Date.now(), }; } async start(): Promise { this.startTime = Date.now(); this.visited.clear(); this.queue = [{ url: normalizeUrl(this.options.baseUrl), sourceUrl: "", method: "initial", }]; this.results = []; this.actions = []; this.trace = []; this.blockedExternalCount = 0; this.failureArtifactCount = 0; this.invariantState = new Map(); this.globalCoverage = new Set(); this.pageCoverageDeltas = []; this.targetNovelty = new Map(); if (this.advisorRuntime) { this.advisorRuntime.budget = new AdvisorBudget(); this.advisorRuntime.stall = new StallTracker(); this.advisorRuntime.callsAttempted = 0; this.advisorRuntime.callsSucceeded = 0; this.advisorRuntime.picks = []; this.advisorRuntime.pendingPick = null; } this.replayFidelity = this.options.traceReplay ? { totalActions: 0, succeeded: 0, selectorMissing: 0, noSelectorRecorded: 0, threw: 0 } : null; if (this.isRecordingTrace()) { this.trace.push({ kind: "meta", v: TRACE_FORMAT_VERSION, seed: this.rng.seed, baseUrl: this.options.baseUrl, startTime: this.startTime, }); } if (this.options.seedFromSitemap) { await this.seedQueueFromSitemap(this.options.seedFromSitemap); } // Reset recovery state this.currentPageActions = []; this.lastSuccessfulUrl = this.options.baseUrl; this.recoveryCount = 0; // Reset discovery metrics this.discoveryMetrics = { extractedLinks: 0, clickedLinks: 0, uniquePages: 0, deadLinks: [], spaIssues: [], }; // Log crawl start this.logger.logCrawlStart(this.options.baseUrl, { maxPages: this.options.maxPages, maxActionsPerPage: this.options.maxActionsPerPage, timeout: this.options.timeout, blockExternalNavigation: this.options.blockExternalNavigation, enableRecovery: this.options.enableRecovery, }); if (this.options.screenshots && !existsSync(this.options.screenshotDir)) { mkdirSync(this.options.screenshotDir, { recursive: true }); } this.browser = await chromium.launch({ headless: this.options.headless }); // Device descriptor overrides viewport / userAgent / device pixel ratio; // explicit options in CrawlerOptions still win because they come later. const deviceDesc = this.options.device && devices[this.options.device] ? devices[this.options.device] : undefined; this.context = await this.browser.newContext({ ...deviceDesc, // Device descriptor's viewport wins when set — device emulation is // only meaningful if the viewport matches. Otherwise fall back to // the configured default. viewport: deviceDesc?.viewport ?? this.options.viewport, userAgent: this.options.userAgent || deviceDesc?.userAgent || undefined, // Record mode: ask Playwright to capture all network into the HAR. recordHar: this.options.har?.mode === "record" ? { path: this.options.har.path } : undefined, // Preloaded cookies + localStorage for auth'd crawls. Playwright parses // and validates the file; we don't touch it. storageState: this.options.storageState || undefined, }); // Runtime fault init script: monkey-patches in-page JS APIs (fetch / Date / // …) on every navigation. Installed at the context level so every page // (including ones opened via window.open later) inherits the patches. if (this.compiledRuntimeFaults.length > 0) { const script = buildRuntimeFaultsScript( this.compiledRuntimeFaults.map((c) => c.fault), this.rng.seed, ); await this.context.addInitScript({ content: script }); } // Replay mode: serve every matching request from the HAR before it hits // the network. Fault injection (installed per-page) still wins because // page.route runs before context.route in Playwright. if (this.options.har?.mode === "replay") { await this.context.routeFromHAR(this.options.har.path, { notFound: this.options.har.notFound ?? "fallback", }); } try { if (this.options.traceReplay) { // Replay: iterate every recorded (visit, actions) group. The trace // itself defines the scope — applying maxPages here would silently // truncate larger traces, so the cap only applies to live crawls. const groups = groupTrace(readTrace(this.options.traceReplay)); for (let i = 0; i < groups.length; i++) { const group = groups[i]!; if (this.shouldExclude(group.url)) { this.logger.debug("page_excluded", { url: group.url }); continue; } this.currentEntry = { url: group.url, sourceUrl: "", method: "initial" }; this.currentReplayActions = group.actions; this.discoveryMetrics.uniquePages++; this.events.onProgress?.(i + 1, groups.length); this.logger.logProgress(i + 1, groups.length); if (this.isRecordingTrace()) { this.trace.push({ kind: "visit", url: group.url }); } try { const result = await this.crawlPage(this.currentEntry); this.results.push(result); } finally { this.currentReplayActions = null; } } } else { while (this.queue.length > 0 && this.visited.size < this.options.maxPages) { const entry = this.queue.shift()!; if (this.visited.has(entry.url)) continue; if (this.shouldExclude(entry.url)) { this.logger.debug("page_excluded", { url: entry.url }); continue; } this.visited.add(entry.url); this.currentEntry = entry; this.discoveryMetrics.uniquePages++; this.events.onProgress?.(this.visited.size, this.options.maxPages); this.logger.logProgress(this.visited.size, this.options.maxPages); if (this.isRecordingTrace()) { this.trace.push({ kind: "visit", url: entry.url }); } const result = await this.crawlPage(entry); this.results.push(result); // Add discovered links to queue with source tracking for (const rawLink of result.links) { const link = normalizeUrl(rawLink); const alreadyQueued = this.queue.some((e) => e.url === link); if (!this.visited.has(link) && !alreadyQueued && this.ownsUrl(link)) { this.queue.push({ url: link, sourceUrl: entry.url, method: "extracted", }); this.discoveryMetrics.extractedLinks++; } } } } } finally { // Close the context explicitly so the HAR file (record mode) is flushed // before `browser.close()` tears everything down. await this.context?.close(); await this.browser.close(); if (this.options.traceOut && this.trace.length > 0) { writeTrace(this.options.traceOut, this.trace); } } const endTime = Date.now(); const report = this.generateReport(endTime); // Log crawl end this.logger.logCrawlEnd({ duration: report.duration, pagesVisited: report.pagesVisited, totalErrors: report.totalErrors, blockedExternalNavigations: report.blockedExternalNavigations, recoveryCount: this.recoveryCount, }); // Close logger await this.logger.close(); return report; } /** * Run chaos testing on a single page (for Playwright Test integration) */ async testPage(page: Page, url: string): Promise { this.startTime = Date.now(); this.baseOrigin = new URL(url).origin; // Set up external navigation blocking and/or fault injection routing. if ( this.options.blockExternalNavigation || this.compiledFaultRules.length > 0 || this.options.traceparent ) { await this.setupNavigationBlocking(page); } const result = await this.crawlPageWithExistingPage(page, url); this.events.onPageComplete?.(result); this.logger.logPageComplete(result); this.results.push(result); return result; } private shouldExclude(url: string): boolean { return matchesAnyPattern(url, this.options.excludePatterns); } /** * Shard ownership gate. Returns true when this shard should enqueue `url`. * Single-shard configs always return true. Multi-shard configs drop every * URL whose hash doesn't match this shard's index — except `baseUrl`, which * every shard must process so it has a seed for BFS. */ /** * True when trace entries should be recorded in memory. `traceOut` * obviously needs them; `failureArtifacts` also needs an in-memory trace * so it can serialize the prefix-up-to-failure into each bundle — but * only when `saveTrace` isn't explicitly disabled. Recording for a * caller that has opted out wastes memory on long crawls. */ private isRecordingTrace(): boolean { if (this.options.traceOut) return true; const fa = this.options.failureArtifacts; if (fa && fa.saveTrace !== false) return true; return false; } /** * If failure artefacts are enabled and the page result qualifies, capture * a screenshot + HTML + trace snapshot and dump a bundle directory. * Errors here are intentionally swallowed: the bundle is diagnostic, not * load-bearing — losing one bundle shouldn't take the crawler down. */ /** * Read `window.__chaosbringerRuntimeStats` and accumulate into the * compiled runtime-fault counters. Errors are swallowed: the in-page * counter is best-effort diagnostics, not load-bearing. */ private async collectRuntimeFaultStats(page: Page): Promise { if (this.compiledRuntimeFaults.length === 0) return; try { const pageStats = (await page.evaluate( () => (globalThis as { __chaosbringerRuntimeStats?: Record }) .__chaosbringerRuntimeStats ?? {}, )) as Record; mergeRuntimeStats(this.compiledRuntimeFaults, pageStats); } catch (err) { this.logger.warn("runtime_fault_stats_failed", { error: err instanceof Error ? err.message : String(err), }); } } private async maybeWriteFailureBundle(page: Page, result: PageResult): Promise { const opts = this.options.failureArtifacts; if (!opts) return; if (!shouldSaveArtifacts(result)) return; if ( typeof opts.maxArtifacts === "number" && this.failureArtifactCount >= opts.maxArtifacts ) { return; } const sequence = this.failureArtifactCount; this.failureArtifactCount++; let screenshot: Buffer | undefined; if ((opts.saveScreenshot ?? true)) { try { screenshot = await page.screenshot({ fullPage: true, type: "png" }); } catch (err) { this.logger.warn("failure_artifact_screenshot_failed", { url: result.url, error: err instanceof Error ? err.message : String(err), }); } } let html: string | undefined; if ((opts.saveHtml ?? true)) { try { html = await page.content(); } catch (err) { this.logger.warn("failure_artifact_html_failed", { url: result.url, error: err instanceof Error ? err.message : String(err), }); } } try { const bundleDir = writeFailureBundle({ options: opts, baseUrl: this.options.baseUrl, seed: this.rng.seed, sequence, result, screenshot, html, trace: (opts.saveTrace ?? true) ? this.trace : undefined, }); this.logger.info("failure_artifact_written", { url: result.url, bundleDir }); } catch (err) { this.logger.warn("failure_artifact_write_failed", { url: result.url, error: err instanceof Error ? err.message : String(err), }); } } private ownsUrl(url: string): boolean { const count = this.options.shardCount; if (count === undefined || count <= 1) return true; if (url === normalizeUrl(this.options.baseUrl)) return true; return shardOwns(url, this.options.shardIndex ?? 0, count); } /** Check if URL matches SPA patterns */ private matchesSpaPattern(url: string): string | null { return matchesSpaPatternPure(url, this.options.spaPatterns); } private shouldIgnoreError(message: string): boolean { return matchesAnyPattern(message, this.options.ignoreErrorPatterns, "i"); } private isExternalUrl(url: string): boolean { return isExternalUrlPure(url, this.baseOrigin); } /** * Pull URLs out of a sitemap (index-aware) and prepend them to the queue. * URLs outside the baseUrl origin are dropped — the crawler's * blockExternalNavigation would block them anyway, and queueing them * wastes visit budget. */ private async seedQueueFromSitemap(source: string): Promise { let urls: string[]; try { urls = await fetchSitemapUrls(source); } catch (err) { this.logger.warn("sitemap_fetch_failed", { source, reason: err instanceof Error ? err.message : String(err), }); return; } const baseOrigin = this.baseOrigin; const queuedUrls = new Set(this.queue.map((q) => q.url)); let added = 0; let skippedExternal = 0; for (const raw of urls) { let normalized: string; try { normalized = normalizeUrl(new URL(raw, this.options.baseUrl).toString()); } catch { continue; } try { if (new URL(normalized).origin !== baseOrigin) { skippedExternal++; continue; } } catch { continue; } if (queuedUrls.has(normalized)) continue; if (!this.ownsUrl(normalized)) continue; queuedUrls.add(normalized); this.queue.push({ url: normalized, sourceUrl: source, method: "extracted" }); added++; } this.logger.info("sitemap_seeded", { source, added, skippedExternal, total: urls.length }); } /** * Take a coverage snapshot right after `page.goto` finishes. Functions * executed during page load are credited to the URL itself (no specific * action) and folded into `globalCoverage`. The snapshot is also saved as * the per-action delta baseline. */ private async recordPageLoadCoverage(url: string): Promise { if (this.advisorRuntime) { this.advisorRuntime.stall.resetForNewPage(); this.advisorRuntime.budget.resetPage(url); } if (!this.coverageCollector) { this.lastCoverageSnapshot = new Set(); return; } let snapshot: Set; try { snapshot = await this.coverageCollector.take(); } catch (err) { this.logger.warn("coverage_take_failed", { url, phase: "page-load", reason: err instanceof Error ? err.message : String(err), }); this.lastCoverageSnapshot = new Set(); return; } const novel = coverageDelta(this.globalCoverage, snapshot); for (const fp of novel) this.globalCoverage.add(fp); this.pageCoverageDeltas.push({ url, addedCount: novel.size }); this.lastCoverageSnapshot = snapshot; } /** * Take a coverage snapshot after a chaos action and credit any * never-before-seen functions to `(url, selector)` in `targetNovelty`. * Updates `globalCoverage` and `lastCoverageSnapshot` so subsequent * actions see the right baseline. */ private async attributeActionCoverage(url: string, selector: string): Promise { if (!this.coverageCollector) return; let snapshot: Set; try { snapshot = await this.coverageCollector.take(); } catch (err) { this.logger.warn("coverage_take_failed", { url, selector, phase: "action", reason: err instanceof Error ? err.message : String(err), }); return; } const actionDelta = coverageDelta(this.lastCoverageSnapshot, snapshot); if (actionDelta.size === 0) { this.lastCoverageSnapshot = snapshot; this.advisorRuntime?.stall.recordZeroNovelty(); return; } const novel = coverageDelta(this.globalCoverage, actionDelta); if (novel.size > 0) { const key = targetKey(url, selector); this.targetNovelty.set(key, (this.targetNovelty.get(key) ?? 0) + novel.size); for (const fp of novel) this.globalCoverage.add(fp); this.advisorRuntime?.stall.recordNovelty(); } else { this.advisorRuntime?.stall.recordZeroNovelty(); } this.lastCoverageSnapshot = snapshot; } /** * Compute the coverage-feedback weight multiplier for a target on a given * URL. Returns 1 when feedback is off or when the target has no history. */ private coverageWeightFor(url: string, selector: string): number { if (!this.coverageFeedback) return 1; const score = this.targetNovelty.get(targetKey(url, selector)) ?? 0; return noveltyMultiplier(score, this.coverageFeedback.boost); } /** * Append a trace-id to the currently-executing action, if any. Called * from the per-request traceparent injection in `setupNavigationBlocking`. * No-op when no action is in flight (e.g. during initial page load). */ private recordTraceId(traceId: string): void { if (!this.currentAction) return; if (!this.currentAction.traceIds) this.currentAction.traceIds = []; this.currentAction.traceIds.push(traceId); } private async consultAdvisorIfStalled( page: Page, url: string, targets: ReadonlyArray, ): Promise { const runtime = this.advisorRuntime; if (!runtime) return null; const candidates: AdvisorCandidate[] = targets.map((t, index) => ({ index, selector: t.selector, description: describeTarget(t), })); const result = await consultAdvisor({ state: { callsThisCrawl: runtime.budget.callsThisCrawl(), callsThisPage: runtime.budget.callsThisPage(url), consecutiveZeroNovelty: runtime.stall.consecutiveZeroNovelty(), pendingInvariantViolation: runtime.stall.invariantViolationPending(), }, policy: runtime.policy, budget: runtime.budget, provider: runtime.provider, url, candidates, screenshotSupplier: () => page.screenshot({ fullPage: runtime.screenshotFullPage }), timeoutMs: runtime.timeoutMs, }); if (result.outcome === "skipped") return null; runtime.callsAttempted += 1; this.logger.debug("advisor_consult", { url, reason: result.decision.reason, candidateCount: candidates.length, outcome: result.outcome, durationMs: result.durationMs, provider: runtime.provider.name, }); if (!result.suggestion || !result.decision.reason) return null; runtime.callsSucceeded += 1; runtime.stall.recordAdvisorPick(); const target = targets[result.suggestion.chosenIndex]; if (!target) return null; const storedReasoning = runtime.redactReasoning ? REDACTED_REASONING : result.suggestion.reasoning; runtime.picks.push({ url, reason: result.decision.reason, chosenSelector: target.selector, reasoning: storedReasoning, }); runtime.pendingPick = { selector: target.selector, reasoning: storedReasoning, reason: result.decision.reason, }; return target; } /** * Pop the just-pending advisor pick if (and only if) it matches the * selector that was actually performed. Mismatch can happen when the * advisor's chosen target was rejected as not-visible and the loop * fell back to a heuristic pick — those should NOT be tagged advisor. */ private consumeAdvisorStamp(selector: string) { const runtime = this.advisorRuntime; if (!runtime?.pendingPick) return undefined; const pending = runtime.pendingPick; runtime.pendingPick = null; if (pending.selector !== selector) return undefined; return { provider: runtime.provider.name, reason: pending.reason, reasoning: pending.reasoning, }; } /** * Run every lifecycle fault that targets `stage` and matches `url`. Errors * are caught and recorded in the fault's stats counter — a misbehaving * fault should not abort the rest of the crawl. */ private async applyLifecycleStage( stage: LifecycleStage, page: Page, url: string, ): Promise { const compiled = lifecycleFaultsAtStage(this.compiledLifecycleFaults, stage); if (compiled.length === 0) return; if (this.lifecycleExecutor === null) { this.lifecycleExecutor = new PlaywrightLifecycleExecutor(page, this.context!); } const executor = this.lifecycleExecutor; for (const c of compiled) { if (!lifecycleMatchesUrl(c, url)) continue; c.matched++; if (!shouldFireProbability(c.fault.probability, this.rng)) continue; try { await executeLifecycleAction(c.fault.action, executor); c.fired++; } catch (err) { c.errored++; this.logger.warn("lifecycle_fault_failed", { name: c.name, stage, url, reason: err instanceof Error ? err.message : String(err), }); } } } /** * Attach a CDP session to the page and apply a throttling preset. Called * per-page because `Network.emulateNetworkConditions` is a Page-level * setting in Playwright — there's no context-wide equivalent. */ private async applyNetworkProfile(page: Page, profile: NetworkProfile): Promise { try { const client = await this.context!.newCDPSession(page); await client.send("Network.enable"); await client.send("Network.emulateNetworkConditions", networkConditionsFor(profile)); } catch (err) { this.logger.warn("network_profile_failed", { profile, reason: err instanceof Error ? err.message : String(err), }); } } private async setupNavigationBlocking(page: Page): Promise { const blockExternal = this.options.blockExternalNavigation; const rules = this.compiledFaultRules; const traceparentEnabled = this.options.traceparent !== undefined && this.options.traceparent !== false; const traceparentHook = typeof this.options.traceparent === "object" ? this.options.traceparent.onInject : undefined; // Install a single route handler that first considers fault injection, // then falls back to external-navigation blocking, then continues. await page.route("**/*", async (route: Route) => { const request = route.request(); const url = request.url(); const method = request.method().toUpperCase(); // Decide on the traceparent header up front so it's attached to every // path through this handler (fault response, blocked, fallback). let outgoingHeaders: Record | null = null; if (traceparentEnabled) { const reqHeaders = await request.allHeaders(); const existingTp = reqHeaders["traceparent"]; if (existingTp) { // Honour upstream propagation; record the correlation id on the // current action regardless of whether the user supplied a hook. const parts = parseTraceparent(existingTp); if (parts?.traceId) this.recordTraceId(parts.traceId); if (traceparentHook) { traceparentHook({ url, method, traceparent: existingTp, traceId: parts?.traceId ?? "", spanId: parts?.spanId ?? "", existing: true, }); } } else { const traceId = randomBytes(16).toString("hex"); // 32 hex chars const spanId = randomBytes(8).toString("hex"); // 16 hex chars const traceparent = `00-${traceId}-${spanId}-01`; outgoingHeaders = { ...reqHeaders, traceparent }; this.recordTraceId(traceId); traceparentHook?.({ url, method, traceparent, traceId, spanId, existing: false, }); } } // 1. Fault injection has priority so tests can exercise backends that // would otherwise be allowed through. for (const compiled of rules) { if (!compiled.pattern.test(url)) continue; if (compiled.methods && !compiled.methods.includes(method)) continue; compiled.matched++; const prob = compiled.rule.probability ?? 1; // prob 0 should never inject; prob 1 always injects; in between we // use the crawler's seeded RNG so probability is reproducible. if (prob < 1 && this.rng.next() >= prob) continue; compiled.injected++; await applyFault(route, compiled.rule.fault); return; } // 2. Block external navigation if requested. if (blockExternal && this.isExternalUrl(url)) { if (request.isNavigationRequest()) { this.blockedExternalCount++; this.events.onBlockedNavigation?.(url); this.logger.logBlockedNavigation(url); await route.abort("blockedbyclient"); return; } // Allow non-navigation external requests (images, scripts, etc.) } // route.fallback() (not continue) so context-level routes — notably // routeFromHAR for replay — still get a chance to serve this request. // When traceparent injection is on, override headers; otherwise let the // request through unchanged. if (outgoingHeaders) { await route.fallback({ headers: outgoingHeaders }); } else { await route.fallback(); } }); } private async crawlPage(entry: QueueEntry): Promise { const page = await this.context!.newPage(); const { url, sourceUrl, method, sourceElement } = entry; // Scope recovery diagnostics to this page only. this.currentPageActions = []; // Drop the previous page's lifecycle executor — next stage call // re-creates one against the current page. this.lifecycleExecutor = null; this.coverageCollector = null; if (this.options.network) { await this.applyNetworkProfile(page, this.options.network); } if (this.coverageFeedback) { // Attach a CDP session and start V8 precise coverage BEFORE goto so // load-time function execution is captured. try { const cdp = await this.context!.newCDPSession(page); this.coverageCollector = new CoverageCollector(cdp); await this.coverageCollector.start(); } catch (err) { this.logger.warn("coverage_attach_failed", { url, reason: err instanceof Error ? err.message : String(err), }); this.coverageCollector = null; } } if ( this.options.blockExternalNavigation || this.compiledFaultRules.length > 0 || this.options.traceparent ) { await this.setupNavigationBlocking(page); } try { const result = await this.crawlPageWithExistingPage(page, url); // Add source tracking to result result.discoveryMethod = method; result.sourceUrl = sourceUrl; result.sourceElement = sourceElement; // Capture failure artefacts BEFORE the recovery branch navigates the // page away — otherwise the screenshot would show the recovered URL // rather than the failing one. await this.maybeWriteFailureBundle(page, result); // Merge runtime-fault stats from the in-page counter. Recovery // navigates away, so we collect before that path runs. await this.collectRuntimeFaultStats(page); // Handle recovery from 404 or error status if ( this.options.enableRecovery && result.statusCode && (result.statusCode === 404 || result.statusCode >= 500) ) { // Track dead link with source information this.discoveryMetrics.deadLinks.push({ url, statusCode: result.statusCode, sourceUrl, sourceElement, method, }); const recovery = this.createRecoveryInfo( url, `HTTP ${result.statusCode}` ); this.logger.logRecovery(recovery); this.logger.logNavigationError(url, result.statusCode, `HTTP ${result.statusCode}`); this.recoveryCount++; // Try to recover by going back to last successful URL if (this.lastSuccessfulUrl && this.lastSuccessfulUrl !== url) { try { await page.goto(this.lastSuccessfulUrl, { timeout: this.options.timeout, waitUntil: "networkidle", }); this.logger.info("recovery_success", { recoveredTo: this.lastSuccessfulUrl }); } catch { // Recovery navigation failed, just continue this.logger.warn("recovery_failed", { url: this.lastSuccessfulUrl }); } } // Mark result as recovered result.recovery = recovery; result.status = "recovered"; } else if (result.status === "success" && result.statusCode === 200) { // Update last successful URL this.lastSuccessfulUrl = url; } this.events.onPageComplete?.(result); this.logger.logPageComplete(result); return result; } finally { if (this.coverageCollector) { try { await this.coverageCollector.stop(); } catch { /* page may already be closing — drop. */ } this.coverageCollector = null; } await page.close(); } } private async crawlPageWithExistingPage(page: Page, url: string): Promise { const errors: PageError[] = []; const warnings: string[] = []; const blockedNavigations: string[] = []; const startTime = Date.now(); // Set to false once collection is done so spurious events fired during // page.close() (in-flight requests getting cancelled as ERR_ABORTED, etc.) // don't pollute the PageResult. let collecting = true; this.events.onPageStart?.(url); this.logger.logPageStart(url); // Set up error listeners. Each error records `page.url()` at fire time // so that errors triggered after a chaos-action navigation are attributed // to the URL actually in the address bar, not the original crawlPage URL. page.on("console", (msg) => { if (!collecting) return; const type = msg.type(); const text = msg.text(); if (type === "error") { if (this.shouldIgnoreError(text)) return; const error: PageError = { type: "console", message: text, url: page.url(), timestamp: Date.now(), }; errors.push(error); this.events.onError?.(error); this.logger.logPageError(error); } else if (type === "warning") { warnings.push(text); } }); // Capture unhandled exceptions page.on("pageerror", (err) => { if (!collecting) return; if (this.shouldIgnoreError(err.message)) return; const error: PageError = { type: "exception", message: err.message, stack: err.stack, url: page.url(), timestamp: Date.now(), }; errors.push(error); this.events.onError?.(error); this.logger.logPageError(error); }); // Server-fault collector: when chaos() runs in remote-server mode, every // response carries `x-chaos-fault-*` headers describing any fault the // server-side middleware injected. Forward each response's headers to // the collector so `generateReport` (Task 13) can drain them. if (this.serverFaultCollector) { const collector = this.serverFaultCollector; page.on("response", (response) => { if (!collecting) return; // Playwright's APIResponse / Response gives a plain object via headers(). // Wrap in Headers so the collector's parser sees a Web-Standard surface. const h = new Headers(); for (const [k, v] of Object.entries(response.headers())) h.set(k, v); collector.observe({ headers: h, pageUrl: page.url() }); }); } // Capture unhandled promise rejections. Claim them via preventDefault so // they don't also fire as `pageerror` (which we'd misclassify as exception). await page.addInitScript(() => { // @ts-ignore - custom bag attached to window window.__chaosRejections = []; window.addEventListener("unhandledrejection", (event) => { const message = event.reason?.message || String(event.reason); const stack = event.reason?.stack; // @ts-ignore window.__chaosRejections.push({ message, stack }); event.preventDefault(); }); }); // Capture SPA route changes that go through the History API // (`pushState` / `replaceState`). React Router, Vue Router, SvelteKit, // Next.js client-side links, hand-rolled `useNavigate()` buttons — // all of them mutate history without firing a real navigation, which // means `extractLinks` (DOM-only) misses every URL they would route // to. We monkey-patch the two methods on every page so each call // appends the URL into a side channel that `drainSpaNavigations` // reads later. await page.addInitScript(() => { // @ts-ignore - custom bag attached to window window.__chaosNavigations = []; const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function (...args: unknown[]) { try { const url = args[2]; if (typeof url === "string" && url.length > 0) { // @ts-ignore window.__chaosNavigations.push({ method: "pushState", url, timestamp: Date.now(), }); } } catch { /* never let our hook break the host page */ } // eslint-disable-next-line @typescript-eslint/no-explicit-any return origPush.apply(this, args as any); }; history.replaceState = function (...args: unknown[]) { try { const url = args[2]; if (typeof url === "string" && url.length > 0) { // @ts-ignore window.__chaosNavigations.push({ method: "replaceState", url, timestamp: Date.now(), }); } } catch { /* never let our hook break the host page */ } // eslint-disable-next-line @typescript-eslint/no-explicit-any return origReplace.apply(this, args as any); }; }); page.on("requestfailed", (request) => { if (!collecting) return; const requestUrl = request.url(); if (this.shouldIgnoreError(requestUrl)) return; // Check if this is a SPA-related error const spaPattern = this.matchesSpaPattern(requestUrl); if (spaPattern) { this.discoveryMetrics.spaIssues.push({ url: requestUrl, type: "routing-404", message: request.failure()?.errorText || "SPA routing issue", matchedPattern: spaPattern, }); this.logger.debug("spa_issue", { url: requestUrl, pattern: spaPattern }); return; // Don't count as regular error } const failure = request.failure(); const error: PageError = { type: "network", message: `${requestUrl} - ${failure?.errorText || "Unknown error"}`, url: page.url(), timestamp: Date.now(), }; errors.push(error); this.events.onError?.(error); this.logger.logPageError(error); }); // Track blocked external navigations const originalBlockedCount = this.blockedExternalCount; let result: PageResult; try { // beforeNavigation lifecycle faults — applied before the load itself, // so e.g. CDP CPU throttling slows the navigation request. await this.applyLifecycleStage("beforeNavigation", page, url); const response = await page.goto(url, { timeout: this.options.timeout, waitUntil: "networkidle", }); // Drain any unhandled rejections captured during load. this.reclassifyRejections(errors, await this.drainRejections(page), url); // afterLoad lifecycle faults — page exists, DOM is reachable, but no // chaos actions have run yet. Storage wipes and tampering happen here. await this.applyLifecycleStage("afterLoad", page, url); // Take an initial coverage snapshot so subsequent action deltas have // a baseline. The page-load attribution (functions executed during // navigation) folds straight into globalCoverage — no specific action // owns it. await this.recordPageLoadCoverage(url); await this.runInvariants("afterLoad", page, url, errors); const loadTime = Date.now() - startTime; const metrics = await this.collectMetrics(page); this.enforcePerformanceBudget(metrics, url, errors); const links = await this.extractLinks(page); // History-API navigations that fired during page load (auto-routing // SPAs that redirect / on mount). Same de-dup happens at the queue // feeder, so duplicates between extractLinks and SPA drain are fine. const loadSpaUrls = resolveSpaNavigationUrls( await this.drainSpaNavigations(page), page.url(), ); for (const u of loadSpaUrls) links.push(u); // beforeActions lifecycle faults — invariants have passed, the chaos // driver is about to start. Service Worker cache eviction lives here. await this.applyLifecycleStage("beforeActions", page, url); // Replay mode bypasses the weighted random driver — playback owns // exactly what runs and in what order. if (this.currentReplayActions) { await this.performReplayActions(page, url, this.currentReplayActions); } else { await this.performWeightedActions(page, url); } // Drain any rejections that fired during actions. this.reclassifyRejections(errors, await this.drainRejections(page), url); // History-API navigations that fired DURING actions (every chaos // click on a React Router `