/** * Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes. * * {@link discoverNextRoutes} reads the filesystem, so it sees one route per * `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that * expresses every view as a query param (`/?tab=overview`) or client-side push. * Those surfaces only exist in the *rendered* DOM, as the nav's links. This module * turns that rendered link set into a surface list: navigate the app's root, read * its ``s, and capture each — no hand-maintained `surfaces` array to drift * out of sync with the nav (the same drift the coverage guard exists to catch, * removed at the source). * * The DOM read happens at run time inside a Playwright test (a browser is needed to * see hydrated links), so this file holds only the PURE part — turning a list of * raw href strings into deduped, keyed, navigable surfaces — which is unit-testable * with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and * feeds the hrefs here. */ /** A discovered surface: a filename-safe key and the same-origin path to navigate. */ export type CrawlLink = { /** Capture file-name prefix, derived from the URL (see {@link defaultLinkKey}). */ key: string; /** Root-relative path+query to navigate (`/?tab=overview`, `/about`). */ url: string; }; /** * Keep only links whose resolved URL matches: a substring tested against the full * href, a RegExp tested against it, or a predicate over the parsed URL. Omit to keep * every same-origin link. */ export type LinkMatch = string | RegExp | ((url: URL) => boolean); export type SelectLinksOptions = { /** Absolute URL of the crawled page. Relative hrefs resolve against it and only * same-origin links are kept (external nav, mailto:, tel:, javascript: dropped). */ base: string; /** Narrow the kept links. Default: every same-origin link. */ match?: LinkMatch; /** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */ key?: (url: URL) => string; /** Also capture the crawled page itself as the first surface, so `from` is always * covered — even if the nav doesn't link back to it, or it's a single-page app with * no links at all. Default false. Used for an unfiltered "capture everything" crawl. */ includeSelf?: boolean; }; /** * Filename-safe, readable key from a link URL. Joins the path segments and the * query-param *values* (the discriminator for a tab SPA — `/?tab=overview` → * `overview`), so the common single-route-with-`?tab=` case reads cleanly while a * multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are * dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a * project needs a different scheme. * * Params are sorted by name before their values are joined, so the SAME logical * route keys identically regardless of the order the nav happened to render its * query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the * key flaps with render order and the coverage guard reports phantom * nav-regressions / unowned routes for a route that never changed. */ export declare function defaultLinkKey(url: URL): string; /** * Dedup identity for a navigable path+query. Two forms of the same route must share * one identity, or a static multi-page site (whose nav links the `.html` files) gets * captured twice as byte-near-identical maps, doubling the work and duplicating every * finding in the diff: * * - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same * route), so it's stripped — but never from the root `/` itself, nor from the query. * - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and * `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the * literal `index.html` filename normalizes — a real `about.html` is left untouched * and stays a distinct surface from `about`. * * The navigable url the caller returns keeps its original form; only the SET * membership test is normalized, so the first-seen href still wins. */ export declare function dedupIdentity(pathAndSearch: string): string; /** * Turn a page's raw `` values into a deduped, keyed surface list. * * Each href is classified by {@link toLink} (resolve against `base`, keep http(s) * same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the * survivors are deduped by path+query (trailing slash normalized — `/about` and * `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so * the capture order is the nav's order — stable across runs. * * Keys are then disambiguated: two GENUINELY different surfaces whose derived keys * collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey}) * would otherwise both write `@.json.gz` and the second would silently * overwrite the first — a captured surface vanishing without a trace. Instead the * second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both * survive as distinct maps. Trailing-slash duplicates never reach here — they're * already deduped to one surface above — so this only fires on real collisions. */ export declare function selectCrawlLinks(hrefs: Iterable, opts: SelectLinksOptions): CrawlLink[]; /** * The reconciliation of a rendered nav (the crawl's discovered link keys) against a * declared `expected` universe, both directions. Where the spec guard treats the * hand-listed `surfaces` as what's captured, here the crawl's DISCOVERED links are — * the nav is the route universe for a link-crawled SPA, so it is the source of truth. * * - `missing`: an `expected` key with no rendered link and no `exclude` entry — a * nav-regression (a route the app promised is no longer linked). * - `unexpected`: a rendered link with no `expected` entry and no `exclude` entry — a * new route/view with no owner in the registry. * - `staleExclusions`: an `exclude` key absent from BOTH `expected` and the rendered * set — a rotted opt-out. * * Unlike {@link CoverageGaps} (which permits captured-not-expected so a spec can * tighten its registry over time), the crawl asserts BOTH directions strictly: the * rendered link set is complete by construction, so an unowned link is a real gap. * Pure and browser-free so it's unit-testable; {@link import('./runner.js')} wraps it * in the crawl capture test, where the link set is finally known. */ export type CrawlCoverageGaps = { /** Expected keys with no rendered link and no `exclude` — a nav regression. */ missing: string[]; /** Rendered link keys absent from `expected` and `exclude` — a route with no owner. */ unexpected: string[]; /** `exclude` keys in neither `expected` nor the rendered set — a rotted opt-out. */ staleExclusions: string[]; }; export declare function crawlCoverageGaps(discoveredKeys: Iterable, expected: Iterable, exclude?: Record): CrawlCoverageGaps; /** * Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and * render the failure message, or `null` when the nav reconciles. `from` names the crawl * root in the message. Kept pure and out of the capture test so the wording is * unit-testable and {@link defineCrawlCapture} just throws what this returns. */ export declare function crawlCoverageError(from: string, discoveredKeys: Iterable, expected: Iterable, exclude?: Record): string | null;