646 lines
33 KiB
TypeScript
646 lines
33 KiB
TypeScript
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 };
|
||
}
|