Merge branch 'main' into fix/issue-43909

This commit is contained in:
Val Alexander 2026-03-17 08:52:55 -05:00 committed by GitHub
commit 7e45798f34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 166 additions and 74 deletions

View File

@ -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,

View File

@ -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<typeof vi.fn>).mock.calls;
const calls = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
| 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<typeof vi.fn>).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<typeof vi.fn>).mock.calls;
const calls = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
};
@ -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<typeof vi.fn>).mock.calls;
const calls = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
@ -241,7 +243,7 @@ describe("createFeishuClient HTTP timeout", () => {
config: { httpTimeoutMs: 45_000 },
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const calls = clientCtorMock.mock.calls;
expect(calls.length).toBe(2);
const lastCall = calls[calls.length - 1][0] as {

View File

@ -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<string> | 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<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
@ -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<FeishuClientSdk>;
HttpsProxyAgent?: typeof HttpsProxyAgent;
}): void {
feishuClientSdk = overrides?.sdk
? { ...defaultFeishuClientSdk, ...overrides.sdk }
: defaultFeishuClientSdk;
httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent;
}

View File

@ -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<string, unknown>) {
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<string, unknown>) {
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();

View File

@ -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<typeof existsSync>[0]) => ReturnType<typeof existsSync>;
readFileSync: (path: Parameters<typeof readFileSync>[0], encoding: "utf8") => string;
realpathSync: (path: Parameters<typeof realpathSync>[0]) => string;
readdirSync: (
path: Parameters<typeof readdirSync>[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<CredentialFs>): 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;

View File

@ -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<typeof import("node:fs")>();
return {
...actual,
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => 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

View File

@ -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<typeof import("./api.js")>();
return {
...actual,
sendGoogleChatMessage: sendGoogleChatMessageMock,
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
};
});
import { googlechatPlugin } from "./channel.js";
import { setGoogleChatRuntime } from "./runtime.js";