mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
test: add extensible browser evaluation harness
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user