import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, resolve } from "node:path"; import type { Command } from "commander"; import { PNG } from "pngjs"; import type { EmitContext, HarneryProgramContext } from "../commander.ts"; import { resolveBinName } from "../core/config.ts"; import { CAPTURE_FIDELITY_MISMATCH_THRESHOLD, compareBand, cropNativePng, decideFidelity, type FidelityProbe, pngDimensions, } from "../lib/browser/capture-fidelity.ts"; import { type AssertResult, Browser, browserProxyFromEnv, browserProxyGateFromEnv, type ContentAnnotationBox, type ContentChecksResult, type CritiqueCoverage, type CritiqueResult, type CritiqueTile, captureDevOverlay, DEFAULT_CRITIQUE_RUBRIC, type DevOverlayResult, type Diagnostics, extractObservedIp, type LayoutAxis, type LayoutLintResult, type OverflowResult, parseAssertSpec, type RuntsResult, runCritique, type StandaloneHtmlResult, type TargetSizeProfile, type TargetSizeResult, tilesFromFullPage, type VisibilityResult, WEBRTC_PROXY_ONLY_ARG, type WidthResult, writeNetscapeCookieFile, wslHeadedLaunchArgs, } from "../lib/browser/index.ts"; import { allocateTileBudget, validatePageReviewAllocation, } from "../lib/browser/page-review-budget.ts"; import { PAGE_REVIEW_CAPTURE_PLAN_SCHEMA, type PageReviewCapturePlan, type PageReviewContextAllocation, type PageReviewSourceEvidence, } from "../lib/browser/page-review-contracts.ts"; import { findPackTile, gateHitsFromEnvelope, type PageReviewCaptureFidelity, type PageReviewContextRecord, type PageReviewExpandedTileRecord, readPackContext, tileId, writePackContext, writePackExpandedTile, } from "../lib/browser/page-review-pack.ts"; import { retainPageReviewSourceIdentity } from "../lib/browser/page-review-source.ts"; import { buildQaManifest, classifySignatures, type QaClassification, type QaContext, type QaManifest, type QaScope, type QaSignature, } from "../lib/browser/qa-plan.ts"; import { type CritiqueReusePlan, DEFAULT_REUSE_MISMATCH_RATIO, type PersistedCritique, planCritiqueReuse, QA_CRITIQUE_CONTRACT_VERSION, rubricDigest, } from "../lib/browser/qa-reuse.ts"; import { loadQaSnapshot, resolveQaBaseline, saveQaSnapshot } from "../lib/browser/qa-snapshot.ts"; import { allocateCaptureReservation, assertGatePlanStable, CaptureSourceChanged, runCaptureTransaction, writeCaptureTransaction, } from "../lib/browser/review-capture-transaction.ts"; import { type BrowserSessionServer, startBrowserSessionServer, } from "../lib/browser/session-control.ts"; import { type CaptureFingerprint, captureThumbnailFingerprint, registerCaptureThumbnail, } from "../lib/browser/thumbnail-association.ts"; import { reviewCandidateRects } from "../lib/browser/tiling.ts"; import { type DiffResult, diffAgainstBaseline, type SaveBaselineResult, saveBaseline, } from "../lib/browser/visual-diff.ts"; import { applyExtraCookies, CookieJar } from "../lib/cookies/index.ts"; /** * `harn browse `: Playwright-backed page navigation with shared * cookie jar + persistent profile + diagnostics capture for LLM * iteration loops. * * **Default behavior is "trio of files":** running `harn browse ` writes: * * .png Full-page screenshot (omit with --no-screenshot) * .html Post-JS-render serialized DOM * .json Diagnostics: title, url, status, viewport, console * events, console errors, page errors, failed requests * * `` defaults to `~/.cache/harnery/browse/last`. Override with * `--out `. * * Print modes (`--snapshot`, `--html`, `--json`) skip file writes and * print to stdout instead, handy for shell pipelines like * `harn browse --html | harn read -`. */ const DEFAULT_PROFILE = resolve(homedir(), ".cache", "harnery", "browser-profile"); const DEFAULT_STORE = resolve(homedir(), ".cache", "harnery", "cookies.json"); const FALLBACK_OUT_PREFIX = resolve(homedir(), ".cache", "harnery", "browse", "last"); interface BrowseOpts { reviewPackPlan?: boolean; reviewPackAllocation?: string; reviewPackReservation?: string; captureTransactionAllocation?: PageReviewContextAllocation; captureTransaction?: { attempts: Array<{ attempt: number; status: string; source_digest?: string; error?: string }>; source_digest?: string; }; capturePlanResult?: PageReviewCapturePlan; captureSourceEvidence?: PageReviewSourceEvidence; out?: string; // Commander expands `--no-X` into `opts.x = false` (default true), not // `opts.noX`. So `--no-screenshot` toggles `screenshot`, `--no-full-page` // toggles `fullPage`, `--no-cookies` toggles `cookies`. screenshot?: boolean; fullPage?: boolean; snapshot?: boolean; html?: boolean; selector?: string; click?: string; fill?: string; press?: string; waitFor?: string; evaluate?: string; captureEvaluate?: string; batch?: string; networkHar?: string; login?: boolean; loginCloseFile?: string; controlFile?: string; headed?: boolean; browserArg?: string[]; proxyFromEnv?: boolean; exportCookies?: string; cookies?: boolean; store?: string; profile?: string; viewport?: string; deviceScaleFactor?: string; colorScheme?: string; waitUntil: string; timeout: string; json?: boolean; // Visibility / occlusion checks checkVisible?: string[]; checkVisibleThreshold?: string; checkVisibleFail?: boolean; checkVisibleSampleGrid?: string; // Commander maps `--no-check-visible-annotate` to `checkVisibleAnnotate: false`. checkVisibleAnnotate?: boolean; // Width-fill check checkWidth?: string[]; checkWidthThreshold?: string; checkWidthFail?: boolean; checkWidthAnnotate?: boolean; // `--check-runts [selector]`: true = whole body, string = scope selector. checkRunts?: boolean | string; checkRuntsMinChars?: string; checkRuntsFail?: boolean; checkRuntsAnnotate?: boolean; // Horizontal-overflow check checkOverflow?: boolean; checkOverflowFail?: boolean; checkOverflowAnnotate?: boolean; // Rendered-layout relation checks checkAlign?: string[]; checkAlignAxis?: string; checkAlignThreshold?: string; checkAlignFail?: boolean; checkAlignAnnotate?: boolean; checkGap?: string[]; checkGapAxis?: string; checkGapExpected?: string; checkGapThreshold?: string; checkGapFail?: boolean; checkGapAnnotate?: boolean; checkClip?: string[]; checkClipThreshold?: string; checkClipFail?: boolean; checkClipAnnotate?: boolean; checkOverlap?: string[]; checkOverlapThreshold?: string; checkOverlapFail?: boolean; checkOverlapAnnotate?: boolean; checkCrowd?: string[]; checkCrowdMin?: string; checkCrowdFail?: boolean; checkCrowdAnnotate?: boolean; // Content checks (optional selector scope; true = whole body) checkPlaceholder?: boolean | string; checkPlaceholderFail?: boolean; checkPlaceholderAnnotate?: boolean; checkImages?: boolean | string; checkImagesTolerance?: string; checkImagesFail?: boolean; checkImagesAnnotate?: boolean; checkTruncation?: boolean | string; checkTruncationTolerance?: string; checkTruncationFail?: boolean; checkTruncationAnnotate?: boolean; checkContrast?: boolean | string; checkContrastFail?: boolean; checkContrastAnnotate?: boolean; // Vision-model critique (optional selector = semantic per-element tiling) checkCritique?: boolean | string; checkCritiqueBand?: string; checkCritiqueOverlap?: string; checkCritiqueMaxTiles?: string; checkCritiqueRubric?: string; checkCritiqueFail?: boolean; // Value assertions assert?: string[]; assertFail?: boolean; // Optional selector; null means the full document. Repeatable. checkHit?: Array; checkHitProfile?: string; checkHitFail?: boolean; checkHitAnnotate?: boolean; // Visual regression baseline?: string; diff?: string; diffThreshold?: string; diffFail?: boolean; // Diff-aware QA planning (qa-plan.ts / qa-snapshot.ts) qaPlan?: boolean; qaSnapshot?: boolean; qaTarget?: string; qaTheme?: string; qaState?: string; qaScope?: string[]; qaStates?: string[]; qaReuse?: boolean; qaReuseThreshold?: string; // Page review pack capture (page-review-pack.ts): tiles + DOM to disk, no // vision call; the judge stage reviews the pack after the browser closed. reviewPack?: string; reviewPackContext?: string; reviewPackScope?: string[]; /** Re-capture ONE existing tile of --review-pack-context at --device-scale-factor. */ reviewPackExpand?: string; /** Gate-hit rectangles (`x,y,w,h` strings) whose bands are captured past the tile cap. */ reviewPackHitRect?: string[]; // Next.js dev-overlay capture (auto-on; --no-dev-overlay opts out) devOverlay?: boolean; } const VIEWPORT_PRESETS: Record = { mobile: { width: 390, height: 844 }, tablet: { width: 820, height: 1180 }, desktop: { width: 1280, height: 800 }, hd: { width: 1920, height: 1080 }, }; // Module-scoped emit assigned by registerBrowseCommand. Same pattern as // cookies/read: the many helper functions in this large file close over // `emit` so action callbacks stay concise. let emit: EmitContext; export function registerBrowseCommand( program: Command, emitParam: EmitContext, context?: HarneryProgramContext, binName = resolveBinName(), ): void { emit = emitParam; program .command("browse ") .description( "Headless Chromium with persistent profile + cookie jar. Default writes a trio of files (last.png, last.html, last.json) for the LLM iteration loop; --snapshot/--html/--json switch to stdout-print mode.", ) .option( "--out ", "Output prefix for the trio (writes .png, .html, .json). Defaults to ~/.cache/harnery/browse/last.", ) .option("--no-screenshot", "Skip the .png in the trio (DOM + JSON only)") .option("--no-full-page", "Capture only the viewport, not the full scrollable page") .option("--snapshot", "Print body innerText to stdout (skips file writes)") .option( "--html", `Print raw outer HTML to stdout (skips file writes; pair with \`${binName} read -\`)`, ) .option("--json", "Print full JSON envelope to stdout (skips file writes)") .option("--selector ", "Scope --html / --snapshot to one element") .option("--click ", "Click this selector after navigation") .option("--fill value>", "Fill an input: 'input[name=q]=>hello' (separator is `=>`)") .option("--press ", "Press a key after navigation/fill (e.g., Enter)") .option("--wait-for ", "Wait for this selector before capturing output") .option( "--evaluate ", "Run JS in the page context after navigation; result printed to stdout", ) .option( "--capture-evaluate ", "In trio mode, evaluate JS inside the exact screenshot viewport immediately before capture; result is written as captureEval", ) .option( "--batch ", "Run multiple steps in one session, semicolon-separated. Each step is one of: " + "`click `, `fill value>`, `press `, `wait `, `eval `, `reload`. " + 'Example: `--batch "click button; wait 1500; reload; wait 3000"`. `reload` preserves sessionStorage + cookies, which is how to repro sessionStorage-restored UI state.', ) .option("--network-har ", "Record network traffic to a HAR file (finalized on close)") .option( "--viewport ", "Viewport: mobile (390x844), tablet (820x1180), desktop (1280x800), hd (1920x1080), or explicit '1920x1080'", "desktop", ) .option( "--color-scheme ", "Emulate prefers-color-scheme for the whole session: light | dark. " + "Renders theme-aware pages in that scheme without page-specific toggles. " + "Absent = browser default (unchanged behavior).", ) .option("--login", "Headed mode for one-time auth flow (cookies persist in profile)") .option( "--login-close-file ", "With --login, wait for this file instead of terminal Enter, then remove it and close cleanly", ) .option( "--control-file ", "With --login, publish an owner-only descriptor for repeated browse-session control", ) .option("--headed", "Headed mode for one-off (no auth-flow framing)") .option( "--browser-arg ", "Extra Chromium launch flag, passed straight to the browser (repeatable). " + "e.g. --browser-arg --disable-gpu. Also settable machine-wide via the " + "HARNERY_BROWSER_ARGS env var (whitespace-separated). Under WSL, headed " + "launches auto-add --disable-gpu to fix blank-window paint; opt out with " + "HARNERY_BROWSER_NO_WSL_DEFAULTS=1.", (value: string, prev: string[] = []) => [...prev, value], [] as string[], ) .option( "--proxy-from-env", "Pass HTTP(S)_PROXY to Playwright as an authenticated browser proxy. " + "Credentials stay in the child environment, never command arguments. " + "When the host also injects the HARNERY_BROWSER_PROXY_* gate variables, " + "the expected IP is verified before the requested URL opens.", ) .option("--no-cookies", "Skip cookie-jar attach and persist") .option("--store ", `Cookie store path (default ${DEFAULT_STORE})`) .option("--profile ", `Persistent Chromium profile dir (default ${DEFAULT_PROFILE})`) .option( "--export-cookies ", "Write live profile cookies as an owner-only Netscape cookies.txt file on close", ) .option( "--wait-until ", "Navigation wait strategy: load | domcontentloaded | networkidle | commit", "load", ) .option("--timeout ", "Navigation timeout in milliseconds", "30000") .option( "--check-visible ", "Run an occlusion check on this selector after navigation + batch. " + "Samples a 3×3 grid inside the element's bounding rect, reports " + "`visibleRatio` + the dominant occluder in the JSON envelope, and " + "(by default) overlays green/red/amber boxes on the screenshot. " + "Repeat the flag for multiple targets.", (value: string, prev: string[] = []) => [...prev, value], [] as string[], ) .option( "--check-visible-threshold ", "visibleRatio threshold below which a target is considered occluded " + "(0–1, default 0.9). Used by the screenshot annotation color + by " + "--check-visible-fail.", "0.9", ) .option( "--check-visible-fail", "Exit non-zero if any --check-visible target falls below threshold. " + "Use in deploy scripts to break the build on UI regressions.", ) .option( "--check-visible-sample-grid ", "Grid size for occlusion sampling (n×n points). Default 3 (9 samples).", "3", ) .option( "--no-check-visible-annotate", "Skip drawing target + occluder boxes on the screenshot (JSON still emitted).", ) .option( "--check-width ", "Assert this selector's bounding rect width is at least --check-width-threshold " + "of the viewport. Catches the class of mobile-layout bug where a table or card " + "sits at e.g. 85% viewport fill because of stacked padding. Repeat the flag for " + "multiple targets. Reports viewportFill + parentFill in the JSON envelope.", (value: string, prev: string[] = []) => [...prev, value], [] as string[], ) .option( "--check-width-threshold ", "viewportFill threshold below which a target is considered too-narrow (0–1, default 0.9). " + "Used by the screenshot annotation color + by --check-width-fail.", "0.9", ) .option( "--check-width-fail", "Exit non-zero if any --check-width target falls below threshold.", ) .option( "--no-check-width-annotate", "Skip drawing width-check boxes on the screenshot (JSON still emitted).", ) .option( "--check-overflow", "Assert the document has no horizontal overflow (document.scrollWidth <= window.innerWidth). " + "Surfaces protruding elements in the JSON envelope and annotates them on the screenshot. " + "Catches the class of mobile-layout bug where a nav/table overflows the viewport edge.", ) .option( "--check-overflow-fail", "Run --check-overflow and exit 2 if horizontal overflow is detected.", ) .option( "--no-check-overflow-annotate", "Skip drawing overflow annotations on the screenshot (JSON still emitted).", ) .option( "--check-runts [selector]", "Scan text blocks for runts (a single word alone on a block's last visual line) " + "by counting words on the last line via per-word Range rects — width thresholds " + "miss narrow-column runts. Optional selector scopes the sweep (default: whole body). " + "Atomic tokens (URLs, emails, phone numbers) are excluded. Reports hits in the JSON " + "envelope under `runts` and annotates them on the screenshot.", ) .option( "--check-runts-min-chars ", "Minimum block text length to scan (default 40; smaller labels can't meaningfully wrap).", "40", ) .option( "--check-runts-fail", "Run a document-wide runt check when needed; exit 2 on a runt, missing scope, or incomplete sweep.", ) .option( "--no-check-runts-annotate", "Skip drawing runt boxes on the screenshot (JSON still emitted).", ) .option( "--check-align ", "Check rendered child-content alignment inside this container (repeatable).", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option("--check-align-axis ", "Alignment axis: auto | x | y (default auto).", "auto") .option( "--check-align-threshold ", "Maximum rendered-center drift in CSS pixels (default 2).", "2", ) .option( "--check-align-fail", "Exit 2 when an alignment target fails, is unknown, or is missing.", ) .option("--no-check-align-annotate", "Skip alignment screenshot annotations.") .option( "--check-gap ", "Check adjacent rendered gaps inside this container (repeatable).", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option("--check-gap-axis ", "Gap axis: auto | x | y (default auto).", "auto") .option( "--check-gap-expected ", "Expected gap in CSS pixels. Without it, groups of 3+ infer the median gap.", ) .option("--check-gap-threshold ", "Maximum gap deviation in CSS pixels (default 2).", "2") .option("--check-gap-fail", "Exit 2 when a gap target fails, is unknown, or is missing.") .option("--no-check-gap-annotate", "Skip gap screenshot annotations.") .option( "--check-clip ", "Check every matching container for descendant boxes or text paint leaving its " + "rectangular clipping chain or nearest block parent (repeatable).", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--check-clip-threshold ", "Allowed rectangular overrun in CSS pixels (default 0).", "0", ) .option("--check-clip-fail", "Exit 2 when a clip target fails, is unknown, or is missing.") .option("--no-check-clip-annotate", "Skip clip screenshot annotations.") .option( "--check-overlap ", "Check measurable in-flow siblings for unintended overlap (repeatable).", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--check-overlap-threshold ", "Allowed overlap depth on either axis in CSS pixels (default 0).", "0", ) .option( "--check-overlap-fail", "Exit 2 when an overlap target fails, is unknown, or is missing.", ) .option("--no-check-overlap-annotate", "Skip overlap screenshot annotations.") .option( "--check-crowd ", "Flag adjacent card panels (full border, modest corner radius, or box-shadow) " + "that touch or nearly touch. Also treats wrappers that contain panels as " + "peers, measuring the nearest face panels inside them so a card-grid " + "flush against the next card is caught. Walks the whole subtree under the " + "selector, so one --check-crowd .wrap catches nested cases. Fills the gap " + "between --check-overlap (needs a 2D intersection) and --check-gap (flags " + "uneven spacing, so a uniformly-flush stack passes). Repeatable.", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--check-crowd-min ", "Minimum acceptable edge gap between adjacent panel faces in CSS px (default 6). " + "Pairs closer than this are flagged; negative separation (overlap) always flags.", "6", ) .option("--check-crowd-fail", "Exit 2 when a crowd target fails, is unknown, or is missing.") .option("--no-check-crowd-annotate", "Skip crowd screenshot annotations.") .option( "--check-placeholder [selector]", "Scan rendered text for unrendered template tokens and bound-value tells: " + "JS template literals, `{{x}}` handlebars, `[object Object]`, `Invalid Date`, " + "`NaN`, or an element whose whole text is literally 'undefined' / 'null'. Optional selector " + "scopes the sweep (default: whole body). Catches the most common generation " + "bug on funnels and reports. Do not point it at a page that documents template " + "syntax in prose.", ) .option( "--check-placeholder-fail", "Run a document-wide placeholder check when needed; exit 2 if the result does not pass.", ) .option("--no-check-placeholder-annotate", "Skip placeholder screenshot annotations.") .option( "--check-images [selector]", "Audit elements for load + aspect health: a failed load (naturalWidth 0), " + "a still-loading image, or a stretched image (rendered aspect ratio far from the " + "intrinsic one, with an object-fit that does not correct it). Optional selector " + "scopes the sweep (default: whole body).", ) .option( "--check-images-tolerance ", "Aspect-ratio deviation above which an image counts as stretched (0–1, default 0.1 = 10%).", "0.1", ) .option( "--check-images-fail", "Run a document-wide image check when needed; exit 2 if the result does not pass.", ) .option("--no-check-images-annotate", "Skip image screenshot annotations.") .option( "--check-truncation [selector]", "Flag text actively cut off by an ellipsis or -webkit-line-clamp — the author asked " + "to truncate AND the content overflows. High precision: it does not flag plain " + "overflow:hidden clips. Optional selector scopes the sweep (default: whole body).", ) .option( "--check-truncation-tolerance ", "Overflow past the clip box, in CSS px, above which truncation counts (default 2).", "2", ) .option( "--check-truncation-fail", "Run a document-wide truncation check when needed; exit 2 if the result does not pass.", ) .option("--no-check-truncation-annotate", "Skip truncation screenshot annotations.") .option( "--check-contrast [selector]", "Flag rendered text below the WCAG AA contrast ratio (4.5:1 normal, 3:1 large) against " + "its effective background. Runs at whatever theme the page is in, so toggle the theme " + "with --batch to cover light and dark. Text over an image or gradient is reported as " + "`unknown`, not failed. Optional selector scopes the sweep (default: whole body).", ) .option( "--check-contrast-fail", "Run a document-wide contrast check when needed; exit 2 on fail, unknown, or a missing scope.", ) .option("--no-check-contrast-annotate", "Skip contrast screenshot annotations.") .option( "--check-critique [selector]", "Hand the rendered page to a vision model for a visual-defect review, tile by " + "tile. Catches the long tail heuristic checks can't enumerate. Default tiling " + "cuts the page into overlapping vertical bands; pass a selector to tile one " + "screenshot per matching element (semantic tiling). Requires the host to inject " + "a critiqueProvider (harnery ships no model client) — without one the check " + "reports `skipped`. Findings land under `critique` in the JSON envelope.", ) .option("--check-critique-band ", "Band height for default tiling (default 1400).", "1400") .option( "--check-critique-overlap ", "Vertical overlap between bands so a finding at a seam stays visible (default 120).", "120", ) .option( "--check-critique-max-tiles ", "Cap on tiles sent to the model, to bound cost (default 24).", "24", ) .option("--check-critique-rubric ", "Override the default critique rubric.") .option( "--check-critique-fail", "Run a whole-page critique when needed; exit 2 unless the critique conclusively passes.", ) .option( "--assert ", "Assert a page value (repeatable). Grammar: ' => ', where op is " + "text (trimmed text equals), contains (text includes), matches (text matches a regex), " + "count (match count vs a number or >=/<=/>/< comparator), exists / absent (no => needed). " + "e.g. --assert 'text h1 => Welcome' --assert 'count .card => >=3' --assert 'absent .error'.", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option("--assert-fail", "Exit 2 if any --assert fails; requires at least one --assert.") .option( "--check-hit [selector]", "Check pointer-target size and spacing in the document or optional scope (repeatable).", (value: string | boolean, previous: Array = []) => [ ...previous, typeof value === "string" ? value : null, ], [] as Array, ) .option( "--check-hit-profile ", "Target profile: wcag-aa (24px) | comfortable (44px).", "wcag-aa", ) .option( "--check-hit-fail", "Run a document-wide target-size check when needed; exit 2 when it fails or is incomplete.", ) .option("--no-check-hit-annotate", "Skip target-size screenshot annotations.") .option( "--baseline ", "Save the captured screenshot as a named baseline at " + "~/.cache/harnery/visual-baselines/.png. Use --diff later to " + "compare future captures against it (visual-regression check).", ) .option( "--diff ", "Pixel-diff the captured screenshot against the named baseline. Writes " + "the diff PNG next to the screenshot, reports mismatchedPixels + " + "similarity in the JSON envelope.", ) .option( "--diff-threshold ", "mismatchRatio (mismatchedPixels / totalPixels) below which the diff is " + "considered a match (0–1, default 0.01 = 1%).", "0.01", ) .option( "--diff-fail", "Exit non-zero if --diff mismatchRatio exceeds --diff-threshold; requires --diff.", ) .option( "--qa-plan", "Classify this render against the persisted QA baseline and emit a review manifest " + "(change class, scope selectors, required contexts, provider-call ceiling) under " + "`qaPlan` in the JSON envelope — BEFORE any vision spend. Text/data-only edits plan " + "zero model calls; ambiguity widens, never narrows. Baseline comes from the snapshot " + "store (seed one with --qa-snapshot); no baseline classifies as `unknown`.", ) .option( "--qa-snapshot", "Persist this render (signature + DOM + full-page screenshot) as the QA baseline for the " + "target + context, replacing any prior snapshot atomically. Run it on a page state you " + "trust — typically right after a passing QA run — so the next --qa-plan inherits its " + "baseline for free.", ) .option( "--qa-target ", "Override the snapshot-store key (default: the url argument). Lets a production render " + "seed the baseline for a local file/route: browse the prod URL with --qa-snapshot " + "--qa-target , then --qa-plan the local target.", ) .option( "--qa-theme ", "Context label for the QA snapshot store: light | dark (default light). Labels the " + "stored/compared context; it does not switch the page's rendered theme.", "light", ) .option( "--qa-state ", "Context state label for the QA snapshot store (default 'default').", "default", ) .option( "--qa-scope ", "Explicit scope selector from the producing task (repeatable) — resolution-order rung 1; " + "overrides lifted anchors in the --qa-plan manifest.", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--qa-states ", "Comma-separated interaction states to review (promotes the plan to interaction-state).", (value: string) => value .split(",") .map((s) => s.trim()) .filter(Boolean), ) .option( "--qa-reuse", "Reuse the baseline run's critique verdicts for tiles whose pixels provably didn't " + "change (band-diff against the persisted --qa-snapshot screenshot; strict mismatch " + "threshold). Only clean regions are reused — a region with baseline findings or any " + "pixel drift is always re-reviewed. Requires --check-critique; misses degrade to a " + "full fresh review, never an error. Stats land under `qaReuse` in the JSON envelope.", ) .option( "--qa-reuse-threshold ", "Band-diff mismatch ratio at or below which a clean tile region is reused " + `(default ${DEFAULT_REUSE_MISMATCH_RATIO}; lower it toward 0 for stricter reuse).`, ) .option( "--review-pack ", "Capture this render into a page review pack at (contexts//: full-page " + "screenshot, critique tiles as PNG files, serialized DOM, QA signature) and make no " + "vision call. A judge stage (`review-pack judge`, or qa-run) reviews the tiles from disk " + "after every browser has closed. Tiling knobs are the --check-critique-* flags.", ) .option( "--review-pack-plan", "Emit all native capture candidates and gate associations without capturing tiles. " + "With --out, retain private source identity inputs beside the output for diagnostics.", ) .option( "--review-pack-allocation ", "Capture exactly the selected IDs in a verified context allocation.", ) .option( "--review-pack-reservation ", "Reserve the preliminary allocation's tile count, then rerun supplied gates and allocate against the same page before capture. Full source checks and native coverage remain required.", ) .option( "--review-pack-context ", "Context id inside the pack (default -- from --viewport, " + "--qa-theme, --qa-state).", ) .option( "--review-pack-scope ", "Also tile one screenshot per element matching into the pack (repeatable).", (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--review-pack-expand ", "Instead of capturing a context, re-render ONE existing tile of --review-pack-context " + "(e.g. T012) at --device-scale-factor and write it beside the original as " + "tiles/@x.png. Existing tiles are untouched.", ) .option( "--review-pack-hit-rect ", "Document-space rectangle (integer px) a deterministic gate already flagged. " + "Intersecting bands are prioritized within the hard tile cap " + `(repeatable, at most ${REVIEW_PACK_HIT_RECT_MAX}).`, (value: string, previous: string[] = []) => [...previous, value], [] as string[], ) .option( "--device-scale-factor ", "Device scale factor for the browser context (default 1). 2 renders every screenshot " + "at twice the pixels; pair with --review-pack-expand for a sharper look at one tile.", ) .option( "--no-dev-overlay", "Skip auto-capture of Next.js dev-overlay issues. Default: capture every queued error (kind/code/message/stack) when a shadow root is present. Necessary because Next.js 16 + React 19 route hydration errors + most React warnings through onCaughtError → next-devtools' errorQueue, NOT through console.error, so Playwright's standard listener doesn't see them. Surfaces them in the JSON envelope under `devOverlay`.", ) .action(async (url: string, opts: BrowseOpts) => { try { await runBrowse(url, opts, context); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); emit.error({ code: "browse_error", message: msg }); process.exit(1); } }); } async function runBrowse( url: string, opts: BrowseOpts, context: HarneryProgramContext | undefined, ): Promise { normalizeCheckFailOptions(opts); const alignAxis = parseLayoutAxis(opts.checkAlignAxis ?? "auto", "--check-align-axis"); const gapAxis = parseLayoutAxis(opts.checkGapAxis ?? "auto", "--check-gap-axis"); const alignThreshold = parseNonNegativeNumber( opts.checkAlignThreshold ?? "2", "--check-align-threshold", ); const gapThreshold = parseNonNegativeNumber( opts.checkGapThreshold ?? "2", "--check-gap-threshold", ); const gapExpected = opts.checkGapExpected === undefined ? null : parseNonNegativeNumber(opts.checkGapExpected, "--check-gap-expected"); const clipThreshold = parseNonNegativeNumber( opts.checkClipThreshold ?? "0", "--check-clip-threshold", ); const overlapThreshold = parseNonNegativeNumber( opts.checkOverlapThreshold ?? "0", "--check-overlap-threshold", ); const crowdMin = parseNonNegativeNumber(opts.checkCrowdMin ?? "6", "--check-crowd-min"); const hitProfile = parseTargetSizeProfile(opts.checkHitProfile ?? "wcag-aa"); // Commander: `--no-cookies` turns `opts.cookies` into `false`. Default is `true`. const jar = opts.cookies === false ? null : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" }); if (jar) applyExtraCookies(url, jar, context?.extraCookies); const headed = opts.login || opts.headed; const viewport = parseViewport(opts.viewport ?? "desktop"); const deviceScaleFactor = parseDeviceScaleFactor(opts.deviceScaleFactor); if (opts.reviewPackAllocation && !opts.reviewPack) { throw new Error("--review-pack-allocation requires --review-pack ."); } if ( opts.reviewPackReservation && (!opts.reviewPack || opts.reviewPackAllocation || opts.reviewPackExpand || opts.checkCritique) ) { throw new Error( "--review-pack-reservation requires an ordinary --review-pack capture without allocation, expansion or inline critique.", ); } if (opts.reviewPackExpand !== undefined) { if (opts.reviewPack === undefined || opts.reviewPackContext === undefined) { throw new Error( "--review-pack-expand requires --review-pack and --review-pack-context .", ); } } // Malformed hit rects fail here, before a browser launches. parseReviewPackHitRects(opts.reviewPackHitRect); let colorScheme: "light" | "dark" | undefined; if (opts.colorScheme !== undefined) { if (opts.colorScheme !== "light" && opts.colorScheme !== "dark") { throw new Error(`--color-scheme must be light or dark (got: ${opts.colorScheme}).`); } colorScheme = opts.colorScheme; } const proxy = opts.proxyFromEnv ? browserProxyFromEnv() : undefined; const proxyGate = opts.proxyFromEnv ? browserProxyGateFromEnv() : null; if (opts.loginCloseFile && !opts.login) { throw new Error("--login-close-file requires --login."); } if (opts.controlFile && !opts.login) { throw new Error("--control-file requires --login."); } const loginCloseFile = opts.loginCloseFile ? resolve(opts.loginCloseFile) : null; if (loginCloseFile && existsSync(loginCloseFile)) { throw new Error( `Login close signal already exists at ${loginCloseFile}. Remove the stale file before launching.`, ); } const browser = new Browser({ profileDir: opts.profile ?? DEFAULT_PROFILE, headed, jar, viewport, ...(deviceScaleFactor !== undefined ? { deviceScaleFactor } : {}), ...(colorScheme ? { colorScheme } : {}), navigationTimeout: Number.parseInt(opts.timeout, 10), waitUntil: opts.waitUntil as BrowseOpts["waitUntil"] as never, recordHarPath: opts.networkHar ? resolve(opts.networkHar) : undefined, extraHeaders: context?.extraHeaders, launchArgs: resolveLaunchArgs(opts, Boolean(headed), Boolean(proxy)), proxy, }); // Print mode: --snapshot / --html / --json all suppress file writes. const printMode = opts.snapshot || opts.html || opts.json; let controlServer: BrowserSessionServer | null = null; let receivedSignal: NodeJS.Signals | null = null; let resolveSignal: (() => void) | null = null; const signalPromise = new Promise((resolveSignalPromise) => { resolveSignal = resolveSignalPromise; }); const handleSignal = (signal: NodeJS.Signals) => { receivedSignal = signal; resolveSignal?.(); }; const handleSigint = () => handleSignal("SIGINT"); const handleSigterm = () => handleSignal("SIGTERM"); try { await browser.open(); if (proxyGate) await verifyBrowserProxyGate(browser, proxyGate); const navResult = await browser.navigate(url); if (opts.fill) { const sep = opts.fill.indexOf("=>"); if (sep < 0) { throw new Error( `--fill expects 'selector=>value' (got: ${opts.fill}). The separator is '=>' (not '='), so CSS attribute selectors like input[name=q] don't collide.`, ); } await browser.fill(opts.fill.slice(0, sep), opts.fill.slice(sep + 2)); } if (opts.click) await browser.click(opts.click); if (opts.press) await browser.press(opts.press); if (opts.waitFor) await browser.waitForSelector(opts.waitFor, Number.parseInt(opts.timeout, 10)); let batchResult: BatchResult | undefined; if (opts.batch) { batchResult = await runBatch(browser, opts.batch, Number.parseInt(opts.timeout, 10)); } let evalResult: unknown; if (opts.evaluate) { evalResult = await browser.evaluate(opts.evaluate); } if (opts.reviewPackPlan || opts.reviewPack) await browser.waitForReviewReady(); // Run visibility checks AFTER any --batch interactions but BEFORE the // screenshot. Annotation injection happens between sampling and capture // so the boxes show on the saved PNG; they're cleared post-screenshot // so the live profile state isn't polluted. const collectGates = async (attempt = 0) => { const before = opts.reviewPackReservation ? await buildReviewCapturePlan(browser, opts) : undefined; let visibility: VisibilityResult[] | undefined; if (opts.checkVisible && opts.checkVisible.length > 0) { visibility = await browser.checkVisibility(opts.checkVisible, { sampleGrid: Number.parseInt(opts.checkVisibleSampleGrid ?? "3", 10), }); if (opts.checkVisibleAnnotate !== false && !opts.reviewPackPlan && !opts.reviewPack) { await browser.annotateVisibility(visibility); } } let widths: WidthResult[] | undefined; if (opts.checkWidth && opts.checkWidth.length > 0) { widths = await browser.checkWidth(opts.checkWidth); } let overflow: OverflowResult | undefined; if (opts.checkOverflow) { overflow = await browser.checkOverflow(); } let runts: RuntsResult | undefined; if (opts.checkRunts) { runts = await browser.checkRunts({ scope: typeof opts.checkRunts === "string" ? opts.checkRunts : null, minChars: Number.parseInt(opts.checkRuntsMinChars ?? "40", 10), }); } const hasLayoutLint = (opts.checkAlign?.length ?? 0) > 0 || (opts.checkGap?.length ?? 0) > 0 || (opts.checkClip?.length ?? 0) > 0 || (opts.checkOverlap?.length ?? 0) > 0 || (opts.checkCrowd?.length ?? 0) > 0; let layoutLint: LayoutLintResult | undefined; if (hasLayoutLint) { layoutLint = await browser.checkLayoutLint({ align: (opts.checkAlign ?? []).map((selector) => ({ selector, axis: alignAxis, tolerancePx: alignThreshold, })), gap: (opts.checkGap ?? []).map((selector) => ({ selector, axis: gapAxis, tolerancePx: gapThreshold, expectedGapPx: gapExpected, })), clip: (opts.checkClip ?? []).map((selector) => ({ selector, tolerancePx: clipThreshold, })), overlap: (opts.checkOverlap ?? []).map((selector) => ({ selector, tolerancePx: overlapThreshold, })), crowd: (opts.checkCrowd ?? []).map((selector) => ({ selector, minGapPx: crowdMin, })), }); } let hit: TargetSizeResult[] | undefined; if (opts.checkHit && opts.checkHit.length > 0) { hit = await browser.checkTargetSize(opts.checkHit, hitProfile); } const hasContentChecks = opts.checkPlaceholder !== undefined || opts.checkImages !== undefined || opts.checkTruncation !== undefined || opts.checkContrast !== undefined; let content: ContentChecksResult | undefined; if (hasContentChecks) { content = await browser.checkContent({ placeholder: opts.checkPlaceholder !== undefined ? { scope: contentScope(opts.checkPlaceholder) } : null, image: opts.checkImages !== undefined ? { scope: contentScope(opts.checkImages), tolerance: parseNonNegativeNumber( opts.checkImagesTolerance ?? "0.1", "--check-images-tolerance", ), } : null, truncation: opts.checkTruncation !== undefined ? { scope: contentScope(opts.checkTruncation), tolerance: parseNonNegativeNumber( opts.checkTruncationTolerance ?? "2", "--check-truncation-tolerance", ), } : null, contrast: opts.checkContrast !== undefined ? { scope: contentScope(opts.checkContrast) } : null, }); } // Diff-aware QA planning. Signature capture must also happen BEFORE any // annotation overlays are injected — an annotation box is a DOM change. let qaPlan: QaPlanReport | undefined; let qaCapture: QaCaptureState | undefined; if (opts.qaPlan || opts.qaSnapshot) { const qa = await runQaPlanning(browser, url, navResult.url, opts); qaPlan = qa.report; qaCapture = qa.capture; if (qaPlan?.manifest) { const m = qaPlan.manifest; emit.log( `qa-plan: class=${m.change_class} scopes=${m.scopes.map((s) => s.selector).join(",") || "-"} ` + `contexts=${m.contexts.length} model-calls<=${m.predicted.model_calls_ceiling} baseline=${m.baseline_source}`, "info", ); } } let asserts: AssertResult[] | undefined; if (opts.assert && opts.assert.length > 0) { const specs = opts.assert.map(parseAssertSpec); asserts = await browser.checkAsserts(specs); } if (opts.reviewPackPlan || opts.reviewPackReservation) { const gateEnvelope: Record = { runts, overflow, hit, ...layoutLint }; if (content) assignContent(gateEnvelope, content); opts.capturePlanResult = await buildReviewCapturePlan(browser, opts); const hits = gateHitsFromEnvelope(gateEnvelope, Number.POSITIVE_INFINITY); for (const candidate of opts.capturePlanResult.candidates) { const r = candidate.rect; candidate.gate_hits = hits.flatMap((hit, index) => { const dpr = opts.capturePlanResult!.dpr; const h = { x: hit.rect.x * dpr, y: hit.rect.y * dpr, width: Math.max(1, hit.rect.width * dpr), height: Math.max(1, hit.rect.height * dpr), }; return r.x < h.x + h.width && r.x + r.width > h.x && r.y < h.y + h.height && r.y + r.height > h.y ? [{ check_id: `${hit.rule}:${index + 1}`, severity: "high" as const }] : []; }); } } if (opts.reviewPackReservation) { const prefix = opts.out ?? resolve(opts.reviewPack!, opts.reviewPackContext ?? "capture"); mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 }); writeFileSync( `${prefix}.attempt-${attempt}.gates.json`, JSON.stringify( { attempt, source_before: before?.source_digest, source_after: opts.capturePlanResult?.source_digest, source_evidence: opts.captureSourceEvidence, visibility, widths, overflow, runts, layoutLint, hit, content, asserts, }, null, 2, ), { mode: 0o600 }, ); } if (before) assertGatePlanStable(before, opts.capturePlanResult!); return { visibility, widths, overflow, runts, layoutLint, hit, content, qaPlan, qaCapture, asserts, }; }; let { visibility, widths, overflow, runts, layoutLint, hit, content, qaPlan, qaCapture, asserts, }: Partial>> = opts.reviewPackReservation ? {} : await collectGates(); // Vision critique. Capture tiles BEFORE any annotation overlays are injected // so the model sees the real page, not our boxes. let critique: CritiqueResult | undefined; let qaReuse: CritiqueReusePlan | undefined; let critiqueTiles: CritiqueTile[] | undefined; let critiqueFullPage: Buffer | undefined; if (opts.checkCritique !== undefined) { const critiqueProvider = context?.critiqueProvider ?? (await context?.critiqueProviderLoader?.()); const rubric = opts.checkCritiqueRubric ?? DEFAULT_CRITIQUE_RUBRIC; const captured = await captureCritiqueTiles(browser, opts, critiqueProvider?.tileBudgetPx); critiqueTiles = captured.tiles; critiqueFullPage = captured.fullPage; let tilesToReview = captured.tiles; if (opts.qaReuse) { const reuseContext: QaContext = { viewport: opts.viewport ?? "desktop", theme: opts.qaTheme === "dark" ? "dark" : "light", state: opts.qaState ?? "default", }; const stored = loadQaSnapshot(opts.qaTarget ?? url, reuseContext, {}); if (stored?.screenshotPath && stored.critique) { qaReuse = planCritiqueReuse({ baselineScreenshot: readFileSync(stored.screenshotPath), baselineCritique: stored.critique, currentScreenshot: captured.fullPage, tiles: captured.tiles, rubric, mismatchThreshold: opts.qaReuseThreshold ? Number.parseFloat(opts.qaReuseThreshold) : undefined, }); } else { qaReuse = { mode: "band-diff", review: captured.tiles, decisions: [], tiles_total: captured.tiles.length, tiles_reused: 0, tiles_reviewed: captured.tiles.length, provider_calls_avoided: 0, mismatch_threshold: DEFAULT_REUSE_MISMATCH_RATIO, invalidation: "no persisted baseline snapshot with critique results for this target/context", }; } tilesToReview = qaReuse.review; emit.log( `qa-reuse: ${qaReuse.tiles_reused}/${qaReuse.tiles_total} tiles reused (band-diff), ` + `${qaReuse.tiles_reviewed} reviewed fresh${qaReuse.invalidation ? ` — ${qaReuse.invalidation}` : ""}`, "info", ); } critique = { ...(await runCritique({ url: navResult.url, rubric, tiles: tilesToReview, provider: critiqueProvider, })), // Coverage rides on every envelope, skipped or not, so a reader can // tell a pass over the whole page from a pass over its first N bands. coverage: captured.coverage, }; } // Page review pack capture: tiles, DOM, and signature to disk, no vision // call. Also before annotations, for the same reason as the critique. let reviewPack: ReviewPackReport | undefined; if (opts.reviewPack !== undefined && opts.reviewPackExpand !== undefined) { reviewPack = await expandReviewPackTile(browser, opts, deviceScaleFactor ?? 1); } else if (opts.reviewPackReservation) { const reservation = JSON.parse(readFileSync(resolve(opts.reviewPackReservation), "utf8")); opts.captureTransaction = { attempts: [] }; const persistTransaction = () => { const prefix = opts.out ?? resolve(opts.reviewPack!, opts.reviewPackContext ?? "capture"); mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 }); writeFileSync( `${prefix}.capture-transaction.json`, JSON.stringify(opts.captureTransaction, null, 2), { mode: 0o600 }, ); }; reviewPack = await runCaptureTransaction( async (attempt) => { await browser.waitForReviewReady(); ({ visibility, widths, overflow, runts, layoutLint, hit, content, qaPlan, qaCapture, asserts, } = await collectGates(attempt)); opts.captureTransactionAllocation = allocateCaptureReservation( reservation, opts.capturePlanResult!, ); opts.captureTransaction!.attempts.push({ attempt, status: "gated", source_digest: opts.capturePlanResult!.source_digest, }); persistTransaction(); }, async () => { const captured = await captureReviewPackContext(browser, url, navResult, opts, qaCapture); opts.captureTransaction!.source_digest = opts.captureTransactionAllocation!.plan.source_digest; opts.captureTransaction!.attempts.at(-1)!.status = "captured"; persistTransaction(); return captured; }, (attempt, error) => { opts.captureTransaction!.attempts.push({ attempt, status: "failed", error: error instanceof Error ? error.message : String(error), source_digest: opts.capturePlanResult?.source_digest, }); persistTransaction(); }, ); } else if (opts.reviewPack !== undefined) { reviewPack = await captureReviewPackContext(browser, url, navResult, opts, qaCapture); } // Persist the QA baseline AFTER critique so a passing run's verdicts ride // along with the snapshot (still before annotations mutate the page). if (opts.qaSnapshot && qaCapture) { const screenshotPng = critiqueFullPage ?? (await browser.fullPageScreenshotBuffer()); // Persist critique results only when the full tile set was freshly // reviewed — a partially-reused run must never become the next // baseline's finding record. const fullCoverage = critique?.provider && critique.outcome !== "skipped" && critiqueTiles !== undefined && !critique.coverage?.capped && (!qaReuse || qaReuse.tiles_reused === 0); const persistedCritique: PersistedCritique | undefined = fullCoverage && critique && critiqueTiles ? { contract_version: QA_CRITIQUE_CONTRACT_VERSION, rubric_digest: rubricDigest(opts.checkCritiqueRubric ?? DEFAULT_CRITIQUE_RUBRIC), outcome: critique.outcome as "pass" | "fail", findings: critique.findings, tiles: critiqueTiles.map((t) => ({ index: t.index, label: t.label, x: t.x ?? 0, scrollY: t.scrollY, width: t.width, height: t.height, })), } : undefined; const saved = saveQaSnapshot( qaCapture.target, qaCapture.context, { signature: qaCapture.signature, domHtml: qaCapture.domHtml, screenshotPng, ...(persistedCritique ? { critique: persistedCritique } : {}), }, {}, ); if (qaPlan) { qaPlan.snapshotSaved = { path: saved.path, target: qaCapture.target, context: qaCapture.context, }; } emit.log( `qa-snapshot: saved baseline for ${qaCapture.target} ` + `[${qaCapture.context.viewport}/${qaCapture.context.theme}/${qaCapture.context.state}]` + `${persistedCritique ? " (with critique results)" : ""}`, "info", ); } const widthThreshold = Number.parseFloat(opts.checkWidthThreshold ?? "0.9"); const annotateWidth = widths && opts.checkWidthAnnotate !== false; const annotateOverflow = overflow && opts.checkOverflowAnnotate !== false; if (annotateWidth || annotateOverflow) { await browser.annotateLayout({ widths: annotateWidth ? widths! : [], overflow: annotateOverflow ? overflow! : null, widthThreshold, }); } const annotateRunts = runts && runts.runts.length > 0 && opts.checkRuntsAnnotate !== false; if (annotateRunts) { await browser.annotateRunts(runts!); } const annotateLayoutLint = layoutLint ? { align: opts.checkAlignAnnotate === false ? [] : layoutLint.align, gap: opts.checkGapAnnotate === false ? [] : layoutLint.gap, clip: opts.checkClipAnnotate === false ? [] : layoutLint.clip, overlap: opts.checkOverlapAnnotate === false ? [] : layoutLint.overlap, crowd: opts.checkCrowdAnnotate === false ? [] : layoutLint.crowd, } : undefined; if ( annotateLayoutLint && (annotateLayoutLint.align.length > 0 || annotateLayoutLint.gap.length > 0 || annotateLayoutLint.clip.length > 0 || annotateLayoutLint.overlap.length > 0 || annotateLayoutLint.crowd.length > 0) ) { await browser.annotateLayoutLint(annotateLayoutLint); } const annotateHit = hit && opts.checkHitAnnotate !== false; if (annotateHit) { await browser.annotateTargetSize(hit!); } const contentBoxes = content ? collectContentBoxes(content, opts) : []; if (contentBoxes.length > 0) { await browser.annotateContent(contentBoxes); } if (opts.controlFile) { controlServer = await startBrowserSessionServer(opts.controlFile, browser); process.once("SIGINT", handleSigint); process.once("SIGTERM", handleSigterm); emit.log(`browser session ready: ${controlServer.descriptorPath}`, "info"); } if (opts.login) { const lifecycleAbort = new AbortController(); const closeWaits: Promise[] = []; if (loginCloseFile) { emit.log( `[--login] Headed Chromium is open. Drive the visible window now. Create ${loginCloseFile} to close cleanly.`, "info", ); closeWaits.push(waitForLoginCloseFile(loginCloseFile, lifecycleAbort.signal)); } else { emit.log( "[--login] Headed Chromium is open. Walk through your auth flow now. Press Enter here to close + persist cookies into the profile.", "info", ); closeWaits.push(waitForTerminalEnter(lifecycleAbort.signal)); } if (controlServer) { closeWaits.push(controlServer.closeRequested, signalPromise); } await Promise.race(closeWaits); lifecycleAbort.abort(); if (controlServer) await controlServer.stopAccepting(); } if (opts.exportCookies) { const exportPath = resolve(opts.exportCookies); writeNetscapeCookieFile(exportPath, await browser.cookies()); emit.log(`exported live profile cookies to ${exportPath} (mode 600)`, "info"); } // Auto-capture Next.js dev-overlay issues unless --no-dev-overlay was passed. // Cheap no-op when no shadow host is present (non-Next.js page). let devOverlay: DevOverlayResult | undefined; if (opts.devOverlay !== false) { devOverlay = await captureDevOverlay(browser.currentPage); } if (printMode) { if (opts.captureEvaluate) { throw new Error( "--capture-evaluate requires trio mode so its result can be bound to a screenshot.", ); } if (opts.baseline || opts.diff) { throw new Error( "--baseline / --diff require trio mode (screenshot file). Remove --snapshot/--html/--json or capture the screenshot first.", ); } await runPrintMode( browser, navResult, opts, evalResult, visibility, widths, overflow, runts, layoutLint, hit, content, critique, asserts, devOverlay, batchResult, qaPlan, qaReuse ? summarizeReuse(qaReuse) : undefined, reviewPack, ); } else { await runTrioMode( browser, navResult, opts, evalResult, visibility, widths, overflow, runts, layoutLint, hit, content, critique, asserts, devOverlay, batchResult, qaPlan, qaReuse ? summarizeReuse(qaReuse) : undefined, reviewPack, ); } if (visibility && opts.checkVisibleAnnotate !== false) { await browser.clearVisibilityAnnotations(); } if (annotateWidth || annotateOverflow) { await browser.clearLayoutAnnotations(); } if (annotateRunts) { await browser.clearRuntsAnnotations(); } if (annotateLayoutLint) { await browser.clearLayoutLintAnnotations(); } if (annotateHit) { await browser.clearTargetSizeAnnotations(); } if (contentBoxes.length > 0) { await browser.clearContentAnnotations(); } if (opts.checkVisibleFail && visibility) { const threshold = Number.parseFloat(opts.checkVisibleThreshold ?? "0.9"); const failed = visibility.filter( (r) => !r.found || !r.cssVisible || r.visibleRatio < threshold, ); if (failed.length > 0) { for (const f of failed) { let reason: string; if (!f.found) { reason = "element not found"; } else if (!f.cssVisible) { const hb = f.hiddenBy; reason = hb ? `CSS-hidden via ${hb.reason} on ${hb.ancestorTag}${hb.ancestorId ? `#${hb.ancestorId}` : hb.ancestorClass ? `.${hb.ancestorClass.split(" ").slice(0, 2).join(".")}` : ""} (${hb.propertyValue})` : "CSS-hidden (display/visibility/opacity/content-visibility)"; } else { reason = `visibleRatio ${(f.visibleRatio * 100).toFixed(0)}% < ${(threshold * 100).toFixed(0)}%${f.occludedBy ? ` (occluded by ${f.occludedBy.tagName}${f.occludedBy.id ? `#${f.occludedBy.id}` : f.occludedBy.className ? `.${f.occludedBy.className.split(" ").slice(0, 2).join(".")}` : ""})` : ""}`; } emit.log(`check-visible FAIL ${f.selector}: ${reason}`, "warn"); } process.exitCode = 2; } } if (opts.checkWidthFail && widths) { const failed = widths.filter((w) => !w.found || w.viewportFill < widthThreshold); if (failed.length > 0) { for (const f of failed) { const reason = !f.found ? "element not found" : `viewportFill ${(f.viewportFill * 100).toFixed(0)}% < ${(widthThreshold * 100).toFixed(0)}% (rect=${f.rect.width}px, viewport=${f.viewportWidth}px)`; emit.log(`check-width FAIL ${f.selector}: ${reason}`, "warn"); } process.exitCode = 2; } } if (opts.checkRuntsFail && runts && runts.outcome !== "pass") { if (!runts.found) { emit.log(`check-runts FAIL ${runts.scope}: scope not found`, "warn"); } else if (runts.outcome === "unknown") { emit.log( `check-runts FAIL ${runts.scope ?? "document"}: sweep incomplete after ${runts.scannedBlocks} blocks`, "warn", ); } else { for (const hit of runts.runts) { emit.log( `check-runts FAIL ${hit.block}: last line is a lone "${hit.word}" ("…${hit.snippet.slice(-40)}")`, "warn", ); } } process.exitCode = 2; } if (opts.checkOverflowFail && overflow?.hasHorizontalOverflow) { const culprit = overflow.widerThanViewport[0] ?? overflow.rightOverflow[0] ?? null; const detail = culprit ? `, top culprit: ${culprit.tagName}${culprit.id ? `#${culprit.id}` : culprit.className ? `.${culprit.className.split(" ").slice(0, 2).join(".")}` : ""} (${culprit.widthOverflowPx > 0 ? `+${culprit.widthOverflowPx}px wider` : `+${culprit.rightOverflowPx}px past right`})` : ""; emit.log( `check-overflow FAIL: documentScrollWidth ${overflow.documentScrollWidth}px > viewport ${overflow.viewport.width}px (+${overflow.overflowPx}px)${detail}`, "warn", ); process.exitCode = 2; } applyLayoutLintFailGates(opts, layoutLint, hit); applyContentFailGates(opts, content); if (opts.checkCritiqueFail && critique && critique.outcome !== "pass") { const detail = critique.outcome === "skipped" ? (critique.error ?? "critique was skipped") : `${critique.findings.filter((f) => f.severity === "high").length} high-severity finding(s)`; emit.log(`check-critique FAIL: ${detail}`, "warn"); process.exitCode = 2; } if (opts.assertFail && asserts) { const failed = asserts.filter((a) => a.outcome === "fail"); for (const a of failed) { emit.log( `assert FAIL: ${a.op} ${a.selector} → "${a.actual}"${a.error ? ` (${a.error})` : ""}`, "warn", ); } if (failed.length > 0) process.exitCode = 2; } } finally { process.removeListener("SIGINT", handleSigint); process.removeListener("SIGTERM", handleSigterm); await controlServer?.cleanup(); await browser.close(); if (receivedSignal) process.exitCode = receivedSignal === "SIGINT" ? 130 : 143; } } // --------------------------------------------------------------------------- // Batch mode parser. Each step is ` `; verbs are click, fill, // press, wait, eval. Steps are separated by `;` (escape with `\;` if a value // genuinely contains a semicolon, rare for the supported verbs). // --------------------------------------------------------------------------- interface BatchResult { /** Each `clipboard [