Files
harness-tools/extensions/fast-browser/scenarios.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

646 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import type {
CredentialBindings,
PageAssertion,
Postcondition,
ScenarioDefinition,
ScenarioFixtureConfig,
ScenarioHttpReadiness,
ScenarioOperation,
ScenarioOperationIndex,
ScenarioProcessHook,
ScenarioServerConfig,
ScenarioTabIndex,
ScenarioTabReference,
ScenarioTabSelection,
Step,
} from "./contracts.ts";
import { FAST_BROWSER_SCENARIO_FILE_VERSION } from "./contracts.ts";
import { validateSteps } from "./controller.ts";
import { createCredentialResolver } from "./credentials.ts";
/**
* The scenario loader is deliberately independent of browser execution. The
* runtime contract owns session, assertion, and evidence execution; this file
* only validates the declarative input that a caller explicitly selected.
*/
export const SCENARIO_SCHEMA_VERSION = FAST_BROWSER_SCENARIO_FILE_VERSION;
export const MAX_SCENARIO_FILE_BYTES = 512 * 1024;
export const MAX_SCENARIO_FILES = 32;
export const MAX_SCENARIOS_PER_FILE = 32;
export const MAX_SCENARIO_ID = 128;
export const MAX_SCENARIO_DESCRIPTION = 4_000;
export const MAX_HOOK_ARGUMENTS = 32;
export const MAX_HOOK_ENVIRONMENT = 32;
export const MAX_HOOK_TIMEOUT_MS = 120_000;
export class ScenarioValidationError extends Error {
override name = "ScenarioValidationError";
}
export interface ScenarioHook {
/** Executable name/path. The loader never turns page data into a command. */
command: string;
/** Explicit argv entries; shell execution is never enabled by this schema. */
args: string[];
cwd?: string;
/** Values are either explicit safe strings or references to process env. */
env?: Record<string, string | { env: string }>;
timeoutMs?: number;
}
export interface ScenarioHooks {
setup?: ScenarioHook;
reset?: ScenarioHook;
cleanup?: ScenarioHook;
}
export interface ScenarioEvidencePolicy {
/** Failure evidence is local-only and never forwarded to a model. */
onFailure: boolean;
/** Explicit request evidence is local-only and never forwarded to a model. */
onRequest: boolean;
}
/**
* A validated source definition. Browser operations use the published
* ScenarioOperation union; settings/hooks are caller-owned scenario metadata
* consumed by the eventual runner and never inferred from page content.
*/
export interface DeclarativeScenario extends ScenarioDefinition {
description?: string;
settings?: Record<string, unknown>;
credentials?: CredentialBindings;
hooks?: ScenarioHooks;
evidence?: ScenarioEvidencePolicy;
budgets?: {
maxSteps?: number;
timeoutMs?: number;
minConfidence?: number;
};
}
export interface ScenarioDocument {
version: typeof SCENARIO_SCHEMA_VERSION;
scenarios: DeclarativeScenario[];
}
export interface LoadedScenario {
scenario: DeclarativeScenario;
path: string;
index: number;
}
export interface LoadedScenarios {
files: string[];
scenarios: LoadedScenario[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function boundedString(value: unknown, label: string, max: number, options: { trim?: boolean } = {}): string {
if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) {
throw new ScenarioValidationError(`${label} must be a bounded string`);
}
const result = options.trim === false ? value : value.trim();
if (!result) throw new ScenarioValidationError(`${label} must not be empty`);
return result;
}
function assertKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
const accepted = new Set(allowed);
if (Object.keys(value).some((key) => !accepted.has(key))) {
throw new ScenarioValidationError(`${label} contains an unknown field`);
}
}
function parseEnvironmentName(value: unknown, label: string): string {
const name = boundedString(value, label, 256);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new ScenarioValidationError(`${label} must be an environment variable name`);
}
return name;
}
function parseHook(value: unknown, label: string): ScenarioHook {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["command", "args", "argv", "cwd", "env", "timeoutMs", "shell"], label);
if (value.shell !== undefined && value.shell !== false) {
throw new ScenarioValidationError(`${label}.shell must be false; shell execution is disabled`);
}
const hasCommand = value.command !== undefined;
const hasArgv = value.argv !== undefined;
if (hasCommand === hasArgv) {
throw new ScenarioValidationError(`${label} requires exactly one command or argv`);
}
let command: string;
let args: string[];
if (hasArgv) {
if (!Array.isArray(value.argv) || value.argv.length < 1 || value.argv.length > MAX_HOOK_ARGUMENTS + 1) {
throw new ScenarioValidationError(`${label}.argv must contain a bounded executable and arguments`);
}
const argv = value.argv.map((entry, index) => boundedString(entry, `${label}.argv[${index}]`, 4_000, { trim: false }));
command = argv[0]!;
args = argv.slice(1);
} else {
command = boundedString(value.command, `${label}.command`, 4_000, { trim: false });
if (value.args === undefined) args = [];
else if (!Array.isArray(value.args) || value.args.length > MAX_HOOK_ARGUMENTS) {
throw new ScenarioValidationError(`${label}.args must contain at most ${MAX_HOOK_ARGUMENTS} entries`);
} else {
args = value.args.map((entry, index) => boundedString(entry, `${label}.args[${index}]`, 4_000, { trim: false }));
}
}
if (command.startsWith("-") || command.includes("\r") || command.includes("\n")
|| args.some((arg) => arg.includes("\r") || arg.includes("\n"))) {
throw new ScenarioValidationError(`${label}.command is not a valid executable`);
}
const cwd = value.cwd === undefined ? undefined : boundedString(value.cwd, `${label}.cwd`, 4_000, { trim: false });
let env: Record<string, string | { env: string }> | undefined;
if (value.env !== undefined) {
if (!isRecord(value.env) || Object.keys(value.env).length > MAX_HOOK_ENVIRONMENT) {
throw new ScenarioValidationError(`${label}.env must be a bounded object`);
}
env = {};
for (const [name, raw] of Object.entries(value.env)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new ScenarioValidationError(`${label}.env contains an invalid variable name`);
}
if (typeof raw === "string") {
if (raw.length > 4_000 || raw.includes("\0") || raw.includes("\r") || raw.includes("\n")) {
throw new ScenarioValidationError(`${label}.env contains an oversized value`);
}
env[name] = raw;
} else if (isRecord(raw) && Object.keys(raw).length === 1 && Object.hasOwn(raw, "env")) {
env[name] = { env: parseEnvironmentName(raw.env, `${label}.env.${name}.env`) };
} else {
throw new ScenarioValidationError(`${label}.env values must be strings or environment references`);
}
}
}
let timeoutMs: number | undefined;
if (value.timeoutMs !== undefined) {
const rawTimeout = value.timeoutMs;
if (typeof rawTimeout !== "number" || !Number.isSafeInteger(rawTimeout) || rawTimeout < 1 || rawTimeout > MAX_HOOK_TIMEOUT_MS) {
throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`);
}
timeoutMs = rawTimeout;
}
return {
command,
args,
...(cwd === undefined ? {} : { cwd }),
...(env === undefined ? {} : { env }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
};
}
function parseHooks(value: unknown, label: string): ScenarioHooks {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["setup", "reset", "cleanup"], label);
return {
...(value.setup === undefined ? {} : { setup: parseHook(value.setup, `${label}.setup`) }),
...(value.reset === undefined ? {} : { reset: parseHook(value.reset, `${label}.reset`) }),
...(value.cleanup === undefined ? {} : { cleanup: parseHook(value.cleanup, `${label}.cleanup`) }),
};
}
function parseEvidence(value: unknown, label: string): ScenarioEvidencePolicy {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["onFailure", "onRequest"], label);
if (value.onFailure !== undefined && typeof value.onFailure !== "boolean") {
throw new ScenarioValidationError(`${label}.onFailure must be a boolean`);
}
if (value.onRequest !== undefined && typeof value.onRequest !== "boolean") {
throw new ScenarioValidationError(`${label}.onRequest must be a boolean`);
}
return { onFailure: value.onFailure ?? true, onRequest: value.onRequest ?? false };
}
function parseReadiness(value: unknown, label: string): ScenarioHttpReadiness {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["url", "expectedStatus", "timeoutMs"], label);
const expectedStatus = value.expectedStatus === undefined ? undefined : value.expectedStatus;
if (expectedStatus !== undefined && (typeof expectedStatus !== "number" || !Number.isSafeInteger(expectedStatus) || expectedStatus < 100 || expectedStatus > 599)) {
throw new ScenarioValidationError(`${label}.expectedStatus is out of bounds`);
}
const timeoutMs = value.timeoutMs === undefined ? undefined : value.timeoutMs;
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)) {
throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`);
}
return {
url: parseHttpUrl(value.url, `${label}.url`),
...(expectedStatus === undefined ? {} : { expectedStatus }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
};
}
function parseFixture(value: unknown, label: string): ScenarioFixtureConfig {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["baseUrl", "server"], label);
let server: ScenarioServerConfig | undefined;
if (value.server !== undefined) {
if (!isRecord(value.server)) throw new ScenarioValidationError(`${label}.server must be an object`);
if (value.server.mode === "external") {
assertKeys(value.server, ["mode", "readiness"], `${label}.server`);
server = { mode: "external", readiness: parseReadiness(value.server.readiness, `${label}.server.readiness`) };
} else if (value.server.mode === "managed") {
assertKeys(value.server, ["mode", "start", "readiness", "shutdownTimeoutMs"], `${label}.server`);
const shutdownTimeoutMs = value.server.shutdownTimeoutMs === undefined ? undefined : value.server.shutdownTimeoutMs;
if (shutdownTimeoutMs !== undefined && (typeof shutdownTimeoutMs !== "number" || !Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 10_000)) {
throw new ScenarioValidationError(`${label}.server.shutdownTimeoutMs is out of bounds`);
}
server = {
mode: "managed",
start: parseHook(value.server.start, `${label}.server.start`) as ScenarioProcessHook,
readiness: parseReadiness(value.server.readiness, `${label}.server.readiness`),
...(shutdownTimeoutMs === undefined ? {} : { shutdownTimeoutMs }),
};
} else {
throw new ScenarioValidationError(`${label}.server.mode must be managed or external`);
}
}
return {
baseUrl: parseHttpUrl(value.baseUrl, `${label}.baseUrl`),
...(server === undefined ? {} : { server }),
};
}
function parseBudgets(value: unknown, label: string, stepCount: number): DeclarativeScenario["budgets"] {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["maxSteps", "timeoutMs", "minConfidence"], label);
const maxSteps = value.maxSteps === undefined ? undefined : value.maxSteps;
const timeoutMs = value.timeoutMs === undefined ? undefined : value.timeoutMs;
const minConfidence = value.minConfidence === undefined ? undefined : value.minConfidence;
if (maxSteps !== undefined && (typeof maxSteps !== "number" || !Number.isSafeInteger(maxSteps) || maxSteps < 1 || maxSteps > 10 || stepCount > maxSteps)) {
throw new ScenarioValidationError(`${label}.maxSteps is out of bounds`);
}
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)) {
throw new ScenarioValidationError(`${label}.timeoutMs is out of bounds`);
}
if (minConfidence !== undefined && (typeof minConfidence !== "number" || !Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1)) {
throw new ScenarioValidationError(`${label}.minConfidence is out of bounds`);
}
return {
...(maxSteps === undefined ? {} : { maxSteps: maxSteps as number }),
...(timeoutMs === undefined ? {} : { timeoutMs: timeoutMs as number }),
...(minConfidence === undefined ? {} : { minConfidence }),
};
}
function parseStep(value: unknown, index: number): Step {
if (!isRecord(value)) throw new ScenarioValidationError(`step[${index}] must be an object`);
assertKeys(value, ["instruction", "action", "text", "compose", "credential", "postcondition"], `step[${index}]`);
if (value.postcondition !== undefined) {
if (!isRecord(value.postcondition)) throw new ScenarioValidationError(`step[${index}].postcondition must be an object`);
assertKeys(value.postcondition, ["kind", "value"], `step[${index}].postcondition`);
}
return {
instruction: value.instruction as string,
action: value.action as Step["action"],
...(value.text === undefined ? {} : { text: value.text as string }),
...(value.compose === undefined ? {} : { compose: value.compose as string }),
...(value.credential === undefined ? {} : { credential: value.credential as string }),
...(value.postcondition === undefined ? {} : { postcondition: value.postcondition as Postcondition }),
};
}
interface PriorScenarioOperation {
index: number;
id?: string;
op: ScenarioOperation["op"];
}
function parseHttpUrl(value: unknown, label: string): string {
const url = boundedString(value, label, 8_000);
let parsed: URL;
try { parsed = new URL(url); } catch { throw new ScenarioValidationError(`${label} must be an HTTP(S) URL`); }
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new ScenarioValidationError(`${label} must be an HTTP(S) URL without credentials`);
}
return url;
}
function parseOrigin(value: unknown, label: string): string {
const url = parseHttpUrl(value, label);
const parsed = new URL(url);
if (url !== parsed.origin) throw new ScenarioValidationError(`${label} must be an exact HTTP(S) origin`);
return url;
}
function parseBoundedIndex(value: unknown, label: string): ScenarioTabIndex {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 31) {
throw new ScenarioValidationError(`${label} must be an integer from 0 through 31`);
}
return value as ScenarioTabIndex;
}
function parseOperationIndex(value: unknown, label: string): ScenarioOperationIndex {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 31) {
throw new ScenarioValidationError(`${label} must be an integer from 0 through 31`);
}
return value as ScenarioOperationIndex;
}
function parseOperationRef(value: unknown, label: string, prior: readonly PriorScenarioOperation[]): PriorScenarioOperation {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["index", "id"], label);
if ((value.index === undefined) === (value.id === undefined)) throw new ScenarioValidationError(`${label} must contain exactly one of index or id`);
let operation: PriorScenarioOperation | undefined;
if (value.index !== undefined) {
const index = parseOperationIndex(value.index, `${label}.index`);
operation = prior.find((candidate) => candidate.index === index);
} else {
const id = boundedString(value.id, `${label}.id`, MAX_SCENARIO_ID);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new ScenarioValidationError(`${label}.id has invalid characters`);
operation = prior.find((candidate) => candidate.id === id);
}
if (!operation) throw new ScenarioValidationError(`${label} must reference an earlier operation`);
return operation;
}
function parseTabReference(value: unknown, label: string, prior: readonly PriorScenarioOperation[]): ScenarioTabReference {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["source", "target", "operation", "result", "index"], label);
if (value.source === "initial") {
if (value.target !== "selected" || value.operation !== undefined || value.result !== undefined || value.index !== undefined) {
throw new ScenarioValidationError(`${label} initial references must target selected`);
}
return { source: "initial", target: "selected" };
}
if (value.source !== "operation" || value.target !== undefined) throw new ScenarioValidationError(`${label}.source is unsupported`);
if (value.result !== "selectedTab" && value.result !== "popup" && value.result !== "listedTab") {
throw new ScenarioValidationError(`${label}.result is unsupported`);
}
const operation = parseOperationRef(value.operation, `${label}.operation`, prior);
if (value.result === "selectedTab" && operation.op !== "selectTab") throw new ScenarioValidationError(`${label}.result selectedTab requires an earlier selectTab operation`);
if (value.result === "popup" && operation.op !== "waitPopup") throw new ScenarioValidationError(`${label}.result popup requires an earlier waitPopup operation`);
if (value.result === "listedTab" && operation.op !== "listTabs") throw new ScenarioValidationError(`${label}.result listedTab requires an earlier listTabs operation`);
if (value.result !== "listedTab" && value.index !== undefined) throw new ScenarioValidationError(`${label}.index is only valid for listedTab`);
return value.result === "listedTab"
? { source: "operation", operation: operation.id === undefined ? { index: operation.index as ScenarioOperationIndex } : { id: operation.id }, result: "listedTab", index: parseBoundedIndex(value.index, `${label}.index`) }
: { source: "operation", operation: operation.id === undefined ? { index: operation.index as ScenarioOperationIndex } : { id: operation.id }, result: value.result };
}
function parseTabSelection(value: unknown, label: string): ScenarioTabSelection {
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
assertKeys(value, ["kind", "index", "origin"], label);
if (value.kind === "index") {
if (value.origin !== undefined) throw new ScenarioValidationError(`${label}.origin is not valid for index selection`);
return { kind: "index", index: parseBoundedIndex(value.index, `${label}.index`) };
}
if (value.kind === "origin") {
if (value.index !== undefined) throw new ScenarioValidationError(`${label}.index is not valid for origin selection`);
return { kind: "origin", origin: parseOrigin(value.origin, `${label}.origin`) };
}
throw new ScenarioValidationError(`${label}.kind is unsupported`);
}
function parseOperationId(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined;
const id = boundedString(value, label, MAX_SCENARIO_ID);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new ScenarioValidationError(`${label} has invalid characters`);
return id;
}
function parseScenarioOperation(value: unknown, index: number, prior: readonly PriorScenarioOperation[]): ScenarioOperation {
const label = `operations[${index}]`;
if (!isRecord(value)) throw new ScenarioValidationError(`${label} must be an object`);
const op = value.op;
const id = parseOperationId(value.id, `${label}.id`);
const metadata = id === undefined ? {} : { id };
if (op === "step") {
assertKeys(value, ["op", "step", "id"], label);
return { op: "step", step: parseStep(value.step, index), ...metadata };
}
if (op === "read") {
assertKeys(value, ["op", "request", "id"], label);
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
assertKeys(value.request, ["tab"], `${label}.request`);
return { op: "read", request: value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata };
}
if (op === "assert") {
assertKeys(value, ["op", "request", "id"], label);
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
assertKeys(value.request, ["tab", "assertion", "wait"], `${label}.request`);
if (value.request.wait !== "none" && value.request.wait !== "bounded") throw new ScenarioValidationError(`${label}.request.wait must be none or bounded`);
if (!isRecord(value.request.assertion)) throw new ScenarioValidationError(`${label}.request.assertion must be an object`);
assertKeys(value.request.assertion, ["kind", "match", "value"], `${label}.request.assertion`);
const assertion = value.request.assertion;
if (assertion.kind !== "url" && assertion.kind !== "title" && assertion.kind !== "visibleText") throw new ScenarioValidationError(`${label}.request.assertion.kind is unsupported`);
const allowedMatches = assertion.kind === "url" ? ["exact", "origin"] : assertion.kind === "title" ? ["exact", "contains"] : ["contains"];
if (!allowedMatches.includes(assertion.match as string)) throw new ScenarioValidationError(`${label}.request.assertion.match is unsupported`);
const assertionValue = assertion.kind === "url" ? parseHttpUrl(assertion.value, `${label}.request.assertion.value`) : boundedString(assertion.value, `${label}.request.assertion.value`, 4_000, { trim: false });
const parsedAssertion = { kind: assertion.kind, match: assertion.match, value: assertionValue } as PageAssertion;
return { op: "assert", request: { assertion: parsedAssertion, wait: value.request.wait, ...(value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }) }, ...metadata };
}
if (op === "navigate") {
assertKeys(value, ["op", "request", "id"], label);
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
assertKeys(value.request, ["tab", "url"], `${label}.request`);
return { op: "navigate", request: { url: parseHttpUrl(value.request.url, `${label}.request.url`), ...(value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }) }, ...metadata };
}
if (op === "reload") {
assertKeys(value, ["op", "request", "id"], label);
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
assertKeys(value.request, ["tab"], `${label}.request`);
return { op: "reload", request: value.request.tab === undefined ? {} : { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata };
}
if (op === "listTabs") {
assertKeys(value, ["op", "request", "id"], label);
if (value.request === undefined) return { op: "listTabs", ...metadata };
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
assertKeys(value.request, ["select"], `${label}.request`);
return { op: "listTabs", request: value.request.select === undefined ? {} : { select: parseTabSelection(value.request.select, `${label}.request.select`) }, ...metadata };
}
if (op === "selectTab" || op === "waitPopup" || op === "closeTab") {
assertKeys(value, ["op", "request", "id"], label);
if (!isRecord(value.request)) throw new ScenarioValidationError(`${label}.request must be an object`);
const key = op === "waitPopup" ? "opener" : "tab";
assertKeys(value.request, [key], `${label}.request`);
if (value.request[key] === undefined) throw new ScenarioValidationError(`${label}.request.${key} is required`);
if (op === "selectTab") return { op, request: { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata };
if (op === "waitPopup") return { op, request: { opener: parseTabReference(value.request.opener, `${label}.request.opener`, prior) }, ...metadata };
return { op, request: { tab: parseTabReference(value.request.tab, `${label}.request.tab`, prior) }, ...metadata };
}
throw new ScenarioValidationError(`${label}.op is not supported by scenarios`);
}
function parseScenario(value: unknown, index: number, documentVersion: number): DeclarativeScenario {
if (!isRecord(value)) throw new ScenarioValidationError(`scenarios[${index}] must be an object`);
// Runtime-owned fields can be added later, but these source fields must not
// be silently ignored while the schema is version 1.
assertKeys(value, [
"version", "id", "description", "operations", "settings", "credentials", "hooks", "evidence", "budgets", "fixture",
], `scenarios[${index}]`);
if (value.version !== SCENARIO_SCHEMA_VERSION) {
throw new ScenarioValidationError(`scenarios[${index}].version is unsupported`);
}
if (documentVersion !== SCENARIO_SCHEMA_VERSION) throw new ScenarioValidationError("scenario document version is unsupported");
const id = boundedString(value.id, `scenarios[${index}].id`, MAX_SCENARIO_ID);
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) {
throw new ScenarioValidationError(`scenarios[${index}].id has invalid characters`);
}
const rawOperations = value.operations;
if (!Array.isArray(rawOperations) || rawOperations.length < 1 || rawOperations.length > 32) {
throw new ScenarioValidationError(`scenarios[${index}].operations must contain 1–32 operations`);
}
const prior: PriorScenarioOperation[] = [];
const operations = rawOperations.map((operation, operationIndex) => {
const parsed = parseScenarioOperation(operation, operationIndex, prior);
if (parsed.id !== undefined && prior.some((candidate) => candidate.id === parsed.id)) {
throw new ScenarioValidationError(`operations[${operationIndex}].id is duplicated`);
}
prior.push({ index: operationIndex, ...(parsed.id === undefined ? {} : { id: parsed.id }), op: parsed.op });
return parsed;
});
const steps = operations.filter((operation): operation is { op: "step"; step: Step } => operation.op === "step").map((operation) => operation.step);
const budgets = value.budgets === undefined ? undefined : parseBudgets(value.budgets, `scenarios[${index}].budgets`, steps.length);
const fixture = value.fixture === undefined ? undefined : parseFixture(value.fixture, `scenarios[${index}].fixture`);
try {
if (steps.length) validateSteps(steps, budgets?.maxSteps ?? 10);
} catch {
throw new ScenarioValidationError(`scenarios[${index}].steps are invalid`);
}
const description = value.description === undefined ? undefined : boundedString(value.description, `scenarios[${index}].description`, MAX_SCENARIO_DESCRIPTION, { trim: false });
let settings: Record<string, unknown> | undefined;
if (value.settings !== undefined) {
if (!isRecord(value.settings) || Object.keys(value.settings).length > 32) {
throw new ScenarioValidationError(`scenarios[${index}].settings must be a bounded object`);
}
settings = structuredClone(value.settings);
}
const hasComposition = steps.some((step) => step.compose !== undefined);
if (hasComposition && (typeof settings?.textModel !== "string" || !settings.textModel.trim())) {
throw new ScenarioValidationError(`scenarios[${index}] composition requires an explicit settings.textModel`);
}
let credentials: CredentialBindings | undefined;
if (value.credentials !== undefined) {
if (!isRecord(value.credentials) || Object.keys(value.credentials).length > 40) {
throw new ScenarioValidationError(`scenarios[${index}].credentials must be a bounded object`);
}
credentials = structuredClone(value.credentials) as CredentialBindings;
// Validate declarations now; resolver access remains lazy and no value is read.
try { createCredentialResolver(credentials, { cwd: process.cwd(), env: {} }); }
catch { throw new ScenarioValidationError(`scenarios[${index}].credentials are invalid`); }
}
const hooks = value.hooks === undefined ? undefined : parseHooks(value.hooks, `scenarios[${index}].hooks`);
const evidence = value.evidence === undefined ? undefined : parseEvidence(value.evidence, `scenarios[${index}].evidence`);
return {
version: SCENARIO_SCHEMA_VERSION,
id,
...(description === undefined ? {} : { description }),
operations,
...(settings === undefined ? {} : { settings }),
...(credentials === undefined ? {} : { credentials }),
...(hooks === undefined ? {} : { hooks }),
...(evidence === undefined ? {} : { evidence }),
...(budgets === undefined ? {} : { budgets }),
...(fixture === undefined ? {} : { fixture }),
};
}
/** Parse and validate one already-decoded declarative JSON value. */
export function parseScenarioDocument(value: unknown): ScenarioDocument {
if (!isRecord(value)) throw new ScenarioValidationError("scenario file must contain a JSON object");
if (value.version !== SCENARIO_SCHEMA_VERSION) {
throw new ScenarioValidationError("scenario document version is unsupported");
}
let rawScenarios: unknown[];
if (Object.hasOwn(value, "scenarios")) {
assertKeys(value, ["version", "scenarios"], "scenario document");
if (!Array.isArray(value.scenarios) || value.scenarios.length < 1 || value.scenarios.length > MAX_SCENARIOS_PER_FILE) {
throw new ScenarioValidationError("scenario document scenarios must be a bounded non-empty array");
}
rawScenarios = value.scenarios;
} else {
// A single scenario file keeps the explicit version at the document root.
assertKeys(value, ["version", "id", "description", "operations", "settings", "credentials", "hooks", "evidence", "budgets", "fixture"], "scenario document");
rawScenarios = [value];
}
const scenarios = rawScenarios.map((entry, index) => parseScenario(entry, index, value.version as number));
const ids = new Set<string>();
for (const scenario of scenarios) {
if (ids.has(scenario.id)) throw new ScenarioValidationError(`duplicate scenario id: ${scenario.id}`);
ids.add(scenario.id);
}
return { version: SCENARIO_SCHEMA_VERSION, scenarios };
}
async function readBoundedRegularFile(path: string): Promise<string> {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
const info = await handle.stat();
if (!info.isFile() || info.size > MAX_SCENARIO_FILE_BYTES) {
throw new ScenarioValidationError("scenario file must be a bounded regular file");
}
const bytes = Buffer.alloc(MAX_SCENARIO_FILE_BYTES + 1);
let used = 0;
while (used <= MAX_SCENARIO_FILE_BYTES) {
const result = await handle.read(bytes, used, bytes.length - used, null);
if (result.bytesRead === 0) break;
used += result.bytesRead;
if (used > MAX_SCENARIO_FILE_BYTES) throw new ScenarioValidationError("scenario file exceeds the size limit");
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used));
} catch {
throw new ScenarioValidationError("scenario file is not valid UTF-8");
}
} catch (error) {
if (error instanceof ScenarioValidationError) throw error;
throw new ScenarioValidationError("scenario file could not be read");
} finally {
try { await handle?.close(); } catch { /* preserve the original read outcome */ }
}
}
/** Read one explicit scenario path without accepting stdin, pipes, or devices. */
export async function readScenarioFile(path: string, cwd = process.cwd()): Promise<ScenarioDocument> {
if (typeof path !== "string" || path.length === 0 || path.length > 4_000 || path.includes("\0") || path === "-") {
throw new ScenarioValidationError("scenario path must be an explicit bounded file path");
}
let text: string;
try { text = await readBoundedRegularFile(resolve(cwd, path)); }
catch (error) {
if (error instanceof ScenarioValidationError) throw error;
throw new ScenarioValidationError("scenario file could not be read");
}
let value: unknown;
try { value = JSON.parse(text) as unknown; }
catch { throw new ScenarioValidationError("scenario file contains invalid JSON"); }
return parseScenarioDocument(value);
}
/** Load explicitly selected files and reject duplicate scenario identities. */
export async function loadScenarioFiles(paths: readonly string[], options: { cwd?: string } = {}): Promise<LoadedScenarios> {
if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_SCENARIO_FILES) {
throw new ScenarioValidationError(`provide 1–${MAX_SCENARIO_FILES} scenario files`);
}
const cwd = options.cwd ?? process.cwd();
const scenarios: LoadedScenario[] = [];
const ids = new Set<string>();
const files: string[] = [];
for (const path of paths) {
const document = await readScenarioFile(path, cwd);
const absolute = resolve(cwd, path);
files.push(absolute);
document.scenarios.forEach((scenario, index) => {
if (ids.has(scenario.id)) throw new ScenarioValidationError(`duplicate scenario id: ${scenario.id}`);
ids.add(scenario.id);
scenarios.push({ scenario, path: absolute, index });
});
}
if (scenarios.length > MAX_SCENARIOS_PER_FILE * MAX_SCENARIO_FILES) {
throw new ScenarioValidationError("scenario selection is too large");
}
return { files, scenarios };
}