diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index 41da12974c3..23535789860 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -4,21 +4,10 @@ import { createTempHomeHarness, makeReplyConfig } from "./reply.test-harness.js" const runEmbeddedPiAgentMock = vi.fn(); -vi.mock("../agents/model-fallback.js", () => ({ - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), -})); +vi.mock( + "../agents/model-fallback.js", + async () => await import("../test-utils/model-fallback.mock.js"), +); vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts index d053eed25fb..c44d57ec104 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts @@ -1,13 +1,13 @@ import fs from "node:fs/promises"; import { beforeAll, describe, expect, it } from "vitest"; import { + expectDirectElevatedToggleOn, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, MAIN_SESSION_KEY, makeWhatsAppElevatedCfg, requireSessionStorePath, - runDirectElevatedToggleAndLoadStore, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -20,15 +20,7 @@ installTriggerHandlingE2eTestHooks(); describe("trigger handling", () => { it("allows approved sender to toggle elevated mode", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home); - const { text, store } = await runDirectElevatedToggleAndLoadStore({ - cfg, - getReplyFromConfig, - }); - expect(text).toContain("Elevated mode set to ask"); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); + await expectDirectElevatedToggleOn({ getReplyFromConfig }); }); it("rejects elevated toggles when disabled", async () => { await withTempHome(async (home) => { diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts index 034eeb7cdd5..731c496be96 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts @@ -1,13 +1,12 @@ import { beforeAll, describe, expect, it } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { + expectDirectElevatedToggleOn, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, - MAIN_SESSION_KEY, makeWhatsAppElevatedCfg, readSessionStore, requireSessionStorePath, - runDirectElevatedToggleAndLoadStore, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -72,14 +71,6 @@ describe("trigger handling", () => { }); it("allows elevated directive in direct chats without mentions", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home); - const { text, store } = await runDirectElevatedToggleAndLoadStore({ - cfg, - getReplyFromConfig, - }); - expect(text).toContain("Elevated mode set to ask"); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); + await expectDirectElevatedToggleOn({ getReplyFromConfig }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts index 21c95efce45..96fe1538cff 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts @@ -53,6 +53,22 @@ async function runCommandAndCollectReplies(params: { return { blockReplies, replies }; } +async function expectStopAbortWithoutAgent(params: { home: string; body: string; from: string }) { + const res = await getReplyFromConfig( + { + Body: params.body, + From: params.from, + To: "+2000", + CommandAuthorized: true, + }, + {}, + makeCfg(params.home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("⚙️ Agent was aborted."); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); +} + describe("trigger handling", () => { it("filters usage summary to the current model provider", async () => { await withTempHome(async (home) => { @@ -228,36 +244,20 @@ describe("trigger handling", () => { }); it("aborts even with timestamp prefix", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "[Dec 5 10:00] stop", - From: "+1000", - To: "+2000", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("⚙️ Agent was aborted."); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + await expectStopAbortWithoutAgent({ + home, + body: "[Dec 5 10:00] stop", + from: "+1000", + }); }); }); it("handles /stop without invoking the agent", async () => { await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/stop", - From: "+1003", - To: "+2000", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("⚙️ Agent was aborted."); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + await expectStopAbortWithoutAgent({ + home, + body: "/stop", + from: "+1003", + }); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 8276a3cd8cf..b3d1762d5a7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -1,11 +1,10 @@ import { beforeAll, describe, expect, it } from "vitest"; import { - createBlockReplyCollector, + expectInlineCommandHandledAndStripped, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, makeCfg, - mockRunEmbeddedPiAgentOk, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -48,52 +47,28 @@ async function expectUnauthorizedCommandDropped(home: string, body: "/status" | describe("trigger handling", () => { it("handles inline /commands and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); - const { blockReplies, handlers } = createBlockReplyCollector(); - const res = await getReplyFromConfig( - { - Body: "please /commands now", - From: "+1002", - To: "+2000", - CommandAuthorized: true, - }, - handlers, - makeCfg(home), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(blockReplies.length).toBe(1); - expect(blockReplies[0]?.text).toContain("Slash commands"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).not.toContain("/commands"); - expect(text).toBe("ok"); + await expectInlineCommandHandledAndStripped({ + home, + getReplyFromConfig, + body: "please /commands now", + stripToken: "/commands", + blockReplyContains: "Slash commands", + }); }); }); it("handles inline /whoami and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); - const { blockReplies, handlers } = createBlockReplyCollector(); - const res = await getReplyFromConfig( - { - Body: "please /whoami now", - From: "+1002", - To: "+2000", + await expectInlineCommandHandledAndStripped({ + home, + getReplyFromConfig, + body: "please /whoami now", + stripToken: "/whoami", + blockReplyContains: "Identity", + requestOverrides: { SenderId: "12345", - CommandAuthorized: true, }, - handlers, - makeCfg(home), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(blockReplies.length).toBe(1); - expect(blockReplies[0]?.text).toContain("Identity"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).not.toContain("/whoami"); - expect(text).toBe("ok"); + }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts index 8033ba4f5e2..52172b3ea98 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts @@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { resolveSessionKey } from "../config/sessions.js"; import { createBlockReplyCollector, + expectInlineCommandHandledAndStripped, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, @@ -116,25 +117,13 @@ describe("trigger handling", () => { it("handles inline /help and strips it before the agent", async () => { await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); - const { blockReplies, handlers } = createBlockReplyCollector(); - const res = await getReplyFromConfig( - { - Body: "please /help now", - From: "+1002", - To: "+2000", - CommandAuthorized: true, - }, - handlers, - makeCfg(home), - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(blockReplies.length).toBe(1); - expect(blockReplies[0]?.text).toContain("Help"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).not.toContain("/help"); - expect(text).toBe("ok"); + await expectInlineCommandHandledAndStripped({ + home, + getReplyFromConfig, + body: "please /help now", + stripToken: "/help", + blockReplyContains: "Help", + }); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index fcc3a6d0a2b..aef59ed891c 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -214,6 +214,51 @@ export async function runDirectElevatedToggleAndLoadStore(params: { return { text, store }; } +export async function expectDirectElevatedToggleOn(params: { + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; +}) { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home); + const { text, store } = await runDirectElevatedToggleAndLoadStore({ + cfg, + getReplyFromConfig: params.getReplyFromConfig, + }); + expect(text).toContain("Elevated mode set to ask"); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); + }); +} + +export async function expectInlineCommandHandledAndStripped(params: { + home: string; + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; + body: string; + stripToken: string; + blockReplyContains: string; + requestOverrides?: Record; +}) { + const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + const { blockReplies, handlers } = createBlockReplyCollector(); + const res = await params.getReplyFromConfig( + { + Body: params.body, + From: "+1002", + To: "+2000", + CommandAuthorized: true, + ...params.requestOverrides, + }, + handlers, + makeCfg(params.home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(blockReplies.length).toBe(1); + expect(blockReplies[0]?.text).toContain(params.blockReplyContains); + expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); + const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).not.toContain(params.stripToken); + expect(text).toBe("ok"); +} + export async function runGreetingPromptForBareNewOrReset(params: { home: string; body: "/new" | "/reset"; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 10d4efdd56c..fb183d76f2a 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -8,21 +8,10 @@ import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); -vi.mock("../../agents/model-fallback.js", () => ({ - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), -})); +vi.mock( + "../../agents/model-fallback.js", + async () => await import("../../test-utils/model-fallback.mock.js"), +); vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index 04e1e877874..c3891207540 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -1,35 +1,9 @@ import { describe, expect, it } from "vitest"; +import { createMockCronStateForJobs } from "./service.test-harness.js"; import { recomputeNextRunsForMaintenance } from "./service/jobs.js"; -import type { CronServiceState } from "./service/state.js"; import type { CronJob } from "./types.js"; describe("issue #13992 regression - cron jobs skip execution", () => { - function createMockState(jobs: CronJob[]): CronServiceState { - return { - store: { version: 1, jobs }, - running: false, - timer: null, - storeLoadedAtMs: Date.now(), - storeFileMtimeMs: null, - op: Promise.resolve(), - warnedDisabled: false, - deps: { - storePath: "/mock/path", - cronEnabled: true, - nowMs: () => Date.now(), - enqueueSystemEvent: () => {}, - requestHeartbeatNow: () => {}, - runIsolatedAgentJob: async () => ({ status: "ok" }), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as never, - }, - }; - } - it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => { const now = Date.now(); const pastDue = now - 60_000; // 1 minute ago @@ -49,7 +23,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { }, }; - const state = createMockState([job]); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); // Should not have changed the past-due nextRunAtMs @@ -74,7 +48,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { }, }; - const state = createMockState([job]); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); // Should have computed a nextRunAtMs @@ -101,7 +75,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { }, }; - const state = createMockState([job]); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); // Should have cleared nextRunAtMs for disabled job @@ -129,7 +103,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { }, }; - const state = createMockState([job]); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); // Should have cleared stuck running marker @@ -172,7 +146,7 @@ describe("issue #13992 regression - cron jobs skip execution", () => { }, }; - const state = createMockState([dueJob, malformedJob]); + const state = createMockCronStateForJobs({ jobs: [dueJob, malformedJob], nowMs: now }); expect(() => recomputeNextRunsForMaintenance(state)).not.toThrow(); expect(dueJob.state.nextRunAtMs).toBe(pastDue); diff --git a/src/cron/service.issue-16156-list-skips-cron.test.ts b/src/cron/service.issue-16156-list-skips-cron.test.ts index ff4bd7d7d7f..c0cda6d20bd 100644 --- a/src/cron/service.issue-16156-list-skips-cron.test.ts +++ b/src/cron/service.issue-16156-list-skips-cron.test.ts @@ -1,26 +1,16 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; -import { createStartedCronServiceWithFinishedBarrier } from "./service.test-harness.js"; +import { + createStartedCronServiceWithFinishedBarrier, + setupCronServiceSuite, +} from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -let fixtureRoot = ""; -let caseId = 0; - -async function makeStorePath() { - const dir = path.join(fixtureRoot, `case-${caseId++}`); - const storePath = path.join(dir, "cron", "jobs.json"); - await fs.mkdir(path.dirname(storePath), { recursive: true }); - return { storePath }; -} +const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({ + prefix: "openclaw-cron-16156-", + baseTimeIso: "2025-12-13T00:00:00.000Z", +}); async function writeJobsStore(storePath: string, jobs: unknown[]) { await fs.mkdir(path.dirname(storePath), { recursive: true }); @@ -39,29 +29,6 @@ function createCronFromStorePath(storePath: string) { } describe("#16156: cron.list() must not silently advance past-due recurring jobs", () => { - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - } - }); - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); - noopLogger.debug.mockClear(); - noopLogger.info.mockClear(); - noopLogger.warn.mockClear(); - noopLogger.error.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it("does not skip a cron job when list() is called while the job is past-due", async () => { const store = await makeStorePath(); const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ diff --git a/src/cron/service.issue-17852-daily-skip.test.ts b/src/cron/service.issue-17852-daily-skip.test.ts index 27f56abdd6a..3ec2a75466b 100644 --- a/src/cron/service.issue-17852-daily-skip.test.ts +++ b/src/cron/service.issue-17852-daily-skip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { createMockCronStateForJobs } from "./service.test-harness.js"; import { recomputeNextRuns, recomputeNextRunsForMaintenance } from "./service/jobs.js"; -import type { CronServiceState } from "./service/state.js"; import type { CronJob } from "./types.js"; /** @@ -19,32 +19,6 @@ describe("issue #17852 - daily cron jobs should not skip days", () => { const HOUR_MS = 3_600_000; const DAY_MS = 24 * HOUR_MS; - function createMockState(jobs: CronJob[], nowMs: number): CronServiceState { - return { - store: { version: 1, jobs }, - running: false, - timer: null, - storeLoadedAtMs: nowMs, - storeFileMtimeMs: null, - op: Promise.resolve(), - warnedDisabled: false, - deps: { - storePath: "/mock/path", - cronEnabled: true, - nowMs: () => nowMs, - enqueueSystemEvent: () => {}, - requestHeartbeatNow: () => {}, - runIsolatedAgentJob: async () => ({ status: "ok" }), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as never, - }, - }; - } - function createDailyThreeAmJob(threeAM: number): CronJob { return { id: "daily-job", @@ -71,7 +45,7 @@ describe("issue #17852 - daily cron jobs should not skip days", () => { const job = createDailyThreeAmJob(threeAM); - const state = createMockState([job], now); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); // Maintenance should NOT touch existing past-due nextRunAtMs. @@ -88,7 +62,7 @@ describe("issue #17852 - daily cron jobs should not skip days", () => { const job = createDailyThreeAmJob(threeAM); - const state = createMockState([job], now); + const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRuns(state); // The full recomputeNextRuns advances it to TOMORROW — skipping today's diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 1ea407a11db..4de0ab91f48 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as schedule from "./schedule.js"; import { CronService } from "./service.js"; -import { createRunningCronServiceState } from "./service.test-harness.js"; +import { createDeferred, createRunningCronServiceState } from "./service.test-harness.js"; import { computeJobNextRunAtMs } from "./service/jobs.js"; import { createCronServiceState, type CronEvent } from "./service/state.js"; import { onTimer } from "./service/timer.js"; @@ -38,16 +38,6 @@ async function makeStorePath() { }; } -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function createDueIsolatedJob(params: { id: string; nowMs: number; @@ -563,7 +553,6 @@ describe("Cron issue regressions", () => { let now = scheduledAt; let fireCount = 0; - const events: CronEvent[] = []; const state = createCronServiceState({ cronEnabled: true, storePath: store.storePath, @@ -571,9 +560,6 @@ describe("Cron issue regressions", () => { nowMs: () => now, enqueueSystemEvent: vi.fn(), requestHeartbeatNow: vi.fn(), - onEvent: (evt) => { - events.push(evt); - }, runIsolatedAgentJob: vi.fn(async () => { // Job completes very quickly (7ms) — still within the same second now += 7; diff --git a/src/cron/service.restart-catchup.test.ts b/src/cron/service.restart-catchup.test.ts index 5a430ef8c8b..ea42e7b5a70 100644 --- a/src/cron/service.restart-catchup.test.ts +++ b/src/cron/service.restart-catchup.test.ts @@ -2,16 +2,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; -import { - createCronStoreHarness, - createNoopLogger, - installCronTestHooks, -} from "./service.test-harness.js"; +import { setupCronServiceSuite } from "./service.test-harness.js"; -const noopLogger = createNoopLogger(); -const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); -installCronTestHooks({ - logger: noopLogger, +const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({ + prefix: "openclaw-cron-", baseTimeIso: "2025-12-13T17:00:00.000Z", }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 7729d2fa30e..97a2e04a301 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; import type { CronEvent, CronServiceDeps } from "./service.js"; import { CronService } from "./service.js"; -import { createNoopLogger, installCronTestHooks } from "./service.test-harness.js"; +import { createDeferred, createNoopLogger, installCronTestHooks } from "./service.test-harness.js"; const noopLogger = createNoopLogger(); installCronTestHooks({ logger: noopLogger }); @@ -196,16 +196,6 @@ beforeEach(() => { ensureDir(fixturesRoot); }); -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function createCronEventHarness() { const events: CronEvent[] = []; const waiters: Array<{ diff --git a/src/cron/service.store-migration.test.ts b/src/cron/service.store-migration.test.ts index c931d27cbfc..adaeec2b1e6 100644 --- a/src/cron/service.store-migration.test.ts +++ b/src/cron/service.store-migration.test.ts @@ -2,16 +2,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; -import { - createCronStoreHarness, - createNoopLogger, - installCronTestHooks, -} from "./service.test-harness.js"; +import { setupCronServiceSuite } from "./service.test-harness.js"; -const noopLogger = createNoopLogger(); -const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); -installCronTestHooks({ - logger: noopLogger, +const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({ + prefix: "openclaw-cron-", baseTimeIso: "2026-02-06T17:00:00.000Z", }); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 5ed45e33761..3143000d1ec 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import type { CronEvent } from "./service.js"; import { CronService } from "./service.js"; -import { createCronServiceState } from "./service/state.js"; +import { createCronServiceState, type CronServiceState } from "./service/state.js"; import type { CronJob } from "./types.js"; export type NoopLogger = { @@ -85,6 +85,16 @@ export function installCronTestHooks(options: { }); } +export function setupCronServiceSuite(options?: { prefix?: string; baseTimeIso?: string }) { + const logger = createNoopLogger(); + const { makeStorePath } = createCronStoreHarness({ prefix: options?.prefix }); + installCronTestHooks({ + logger, + baseTimeIso: options?.baseTimeIso, + }); + return { logger, makeStorePath }; +} + export function createFinishedBarrier() { const resolvers = new Map void>(); return { @@ -152,3 +162,43 @@ export function createRunningCronServiceState(params: { }; return state; } + +export function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export function createMockCronStateForJobs(params: { + jobs: CronJob[]; + nowMs?: number; +}): CronServiceState { + const nowMs = params.nowMs ?? Date.now(); + return { + store: { version: 1, jobs: params.jobs }, + running: false, + timer: null, + storeLoadedAtMs: nowMs, + storeFileMtimeMs: null, + op: Promise.resolve(), + warnedDisabled: false, + deps: { + storePath: "/mock/path", + cronEnabled: true, + nowMs: () => nowMs, + enqueueSystemEvent: () => {}, + requestHeartbeatNow: () => {}, + runIsolatedAgentJob: async () => ({ status: "ok" }), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as never, + }, + }; +} diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 8ef1348de06..b835df8863d 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -1,24 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; -import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; +import { + seedMainSessionStore, + setupTelegramHeartbeatPluginRuntimeForTests, + withTempHeartbeatSandbox, +} from "./heartbeat-runner.test-utils.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); beforeEach(() => { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), - ); + setupTelegramHeartbeatPluginRuntimeForTests(); resetSystemEventsForTest(); }); diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 70085d44f89..a5d72b4adad 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -2,9 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { vi } from "vitest"; +import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import * as replyModule from "../auto-reply/reply.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; export type HeartbeatSessionSeed = { sessionId?: string; @@ -91,3 +96,11 @@ export async function withTempTelegramHeartbeatSandbox( unsetEnvVars: ["TELEGRAM_BOT_TOKEN"], }); } + +export function setupTelegramHeartbeatPluginRuntimeForTests() { + const runtime = createPluginRuntime(); + setTelegramRuntime(runtime); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + ); +} diff --git a/src/infra/heartbeat-runner.transcript-prune.test.ts b/src/infra/heartbeat-runner.transcript-prune.test.ts index 715032a6199..9fea64d3406 100644 --- a/src/infra/heartbeat-runner.transcript-prune.test.ts +++ b/src/infra/heartbeat-runner.transcript-prune.test.ts @@ -1,16 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; -import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createPluginRuntime } from "../plugins/runtime/index.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; import { seedSessionStore, + setupTelegramHeartbeatPluginRuntimeForTests, withTempTelegramHeartbeatSandbox, } from "./heartbeat-runner.test-utils.js"; @@ -18,11 +14,7 @@ import { vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); beforeEach(() => { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); - setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), - ); + setupTelegramHeartbeatPluginRuntimeForTests(); }); describe("heartbeat transcript pruning", () => { diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts index cd36e52dd44..1f65b005652 100644 --- a/src/infra/infra-store.test.ts +++ b/src/infra/infra-store.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; import { getChannelActivity, recordChannelActivity, @@ -20,15 +20,6 @@ import { setVoiceWakeTriggers, } from "./voicewake.js"; -async function withTempDir(prefix: string, run: (dir: string) => Promise) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - await run(dir); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } -} - describe("infra store", () => { describe("state migrations fs", () => { it("treats array session stores as invalid", async () => { diff --git a/src/test-utils/model-fallback.mock.ts b/src/test-utils/model-fallback.mock.ts new file mode 100644 index 00000000000..fcdac4ea1fb --- /dev/null +++ b/src/test-utils/model-fallback.mock.ts @@ -0,0 +1,11 @@ +export async function runWithModelFallback(params: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; +}) { + return { + result: await params.run(params.provider, params.model), + provider: params.provider, + model: params.model, + }; +}