Files
rea/src/domain/browserScenarioValues.ts

551 lines
17 KiB
TypeScript

import { z } from "zod";
import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
import { jsonValueSchema } from "./jsonValue.js";
import { absoluteLocalPathStringSchema } from "./localPath.js";
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js";
import {
browserScenarioCaptureLimitsSchema,
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
} from "./browserScenarioCaptureLimits.js";
import {
browserEndpointSchema,
browserOriginSchema,
} from "./browserObservation.js";
export const scenarioIdentifierSchema = z.string().regex(IDENTIFIER_PATTERN);
/**
* Step identity the provider owns for the initial-state step. Callers cannot
* claim it, because two capture steps with one identity cannot be aligned.
*/
export const BROWSER_SCENARIO_START_STEP_ID = "scenario_start";
const browserScenarioBaseUrlSchema = z
.string()
.min(1)
.transform((value, context) => {
let url: URL;
try {
url = new URL(value);
} catch (cause: unknown) {
// Invalid input is reported through the zod issue.
void cause;
context.addIssue({ code: "custom", message: "Invalid browser URL" });
return z.NEVER;
}
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username !== "" ||
url.password !== ""
) {
context.addIssue({
code: "custom",
message: "Browser URLs must be HTTP(S) and omit credentials",
});
return z.NEVER;
}
return url.toString();
});
export const browserScenarioValueSchema = z.discriminatedUnion("source", [
z.strictObject({
source: z.literal("literal"),
value: z.string(),
}),
z.strictObject({
source: z.literal("secret"),
secret_id: scenarioIdentifierSchema,
}),
]);
export type BrowserScenarioValue = z.infer<typeof browserScenarioValueSchema>;
const queryEntrySchema = z.strictObject({
name: z.string().min(1),
value: browserScenarioValueSchema,
});
/** HTTP methods a request step may send. */
export const BROWSER_SCENARIO_REQUEST_METHODS = [
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
] as const;
/** Header names a page's fetch cannot set (and User-Agent, which Chrome drops), so a request step refuses them rather than having them dropped. */
const FORBIDDEN_REQUEST_HEADERS = new Set([
"accept-charset",
"accept-encoding",
"access-control-request-headers",
"access-control-request-method",
"connection",
"content-length",
"cookie",
"cookie2",
"date",
"dnt",
"expect",
"host",
"keep-alive",
"origin",
"referer",
"set-cookie",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"user-agent",
"via",
]);
/** Characters an HTTP field value may carry: visible ASCII, space, tab and obs-text. */
export const HEADER_FIELD_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/u;
const settableRequestHeader = (name: string): boolean => {
const lower = name.toLowerCase();
return (
!FORBIDDEN_REQUEST_HEADERS.has(lower) &&
!lower.startsWith("proxy-") &&
!lower.startsWith("sec-")
);
};
/** A header value a request step reads from the page as it runs; it is redacted like a declared secret. */
const browserScenarioPageValueSchemas = [
z.strictObject({
source: z.literal("cookie"),
name: z
.string()
.min(1)
.describe("Cookie the browser holds for the request URL."),
}),
z.strictObject({
source: z.literal("local_storage"),
key: z.string().min(1).describe("Local storage key on the start origin."),
json_pointer: z
.string()
.regex(/^(?:\/.*)?$/u)
.optional()
.describe(
"Parse the stored value as JSON and use the string or number at this pointer; empty selects the whole value.",
),
}),
] as const;
/** A path on the start origin: one leading slash, never `//` or `/\`. Written without look-around or reserved class syntax, so every JSON Schema regex dialect reads it. */
const START_ORIGIN_PATH = /^\x2F(?:[^\x2F\x5C]|$)/u;
/** `{saved:name}` as written, which sends a saved value in a request path or a JSON body string. */
export const SAVED_PLACEHOLDER = /\{saved:([A-Za-z][A-Za-z0-9._-]*)\}/gu;
/**
* `{saved:name}` in a goto destination's URL, which the scenario schema parsed, so it may spell the braces and the colon
* percent-encoded. Other text keeps an encoded spelling as written.
*/
export const SAVED_URL_PLACEHOLDER =
/(?:\{|%7[Bb])saved(?::|%3[Aa])([A-Za-z][A-Za-z0-9._-]*)(?:\}|%7[Dd])/gu;
/** The saved value names a text's placeholders send, as `placeholder` spells them. */
export const savedPlaceholderNames = (
text: string,
placeholder: RegExp = SAVED_PLACEHOLDER,
): readonly string[] =>
[...text.matchAll(placeholder)].map(([, name]) => name!);
/** A value an earlier step saved from a JSON response. */
const savedValueSchema = z.strictObject({
source: z.literal("saved"),
name: scenarioIdentifierSchema,
});
/** A literal, declared secret or saved value a request step sends. */
export const browserScenarioRequestValueSchema = z.discriminatedUnion(
"source",
[...browserScenarioValueSchema.options, savedValueSchema],
);
export type BrowserScenarioRequestValue = z.infer<
typeof browserScenarioRequestValueSchema
>;
/**
* A part of Basic credentials: a literal, declared secret or saved value, or a value read from the page, as a frontend
* builds such a header from a login token. The page values carry no descriptions here, since each part repeats them.
*/
const basicCredentialPartSchema = z.discriminatedUnion("source", [
...browserScenarioRequestValueSchema.options,
z.strictObject({ source: z.literal("cookie"), name: z.string().min(1) }),
z.strictObject({
source: z.literal("local_storage"),
key: z.string().min(1),
json_pointer: z
.string()
.regex(/^(?:\/.*)?$/u)
.optional(),
}),
]);
export type BrowserScenarioCredentialPart = z.infer<
typeof basicCredentialPartSchema
>;
/** Basic auth uses the first colon as the username/password separator. */
export const validBasicCredentialUsername = (username: string): boolean =>
!username.includes(":");
/** A username and password sent as the base64 of `username:password`, as HTTP Basic auth spells them; redacted like a declared secret. */
const basicCredentialsSchema = z.strictObject({
source: z.literal("basic_credentials"),
parts: z
.array(basicCredentialPartSchema)
.length(2)
.describe("Username, then password."),
});
/** A string or number a step keeps from a JSON response, by name, for later steps. */
const saveEntrySchema = z.strictObject({
name: scenarioIdentifierSchema,
json_pointer: z.string().regex(/^(?:\/.*)?$/u),
secret: z.boolean().default(false),
});
export type BrowserScenarioSaveEntry = z.infer<typeof saveEntrySchema>;
/** Values a UI step keeps from a newly observed matching response; matching does not prove causality. */
const saveResponseSchema = z
.strictObject({
method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS),
path: z
.string()
.regex(START_ORIGIN_PATH)
.describe("`{*}` matches one segment; the query is ignored."),
save: z.array(saveEntrySchema).min(1),
})
.describe(
"Save values from a newly observed matching 2xx JSON response; matching does not prove the UI caused it. Multiple observed matches fail.",
);
export type BrowserScenarioSaveResponse = z.infer<typeof saveResponseSchema>;
const requestHeaderSchema = z.strictObject({
name: z
.string()
// Hex escapes retain HTTP token characters in Unicode-set client regexes.
.regex(/^[!#$%&'*+.^_`\x7C~0-9A-Za-z\x2D]+$/u)
.refine(settableRequestHeader, {
message: "A page's fetch cannot set this header",
}),
value: z.discriminatedUnion("source", [
...browserScenarioValueSchema.options,
...browserScenarioPageValueSchemas,
savedValueSchema,
basicCredentialsSchema,
]),
prefix: z
.string()
.regex(HEADER_FIELD_VALUE, {
message: "A header value cannot carry line breaks or control characters",
})
.optional()
.describe("Text sent before the value, such as `Bearer `."),
});
export type BrowserScenarioRequestHeader = z.infer<typeof requestHeaderSchema>;
/** URL with ordinary inline details and optional structured secret query references. */
export const browserScenarioUrlSchema = z.strictObject({
url: browserScenarioBaseUrlSchema,
query: z.array(queryEntrySchema).default([]),
});
export type BrowserScenarioUrl = z.infer<typeof browserScenarioUrlSchema>;
export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [
z.strictObject({
mode: z.literal("launch"),
executable_path: absoluteLocalPathStringSchema.describe(
"Absolute local filesystem path for the browser executable; relative paths are rejected.",
),
headless: z
.boolean()
.default(true)
.describe(
"Run without a visible browser window; defaults to true and can be disabled when the host supports a display.",
),
}),
z.strictObject({
mode: z.literal("connect"),
cdp_endpoint: browserEndpointSchema,
target_id: z.string().min(1),
}),
]);
export const browserScenarioEnvironmentSchema = z
.strictObject({
viewport: z
.strictObject({
width: z.int().positive().default(1_280),
height: z.int().positive().default(720),
device_scale_factor: z.number().positive().default(1),
})
.default({ width: 1_280, height: 720, device_scale_factor: 1 }),
locale: z
.string()
.min(2)
.regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u)
.default("en-US")
.describe(
"Formatting locale. Launch also sets navigator.language(s) and Accept-Language; connect retains the caller's page and request languages and reports this limitation.",
),
timezone: z
.string()
.min(1)
.regex(/^[A-Za-z0-9_+\x2d]+(?:\x2f[A-Za-z0-9_+\x2d]+)*$/u)
.default("UTC"),
color_scheme: z.enum(["light", "dark", "no-preference"]).default("light"),
reduced_motion: z.enum(["reduce", "no-preference"]).default("reduce"),
service_workers: z.enum(["allow", "block"]).default("block"),
})
.default({
viewport: { width: 1_280, height: 720, device_scale_factor: 1 },
locale: "en-US",
timezone: "UTC",
color_scheme: "light",
reduced_motion: "reduce",
service_workers: "block",
})
.describe(
"Optional deterministic browser settings. Defaults to 1280x720, en-US, UTC, light mode, reduced motion, and blocked service workers.",
);
const locatorSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("test_id"),
value: z.string().min(1),
}),
z.strictObject({
kind: z.literal("role"),
role: z.enum([
"button",
"checkbox",
"combobox",
"dialog",
"link",
"listbox",
"menuitem",
"option",
"radio",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
]),
name: z.string().min(1),
}),
z.strictObject({
kind: z.literal("css"),
selector: z.string().min(1),
}),
]);
// Playwright forwards these intervals to Node timers.
const MAX_BROWSER_SCENARIO_TIMEOUT_MS = 2_147_483_647;
const stepBase = {
step_id: scenarioIdentifierSchema,
timeout_ms: z
.int()
.min(1)
.max(MAX_BROWSER_SCENARIO_TIMEOUT_MS)
.optional()
.describe(
"Optional action deadline in milliseconds; omitted means wait until completion or request cancellation.",
),
};
export const browserScenarioActionSchema = z.discriminatedUnion("action", [
z.strictObject({
...stepBase,
action: z.literal("goto"),
destination: browserScenarioUrlSchema,
wait_until: z.enum(["commit", "domcontentloaded", "load"]),
}),
z.strictObject({
...stepBase,
action: z.literal("click"),
locator: locatorSchema,
button: z.enum(["left", "middle", "right"]).default("left"),
click_count: z.int().positive().default(1),
save_response: saveResponseSchema.optional(),
}),
z.strictObject({
...stepBase,
action: z.literal("fill"),
locator: locatorSchema,
value: browserScenarioValueSchema,
}),
z.strictObject({
...stepBase,
action: z.literal("press"),
locator: locatorSchema,
key: z.string().min(1),
save_response: saveResponseSchema.optional(),
}),
z.strictObject({
...stepBase,
action: z.literal("select_option"),
locator: locatorSchema,
value: browserScenarioValueSchema,
}),
z.strictObject({
...stepBase,
action: z.enum(["check", "uncheck"]),
locator: locatorSchema,
}),
z.strictObject({
...stepBase,
action: z.literal("wait_for"),
locator: locatorSchema,
state: z.enum(["attached", "detached", "visible", "hidden"]),
}),
z.strictObject({
...stepBase,
action: z.literal("request"),
method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS),
path: z
.string()
.regex(START_ORIGIN_PATH)
.describe(
"Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.",
),
query: z.array(queryEntrySchema).default([]),
headers: z.array(requestHeaderSchema).default([]),
json: jsonValueSchema
.optional()
.describe(
"JSON request body, sent with Content-Type application/json unless a header sets one; not allowed for GET or HEAD.",
),
save: z
.array(saveEntrySchema)
.default([])
.describe(
"Values to save from the JSON response. Later steps send one as `{saved:name}` in a request path, goto URL or JSON string, or as a header value.",
),
expected_status: z
.array(
z
.int()
.min(100)
.max(599)
.refine((status) => status < 300 || status > 399, {
message:
"Request steps follow no redirects, so a 3xx status cannot complete one",
}),
)
.min(1)
.optional()
.describe(
"Statuses that complete the step; any other status fails it. Omitted means any response completes it.",
),
}),
z.strictObject({
step_id: scenarioIdentifierSchema,
action: z.literal("wait_for_timeout"),
duration_ms: z
.int()
.min(1)
.max(MAX_BROWSER_SCENARIO_TIMEOUT_MS)
.describe(
"Wait this many milliseconds; the wait ends early on cancellation.",
),
}),
]);
export type BrowserScenarioAction = z.infer<typeof browserScenarioActionSchema>;
const storageEntrySchema = z.strictObject({
name: z.string().min(1),
value: browserScenarioValueSchema,
});
export const browserScenarioStorageSchema = z
.strictObject({
cookies: z
.array(
z.strictObject({
name: z.string().min(1),
value: browserScenarioValueSchema,
destination: browserScenarioUrlSchema,
http_only: z.boolean(),
secure: z.boolean(),
same_site: z.enum(["Strict", "Lax", "None"]),
}),
)
.default([]),
local_storage: z
.array(
z.strictObject({
origin: browserOriginSchema,
entries: z.array(storageEntrySchema),
}),
)
.default([]),
session_storage: z
.array(
z.strictObject({
origin: browserOriginSchema,
entries: z.array(storageEntrySchema),
}),
)
.default([]),
})
.default({ cookies: [], local_storage: [], session_storage: [] })
.describe(
"Optional initial cookies, local storage, or session storage. Defaults to empty; every supplied value and origin must be declared and within the allowed origins.",
);
export const browserScenarioSecretSchema = z.strictObject({
secret_id: scenarioIdentifierSchema,
environment_variable: z
.string()
.min(1)
.refine(
(name) => !name.includes("=") && !name.includes("\0"),
"Environment variable names cannot contain '=' or NUL",
),
});
const snapshotKindSchema = z.enum([
"screenshot",
"dom",
"accessibility",
"url",
"history",
"storage",
]);
export const browserScenarioCaptureSchema = z
.strictObject({
after_each_step: z.array(snapshotKindSchema).default([]),
at_end: z.array(snapshotKindSchema).default(["url"]),
events: z.array(browserScenarioEventFamilySchema).default([]),
network: browserNetworkContentSelectionSchema,
limits: browserScenarioCaptureLimitsSchema.default(
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
),
})
.default({
after_each_step: [],
at_end: ["url"],
events: [],
network: {
request_body: false,
response_body: false,
header_values: false,
},
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
})
.describe(
"Optional retained artifacts and event families. Defaults to only a final sanitized URL; request screenshots, DOM, accessibility, history, storage, and event capture explicitly.",
);