test: harden CI-sensitive test suites

This commit is contained in:
Peter Steinberger
2026-03-17 08:01:38 +00:00
parent 094a0cc412
commit df76e0f44b
6 changed files with 131 additions and 81 deletions

View File

@@ -274,8 +274,8 @@ jobs:
- name: Run changed extension tests
env:
EXTENSION_ID: ${{ matrix.extension }}
run: pnpm test:extension "$EXTENSION_ID"
OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"
# Types, lint, and format check.
check:

View File

@@ -77,14 +77,19 @@ vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
getSessionBindingService: () => ({
resolveByConversation: mockResolveBoundConversation,
touch: mockTouchBinding,
}),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const original =
await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...original,
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
getSessionBindingService: () => ({
resolveByConversation: mockResolveBoundConversation,
touch: mockTouchBinding,
}),
};
});
function createRuntimeEnv(): RuntimeEnv {
return {

View File

@@ -1,7 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
const clientCtorMock = vi.hoisted(() => vi.fn());
type CreateFeishuClient = typeof import("./client.js").createFeishuClient;
type CreateFeishuWSClient = typeof import("./client.js").createFeishuWSClient;
type ClearClientCache = typeof import("./client.js").clearClientCache;
type SetFeishuClientRuntimeForTest = typeof import("./client.js").setFeishuClientRuntimeForTest;
const clientCtorMock = vi.hoisted(() =>
vi.fn(function clientCtor() {
return { connected: true };
}),
);
const wsClientCtorMock = vi.hoisted(() =>
vi.fn(function wsClientCtor() {
return { connected: true };
@@ -12,7 +21,6 @@ const httpsProxyAgentCtorMock = vi.hoisted(() =>
return { proxyUrl };
}),
);
const mockBaseHttpInstance = vi.hoisted(() => ({
request: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
@@ -23,19 +31,17 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
head: vi.fn().mockResolvedValue({}),
options: vi.fn().mockResolvedValue({}),
}));
import {
createFeishuClient,
createFeishuWSClient,
clearClientCache,
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;
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
let createFeishuClient: CreateFeishuClient;
let createFeishuWSClient: CreateFeishuWSClient;
let clearClientCache: ClearClientCache;
let setFeishuClientRuntimeForTest: SetFeishuClientRuntimeForTest;
let FEISHU_HTTP_TIMEOUT_MS: number;
let FEISHU_HTTP_TIMEOUT_MAX_MS: number;
let FEISHU_HTTP_TIMEOUT_ENV_VAR: string;
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
let priorFeishuTimeoutEnv: string | undefined;
@@ -55,7 +61,31 @@ function firstWsClientOptions(): { agent?: unknown } {
return calls[0]?.[0] ?? {};
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("@larksuiteoapi/node-sdk", () => ({
AppType: { SelfBuild: "self" },
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
LoggerLevel: { info: "info" },
Client: clientCtorMock,
WSClient: wsClientCtorMock,
EventDispatcher: vi.fn(),
defaultHttpInstance: mockBaseHttpInstance,
}));
vi.doMock("https-proxy-agent", () => ({
HttpsProxyAgent: httpsProxyAgentCtorMock,
}));
({
createFeishuClient,
createFeishuWSClient,
clearClientCache,
setFeishuClientRuntimeForTest,
FEISHU_HTTP_TIMEOUT_MS,
FEISHU_HTTP_TIMEOUT_MAX_MS,
FEISHU_HTTP_TIMEOUT_ENV_VAR,
} = await import("./client.js"));
priorProxyEnv = {};
priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR];
@@ -104,7 +134,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
const getLastClientHttpInstance = () => {
const calls = clientCtorMock.mock.calls;
const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>;
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
| undefined;
@@ -124,21 +154,22 @@ 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 = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
expect(lastCall.httpInstance).toBeDefined();
const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>;
const lastCall = calls[calls.length - 1]?.[0] as { httpInstance?: unknown } | undefined;
expect(lastCall?.httpInstance).toBeDefined();
});
it("injects default timeout into HTTP request options", async () => {
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
const calls = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
};
const httpInstance = lastCall.httpInstance;
const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>;
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance: { post: (...args: unknown[]) => Promise<unknown> } }
| undefined;
const httpInstance = lastCall?.httpInstance;
await httpInstance.post(
expect(httpInstance).toBeDefined();
await httpInstance?.post(
"https://example.com/api",
{ data: 1 },
{ headers: { "X-Custom": "yes" } },
@@ -154,13 +185,14 @@ 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 = clientCtorMock.mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
const httpInstance = lastCall.httpInstance;
const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>;
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance: { get: (...args: unknown[]) => Promise<unknown> } }
| undefined;
const httpInstance = lastCall?.httpInstance;
await httpInstance.get("https://example.com/api", { timeout: 5_000 });
expect(httpInstance).toBeDefined();
await httpInstance?.get("https://example.com/api", { timeout: 5_000 });
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
@@ -243,13 +275,14 @@ describe("createFeishuClient HTTP timeout", () => {
config: { httpTimeoutMs: 45_000 },
});
const calls = clientCtorMock.mock.calls;
const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>;
expect(calls.length).toBe(2);
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
await lastCall.httpInstance.get("https://example.com/api");
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance: { get: (...args: unknown[]) => Promise<unknown> } }
| undefined;
expect(lastCall?.httpInstance).toBeDefined();
await lastCall?.httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
@@ -264,7 +297,7 @@ describe("createFeishuWSClient proxy handling", () => {
expect(httpsProxyAgentCtorMock).not.toHaveBeenCalled();
const options = firstWsClientOptions();
expect(options?.agent).toBeUndefined();
expect(options.agent).toBeUndefined();
});
it("uses proxy env precedence: https_proxy first, then HTTPS_PROXY, then http_proxy/HTTP_PROXY", () => {

View File

@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js";
type AggregateTimeoutParams = Parameters<typeof waitForCompactionRetryWithAggregateTimeout>[0];
type TimeoutCallback = NonNullable<AggregateTimeoutParams["onTimeout"]>;
type TimeoutCallbackMock = ReturnType<typeof vi.fn<TimeoutCallback>>;
async function withFakeTimers(run: () => Promise<void>) {
vi.useFakeTimers();
@@ -13,7 +15,7 @@ async function withFakeTimers(run: () => Promise<void>) {
}
}
function expectClearedTimeoutState(onTimeout: ReturnType<typeof vi.fn>, timedOut: boolean) {
function expectClearedTimeoutState(onTimeout: TimeoutCallbackMock, timedOut: boolean) {
if (timedOut) {
expect(onTimeout).toHaveBeenCalledTimes(1);
} else {
@@ -25,18 +27,15 @@ function expectClearedTimeoutState(onTimeout: ReturnType<typeof vi.fn>, timedOut
function buildAggregateTimeoutParams(
overrides: Partial<AggregateTimeoutParams> &
Pick<AggregateTimeoutParams, "waitForCompactionRetry">,
): { params: AggregateTimeoutParams; onTimeoutSpy: ReturnType<typeof vi.fn> } {
const onTimeoutSpy = vi.fn();
const onTimeout = overrides.onTimeout ?? (() => onTimeoutSpy());
): AggregateTimeoutParams & { onTimeout: TimeoutCallbackMock } {
const onTimeout =
(overrides.onTimeout as TimeoutCallbackMock | undefined) ?? vi.fn<TimeoutCallback>();
return {
params: {
waitForCompactionRetry: overrides.waitForCompactionRetry,
abortable: overrides.abortable ?? (async (promise) => await promise),
aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000,
isCompactionStillInFlight: overrides.isCompactionStillInFlight,
onTimeout,
},
onTimeoutSpy,
waitForCompactionRetry: overrides.waitForCompactionRetry,
abortable: overrides.abortable ?? (async (promise) => await promise),
aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000,
isCompactionStillInFlight: overrides.isCompactionStillInFlight,
onTimeout,
};
}
@@ -44,7 +43,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
it("times out and fires callback when compaction retry never resolves", async () => {
await withFakeTimers(async () => {
const waitForCompactionRetry = vi.fn(async () => await new Promise<void>(() => {}));
const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry });
const params = buildAggregateTimeoutParams({ waitForCompactionRetry });
const resultPromise = waitForCompactionRetryWithAggregateTimeout(params);
@@ -52,7 +51,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
const result = await resultPromise;
expect(result.timedOut).toBe(true);
expectClearedTimeoutState(onTimeoutSpy, true);
expectClearedTimeoutState(params.onTimeout, true);
});
});
@@ -72,15 +71,14 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
waitForCompactionRetry,
isCompactionStillInFlight: () => compactionInFlight,
});
const { params: aggregateTimeoutParams, onTimeoutSpy } = params;
const resultPromise = waitForCompactionRetryWithAggregateTimeout(aggregateTimeoutParams);
const resultPromise = waitForCompactionRetryWithAggregateTimeout(params);
await vi.advanceTimersByTimeAsync(170_000);
const result = await resultPromise;
expect(result.timedOut).toBe(false);
expectClearedTimeoutState(onTimeoutSpy, false);
expectClearedTimeoutState(params.onTimeout, false);
});
});
@@ -91,7 +89,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
setTimeout(() => {
compactionInFlight = false;
}, 90_000);
const { params, onTimeoutSpy } = buildAggregateTimeoutParams({
const params = buildAggregateTimeoutParams({
waitForCompactionRetry,
isCompactionStillInFlight: () => compactionInFlight,
});
@@ -102,19 +100,19 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
const result = await resultPromise;
expect(result.timedOut).toBe(true);
expectClearedTimeoutState(onTimeoutSpy, true);
expectClearedTimeoutState(params.onTimeout, true);
});
});
it("does not time out when compaction retry resolves", async () => {
await withFakeTimers(async () => {
const waitForCompactionRetry = vi.fn(async () => {});
const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry });
const params = buildAggregateTimeoutParams({ waitForCompactionRetry });
const result = await waitForCompactionRetryWithAggregateTimeout(params);
expect(result.timedOut).toBe(false);
expectClearedTimeoutState(onTimeoutSpy, false);
expectClearedTimeoutState(params.onTimeout, false);
});
});
@@ -123,7 +121,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
const abortError = new Error("aborted");
abortError.name = "AbortError";
const waitForCompactionRetry = vi.fn(async () => await new Promise<void>(() => {}));
const { params, onTimeoutSpy } = buildAggregateTimeoutParams({
const params = buildAggregateTimeoutParams({
waitForCompactionRetry,
abortable: async () => {
throw abortError;
@@ -132,7 +130,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => {
await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow("aborted");
expectClearedTimeoutState(onTimeoutSpy, false);
expectClearedTimeoutState(params.onTimeout, false);
});
});
});

View File

@@ -2,16 +2,25 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js";
type MutableSystemPromptFields = {
type MutableSession = {
_baseSystemPrompt?: string;
_rebuildSystemPrompt?: (toolNames: string[]) => string;
};
function createMockSession() {
const setSystemPrompt = vi.fn();
type MockSession = MutableSession & {
agent: {
setSystemPrompt: ReturnType<typeof vi.fn>;
};
};
function createMockSession(): {
session: MockSession;
setSystemPrompt: ReturnType<typeof vi.fn>;
} {
const setSystemPrompt = vi.fn<(prompt: string) => void>();
const session = {
agent: { setSystemPrompt },
} as unknown as AgentSession;
} as MockSession;
return { session, setSystemPrompt };
}
@@ -19,9 +28,9 @@ function applyAndGetMutableSession(
prompt: Parameters<typeof applySystemPromptOverrideToSession>[1],
) {
const { session, setSystemPrompt } = createMockSession();
applySystemPromptOverrideToSession(session, prompt);
applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt);
return {
mutable: session as unknown as MutableSystemPromptFields,
mutable: session,
setSystemPrompt,
};
}

View File

@@ -8,12 +8,10 @@ vi.mock("../providers.js", () => ({
resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args),
}));
const {
buildProviderPluginMethodChoice,
resolveProviderModelPickerEntries,
resolveProviderPluginChoice,
resolveProviderWizardOptions,
} = await import("../provider-wizard.js");
let buildProviderPluginMethodChoice: typeof import("../provider-wizard.js").buildProviderPluginMethodChoice;
let resolveProviderModelPickerEntries: typeof import("../provider-wizard.js").resolveProviderModelPickerEntries;
let resolveProviderPluginChoice: typeof import("../provider-wizard.js").resolveProviderPluginChoice;
let resolveProviderWizardOptions: typeof import("../provider-wizard.js").resolveProviderWizardOptions;
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
const values: string[] = [];
@@ -72,7 +70,14 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
}
describe("provider wizard contract", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({
buildProviderPluginMethodChoice,
resolveProviderModelPickerEntries,
resolveProviderPluginChoice,
resolveProviderWizardOptions,
} = await import("../provider-wizard.js"));
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
});