/**
* `loadPageScenarios` — harvest scenarios that a target app self-declares
* on `window.__chaosbringer`. Inspired by WebMCP: instead of the testing
* tool guessing what to do, the app exposes the operations it wants
* tested. This decouples scenario maintenance from the test harness
* code — UI rewrites can't silently break scenarios that the app team
* owns and ships alongside the feature.
*
* Shape the app publishes:
*
* ```html
*
* ```
*
* The shape mirrors `ActionRecipe` so the harvested entries drop
* straight into a `RecipeStore`. `stats` / `status` / timestamps are
* filled in here — the app only declares the *intent*, not the runtime
* metadata.
*
* Trust model: harvesting evaluates the page's `window.__chaosbringer`
* verbatim. **Only run this against your own app or a trusted target.**
* A hostile page could publish a "scenario" containing a click on a
* destructive button — replaying it would trigger the destruction.
* The driver respects this exactly the same way it respects any
* captured recipe: it's the operator's problem to point the harness
* at apps they trust.
*/
import type { Page } from "playwright";
import type { ActionRecipe, RecipePrecondition, RecipeStep } from "./types.js";
import { emptyStats } from "./types.js";
export const PAGE_SCENARIO_GLOBAL = "__chaosbringer";
export interface PageDeclaredScenario {
name: string;
description?: string;
goal?: string;
preconditions?: RecipePrecondition[];
steps: RecipeStep[];
postconditions?: RecipePrecondition[];
requires?: string[];
}
export interface PageDeclaredBundle {
version?: number;
scenarios?: PageDeclaredScenario[];
}
export interface LoadPageScenariosOptions {
/** Override the window global key. Default: `"__chaosbringer"`. */
globalKey?: string;
/**
* Maximum number of scenarios to accept from a single page. Default: 50.
* Page-declared bundles should be small lists, not full crawler-style
* dumps; a much larger list signals either a bug or untrusted input.
*/
maxScenarios?: number;
/**
* Set `status: "verified"` on harvested scenarios so they're
* immediately replayable. Default: false — harvested scenarios
* start as `candidate` and go through normal verification.
*
* Pass true ONLY when the app you target is your own and you've
* decided the declared scenarios are authoritative.
*/
trustPublisher?: boolean;
}
/**
* Read `window.__chaosbringer.scenarios` from the page (if present)
* and convert each entry into a `candidate`-status `ActionRecipe`.
* Returns `[]` when nothing is declared or the shape is invalid.
*
* Safe to call on any page — invalid / missing globals just return an
* empty list, never throw.
*/
export async function loadPageScenarios(
page: Page,
opts: LoadPageScenariosOptions = {},
): Promise {
const key = opts.globalKey ?? PAGE_SCENARIO_GLOBAL;
const maxScenarios = opts.maxScenarios ?? 50;
const bundle = await page
.evaluate(
(k: string) => {
const w = window as unknown as Record;
const raw = w[k];
if (!raw || typeof raw !== "object") return null;
return raw as unknown;
},
key,
)
.catch(() => null);
if (!bundle || typeof bundle !== "object") return [];
const list = (bundle as PageDeclaredBundle).scenarios;
if (!Array.isArray(list)) return [];
const now = Date.now();
const accepted: ActionRecipe[] = [];
for (const s of list) {
if (accepted.length >= maxScenarios) break;
if (!isValidScenario(s)) continue;
accepted.push({
name: s.name,
description: s.description ?? `Page-declared scenario "${s.name}"`,
goal: s.goal,
preconditions: s.preconditions ?? [],
steps: s.steps,
postconditions: s.postconditions ?? [],
requires: s.requires ?? [],
stats: emptyStats(),
origin: "page-declared",
status: opts.trustPublisher ? "verified" : "candidate",
version: 1,
createdAt: now,
updatedAt: now,
});
}
return accepted;
}
/**
* Validation gate. We accept "looks structurally right" — strict
* step-kind validation lives in the replay layer, which gives a
* better error message on actual misuse anyway.
*/
function isValidScenario(s: unknown): s is PageDeclaredScenario {
if (!s || typeof s !== "object") return false;
const v = s as Record;
if (typeof v.name !== "string" || !v.name) return false;
if (!Array.isArray(v.steps) || v.steps.length === 0) return false;
// Sanity: every step must be an object with a string `kind`. We
// accept anything past that — the replayer's switch handles invalid
// kinds by throwing at execution time.
for (const step of v.steps) {
if (!step || typeof step !== "object") return false;
if (typeof (step as Record).kind !== "string") return false;
}
return true;
}