Files
BrowserSkill/evals/browser/lib/case-loader.mjs
T

217 lines
8.1 KiB
JavaScript

import { readFile } from "node:fs/promises";
import { discoverFiles } from "./discovery.mjs";
import { OPERATION_NAMES, WORKFLOW_ACTIONS } from "./operations.mjs";
import { casesDirectory as defaultCasesDirectory } from "./paths.mjs";
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const EVIDENCE_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/;
const TOP_LEVEL_KEYS = new Set([
"$schema",
"schemaVersion",
"id",
"title",
"order",
"suite",
"tags",
"seed",
"source",
"fixture",
"prompts",
"coverage",
"assertions",
"smoke",
]);
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function checkString(errors, value, path) {
if (typeof value !== "string" || value.trim() === "") errors.push(`${path} must be a string`);
}
function validateAssertion(errors, assertion, path, kind) {
if (!isObject(assertion)) {
errors.push(`${path} must be an object`);
return;
}
checkString(errors, assertion.label, `${path}.label`);
if (kind === "site") {
checkString(errors, assertion.type, `${path}.type`);
if (!Number.isInteger(assertion.minCount) || assertion.minCount < 1) {
errors.push(`${path}.minCount must be a positive integer`);
}
if (assertion.where !== undefined && !isObject(assertion.where)) {
errors.push(`${path}.where must be an object`);
}
} else if (kind === "response") {
checkString(errors, assertion.includes, `${path}.includes`);
} else {
checkString(errors, assertion.key, `${path}.key`);
}
}
function validateWorkflowStep(errors, step, path) {
if (!isObject(step)) {
errors.push(`${path} must be an object`);
return;
}
if (!WORKFLOW_ACTIONS.has(step.action)) {
errors.push(`${path}.action is unsupported: ${JSON.stringify(step.action)}`);
}
if (step.saveAs !== undefined && !ID_PATTERN.test(step.saveAs)) {
errors.push(`${path}.saveAs must use lowercase kebab-case`);
}
if (step.evidence !== undefined && !EVIDENCE_PATTERN.test(step.evidence)) {
errors.push(`${path}.evidence must be an identifier`);
}
if (["click", "hover", "fill", "select"].includes(step.action) && !step.selector && !step.ref) {
errors.push(`${path} requires selector or ref`);
}
if (step.action === "fill") checkString(errors, step.value, `${path}.value`);
if (step.action === "select") {
if (!Array.isArray(step.values) || step.values.length === 0) {
errors.push(`${path}.values must be a non-empty array`);
} else if (step.values.some((value) => typeof value !== "string")) {
errors.push(`${path}.values must contain only strings`);
}
}
if (step.action === "press") checkString(errors, step.key, `${path}.key`);
if (step.action === "wait") checkString(errors, step.duration, `${path}.duration`);
if (step.action === "wait-site-event") checkString(errors, step.type, `${path}.type`);
if (["tab-select", "tab-close", "borrow", "return"].includes(step.action)) {
checkString(errors, step.tabId, `${path}.tabId`);
}
if (step.action === "resize") {
if (
!Number.isInteger(step.width) ||
step.width < 1 ||
!Number.isInteger(step.height) ||
step.height < 1
) {
errors.push(`${path}.width and height must be positive integers`);
}
}
if (step.action === "emulate") checkString(errors, step.device, `${path}.device`);
if (step.action === "request-help") checkString(errors, step.prompt, `${path}.prompt`);
if (step.timeoutMs !== undefined && (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1)) {
errors.push(`${path}.timeoutMs must be a positive integer`);
}
}
export function validateCaseManifest(manifest, { source = "case manifest" } = {}) {
const errors = [];
if (!isObject(manifest)) return [`${source} must contain a JSON object`];
for (const key of Object.keys(manifest)) {
if (!TOP_LEVEL_KEYS.has(key)) errors.push(`unknown top-level property ${key}`);
}
if (manifest.schemaVersion !== 1) errors.push("schemaVersion must equal 1");
if (!ID_PATTERN.test(manifest.id ?? "")) errors.push("id must use lowercase kebab-case");
checkString(errors, manifest.title, "title");
if (manifest.order !== undefined && (!Number.isInteger(manifest.order) || manifest.order < 0)) {
errors.push("order must be a non-negative integer");
}
if (!ID_PATTERN.test(manifest.suite ?? "")) errors.push("suite must use lowercase kebab-case");
if (!Array.isArray(manifest.tags) || manifest.tags.some((tag) => !ID_PATTERN.test(tag))) {
errors.push("tags must be an array of lowercase kebab-case strings");
} else if (new Set(manifest.tags).size !== manifest.tags.length) {
errors.push("tags must not contain duplicates");
}
if (!isObject(manifest.fixture)) errors.push("fixture must be an object");
else if (
typeof manifest.fixture.startPath !== "string" ||
!manifest.fixture.startPath.startsWith("/")
) {
errors.push("fixture.startPath must be an absolute URL path");
}
if (!isObject(manifest.prompts)) errors.push("prompts must be an object");
else {
checkString(errors, manifest.prompts.en, "prompts.en");
checkString(errors, manifest.prompts["zh-CN"], "prompts.zh-CN");
}
if (!Array.isArray(manifest.coverage)) errors.push("coverage must be an array");
else {
for (const operation of manifest.coverage) {
if (!OPERATION_NAMES.has(operation))
errors.push(`coverage contains unknown operation ${operation}`);
}
if (new Set(manifest.coverage).size !== manifest.coverage.length) {
errors.push("coverage must not contain duplicates");
}
}
if (!isObject(manifest.assertions)) errors.push("assertions must be an object");
else {
for (const kind of ["site", "response", "adapter"]) {
if (!Array.isArray(manifest.assertions[kind])) {
errors.push(`assertions.${kind} must be an array`);
} else {
manifest.assertions[kind].forEach((assertion, index) =>
validateAssertion(errors, assertion, `assertions.${kind}[${index}]`, kind),
);
}
}
}
if (!isObject(manifest.smoke) || !Array.isArray(manifest.smoke.steps)) {
errors.push("smoke.steps must be an array");
} else {
manifest.smoke.steps.forEach((step, index) =>
validateWorkflowStep(errors, step, `smoke.steps[${index}]`),
);
}
if (manifest.seed !== undefined && !Number.isSafeInteger(manifest.seed)) {
errors.push("seed must be a safe integer");
}
if (manifest.source !== undefined && !isObject(manifest.source)) {
errors.push("source must be an object");
}
return errors.map((error) => `${source}: ${error}`);
}
function normalizeCase(manifest, sourceFile) {
return Object.freeze({
...manifest,
startPath: manifest.fixture.startPath,
siteAssertions: manifest.assertions.site,
responseAssertions: manifest.assertions.response,
adapterAssertions: manifest.assertions.adapter,
smokeSteps: manifest.smoke.steps,
sourceFile,
});
}
export async function loadCaseManifests({ casesDirectory = defaultCasesDirectory } = {}) {
const files = await discoverFiles(casesDirectory, (path) => path.endsWith(".case.json"));
const cases = [];
const ids = new Map();
const errors = [];
for (const file of files) {
let manifest;
try {
manifest = JSON.parse(await readFile(file, "utf8"));
} catch (error) {
errors.push(`${file}: invalid JSON: ${error.message}`);
continue;
}
const manifestErrors = validateCaseManifest(manifest, { source: file });
errors.push(...manifestErrors);
if (typeof manifest?.id === "string") {
if (ids.has(manifest.id)) {
errors.push(`${file}: duplicate id also declared by ${ids.get(manifest.id)}`);
} else {
ids.set(manifest.id, file);
}
}
if (manifestErrors.length === 0) cases.push(normalizeCase(manifest, file));
}
if (files.length === 0) errors.push(`${casesDirectory}: no .case.json files found`);
if (errors.length > 0) throw new Error(`case validation failed:\n${errors.join("\n")}`);
cases.sort(
(left, right) =>
left.suite.localeCompare(right.suite) ||
(left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER) ||
left.id.localeCompare(right.id),
);
return Object.freeze(cases);
}