refactor(test): use structural MockFn for harness exports

This commit is contained in:
Peter Steinberger
2026-02-14 22:04:56 +01:00
parent 07850e8a93
commit 240fbf08d4
9 changed files with 167 additions and 109 deletions

View File

@@ -1,20 +1,18 @@
import { join } from "node:path";
import { afterEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMock = any;
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMocks = Record<string, any>;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyMockMap = Record<string, MockFn>;
const piEmbeddedMocks = vi.hoisted(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
abortEmbeddedPiRun: vi.fn<(...args: unknown[]) => boolean>().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn<(...args: unknown[]) => unknown>(),
runEmbeddedPiAgent: vi.fn<(...args: unknown[]) => unknown>(),
queueEmbeddedPiMessage: vi.fn<(...args: unknown[]) => boolean>().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn<(...args: unknown[]) => boolean>().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn<(...args: unknown[]) => boolean>().mockReturnValue(false),
}));
export function getAbortEmbeddedPiRunMock(): AnyMock {
@@ -50,12 +48,18 @@ const providerUsageMocks = vi.hoisted(() => ({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
formatUsageWindowSummary: vi.fn().mockReturnValue("Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
formatUsageSummaryLine: vi
.fn<(...args: unknown[]) => string>()
.mockReturnValue("📊 Usage: Claude 80% left"),
formatUsageWindowSummary: vi
.fn<(...args: unknown[]) => string>()
.mockReturnValue("Claude 80% left"),
resolveUsageProviderId: vi.fn<(provider: string) => string>(
(provider: string) => provider.split("/")[0],
),
}));
export function getProviderUsageMocks(): AnyMocks {
export function getProviderUsageMocks(): AnyMockMap {
return providerUsageMocks;
}
@@ -80,10 +84,10 @@ const modelCatalogMocks = vi.hoisted(() => ({
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
]),
resetModelCatalogCacheForTest: vi.fn(),
resetModelCatalogCacheForTest: vi.fn<(...args: unknown[]) => unknown>(),
}));
export function getModelCatalogMocks(): AnyMocks {
export function getModelCatalogMocks(): AnyMockMap {
return modelCatalogMocks;
}
@@ -95,7 +99,7 @@ const webSessionMocks = vi.hoisted(() => ({
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
export function getWebSessionMocks(): AnyMocks {
export function getWebSessionMocks(): AnyMockMap {
return webSessionMocks;
}

View File

@@ -1,20 +1,17 @@
import { beforeEach, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import type { MockFn } from "../../test-utils/vitest-mock-fn.js";
import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMock = any;
const state = vi.hoisted(() => ({
runEmbeddedPiAgentMock: vi.fn(),
runEmbeddedPiAgentMock: vi.fn<(...args: unknown[]) => unknown>(),
}));
export function getRunEmbeddedPiAgentMock(): AnyMock {
export function getRunEmbeddedPiAgentMock(): MockFn<(...args: unknown[]) => unknown> {
return state.runEmbeddedPiAgentMock;
}

View File

@@ -1,14 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest";
import type { MockFn } from "../../test-utils/vitest-mock-fn.js";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMock = any;
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
@@ -16,15 +13,15 @@ type EmbeddedRunParams = {
};
const state = vi.hoisted(() => ({
runEmbeddedPiAgentMock: vi.fn(),
runCliAgentMock: vi.fn(),
runEmbeddedPiAgentMock: vi.fn<(...args: unknown[]) => unknown>(),
runCliAgentMock: vi.fn<(...args: unknown[]) => unknown>(),
}));
export function getRunEmbeddedPiAgentMock(): AnyMock {
export function getRunEmbeddedPiAgentMock(): MockFn<(...args: unknown[]) => unknown> {
return state.runEmbeddedPiAgentMock;
}
export function getRunCliAgentMock(): AnyMock {
export function getRunCliAgentMock(): MockFn<(...args: unknown[]) => unknown> {
return state.runCliAgentMock;
}

View File

@@ -331,10 +331,10 @@ describe("monitorIMessageProvider", () => {
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Your iMessage sender id: +15550001111",
);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
const body = sendMock.mock.calls[0]?.[1];
const bodyText = typeof body === "string" ? body : JSON.stringify(body ?? "");
expect(bodyText).toContain("Your iMessage sender id: +15550001111");
expect(bodyText).toContain("Pairing code: PAIRCODE");
});
it("delivers group replies when mentioned", async () => {

View File

@@ -1,25 +1,25 @@
import { beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
type NotificationHandler = (msg: { method: string; params?: unknown }) => void;
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMock = any;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type RequestMock = MockFn<(method: string, ...args: unknown[]) => Promise<unknown>>;
const state = vi.hoisted(() => ({
requestMock: vi.fn(),
stopMock: vi.fn(),
sendMock: vi.fn(),
replyMock: vi.fn(),
updateLastRouteMock: vi.fn(),
readAllowFromStoreMock: vi.fn(),
upsertPairingRequestMock: vi.fn(),
requestMock: vi.fn<(method: string, ...args: unknown[]) => Promise<unknown>>(),
stopMock: vi.fn<(...args: unknown[]) => unknown>(),
sendMock: vi.fn<(...args: unknown[]) => unknown>(),
replyMock: vi.fn<(...args: unknown[]) => unknown>(),
updateLastRouteMock: vi.fn<(...args: unknown[]) => unknown>(),
readAllowFromStoreMock: vi.fn<(...args: unknown[]) => unknown>(),
upsertPairingRequestMock: vi.fn<(...args: unknown[]) => unknown>(),
config: {} as Record<string, unknown>,
notificationHandler: undefined as NotificationHandler | undefined,
closeResolve: undefined as (() => void) | undefined,
}));
export function getRequestMock(): AnyMock {
export function getRequestMock(): RequestMock {
return state.requestMock;
}
@@ -95,7 +95,7 @@ vi.mock("./client.js", () => ({
createIMessageRpcClient: vi.fn(async (opts: { onNotification?: NotificationHandler }) => {
state.notificationHandler = opts.onNotification;
return {
request: (...args: unknown[]) => state.requestMock(...args),
request: (method: string, ...args: unknown[]) => state.requestMock(method, ...args),
waitForClose: () =>
new Promise<void>((resolve) => {
state.closeResolve = resolve;

View File

@@ -16,7 +16,7 @@ const { sessionStorePath } = vi.hoisted(() => ({
}));
const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({
loadWebMedia: vi.fn(),
loadWebMedia: vi.fn<(...args: unknown[]) => unknown>(),
}));
export function getLoadWebMediaMock(): AnyMock {
@@ -28,7 +28,7 @@ vi.mock("../web/media.js", () => ({
}));
const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
loadConfig: vi.fn(() => ({})),
loadConfig: vi.fn<() => Record<string, unknown>>(() => ({})),
}));
export function getLoadConfigMock(): AnyMock {
@@ -55,8 +55,13 @@ const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
readChannelAllowFromStore: AnyAsyncMock;
upsertChannelPairingRequest: AnyAsyncMock;
} => ({
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn(async () => ({
readChannelAllowFromStore: vi.fn<() => Promise<string[]>>(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn<
() => Promise<{
code: string;
created: boolean;
}>
>(async () => ({
code: "PAIRCODE",
created: true,
})),
@@ -76,24 +81,38 @@ vi.mock("../pairing/pairing-store.js", () => ({
upsertChannelPairingRequest,
}));
export const useSpy: MockFn<(arg: unknown) => void> = vi.fn();
export const middlewareUseSpy: AnyMock = vi.fn();
export const onSpy: AnyMock = vi.fn();
export const stopSpy: AnyMock = vi.fn();
export const commandSpy: AnyMock = vi.fn();
export const botCtorSpy: AnyMock = vi.fn();
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined);
export const sendChatActionSpy: AnyMock = vi.fn();
export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const deleteMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const getMeSpy: AnyAsyncMock = vi.fn(async () => ({
export const useSpy: MockFn<(arg: unknown) => void> = vi.fn<(arg: unknown) => void>();
export const middlewareUseSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const onSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const stopSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const commandSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const botCtorSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => undefined,
);
export const sendChatActionSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>();
export const setMessageReactionSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => undefined,
);
export const setMyCommandsSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => undefined,
);
export const deleteMyCommandsSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => undefined,
);
export const getMeSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
username: "openclaw_bot",
has_topics_enabled: true,
}));
export const sendMessageSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 77 }));
export const sendAnimationSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 78 }));
export const sendPhotoSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 79 }));
export const sendMessageSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => ({ message_id: 77 }),
);
export const sendAnimationSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => ({ message_id: 78 }),
);
export const sendPhotoSpy: AnyAsyncMock = vi.fn<(...args: unknown[]) => Promise<unknown>>(
async () => ({ message_id: 79 }),
);
type ApiStub = {
config: { use: (arg: unknown) => void };
@@ -141,7 +160,9 @@ vi.mock("grammy", () => ({
}));
const sequentializeMiddleware = vi.fn();
export const sequentializeSpy: AnyMock = vi.fn(() => sequentializeMiddleware);
export const sequentializeSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>(
() => sequentializeMiddleware,
);
export let sequentializeKey: ((ctx: unknown) => string) | undefined;
vi.mock("@grammyjs/runner", () => ({
sequentialize: (keyFn: (ctx: unknown) => string) => {
@@ -150,18 +171,18 @@ vi.mock("@grammyjs/runner", () => ({
},
}));
export const throttlerSpy: AnyMock = vi.fn(() => "throttler");
export const throttlerSpy: AnyMock = vi.fn<(...args: unknown[]) => unknown>(() => "throttler");
vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => throttlerSpy(),
}));
export const replySpy: MockFn<(ctx: unknown, opts?: ReplyOpts) => Promise<void>> = vi.fn(
async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
},
);
export const replySpy: MockFn<(ctx: unknown, opts?: ReplyOpts) => Promise<void>> = vi.fn<
(ctx: unknown, opts?: ReplyOpts) => Promise<void>
>(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: replySpy,

View File

@@ -1,6 +1,32 @@
// Centralized Vitest mock type for harness modules under `src/`.
// Using an explicit named type avoids exporting inferred `vi.fn()` types that can trip TS2742.
// Centralized structural mock type for harness modules under `src/`.
//
// Why structural?
// - Exporting inferred `vi.fn()` types can trigger TS2742 during d.ts emit under pnpm.
// - Referring to vitest's internal spy types can still hit pnpm-path portability issues.
//
// Keep this type minimal: only methods used in harnesses.
// Keep it permissive: avoid referencing vitest types while keeping mock ergonomics in harnesses.
// oxlint-disable-next-line typescript/no-explicit-any
export type MockFn<T extends (...args: any[]) => any = (...args: any[]) => any> =
import("vitest").Mock<T>;
type Any = any;
type AnyFn = (...args: Any[]) => Any;
// Callable mock function with `mock.*` helpers.
// Note: use `vi.fn<T>()` in harnesses to avoid Vitest's default `Constructable | Procedure` widening.
export type MockFn<T extends AnyFn = AnyFn> = T & {
mock: {
// Keep this wide; harness code typically inspects `[0]`, `[1]`, etc.
calls: Any[][];
};
mockClear: () => Any;
mockReset: () => Any;
mockImplementation: (fn: AnyFn) => Any;
mockImplementationOnce: (fn: AnyFn) => Any;
mockReturnValue: (value: Any) => Any;
mockReturnValueOnce: (value: Any) => Any;
mockResolvedValue: (value: Any) => Any;
mockResolvedValueOnce: (value: Any) => Any;
mockRejectedValue: (value: Any) => Any;
mockRejectedValueOnce: (value: Any) => Any;
mockName: (name: string) => Any;
};

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { WebInboundMessage } from "./inbound.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import * as ssrf from "../infra/net/ssrf.js";
@@ -14,10 +15,6 @@ import {
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyExport = any;
export const TEST_NET_IP = "203.0.113.10";
vi.mock("../agents/pi-embedded.js", () => ({
@@ -123,13 +120,20 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
});
}
export function createWebListenerFactoryCapture(): AnyExport {
export type WebListenerFactoryCapture = {
listenerFactory: (opts: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) => Promise<{ close: MockFn<() => void> }>;
getOnMessage: () => ((msg: WebInboundMessage) => Promise<void>) | undefined;
};
export function createWebListenerFactoryCapture(): WebListenerFactoryCapture {
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
const listenerFactory = async (opts: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
return { close: vi.fn<() => void>() };
};
return {
@@ -138,11 +142,17 @@ export function createWebListenerFactoryCapture(): AnyExport {
};
}
export function createWebInboundDeliverySpies(): AnyExport {
export type WebInboundDeliverySpies = {
sendMedia: MockFn<(...args: unknown[]) => unknown>;
reply: MockFn<(...args: unknown[]) => Promise<void>>;
sendComposing: MockFn<(...args: unknown[]) => unknown>;
};
export function createWebInboundDeliverySpies(): WebInboundDeliverySpies {
return {
sendMedia: vi.fn(),
reply: vi.fn().mockResolvedValue(undefined),
sendComposing: vi.fn(),
sendMedia: vi.fn<(...args: unknown[]) => unknown>(),
reply: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
sendComposing: vi.fn<(...args: unknown[]) => unknown>(),
};
}

View File

@@ -3,12 +3,9 @@ import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
type AnyMockFn = any;
export const DEFAULT_ACCOUNT_ID = "default";
export const DEFAULT_WEB_INBOX_CONFIG = {
@@ -24,24 +21,30 @@ export const DEFAULT_WEB_INBOX_CONFIG = {
},
} as const;
export const mockLoadConfig: AnyMockFn = vi.fn().mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
export const mockLoadConfig: MockFn<() => typeof DEFAULT_WEB_INBOX_CONFIG> = vi
.fn<() => typeof DEFAULT_WEB_INBOX_CONFIG>()
.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
export const readAllowFromStoreMock: AnyMockFn = vi.fn().mockResolvedValue([]);
export const upsertPairingRequestMock: AnyMockFn = vi
.fn()
export const readAllowFromStoreMock: MockFn<(...args: unknown[]) => Promise<unknown[]>> = vi
.fn<(...args: unknown[]) => Promise<unknown[]>>()
.mockResolvedValue([]);
export const upsertPairingRequestMock: MockFn<
(...args: unknown[]) => Promise<{ code: string; created: boolean }>
> = vi
.fn<(...args: unknown[]) => Promise<{ code: string; created: boolean }>>()
.mockResolvedValue({ code: "PAIRCODE", created: true });
export type MockSock = {
ev: EventEmitter;
ws: { close: AnyMockFn };
sendPresenceUpdate: AnyMockFn;
sendMessage: AnyMockFn;
readMessages: AnyMockFn;
updateMediaMessage: AnyMockFn;
ws: { close: MockFn };
sendPresenceUpdate: MockFn;
sendMessage: MockFn;
readMessages: MockFn;
updateMediaMessage: MockFn;
logger: Record<string, unknown>;
signalRepository: {
lidMapping: {
getPNForLID: AnyMockFn;
getPNForLID: MockFn;
};
};
user: { id: string };
@@ -51,15 +54,15 @@ function createMockSock(): MockSock {
const ev = new EventEmitter();
return {
ev,
ws: { close: vi.fn() },
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
readMessages: vi.fn().mockResolvedValue(undefined),
updateMediaMessage: vi.fn(),
ws: { close: vi.fn<() => void>() },
sendPresenceUpdate: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
sendMessage: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
readMessages: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
updateMediaMessage: vi.fn<(...args: unknown[]) => unknown>(),
logger: {},
signalRepository: {
lidMapping: {
getPNForLID: vi.fn().mockResolvedValue(null),
getPNForLID: vi.fn<(...args: unknown[]) => Promise<unknown>>().mockResolvedValue(null),
},
},
user: { id: "123@s.whatsapp.net" },