refactor(test): centralize trigger and cron test helpers

This commit is contained in:
Peter Steinberger
2026-02-22 20:01:54 +00:00
parent 3c75bc0e41
commit 5e8b1f5ac8
21 changed files with 217 additions and 316 deletions

View File

@@ -4,21 +4,10 @@ import { createTempHomeHarness, makeReplyConfig } from "./reply.test-harness.js"
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../agents/model-fallback.js", () => ({ vi.mock(
runWithModelFallback: async ({ "../agents/model-fallback.js",
provider, async () => await import("../test-utils/model-fallback.mock.js"),
model, );
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),

View File

@@ -1,13 +1,13 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { import {
expectDirectElevatedToggleOn,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig, loadGetReplyFromConfig,
MAIN_SESSION_KEY, MAIN_SESSION_KEY,
makeWhatsAppElevatedCfg, makeWhatsAppElevatedCfg,
requireSessionStorePath, requireSessionStorePath,
runDirectElevatedToggleAndLoadStore,
withTempHome, withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js"; } from "./reply.triggers.trigger-handling.test-harness.js";
@@ -20,15 +20,7 @@ installTriggerHandlingE2eTestHooks();
describe("trigger handling", () => { describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => { it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => { await expectDirectElevatedToggleOn({ getReplyFromConfig });
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");
});
}); });
it("rejects elevated toggles when disabled", async () => { it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {

View File

@@ -1,13 +1,12 @@
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadSessionStore } from "../config/sessions.js"; import { loadSessionStore } from "../config/sessions.js";
import { import {
expectDirectElevatedToggleOn,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig, loadGetReplyFromConfig,
MAIN_SESSION_KEY,
makeWhatsAppElevatedCfg, makeWhatsAppElevatedCfg,
readSessionStore, readSessionStore,
requireSessionStorePath, requireSessionStorePath,
runDirectElevatedToggleAndLoadStore,
withTempHome, withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js"; } 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 () => { it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => { await expectDirectElevatedToggleOn({ getReplyFromConfig });
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");
});
}); });
}); });

View File

@@ -53,6 +53,22 @@ async function runCommandAndCollectReplies(params: {
return { blockReplies, replies }; 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", () => { describe("trigger handling", () => {
it("filters usage summary to the current model provider", async () => { it("filters usage summary to the current model provider", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
@@ -228,36 +244,20 @@ describe("trigger handling", () => {
}); });
it("aborts even with timestamp prefix", async () => { it("aborts even with timestamp prefix", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const res = await getReplyFromConfig( await expectStopAbortWithoutAgent({
{ home,
Body: "[Dec 5 10:00] stop", body: "[Dec 5 10:00] stop",
From: "+1000", 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();
}); });
}); });
it("handles /stop without invoking the agent", async () => { it("handles /stop without invoking the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const res = await getReplyFromConfig( await expectStopAbortWithoutAgent({
{ home,
Body: "/stop", body: "/stop",
From: "+1003", 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();
}); });
}); });
}); });

View File

@@ -1,11 +1,10 @@
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { import {
createBlockReplyCollector, expectInlineCommandHandledAndStripped,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig, loadGetReplyFromConfig,
makeCfg, makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome, withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js"; } from "./reply.triggers.trigger-handling.test-harness.js";
@@ -48,52 +47,28 @@ async function expectUnauthorizedCommandDropped(home: string, body: "/status" |
describe("trigger handling", () => { describe("trigger handling", () => {
it("handles inline /commands and strips it before the agent", async () => { it("handles inline /commands and strips it before the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); await expectInlineCommandHandledAndStripped({
const { blockReplies, handlers } = createBlockReplyCollector(); home,
const res = await getReplyFromConfig( getReplyFromConfig,
{ body: "please /commands now",
Body: "please /commands now", stripToken: "/commands",
From: "+1002", blockReplyContains: "Slash commands",
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");
}); });
}); });
it("handles inline /whoami and strips it before the agent", async () => { it("handles inline /whoami and strips it before the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); await expectInlineCommandHandledAndStripped({
const { blockReplies, handlers } = createBlockReplyCollector(); home,
const res = await getReplyFromConfig( getReplyFromConfig,
{ body: "please /whoami now",
Body: "please /whoami now", stripToken: "/whoami",
From: "+1002", blockReplyContains: "Identity",
To: "+2000", requestOverrides: {
SenderId: "12345", 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");
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from "vitest";
import { resolveSessionKey } from "../config/sessions.js"; import { resolveSessionKey } from "../config/sessions.js";
import { import {
createBlockReplyCollector, createBlockReplyCollector,
expectInlineCommandHandledAndStripped,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig, loadGetReplyFromConfig,
@@ -116,25 +117,13 @@ describe("trigger handling", () => {
it("handles inline /help and strips it before the agent", async () => { it("handles inline /help and strips it before the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); await expectInlineCommandHandledAndStripped({
const { blockReplies, handlers } = createBlockReplyCollector(); home,
const res = await getReplyFromConfig( getReplyFromConfig,
{ body: "please /help now",
Body: "please /help now", stripToken: "/help",
From: "+1002", blockReplyContains: "Help",
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");
}); });
}); });
}); });

View File

@@ -214,6 +214,51 @@ export async function runDirectElevatedToggleAndLoadStore(params: {
return { text, store }; 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<string, unknown>;
}) {
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: { export async function runGreetingPromptForBareNewOrReset(params: {
home: string; home: string;
body: "/new" | "/reset"; body: "/new" | "/reset";

View File

@@ -8,21 +8,10 @@ import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({ vi.mock(
runWithModelFallback: async ({ "../../agents/model-fallback.js",
provider, async () => await import("../../test-utils/model-fallback.mock.js"),
model, );
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({ vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),

View File

@@ -1,35 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createMockCronStateForJobs } from "./service.test-harness.js";
import { recomputeNextRunsForMaintenance } from "./service/jobs.js"; import { recomputeNextRunsForMaintenance } from "./service/jobs.js";
import type { CronServiceState } from "./service/state.js";
import type { CronJob } from "./types.js"; import type { CronJob } from "./types.js";
describe("issue #13992 regression - cron jobs skip execution", () => { 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", () => { it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => {
const now = Date.now(); const now = Date.now();
const pastDue = now - 60_000; // 1 minute ago 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); recomputeNextRunsForMaintenance(state);
// Should not have changed the past-due nextRunAtMs // 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); recomputeNextRunsForMaintenance(state);
// Should have computed a nextRunAtMs // 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); recomputeNextRunsForMaintenance(state);
// Should have cleared nextRunAtMs for disabled job // 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); recomputeNextRunsForMaintenance(state);
// Should have cleared stuck running marker // 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(() => recomputeNextRunsForMaintenance(state)).not.toThrow();
expect(dueJob.state.nextRunAtMs).toBe(pastDue); expect(dueJob.state.nextRunAtMs).toBe(pastDue);

View File

@@ -1,26 +1,16 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; 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 { CronService } from "./service.js";
import { createStartedCronServiceWithFinishedBarrier } from "./service.test-harness.js"; import {
createStartedCronServiceWithFinishedBarrier,
setupCronServiceSuite,
} from "./service.test-harness.js";
const noopLogger = { const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
debug: vi.fn(), prefix: "openclaw-cron-16156-",
info: vi.fn(), baseTimeIso: "2025-12-13T00:00:00.000Z",
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 };
}
async function writeJobsStore(storePath: string, jobs: unknown[]) { async function writeJobsStore(storePath: string, jobs: unknown[]) {
await fs.mkdir(path.dirname(storePath), { recursive: true }); 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", () => { 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 () => { it("does not skip a cron job when list() is called while the job is past-due", async () => {
const store = await makeStorePath(); const store = await makeStorePath();
const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createMockCronStateForJobs } from "./service.test-harness.js";
import { recomputeNextRuns, recomputeNextRunsForMaintenance } from "./service/jobs.js"; import { recomputeNextRuns, recomputeNextRunsForMaintenance } from "./service/jobs.js";
import type { CronServiceState } from "./service/state.js";
import type { CronJob } from "./types.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 HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS; 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 { function createDailyThreeAmJob(threeAM: number): CronJob {
return { return {
id: "daily-job", id: "daily-job",
@@ -71,7 +45,7 @@ describe("issue #17852 - daily cron jobs should not skip days", () => {
const job = createDailyThreeAmJob(threeAM); const job = createDailyThreeAmJob(threeAM);
const state = createMockState([job], now); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state); recomputeNextRunsForMaintenance(state);
// Maintenance should NOT touch existing past-due nextRunAtMs. // 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 job = createDailyThreeAmJob(threeAM);
const state = createMockState([job], now); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRuns(state); recomputeNextRuns(state);
// The full recomputeNextRuns advances it to TOMORROW — skipping today's // The full recomputeNextRuns advances it to TOMORROW — skipping today's

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as schedule from "./schedule.js"; import * as schedule from "./schedule.js";
import { CronService } from "./service.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 { computeJobNextRunAtMs } from "./service/jobs.js";
import { createCronServiceState, type CronEvent } from "./service/state.js"; import { createCronServiceState, type CronEvent } from "./service/state.js";
import { onTimer } from "./service/timer.js"; import { onTimer } from "./service/timer.js";
@@ -38,16 +38,6 @@ async function makeStorePath() {
}; };
} }
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createDueIsolatedJob(params: { function createDueIsolatedJob(params: {
id: string; id: string;
nowMs: number; nowMs: number;
@@ -563,7 +553,6 @@ describe("Cron issue regressions", () => {
let now = scheduledAt; let now = scheduledAt;
let fireCount = 0; let fireCount = 0;
const events: CronEvent[] = [];
const state = createCronServiceState({ const state = createCronServiceState({
cronEnabled: true, cronEnabled: true,
storePath: store.storePath, storePath: store.storePath,
@@ -571,9 +560,6 @@ describe("Cron issue regressions", () => {
nowMs: () => now, nowMs: () => now,
enqueueSystemEvent: vi.fn(), enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(), requestHeartbeatNow: vi.fn(),
onEvent: (evt) => {
events.push(evt);
},
runIsolatedAgentJob: vi.fn(async () => { runIsolatedAgentJob: vi.fn(async () => {
// Job completes very quickly (7ms) — still within the same second // Job completes very quickly (7ms) — still within the same second
now += 7; now += 7;

View File

@@ -2,16 +2,10 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js"; import { CronService } from "./service.js";
import { import { setupCronServiceSuite } from "./service.test-harness.js";
createCronStoreHarness,
createNoopLogger,
installCronTestHooks,
} from "./service.test-harness.js";
const noopLogger = createNoopLogger(); const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); prefix: "openclaw-cron-",
installCronTestHooks({
logger: noopLogger,
baseTimeIso: "2025-12-13T17:00:00.000Z", baseTimeIso: "2025-12-13T17:00:00.000Z",
}); });

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
import type { CronEvent, CronServiceDeps } from "./service.js"; import type { CronEvent, CronServiceDeps } from "./service.js";
import { CronService } 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(); const noopLogger = createNoopLogger();
installCronTestHooks({ logger: noopLogger }); installCronTestHooks({ logger: noopLogger });
@@ -196,16 +196,6 @@ beforeEach(() => {
ensureDir(fixturesRoot); ensureDir(fixturesRoot);
}); });
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createCronEventHarness() { function createCronEventHarness() {
const events: CronEvent[] = []; const events: CronEvent[] = [];
const waiters: Array<{ const waiters: Array<{

View File

@@ -2,16 +2,10 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js"; import { CronService } from "./service.js";
import { import { setupCronServiceSuite } from "./service.test-harness.js";
createCronStoreHarness,
createNoopLogger,
installCronTestHooks,
} from "./service.test-harness.js";
const noopLogger = createNoopLogger(); const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-" }); prefix: "openclaw-cron-",
installCronTestHooks({
logger: noopLogger,
baseTimeIso: "2026-02-06T17:00:00.000Z", baseTimeIso: "2026-02-06T17:00:00.000Z",
}); });

View File

@@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { CronEvent } from "./service.js"; import type { CronEvent } from "./service.js";
import { CronService } 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"; import type { CronJob } from "./types.js";
export type NoopLogger = { 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() { export function createFinishedBarrier() {
const resolvers = new Map<string, (evt: CronEvent) => void>(); const resolvers = new Map<string, (evt: CronEvent) => void>();
return { return {
@@ -152,3 +162,43 @@ export function createRunningCronServiceState(params: {
}; };
return state; return state;
} }
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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,
},
};
}

View File

@@ -1,24 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 * as replyModule from "../auto-reply/reply.js";
import type { OpenClawConfig } from "../config/config.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 { 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"; import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js";
// Avoid pulling optional runtime deps during isolated runs. // Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
beforeEach(() => { beforeEach(() => {
const runtime = createPluginRuntime(); setupTelegramHeartbeatPluginRuntimeForTests();
setTelegramRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
);
resetSystemEventsForTest(); resetSystemEventsForTest();
}); });

View File

@@ -2,9 +2,14 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { vi } from "vitest"; 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 * as replyModule from "../auto-reply/reply.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.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 = { export type HeartbeatSessionSeed = {
sessionId?: string; sessionId?: string;
@@ -91,3 +96,11 @@ export async function withTempTelegramHeartbeatSandbox<T>(
unsetEnvVars: ["TELEGRAM_BOT_TOKEN"], unsetEnvVars: ["TELEGRAM_BOT_TOKEN"],
}); });
} }
export function setupTelegramHeartbeatPluginRuntimeForTests() {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
);
}

View File

@@ -1,16 +1,12 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 type { OpenClawConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.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 { runHeartbeatOnce } from "./heartbeat-runner.js";
import { import {
seedSessionStore, seedSessionStore,
setupTelegramHeartbeatPluginRuntimeForTests,
withTempTelegramHeartbeatSandbox, withTempTelegramHeartbeatSandbox,
} from "./heartbeat-runner.test-utils.js"; } from "./heartbeat-runner.test-utils.js";
@@ -18,11 +14,7 @@ import {
vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
beforeEach(() => { beforeEach(() => {
const runtime = createPluginRuntime(); setupTelegramHeartbeatPluginRuntimeForTests();
setTelegramRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
);
}); });
describe("heartbeat transcript pruning", () => { describe("heartbeat transcript pruning", () => {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-utils/temp-dir.js";
import { import {
getChannelActivity, getChannelActivity,
recordChannelActivity, recordChannelActivity,
@@ -20,15 +20,6 @@ import {
setVoiceWakeTriggers, setVoiceWakeTriggers,
} from "./voicewake.js"; } from "./voicewake.js";
async function withTempDir(prefix: string, run: (dir: string) => Promise<void>) {
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("infra store", () => {
describe("state migrations fs", () => { describe("state migrations fs", () => {
it("treats array session stores as invalid", async () => { it("treats array session stores as invalid", async () => {

View File

@@ -0,0 +1,11 @@
export async function runWithModelFallback(params: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) {
return {
result: await params.run(params.provider, params.model),
provider: params.provider,
model: params.model,
};
}