Files
openclaw/extensions/qa-lab/src/scenario-flow-runner.ts
2026-04-08 11:56:02 +01:00

298 lines
9.2 KiB
TypeScript

import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { QaBusState } from "./bus-state.js";
import type { QaScenarioFlow, QaSeedScenarioWithSource } from "./scenario-catalog.js";
type QaSuiteStep = {
name: string;
run: () => Promise<string | void>;
};
type QaSuiteScenarioResult = {
name: string;
status: "pass" | "fail";
steps: Array<{
name: string;
status: "pass" | "fail" | "skip";
details?: string;
}>;
details?: string;
};
type QaFlowApi = Record<string, unknown> & {
state: QaBusState;
scenario: QaSeedScenarioWithSource;
config: Record<string, unknown>;
runScenario: (name: string, steps: QaSuiteStep[]) => Promise<QaSuiteScenarioResult>;
};
type QaFlowVars = Record<string, unknown>;
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new (
...args: string[]
) => (...fnArgs: unknown[]) => Promise<unknown>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatFlowDetails(details: unknown) {
if (details === undefined) {
return undefined;
}
if (typeof details === "string") {
return details;
}
if (typeof details === "number" || typeof details === "boolean" || typeof details === "bigint") {
return String(details);
}
return JSON.stringify(details, null, 2);
}
function getPathWithParent(
root: Record<string, unknown>,
ref: string,
): { parent: Record<string, unknown> | null; value: unknown } {
const parts = ref.split(".").filter(Boolean);
let current: unknown = root;
let parent: Record<string, unknown> | null = null;
for (const part of parts) {
if (!isPlainObject(current)) {
return { parent: null, value: undefined };
}
parent = current;
current = current[part];
}
return { parent, value: current };
}
function createEvalContext(api: QaFlowApi, vars: QaFlowVars) {
return {
...api,
vars,
...vars,
};
}
async function evalExpr(expr: string, api: QaFlowApi, vars: QaFlowVars) {
const context = createEvalContext(api, vars);
const names = Object.keys(context);
const values = Object.values(context);
const fn = new AsyncFunction(...names, `return (${expr});`);
return await fn(...values);
}
function buildLambda(
spec: { params?: string[]; expr: string; async?: boolean },
api: QaFlowApi,
vars: QaFlowVars,
) {
const context = createEvalContext(api, vars);
const names = Object.keys(context);
const values = Object.values(context);
const params = spec.params ?? [];
const Factory = spec.async ? AsyncFunction : Function;
const fn = new Factory(...names, ...params, `return (${spec.expr});`) as (
...fnArgs: unknown[]
) => unknown;
return (...lambdaArgs: unknown[]) => fn(...values, ...lambdaArgs);
}
async function resolveValue(node: unknown, api: QaFlowApi, vars: QaFlowVars): Promise<unknown> {
if (Array.isArray(node)) {
return await Promise.all(node.map((entry) => resolveValue(entry, api, vars)));
}
if (!isPlainObject(node)) {
return node;
}
const keys = Object.keys(node);
if (keys.length === 1 && typeof node.ref === "string") {
return getPathWithParent(createEvalContext(api, vars), node.ref).value;
}
if (keys.length === 1 && typeof node.expr === "string") {
return await evalExpr(node.expr, api, vars);
}
if (keys.length === 1 && isPlainObject(node.lambda) && typeof node.lambda.expr === "string") {
return buildLambda(
{
expr: node.lambda.expr,
params: Array.isArray(node.lambda.params)
? node.lambda.params.filter((entry): entry is string => typeof entry === "string")
: [],
async: node.lambda.async === true,
},
api,
vars,
);
}
const entries = await Promise.all(
Object.entries(node).map(async ([key, value]) => [key, await resolveValue(value, api, vars)]),
);
return Object.fromEntries(entries);
}
function resolveCallable(path: string, api: QaFlowApi, vars: QaFlowVars) {
const { parent, value } = getPathWithParent(createEvalContext(api, vars), path);
if (typeof value !== "function") {
throw new Error(`qa flow callable not found: ${path}`);
}
return parent ? value.bind(parent) : value;
}
async function runFlowAction(action: unknown, api: QaFlowApi, vars: QaFlowVars) {
if (!isPlainObject(action)) {
throw new Error(`invalid qa flow action: ${JSON.stringify(action)}`);
}
if (typeof action.call === "string") {
const callable = resolveCallable(action.call, api, vars);
const args = Array.isArray(action.args)
? await Promise.all(action.args.map((entry) => resolveValue(entry, api, vars)))
: [];
const result = await callable(...args);
if (typeof action.saveAs === "string" && action.saveAs.trim()) {
vars[action.saveAs.trim()] = result;
}
return;
}
if (typeof action.set === "string") {
vars[action.set] = await resolveValue(action.value, api, vars);
return;
}
if (typeof action.assert === "string" || isPlainObject(action.assert)) {
const spec =
typeof action.assert === "string"
? { expr: action.assert, message: undefined }
: {
expr: typeof action.assert.expr === "string" ? action.assert.expr : "",
message: action.assert.message,
};
if (!spec.expr) {
throw new Error(`invalid qa flow assertion: ${JSON.stringify(action.assert)}`);
}
const passed = Boolean(await evalExpr(spec.expr, api, vars));
if (!passed) {
const message =
spec.message === undefined ? undefined : await resolveValue(spec.message, api, vars);
throw new Error(
typeof message === "string" && message.trim()
? message
: `qa flow assertion failed: ${spec.expr}`,
);
}
return;
}
if (typeof action.throw === "string" || isPlainObject(action.throw)) {
const spec =
typeof action.throw === "string"
? { expr: undefined, message: action.throw }
: {
expr: typeof action.throw.expr === "string" ? action.throw.expr : undefined,
message: action.throw.message,
};
const evaluated = spec.expr ? await evalExpr(spec.expr, api, vars) : undefined;
const message =
spec.message === undefined ? undefined : await resolveValue(spec.message, api, vars);
if (evaluated instanceof Error) {
throw evaluated;
}
if (typeof evaluated === "string" && evaluated.trim()) {
throw new Error(evaluated);
}
if (typeof message === "string" && message.trim()) {
throw new Error(message);
}
throw new Error("qa flow throw");
}
if (isPlainObject(action.if)) {
const ifAction = action.if as { expr: string; then: unknown[]; else?: unknown[] };
const passed = Boolean(await evalExpr(ifAction.expr, api, vars));
const branch = passed ? ifAction.then : (ifAction.else ?? []);
for (const nested of branch) {
await runFlowAction(nested, api, vars);
}
return;
}
if (isPlainObject(action.forEach)) {
const forEachAction = action.forEach as {
items: unknown;
item: string;
index?: string;
actions: unknown[];
};
const items = await resolveValue(forEachAction.items, api, vars);
if (!Array.isArray(items)) {
throw new Error(`qa flow forEach items must resolve to array: ${JSON.stringify(items)}`);
}
for (const [index, item] of items.entries()) {
vars[forEachAction.item] = item;
if (forEachAction.index) {
vars[forEachAction.index] = index;
}
for (const nested of forEachAction.actions) {
await runFlowAction(nested, api, vars);
}
}
return;
}
if (isPlainObject(action.try)) {
const tryAction = action.try as {
actions: unknown[];
catchAs?: string;
catch?: unknown[];
finally?: unknown[];
};
try {
for (const nested of tryAction.actions) {
await runFlowAction(nested, api, vars);
}
} catch (error) {
if (!tryAction.catch && !tryAction.finally) {
throw error;
}
if (tryAction.catchAs) {
vars[tryAction.catchAs] = error;
}
if (tryAction.catch) {
for (const nested of tryAction.catch) {
await runFlowAction(nested, api, vars);
}
} else {
throw error;
}
} finally {
if (tryAction.finally) {
for (const nested of tryAction.finally) {
await runFlowAction(nested, api, vars);
}
}
}
return;
}
throw new Error(`unknown qa flow action: ${JSON.stringify(action)}`);
}
export async function runScenarioFlow(params: {
api: QaFlowApi;
flow: QaScenarioFlow;
scenarioTitle: string;
}) {
const vars: QaFlowVars = {};
const steps: QaSuiteStep[] = params.flow.steps.map((step) => ({
name: step.name,
run: async () => {
for (const action of step.actions) {
await runFlowAction(action, params.api, vars);
}
if (!step.detailsExpr) {
return undefined;
}
const details = await evalExpr(step.detailsExpr, params.api, vars);
return formatFlowDetails(details);
},
}));
return await params.api.runScenario(params.scenarioTitle, steps);
}
export function describeScenarioFlowError(error: unknown) {
return formatErrorMessage(error);
}