diff --git a/scripts/check-plugin-gateway-gauntlet.mjs b/scripts/check-plugin-gateway-gauntlet.mjs index 78d7d3c577c2..e95e3cef8234 100644 --- a/scripts/check-plugin-gateway-gauntlet.mjs +++ b/scripts/check-plugin-gateway-gauntlet.mjs @@ -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(",") : ""; 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, diff --git a/scripts/lib/plugin-gateway-gauntlet.mjs b/scripts/lib/plugin-gateway-gauntlet.mjs index 506cf4800aa2..b8d63693fbda 100644 --- a/scripts/lib/plugin-gateway-gauntlet.mjs +++ b/scripts/lib/plugin-gateway-gauntlet.mjs @@ -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, diff --git a/test/scripts/plugin-gateway-gauntlet.test.ts b/test/scripts/plugin-gateway-gauntlet.test.ts index da5329413178..a05e09e4832a 100644 --- a/test/scripts/plugin-gateway-gauntlet.test.ts +++ b/test/scripts/plugin-gateway-gauntlet.test.ts @@ -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 () => {