From 4234d9b42c49a60f77f9db9d4a99589b85bb64aa Mon Sep 17 00:00:00 2001 From: huntharo Date: Tue, 17 Mar 2026 09:29:56 -0400 Subject: [PATCH 1/4] tests: fix googlechat outbound partial mock --- extensions/googlechat/src/channel.outbound.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts index c9180dd8158..b936a5e3139 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -4,10 +4,14 @@ import { describe, expect, it, vi } from "vitest"; const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn()); -vi.mock("./api.js", () => ({ - sendGoogleChatMessage: sendGoogleChatMessageMock, - uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, -})); +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendGoogleChatMessage: sendGoogleChatMessageMock, + uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, + }; +}); import { googlechatPlugin } from "./channel.js"; import { setGoogleChatRuntime } from "./runtime.js"; From a413da9ccaaccd6665390f0a748259811e89386b Mon Sep 17 00:00:00 2001 From: huntharo Date: Tue, 17 Mar 2026 09:30:01 -0400 Subject: [PATCH 2/4] tests(google): inject oauth credential fs stubs --- extensions/google/oauth.credentials.ts | 37 +++++++++++++++++----- extensions/google/oauth.test.ts | 43 ++++++++++++++------------ 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index 1c1e88db042..670ae4de943 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -1,7 +1,27 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import type { Dirent } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; +type CredentialFs = { + existsSync: (path: Parameters[0]) => ReturnType; + readFileSync: (path: Parameters[0], encoding: "utf8") => string; + realpathSync: (path: Parameters[0]) => string; + readdirSync: ( + path: Parameters[0], + options: { withFileTypes: true }, + ) => Dirent[]; +}; + +const defaultFs: CredentialFs = { + existsSync, + readFileSync, + realpathSync, + readdirSync, +}; + +let credentialFs: CredentialFs = defaultFs; + function resolveEnv(keys: string[]): string | undefined { for (const key of keys) { const value = process.env[key]?.trim(); @@ -18,6 +38,10 @@ export function clearCredentialsCache(): void { cachedGeminiCliCredentials = null; } +export function setOAuthCredentialsFsForTest(overrides?: Partial): void { + credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs; +} + export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { if (cachedGeminiCliCredentials) { return cachedGeminiCliCredentials; @@ -29,7 +53,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return null; } - const resolvedPath = realpathSync(geminiPath); + const resolvedPath = credentialFs.realpathSync(geminiPath); const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); let content: string | null = null; @@ -55,10 +79,9 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: "oauth2.js", ), ]; - for (const path of searchPaths) { - if (existsSync(path)) { - content = readFileSync(path, "utf8"); + if (credentialFs.existsSync(path)) { + content = credentialFs.readFileSync(path, "utf8"); break; } } @@ -67,7 +90,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: } const found = findFile(geminiCliDir, "oauth2.js", 10); if (found) { - content = readFileSync(found, "utf8"); + content = credentialFs.readFileSync(found, "utf8"); break; } } @@ -116,7 +139,7 @@ function findInPath(name: string): string | null { for (const dir of (process.env.PATH ?? "").split(delimiter)) { for (const ext of exts) { const path = join(dir, name + ext); - if (existsSync(path)) { + if (credentialFs.existsSync(path)) { return path; } } @@ -129,7 +152,7 @@ function findFile(dir: string, name: string, depth: number): string | null { return null; } try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { + for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isFile() && entry.name === name) { return path; diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 8aec64d528d..d37f0751dbe 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -21,23 +21,11 @@ vi.mock("../../src/infra/net/fetch-guard.js", () => ({ }, })); -// Mock fs module before importing the module under test const mockExistsSync = vi.fn(); const mockReadFileSync = vi.fn(); const mockRealpathSync = vi.fn(); const mockReaddirSync = vi.fn(); -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: (...args: Parameters) => mockExistsSync(...args), - readFileSync: (...args: Parameters) => mockReadFileSync(...args), - realpathSync: (...args: Parameters) => mockRealpathSync(...args), - readdirSync: (...args: Parameters) => mockReaddirSync(...args), - }; -}); - describe("extractGeminiCliCredentials", () => { const normalizePath = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); @@ -51,6 +39,20 @@ describe("extractGeminiCliCredentials", () => { let originalPath: string | undefined; + async function loadCredentialsModule() { + return await import("./oauth.credentials.js"); + } + + async function installMockFs() { + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest({ + existsSync: (...args) => mockExistsSync(...args), + readFileSync: (...args) => mockReadFileSync(...args), + realpathSync: (...args) => mockRealpathSync(...args), + readdirSync: (...args) => mockReaddirSync(...args), + }); + } + function makeFakeLayout() { const binDir = join(rootDir, "fake", "bin"); const geminiPath = join(binDir, "gemini"); @@ -157,17 +159,20 @@ describe("extractGeminiCliCredentials", () => { beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; + await installMockFs(); }); - afterEach(() => { + afterEach(async () => { process.env.PATH = originalPath; + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest(); }); it("returns null when gemini binary is not in PATH", async () => { process.env.PATH = "/nonexistent"; mockExistsSync.mockReturnValue(false); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -175,7 +180,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials from oauth2.js in known path", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -185,7 +190,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials when PATH entry is an npm global shim", async () => { installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -195,7 +200,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js cannot be found", async () => { installGeminiLayout({ oauth2Exists: false, readdir: [] }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -203,7 +208,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js lacks credentials", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -211,7 +216,7 @@ describe("extractGeminiCliCredentials", () => { it("caches credentials after first extraction", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); // First call From 3e8bf845cbeb1e5da16e9b2b16d4d87621966f5a Mon Sep 17 00:00:00 2001 From: huntharo Date: Tue, 17 Mar 2026 09:30:06 -0400 Subject: [PATCH 3/4] tests(feishu): mock conversation runtime seam --- extensions/feishu/src/bot.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 4594f09fd59..ea7dbcb51ec 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -77,12 +77,9 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); -vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), -})); - -vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ resolveByConversation: mockResolveBoundConversation, touch: mockTouchBinding, From 8448f48cc57e5dccb4d49a579fa2fc6a755d3432 Mon Sep 17 00:00:00 2001 From: huntharo Date: Tue, 17 Mar 2026 09:30:09 -0400 Subject: [PATCH 4/4] tests(feishu): inject client runtime seam --- extensions/feishu/src/client.test.ts | 44 ++++++++++++----------- extensions/feishu/src/client.ts | 53 +++++++++++++++++++++++----- extensions/feishu/src/probe.test.ts | 46 ++++++++++++++++++------ 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index ccaf6ea6d0d..6efda0cbb4e 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; +const clientCtorMock = vi.hoisted(() => vi.fn()); const wsClientCtorMock = vi.hoisted(() => vi.fn(function wsClientCtor() { return { connected: true }; @@ -22,22 +23,6 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ head: vi.fn().mockResolvedValue({}), options: vi.fn().mockResolvedValue({}), })); - -vi.mock("@larksuiteoapi/node-sdk", () => ({ - AppType: { SelfBuild: "self" }, - Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" }, - LoggerLevel: { info: "info" }, - Client: vi.fn(), - WSClient: wsClientCtorMock, - EventDispatcher: vi.fn(), - defaultHttpInstance: mockBaseHttpInstance, -})); - -vi.mock("https-proxy-agent", () => ({ - HttpsProxyAgent: httpsProxyAgentCtorMock, -})); - -import { Client as LarkClient } from "@larksuiteoapi/node-sdk"; import { createFeishuClient, createFeishuWSClient, @@ -45,6 +30,7 @@ import { FEISHU_HTTP_TIMEOUT_MS, FEISHU_HTTP_TIMEOUT_MAX_MS, FEISHU_HTTP_TIMEOUT_ENV_VAR, + setFeishuClientRuntimeForTest, } from "./client.js"; const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; @@ -78,6 +64,21 @@ beforeEach(() => { delete process.env[key]; } vi.clearAllMocks(); + setFeishuClientRuntimeForTest({ + sdk: { + AppType: { SelfBuild: "self" } as never, + Domain: { + Feishu: "https://open.feishu.cn", + Lark: "https://open.larksuite.com", + } as never, + LoggerLevel: { info: "info" } as never, + Client: clientCtorMock as never, + WSClient: wsClientCtorMock as never, + EventDispatcher: vi.fn() as never, + defaultHttpInstance: mockBaseHttpInstance as never, + }, + HttpsProxyAgent: httpsProxyAgentCtorMock as never, + }); }); afterEach(() => { @@ -94,6 +95,7 @@ afterEach(() => { } else { process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv; } + setFeishuClientRuntimeForTest(); }); describe("createFeishuClient HTTP timeout", () => { @@ -102,7 +104,7 @@ describe("createFeishuClient HTTP timeout", () => { }); const getLastClientHttpInstance = () => { - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1]?.[0] as | { httpInstance?: { get: (...args: unknown[]) => Promise } } | undefined; @@ -122,7 +124,7 @@ describe("createFeishuClient HTTP timeout", () => { it("passes a custom httpInstance with default timeout to Lark.Client", () => { createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; expect(lastCall.httpInstance).toBeDefined(); }); @@ -130,7 +132,7 @@ describe("createFeishuClient HTTP timeout", () => { it("injects default timeout into HTTP request options", async () => { createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance: { post: (...args: unknown[]) => Promise }; }; @@ -152,7 +154,7 @@ describe("createFeishuClient HTTP timeout", () => { it("allows explicit timeout override per-request", async () => { createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance: { get: (...args: unknown[]) => Promise }; }; @@ -241,7 +243,7 @@ describe("createFeishuClient HTTP timeout", () => { config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; expect(calls.length).toBe(2); const lastCall = calls[calls.length - 1][0] as { diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index d9fdde7f059..c4498dcffc3 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -2,6 +2,30 @@ import * as Lark from "@larksuiteoapi/node-sdk"; import { HttpsProxyAgent } from "https-proxy-agent"; import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; +type FeishuClientSdk = Pick< + typeof Lark, + | "AppType" + | "Client" + | "defaultHttpInstance" + | "Domain" + | "EventDispatcher" + | "LoggerLevel" + | "WSClient" +>; + +const defaultFeishuClientSdk: FeishuClientSdk = { + AppType: Lark.AppType, + Client: Lark.Client, + defaultHttpInstance: Lark.defaultHttpInstance, + Domain: Lark.Domain, + EventDispatcher: Lark.EventDispatcher, + LoggerLevel: Lark.LoggerLevel, + WSClient: Lark.WSClient, +}; + +let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk; +let httpsProxyAgentCtor: typeof HttpsProxyAgent = HttpsProxyAgent; + /** Default HTTP timeout for Feishu API requests (30 seconds). */ export const FEISHU_HTTP_TIMEOUT_MS = 30_000; export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; @@ -14,7 +38,7 @@ function getWsProxyAgent(): HttpsProxyAgent | undefined { process.env.http_proxy || process.env.HTTP_PROXY; if (!proxyUrl) return undefined; - return new HttpsProxyAgent(proxyUrl); + return new httpsProxyAgentCtor(proxyUrl); } // Multi-account client cache @@ -28,10 +52,10 @@ const clientCache = new Map< function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { if (domain === "lark") { - return Lark.Domain.Lark; + return feishuClientSdk.Domain.Lark; } if (domain === "feishu" || !domain) { - return Lark.Domain.Feishu; + return feishuClientSdk.Domain.Feishu; } return domain.replace(/\/+$/, ""); // Custom URL for private deployment } @@ -42,7 +66,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { * (e.g. when the Feishu API is slow, causing per-chat queue deadlocks). */ function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance { - const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance; + const base: Lark.HttpInstance = + feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance; function injectTimeout(opts?: Lark.HttpRequestOptions): Lark.HttpRequestOptions { return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions; @@ -129,10 +154,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client } // Create new client with timeout-aware HTTP instance - const client = new Lark.Client({ + const client = new feishuClientSdk.Client({ appId, appSecret, - appType: Lark.AppType.SelfBuild, + appType: feishuClientSdk.AppType.SelfBuild, domain: resolveDomain(domain), httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs), }); @@ -158,11 +183,11 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli } const agent = getWsProxyAgent(); - return new Lark.WSClient({ + return new feishuClientSdk.WSClient({ appId, appSecret, domain: resolveDomain(domain), - loggerLevel: Lark.LoggerLevel.info, + loggerLevel: feishuClientSdk.LoggerLevel.info, ...(agent ? { agent } : {}), }); } @@ -171,7 +196,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli * Create an event dispatcher for an account. */ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher { - return new Lark.EventDispatcher({ + return new feishuClientSdk.EventDispatcher({ encryptKey: account.encryptKey, verificationToken: account.verificationToken, }); @@ -194,3 +219,13 @@ export function clearClientCache(accountId?: string): void { clientCache.clear(); } } + +export function setFeishuClientRuntimeForTest(overrides?: { + sdk?: Partial; + HttpsProxyAgent?: typeof HttpsProxyAgent; +}): void { + feishuClientSdk = overrides?.sdk + ? { ...defaultFeishuClientSdk, ...overrides.sdk } + : defaultFeishuClientSdk; + httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent; +} diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index bfc270a4459..ec1ebdc5b77 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const createFeishuClientMock = vi.hoisted(() => vi.fn()); - -vi.mock("./client.js", () => ({ - createFeishuClient: createFeishuClientMock, +const clientCtorMock = vi.hoisted(() => vi.fn()); +const mockBaseHttpInstance = vi.hoisted(() => ({ + request: vi.fn().mockResolvedValue({}), + get: vi.fn().mockResolvedValue({}), + post: vi.fn().mockResolvedValue({}), + put: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + head: vi.fn().mockResolvedValue({}), + options: vi.fn().mockResolvedValue({}), })); +import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js"; import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js"; const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret @@ -28,9 +35,15 @@ function makeRequestFn(response: Record) { return vi.fn().mockResolvedValue(response); } +function installClientCtor(requestFn: unknown) { + clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) { + this.request = requestFn; + } as never); +} + function setupClient(response: Record) { const requestFn = makeRequestFn(response); - createFeishuClientMock.mockReturnValue({ request: requestFn }); + installClientCtor(requestFn); return requestFn; } @@ -60,7 +73,7 @@ async function expectErrorResultCached(params: { expectedError: string; ttlMs: number; }) { - createFeishuClientMock.mockReturnValue({ request: params.requestFn }); + installClientCtor(params.requestFn); const first = await probeFeishu(DEFAULT_CREDS); const second = await probeFeishu(DEFAULT_CREDS); @@ -95,11 +108,25 @@ async function readSequentialDefaultProbePair() { describe("probeFeishu", () => { beforeEach(() => { clearProbeCache(); - vi.restoreAllMocks(); + clearClientCache(); + vi.clearAllMocks(); + setFeishuClientRuntimeForTest({ + sdk: { + AppType: { SelfBuild: "self" } as never, + Domain: { + Feishu: "https://open.feishu.cn", + Lark: "https://open.larksuite.com", + } as never, + Client: clientCtorMock as never, + defaultHttpInstance: mockBaseHttpInstance as never, + }, + }); }); afterEach(() => { clearProbeCache(); + clearClientCache(); + setFeishuClientRuntimeForTest(); }); it("returns error when credentials are missing", async () => { @@ -141,7 +168,7 @@ describe("probeFeishu", () => { it("returns timeout error when request exceeds timeout", async () => { await withFakeTimers(async () => { const requestFn = vi.fn().mockImplementation(() => new Promise(() => {})); - createFeishuClientMock.mockReturnValue({ request: requestFn }); + installClientCtor(requestFn); const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 }); await vi.advanceTimersByTimeAsync(1_000); @@ -152,7 +179,6 @@ describe("probeFeishu", () => { }); it("returns aborted when abort signal is already aborted", async () => { - createFeishuClientMock.mockClear(); const abortController = new AbortController(); abortController.abort(); @@ -162,7 +188,7 @@ describe("probeFeishu", () => { ); expect(result).toMatchObject({ ok: false, error: "probe aborted" }); - expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(clientCtorMock).not.toHaveBeenCalled(); }); it("returns cached result on subsequent calls within TTL", async () => { const requestFn = setupSuccessClient();