Skip to content

Commit 7ac2bba

Browse files
committed
fix(qa): install gauntlet plugin requirements
1 parent 96404a7 commit 7ac2bba

3 files changed

Lines changed: 217 additions & 18 deletions

File tree

scripts/check-plugin-gateway-gauntlet.mjs

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
buildGauntletPrebuildEnv,
1818
collectGatewayCpuObservations,
1919
collectMetricObservations,
20+
collectPluginsWithRequiredEntries,
21+
collectRequiredPluginEntries,
2022
collectQaBaselineRegressionObservations,
2123
detectCommandDiagnosticFailure,
2224
discoverBundledPluginManifests,
@@ -669,8 +671,35 @@ function buildSlashHelpProbe(params) {
669671
};
670672
}
671673

674+
async function runPluginLifecycleCommand(params) {
675+
process.stderr.write(`[plugin-gauntlet] ${params.logPluginId} ${params.phase}\n`);
676+
params.rows.push(
677+
await runMeasuredCommand({
678+
cwd: params.repoRoot,
679+
env: params.env,
680+
logDir: path.join(params.outputDir, "logs", "lifecycle"),
681+
...openclawCommand(params.repoRoot, ["plugins", ...params.args]),
682+
label: params.label,
683+
phase: `lifecycle:${params.phase}`,
684+
pluginId: params.pluginId,
685+
timeoutMs: params.commandTimeoutMs,
686+
}),
687+
);
688+
}
689+
672690
async function runPluginLifecycle(params) {
673691
for (const plugin of params.plugins) {
692+
const requiredPlugins = collectRequiredPluginEntries(params.matrix, [plugin]);
693+
for (const requiredPlugin of requiredPlugins) {
694+
await runPluginLifecycleCommand({
695+
...params,
696+
logPluginId: plugin.id,
697+
label: `${plugin.id}-requires-${requiredPlugin.id}-install`,
698+
phase: `requires:${requiredPlugin.id}:install`,
699+
args: ["install", requiredPlugin.id],
700+
pluginId: requiredPlugin.id,
701+
});
702+
}
674703
const commands = [
675704
{
676705
phase: "install",
@@ -688,8 +717,8 @@ async function runPluginLifecycle(params) {
688717
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
689718
];
690719
for (const { phase, args, alias } of commands) {
691-
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
692720
if (alias) {
721+
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
693722
params.rows.push(
694723
await runMeasuredCommand({
695724
...buildSlashHelpProbe({
@@ -705,18 +734,24 @@ async function runPluginLifecycle(params) {
705734
);
706735
continue;
707736
}
708-
params.rows.push(
709-
await runMeasuredCommand({
710-
cwd: params.repoRoot,
711-
env: params.env,
712-
logDir: path.join(params.outputDir, "logs", "lifecycle"),
713-
...openclawCommand(params.repoRoot, ["plugins", ...args]),
714-
label: `${plugin.id}-${phase}`,
715-
phase: `lifecycle:${phase}`,
716-
pluginId: plugin.id,
717-
timeoutMs: params.commandTimeoutMs,
718-
}),
719-
);
737+
await runPluginLifecycleCommand({
738+
...params,
739+
logPluginId: plugin.id,
740+
label: `${plugin.id}-${phase}`,
741+
phase,
742+
args,
743+
pluginId: plugin.id,
744+
});
745+
}
746+
for (const requiredPlugin of requiredPlugins.toReversed()) {
747+
await runPluginLifecycleCommand({
748+
...params,
749+
logPluginId: plugin.id,
750+
label: `${plugin.id}-requires-${requiredPlugin.id}-uninstall`,
751+
phase: `requires:${requiredPlugin.id}:uninstall`,
752+
args: ["uninstall", requiredPlugin.id, "--force"],
753+
pluginId: requiredPlugin.id,
754+
});
720755
}
721756
}
722757
}
@@ -758,6 +793,9 @@ async function runQaChunks(params) {
758793
const outputDir = path.join(params.outputDir, "qa-suite", chunk.label);
759794
const outputArg = toRepoRelativePath(params.repoRoot, outputDir);
760795
const pluginIds = chunk.plugins.map((plugin) => plugin.id);
796+
const enabledPluginIds = collectPluginsWithRequiredEntries(params.matrix, chunk.plugins).map(
797+
(plugin) => plugin.id,
798+
);
761799
const pluginIdLabel = pluginIds.length > 0 ? pluginIds.join(",") : "<baseline>";
762800
process.stderr.write(
763801
`[plugin-gauntlet] qa chunk ${index + 1}/${chunks.length}: ${pluginIdLabel}\n`,
@@ -776,7 +814,7 @@ async function runQaChunks(params) {
776814
"--output-dir",
777815
outputArg,
778816
...params.qaScenarios.flatMap((scenario) => ["--scenario", scenario]),
779-
...pluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
817+
...enabledPluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
780818
]),
781819
label: `qa-${chunk.label}`,
782820
phase: "qa:rpc",
@@ -819,10 +857,11 @@ async function main() {
819857
shardIndex: options.shardIndex,
820858
limit: options.limit,
821859
});
860+
const selectedPluginsWithRequired = collectPluginsWithRequiredEntries(matrix, selectedPlugins);
822861
const rows = [];
823862
const commandEnv = buildGauntletPrebuildEnv(env, {
824863
includePrivateQa: !options.skipQa,
825-
buildIds: selectedPlugins.map((plugin) => plugin.buildId),
864+
buildIds: selectedPluginsWithRequired.map((plugin) => plugin.buildId),
826865
skipDeclarationBuild: true,
827866
});
828867
if (!options.skipPrebuild && (selectedPlugins.length > 0 || !options.skipQa)) {
@@ -849,6 +888,7 @@ async function main() {
849888
repoRoot,
850889
outputDir: options.outputDir,
851890
env: commandEnv,
891+
matrix,
852892
plugins: selectedPlugins,
853893
rows,
854894
commandTimeoutMs: options.commandTimeoutMs,
@@ -873,6 +913,7 @@ async function main() {
873913
repoRoot,
874914
outputDir: options.outputDir,
875915
env: commandEnv,
916+
matrix,
876917
plugins: selectedPlugins,
877918
qaBaseline: options.qaBaseline,
878919
rows,

scripts/lib/plugin-gateway-gauntlet.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ function buildPluginMatrixEntry(params) {
148148
skills: normalizeStringArray(manifest.skills),
149149
authMethods: collectAuthMethods(manifest),
150150
onboardingScopes: collectOnboardingScopes(manifest),
151+
requiredPlugins: normalizeStringArray(manifest.requiresPlugins),
151152
hasConfigSchema: isPlainObject(manifest.configSchema),
152153
hasRequiredConfigFields: schemaHasRequiredFields(manifest.configSchema),
153154
commandAliases,
@@ -206,6 +207,52 @@ function selectPluginEntries(entries, options = {}) {
206207
return selected;
207208
}
208209

210+
function collectRequiredPluginEntries(entries, plugins) {
211+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
212+
const selectedIds = new Set(plugins.map((entry) => entry.id));
213+
const required = new Map();
214+
const visit = (requiredId, ownerId, trail) => {
215+
const entry = byId.get(requiredId);
216+
if (!entry) {
217+
throw new Error(
218+
`Bundled plugin "${ownerId}" requires unknown bundled plugin "${requiredId}"`,
219+
);
220+
}
221+
const cycleIndex = trail.indexOf(requiredId);
222+
if (cycleIndex !== -1) {
223+
const cycle = [...trail.slice(cycleIndex), requiredId].join(" -> ");
224+
throw new Error(`Bundled plugin dependency cycle detected: ${cycle}`);
225+
}
226+
if (required.has(requiredId)) {
227+
return;
228+
}
229+
const nextTrail = [...trail, requiredId];
230+
for (const transitiveRequiredId of entry.requiredPlugins ?? []) {
231+
visit(transitiveRequiredId, ownerId, nextTrail);
232+
}
233+
if (!selectedIds.has(requiredId)) {
234+
required.set(requiredId, entry);
235+
}
236+
};
237+
for (const plugin of plugins) {
238+
for (const requiredId of plugin.requiredPlugins ?? []) {
239+
visit(requiredId, plugin.id, [plugin.id]);
240+
}
241+
}
242+
return [...required.values()];
243+
}
244+
245+
function collectPluginsWithRequiredEntries(entries, plugins) {
246+
const combined = new Map();
247+
for (const plugin of collectRequiredPluginEntries(entries, plugins)) {
248+
combined.set(plugin.id, plugin);
249+
}
250+
for (const plugin of plugins) {
251+
combined.set(plugin.id, plugin);
252+
}
253+
return [...combined.values()];
254+
}
255+
209256
function median(values) {
210257
const sorted = values
211258
.filter((value) => typeof value === "number" && Number.isFinite(value))
@@ -578,6 +625,8 @@ function isNonNegativeInteger(value) {
578625

579626
export {
580627
collectQaBaselineRegressionObservations,
628+
collectPluginsWithRequiredEntries,
629+
collectRequiredPluginEntries,
581630
collectGatewayCpuObservations,
582631
collectMetricObservations,
583632
buildGauntletPrebuildEnv,

test/scripts/plugin-gateway-gauntlet.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
buildGauntletPrebuildEnv,
1919
collectGatewayCpuObservations,
2020
collectMetricObservations,
21+
collectPluginsWithRequiredEntries,
22+
collectRequiredPluginEntries,
2123
collectQaBaselineRegressionObservations,
2224
detectCommandDiagnosticFailure,
2325
discoverBundledPluginManifests,
@@ -160,6 +162,7 @@ describe("plugin gateway gauntlet helpers", () => {
160162
name: "alpha",
161163
onboardingScopes: ["models"],
162164
providers: ["openai"],
165+
requiredPlugins: [],
163166
runtimeSlashAliases: [{ name: "alpha", kind: "runtime-slash", cliCommand: "plugins" }],
164167
skills: [],
165168
});
@@ -223,6 +226,49 @@ describe("plugin gateway gauntlet helpers", () => {
223226
);
224227
});
225228

229+
it("collects required plugin support outside the selected shard", () => {
230+
const entries = [
231+
{ id: "alpha", requiredPlugins: ["beta"] },
232+
{ id: "beta", requiredPlugins: ["gamma"] },
233+
{ id: "gamma" },
234+
{ id: "delta" },
235+
];
236+
const selected = selectPluginEntries(entries, {
237+
ids: ["alpha"],
238+
shardTotal: 2,
239+
shardIndex: 0,
240+
});
241+
242+
expect(collectRequiredPluginEntries(entries, selected).map((entry) => entry.id)).toEqual([
243+
"gamma",
244+
"beta",
245+
]);
246+
expect(collectPluginsWithRequiredEntries(entries, selected).map((entry) => entry.id)).toEqual([
247+
"gamma",
248+
"beta",
249+
"alpha",
250+
]);
251+
});
252+
253+
it("rejects missing bundled plugin requirements", () => {
254+
const entries = [{ id: "alpha", requiredPlugins: ["missing"] }];
255+
256+
expect(() => collectRequiredPluginEntries(entries, entries)).toThrow(
257+
'Bundled plugin "alpha" requires unknown bundled plugin "missing"',
258+
);
259+
});
260+
261+
it("rejects bundled plugin requirement cycles", () => {
262+
const entries = [
263+
{ id: "alpha", requiredPlugins: ["beta"] },
264+
{ id: "beta", requiredPlugins: ["alpha"] },
265+
];
266+
267+
expect(() => collectRequiredPluginEntries(entries, [entries[0]])).toThrow(
268+
"Bundled plugin dependency cycle detected: alpha -> beta -> alpha",
269+
);
270+
});
271+
226272
it("detects required schema fields recursively", () => {
227273
expect(
228274
schemaHasRequiredFields({
@@ -1038,13 +1084,19 @@ setInterval(() => {}, 1000);
10381084
]);
10391085
});
10401086

1041-
it("carries bounded build ids into QA run-node chunks", async () => {
1087+
it("carries required plugin build ids and enables dependencies in QA chunks", async () => {
10421088
const outputDir = path.join(repoRoot, "artifacts");
10431089
const qaSummaryJson = JSON.stringify(
10441090
minimalQaSuiteSummary({ gatewayCpuCoreRatio: 0, wallMs: 1 }),
10451091
);
1046-
await writeManifest("alpha", "openclaw.plugin.json", JSON.stringify({ id: "alpha" }));
1092+
await writeManifest(
1093+
"alpha",
1094+
"openclaw.plugin.json",
1095+
JSON.stringify({ id: "alpha", requiresPlugins: ["beta"] }),
1096+
);
1097+
await writeManifest("beta", "openclaw.plugin.json", JSON.stringify({ id: "beta" }));
10471098
await fs.writeFile(path.join(repoRoot, "extensions", "alpha", "index.ts"), "export {};\n");
1099+
await fs.writeFile(path.join(repoRoot, "extensions", "beta", "index.ts"), "export {};\n");
10481100
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
10491101
await fs.writeFile(
10501102
path.join(repoRoot, "scripts", "run-node.mjs"),
@@ -1055,6 +1107,7 @@ setInterval(() => {}, 1000);
10551107
"const outputDir = path.resolve(process.cwd(), process.argv[outputArgIndex + 1]);",
10561108
"fs.mkdirSync(outputDir, { recursive: true });",
10571109
'fs.writeFileSync(path.join(outputDir, "env.txt"), process.env.OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS ?? "", "utf8");',
1110+
'fs.writeFileSync(path.join(outputDir, "args.txt"), process.argv.slice(2).join("\\n"), "utf8");',
10581111
`fs.writeFileSync(path.join(outputDir, "qa-suite-summary.json"), ${JSON.stringify(qaSummaryJson)}, "utf8");`,
10591112
].join("\n"),
10601113
"utf8",
@@ -1085,7 +1138,63 @@ setInterval(() => {}, 1000);
10851138
expect(result.status, result.stderr).toBe(0);
10861139
await expect(
10871140
fs.readFile(path.join(outputDir, "qa-suite", "chunk-00", "env.txt"), "utf8"),
1088-
).resolves.toBe("alpha,qa-channel,qa-lab,qa-matrix");
1141+
).resolves.toBe("alpha,beta,qa-channel,qa-lab,qa-matrix");
1142+
await expect(
1143+
fs.readFile(path.join(outputDir, "qa-suite", "chunk-00", "args.txt"), "utf8"),
1144+
).resolves.toContain(["--enable-plugin", "beta", "--enable-plugin", "alpha"].join("\n"));
1145+
});
1146+
1147+
it("installs required plugins around a dependent plugin lifecycle probe", async () => {
1148+
const outputDir = path.join(repoRoot, "artifacts");
1149+
await writeManifest(
1150+
"alpha",
1151+
"openclaw.plugin.json",
1152+
JSON.stringify({ id: "alpha", requiresPlugins: ["beta"] }),
1153+
);
1154+
await writeManifest("beta", "openclaw.plugin.json", JSON.stringify({ id: "beta" }));
1155+
await fs.writeFile(path.join(repoRoot, "extensions", "alpha", "index.ts"), "export {};\n");
1156+
await fs.writeFile(path.join(repoRoot, "extensions", "beta", "index.ts"), "export {};\n");
1157+
await fs.mkdir(path.join(repoRoot, "dist"), { recursive: true });
1158+
await fs.writeFile(
1159+
path.join(repoRoot, "dist", "entry.js"),
1160+
"if (process.argv[3] === 'inspect') console.log('{}');\n",
1161+
"utf8",
1162+
);
1163+
1164+
const result = spawnSync(
1165+
process.execPath,
1166+
[
1167+
path.resolve("scripts/check-plugin-gateway-gauntlet.mjs"),
1168+
"--repo-root",
1169+
repoRoot,
1170+
"--output-dir",
1171+
outputDir,
1172+
"--skip-prebuild",
1173+
"--skip-qa",
1174+
"--skip-slash-help",
1175+
"--plugin",
1176+
"alpha",
1177+
],
1178+
{
1179+
cwd: path.resolve("."),
1180+
encoding: "utf8",
1181+
},
1182+
);
1183+
1184+
expect(result.status, result.stderr).toBe(0);
1185+
const summary = JSON.parse(
1186+
await fs.readFile(path.join(outputDir, "plugin-gateway-gauntlet-summary.json"), "utf8"),
1187+
);
1188+
expect(summary.rows.map((row: { label: string }) => row.label)).toEqual([
1189+
"alpha-requires-beta-install",
1190+
"alpha-install",
1191+
"alpha-inspect",
1192+
"alpha-disable",
1193+
"alpha-enable",
1194+
"alpha-doctor",
1195+
"alpha-uninstall",
1196+
"alpha-requires-beta-uninstall",
1197+
]);
10891198
});
10901199

10911200
it("fails successful QA chunks whose summary reports failed scenarios", async () => {

0 commit comments

Comments
 (0)