From 53cc62348120bd7e1429b63137e49f49b1c6455f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 21:47:57 +0000 Subject: [PATCH] perf(test): speed up web auto-reply last-route coverage --- src/web/auto-reply.test-harness.ts | 64 +++++--- ...-reply.group-gating-and-activation.test.ts | 12 +- ...to-reply.web-auto-reply.last-route.test.ts | 142 ++++++++++++++++++ ...-reply.prefixes-and-partial-gating.test.ts | 115 ++------------ 4 files changed, 208 insertions(+), 125 deletions(-) create mode 100644 src/web/auto-reply.web-auto-reply.last-route.test.ts diff --git a/src/web/auto-reply.test-harness.ts b/src/web/auto-reply.test-harness.ts index f412e485ad2..28313e1a7b3 100644 --- a/src/web/auto-reply.test-harness.ts +++ b/src/web/auto-reply.test-harness.ts @@ -2,7 +2,7 @@ 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 { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import type { WebInboundMessage } from "./inbound.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as ssrf from "../infra/net/ssrf.js"; @@ -36,43 +36,67 @@ export async function rmDirWithRetries( 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; + // Let Node handle retries (faster than re-walking the tree in JS on each retry). + try { + await fs.rm(dir, { + recursive: true, + force: true, + maxRetries: attempts, + retryDelay: delayMs, + }); + return; + } catch (err) { + // Fall back for older Node implementations (or unexpected retry behavior). + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true }); + return; + } catch (retryErr) { + const code = + retryErr && typeof retryErr === "object" && "code" in retryErr + ? String((retryErr as { code?: unknown }).code) + : null; + if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + throw retryErr; } - throw err; } - } - await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(dir, { recursive: true, force: true }); + } } let previousHome: string | undefined; let tempHome: string | undefined; +let tempHomeRoot: string | undefined; +let tempHomeId = 0; export function installWebAutoReplyTestHomeHooks() { + beforeAll(async () => { + tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-suite-")); + }); + beforeEach(async () => { resetInboundDedupe(); previousHome = process.env.HOME; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-")); + tempHome = path.join(tempHomeRoot ?? os.tmpdir(), `case-${++tempHomeId}`); + await fs.mkdir(tempHome, { recursive: true }); process.env.HOME = tempHome; }); afterEach(async () => { process.env.HOME = previousHome; - if (tempHome) { - await rmDirWithRetries(tempHome); - tempHome = undefined; + tempHome = undefined; + }); + + afterAll(async () => { + if (tempHomeRoot) { + await rmDirWithRetries(tempHomeRoot); + tempHomeRoot = undefined; } + tempHomeId = 0; }); } diff --git a/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts b/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts index 5d341c44ec0..c1174e509c8 100644 --- a/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts +++ b/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { setLoggerOverride } from "../logging.js"; import { @@ -18,9 +18,19 @@ installWebAutoReplyTestHomeHooks(); let monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; let SILENT_REPLY_TOKEN: typeof import("./auto-reply.js").SILENT_REPLY_TOKEN; +let lastRouteSpy: { mockRestore: () => void } | undefined; beforeAll(async () => { ({ monitorWebChannel, SILENT_REPLY_TOKEN } = await import("./auto-reply.js")); + const lastRoute = await import("./auto-reply/monitor/last-route.js"); + lastRouteSpy = vi + .spyOn(lastRoute, "updateLastRouteInBackground") + .mockImplementation(() => undefined); +}); + +afterAll(() => { + lastRouteSpy?.mockRestore(); + lastRouteSpy = undefined; }); describe("web auto-reply", () => { diff --git a/src/web/auto-reply.web-auto-reply.last-route.test.ts b/src/web/auto-reply.web-auto-reply.last-route.test.ts new file mode 100644 index 00000000000..989010bba1a --- /dev/null +++ b/src/web/auto-reply.web-auto-reply.last-route.test.ts @@ -0,0 +1,142 @@ +import "./test-helpers.js"; +import fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js"; +import { buildMentionConfig } from "./auto-reply/mentions.js"; +import { createEchoTracker } from "./auto-reply/monitor/echo.js"; +import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js"; +import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js"; + +describe("web auto-reply last-route", () => { + installWebAutoReplyUnitTestHooks(); + + it("updates last-route for direct chats without senderE164", async () => { + const now = Date.now(); + const mainSessionKey = "agent:main:main"; + const store = await makeSessionStore({ + [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: store.storePath }, + }; + + const backgroundTasks = new Set>(); + const handler = createWebOnMessageHandler({ + cfg: mockConfig, + verbose: false, + connectionId: "test", + maxMediaBytes: 1024, + groupHistoryLimit: 3, + groupHistories: new Map(), + groupMemberNames: new Map(), + echoTracker: createEchoTracker({ maxItems: 10 }), + backgroundTasks, + replyResolver, + replyLogger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as Parameters[0]["replyLogger"], + baseMentionConfig: buildMentionConfig(mockConfig), + account: {}, + }); + + await handler({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "direct", + chatId: "direct:+1000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + + await awaitBackgroundTasks(backgroundTasks); + + const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastChannel?: string; lastTo?: string } + >; + expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp"); + expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); + + await store.cleanup(); + }); + + it("updates last-route for group chats with account id", async () => { + const now = Date.now(); + const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; + const store = await makeSessionStore({ + [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, + }); + + const replyResolver = vi.fn().mockResolvedValue(undefined); + + const mockConfig: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: store.storePath }, + }; + + const backgroundTasks = new Set>(); + const handler = createWebOnMessageHandler({ + cfg: mockConfig, + verbose: false, + connectionId: "test", + maxMediaBytes: 1024, + groupHistoryLimit: 3, + groupHistories: new Map(), + groupMemberNames: new Map(), + echoTracker: createEchoTracker({ maxItems: 10 }), + backgroundTasks, + replyResolver, + replyLogger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as Parameters[0]["replyLogger"], + baseMentionConfig: buildMentionConfig(mockConfig), + account: {}, + }); + + await handler({ + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+2000", + body: "hello", + timestamp: now, + chatType: "group", + chatId: "123@g.us", + accountId: "work", + senderE164: "+1000", + senderName: "Alice", + selfE164: "+2000", + sendComposing: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + sendMedia: vi.fn().mockResolvedValue(undefined), + }); + + await awaitBackgroundTasks(backgroundTasks); + + const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< + string, + { lastChannel?: string; lastTo?: string; lastAccountId?: string } + >; + expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp"); + expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); + expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); + + await store.cleanup(); + }); +}); diff --git a/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts b/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts index 59d67d0ae73..1b0bc6c14e3 100644 --- a/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts +++ b/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts @@ -1,6 +1,6 @@ import "./test-helpers.js"; import fs from "node:fs/promises"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { installWebAutoReplyTestHomeHooks, @@ -16,11 +16,21 @@ let monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; let HEARTBEAT_TOKEN: typeof import("./auto-reply.js").HEARTBEAT_TOKEN; let getReplyFromConfig: typeof import("../auto-reply/reply.js").getReplyFromConfig; let runEmbeddedPiAgent: typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent; +let lastRouteSpy: { mockRestore: () => void } | undefined; beforeAll(async () => { ({ monitorWebChannel, HEARTBEAT_TOKEN } = await import("./auto-reply.js")); ({ getReplyFromConfig } = await import("../auto-reply/reply.js")); ({ runEmbeddedPiAgent } = await import("../agents/pi-embedded.js")); + const lastRoute = await import("./auto-reply/monitor/last-route.js"); + lastRouteSpy = vi + .spyOn(lastRoute, "updateLastRouteInBackground") + .mockImplementation(() => undefined); +}); + +afterAll(() => { + lastRouteSpy?.mockRestore(); + lastRouteSpy = undefined; }); function createCapturedListener() { @@ -512,109 +522,6 @@ describe("partial reply gating", () => { expect(ctx.SenderId).toBe("+1000"); }); - it("updates last-route for direct chats without senderE164", async () => { - const now = Date.now(); - const mainSessionKey = "agent:main:main"; - const store = await makeSessionStore({ - [mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, - }); - - const replyResolver = vi.fn().mockResolvedValue(undefined); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: store.storePath }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: now, - chatType: "direct", - chatId: "direct:+1000", - sendComposing: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - sendMedia: vi.fn().mockResolvedValue(undefined), - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< - string, - { lastChannel?: string; lastTo?: string } - >; - expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); - - resetLoadConfigMock(); - await store.cleanup(); - }); - - it("updates last-route for group chats with account id", async () => { - const now = Date.now(); - const groupSessionKey = "agent:main:whatsapp:group:123@g.us"; - const store = await makeSessionStore({ - [groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 }, - }); - - const replyResolver = vi.fn().mockResolvedValue(undefined); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: store.storePath }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "g1", - from: "123@g.us", - conversationId: "123@g.us", - to: "+2000", - body: "hello", - timestamp: now, - chatType: "group", - chatId: "123@g.us", - accountId: "work", - senderE164: "+1000", - senderName: "Alice", - selfE164: "+2000", - sendComposing: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - sendMedia: vi.fn().mockResolvedValue(undefined), - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record< - string, - { lastChannel?: string; lastTo?: string; lastAccountId?: string } - >; - expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); - expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); - - resetLoadConfigMock(); - await store.cleanup(); - }); - it("defaults to self-only when no config is present", async () => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }],