QA: fix matrix runner staging and host registration

This commit is contained in:
Gustavo Madeira Santana
2026-04-14 17:18:25 -04:00
parent 731d4666d2
commit 653100488d
3 changed files with 91 additions and 11 deletions

View File

@@ -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");
});
});

View File

@@ -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})`);

View File

@@ -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<QaRunnerRuntimeSurface>({
dirName: plugin.id,
artifactBasename: "runtime-api.js",
});
}
return tryLoadActivatedBundledPluginPublicSurfaceModuleSync<QaRunnerRuntimeSurface>({
dirName: plugin.id,
artifactBasename: "runtime-api.js",
});
}
export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution[] {
const contributions = new Map<string, QaRunnerCliContribution>();
@@ -106,11 +122,7 @@ export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution
}
for (const plugin of listDeclaredQaRunnerPlugins()) {
const runtimeSurface =
tryLoadActivatedBundledPluginPublicSurfaceModuleSync<QaRunnerRuntimeSurface>({
dirName: plugin.id,
artifactBasename: "runtime-api.js",
});
const runtimeSurface = loadQaRunnerRuntimeSurface(plugin);
const runtimeRegistrationByCommandName = runtimeSurface
? indexRuntimeRegistrations(plugin.id, runtimeSurface)
: null;