fix(qa): install gauntlet plugin requirements

This commit is contained in:
Vincent Koc
2026-06-16 06:59:31 +02:00
parent 96404a7bd5
commit 7ac2bbaaf0
3 changed files with 217 additions and 18 deletions
+56 -15
View File
@@ -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,
+49
View File
@@ -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,
+112 -3
View File
@@ -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 () => {