mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(qa): install gauntlet plugin requirements
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
buildGauntletPrebuildEnv,
|
||||
collectGatewayCpuObservations,
|
||||
collectMetricObservations,
|
||||
collectPluginsWithRequiredEntries,
|
||||
collectRequiredPluginEntries,
|
||||
collectQaBaselineRegressionObservations,
|
||||
detectCommandDiagnosticFailure,
|
||||
discoverBundledPluginManifests,
|
||||
@@ -669,8 +671,35 @@ function buildSlashHelpProbe(params) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runPluginLifecycleCommand(params) {
|
||||
process.stderr.write(`[plugin-gauntlet] ${params.logPluginId} ${params.phase}\n`);
|
||||
params.rows.push(
|
||||
await runMeasuredCommand({
|
||||
cwd: params.repoRoot,
|
||||
env: params.env,
|
||||
logDir: path.join(params.outputDir, "logs", "lifecycle"),
|
||||
...openclawCommand(params.repoRoot, ["plugins", ...params.args]),
|
||||
label: params.label,
|
||||
phase: `lifecycle:${params.phase}`,
|
||||
pluginId: params.pluginId,
|
||||
timeoutMs: params.commandTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runPluginLifecycle(params) {
|
||||
for (const plugin of params.plugins) {
|
||||
const requiredPlugins = collectRequiredPluginEntries(params.matrix, [plugin]);
|
||||
for (const requiredPlugin of requiredPlugins) {
|
||||
await runPluginLifecycleCommand({
|
||||
...params,
|
||||
logPluginId: plugin.id,
|
||||
label: `${plugin.id}-requires-${requiredPlugin.id}-install`,
|
||||
phase: `requires:${requiredPlugin.id}:install`,
|
||||
args: ["install", requiredPlugin.id],
|
||||
pluginId: requiredPlugin.id,
|
||||
});
|
||||
}
|
||||
const commands = [
|
||||
{
|
||||
phase: "install",
|
||||
@@ -688,8 +717,8 @@ async function runPluginLifecycle(params) {
|
||||
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
|
||||
];
|
||||
for (const { phase, args, alias } of commands) {
|
||||
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
|
||||
if (alias) {
|
||||
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
|
||||
params.rows.push(
|
||||
await runMeasuredCommand({
|
||||
...buildSlashHelpProbe({
|
||||
@@ -705,18 +734,24 @@ async function runPluginLifecycle(params) {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
params.rows.push(
|
||||
await runMeasuredCommand({
|
||||
cwd: params.repoRoot,
|
||||
env: params.env,
|
||||
logDir: path.join(params.outputDir, "logs", "lifecycle"),
|
||||
...openclawCommand(params.repoRoot, ["plugins", ...args]),
|
||||
label: `${plugin.id}-${phase}`,
|
||||
phase: `lifecycle:${phase}`,
|
||||
pluginId: plugin.id,
|
||||
timeoutMs: params.commandTimeoutMs,
|
||||
}),
|
||||
);
|
||||
await runPluginLifecycleCommand({
|
||||
...params,
|
||||
logPluginId: plugin.id,
|
||||
label: `${plugin.id}-${phase}`,
|
||||
phase,
|
||||
args,
|
||||
pluginId: plugin.id,
|
||||
});
|
||||
}
|
||||
for (const requiredPlugin of requiredPlugins.toReversed()) {
|
||||
await runPluginLifecycleCommand({
|
||||
...params,
|
||||
logPluginId: plugin.id,
|
||||
label: `${plugin.id}-requires-${requiredPlugin.id}-uninstall`,
|
||||
phase: `requires:${requiredPlugin.id}:uninstall`,
|
||||
args: ["uninstall", requiredPlugin.id, "--force"],
|
||||
pluginId: requiredPlugin.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,6 +793,9 @@ async function runQaChunks(params) {
|
||||
const outputDir = path.join(params.outputDir, "qa-suite", chunk.label);
|
||||
const outputArg = toRepoRelativePath(params.repoRoot, outputDir);
|
||||
const pluginIds = chunk.plugins.map((plugin) => plugin.id);
|
||||
const enabledPluginIds = collectPluginsWithRequiredEntries(params.matrix, chunk.plugins).map(
|
||||
(plugin) => plugin.id,
|
||||
);
|
||||
const pluginIdLabel = pluginIds.length > 0 ? pluginIds.join(",") : "<baseline>";
|
||||
process.stderr.write(
|
||||
`[plugin-gauntlet] qa chunk ${index + 1}/${chunks.length}: ${pluginIdLabel}\n`,
|
||||
@@ -776,7 +814,7 @@ async function runQaChunks(params) {
|
||||
"--output-dir",
|
||||
outputArg,
|
||||
...params.qaScenarios.flatMap((scenario) => ["--scenario", scenario]),
|
||||
...pluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
|
||||
...enabledPluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
|
||||
]),
|
||||
label: `qa-${chunk.label}`,
|
||||
phase: "qa:rpc",
|
||||
@@ -819,10 +857,11 @@ async function main() {
|
||||
shardIndex: options.shardIndex,
|
||||
limit: options.limit,
|
||||
});
|
||||
const selectedPluginsWithRequired = collectPluginsWithRequiredEntries(matrix, selectedPlugins);
|
||||
const rows = [];
|
||||
const commandEnv = buildGauntletPrebuildEnv(env, {
|
||||
includePrivateQa: !options.skipQa,
|
||||
buildIds: selectedPlugins.map((plugin) => plugin.buildId),
|
||||
buildIds: selectedPluginsWithRequired.map((plugin) => plugin.buildId),
|
||||
skipDeclarationBuild: true,
|
||||
});
|
||||
if (!options.skipPrebuild && (selectedPlugins.length > 0 || !options.skipQa)) {
|
||||
@@ -849,6 +888,7 @@ async function main() {
|
||||
repoRoot,
|
||||
outputDir: options.outputDir,
|
||||
env: commandEnv,
|
||||
matrix,
|
||||
plugins: selectedPlugins,
|
||||
rows,
|
||||
commandTimeoutMs: options.commandTimeoutMs,
|
||||
@@ -873,6 +913,7 @@ async function main() {
|
||||
repoRoot,
|
||||
outputDir: options.outputDir,
|
||||
env: commandEnv,
|
||||
matrix,
|
||||
plugins: selectedPlugins,
|
||||
qaBaseline: options.qaBaseline,
|
||||
rows,
|
||||
|
||||
@@ -148,6 +148,7 @@ function buildPluginMatrixEntry(params) {
|
||||
skills: normalizeStringArray(manifest.skills),
|
||||
authMethods: collectAuthMethods(manifest),
|
||||
onboardingScopes: collectOnboardingScopes(manifest),
|
||||
requiredPlugins: normalizeStringArray(manifest.requiresPlugins),
|
||||
hasConfigSchema: isPlainObject(manifest.configSchema),
|
||||
hasRequiredConfigFields: schemaHasRequiredFields(manifest.configSchema),
|
||||
commandAliases,
|
||||
@@ -206,6 +207,52 @@ function selectPluginEntries(entries, options = {}) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
function collectRequiredPluginEntries(entries, plugins) {
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
const selectedIds = new Set(plugins.map((entry) => entry.id));
|
||||
const required = new Map();
|
||||
const visit = (requiredId, ownerId, trail) => {
|
||||
const entry = byId.get(requiredId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Bundled plugin "${ownerId}" requires unknown bundled plugin "${requiredId}"`,
|
||||
);
|
||||
}
|
||||
const cycleIndex = trail.indexOf(requiredId);
|
||||
if (cycleIndex !== -1) {
|
||||
const cycle = [...trail.slice(cycleIndex), requiredId].join(" -> ");
|
||||
throw new Error(`Bundled plugin dependency cycle detected: ${cycle}`);
|
||||
}
|
||||
if (required.has(requiredId)) {
|
||||
return;
|
||||
}
|
||||
const nextTrail = [...trail, requiredId];
|
||||
for (const transitiveRequiredId of entry.requiredPlugins ?? []) {
|
||||
visit(transitiveRequiredId, ownerId, nextTrail);
|
||||
}
|
||||
if (!selectedIds.has(requiredId)) {
|
||||
required.set(requiredId, entry);
|
||||
}
|
||||
};
|
||||
for (const plugin of plugins) {
|
||||
for (const requiredId of plugin.requiredPlugins ?? []) {
|
||||
visit(requiredId, plugin.id, [plugin.id]);
|
||||
}
|
||||
}
|
||||
return [...required.values()];
|
||||
}
|
||||
|
||||
function collectPluginsWithRequiredEntries(entries, plugins) {
|
||||
const combined = new Map();
|
||||
for (const plugin of collectRequiredPluginEntries(entries, plugins)) {
|
||||
combined.set(plugin.id, plugin);
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
combined.set(plugin.id, plugin);
|
||||
}
|
||||
return [...combined.values()];
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = values
|
||||
.filter((value) => typeof value === "number" && Number.isFinite(value))
|
||||
@@ -578,6 +625,8 @@ function isNonNegativeInteger(value) {
|
||||
|
||||
export {
|
||||
collectQaBaselineRegressionObservations,
|
||||
collectPluginsWithRequiredEntries,
|
||||
collectRequiredPluginEntries,
|
||||
collectGatewayCpuObservations,
|
||||
collectMetricObservations,
|
||||
buildGauntletPrebuildEnv,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
buildGauntletPrebuildEnv,
|
||||
collectGatewayCpuObservations,
|
||||
collectMetricObservations,
|
||||
collectPluginsWithRequiredEntries,
|
||||
collectRequiredPluginEntries,
|
||||
collectQaBaselineRegressionObservations,
|
||||
detectCommandDiagnosticFailure,
|
||||
discoverBundledPluginManifests,
|
||||
@@ -160,6 +162,7 @@ describe("plugin gateway gauntlet helpers", () => {
|
||||
name: "alpha",
|
||||
onboardingScopes: ["models"],
|
||||
providers: ["openai"],
|
||||
requiredPlugins: [],
|
||||
runtimeSlashAliases: [{ name: "alpha", kind: "runtime-slash", cliCommand: "plugins" }],
|
||||
skills: [],
|
||||
});
|
||||
@@ -223,6 +226,49 @@ describe("plugin gateway gauntlet helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("collects required plugin support outside the selected shard", () => {
|
||||
const entries = [
|
||||
{ id: "alpha", requiredPlugins: ["beta"] },
|
||||
{ id: "beta", requiredPlugins: ["gamma"] },
|
||||
{ id: "gamma" },
|
||||
{ id: "delta" },
|
||||
];
|
||||
const selected = selectPluginEntries(entries, {
|
||||
ids: ["alpha"],
|
||||
shardTotal: 2,
|
||||
shardIndex: 0,
|
||||
});
|
||||
|
||||
expect(collectRequiredPluginEntries(entries, selected).map((entry) => entry.id)).toEqual([
|
||||
"gamma",
|
||||
"beta",
|
||||
]);
|
||||
expect(collectPluginsWithRequiredEntries(entries, selected).map((entry) => entry.id)).toEqual([
|
||||
"gamma",
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects missing bundled plugin requirements", () => {
|
||||
const entries = [{ id: "alpha", requiredPlugins: ["missing"] }];
|
||||
|
||||
expect(() => collectRequiredPluginEntries(entries, entries)).toThrow(
|
||||
'Bundled plugin "alpha" requires unknown bundled plugin "missing"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects bundled plugin requirement cycles", () => {
|
||||
const entries = [
|
||||
{ id: "alpha", requiredPlugins: ["beta"] },
|
||||
{ id: "beta", requiredPlugins: ["alpha"] },
|
||||
];
|
||||
|
||||
expect(() => collectRequiredPluginEntries(entries, [entries[0]])).toThrow(
|
||||
"Bundled plugin dependency cycle detected: alpha -> beta -> alpha",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects required schema fields recursively", () => {
|
||||
expect(
|
||||
schemaHasRequiredFields({
|
||||
@@ -1038,13 +1084,19 @@ setInterval(() => {}, 1000);
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries bounded build ids into QA run-node chunks", async () => {
|
||||
it("carries required plugin build ids and enables dependencies in QA chunks", async () => {
|
||||
const outputDir = path.join(repoRoot, "artifacts");
|
||||
const qaSummaryJson = JSON.stringify(
|
||||
minimalQaSuiteSummary({ gatewayCpuCoreRatio: 0, wallMs: 1 }),
|
||||
);
|
||||
await writeManifest("alpha", "openclaw.plugin.json", JSON.stringify({ id: "alpha" }));
|
||||
await writeManifest(
|
||||
"alpha",
|
||||
"openclaw.plugin.json",
|
||||
JSON.stringify({ id: "alpha", requiresPlugins: ["beta"] }),
|
||||
);
|
||||
await writeManifest("beta", "openclaw.plugin.json", JSON.stringify({ id: "beta" }));
|
||||
await fs.writeFile(path.join(repoRoot, "extensions", "alpha", "index.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(repoRoot, "extensions", "beta", "index.ts"), "export {};\n");
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "run-node.mjs"),
|
||||
@@ -1055,6 +1107,7 @@ setInterval(() => {}, 1000);
|
||||
"const outputDir = path.resolve(process.cwd(), process.argv[outputArgIndex + 1]);",
|
||||
"fs.mkdirSync(outputDir, { recursive: true });",
|
||||
'fs.writeFileSync(path.join(outputDir, "env.txt"), process.env.OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS ?? "", "utf8");',
|
||||
'fs.writeFileSync(path.join(outputDir, "args.txt"), process.argv.slice(2).join("\\n"), "utf8");',
|
||||
`fs.writeFileSync(path.join(outputDir, "qa-suite-summary.json"), ${JSON.stringify(qaSummaryJson)}, "utf8");`,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
@@ -1085,7 +1138,63 @@ setInterval(() => {}, 1000);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
await expect(
|
||||
fs.readFile(path.join(outputDir, "qa-suite", "chunk-00", "env.txt"), "utf8"),
|
||||
).resolves.toBe("alpha,qa-channel,qa-lab,qa-matrix");
|
||||
).resolves.toBe("alpha,beta,qa-channel,qa-lab,qa-matrix");
|
||||
await expect(
|
||||
fs.readFile(path.join(outputDir, "qa-suite", "chunk-00", "args.txt"), "utf8"),
|
||||
).resolves.toContain(["--enable-plugin", "beta", "--enable-plugin", "alpha"].join("\n"));
|
||||
});
|
||||
|
||||
it("installs required plugins around a dependent plugin lifecycle probe", async () => {
|
||||
const outputDir = path.join(repoRoot, "artifacts");
|
||||
await writeManifest(
|
||||
"alpha",
|
||||
"openclaw.plugin.json",
|
||||
JSON.stringify({ id: "alpha", requiresPlugins: ["beta"] }),
|
||||
);
|
||||
await writeManifest("beta", "openclaw.plugin.json", JSON.stringify({ id: "beta" }));
|
||||
await fs.writeFile(path.join(repoRoot, "extensions", "alpha", "index.ts"), "export {};\n");
|
||||
await fs.writeFile(path.join(repoRoot, "extensions", "beta", "index.ts"), "export {};\n");
|
||||
await fs.mkdir(path.join(repoRoot, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "dist", "entry.js"),
|
||||
"if (process.argv[3] === 'inspect') console.log('{}');\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve("scripts/check-plugin-gateway-gauntlet.mjs"),
|
||||
"--repo-root",
|
||||
repoRoot,
|
||||
"--output-dir",
|
||||
outputDir,
|
||||
"--skip-prebuild",
|
||||
"--skip-qa",
|
||||
"--skip-slash-help",
|
||||
"--plugin",
|
||||
"alpha",
|
||||
],
|
||||
{
|
||||
cwd: path.resolve("."),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const summary = JSON.parse(
|
||||
await fs.readFile(path.join(outputDir, "plugin-gateway-gauntlet-summary.json"), "utf8"),
|
||||
);
|
||||
expect(summary.rows.map((row: { label: string }) => row.label)).toEqual([
|
||||
"alpha-requires-beta-install",
|
||||
"alpha-install",
|
||||
"alpha-inspect",
|
||||
"alpha-disable",
|
||||
"alpha-enable",
|
||||
"alpha-doctor",
|
||||
"alpha-uninstall",
|
||||
"alpha-requires-beta-uninstall",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails successful QA chunks whose summary reports failed scenarios", async () => {
|
||||
|
||||
Reference in New Issue
Block a user