mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:00:41 +00:00
test(plugins): add gateway gauntlet
This commit is contained in:
326
scripts/lib/plugin-gateway-gauntlet.mjs
Normal file
326
scripts/lib/plugin-gateway-gauntlet.mjs
Normal file
@@ -0,0 +1,326 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const MANIFEST_NAMES = ["openclaw.plugin.json", "openclaw.plugin.json5"];
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeStringArray(value) {
|
||||
return Array.isArray(value)
|
||||
? value.map((entry) => normalizeString(entry)).filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function readPluginManifest(manifestPath) {
|
||||
const raw = fs.readFileSync(manifestPath, "utf8");
|
||||
const parsed = manifestPath.endsWith(".json5") ? JSON5.parse(raw) : JSON.parse(raw);
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error(`Plugin manifest must be an object: ${manifestPath}`);
|
||||
}
|
||||
const id = normalizeString(parsed.id);
|
||||
if (!id) {
|
||||
throw new Error(`Plugin manifest is missing id: ${manifestPath}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function schemaHasRequiredFields(schema, seen = new Set()) {
|
||||
if (!isPlainObject(schema) || seen.has(schema)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(schema);
|
||||
if (Array.isArray(schema.required) && schema.required.length > 0) {
|
||||
return true;
|
||||
}
|
||||
for (const key of ["properties", "patternProperties", "$defs", "definitions"]) {
|
||||
const children = schema[key];
|
||||
if (!isPlainObject(children)) {
|
||||
continue;
|
||||
}
|
||||
for (const child of Object.values(children)) {
|
||||
if (schemaHasRequiredFields(child, seen)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of ["items", "additionalProperties", "contains", "not", "if", "then", "else"]) {
|
||||
if (schemaHasRequiredFields(schema[key], seen)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
||||
const children = schema[key];
|
||||
if (!Array.isArray(children)) {
|
||||
continue;
|
||||
}
|
||||
if (children.some((child) => schemaHasRequiredFields(child, seen))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectCommandAliasRecords(manifest) {
|
||||
const aliases = Array.isArray(manifest.commandAliases) ? manifest.commandAliases : [];
|
||||
return aliases
|
||||
.map((alias) => {
|
||||
if (typeof alias === "string") {
|
||||
const name = normalizeString(alias);
|
||||
return name ? { name, kind: "runtime-slash", cliCommand: null } : null;
|
||||
}
|
||||
if (!isPlainObject(alias)) {
|
||||
return null;
|
||||
}
|
||||
const name = normalizeString(alias.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
kind: normalizeString(alias.kind) || "runtime-slash",
|
||||
cliCommand: normalizeString(alias.cliCommand) || null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectAuthMethods(manifest) {
|
||||
const auth = Array.isArray(manifest.auth) ? manifest.auth : [];
|
||||
return auth
|
||||
.map((entry) => (isPlainObject(entry) ? normalizeString(entry.method) : ""))
|
||||
.filter((method) => method.length > 0);
|
||||
}
|
||||
|
||||
function collectOnboardingScopes(manifest) {
|
||||
const scopes = new Set();
|
||||
const addScopes = (value) => {
|
||||
for (const scope of normalizeStringArray(value)) {
|
||||
scopes.add(scope);
|
||||
}
|
||||
};
|
||||
addScopes(manifest.onboardingScopes);
|
||||
if (Array.isArray(manifest.auth)) {
|
||||
for (const entry of manifest.auth) {
|
||||
if (isPlainObject(entry)) {
|
||||
addScopes(entry.onboardingScopes);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
function buildPluginMatrixEntry(params) {
|
||||
const { repoRoot, manifestPath, manifest } = params;
|
||||
const relativeManifestPath = path.relative(repoRoot, manifestPath);
|
||||
const commandAliases = collectCommandAliasRecords(manifest);
|
||||
return {
|
||||
id: manifest.id,
|
||||
name: normalizeString(manifest.name) || manifest.id,
|
||||
dir: path.relative(repoRoot, path.dirname(manifestPath)),
|
||||
manifestPath: relativeManifestPath,
|
||||
enabledByDefault: manifest.enabledByDefault === true,
|
||||
activation: isPlainObject(manifest.activation) ? manifest.activation : {},
|
||||
providers: normalizeStringArray(manifest.providers),
|
||||
channels: normalizeStringArray(manifest.channels),
|
||||
skills: normalizeStringArray(manifest.skills),
|
||||
authMethods: collectAuthMethods(manifest),
|
||||
onboardingScopes: collectOnboardingScopes(manifest),
|
||||
hasConfigSchema: isPlainObject(manifest.configSchema),
|
||||
hasRequiredConfigFields: schemaHasRequiredFields(manifest.configSchema),
|
||||
commandAliases,
|
||||
cliCommandAliases: commandAliases.filter((alias) => alias.cliCommand),
|
||||
runtimeSlashAliases: commandAliases.filter((alias) => alias.kind === "runtime-slash"),
|
||||
};
|
||||
}
|
||||
|
||||
function discoverBundledPluginManifests(repoRoot) {
|
||||
const extensionsDir = path.join(repoRoot, "extensions");
|
||||
const entries = fs
|
||||
.readdirSync(extensionsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.flatMap((entry) => {
|
||||
const pluginDir = path.join(extensionsDir, entry.name);
|
||||
const manifestName = MANIFEST_NAMES.find((name) => fs.existsSync(path.join(pluginDir, name)));
|
||||
if (!manifestName) {
|
||||
return [];
|
||||
}
|
||||
const manifestPath = path.join(pluginDir, manifestName);
|
||||
const manifest = readPluginManifest(manifestPath);
|
||||
return [buildPluginMatrixEntry({ repoRoot, manifestPath, manifest })];
|
||||
});
|
||||
return entries.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function selectPluginEntries(entries, options = {}) {
|
||||
const ids = new Set(normalizeStringArray(options.ids));
|
||||
let selected = ids.size > 0 ? entries.filter((entry) => ids.has(entry.id)) : [...entries];
|
||||
const missingIds = [...ids].filter((id) => !entries.some((entry) => entry.id === id));
|
||||
if (missingIds.length > 0) {
|
||||
throw new Error(`Unknown bundled plugin id(s): ${missingIds.join(", ")}`);
|
||||
}
|
||||
const shardTotal = options.shardTotal ?? 1;
|
||||
const shardIndex = options.shardIndex ?? 0;
|
||||
if (!Number.isInteger(shardTotal) || shardTotal < 1) {
|
||||
throw new Error("--shard-total must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(shardIndex) || shardIndex < 0 || shardIndex >= shardTotal) {
|
||||
throw new Error("--shard-index must be in range [0, shard-total)");
|
||||
}
|
||||
selected = selected.filter((_, index) => index % shardTotal === shardIndex);
|
||||
if (options.limit !== undefined) {
|
||||
if (!Number.isInteger(options.limit) || options.limit < 1) {
|
||||
throw new Error("--limit must be a positive integer");
|
||||
}
|
||||
selected = selected.slice(0, options.limit);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = values
|
||||
.filter((value) => typeof value === "number" && Number.isFinite(value))
|
||||
.sort((left, right) => left - right);
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const midpoint = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1 ? sorted[midpoint] : (sorted[midpoint - 1] + sorted[midpoint]) / 2;
|
||||
}
|
||||
|
||||
function groupByPhase(rows) {
|
||||
const phases = new Map();
|
||||
for (const row of rows) {
|
||||
const phase = normalizeString(row.phase) || "unknown";
|
||||
const current = phases.get(phase) ?? [];
|
||||
current.push(row);
|
||||
phases.set(phase, current);
|
||||
}
|
||||
return phases;
|
||||
}
|
||||
|
||||
function collectMetricObservations(rows, thresholds = {}) {
|
||||
const cpuCoreWarn = thresholds.cpuCoreWarn ?? 0.9;
|
||||
const hotWallWarnMs = thresholds.hotWallWarnMs ?? 30_000;
|
||||
const wallAnomalyMultiplier = thresholds.wallAnomalyMultiplier ?? 3;
|
||||
const maxRssWarnMb = thresholds.maxRssWarnMb ?? null;
|
||||
const rssAnomalyMultiplier = thresholds.rssAnomalyMultiplier ?? 2.5;
|
||||
const observations = [];
|
||||
for (const [phase, phaseRows] of groupByPhase(rows)) {
|
||||
const wallMedianMs = median(phaseRows.map((row) => row.wallMs));
|
||||
const rssMedianMb = median(phaseRows.map((row) => row.maxRssMb));
|
||||
for (const row of phaseRows) {
|
||||
if (
|
||||
typeof row.cpuCoreRatio === "number" &&
|
||||
typeof row.wallMs === "number" &&
|
||||
row.cpuCoreRatio >= cpuCoreWarn &&
|
||||
row.wallMs >= hotWallWarnMs
|
||||
) {
|
||||
observations.push({
|
||||
kind: "phase-cpu-hot",
|
||||
pluginId: row.pluginId ?? null,
|
||||
phase,
|
||||
cpuCoreRatio: row.cpuCoreRatio,
|
||||
wallMs: row.wallMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
wallMedianMs !== null &&
|
||||
phaseRows.length >= 3 &&
|
||||
typeof row.wallMs === "number" &&
|
||||
row.wallMs >= wallMedianMs * wallAnomalyMultiplier
|
||||
) {
|
||||
observations.push({
|
||||
kind: "phase-wall-anomaly",
|
||||
pluginId: row.pluginId ?? null,
|
||||
phase,
|
||||
wallMs: row.wallMs,
|
||||
medianWallMs: wallMedianMs,
|
||||
multiplier: wallAnomalyMultiplier,
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof maxRssWarnMb === "number" &&
|
||||
typeof row.maxRssMb === "number" &&
|
||||
row.maxRssMb >= maxRssWarnMb
|
||||
) {
|
||||
observations.push({
|
||||
kind: "phase-rss-high",
|
||||
pluginId: row.pluginId ?? null,
|
||||
phase,
|
||||
maxRssMb: row.maxRssMb,
|
||||
thresholdMb: maxRssWarnMb,
|
||||
});
|
||||
}
|
||||
if (
|
||||
rssMedianMb !== null &&
|
||||
rssMedianMb > 0 &&
|
||||
phaseRows.length >= 3 &&
|
||||
typeof row.maxRssMb === "number" &&
|
||||
row.maxRssMb >= rssMedianMb * rssAnomalyMultiplier
|
||||
) {
|
||||
observations.push({
|
||||
kind: "phase-rss-anomaly",
|
||||
pluginId: row.pluginId ?? null,
|
||||
phase,
|
||||
maxRssMb: row.maxRssMb,
|
||||
medianRssMb: rssMedianMb,
|
||||
multiplier: rssAnomalyMultiplier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
function collectGatewayCpuObservations(params) {
|
||||
const observations = [];
|
||||
for (const result of params.startup?.results ?? []) {
|
||||
const cpuCoreMax = result.summary?.cpuCoreRatio?.max;
|
||||
const wallMax = result.summary?.readyzMs?.max ?? result.summary?.healthzMs?.max;
|
||||
if (
|
||||
typeof cpuCoreMax === "number" &&
|
||||
typeof wallMax === "number" &&
|
||||
cpuCoreMax >= params.cpuCoreWarn &&
|
||||
wallMax >= params.hotWallWarnMs
|
||||
) {
|
||||
observations.push({
|
||||
kind: "startup-cpu-hot",
|
||||
id: result.id,
|
||||
cpuCoreRatioMax: cpuCoreMax,
|
||||
wallMsMax: wallMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
const qaCpuCoreRatio = params.qa?.metrics?.gatewayCpuCoreRatio;
|
||||
const qaWallMs = params.qa?.metrics?.wallMs;
|
||||
if (
|
||||
typeof qaCpuCoreRatio === "number" &&
|
||||
typeof qaWallMs === "number" &&
|
||||
qaCpuCoreRatio >= params.cpuCoreWarn &&
|
||||
qaWallMs >= params.hotWallWarnMs
|
||||
) {
|
||||
observations.push({
|
||||
kind: "qa-cpu-hot",
|
||||
id: "qa-suite",
|
||||
cpuCoreRatio: qaCpuCoreRatio,
|
||||
wallMs: qaWallMs,
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
export {
|
||||
collectCommandAliasRecords,
|
||||
collectGatewayCpuObservations,
|
||||
collectMetricObservations,
|
||||
discoverBundledPluginManifests,
|
||||
schemaHasRequiredFields,
|
||||
selectPluginEntries,
|
||||
};
|
||||
Reference in New Issue
Block a user