Initial rrweb SGrobot skill package
This commit is contained in:
536
scripts/generate_sgrobot_flow.mjs
Normal file
536
scripts/generate_sgrobot_flow.mjs
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env node
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MUTATING_TYPES = new Set(["click", "fill", "input", "type", "select", "check", "uncheck", "submit", "press"]);
|
||||
const WRITE_RISKS = new Set(["write", "dangerous", "destructive", "submit", "payment", "approval"]);
|
||||
|
||||
export function generateSgRobotArtifacts(trace, options = {}) {
|
||||
const normalized = normalizeTrace(trace, options);
|
||||
const files = new Map();
|
||||
const skillRoot = `.sgclaw-workspace/skills/${normalized.skillName}`;
|
||||
|
||||
files.set(`plan_templates/${normalized.planId}/PlanTemplate.toml`, renderPlanTemplate(normalized));
|
||||
files.set(`${skillRoot}/SKILL.toml`, renderSkillToml(normalized));
|
||||
files.set(`${skillRoot}/scripts/execute_trace.js`, renderRuntimeScript(normalized));
|
||||
files.set(`${skillRoot}/trace.semantic.json`, `${JSON.stringify(trace, null, 2)}\n`);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
planId: normalized.planId,
|
||||
skillName: normalized.skillName,
|
||||
expectedDomain: normalized.expectedDomain,
|
||||
defaultMode: normalized.defaultMode,
|
||||
requiresApproval: normalized.requiresApproval,
|
||||
actionCount: normalized.actions.length,
|
||||
},
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeArtifacts(root, files, options = {}) {
|
||||
const rootPath = path.resolve(root);
|
||||
const written = [];
|
||||
|
||||
for (const [relativePath, content] of files.entries()) {
|
||||
if (path.isAbsolute(relativePath) || relativePath.includes("..")) {
|
||||
throw new Error(`Refusing unsafe generated path: ${relativePath}`);
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(rootPath, relativePath);
|
||||
if (!targetPath.startsWith(`${rootPath}${path.sep}`)) {
|
||||
throw new Error(`Refusing path outside output root: ${relativePath}`);
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
try {
|
||||
await access(targetPath);
|
||||
throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`);
|
||||
} catch (error) {
|
||||
if (error && error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, content, "utf8");
|
||||
written.push(targetPath);
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
function normalizeTrace(trace, options) {
|
||||
if (!trace || typeof trace !== "object" || Array.isArray(trace)) {
|
||||
throw new Error("Trace must be a JSON object.");
|
||||
}
|
||||
|
||||
const id = requireString(trace.id, "trace.id");
|
||||
const name = requireString(trace.name, "trace.name");
|
||||
const startUrl = requireString(trace.startUrl || trace.start_url, "trace.startUrl");
|
||||
const start = parseHttpUrl(startUrl, "trace.startUrl");
|
||||
const actions = Array.isArray(trace.steps) ? trace.steps : trace.actions;
|
||||
|
||||
if (!Array.isArray(actions) || actions.length === 0) {
|
||||
throw new Error("Trace must include a non-empty steps array.");
|
||||
}
|
||||
|
||||
const baseId = toKebabId(id);
|
||||
const planId = cleanId(trace.planId || trace.plan_id || `rrweb-${baseId}`, "plan id");
|
||||
const skillName = cleanId(trace.skillName || trace.skill_name || `${planId}-execute`, "skill name");
|
||||
const expectedDomain = String(trace.expectedDomain || trace.expected_domain || start.hostname).trim();
|
||||
const defaultMode = normalizeMode(options.defaultMode || options.default_mode || trace.defaultMode || trace.default_mode || "dry-run");
|
||||
const normalizedActions = actions.map((action, index) => normalizeAction(action, index));
|
||||
const hasMutatingAction = normalizedActions.some(isMutatingAction);
|
||||
const requiresApproval =
|
||||
trace.requiresApproval === true ||
|
||||
trace.requires_approval === true ||
|
||||
(defaultMode === "execute" && hasMutatingAction);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: String(trace.description || `${name} generated from rrweb semantic trace.`),
|
||||
startUrl: start.href,
|
||||
planId,
|
||||
skillName,
|
||||
expectedDomain,
|
||||
defaultMode,
|
||||
requiresApproval,
|
||||
actions: normalizedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAction(action, index) {
|
||||
if (!action || typeof action !== "object" || Array.isArray(action)) {
|
||||
throw new Error(`Trace step ${index + 1} must be an object.`);
|
||||
}
|
||||
|
||||
const type = requireString(action.type || action.action, `trace.steps[${index}].type`);
|
||||
const normalized = {
|
||||
...action,
|
||||
type,
|
||||
label: String(action.label || action.name || `${type} #${index + 1}`),
|
||||
};
|
||||
|
||||
if (needsTarget(type) && !action.selector && !Array.isArray(action.selectors) && !action.text) {
|
||||
throw new Error(`Trace step ${index + 1} (${type}) needs selector, selectors, or text.`);
|
||||
}
|
||||
|
||||
if (needsValue(type) && action.value === undefined) {
|
||||
throw new Error(`Trace step ${index + 1} (${type}) needs value.`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function renderPlanTemplate(trace) {
|
||||
const approvalLine = trace.requiresApproval ? "\nrequires_approval = true" : "";
|
||||
|
||||
return `[plan]
|
||||
id = ${tomlString(trace.planId)}
|
||||
name = ${tomlString(trace.name)}
|
||||
description = ${tomlString(trace.description)}
|
||||
version = "1.0.0"
|
||||
|
||||
[plan.trigger]
|
||||
kind = "manual"
|
||||
|
||||
[[plan.steps]]
|
||||
id = "open-target-page"
|
||||
title = "Open target page"
|
||||
description = ${tomlString(`Call browser.open_url to open ${trace.startUrl}.`)}
|
||||
skills = ["browser.open_url"]
|
||||
depends_on = []
|
||||
|
||||
[[plan.steps]]
|
||||
id = "run-recorded-flow"
|
||||
title = "Run recorded browser flow"
|
||||
description = ${tomlString(`Execute the generated rrweb semantic trace through sgBrowser on ${trace.expectedDomain}. Default mode: ${trace.defaultMode}.`)}
|
||||
skills = [${tomlString(trace.skillName)}]
|
||||
depends_on = ["open-target-page"]${approvalLine}
|
||||
|
||||
[[plan.steps]]
|
||||
id = "save-result"
|
||||
title = "Save structured result"
|
||||
description = "Save the generated flow result as a structured JSON artifact."
|
||||
skills = ["json.save_result"]
|
||||
depends_on = ["run-recorded-flow"]
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSkillToml(trace) {
|
||||
return `[skill]
|
||||
name = ${tomlString(trace.skillName)}
|
||||
description = ${tomlString(`Run the generated rrweb semantic trace for ${trace.name} through sgBrowser.`)}
|
||||
version = "0.1.0"
|
||||
author = "rrweb-skill"
|
||||
tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"]
|
||||
|
||||
prompts = [
|
||||
${tomlString(`Use ${trace.skillName} only for the generated ${trace.name} browser flow.`)},
|
||||
"Run it after browser.open_url has opened the target page.",
|
||||
"Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step."
|
||||
]
|
||||
|
||||
[[tools]]
|
||||
name = "execute_trace"
|
||||
description = ${tomlString(`Replay or dry-run the generated ${trace.name} semantic trace in sgBrowser.`)}
|
||||
kind = "sgbrowser_action"
|
||||
command = "sgBrowserExcuteJsCodeByDomain"
|
||||
expected_domain = ${tomlString(trace.expectedDomain)}
|
||||
|
||||
[tools.args]
|
||||
script_path = "scripts/execute_trace.js"
|
||||
mode = ${tomlString(`Baked default mode is ${trace.defaultMode}. Runtime may pass mode=execute or mode=dry-run if supported.`)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRuntimeScript(trace) {
|
||||
const runtimeTrace = {
|
||||
id: trace.id,
|
||||
name: trace.name,
|
||||
start_url: trace.startUrl,
|
||||
expected_domain: trace.expectedDomain,
|
||||
default_mode: trace.defaultMode,
|
||||
actions: trace.actions,
|
||||
};
|
||||
|
||||
return `const TRACE = ${JSON.stringify(runtimeTrace, null, 2)};
|
||||
const input =
|
||||
(typeof args === "object" && args) ||
|
||||
(typeof tool_args === "object" && tool_args) ||
|
||||
(typeof sgclaw_args === "object" && sgclaw_args) ||
|
||||
{};
|
||||
|
||||
function normalizeMode(value) {
|
||||
const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase();
|
||||
return raw === "execute" ? "execute" : "dry-run";
|
||||
}
|
||||
|
||||
function normalizeType(value) {
|
||||
return String(value || "").replace(/[\\s_-]+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value || "").replace(/\\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function selectorList(action) {
|
||||
const selectors = [];
|
||||
if (action.selector) {
|
||||
selectors.push(action.selector);
|
||||
}
|
||||
if (Array.isArray(action.selectors)) {
|
||||
for (const selector of action.selectors) {
|
||||
if (selector) {
|
||||
selectors.push(selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function findByXPath(xpath) {
|
||||
try {
|
||||
return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findByText(text) {
|
||||
const needle = cleanText(text);
|
||||
if (!needle) {
|
||||
return null;
|
||||
}
|
||||
const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label"));
|
||||
return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null;
|
||||
}
|
||||
|
||||
function findElement(action) {
|
||||
for (const rawSelector of selectorList(action)) {
|
||||
const selector = String(rawSelector).trim();
|
||||
if (!selector) {
|
||||
continue;
|
||||
}
|
||||
if (selector.startsWith("xpath=")) {
|
||||
const node = findByXPath(selector.slice("xpath=".length));
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (selector.startsWith("//") || selector.startsWith("(//")) {
|
||||
const node = findByXPath(selector);
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const node = document.querySelector(selector);
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return findByText(action.text || action.label);
|
||||
}
|
||||
|
||||
function describeElement(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tag: String(node.tagName || "").toLowerCase(),
|
||||
id: node.id || "",
|
||||
name: node.getAttribute ? node.getAttribute("name") || "" : "",
|
||||
text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchFieldEvents(node) {
|
||||
node.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
node.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function runAction(action, index, shouldExecute) {
|
||||
const type = normalizeType(action.type);
|
||||
const result = {
|
||||
index,
|
||||
type: action.type,
|
||||
label: action.label || "",
|
||||
mode: shouldExecute ? "execute" : "dry-run",
|
||||
ok: true,
|
||||
};
|
||||
|
||||
if (type === "asserttext" || type === "waitfortext") {
|
||||
const needle = cleanText(action.text || action.value);
|
||||
const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent);
|
||||
result.ok = needle ? haystack.includes(needle) : false;
|
||||
result.expected_text = needle;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "extracttext") {
|
||||
const target = findElement(action) || document.body || document.documentElement;
|
||||
result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000));
|
||||
result.element = describeElement(target);
|
||||
return result;
|
||||
}
|
||||
|
||||
const element = findElement(action);
|
||||
result.element = describeElement(element);
|
||||
if (!element) {
|
||||
result.ok = false;
|
||||
result.error = "target element not found";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "click" || type === "submit") {
|
||||
if (shouldExecute) {
|
||||
element.scrollIntoView({ block: "center", inline: "center" });
|
||||
if (typeof element.focus === "function") {
|
||||
element.focus();
|
||||
}
|
||||
if (type === "submit" && element.form) {
|
||||
element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit();
|
||||
} else {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "fill" || type === "input" || type === "type") {
|
||||
result.value = action.value;
|
||||
if (shouldExecute) {
|
||||
element.focus && element.focus();
|
||||
element.value = String(action.value);
|
||||
dispatchFieldEvents(element);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "select") {
|
||||
result.value = action.value;
|
||||
if (shouldExecute) {
|
||||
element.value = String(action.value);
|
||||
dispatchFieldEvents(element);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "check" || type === "uncheck") {
|
||||
result.checked = type === "check";
|
||||
if (shouldExecute) {
|
||||
element.checked = type === "check";
|
||||
dispatchFieldEvents(element);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ok = false;
|
||||
result.error = "unsupported action type";
|
||||
return result;
|
||||
}
|
||||
|
||||
const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode);
|
||||
const shouldExecute = mode === "execute";
|
||||
const actionResults = [];
|
||||
let ok = true;
|
||||
|
||||
for (let index = 0; index < TRACE.actions.length; index += 1) {
|
||||
const actionResult = runAction(TRACE.actions[index], index, shouldExecute);
|
||||
actionResults.push(actionResult);
|
||||
if (!actionResult.ok) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
ok,
|
||||
trace_id: TRACE.id,
|
||||
trace_name: TRACE.name,
|
||||
mode,
|
||||
executed: shouldExecute,
|
||||
expected_domain: TRACE.expected_domain,
|
||||
url: window.location.href,
|
||||
title: document.title || "",
|
||||
action_count: TRACE.actions.length,
|
||||
action_results: actionResults,
|
||||
finished_at: new Date().toISOString(),
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function isMutatingAction(action) {
|
||||
const type = String(action.type || "").trim().toLowerCase();
|
||||
const risk = String(action.risk || "").trim().toLowerCase();
|
||||
return MUTATING_TYPES.has(type) || WRITE_RISKS.has(risk);
|
||||
}
|
||||
|
||||
function needsTarget(type) {
|
||||
const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase();
|
||||
return !["asserttext", "waitfortext"].includes(normalized);
|
||||
}
|
||||
|
||||
function needsValue(type) {
|
||||
const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase();
|
||||
return ["fill", "input", "type", "select"].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeMode(value) {
|
||||
const mode = String(value || "").trim().toLowerCase();
|
||||
if (mode === "dry-run" || mode === "dryrun" || mode === "dry_run") {
|
||||
return "dry-run";
|
||||
}
|
||||
if (mode === "execute") {
|
||||
return "execute";
|
||||
}
|
||||
throw new Error(`Unsupported default mode: ${value}`);
|
||||
}
|
||||
|
||||
function cleanId(value, label) {
|
||||
const cleaned = toKebabId(value);
|
||||
if (!cleaned) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function toKebabId(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function requireString(value, label) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseHttpUrl(value, label) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new Error("Only http/https URLs are supported.");
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw new Error(`${label} must be a valid http/https URL. ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tomlString(value) {
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const args = {
|
||||
defaultMode: "dry-run",
|
||||
force: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (token === "--trace") {
|
||||
args.trace = argv[++index];
|
||||
} else if (token === "--out") {
|
||||
args.out = argv[++index];
|
||||
} else if (token === "--default-mode" || token === "--mode") {
|
||||
args.defaultMode = argv[++index];
|
||||
} else if (token === "--force") {
|
||||
args.force = true;
|
||||
} else if (token === "--help" || token === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node scripts/generate_sgrobot_flow.mjs --trace <semantic-trace.json> --out <sgrobot-root> [--default-mode dry-run|execute] [--force]
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cli = parseCliArgs(process.argv.slice(2));
|
||||
if (cli.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
if (!cli.trace || !cli.out) {
|
||||
throw new Error(`${usage()}Both --trace and --out are required.`);
|
||||
}
|
||||
|
||||
const trace = JSON.parse(await readFile(cli.trace, "utf8"));
|
||||
const result = generateSgRobotArtifacts(trace, { defaultMode: cli.defaultMode });
|
||||
const written = await writeArtifacts(cli.out, result.files, { force: cli.force });
|
||||
|
||||
console.log(JSON.stringify({ ...result.summary, written }, null, 2));
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
50
scripts/validate_sgrobot_artifacts.mjs
Normal file
50
scripts/validate_sgrobot_artifacts.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(process.argv[2] || ".");
|
||||
const planId = process.argv[3];
|
||||
const skillName = process.argv[4];
|
||||
|
||||
if (!planId || !skillName) {
|
||||
console.error("Usage: node scripts/validate_sgrobot_artifacts.mjs <root> <plan-id> <skill-name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const planPath = path.join(root, "plan_templates", planId, "PlanTemplate.toml");
|
||||
const skillPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "SKILL.toml");
|
||||
const scriptPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "scripts", "execute_trace.js");
|
||||
const tracePath = path.join(root, ".sgclaw-workspace", "skills", skillName, "trace.semantic.json");
|
||||
|
||||
const [plan, skill, script, traceText] = await Promise.all([
|
||||
readFile(planPath, "utf8"),
|
||||
readFile(skillPath, "utf8"),
|
||||
readFile(scriptPath, "utf8"),
|
||||
readFile(tracePath, "utf8"),
|
||||
]);
|
||||
|
||||
const trace = JSON.parse(traceText);
|
||||
const checks = [
|
||||
["plan has [plan]", plan.includes("[plan]")],
|
||||
["plan id matches", plan.includes(`id = "${planId}"`)],
|
||||
["plan opens page via browser.open_url", plan.includes('skills = ["browser.open_url"]')],
|
||||
["plan calls generated skill", plan.includes(`skills = ["${skillName}"]`)],
|
||||
["plan saves result", plan.includes('skills = ["json.save_result"]')],
|
||||
["write flow requires approval", plan.includes("requires_approval = true")],
|
||||
["skill name matches", skill.includes(`name = "${skillName}"`)],
|
||||
["skill uses sgBrowser action", skill.includes('kind = "sgbrowser_action"')],
|
||||
["skill uses sgBrowser JS by domain", skill.includes('command = "sgBrowserExcuteJsCodeByDomain"')],
|
||||
["skill references execute script", skill.includes('script_path = "scripts/execute_trace.js"')],
|
||||
["runtime script contains trace", script.includes(`"id": "${trace.id}"`)],
|
||||
["runtime script returns structured JSON", script.includes("action_results")],
|
||||
["trace has realistic steps", Array.isArray(trace.steps) && trace.steps.length >= 4],
|
||||
];
|
||||
|
||||
const failed = checks.filter(([, ok]) => !ok);
|
||||
for (const [name, ok] of checks) {
|
||||
console.log(`${ok ? "ok" : "not ok"} - ${name}`);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user