fast-browser-with-jev
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s

This commit is contained in:
jb
2026-09-20 12:24:08 -04:00
parent fd0edb1511
commit fd63b0ccec
73 changed files with 21212 additions and 2 deletions
+645
View File
@@ -0,0 +1,645 @@
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 };
}