test: stabilize full-suite execution

This commit is contained in:
Peter Steinberger
2026-04-08 19:39:43 +01:00
parent 365524fc2b
commit a3d21539ef
12 changed files with 167 additions and 74 deletions
@@ -136,6 +136,9 @@ describe("runQaDockerUp", () => {
{ {
async runCommand(command, args, cwd) { async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" ")); calls.push([command, ...args, `@${cwd}`].join(" "));
if (args.join(" ").includes("ps --format json openclaw-qa-gateway")) {
return { stdout: '{"Health":"healthy","State":"running"}\n', stderr: "" };
}
return { stdout: "", stderr: "" }; return { stdout: "", stderr: "" };
}, },
fetchImpl: vi.fn(async () => ({ ok: true })), fetchImpl: vi.fn(async () => ({ ok: true })),
@@ -150,6 +153,7 @@ describe("runQaDockerUp", () => {
expect(calls).toEqual([ expect(calls).toEqual([
`docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} down --remove-orphans @${repoRoot}`, `docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} down --remove-orphans @${repoRoot}`,
`docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} up -d @${repoRoot}`, `docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} up -d @${repoRoot}`,
`docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} ps --format json openclaw-qa-gateway @${repoRoot}`,
]); ]);
} finally { } finally {
await rm(repoRoot, { recursive: true, force: true }); await rm(repoRoot, { recursive: true, force: true });
+2 -1
View File
@@ -631,7 +631,8 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
) { ) {
return []; return [];
} }
const configs = expandToProjectConfigs ? shard.projects : [shard.config]; const expandShard = expandToProjectConfigs || shard.config === FULL_EXTENSIONS_VITEST_CONFIG;
const configs = expandShard ? shard.projects : [shard.config];
return configs.map((config) => ({ return configs.map((config) => ({
config, config,
forwardedArgs, forwardedArgs,
+13 -20
View File
@@ -1,8 +1,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
sendExecApprovalFollowup: vi.fn(),
logWarn: vi.fn(),
resolveExecApprovals: vi.fn(() => ({ resolveExecApprovals: vi.fn(() => ({
defaults: { defaults: {
security: "allowlist", security: "allowlist",
@@ -21,14 +19,6 @@ const mocks = vi.hoisted(() => ({
})), })),
})); }));
vi.mock("./bash-tools.exec-approval-followup.js", () => ({
sendExecApprovalFollowup: mocks.sendExecApprovalFollowup,
}));
vi.mock("../logger.js", () => ({
logWarn: mocks.logWarn,
}));
vi.mock("../infra/exec-approvals.js", async (importOriginal) => { vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>(); const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
return { return {
@@ -43,8 +33,6 @@ let enforceStrictInlineEvalApprovalBoundary: typeof import("./bash-tools.exec-ho
let resolveExecHostApprovalContext: typeof import("./bash-tools.exec-host-shared.js").resolveExecHostApprovalContext; let resolveExecHostApprovalContext: typeof import("./bash-tools.exec-host-shared.js").resolveExecHostApprovalContext;
let resolveExecApprovalUnavailableState: typeof import("./bash-tools.exec-host-shared.js").resolveExecApprovalUnavailableState; let resolveExecApprovalUnavailableState: typeof import("./bash-tools.exec-host-shared.js").resolveExecApprovalUnavailableState;
let buildExecApprovalPendingToolResult: typeof import("./bash-tools.exec-host-shared.js").buildExecApprovalPendingToolResult; let buildExecApprovalPendingToolResult: typeof import("./bash-tools.exec-host-shared.js").buildExecApprovalPendingToolResult;
let sendExecApprovalFollowup: typeof import("./bash-tools.exec-approval-followup.js").sendExecApprovalFollowup;
let logWarn: typeof import("../logger.js").logWarn;
beforeAll(async () => { beforeAll(async () => {
({ ({
@@ -55,14 +43,15 @@ beforeAll(async () => {
resolveExecApprovalUnavailableState, resolveExecApprovalUnavailableState,
buildExecApprovalPendingToolResult, buildExecApprovalPendingToolResult,
} = await import("./bash-tools.exec-host-shared.js")); } = await import("./bash-tools.exec-host-shared.js"));
({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js"));
({ logWarn } = await import("../logger.js"));
}); });
describe("sendExecApprovalFollowupResult", () => { describe("sendExecApprovalFollowupResult", () => {
const sendExecApprovalFollowup = vi.fn();
const logWarn = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.mocked(sendExecApprovalFollowup).mockReset(); sendExecApprovalFollowup.mockReset();
vi.mocked(logWarn).mockReset(); logWarn.mockReset();
mocks.resolveExecApprovals.mockReset(); mocks.resolveExecApprovals.mockReset();
mocks.resolveExecApprovals.mockReturnValue({ mocks.resolveExecApprovals.mockReturnValue({
defaults: { defaults: {
@@ -83,14 +72,15 @@ describe("sendExecApprovalFollowupResult", () => {
}); });
it("logs repeated followup dispatch failures once per approval id and error message", async () => { it("logs repeated followup dispatch failures once per approval id and error message", async () => {
vi.mocked(sendExecApprovalFollowup).mockRejectedValue(new Error("Channel is required")); sendExecApprovalFollowup.mockRejectedValue(new Error("Channel is required"));
const target = { const target = {
approvalId: "approval-log-once", approvalId: "approval-log-once",
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
}; };
await sendExecApprovalFollowupResult(target, "Exec finished"); const deps = { sendExecApprovalFollowup, logWarn };
await sendExecApprovalFollowupResult(target, "Exec finished"); await sendExecApprovalFollowupResult(target, "Exec finished", deps);
await sendExecApprovalFollowupResult(target, "Exec finished", deps);
expect(logWarn).toHaveBeenCalledTimes(1); expect(logWarn).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith( expect(logWarn).toHaveBeenCalledWith(
@@ -99,7 +89,8 @@ describe("sendExecApprovalFollowupResult", () => {
}); });
it("evicts oldest followup failure dedupe keys after reaching the cap", async () => { it("evicts oldest followup failure dedupe keys after reaching the cap", async () => {
vi.mocked(sendExecApprovalFollowup).mockRejectedValue(new Error("Channel is required")); sendExecApprovalFollowup.mockRejectedValue(new Error("Channel is required"));
const deps = { sendExecApprovalFollowup, logWarn };
for (let i = 0; i <= maxExecApprovalFollowupFailureLogKeys; i += 1) { for (let i = 0; i <= maxExecApprovalFollowupFailureLogKeys; i += 1) {
await sendExecApprovalFollowupResult( await sendExecApprovalFollowupResult(
@@ -108,6 +99,7 @@ describe("sendExecApprovalFollowupResult", () => {
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
}, },
"Exec finished", "Exec finished",
deps,
); );
} }
await sendExecApprovalFollowupResult( await sendExecApprovalFollowupResult(
@@ -116,6 +108,7 @@ describe("sendExecApprovalFollowupResult", () => {
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
}, },
"Exec finished", "Exec finished",
deps,
); );
expect(logWarn).toHaveBeenCalledTimes(maxExecApprovalFollowupFailureLogKeys + 2); expect(logWarn).toHaveBeenCalledTimes(maxExecApprovalFollowupFailureLogKeys + 2);
+10 -2
View File
@@ -90,6 +90,11 @@ export type ExecApprovalFollowupTarget = {
turnSourceThreadId?: string | number; turnSourceThreadId?: string | number;
}; };
export type ExecApprovalFollowupResultDeps = {
sendExecApprovalFollowup?: typeof sendExecApprovalFollowup;
logWarn?: typeof logWarn;
};
export type DefaultExecApprovalRequestArgs = { export type DefaultExecApprovalRequestArgs = {
warnings: string[]; warnings: string[];
approvalRunningNoticeMs: number; approvalRunningNoticeMs: number;
@@ -397,8 +402,11 @@ export function buildHeadlessExecApprovalDeniedMessage(params: {
export async function sendExecApprovalFollowupResult( export async function sendExecApprovalFollowupResult(
target: ExecApprovalFollowupTarget, target: ExecApprovalFollowupTarget,
resultText: string, resultText: string,
deps: ExecApprovalFollowupResultDeps = {},
): Promise<void> { ): Promise<void> {
await sendExecApprovalFollowup({ const send = deps.sendExecApprovalFollowup ?? sendExecApprovalFollowup;
const warn = deps.logWarn ?? logWarn;
await send({
approvalId: target.approvalId, approvalId: target.approvalId,
sessionKey: target.sessionKey, sessionKey: target.sessionKey,
turnSourceChannel: target.turnSourceChannel, turnSourceChannel: target.turnSourceChannel,
@@ -412,7 +420,7 @@ export async function sendExecApprovalFollowupResult(
if (!rememberExecApprovalFollowupFailureKey(key)) { if (!rememberExecApprovalFollowupFailureKey(key)) {
return; return;
} }
logWarn(`exec approval followup dispatch failed (id=${target.approvalId}): ${message}`); warn(`exec approval followup dispatch failed (id=${target.approvalId}): ${message}`);
}); });
} }
+16 -35
View File
@@ -1,56 +1,37 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, describe, expect, it, vi } from "vitest";
vi.unmock("../plugins/manifest-registry.js");
vi.unmock("../secrets/provider-env-vars.js"); vi.unmock("../secrets/provider-env-vars.js");
const ORIGINAL_MODELSTUDIO_API_KEY = process.env.MODELSTUDIO_API_KEY;
const ORIGINAL_XAI_API_KEY = process.env.XAI_API_KEY;
let collectProviderApiKeys: typeof import("./live-auth-keys.js").collectProviderApiKeys; let collectProviderApiKeys: typeof import("./live-auth-keys.js").collectProviderApiKeys;
let clearPluginManifestRegistryCache: typeof import("../plugins/manifest-registry.js").clearPluginManifestRegistryCache;
async function loadModulesForTest(): Promise<void> { async function loadModulesForTest(): Promise<void> {
({ clearPluginManifestRegistryCache } = await import("../plugins/manifest-registry.js"));
({ collectProviderApiKeys } = await import("./live-auth-keys.js")); ({ collectProviderApiKeys } = await import("./live-auth-keys.js"));
} }
function clearManifestRegistryCache(): void {
clearPluginManifestRegistryCache();
}
describe("collectProviderApiKeys", () => { describe("collectProviderApiKeys", () => {
beforeAll(async () => { beforeAll(async () => {
vi.doUnmock("../plugins/manifest-registry.js");
vi.doUnmock("../secrets/provider-env-vars.js");
await loadModulesForTest(); await loadModulesForTest();
}); });
beforeEach(() => { it("honors provider auth env vars with nonstandard names", async () => {
clearManifestRegistryCache(); const env = { MODELSTUDIO_API_KEY: "modelstudio-live-key" };
});
afterEach(() => { expect(
clearManifestRegistryCache(); collectProviderApiKeys("alibaba", {
if (ORIGINAL_MODELSTUDIO_API_KEY === undefined) { env,
delete process.env.MODELSTUDIO_API_KEY; providerEnvVars: ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
} else { }),
process.env.MODELSTUDIO_API_KEY = ORIGINAL_MODELSTUDIO_API_KEY; ).toEqual(["modelstudio-live-key"]);
}
if (ORIGINAL_XAI_API_KEY === undefined) {
delete process.env.XAI_API_KEY;
} else {
process.env.XAI_API_KEY = ORIGINAL_XAI_API_KEY;
}
});
it("honors manifest-declared provider auth env vars for nonstandard provider ids", async () => {
process.env.MODELSTUDIO_API_KEY = "modelstudio-live-key";
expect(collectProviderApiKeys("alibaba")).toContain("modelstudio-live-key");
}); });
it("dedupes manifest env vars against direct provider env naming", async () => { it("dedupes manifest env vars against direct provider env naming", async () => {
process.env.XAI_API_KEY = "xai-live-key"; const env = { XAI_API_KEY: "xai-live-key" };
expect(collectProviderApiKeys("xai")).toEqual(["xai-live-key"]); expect(
collectProviderApiKeys("xai", {
env,
providerEnvVars: ["XAI_API_KEY"],
}),
).toEqual(["xai-live-key"]);
}); });
}); });
+20 -12
View File
@@ -21,6 +21,11 @@ type ProviderApiKeyConfig = {
fallbackVars: string[]; fallbackVars: string[];
}; };
type CollectProviderApiKeysOptions = {
env?: NodeJS.ProcessEnv;
providerEnvVars?: readonly string[];
};
const PROVIDER_API_KEY_CONFIG: Record<string, Omit<ProviderApiKeyConfig, "fallbackVars">> = { const PROVIDER_API_KEY_CONFIG: Record<string, Omit<ProviderApiKeyConfig, "fallbackVars">> = {
anthropic: { anthropic: {
liveSingle: "OPENCLAW_LIVE_ANTHROPIC_KEY", liveSingle: "OPENCLAW_LIVE_ANTHROPIC_KEY",
@@ -58,9 +63,9 @@ function parseKeyList(raw?: string | null): string[] {
.filter(Boolean); .filter(Boolean);
} }
function collectEnvPrefixedKeys(prefix: string): string[] { function collectEnvPrefixedKeys(prefix: string, env: NodeJS.ProcessEnv): string[] {
const keys: string[] = []; const keys: string[] = [];
for (const [name, value] of Object.entries(process.env)) { for (const [name, value] of Object.entries(env)) {
if (!name.startsWith(prefix)) { if (!name.startsWith(prefix)) {
continue; continue;
} }
@@ -102,28 +107,31 @@ function resolveProviderApiKeyConfig(provider: string): ProviderApiKeyConfig {
}; };
} }
export function collectProviderApiKeys(provider: string): string[] { export function collectProviderApiKeys(
provider: string,
options: CollectProviderApiKeysOptions = {},
): string[] {
const env = options.env ?? process.env;
const normalizedProvider = normalizeProviderId(provider); const normalizedProvider = normalizeProviderId(provider);
const config = resolveProviderApiKeyConfig(normalizedProvider); const config = resolveProviderApiKeyConfig(normalizedProvider);
const forcedSingle = config.liveSingle const forcedSingle = config.liveSingle
? normalizeOptionalString(process.env[config.liveSingle]) ? normalizeOptionalString(env[config.liveSingle])
: undefined; : undefined;
if (forcedSingle) { if (forcedSingle) {
return [forcedSingle]; return [forcedSingle];
} }
const fromList = parseKeyList(config.listVar ? process.env[config.listVar] : undefined); const fromList = parseKeyList(config.listVar ? env[config.listVar] : undefined);
const primary = config.primaryVar const primary = config.primaryVar ? normalizeOptionalString(env[config.primaryVar]) : undefined;
? normalizeOptionalString(process.env[config.primaryVar]) const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar, env) : [];
: undefined;
const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar) : [];
const fallback = config.fallbackVars const fallback = config.fallbackVars
.map((envVar) => normalizeOptionalString(process.env[envVar])) .map((envVar) => normalizeOptionalString(env[envVar]))
.filter(Boolean) as string[]; .filter(Boolean) as string[];
const manifestFallback = getProviderEnvVars(normalizedProvider) const manifestEnvVars = options.providerEnvVars ?? getProviderEnvVars(normalizedProvider);
.map((envVar) => normalizeOptionalString(process.env[envVar])) const manifestFallback = manifestEnvVars
.map((envVar) => normalizeOptionalString(env[envVar]))
.filter(Boolean) as string[]; .filter(Boolean) as string[];
const seen = new Set<string>(); const seen = new Set<string>();
+25 -3
View File
@@ -194,7 +194,7 @@ describe("scripts/test-projects changed-target routing", () => {
}); });
describe("scripts/test-projects full-suite sharding", () => { describe("scripts/test-projects full-suite sharding", () => {
it("splits untargeted runs into fixed shard configs", () => { it("splits untargeted runs into fixed core shards and per-extension configs", () => {
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL; const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS; delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD; delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
@@ -212,7 +212,26 @@ describe("scripts/test-projects full-suite sharding", () => {
"vitest.full-core-runtime.config.ts", "vitest.full-core-runtime.config.ts",
"vitest.full-agentic.config.ts", "vitest.full-agentic.config.ts",
"vitest.full-auto-reply.config.ts", "vitest.full-auto-reply.config.ts",
"vitest.full-extensions.config.ts", "vitest.extension-acpx.config.ts",
"vitest.extension-bluebubbles.config.ts",
"vitest.extension-channels.config.ts",
"vitest.extension-diffs.config.ts",
"vitest.extension-feishu.config.ts",
"vitest.extension-irc.config.ts",
"vitest.extension-mattermost.config.ts",
"vitest.extension-matrix.config.ts",
"vitest.extension-memory.config.ts",
"vitest.extension-messaging.config.ts",
"vitest.extension-msteams.config.ts",
"vitest.extension-providers.config.ts",
"vitest.extension-telegram.config.ts",
"vitest.extension-voice-call.config.ts",
"vitest.extension-whatsapp.config.ts",
"vitest.extension-zalo.config.ts",
"vitest.extension-browser.config.ts",
"vitest.extension-qa.config.ts",
"vitest.extension-media.config.ts",
"vitest.extension-misc.config.ts",
]); ]);
} finally { } finally {
if (previousParallel === undefined) { if (previousParallel === undefined) {
@@ -316,7 +335,10 @@ describe("scripts/test-projects full-suite sharding", () => {
"vitest.extension-voice-call.config.ts", "vitest.extension-voice-call.config.ts",
"vitest.extension-whatsapp.config.ts", "vitest.extension-whatsapp.config.ts",
"vitest.extension-zalo.config.ts", "vitest.extension-zalo.config.ts",
"vitest.extensions.config.ts", "vitest.extension-browser.config.ts",
"vitest.extension-qa.config.ts",
"vitest.extension-media.config.ts",
"vitest.extension-misc.config.ts",
]); ]);
expect(plans).toEqual( expect(plans).toEqual(
plans.map((plan) => ({ plans.map((plan) => ({
+8
View File
@@ -0,0 +1,8 @@
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
export default createScopedVitestConfig(["browser/**/*.test.ts"], {
dir: "extensions",
name: "extension-browser",
passWithNoTests: true,
setupFiles: ["test/setup.extensions.ts"],
});
+22
View File
@@ -0,0 +1,22 @@
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
export default createScopedVitestConfig(
[
"alibaba/**/*.test.ts",
"deepgram/**/*.test.ts",
"elevenlabs/**/*.test.ts",
"fal/**/*.test.ts",
"image-generation-core/**/*.test.ts",
"runway/**/*.test.ts",
"talk-voice/**/*.test.ts",
"video-generation-core/**/*.test.ts",
"vydra/**/*.test.ts",
"xiaomi/**/*.test.ts",
],
{
dir: "extensions",
name: "extension-media",
passWithNoTests: true,
setupFiles: ["test/setup.extensions.ts"],
},
);
+35
View File
@@ -0,0 +1,35 @@
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
export default createScopedVitestConfig(
[
"arcee/**/*.test.ts",
"brave/**/*.test.ts",
"device-pair/**/*.test.ts",
"diagnostics-otel/**/*.test.ts",
"duckduckgo/**/*.test.ts",
"exa/**/*.test.ts",
"firecrawl/**/*.test.ts",
"fireworks/**/*.test.ts",
"kilocode/**/*.test.ts",
"litellm/**/*.test.ts",
"llm-task/**/*.test.ts",
"lobster/**/*.test.ts",
"opencode/**/*.test.ts",
"opencode-go/**/*.test.ts",
"openshell/**/*.test.ts",
"perplexity/**/*.test.ts",
"phone-control/**/*.test.ts",
"searxng/**/*.test.ts",
"synthetic/**/*.test.ts",
"tavily/**/*.test.ts",
"thread-ownership/**/*.test.ts",
"vercel-ai-gateway/**/*.test.ts",
"webhooks/**/*.test.ts",
],
{
dir: "extensions",
name: "extension-misc",
passWithNoTests: true,
setupFiles: ["test/setup.extensions.ts"],
},
);
+8
View File
@@ -0,0 +1,8 @@
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
export default createScopedVitestConfig(["qa-channel/**/*.test.ts", "qa-lab/**/*.test.ts"], {
dir: "extensions",
name: "extension-qa",
passWithNoTests: true,
setupFiles: ["test/setup.extensions.ts"],
});
+4 -1
View File
@@ -114,7 +114,10 @@ export const fullSuiteVitestShards = [
"vitest.extension-voice-call.config.ts", "vitest.extension-voice-call.config.ts",
"vitest.extension-whatsapp.config.ts", "vitest.extension-whatsapp.config.ts",
"vitest.extension-zalo.config.ts", "vitest.extension-zalo.config.ts",
"vitest.extensions.config.ts", "vitest.extension-browser.config.ts",
"vitest.extension-qa.config.ts",
"vitest.extension-media.config.ts",
"vitest.extension-misc.config.ts",
], ],
}, },
]; ];