mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
310 lines
9.3 KiB
JavaScript
310 lines
9.3 KiB
JavaScript
import { mkdir, stat } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { verifyTask } from "./oracle.mjs";
|
|
import { runProcess } from "./process.mjs";
|
|
import { taskUrl } from "./tasks.mjs";
|
|
|
|
function parseJsonOutput(step) {
|
|
try {
|
|
return JSON.parse(step.stdout);
|
|
} catch (error) {
|
|
throw new Error(`could not parse bsk JSON output: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function renderStepError(step) {
|
|
const raw = step.stderr.trim() || step.stdout.trim();
|
|
if (!raw) return `exit ${step.exitCode}`;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed.hint ? `${parsed.message} (hint: ${parsed.hint})` : parsed.message;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
async function hasNonemptyFile(path) {
|
|
try {
|
|
return (await stat(path)).size > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readPath(value, path) {
|
|
return path.split(".").reduce((current, part) => current?.[part], value);
|
|
}
|
|
|
|
function resolveValue(value, variables) {
|
|
if (typeof value !== "string") return value;
|
|
const exact = value.match(/^\{([^{}]+)}$/);
|
|
if (exact) {
|
|
const resolved = readPath(variables, exact[1]);
|
|
if (resolved === undefined) throw new Error(`unknown workflow variable ${exact[1]}`);
|
|
return resolved;
|
|
}
|
|
return value.replaceAll(/\{([^{}]+)\}/g, (_match, path) => {
|
|
const resolved = readPath(variables, path);
|
|
if (resolved === undefined) throw new Error(`unknown workflow variable ${path}`);
|
|
return String(resolved);
|
|
});
|
|
}
|
|
|
|
function targetArgs(step, variables) {
|
|
if (step.selector) return ["--selector", String(resolveValue(step.selector, variables))];
|
|
if (step.ref) return ["--ref", String(resolveValue(step.ref, variables))];
|
|
return [];
|
|
}
|
|
|
|
function tabArgs(step, variables) {
|
|
return step.tabId === undefined ? [] : ["--tab-id", String(resolveValue(step.tabId, variables))];
|
|
}
|
|
|
|
function matchesWhere(value, where = {}) {
|
|
return Object.entries(where).every(([path, expected]) => readPath(value, path) === expected);
|
|
}
|
|
|
|
export async function runBskSmokeTask({
|
|
bskCommand,
|
|
outputDirectory,
|
|
server,
|
|
serverInfo,
|
|
task,
|
|
runId,
|
|
seed = task.seed,
|
|
timeoutMs = 60_000,
|
|
}) {
|
|
const steps = [];
|
|
const evidence = {};
|
|
const variables = {
|
|
baseUrl: serverInfo.baseUrl,
|
|
runId,
|
|
seed: seed ?? "",
|
|
url: taskUrl(task, serverInfo.baseUrl, runId, seed),
|
|
};
|
|
let sessionId;
|
|
let executionError;
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
|
|
async function bsk(args, { timeout = timeoutMs } = {}) {
|
|
const execution = await runProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout });
|
|
steps.push(execution);
|
|
if (execution.exitCode !== 0 || execution.timedOut || execution.error) {
|
|
throw new Error(
|
|
`bsk ${args.join(" ")} failed: ${execution.error ?? renderStepError(execution)}`,
|
|
);
|
|
}
|
|
return parseJsonOutput(execution);
|
|
}
|
|
|
|
async function waitForSiteEvent(step) {
|
|
const timeout = step.timeoutMs ?? 3_000;
|
|
const deadline = Date.now() + timeout;
|
|
while (Date.now() < deadline) {
|
|
if (
|
|
server
|
|
.snapshot(runId)
|
|
.events.some(
|
|
(event) => event.type === step.type && matchesWhere(event.data ?? {}, step.where ?? {}),
|
|
)
|
|
) {
|
|
return { observed: true };
|
|
}
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
|
|
}
|
|
throw new Error(`local fixture did not report ${step.type} within ${timeout}ms`);
|
|
}
|
|
|
|
async function executeWorkflowStep(step) {
|
|
const session = ["--session", sessionId];
|
|
let result;
|
|
switch (step.action) {
|
|
case "navigate":
|
|
result = await bsk([
|
|
"navigate",
|
|
String(resolveValue(step.url ?? "{url}", variables)),
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
]);
|
|
break;
|
|
case "back":
|
|
case "forward":
|
|
result = await bsk([`navigate-${step.action}`, ...session, ...tabArgs(step, variables)]);
|
|
break;
|
|
case "reload":
|
|
result = await bsk(["reload", ...session, ...tabArgs(step, variables)]);
|
|
break;
|
|
case "wait":
|
|
result = await bsk(["wait-ms", String(resolveValue(step.duration, variables))]);
|
|
break;
|
|
case "wait-site-event":
|
|
result = await waitForSiteEvent(step);
|
|
break;
|
|
case "observe":
|
|
case "snapshot":
|
|
case "html": {
|
|
const command = step.action === "html" ? "get-html" : step.action;
|
|
result = await bsk([command, ...session, ...tabArgs(step, variables)]);
|
|
break;
|
|
}
|
|
case "screenshot": {
|
|
const name = resolveValue(step.artifactName ?? "screenshot.png", variables);
|
|
const path = join(outputDirectory, `${runId}-${name}`);
|
|
result = await bsk(["screenshot", ...session, ...tabArgs(step, variables), "--out", path]);
|
|
if (step.evidence) evidence[step.evidence] = await hasNonemptyFile(path);
|
|
break;
|
|
}
|
|
case "console":
|
|
case "network":
|
|
result = await bsk([step.action, ...session, ...tabArgs(step, variables)]);
|
|
break;
|
|
case "click":
|
|
case "hover":
|
|
result = await bsk([
|
|
step.action,
|
|
...targetArgs(step, variables),
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
]);
|
|
break;
|
|
case "fill":
|
|
result = await bsk([
|
|
"fill",
|
|
...targetArgs(step, variables),
|
|
"--value",
|
|
String(resolveValue(step.value, variables)),
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
]);
|
|
break;
|
|
case "select":
|
|
result = await bsk([
|
|
"select",
|
|
...targetArgs(step, variables),
|
|
...step.values.flatMap((value) => ["--value", String(resolveValue(value, variables))]),
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
]);
|
|
break;
|
|
case "press":
|
|
result = await bsk([
|
|
"press",
|
|
String(resolveValue(step.key, variables)),
|
|
...targetArgs(step, variables),
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
]);
|
|
break;
|
|
case "tab-list":
|
|
result = await bsk(["tab", "list", ...session]);
|
|
break;
|
|
case "tab-create":
|
|
result = await bsk([
|
|
"tab",
|
|
"create",
|
|
...session,
|
|
...(step.url ? ["--url", String(resolveValue(step.url, variables))] : []),
|
|
]);
|
|
break;
|
|
case "tab-select":
|
|
case "tab-close":
|
|
result = await bsk([
|
|
"tab",
|
|
step.action.slice(4),
|
|
String(resolveValue(step.tabId, variables)),
|
|
...session,
|
|
]);
|
|
break;
|
|
case "borrow":
|
|
case "return":
|
|
result = await bsk([
|
|
"tab",
|
|
step.action,
|
|
String(resolveValue(step.tabId, variables)),
|
|
...session,
|
|
]);
|
|
break;
|
|
case "resize":
|
|
result = await bsk([
|
|
"window",
|
|
"resize",
|
|
...session,
|
|
"--width",
|
|
String(step.width),
|
|
"--height",
|
|
String(step.height),
|
|
]);
|
|
break;
|
|
case "emulate":
|
|
result = await bsk([
|
|
"emulate",
|
|
...session,
|
|
...tabArgs(step, variables),
|
|
"--device",
|
|
String(resolveValue(step.device, variables)),
|
|
]);
|
|
break;
|
|
case "request-help":
|
|
result = await bsk([
|
|
"request-help",
|
|
...session,
|
|
"--prompt",
|
|
String(resolveValue(step.prompt, variables)),
|
|
...(step.title ? ["--title", String(resolveValue(step.title, variables))] : []),
|
|
...(step.timeout ? ["--timeout", String(step.timeout)] : []),
|
|
]);
|
|
break;
|
|
default:
|
|
throw new Error(`unsupported smoke workflow action ${step.action}`);
|
|
}
|
|
if (step.saveAs) variables[step.saveAs] = result;
|
|
if (step.evidence && step.action !== "screenshot") evidence[step.evidence] = true;
|
|
}
|
|
|
|
server.reset(runId);
|
|
try {
|
|
if (task.smokeSteps.length === 0) throw new Error(`case ${task.id} has no smoke workflow`);
|
|
const started = await bsk(["session", "start", "--no-focus"], { timeout: 90_000 });
|
|
sessionId = started.session_id;
|
|
variables.sessionId = sessionId;
|
|
await bsk(["session", "list"]);
|
|
for (const step of task.smokeSteps) await executeWorkflowStep(step);
|
|
} catch (error) {
|
|
executionError = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
if (sessionId) {
|
|
try {
|
|
const stopped = await bsk(["session", "stop", sessionId], { timeout: 90_000 });
|
|
evidence.sessionStopped =
|
|
stopped.stopped?.includes(sessionId) === true && (stopped.failed?.length ?? 0) === 0;
|
|
if (!evidence.sessionStopped) {
|
|
executionError ??= `bsk did not confirm that session ${sessionId} stopped`;
|
|
}
|
|
} catch (error) {
|
|
evidence.sessionStopped = false;
|
|
executionError ??= error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 350));
|
|
const responseText = steps.map(({ stdout }) => stdout).join("\n");
|
|
const events = server.snapshot(runId).events;
|
|
return {
|
|
runId,
|
|
caseId: task.id,
|
|
taskId: task.id,
|
|
suite: task.suite,
|
|
tags: task.tags,
|
|
seed,
|
|
adapter: "bsk-direct",
|
|
variant: "cli-direct",
|
|
executionError,
|
|
steps,
|
|
evidence,
|
|
eventCount: events.length,
|
|
verification: verifyTask(task, { events, responseText, adapterEvidence: evidence }),
|
|
};
|
|
}
|