mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
217 lines
8.1 KiB
JavaScript
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);
|
|
}
|