/** * Typed deterministic interaction state recipes (issue #391, production contract). * * StyleProof already captures forced :hover/:focus/:active via CDP and discovers * click/select/form variants. This module is the consumer-facing contract for * *real* interaction states — hover, focus, press, and click — driven through * Playwright with stable keys and the shared destructive-action guard. * * A recipe **collection** is a set of **independent state variants**, not an * ordered action sequence. Each recipe is meant to be applied in isolation from * a known baseline (e.g. after navigation/settle); array order never encodes * multi-step choreography. `parseStateRecipes` enforces unique stable keys and * returns a deterministic key-sorted collection for that reason. * * First production slice (#391 PR #2): * - closed-world schema + pure validation (including conservative press-key vocabulary) * - hover / focus / press / click drivers * - CSS-only selector privacy policy (value-free structural selectors only) * - press always targets an explicit selector (no ambient keyboard) * - stable key derivation + duplicate detection (public `stateRecipeKey` = validate + internal derive) * - deterministic collection ordering * - destructive-label safety (never apply an unsafe control) * - post-action DOM settle via the same real-clock pattern as crawl * - `stateRecipeGo` adapter assignable to `SurfaceVariant.go` * * Surface expansion wiring (this package PR #3 / #391 capture slice): * - `Surface.stateRecipes` / crawl `stateRecipes` expand via `parseStateRecipes` * - independent captures `-` after parent `go` + apply * - `CaptureMetadata.variantKind: 'state-recipe'` + report-only provenance * * Still deferred: automatic discovery, config-file recipe parsing, transient * observation windows, live-region promotion, network/route recipes, report * state-coverage UI, and bare Escape without a target selector (ambient-unsafe). */ import type { Page } from '@playwright/test'; /** Interaction actions supported in this contract slice. */ export type StateRecipeAction = 'hover' | 'focus' | 'press' | 'click'; /** * Conservative keyboard vocabulary for `press` recipes — disclosure and * navigation only. Exact Playwright key names; no modifiers, chords, or free text. */ export declare const ALLOWED_PRESS_KEYS: readonly ["Enter", "Escape", "Space", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]; export type AllowedPressKey = (typeof ALLOWED_PRESS_KEYS)[number]; /** * Max accepted selector length. Keeps provenance/keys bounded and blocks * paste-dump / data-URI style selectors without echoing content in errors. */ export declare const MAX_RECIPE_SELECTOR_LENGTH = 256; /** Declared human labels (provenance / keys) — bounded against log injection. */ export declare const MAX_RECIPE_LABEL_LENGTH = 160; /** Explicit stable state-key fragments before slugging. */ export declare const MAX_RECIPE_STATE_KEY_LENGTH = 80; /** * A single deterministic interaction that reaches a UI state. * * Shape mirrors {@link import('./crawl-surfaces.js').SetupStep}: plain data, * no functions, so recipes can live in JSON fixtures and stay serializable. * * Recipes in a collection are independent variants of state, not steps in a * sequence — see {@link parseStateRecipes}. */ export type StateRecipe = { action: StateRecipeAction; /** * Target selector. Required for every action, including `press` (focus target * then key — never ambient keyboard input). Must pass the recipe selector * privacy policy (CSS-only value-free structural selectors). */ selector: string; /** * Keyboard key for `press`. Must be one of {@link ALLOWED_PRESS_KEYS} * (e.g. `Enter`, `Escape`, `ArrowDown`). Required when `action` is `press`. * Modifiers, chords (`Control+k`), and free-text values are rejected. */ key?: AllowedPressKey; /** * Declared human label for stable keys and provenance. When omitted at apply * time, the driver may read the live accessible label for the destructive * guard and provenance only — live labels never rewrite stable keys. * Blank/whitespace-only and non-slugable (emoji/CJK/punctuation-only) labels * are rejected. Bounded and control-sanitized. */ label?: string; /** * Optional explicit stable state-key fragment. When omitted, derived from * action + declared label/selector/key via {@link stateRecipeKey}. * Bounded, control-sanitized, and must slug to a non-empty fragment. */ stateKey?: string; }; /** Provenance returned after a recipe is successfully applied. */ export type AppliedStateRecipe = { /** Stable key for map/report identity (`hover-open-menu`, …). */ stateKey: string; action: StateRecipeAction; /** Validated value-free CSS selector (never attribute-equality / secret-bearing). */ selector: string; key?: AllowedPressKey; label?: string; }; export type StateRecipeSkipReason = 'unsafe-label'; /** A recipe that must not be driven (destructive-action guard). */ export type StateRecipeSkip = { reason: StateRecipeSkipReason; recipe: StateRecipe; label: string; detail: string; }; export declare class StateRecipeError extends Error { constructor(message: string, options?: ErrorOptions); } /** True when a control label matches the shared destructive-action guard. */ export declare function isUnsafeStateLabel(label: string): boolean; /** * True when `key` is in the conservative press vocabulary (exact match). * Rejects modifiers/chords (`Control+Enter`), free text, and casing variants. */ export declare function isAllowedPressKey(key: string): key is AllowedPressKey; /** * Conservative CSS-only recipe selector privacy policy: * - trim + nonempty, length ≤ {@link MAX_RECIPE_SELECTOR_LENGTH} * - no controls / newlines / NUL / bidi / ZW / BOM * - no quotes or backticks (block value payloads and engine string forms) * - no backslash escapes (no smuggled payloads) * - no attribute-equality (`=`, `~=`, `|=`, `^=`, `$=`, `*=`) — presence-only `[attr]` OK * - no Playwright engine prefixes (`text=`, `xpath=`, `css=`, `id=`, `role=`, …) * - no Playwright locator chaining (`>>` / `button >> …`) — single CSS `>` OK * - no value-carrying functions (`:text(...)`, `:has-text(...)`, `url(...)`, …) * - structural pseudos only (`:first-child`, `:nth-child(2)`, simple `:not(...)`, …) * - no URL query (`?`) or credential forms * * Allowed: `#id`, `.class`, `button.primary`, `[aria-expanded]`, `input[name]`, `nav > a`, * `li:first-child`, `li:nth-child(2)`. * Rejected: `input[value=secret]`, `:text("x")`, `text=secret`, `xpath=//a`, `>> secret`, * `button >> secret`, quotes, escapes. * * Errors name the **policy**, never echo the selector (secrets must not appear in messages). * Prefer false rejection over privacy leak. */ export declare function assertSafeRecipeSelector(value: unknown): string; /** * Bound + control-sanitize declared labels. Must produce a non-empty safe slug * fragment (emoji / CJK / punctuation-only labels are rejected). Never echo the * value in errors. */ export declare function assertSafeRecipeLabel(value: unknown): string; /** * Bound + control-sanitize explicit stateKey fragments. Must slug to a non-empty * stable key (reject all-punctuation/Unicode that would collapse to generic `state`). */ export declare function assertSafeRecipeStateKey(value: unknown): string; /** * Pure shape validation. Does **not** apply the destructive guard — use * {@link classifyStateRecipe} / {@link applyStateRecipe} for that so discovery * lists can still carry skipped unsafe candidates. */ export declare function validateStateRecipe(raw: unknown): StateRecipe; /** * Validate a collection of **independent state variants** (not an ordered * action sequence). Each recipe is a standalone path to a UI state from a * known baseline; collection order is not choreography. * * - Validates every entry via {@link validateStateRecipe} * - Rejects duplicate derived stable keys (internal derive after one validate) * - Returns recipes sorted by stable key for deterministic collection ordering */ export declare function parseStateRecipes(raw: unknown): StateRecipe[]; /** * Stable identity for a recipe. Explicit `stateKey` wins; otherwise * `action[-declared-label|-selector][-press-key]`. Deterministic across runs * and independent of live DOM labels. * * **Entry validation (public API):** runs full {@link validateStateRecipe} * (closed world, selector/label/stateKey privacy, press-key rules) then derives * via the shared internal helper. Direct JS/cast calls with secret-bearing * selectors, unknown fields, or invalid keys throw policy-only errors (no * secret in message/stack) and never return a secret-bearing key. Fragments * that slug to empty are rejected rather than collapsing to a generic `state` * collision key. * * Accepts `unknown` at runtime; TypeScript callers may still pass a * {@link StateRecipe}. */ export declare function stateRecipeKey(recipe: StateRecipe | unknown): string; /** * Classify safety without driving the page. Unsafe labels become named skips * so discovery can report them instead of silently dropping them. */ export declare function classifyStateRecipe(raw: unknown): { ok: true; recipe: StateRecipe; } | { ok: false; skip: StateRecipeSkip; }; export declare function applyStateRecipe(page: Page, raw: unknown): Promise; /** * Build a driver assignable to {@link import('./runner.js').SurfaceVariant.go} * (and similarly shaped slots that take `(page) => Promise`). * Applies the recipe and discards provenance — use {@link applyStateRecipe} * when you need the returned {@link AppliedStateRecipe}. */ export declare function stateRecipeGo(raw: unknown): (page: Page) => Promise;