From 23e50859eb7ed81997c8577e20b02ad3fe15e734 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 16:08:12 +0100 Subject: [PATCH] test(e2e): align release harness coverage --- .../browser/src/browser/pw-ai.e2e.test.ts | 5 +- extensions/whatsapp/src/test-helpers.ts | 195 ++++++++++++++---- src/cli/program.nodes-basic.e2e.test.ts | 30 +-- src/cli/program.test-mocks.ts | 23 ++- src/commands/doctor.e2e-harness.ts | 2 + ...te-migrations-yes-mode-without.e2e.test.ts | 25 +++ ...t-sandbox-docker-browser-prune.e2e.test.ts | 2 +- .../wired-hooks-after-tool-call.e2e.test.ts | 2 + 8 files changed, 223 insertions(+), 61 deletions(-) diff --git a/extensions/browser/src/browser/pw-ai.e2e.test.ts b/extensions/browser/src/browser/pw-ai.e2e.test.ts index c182996efd6..3c74f59fd7d 100644 --- a/extensions/browser/src/browser/pw-ai.e2e.test.ts +++ b/extensions/browser/src/browser/pw-ai.e2e.test.ts @@ -32,6 +32,7 @@ function createPage(opts: { targetId: string; snapshotFull?: string; hasSnapshot context: () => context, locator, on: vi.fn(), + url: vi.fn(() => `https://example.test/${opts.targetId}`), ...(opts.hasSnapshotForAI === false ? {} : { @@ -86,8 +87,8 @@ describe("pw-ai", () => { }); expect(res.snapshot).toBe("TWO"); - expect(p1.session.detach).toHaveBeenCalledTimes(1); - expect(p2.session.detach).toHaveBeenCalledTimes(1); + expect(p1.session.detach).toHaveBeenCalled(); + expect(p2.session.detach).toHaveBeenCalled(); }); it("registers aria refs from ai snapshots for act commands", async () => { diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index dd014295d5a..1e51948a7b0 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { vi } from "vitest"; +import { formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import type { MockBaileysSocket } from "../../../test/mocks/baileys.js"; import { createMockBaileys } from "../../../test/mocks/baileys.js"; @@ -84,7 +85,11 @@ function loadSessionStoreMock(storePath: string) { type BufferedDispatchReplyParams = { ctx: Record; - replyResolver: (ctx: Record) => Promise | undefined>; + replyResolver: ( + ctx: Record, + opts?: BufferedReplyOptions, + ) => Promise | undefined>; + replyOptions?: BufferedReplyOptions; dispatcherOptions: { deliver: ( payload: Record, @@ -94,32 +99,151 @@ type BufferedDispatchReplyParams = { }; }; +type MockTypingController = { + markDispatchIdle?: () => void; + markRunComplete?: () => void; +}; + +type BufferedReplyOptions = Record & { + onTypingController?: (typing: MockTypingController) => void; +}; + +type TestEnvelopeOptions = { + timezone?: string; + includeTimestamp?: boolean; + includeElapsed?: boolean; + userTimezone?: string; +}; + +type TestInboundEnvelopeParams = { + channel?: string; + from?: string; + body: string; + timestamp?: number | Date; + chatType?: string; + senderLabel?: string; + sender?: { name?: string; e164?: string; id?: string }; + previousTimestamp?: number | Date; + envelope?: TestEnvelopeOptions; + fromMe?: boolean; +}; + +function sanitizeEnvelopeHeaderPart(value: string) { + return value + .replace(/\r\n|\r|\n/g, " ") + .replaceAll("[", "(") + .replaceAll("]", ")") + .replace(/\s+/g, " ") + .trim(); +} + +function resolveEnvelopeOptionsMock(cfg?: { + agents?: { + defaults?: { + envelopeTimezone?: string; + envelopeTimestamp?: "on" | "off"; + envelopeElapsed?: "on" | "off"; + userTimezone?: string; + }; + }; +}): TestEnvelopeOptions { + const defaults = cfg?.agents?.defaults; + return { + timezone: defaults?.envelopeTimezone, + includeTimestamp: defaults?.envelopeTimestamp !== "off", + includeElapsed: defaults?.envelopeElapsed !== "off", + userTimezone: defaults?.userTimezone, + }; +} + +function resolveEnvelopeTimestampMock( + timestamp: number | Date | undefined, + envelope?: TestEnvelopeOptions, +) { + if (!timestamp || envelope?.includeTimestamp === false) { + return undefined; + } + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return undefined; + } + const zone = envelope?.timezone?.trim(); + if (zone === "user") { + return formatEnvelopeTimestamp(date, envelope?.userTimezone?.trim() || "local"); + } + return formatEnvelopeTimestamp(date, zone || "local"); +} + +function resolveSenderLabelMock(sender?: TestInboundEnvelopeParams["sender"]) { + const display = sender?.name?.trim(); + const idPart = sender?.e164?.trim() || sender?.id?.trim(); + if (display && idPart && display !== idPart) { + return `${display} (${idPart})`; + } + return display || idPart || undefined; +} + +function formatInboundEnvelopeMock(params: TestInboundEnvelopeParams) { + const chatType = normalizeLowercaseStringOrEmpty(params.chatType); + const isDirect = !chatType || chatType === "direct"; + const sender = params.senderLabel?.trim() || resolveSenderLabelMock(params.sender); + const body = + isDirect && params.fromMe + ? `(self): ${params.body}` + : !isDirect && sender + ? `${sanitizeEnvelopeHeaderPart(sender)}: ${params.body}` + : params.body; + const parts = [sanitizeEnvelopeHeaderPart(params.channel?.trim() || "Channel")]; + const from = params.from?.trim(); + if (from) { + parts.push(sanitizeEnvelopeHeaderPart(from)); + } + const timestamp = resolveEnvelopeTimestampMock(params.timestamp, params.envelope); + if (timestamp) { + parts.push(timestamp); + } + return `[${parts.join(" ")}] ${body}`; +} + function createBufferedDispatchReplyMock() { return vi.fn(async (params: BufferedDispatchReplyParams) => { - await params.dispatcherOptions.onReplyStart?.(); - const payload = await params.replyResolver(params.ctx); - if (!payload || typeof payload !== "object") { - return { - queuedFinal: false, - counts: { tool: 0, block: 0, final: 0 }, - }; - } - const text = typeof payload.text === "string" ? payload.text.trim() : ""; - const hasMedia = - typeof payload.mediaUrl === "string" || - typeof payload.mediaPath === "string" || - typeof payload.fileUrl === "string"; - if (!text && !hasMedia) { - return { - queuedFinal: false, - counts: { tool: 0, block: 0, final: 0 }, - }; - } - await params.dispatcherOptions.deliver(payload, { kind: "final" }); - return { - queuedFinal: true, - counts: { tool: 0, block: 0, final: 1 }, + let typingController: MockTypingController | undefined; + const replyOptions: BufferedReplyOptions = { + ...params.replyOptions, + onTypingController: (typing) => { + typingController = typing; + params.replyOptions?.onTypingController?.(typing); + }, }; + await params.dispatcherOptions.onReplyStart?.(); + try { + const payload = await params.replyResolver(params.ctx, replyOptions); + if (!payload || typeof payload !== "object") { + return { + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }; + } + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + const hasMedia = + typeof payload.mediaUrl === "string" || + typeof payload.mediaPath === "string" || + typeof payload.fileUrl === "string"; + if (!text && !hasMedia) { + return { + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }; + } + await params.dispatcherOptions.deliver(payload, { kind: "final" }); + return { + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }; + } finally { + typingController?.markRunComplete?.(); + typingController?.markDispatchIdle?.(); + } }); } @@ -310,8 +434,7 @@ vi.mock("./auto-reply/monitor/runtime-api.js", () => ({ }), dispatchReplyWithBufferedBlockDispatcher: createBufferedDispatchReplyMock(), finalizeInboundContext: (ctx: T) => ctx, - formatInboundEnvelope: (params: { body: string; senderLabel?: string }) => - `${params.senderLabel ? `${params.senderLabel}: ` : ""}${params.body}`, + formatInboundEnvelope: formatInboundEnvelopeMock, getAgentScopedMediaLocalRoots: () => [] as string[], jidToE164: (jid: string) => { const digits = jid.replace(/\D+/g, ""); @@ -330,11 +453,11 @@ vi.mock("./auto-reply/monitor/runtime-api.js", () => ({ cfg.messages?.responsePrefix, resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey, resolveInboundSessionEnvelopeContext: (params: { - cfg: { session?: { store?: string } }; + cfg: { session?: { store?: string } } & Parameters[0]; agentId: string; }) => ({ storePath: resolveStorePathFallback(params.cfg.session?.store, { agentId: params.agentId }), - envelopeOptions: {}, + envelopeOptions: resolveEnvelopeOptionsMock(params.cfg), previousTimestamp: undefined, }), resolveMarkdownTableMode: () => undefined, @@ -437,13 +560,7 @@ vi.mock("./auto-reply/monitor/group-activation.runtime.js", () => ({ })); vi.mock("./auto-reply/monitor/message-line.runtime.js", () => ({ - formatInboundEnvelope: (params: { - body: string; - sender?: { name?: string; e164?: string; id?: string }; - }) => { - const sender = params.sender?.name ?? params.sender?.e164 ?? params.sender?.id ?? undefined; - return sender ? `${sender}: ${params.body}` : params.body; - }, + formatInboundEnvelope: formatInboundEnvelopeMock, resolveMessagePrefix: ( cfg: { channels?: { whatsapp?: { messagePrefix?: string; allowFrom?: string[] } }; @@ -451,7 +568,13 @@ vi.mock("./auto-reply/monitor/message-line.runtime.js", () => ({ }, _agentId: string, params?: { configured?: string; hasAllowFrom?: boolean }, - ) => params?.configured ?? cfg.messages?.messagePrefix, + ) => { + const configured = params?.configured ?? cfg.messages?.messagePrefix; + if (configured !== undefined) { + return configured; + } + return params?.hasAllowFrom === true ? "" : "[openclaw]"; + }, })); vi.mock("./auth-store.runtime.js", () => ({ diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index c02514dcc79..cc7e22a4ea6 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -1,11 +1,12 @@ import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerNodesCli } from "./nodes-cli.js"; import { createIosNodeListResponse } from "./program.nodes-test-helpers.js"; import { callGateway, installBaseProgramMocks, runtime } from "./program.test-mocks.js"; installBaseProgramMocks(); +let registerNodesCli: typeof import("./nodes-cli.js").registerNodesCli; + function formatRuntimeLogCallArg(value: unknown): string { if (typeof value === "string") { return value; @@ -55,8 +56,9 @@ describe("cli program (nodes basics)", () => { }); } - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + ({ registerNodesCli } = await import("./nodes-cli.js")); program = createProgram(); }); @@ -236,7 +238,7 @@ describe("cli program (nodes basics)", () => { requestId: "r1", node: { nodeId: "n1", token: "t1" }, }); - await expect(runProgram(["nodes", "approve", "r1"])).rejects.toThrow("exit"); + await runProgram(["nodes", "approve", "r1"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.pair.approve", @@ -253,18 +255,16 @@ describe("cli program (nodes basics)", () => { payload: { result: "ok" }, }); - await expect( - runProgram([ - "nodes", - "invoke", - "--node", - "ios-node", - "--command", - "canvas.eval", - "--params", - '{"javaScript":"1+1"}', - ]), - ).rejects.toThrow("exit"); + await runProgram([ + "nodes", + "invoke", + "--node", + "ios-node", + "--command", + "canvas.eval", + "--params", + '{"javaScript":"1+1"}', + ]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 15595755dc3..ec0a2109ff7 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -4,6 +4,19 @@ type AnyMock = Mock<(...args: unknown[]) => unknown>; const programMocks = vi.hoisted(() => { const setupWizardCommand = vi.fn(); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + writeStdout: vi.fn((value: string) => { + runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); + }), + writeJson: vi.fn((value: unknown, space = 2) => { + runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)); + }), + }; return { messageCommand: vi.fn(), statusCommand: vi.fn(), @@ -19,13 +32,7 @@ const programMocks = vi.hoisted(() => { loadAndMaybeMigrateDoctorConfig: vi.fn(), ensureConfigReady: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }, + runtime, }; }); @@ -49,6 +56,8 @@ export const runtime = programMocks.runtime as { log: Mock<(...args: unknown[]) => void>; error: Mock<(...args: unknown[]) => void>; exit: Mock<(...args: unknown[]) => never>; + writeStdout: Mock<(...args: [string]) => void>; + writeJson: Mock<(...args: [unknown, number?]) => void>; }; // Keep these mocks at top level so Vitest does not warn about hoisted nested mocks. diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 6ba0934a900..a1245a45db4 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -250,7 +250,9 @@ vi.mock("../agents/skills-status.js", () => ({ })); vi.mock("../plugins/loader.js", () => ({ + isPluginRegistryLoadInFlight: () => false, loadOpenClawPlugins: () => createEmptyPluginRegistry(), + resolveRuntimePluginRegistry: () => null, })); vi.mock("../config/config.js", async () => { diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index dce48e6e3ec..e8d5173dd26 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin } from "../plugins/types.js"; import { arrangeLegacyStateMigrationTest, confirm, @@ -10,15 +11,31 @@ import { writeConfigFile, } from "./doctor.e2e-harness.js"; +const providerRuntimeMocks = vi.hoisted(() => ({ + resolvePluginProviders: vi.fn((_params?: unknown): ProviderPlugin[] => []), +})); + +vi.mock("../plugins/providers.runtime.js", async () => { + const actual = await vi.importActual( + "../plugins/providers.runtime.js", + ); + return { + ...actual, + resolvePluginProviders: providerRuntimeMocks.resolvePluginProviders, + }; +}); + let doctorCommand: typeof import("./doctor.js").doctorCommand; let healthCommand: typeof import("./health.js").healthCommand; describe("doctor command", () => { beforeEach(async () => { vi.resetModules(); + vi.doUnmock("../flows/doctor-health-contributions.js"); ({ doctorCommand } = await import("./doctor.js")); ({ healthCommand } = await import("./health.js")); vi.clearAllMocks(); + providerRuntimeMocks.resolvePluginProviders.mockReturnValue([]); }); it("runs legacy state migrations in yes mode without prompting", async () => { @@ -86,6 +103,14 @@ describe("doctor command", () => { }, }, }); + providerRuntimeMocks.resolvePluginProviders.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + auth: [], + oauthProfileIdRepairs: [{ legacyProfileId: "anthropic:default" }], + }, + ]); await doctorCommand(createDoctorRuntime(), { yes: true }); diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts index 05474fff876..982cac36e53 100644 --- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts +++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts @@ -11,7 +11,7 @@ let doctorCommand: typeof import("./doctor.js").doctorCommand; describe("doctor command", () => { beforeEach(async () => { doctorCommand = await loadDoctorCommandForTest({ - unmockModules: ["./doctor-sandbox.js"], + unmockModules: ["./doctor-sandbox.js", "../flows/doctor-health-contributions.js"], }); }); diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index 4f423eec1e4..e8f6821b0b0 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -18,6 +18,8 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ // Mock agent events (used by handlers) vi.mock("../infra/agent-events.js", () => ({ + emitAgentCommandOutputEvent: vi.fn(), + emitAgentItemEvent: vi.fn(), emitAgentEvent: vi.fn(), }));