diff --git a/extensions/qa-lab/src/docker-up.runtime.test.ts b/extensions/qa-lab/src/docker-up.runtime.test.ts index 40fe94333a1..d34d8395b29 100644 --- a/extensions/qa-lab/src/docker-up.runtime.test.ts +++ b/extensions/qa-lab/src/docker-up.runtime.test.ts @@ -136,6 +136,9 @@ describe("runQaDockerUp", () => { { async runCommand(command, args, cwd) { 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: "" }; }, fetchImpl: vi.fn(async () => ({ ok: true })), @@ -150,6 +153,7 @@ describe("runQaDockerUp", () => { 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")} up -d @${repoRoot}`, + `docker compose -f ${path.join(repoRoot, ".artifacts/qa-docker/docker-compose.qa.yml")} ps --format json openclaw-qa-gateway @${repoRoot}`, ]); } finally { await rm(repoRoot, { recursive: true, force: true }); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 28172f8bece..85b55c0206d 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -631,7 +631,8 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) { ) { 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) => ({ config, forwardedArgs, diff --git a/src/agents/bash-tools.exec-host-shared.test.ts b/src/agents/bash-tools.exec-host-shared.test.ts index fe71f60c055..f78ac989b45 100644 --- a/src/agents/bash-tools.exec-host-shared.test.ts +++ b/src/agents/bash-tools.exec-host-shared.test.ts @@ -1,8 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - sendExecApprovalFollowup: vi.fn(), - logWarn: vi.fn(), resolveExecApprovals: vi.fn(() => ({ defaults: { 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) => { const mod = await importOriginal(); 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 resolveExecApprovalUnavailableState: typeof import("./bash-tools.exec-host-shared.js").resolveExecApprovalUnavailableState; 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 () => { ({ @@ -55,14 +43,15 @@ beforeAll(async () => { resolveExecApprovalUnavailableState, buildExecApprovalPendingToolResult, } = await import("./bash-tools.exec-host-shared.js")); - ({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js")); - ({ logWarn } = await import("../logger.js")); }); describe("sendExecApprovalFollowupResult", () => { + const sendExecApprovalFollowup = vi.fn(); + const logWarn = vi.fn(); + beforeEach(() => { - vi.mocked(sendExecApprovalFollowup).mockReset(); - vi.mocked(logWarn).mockReset(); + sendExecApprovalFollowup.mockReset(); + logWarn.mockReset(); mocks.resolveExecApprovals.mockReset(); mocks.resolveExecApprovals.mockReturnValue({ defaults: { @@ -83,14 +72,15 @@ describe("sendExecApprovalFollowupResult", () => { }); 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 = { approvalId: "approval-log-once", sessionKey: "agent:main:main", }; - await sendExecApprovalFollowupResult(target, "Exec finished"); - await sendExecApprovalFollowupResult(target, "Exec finished"); + const deps = { sendExecApprovalFollowup, logWarn }; + await sendExecApprovalFollowupResult(target, "Exec finished", deps); + await sendExecApprovalFollowupResult(target, "Exec finished", deps); expect(logWarn).toHaveBeenCalledTimes(1); expect(logWarn).toHaveBeenCalledWith( @@ -99,7 +89,8 @@ describe("sendExecApprovalFollowupResult", () => { }); 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) { await sendExecApprovalFollowupResult( @@ -108,6 +99,7 @@ describe("sendExecApprovalFollowupResult", () => { sessionKey: "agent:main:main", }, "Exec finished", + deps, ); } await sendExecApprovalFollowupResult( @@ -116,6 +108,7 @@ describe("sendExecApprovalFollowupResult", () => { sessionKey: "agent:main:main", }, "Exec finished", + deps, ); expect(logWarn).toHaveBeenCalledTimes(maxExecApprovalFollowupFailureLogKeys + 2); diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index fa8b10538ff..793b05fba3d 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -90,6 +90,11 @@ export type ExecApprovalFollowupTarget = { turnSourceThreadId?: string | number; }; +export type ExecApprovalFollowupResultDeps = { + sendExecApprovalFollowup?: typeof sendExecApprovalFollowup; + logWarn?: typeof logWarn; +}; + export type DefaultExecApprovalRequestArgs = { warnings: string[]; approvalRunningNoticeMs: number; @@ -397,8 +402,11 @@ export function buildHeadlessExecApprovalDeniedMessage(params: { export async function sendExecApprovalFollowupResult( target: ExecApprovalFollowupTarget, resultText: string, + deps: ExecApprovalFollowupResultDeps = {}, ): Promise { - await sendExecApprovalFollowup({ + const send = deps.sendExecApprovalFollowup ?? sendExecApprovalFollowup; + const warn = deps.logWarn ?? logWarn; + await send({ approvalId: target.approvalId, sessionKey: target.sessionKey, turnSourceChannel: target.turnSourceChannel, @@ -412,7 +420,7 @@ export async function sendExecApprovalFollowupResult( if (!rememberExecApprovalFollowupFailureKey(key)) { return; } - logWarn(`exec approval followup dispatch failed (id=${target.approvalId}): ${message}`); + warn(`exec approval followup dispatch failed (id=${target.approvalId}): ${message}`); }); } diff --git a/src/agents/live-auth-keys.test.ts b/src/agents/live-auth-keys.test.ts index 94027a7e01a..6a8b8f42a43 100644 --- a/src/agents/live-auth-keys.test.ts +++ b/src/agents/live-auth-keys.test.ts @@ -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"); -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 clearPluginManifestRegistryCache: typeof import("../plugins/manifest-registry.js").clearPluginManifestRegistryCache; async function loadModulesForTest(): Promise { - ({ clearPluginManifestRegistryCache } = await import("../plugins/manifest-registry.js")); ({ collectProviderApiKeys } = await import("./live-auth-keys.js")); } -function clearManifestRegistryCache(): void { - clearPluginManifestRegistryCache(); -} - describe("collectProviderApiKeys", () => { beforeAll(async () => { - vi.doUnmock("../plugins/manifest-registry.js"); - vi.doUnmock("../secrets/provider-env-vars.js"); await loadModulesForTest(); }); - beforeEach(() => { - clearManifestRegistryCache(); - }); + it("honors provider auth env vars with nonstandard names", async () => { + const env = { MODELSTUDIO_API_KEY: "modelstudio-live-key" }; - afterEach(() => { - clearManifestRegistryCache(); - if (ORIGINAL_MODELSTUDIO_API_KEY === undefined) { - delete process.env.MODELSTUDIO_API_KEY; - } else { - process.env.MODELSTUDIO_API_KEY = ORIGINAL_MODELSTUDIO_API_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"); + expect( + collectProviderApiKeys("alibaba", { + env, + providerEnvVars: ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"], + }), + ).toEqual(["modelstudio-live-key"]); }); 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"]); }); }); diff --git a/src/agents/live-auth-keys.ts b/src/agents/live-auth-keys.ts index 5538a13ef1c..795a60b9663 100644 --- a/src/agents/live-auth-keys.ts +++ b/src/agents/live-auth-keys.ts @@ -21,6 +21,11 @@ type ProviderApiKeyConfig = { fallbackVars: string[]; }; +type CollectProviderApiKeysOptions = { + env?: NodeJS.ProcessEnv; + providerEnvVars?: readonly string[]; +}; + const PROVIDER_API_KEY_CONFIG: Record> = { anthropic: { liveSingle: "OPENCLAW_LIVE_ANTHROPIC_KEY", @@ -58,9 +63,9 @@ function parseKeyList(raw?: string | null): string[] { .filter(Boolean); } -function collectEnvPrefixedKeys(prefix: string): string[] { +function collectEnvPrefixedKeys(prefix: string, env: NodeJS.ProcessEnv): 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)) { 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 config = resolveProviderApiKeyConfig(normalizedProvider); const forcedSingle = config.liveSingle - ? normalizeOptionalString(process.env[config.liveSingle]) + ? normalizeOptionalString(env[config.liveSingle]) : undefined; if (forcedSingle) { return [forcedSingle]; } - const fromList = parseKeyList(config.listVar ? process.env[config.listVar] : undefined); - const primary = config.primaryVar - ? normalizeOptionalString(process.env[config.primaryVar]) - : undefined; - const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar) : []; + const fromList = parseKeyList(config.listVar ? env[config.listVar] : undefined); + const primary = config.primaryVar ? normalizeOptionalString(env[config.primaryVar]) : undefined; + const fromPrefixed = config.prefixedVar ? collectEnvPrefixedKeys(config.prefixedVar, env) : []; const fallback = config.fallbackVars - .map((envVar) => normalizeOptionalString(process.env[envVar])) + .map((envVar) => normalizeOptionalString(env[envVar])) .filter(Boolean) as string[]; - const manifestFallback = getProviderEnvVars(normalizedProvider) - .map((envVar) => normalizeOptionalString(process.env[envVar])) + const manifestEnvVars = options.providerEnvVars ?? getProviderEnvVars(normalizedProvider); + const manifestFallback = manifestEnvVars + .map((envVar) => normalizeOptionalString(env[envVar])) .filter(Boolean) as string[]; const seen = new Set(); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 957f83702f4..6ada3dc0d51 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -194,7 +194,7 @@ describe("scripts/test-projects changed-target routing", () => { }); 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; delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS; 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-agentic.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 { if (previousParallel === undefined) { @@ -316,7 +335,10 @@ describe("scripts/test-projects full-suite sharding", () => { "vitest.extension-voice-call.config.ts", "vitest.extension-whatsapp.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( plans.map((plan) => ({ diff --git a/vitest.extension-browser.config.ts b/vitest.extension-browser.config.ts new file mode 100644 index 00000000000..e08b7d723af --- /dev/null +++ b/vitest.extension-browser.config.ts @@ -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"], +}); diff --git a/vitest.extension-media.config.ts b/vitest.extension-media.config.ts new file mode 100644 index 00000000000..c8de79d87f4 --- /dev/null +++ b/vitest.extension-media.config.ts @@ -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"], + }, +); diff --git a/vitest.extension-misc.config.ts b/vitest.extension-misc.config.ts new file mode 100644 index 00000000000..30aaa5c4b65 --- /dev/null +++ b/vitest.extension-misc.config.ts @@ -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"], + }, +); diff --git a/vitest.extension-qa.config.ts b/vitest.extension-qa.config.ts new file mode 100644 index 00000000000..33466f7a28e --- /dev/null +++ b/vitest.extension-qa.config.ts @@ -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"], +}); diff --git a/vitest.test-shards.mjs b/vitest.test-shards.mjs index 7af04748efe..d0976e49810 100644 --- a/vitest.test-shards.mjs +++ b/vitest.test-shards.mjs @@ -114,7 +114,10 @@ export const fullSuiteVitestShards = [ "vitest.extension-voice-call.config.ts", "vitest.extension-whatsapp.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", ], }, ];