From 653100488d47f377de94391217d67e4dc23898be Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 14 Apr 2026 17:18:25 -0400 Subject: [PATCH] QA: fix matrix runner staging and host registration --- extensions/qa-lab/src/gateway-child.test.ts | 43 ++++++++++++++++++++- extensions/qa-lab/src/gateway-child.ts | 35 +++++++++++++++-- src/plugin-sdk/qa-runner-runtime.ts | 24 +++++++++--- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index c70559457b3..2b445abe97f 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -652,13 +652,14 @@ describe("qa bundled plugin dir", () => { ); }); - it("creates a scoped bundled plugin tree for the allowed plugins only", async () => { + it("creates a scoped bundled plugin tree for allowed plugins plus always-allowed runtime facades", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-scope-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true }); + await mkdir(path.join(repoRoot, "dist", "extensions", "speech-core"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "unused-plugin"), { recursive: true }); await writeFile(path.join(repoRoot, "dist", "shared-chunk-abc123.js"), "export {};\n", "utf8"); const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-target-")); @@ -672,7 +673,11 @@ describe("qa bundled plugin dir", () => { allowedPluginIds: ["qa-channel", "memory-core"], }); - expect((await readdir(bundledPluginsDir)).toSorted()).toEqual(["memory-core", "qa-channel"]); + expect((await readdir(bundledPluginsDir)).toSorted()).toEqual([ + "memory-core", + "qa-channel", + "speech-core", + ]); expect(bundledPluginsDir).toBe( path.join( repoRoot, @@ -688,6 +693,7 @@ describe("qa bundled plugin dir", () => { ); expect((await lstat(path.join(bundledPluginsDir, "qa-channel"))).isDirectory()).toBe(true); expect((await lstat(path.join(bundledPluginsDir, "memory-core"))).isDirectory()).toBe(true); + expect((await lstat(path.join(bundledPluginsDir, "speech-core"))).isDirectory()).toBe(true); await expect( lstat( path.join( @@ -854,4 +860,37 @@ describe("qa bundled plugin dir", () => { }), ).resolves.toBe("2026.4.8"); }); + + it("includes always-allowed runtime facade plugins when raising the QA runtime host version", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-runtime-version-runtime-facade-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify({ version: "2026.4.7-1" }), + "utf8", + ); + const bundledRoot = path.join(repoRoot, "extensions"); + await mkdir(path.join(bundledRoot, "qa-channel"), { recursive: true }); + await writeFile( + path.join(bundledRoot, "qa-channel", "package.json"), + JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.8" } } }), + "utf8", + ); + await mkdir(path.join(bundledRoot, "speech-core"), { recursive: true }); + await writeFile( + path.join(bundledRoot, "speech-core", "package.json"), + JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.9" } } }), + "utf8", + ); + + await expect( + __testing.resolveQaRuntimeHostVersion({ + repoRoot, + bundledPluginsSourceRoot: bundledRoot, + allowedPluginIds: ["qa-channel"], + }), + ).resolves.toBe("2026.4.9"); + }); }); diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 94c6e69edf7..746e47034e2 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -78,6 +78,14 @@ const QA_MOCK_BLOCKED_ENV_KEY_PATTERNS = Object.freeze([ const QA_LIVE_PROVIDER_CONFIG_PATH_ENV = "OPENCLAW_QA_LIVE_PROVIDER_CONFIG_PATH"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN"; +// Keep this in sync with the facade runtime's always-allowed bundled surfaces. +// QA child staging must include these runtime helpers even when they are not in +// cfg.plugins.allow, otherwise lazy facade loads can fail inside the child. +const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([ + "image-generation-core", + "media-understanding-core", + "speech-core", +]); const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ID = "anthropic:qa-setup-token"; @@ -765,8 +773,12 @@ async function resolveQaRuntimeHostVersion(params: { const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8"); const rootPackage = JSON.parse(rootPackageRaw) as { version?: string }; let selected = parseStableSemverFloor(rootPackage.version); + const stagedPluginIds = collectQaBundledPluginIds({ + sourceRoot: params.bundledPluginsSourceRoot, + allowedPluginIds: params.allowedPluginIds, + }); - for (const pluginId of params.allowedPluginIds) { + for (const pluginId of stagedPluginIds) { const packagePath = path.join(params.bundledPluginsSourceRoot, pluginId, "package.json"); if (!existsSync(packagePath)) { continue; @@ -788,12 +800,29 @@ async function resolveQaRuntimeHostVersion(params: { return selected?.label; } +function collectQaBundledPluginIds(params: { + sourceRoot: string; + allowedPluginIds: readonly string[]; +}) { + const pluginIds = new Set(params.allowedPluginIds); + for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) { + if (existsSync(path.join(params.sourceRoot, pluginId))) { + pluginIds.add(pluginId); + } + } + return [...pluginIds]; +} + async function createQaBundledPluginsDir(params: { repoRoot: string; tempRoot: string; allowedPluginIds: readonly string[]; }) { const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot); + const stagedPluginIds = collectQaBundledPluginIds({ + sourceRoot, + allowedPluginIds: params.allowedPluginIds, + }); const sourceTreeRoot = path.dirname(sourceRoot); if ( sourceTreeRoot === path.join(params.repoRoot, "dist") || @@ -814,7 +843,7 @@ async function createQaBundledPluginsDir(params: { const targetPath = path.join(stagedTreeRoot, entry.name); if (entry.name === "extensions") { await fs.mkdir(targetPath, { recursive: true }); - for (const pluginId of params.allowedPluginIds) { + for (const pluginId of stagedPluginIds) { const sourceDir = path.join(sourceRoot, pluginId); if (!existsSync(sourceDir)) { throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`); @@ -834,7 +863,7 @@ async function createQaBundledPluginsDir(params: { const bundledPluginsDir = path.join(params.tempRoot, "bundled-plugins"); await fs.mkdir(bundledPluginsDir, { recursive: true }); - for (const pluginId of params.allowedPluginIds) { + for (const pluginId of stagedPluginIds) { const sourceDir = path.join(sourceRoot, pluginId); if (!existsSync(sourceDir)) { throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`); diff --git a/src/plugin-sdk/qa-runner-runtime.ts b/src/plugin-sdk/qa-runner-runtime.ts index 381fa3eb27f..49d0634c17d 100644 --- a/src/plugin-sdk/qa-runner-runtime.ts +++ b/src/plugin-sdk/qa-runner-runtime.ts @@ -2,7 +2,10 @@ import type { Command } from "commander"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { listBundledQaRunnerCatalog } from "../plugins/qa-runner-catalog.js"; -import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { + loadBundledPluginPublicSurfaceModuleSync, + tryLoadActivatedBundledPluginPublicSurfaceModuleSync, +} from "./facade-runtime.js"; export type QaRunnerCliRegistration = { commandName: string; @@ -98,6 +101,19 @@ function buildKnownQaRunnerCatalog(): readonly QaRunnerCliContribution[] { }); } +function loadQaRunnerRuntimeSurface(plugin: PluginManifestRecord): QaRunnerRuntimeSurface | null { + if (plugin.origin === "bundled") { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: plugin.id, + artifactBasename: "runtime-api.js", + }); + } + return tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: plugin.id, + artifactBasename: "runtime-api.js", + }); +} + export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution[] { const contributions = new Map(); @@ -106,11 +122,7 @@ export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution } for (const plugin of listDeclaredQaRunnerPlugins()) { - const runtimeSurface = - tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ - dirName: plugin.id, - artifactBasename: "runtime-api.js", - }); + const runtimeSurface = loadQaRunnerRuntimeSurface(plugin); const runtimeRegistrationByCommandName = runtimeSurface ? indexRuntimeRegistrations(plugin.id, runtimeSurface) : null;