From 03ff4960b3efd14f50976598df853af2f81c4b66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 17:21:16 +0000 Subject: [PATCH] refactor(test): share web auto-reply harness --- src/web/auto-reply.test-harness.ts | 119 ++++++++++++++++ ...resses-common-formats-jpeg-cap.e2e.test.ts | 130 ++---------------- ...y.falls-back-text-media-send-fails.test.ts | 128 +---------------- 3 files changed, 135 insertions(+), 242 deletions(-) create mode 100644 src/web/auto-reply.test-harness.ts diff --git a/src/web/auto-reply.test-harness.ts b/src/web/auto-reply.test-harness.ts new file mode 100644 index 00000000000..e94e824ae30 --- /dev/null +++ b/src/web/auto-reply.test-harness.ts @@ -0,0 +1,119 @@ +import "./test-helpers.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, vi } from "vitest"; +import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import * as ssrf from "../infra/net/ssrf.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { + resetBaileysMocks as _resetBaileysMocks, + resetLoadConfigMock as _resetLoadConfigMock, +} from "./test-helpers.js"; + +export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; + +export const TEST_NET_IP = "203.0.113.10"; + +vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: vi.fn(), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, +})); + +export async function rmDirWithRetries( + dir: string, + opts?: { attempts?: number; delayMs?: number }, +): Promise { + const attempts = opts?.attempts ?? 10; + const delayMs = opts?.delayMs ?? 5; + // Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY. + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (err) { + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code?: unknown }).code) + : null; + if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + throw err; + } + } + + await fs.rm(dir, { recursive: true, force: true }); +} + +let previousHome: string | undefined; +let tempHome: string | undefined; + +export function installWebAutoReplyTestHomeHooks() { + beforeEach(async () => { + resetInboundDedupe(); + previousHome = process.env.HOME; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-")); + process.env.HOME = tempHome; + }); + + afterEach(async () => { + process.env.HOME = previousHome; + if (tempHome) { + await rmDirWithRetries(tempHome); + tempHome = undefined; + } + }); +} + +export async function makeSessionStore( + entries: Record = {}, +): Promise<{ storePath: string; cleanup: () => Promise }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-")); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, JSON.stringify(entries)); + const cleanup = async () => { + await rmDirWithRetries(dir); + }; + return { + storePath, + cleanup, + }; +} + +export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) { + let resolvePinnedHostnameSpy: { mockRestore: () => unknown } | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + _resetBaileysMocks(); + _resetLoadConfigMock(); + if (opts?.pinDns) { + resolvePinnedHostnameSpy = vi + .spyOn(ssrf, "resolvePinnedHostname") + .mockImplementation(async (hostname) => { + // SSRF guard pins DNS; stub resolution to avoid live lookups in unit tests. + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = [TEST_NET_IP]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); + } + }); + + afterEach(() => { + resolvePinnedHostnameSpy?.mockRestore(); + resolvePinnedHostnameSpy = undefined; + resetLogger(); + setLoggerOverride(null); + vi.useRealTimers(); + }); +} diff --git a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.e2e.test.ts b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.e2e.test.ts index dc97de3a3eb..1e934667081 100644 --- a/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.e2e.test.ts +++ b/src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.e2e.test.ts @@ -1,130 +1,18 @@ -import "./test-helpers.js"; import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import sharp from "sharp"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; - -const TEST_NET_IP = "203.0.113.10"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); - -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { describe, expect, it, vi } from "vitest"; import { monitorWebChannel } from "./auto-reply.js"; -import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; +import { + installWebAutoReplyTestHomeHooks, + installWebAutoReplyUnitTestHooks, + resetLoadConfigMock, + setLoadConfigMock, +} from "./auto-reply.test-harness.js"; -let previousHome: string | undefined; -let tempHome: string | undefined; - -const rmDirWithRetries = async (dir: string): Promise => { - // Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); -}; - -beforeEach(async () => { - resetInboundDedupe(); - previousHome = process.env.HOME; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-")); - process.env.HOME = tempHome; -}); - -afterEach(async () => { - process.env.HOME = previousHome; - if (tempHome) { - await rmDirWithRetries(tempHome); - tempHome = undefined; - } -}); - -const _makeSessionStore = async ( - entries: Record = {}, -): Promise<{ storePath: string; cleanup: () => Promise }> => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-")); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile(storePath, JSON.stringify(entries)); - const cleanup = async () => { - // Session store writes can be in-flight when the test finishes (e.g. updateLastRoute - // after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); - }; - return { - storePath, - cleanup, - }; -}; +installWebAutoReplyTestHomeHooks(); describe("web auto-reply", () => { - let resolvePinnedHostnameSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - resetBaileysMocks(); - resetLoadConfigMock(); - resolvePinnedHostnameSpy = vi - .spyOn(ssrf, "resolvePinnedHostname") - .mockImplementation(async (hostname) => { - // SSRF guard pins DNS; stub resolution to avoid live lookups in unit tests. - const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); - const addresses = [TEST_NET_IP]; - return { - hostname: normalized, - addresses, - lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), - }; - }); - }); - - afterEach(() => { - resolvePinnedHostnameSpy?.mockRestore(); - resolvePinnedHostnameSpy = undefined; - resetLogger(); - setLoggerOverride(null); - vi.useRealTimers(); - }); + installWebAutoReplyUnitTestHooks({ pinDns: true }); it("compresses common formats to jpeg under the cap", { timeout: 45_000 }, async () => { const formats = [ diff --git a/src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts b/src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts index a44351199e9..f4696bdc14f 100644 --- a/src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts +++ b/src/web/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts @@ -1,129 +1,15 @@ -import "./test-helpers.js"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import sharp from "sharp"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; - -const TEST_NET_IP = "203.0.113.10"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); - -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { resetLogger, setLoggerOverride } from "../logging.js"; +import { describe, expect, it, vi } from "vitest"; import { monitorWebChannel } from "./auto-reply.js"; -import { resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; +import { + installWebAutoReplyTestHomeHooks, + installWebAutoReplyUnitTestHooks, +} from "./auto-reply.test-harness.js"; -let previousHome: string | undefined; -let tempHome: string | undefined; - -const rmDirWithRetries = async (dir: string): Promise => { - // Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); -}; - -beforeEach(async () => { - resetInboundDedupe(); - previousHome = process.env.HOME; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-")); - process.env.HOME = tempHome; -}); - -afterEach(async () => { - process.env.HOME = previousHome; - if (tempHome) { - await rmDirWithRetries(tempHome); - tempHome = undefined; - } -}); - -const _makeSessionStore = async ( - entries: Record = {}, -): Promise<{ storePath: string; cleanup: () => Promise }> => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-")); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile(storePath, JSON.stringify(entries)); - const cleanup = async () => { - // Session store writes can be in-flight when the test finishes (e.g. updateLastRoute - // after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY. - for (let attempt = 0; attempt < 10; attempt += 1) { - try { - await fs.rm(dir, { recursive: true, force: true }); - return; - } catch (err) { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code) - : null; - if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { - await new Promise((resolve) => setTimeout(resolve, 5)); - continue; - } - throw err; - } - } - - await fs.rm(dir, { recursive: true, force: true }); - }; - return { - storePath, - cleanup, - }; -}; +installWebAutoReplyTestHomeHooks(); describe("web auto-reply", () => { - let resolvePinnedHostnameSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - resetBaileysMocks(); - resetLoadConfigMock(); - resolvePinnedHostnameSpy = vi - .spyOn(ssrf, "resolvePinnedHostname") - .mockImplementation(async (hostname) => { - // SSRF guard pins DNS; stub resolution to avoid live lookups in unit tests. - const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); - const addresses = [TEST_NET_IP]; - return { - hostname: normalized, - addresses, - lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), - }; - }); - }); - - afterEach(() => { - resolvePinnedHostnameSpy?.mockRestore(); - resolvePinnedHostnameSpy = undefined; - resetLogger(); - setLoggerOverride(null); - vi.useRealTimers(); - }); + installWebAutoReplyUnitTestHooks({ pinDns: true }); it("falls back to text when media send fails", async () => { const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));