From aa73df571d57fa7318b4a614319cd5cc771e472d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 16:22:21 +0100 Subject: [PATCH] perf: narrow auth test mocks --- .../oauth-lock-timeout-classification.test.ts | 26 ++-- .../auth-profiles/oauth-refresh-queue.test.ts | 39 +++--- .../oauth.adopt-identity.test.ts | 39 +++--- .../oauth.concurrent-agents.test.ts | 47 +++---- .../oauth.fallback-to-main-agent.test.ts | 26 +--- .../oauth.mirror-refresh.test.ts | 39 +++--- ...auth.openai-codex-refresh-fallback.test.ts | 20 ++- src/agents/context.eager-warmup.test.ts | 31 +---- src/agents/model-auth.profiles.test.ts | 115 ++++++++---------- src/agents/model-fallback.test.ts | 36 ++++-- src/agents/pi-auth-json.test.ts | 16 +-- src/agents/pi-auth-json.ts | 2 +- 12 files changed, 193 insertions(+), 243 deletions(-) diff --git a/src/agents/auth-profiles/oauth-lock-timeout-classification.test.ts b/src/agents/auth-profiles/oauth-lock-timeout-classification.test.ts index 8862b2e27e9..12b3db1684e 100644 --- a/src/agents/auth-profiles/oauth-lock-timeout-classification.test.ts +++ b/src/agents/auth-profiles/oauth-lock-timeout-classification.test.ts @@ -66,17 +66,21 @@ vi.mock("./external-auth.js", () => ({ shouldPersistExternalAuthProfile: () => true, })); -vi.mock("./external-cli-sync.js", async () => { - const actual = - await vi.importActual("./external-cli-sync.js"); - return { - ...actual, - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - }; -}); +vi.mock("./external-cli-sync.js", () => ({ + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: OAuthCredential | undefined, now = Date.now()) => + credential?.type === "oauth" && + credential.access.trim().length > 0 && + Number.isFinite(credential.expires) && + credential.expires - now > 5 * 60 * 1000, + isSafeToUseExternalCliCredential: () => true, + readExternalCliBootstrapCredential: () => null, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + shouldBootstrapFromExternalCliCredential: () => false, + shouldReplaceStoredOAuthCredential: (existing: unknown, incoming: unknown) => + existing !== incoming, +})); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index f980b71d489..84de0572e3c 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; +import { resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } from "./oauth.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, @@ -11,13 +12,6 @@ import { } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; -let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest; - -async function loadOAuthModuleForTest() { - ({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js")); -} - function resolveApiKeyForProfileInTest( params: Omit[0], "cfg">, ) { @@ -71,17 +65,21 @@ vi.mock("./external-auth.js", () => ({ shouldPersistExternalAuthProfile: () => true, })); -vi.mock("./external-cli-sync.js", async () => { - const actual = - await vi.importActual("./external-cli-sync.js"); - return { - ...actual, - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - }; -}); +vi.mock("./external-cli-sync.js", () => ({ + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: OAuthCredential | undefined, now = Date.now()) => + credential?.type === "oauth" && + credential.access.trim().length > 0 && + Number.isFinite(credential.expires) && + credential.expires - now > 5 * 60 * 1000, + isSafeToUseExternalCliCredential: () => true, + readExternalCliBootstrapCredential: () => null, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + shouldBootstrapFromExternalCliCredential: () => false, + shouldReplaceStoredOAuthCredential: (existing: unknown, incoming: unknown) => + existing !== incoming, +})); function createExpiredOauthStore(params: { profileId: string; @@ -123,7 +121,6 @@ describe("OAuth refresh in-process queue", () => { process.env.OPENCLAW_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir; await fs.mkdir(agentDir, { recursive: true }); - await loadOAuthModuleForTest(); resetOAuthRefreshQueuesForTest(); }); @@ -131,9 +128,7 @@ describe("OAuth refresh in-process queue", () => { envSnapshot.restore(); resetFileLockStateForTest(); clearRuntimeAuthProfileStoreSnapshots(); - if (resetOAuthRefreshQueuesForTest) { - resetOAuthRefreshQueuesForTest(); - } + resetOAuthRefreshQueuesForTest(); if (tempRoot) { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 34f41a4ed8c..3d89ec58db3 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; +import { resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } from "./oauth.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, @@ -18,13 +19,6 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; // is refused (sub store keeps its own credential; main's creds do not // leak through). -let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; -let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest; - -async function loadOAuthModuleForTest() { - ({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js")); -} - function resolveApiKeyForProfileInTest( params: Omit[0], "cfg">, ) { @@ -78,17 +72,21 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", async () => { - const actual = - await vi.importActual("./external-cli-sync.js"); - return { - ...actual, - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - }; -}); +vi.mock("./external-cli-sync.js", () => ({ + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: OAuthCredential | undefined, now = Date.now()) => + credential?.type === "oauth" && + credential.access.trim().length > 0 && + Number.isFinite(credential.expires) && + credential.expires - now > 5 * 60 * 1000, + isSafeToUseExternalCliCredential: () => true, + readExternalCliBootstrapCredential: () => null, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + shouldBootstrapFromExternalCliCredential: () => false, + shouldReplaceStoredOAuthCredential: (existing: unknown, incoming: unknown) => + existing !== incoming, +})); function oauthCred(params: { provider: string; @@ -127,7 +125,6 @@ describe("OAuth credential adoption is identity-gated", () => { process.env.OPENCLAW_AGENT_DIR = mainAgentDir; process.env.PI_CODING_AGENT_DIR = mainAgentDir; await fs.mkdir(mainAgentDir, { recursive: true }); - await loadOAuthModuleForTest(); resetOAuthRefreshQueuesForTest(); }); @@ -135,9 +132,7 @@ describe("OAuth credential adoption is identity-gated", () => { envSnapshot.restore(); resetFileLockStateForTest(); clearRuntimeAuthProfileStoreSnapshots(); - if (resetOAuthRefreshQueuesForTest) { - resetOAuthRefreshQueuesForTest(); - } + resetOAuthRefreshQueuesForTest(); if (tempRoot) { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts index 120827d1618..347bf7a87b5 100644 --- a/src/agents/auth-profiles/oauth.concurrent-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -64,17 +64,21 @@ vi.mock("./doctor.js", () => ({ // External-CLI sync does real I/O against the user's Codex/MiniMax CLI // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. -vi.mock("./external-cli-sync.js", async () => { - const actual = - await vi.importActual("./external-cli-sync.js"); - return { - ...actual, - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - }; -}); +vi.mock("./external-cli-sync.js", () => ({ + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: OAuthCredential | undefined, now = Date.now()) => + credential?.type === "oauth" && + credential.access.trim().length > 0 && + Number.isFinite(credential.expires) && + credential.expires - now > 5 * 60 * 1000, + isSafeToUseExternalCliCredential: () => true, + readExternalCliBootstrapCredential: () => null, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + shouldBootstrapFromExternalCliCredential: () => false, + shouldReplaceStoredOAuthCredential: (existing: unknown, incoming: unknown) => + existing !== incoming, +})); function createExpiredOauthStore(params: { profileId: string; @@ -139,16 +143,17 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } }); - it("refreshes exactly once when 20 agents share one OAuth profile and all race on expiry", async () => { + it("refreshes exactly once when many agents share one OAuth profile and all race on expiry", async () => { + const agentCount = 8; const profileId = "openai-codex:default"; const provider = "openai-codex"; const accountId = "acct-shared"; const freshExpiry = Date.now() + 60 * 60 * 1000; - // Seed 20 sub-agents + main with the SAME stale OAuth credential. Main is + // Seed sub-agents + main with the SAME stale OAuth credential. Main is // also expired so it cannot short-circuit via adoptNewerMainOAuthCredential. const subAgents = await Promise.all( - Array.from({ length: 20 }, async (_, i) => { + Array.from({ length: agentCount }, async (_, i) => { const dir = path.join(tempRoot, "agents", `sub-${i}`, "agent"); await fs.mkdir(dir, { recursive: true }); saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), dir); @@ -157,11 +162,11 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () ); saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), mainAgentDir); - // Count invocations, and add small jitter to widen the race window. + // Count invocations, and keep one event-loop turn to widen the race window. let callCount = 0; refreshProviderOAuthCredentialWithPluginMock.mockImplementation(async () => { callCount += 1; - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setImmediate(resolve)); return { type: "oauth", provider, @@ -172,10 +177,10 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () } as never; }); - // Fire all 20 agents concurrently. With the old per-agentDir lock this - // would produce ~20 concurrent refresh calls and 19 refresh_token_reused + // Fire all agents concurrently. With the old per-agentDir lock this + // would produce one refresh call per agent and refresh_token_reused // 401s. With the new global per-profile lock, only the first refresh is - // performed; the remaining 19 adopt the resulting fresh credentials. + // performed; the remaining agents adopt the resulting fresh credentials. const results = await Promise.all( subAgents.map((agentDir) => resolveApiKeyForProfileInTest({ @@ -187,11 +192,11 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () ); expect(callCount).toBe(1); - expect(results).toHaveLength(20); + expect(results).toHaveLength(agentCount); for (const result of results) { expect(result).not.toBeNull(); expect(result?.apiKey).toBe("cross-agent-refreshed-access"); expect(result?.provider).toBe(provider); } - }, 60_000); // Generous timeout; the fix should complete well under 5s in practice. + }, 10_000); }); diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index a2173077b96..e93c566e3a5 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; +import { resolveApiKeyForProfile } from "./oauth.js"; +import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore } from "./store.js"; import type { AuthProfileStore } from "./types.js"; const { getOAuthApiKeyMock } = vi.hoisted(() => ({ getOAuthApiKeyMock: vi.fn(async () => { @@ -11,15 +13,10 @@ const { getOAuthApiKeyMock } = vi.hoisted(() => ({ }), })); -vi.mock("@mariozechner/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@mariozechner/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: getOAuthApiKeyMock, - }; -}); +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: getOAuthApiKeyMock, + getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }], +})); vi.mock("../cli-credentials.js", () => ({ readCodexCliCredentialsCached: () => null, @@ -45,16 +42,6 @@ afterAll(() => { vi.doUnmock("../../plugins/provider-runtime.js"); }); -let clearRuntimeAuthProfileStoreSnapshots: typeof import("./store.js").clearRuntimeAuthProfileStoreSnapshots; -let ensureAuthProfileStore: typeof import("./store.js").ensureAuthProfileStore; -let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; - -async function loadFreshOAuthModuleForTest() { - vi.resetModules(); - ({ clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore } = await import("./store.js")); - ({ resolveApiKeyForProfile } = await import("./oauth.js")); -} - function createUsableOAuthExpiry(): number { return Date.now() + 30 * 60 * 1000; } @@ -85,7 +72,6 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { process.env.OPENCLAW_STATE_DIR = tmpDir; process.env.OPENCLAW_AGENT_DIR = mainAgentDir; process.env.PI_CODING_AGENT_DIR = mainAgentDir; - await loadFreshOAuthModuleForTest(); clearRuntimeAuthProfileStoreSnapshots(); }); diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 33b8eeff32e..a8d7e67d0cc 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; import { __testing as externalAuthTesting } from "./external-auth.js"; +import { resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } from "./oauth.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, @@ -12,13 +13,6 @@ import { } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; -let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest; - -async function loadOAuthModuleForTest() { - ({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js")); -} - function resolveApiKeyForProfileInTest( params: Omit[0], "cfg">, ) { @@ -75,17 +69,21 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", async () => { - const actual = - await vi.importActual("./external-cli-sync.js"); - return { - ...actual, - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, - }; -}); +vi.mock("./external-cli-sync.js", () => ({ + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + hasUsableOAuthCredential: (credential: OAuthCredential | undefined, now = Date.now()) => + credential?.type === "oauth" && + credential.access.trim().length > 0 && + Number.isFinite(credential.expires) && + credential.expires - now > 5 * 60 * 1000, + isSafeToUseExternalCliCredential: () => true, + readExternalCliBootstrapCredential: () => null, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + shouldBootstrapFromExternalCliCredential: () => false, + shouldReplaceStoredOAuthCredential: (existing: unknown, incoming: unknown) => + existing !== incoming, +})); function createExpiredOauthStore(params: { profileId: string; @@ -134,7 +132,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => process.env.OPENCLAW_AGENT_DIR = mainAgentDir; process.env.PI_CODING_AGENT_DIR = mainAgentDir; await fs.mkdir(mainAgentDir, { recursive: true }); - await loadOAuthModuleForTest(); resetOAuthRefreshQueuesForTest(); }); @@ -143,9 +140,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => resetFileLockStateForTest(); externalAuthTesting.resetResolveExternalAuthProfilesForTest(); clearRuntimeAuthProfileStoreSnapshots(); - if (resetOAuthRefreshQueuesForTest) { - resetOAuthRefreshQueuesForTest(); - } + resetOAuthRefreshQueuesForTest(); if (tempRoot) { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index a41fd07a82d..92c12f31d64 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -46,19 +46,13 @@ vi.mock("../cli-credentials.js", () => ({ resetCliCredentialCachesForTest: () => undefined, })); -vi.mock("@mariozechner/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@mariozechner/pi-ai/oauth", - ); - return { - ...actual, - getOAuthApiKey: getOAuthApiKeyMock, - getOAuthProviders: () => [ - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret - ], - }; -}); +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: getOAuthApiKeyMock, + getOAuthProviders: () => [ + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret + ], +})); vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock, diff --git a/src/agents/context.eager-warmup.test.ts b/src/agents/context.eager-warmup.test.ts index e8c3bae52ba..919b77576d8 100644 --- a/src/agents/context.eager-warmup.test.ts +++ b/src/agents/context.eager-warmup.test.ts @@ -2,20 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadConfigMock = vi.hoisted(() => vi.fn()); -// context.js and command-auth.js still read other config exports at import time, so this test only stubs loadConfig while keeping the rest of the module real. -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: loadConfigMock, - }; -}); +vi.mock("../config/config.js", () => ({ loadConfig: loadConfigMock })); describe("agents/context eager warmup", () => { const originalArgv = process.argv.slice(); beforeEach(() => { - vi.resetModules(); loadConfigMock.mockReset(); }); @@ -28,30 +20,15 @@ describe("agents/context eager warmup", () => { ["agent", ["node", "openclaw", "agent", "--message", "ok"]], ])("does not eager-load config for %s commands on import", async (_label, argv) => { process.argv = argv; + vi.resetModules(); await import("./context.js"); expect(loadConfigMock).not.toHaveBeenCalled(); }); - it("does not eager-load config when onboard imports command-auth through plugin-sdk", async () => { + it("does not eager-load config when plugin-sdk command-auth is imported", async () => { process.argv = ["node", "openclaw", "onboard"]; - - await import("../plugin-sdk/command-auth.js"); - - expect(loadConfigMock).not.toHaveBeenCalled(); - }); - - it("does not eager-load config when pairing approve imports command-auth through plugin-sdk", async () => { - process.argv = ["node", "openclaw", "pairing", "approve", "feishu", "BAH8YVB3"]; - - await import("../plugin-sdk/command-auth.js"); - - expect(loadConfigMock).not.toHaveBeenCalled(); - }); - - it("does not eager-load config when channels login imports command-auth through plugin-sdk", async () => { - process.argv = ["node", "openclaw", "channels", "login", "--channel", "openclaw-weixin"]; - + vi.resetModules(); await import("../plugin-sdk/command-auth.js"); expect(loadConfigMock).not.toHaveBeenCalled(); diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index bb6ac3883ad..d04f8c64ffa 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -13,69 +13,60 @@ import { resolveEnvApiKey, } from "./model-auth.js"; -vi.mock("../plugins/provider-runtime.js", async () => { - const actual = await vi.importActual( - "../plugins/provider-runtime.js", - ); - return { - ...actual, - buildProviderMissingAuthMessageWithPlugin: (params: { - provider: string; - context: { listProfileIds: (providerId: string) => string[] }; - }) => { - if ( - params.provider === "openai" && - params.context.listProfileIds("openai-codex").length > 0 - ) { - return 'No API key found for provider "openai". Use openai-codex/gpt-5.4.'; - } +vi.mock("../plugins/provider-runtime.js", () => ({ + buildProviderMissingAuthMessageWithPlugin: (params: { + provider: string; + context: { listProfileIds: (providerId: string) => string[] }; + }) => { + if (params.provider === "openai" && params.context.listProfileIds("openai-codex").length > 0) { + return 'No API key found for provider "openai". Use openai-codex/gpt-5.4.'; + } + return undefined; + }, + formatProviderAuthProfileApiKeyWithPlugin: async () => undefined, + refreshProviderOAuthCredentialWithPlugin: async () => null, + resolveProviderSyntheticAuthWithPlugin: (params: { + provider: string; + context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } }; + }) => { + if (params.provider !== "ollama" && params.provider !== "demo-local") { return undefined; - }, - formatProviderAuthProfileApiKeyWithPlugin: async () => undefined, - refreshProviderOAuthCredentialWithPlugin: async () => null, - resolveExternalAuthProfilesWithPlugins: () => [], - resolveProviderSyntheticAuthWithPlugin: (params: { - provider: string; - context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } }; - }) => { - if (params.provider !== "ollama" && params.provider !== "demo-local") { - return undefined; - } - const providerConfig = params.context.providerConfig; - const hasMeaningfulOllamaConfig = - params.provider !== "ollama" - ? Boolean(providerConfig?.api?.trim()) || - Boolean(providerConfig?.baseUrl?.trim()) || - (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) - : (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) || - Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") || - Boolean( - providerConfig?.baseUrl?.trim() && - providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434", - ); - if (!hasMeaningfulOllamaConfig) { - return undefined; - } - return { - apiKey: params.provider === "ollama" ? "ollama-local" : "demo-local", - source: `models.providers.${params.provider} (synthetic local key)`, - mode: "api-key" as const, - }; - }, - shouldDeferProviderSyntheticProfileAuthWithPlugin: (params: { - provider: string; - context: { resolvedApiKey?: string }; - }) => { - const expectedMarker = - params.provider === "ollama" - ? "ollama-local" - : params.provider === "demo-local" - ? "demo-local" - : undefined; - return Boolean(expectedMarker && params.context.resolvedApiKey?.trim() === expectedMarker); - }, - }; -}); + } + const providerConfig = params.context.providerConfig; + const hasMeaningfulOllamaConfig = + params.provider !== "ollama" + ? Boolean(providerConfig?.api?.trim()) || + Boolean(providerConfig?.baseUrl?.trim()) || + (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) + : (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) || + Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") || + Boolean( + providerConfig?.baseUrl?.trim() && + providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434", + ); + if (!hasMeaningfulOllamaConfig) { + return undefined; + } + return { + apiKey: params.provider === "ollama" ? "ollama-local" : "demo-local", + source: `models.providers.${params.provider} (synthetic local key)`, + mode: "api-key" as const, + }; + }, + resolveExternalAuthProfilesWithPlugins: () => [], + shouldDeferProviderSyntheticProfileAuthWithPlugin: (params: { + provider: string; + context: { resolvedApiKey?: string }; + }) => { + const expectedMarker = + params.provider === "ollama" + ? "ollama-local" + : params.provider === "demo-local" + ? "demo-local" + : undefined; + return Boolean(expectedMarker && params.context.resolvedApiKey?.trim() === expectedMarker); + }, +})); vi.mock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: () => null, diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index e9e3b81c28c..beb86bf8f40 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; @@ -16,6 +16,10 @@ import { LiveSessionModelSwitchError } from "./live-model-switch-error.js"; import { runWithImageModelFallback, runWithModelFallback } from "./model-fallback.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; +vi.mock("../infra/file-lock.js", () => ({ + withFileLock: async (_filePath: string, _options: unknown, run: () => Promise) => run(), +})); + vi.mock("../plugins/provider-runtime.js", () => ({ buildProviderMissingAuthMessageWithPlugin: () => undefined, resolveExternalAuthProfilesWithPlugins: () => [], @@ -24,6 +28,18 @@ vi.mock("../plugins/provider-runtime.js", () => ({ const makeCfg = makeModelFallbackCfg; const OPENROUTER_MODEL_NOT_FOUND_PAYLOAD = '{"error":{"message":"Healer Alpha was a stealth model revealed on March 18th as an early testing version of MiMo-V2-Omni. Find it here: https://openrouter.ai/xiaomi/mimo-v2-omni","code":404},"user_id":"user_33GTyP8uDSYYbaeBO48AGHXyuMC"}'; +let authTempRoot = ""; +let authTempCounter = 0; + +beforeAll(async () => { + authTempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-suite-")); +}); + +afterAll(async () => { + if (authTempRoot) { + await fs.rm(authTempRoot, { recursive: true, force: true }); + } +}); function makeFallbacksOnlyCfg(): OpenClawConfig { return { @@ -54,13 +70,15 @@ async function withTempAuthStore( store: AuthProfileStore, run: (tempDir: string) => Promise, ): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + const tempDir = await makeAuthTempDir(); saveAuthProfileStore(store, tempDir); - try { - return await run(tempDir); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + return await run(tempDir); +} + +async function makeAuthTempDir(): Promise { + const tempDir = path.join(authTempRoot, `case-${++authTempCounter}`); + await fs.mkdir(tempDir, { recursive: true }); + return tempDir; } async function runWithStoredAuth(params: { @@ -1369,7 +1387,7 @@ describe("runWithModelFallback", () => { provider: string, reason: "rate_limit" | "overloaded" | "timeout" | "auth" | "billing", ): Promise<{ store: AuthProfileStore; dir: string }> { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const tmpDir = await makeAuthTempDir(); const now = Date.now(); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, @@ -1540,7 +1558,7 @@ describe("runWithModelFallback", () => { }); it("tries cross-provider fallbacks when same provider has rate limit", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const tmpDir = await makeAuthTempDir(); const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: { diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 212756aaee4..f8e81ab79df 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -5,21 +5,11 @@ import { describe, expect, it, vi } from "vitest"; import { saveAuthProfileStore } from "./auth-profiles/store.js"; import { ensurePiAuthJsonFromAuthProfiles } from "./pi-auth-json.js"; -vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: () => [], +vi.mock("./auth-profiles/external-auth.js", () => ({ + overlayExternalAuthProfiles: (store: T) => store, + shouldPersistExternalAuthProfile: () => true, })); -vi.mock("./auth-profiles/external-cli-sync.js", async () => { - const actual = await vi.importActual( - "./auth-profiles/external-cli-sync.js", - ); - return { - ...actual, - readManagedExternalCliCredential: () => null, - resolveExternalCliAuthProfiles: () => [], - }; -}); - type AuthProfileStore = Parameters[0]; async function createAgentDir() { diff --git a/src/agents/pi-auth-json.ts b/src/agents/pi-auth-json.ts index 7565b5d9e96..a82bfe607aa 100644 --- a/src/agents/pi-auth-json.ts +++ b/src/agents/pi-auth-json.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { safeParseJsonWithSchema, safeParseWithSchema } from "../utils/zod-parse.js"; -import { ensureAuthProfileStore } from "./auth-profiles.js"; +import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { piCredentialsEqual, resolvePiCredentialMapFromStore,