perf(test): speed up suites and reduce fs churn

This commit is contained in:
Peter Steinberger
2026-02-15 19:18:49 +00:00
parent 8fdde0429e
commit 92f8c0fac3
32 changed files with 1793 additions and 1398 deletions

View File

@@ -190,12 +190,14 @@ describe("CronService interval/cron jobs fire on time", () => {
});
await cron.start();
for (let minute = 1; minute <= 6; minute++) {
// Perf: a few recomputation cycles are enough to catch legacy "every" drift.
for (let minute = 1; minute <= 3; minute++) {
vi.setSystemTime(new Date(nowMs + minute * 60_000));
const minuteRun = await cron.run("minute-cron", "force");
expect(minuteRun).toEqual({ ok: true, ran: true });
}
// "every" cadence is 2m; verify it stays due at the 6-minute boundary.
vi.setSystemTime(new Date(nowMs + 6 * 60_000));
const sfRun = await cron.run("legacy-every", "due");
expect(sfRun).toEqual({ ok: true, ran: true });

View File

@@ -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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CronEvent } from "./service.js";
import { CronService } from "./service.js";
@@ -12,14 +12,14 @@ const noopLogger = {
error: vi.fn(),
};
let fixtureRoot = "";
let caseId = 0;
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-16156-"));
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
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 };
}
function createFinishedBarrier() {
@@ -44,6 +44,16 @@ function createFinishedBarrier() {
}
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"));
@@ -119,7 +129,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(updated?.state.nextRunAtMs).toBeGreaterThan(firstDueAt);
cron.stop();
await store.cleanup();
});
it("does not skip a cron job when status() is called while the job is past-due", async () => {
@@ -172,7 +181,6 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(updated?.state.lastStatus).toBe("ok");
cron.stop();
await store.cleanup();
});
it("still fills missing nextRunAtMs via list() for enabled jobs", async () => {
@@ -226,6 +234,5 @@ describe("#16156: cron.list() must not silently advance past-due recurring jobs"
expect(job?.state.nextRunAtMs).toBeGreaterThan(nowMs);
cron.stop();
await store.cleanup();
});
});

View File

@@ -24,9 +24,6 @@ async function makeStorePath() {
const storePath = path.join(dir, "jobs.json");
return {
storePath,
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
@@ -152,7 +149,6 @@ describe("Cron issue regressions", () => {
}
cron.stop();
await store.cleanup();
});
it("repairs missing nextRunAtMs on non-schedule updates without touching other jobs", async () => {
@@ -183,7 +179,6 @@ describe("Cron issue regressions", () => {
expect(updated.state.nextRunAtMs).toBe(created.state.nextRunAtMs);
cron.stop();
await store.cleanup();
});
it("does not advance unrelated due jobs when updating another job", async () => {
@@ -230,7 +225,6 @@ describe("Cron issue regressions", () => {
expect(persistedDueJob?.state?.nextRunAtMs).toBe(originalDueNextRunAtMs);
cron.stop();
await store.cleanup();
});
it("treats persisted jobs with missing enabled as enabled during update()", async () => {
@@ -366,7 +360,6 @@ describe("Cron issue regressions", () => {
cron.stop();
timeoutSpy.mockRestore();
await store.cleanup();
});
it("re-arms timer without hot-looping when a run is already in progress", async () => {
@@ -400,7 +393,6 @@ describe("Cron issue regressions", () => {
.filter((d): d is number => typeof d === "number");
expect(delays).toContain(60_000);
timeoutSpy.mockRestore();
await store.cleanup();
});
it("skips forced manual runs while a timer-triggered run is in progress", async () => {
@@ -467,7 +459,6 @@ describe("Cron issue regressions", () => {
await cron.list({ includeDisabled: true });
cron.stop();
await store.cleanup();
});
it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => {
@@ -523,7 +514,6 @@ describe("Cron issue regressions", () => {
expect(enqueueSystemEvent).not.toHaveBeenCalled();
cron.stop();
}
await store.cleanup();
});
it("records per-job start time and duration for batched due jobs", async () => {
@@ -569,7 +559,5 @@ describe("Cron issue regressions", () => {
expect(secondDone?.state.lastRunAtMs).toBe(dueAt + 50);
expect(secondDone?.state.lastDurationMs).toBe(20);
expect(startedAtEvents).toEqual([dueAt, dueAt + 50]);
await store.cleanup();
});
});

View File

@@ -1,19 +1,201 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
import type { CronEvent } from "./service.js";
import { CronService } from "./service.js";
import {
createCronStoreHarness,
createNoopLogger,
installCronTestHooks,
} from "./service.test-harness.js";
import { createNoopLogger, installCronTestHooks } from "./service.test-harness.js";
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness();
installCronTestHooks({ logger: noopLogger });
type FakeFsEntry =
| { kind: "file"; content: string; mtimeMs: number }
| { kind: "dir"; mtimeMs: number };
const fsState = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
nowMs: 0,
fixtureCount: 0,
}));
const abs = (p: string) => path.resolve(p);
const fixturesRoot = abs(path.join("__openclaw_vitest__", "cron", "runs-one-shot"));
const isFixturePath = (p: string) => {
const resolved = abs(p);
const rootPrefix = `${fixturesRoot}${path.sep}`;
return resolved === fixturesRoot || resolved.startsWith(rootPrefix);
};
function bumpMtimeMs() {
fsState.nowMs += 1;
return fsState.nowMs;
}
function ensureDir(dirPath: string) {
let current = abs(dirPath);
while (true) {
if (!fsState.entries.has(current)) {
fsState.entries.set(current, { kind: "dir", mtimeMs: bumpMtimeMs() });
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
}
function setFile(filePath: string, content: string) {
const resolved = abs(filePath);
ensureDir(path.dirname(resolved));
fsState.entries.set(resolved, { kind: "file", content, mtimeMs: bumpMtimeMs() });
}
async function makeStorePath() {
const dir = path.join(fixturesRoot, `case-${fsState.fixtureCount++}`);
ensureDir(dir);
const storePath = path.join(dir, "cron", "jobs.json");
ensureDir(path.dirname(storePath));
return { storePath, cleanup: async () => {} };
}
function writeStoreFile(storePath: string, payload: unknown) {
setFile(storePath, JSON.stringify(payload, null, 2));
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const pathMod = await import("node:path");
const absInMock = (p: string) => pathMod.resolve(p);
const isFixtureInMock = (p: string) => {
const resolved = absInMock(p);
const rootPrefix = `${absInMock(fixturesRoot)}${pathMod.sep}`;
return resolved === absInMock(fixturesRoot) || resolved.startsWith(rootPrefix);
};
const mkErr = (code: string, message: string) => Object.assign(new Error(message), { code });
const promises = {
...actual.promises,
mkdir: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.mkdir as any)(p, { recursive: true });
}
ensureDir(p);
},
readFile: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.readFile as any)(p, "utf-8");
}
const entry = fsState.entries.get(absInMock(p));
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, open '${p}'`);
}
return entry.content;
},
writeFile: async (p: string, data: string | Uint8Array) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.writeFile as any)(p, data, "utf-8");
}
const content = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
setFile(p, content);
},
rename: async (from: string, to: string) => {
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.rename as any)(from, to);
}
const fromAbs = absInMock(from);
const toAbs = absInMock(to);
const entry = fsState.entries.get(fromAbs);
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, rename '${from}' -> '${to}'`);
}
ensureDir(pathMod.dirname(toAbs));
fsState.entries.delete(fromAbs);
fsState.entries.set(toAbs, { ...entry, mtimeMs: bumpMtimeMs() });
},
copyFile: async (from: string, to: string) => {
if (!isFixtureInMock(from) || !isFixtureInMock(to)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.copyFile as any)(from, to);
}
const entry = fsState.entries.get(absInMock(from));
if (!entry || entry.kind !== "file") {
throw mkErr("ENOENT", `ENOENT: no such file or directory, copyfile '${from}' -> '${to}'`);
}
setFile(to, entry.content);
},
stat: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.stat as any)(p);
}
const entry = fsState.entries.get(absInMock(p));
if (!entry) {
throw mkErr("ENOENT", `ENOENT: no such file or directory, stat '${p}'`);
}
return {
mtimeMs: entry.mtimeMs,
isDirectory: () => entry.kind === "dir",
isFile: () => entry.kind === "file",
};
},
access: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.access as any)(p);
}
const entry = fsState.entries.get(absInMock(p));
if (!entry) {
throw mkErr("ENOENT", `ENOENT: no such file or directory, access '${p}'`);
}
},
unlink: async (p: string) => {
if (!isFixtureInMock(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.promises.unlink as any)(p);
}
fsState.entries.delete(absInMock(p));
},
} satisfies typeof actual.promises;
const wrapped = { ...actual, promises };
return { ...wrapped, default: wrapped };
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
const wrapped = {
...actual,
mkdir: async (p: string, _opts?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.mkdir as any)(p, { recursive: true });
}
ensureDir(p);
},
writeFile: async (p: string, data: string, _enc?: unknown) => {
if (!isFixturePath(p)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (actual.writeFile as any)(p, data, "utf-8");
}
setFile(p, data);
},
};
return { ...wrapped, default: wrapped };
});
beforeEach(() => {
fsState.entries.clear();
fsState.nowMs = 0;
fsState.fixtureCount = 0;
ensureDir(fixturesRoot);
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -57,35 +239,8 @@ function createCronEventHarness() {
}
describe("CronService", () => {
async function loadLegacyJobFromStore(rawJob: Record<string, unknown>) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({ version: 1, jobs: [rawJob] }, null, 2),
"utf-8",
);
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
return { cron, store, enqueueSystemEvent, requestHeartbeatNow, job };
}
it("runs a one-shot main job and disables it after success when requested", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -134,6 +289,7 @@ describe("CronService", () => {
});
it("runs a one-shot job and deletes it after success by default", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -177,6 +333,7 @@ describe("CronService", () => {
});
it("wakeMode now waits for heartbeat completion when available", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -244,6 +401,7 @@ describe("CronService", () => {
});
it("passes agentId to runHeartbeatOnce for main-session wakeMode now jobs", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -293,6 +451,7 @@ describe("CronService", () => {
});
it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -343,6 +502,7 @@ describe("CronService", () => {
});
it("runs an isolated job and posts summary to main", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -391,6 +551,7 @@ describe("CronService", () => {
});
it("does not post isolated summary to main when run already delivered output", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -437,6 +598,11 @@ describe("CronService", () => {
});
it("migrates legacy payload.provider to payload.channel on load", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const rawJob = {
id: "legacy-1",
name: "legacy",
@@ -456,7 +622,20 @@ describe("CronService", () => {
state: {},
};
const { cron, store, job } = await loadLegacyJobFromStore(rawJob);
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
@@ -469,6 +648,11 @@ describe("CronService", () => {
});
it("canonicalizes payload.channel casing on load", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const rawJob = {
id: "legacy-2",
name: "legacy",
@@ -488,7 +672,20 @@ describe("CronService", () => {
state: {},
};
const { cron, store, job } = await loadLegacyJobFromStore(rawJob);
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
});
await cron.start();
const jobs = await cron.list({ includeDisabled: true });
const job = jobs.find((j) => j.id === rawJob.id);
// Legacy delivery fields are migrated to the top-level delivery object
const delivery = job?.delivery as unknown as Record<string, unknown>;
expect(delivery?.channel).toBe("telegram");
@@ -498,6 +695,7 @@ describe("CronService", () => {
});
it("posts last output to main even when isolated job errors", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
@@ -546,6 +744,7 @@ describe("CronService", () => {
});
it("rejects unsupported session/payload combinations", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const cron = new CronService({
@@ -586,32 +785,29 @@ describe("CronService", () => {
});
it("skips invalid main jobs with agentTurn payloads from disk", async () => {
ensureDir(fixturesRoot);
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const events = createCronEventHarness();
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({
version: 1,
jobs: [
{
id: "job-1",
enabled: true,
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "agentTurn", message: "bad" },
state: {},
},
],
}),
);
writeStoreFile(store.storePath, {
version: 1,
jobs: [
{
id: "job-1",
enabled: true,
createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"),
schedule: { kind: "at", at: new Date(atMs).toISOString() },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "agentTurn", message: "bad" },
state: {},
},
],
});
const cron = new CronService({
storePath: store.storePath,