mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:51:23 +00:00
perf(test): consolidate cron and canvas regression setups
This commit is contained in:
@@ -90,7 +90,7 @@ describe("canvas host", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves canvas content from the mounted base path", async () => {
|
it("serves canvas content from the mounted base path and reuses handlers without double close", async () => {
|
||||||
const dir = await createCaseDir();
|
const dir = await createCaseDir();
|
||||||
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
|
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
|
||||||
|
|
||||||
@@ -131,28 +131,15 @@ describe("canvas host", () => {
|
|||||||
const miss = await fetch(`http://127.0.0.1:${port}/`);
|
const miss = await fetch(`http://127.0.0.1:${port}/`);
|
||||||
expect(miss.status).toBe(404);
|
expect(miss.status).toBe(404);
|
||||||
} finally {
|
} finally {
|
||||||
await handler.close();
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
server.close((err) => (err ? reject(err) : resolve())),
|
server.close((err) => (err ? reject(err) : resolve())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it("reuses a handler without closing it twice", async () => {
|
|
||||||
const dir = await createCaseDir();
|
|
||||||
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
|
|
||||||
|
|
||||||
const handler = await createCanvasHostHandler({
|
|
||||||
runtime: quietRuntime,
|
|
||||||
rootDir: dir,
|
|
||||||
basePath: CANVAS_HOST_PATH,
|
|
||||||
allowInTests: true,
|
|
||||||
});
|
|
||||||
const originalClose = handler.close;
|
const originalClose = handler.close;
|
||||||
const closeSpy = vi.fn(async () => originalClose());
|
const closeSpy = vi.fn(async () => originalClose());
|
||||||
handler.close = closeSpy;
|
handler.close = closeSpy;
|
||||||
|
|
||||||
const server = await startCanvasHost({
|
const hosted = await startCanvasHost({
|
||||||
runtime: quietRuntime,
|
runtime: quietRuntime,
|
||||||
handler,
|
handler,
|
||||||
ownsHandler: false,
|
ownsHandler: false,
|
||||||
@@ -162,9 +149,9 @@ describe("canvas host", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
expect(server.port).toBeGreaterThan(0);
|
expect(hosted.port).toBeGreaterThan(0);
|
||||||
} finally {
|
} finally {
|
||||||
await server.close();
|
await hosted.close();
|
||||||
expect(closeSpy).not.toHaveBeenCalled();
|
expect(closeSpy).not.toHaveBeenCalled();
|
||||||
await originalClose();
|
await originalClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { setTimeout as delay } from "node:timers/promises";
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { CronJob } from "./types.js";
|
import type { CronJob } from "./types.js";
|
||||||
import { CronService } from "./service.js";
|
import { CronService } from "./service.js";
|
||||||
import { createCronServiceState, type CronEvent } from "./service/state.js";
|
import { createCronServiceState, type CronEvent } from "./service/state.js";
|
||||||
@@ -16,8 +16,12 @@ const noopLogger = {
|
|||||||
trace: vi.fn(),
|
trace: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let fixtureRoot = "";
|
||||||
|
let fixtureCount = 0;
|
||||||
|
|
||||||
async function makeStorePath() {
|
async function makeStorePath() {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cron-issues-"));
|
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const storePath = path.join(dir, "jobs.json");
|
const storePath = path.join(dir, "jobs.json");
|
||||||
return {
|
return {
|
||||||
storePath,
|
storePath,
|
||||||
@@ -50,23 +54,32 @@ function createDueIsolatedJob(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Cron issue regressions", () => {
|
describe("Cron issue regressions", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-issues-"));
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
|
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recalculates nextRunAtMs when schedule changes", async () => {
|
it("covers schedule updates, force runs, isolated wake scheduling, and payload patching", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
|
const enqueueSystemEvent = vi.fn();
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
cronEnabled: true,
|
cronEnabled: true,
|
||||||
storePath: store.storePath,
|
storePath: store.storePath,
|
||||||
log: noopLogger,
|
log: noopLogger,
|
||||||
enqueueSystemEvent: vi.fn(),
|
enqueueSystemEvent,
|
||||||
requestHeartbeatNow: vi.fn(),
|
requestHeartbeatNow: vi.fn(),
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
||||||
});
|
});
|
||||||
@@ -86,51 +99,18 @@ describe("Cron issue regressions", () => {
|
|||||||
|
|
||||||
expect(updated.state.nextRunAtMs).toBe(Date.parse("2026-02-06T12:00:00.000Z"));
|
expect(updated.state.nextRunAtMs).toBe(Date.parse("2026-02-06T12:00:00.000Z"));
|
||||||
|
|
||||||
cron.stop();
|
const forceNow = await cron.add({
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs immediately with force mode even when not due", async () => {
|
|
||||||
const store = await makeStorePath();
|
|
||||||
const enqueueSystemEvent = vi.fn();
|
|
||||||
const cron = new CronService({
|
|
||||||
cronEnabled: true,
|
|
||||||
storePath: store.storePath,
|
|
||||||
log: noopLogger,
|
|
||||||
enqueueSystemEvent,
|
|
||||||
requestHeartbeatNow: vi.fn(),
|
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
|
||||||
});
|
|
||||||
await cron.start();
|
|
||||||
|
|
||||||
const created = await cron.add({
|
|
||||||
name: "force-now",
|
name: "force-now",
|
||||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
payload: { kind: "systemEvent", text: "force" },
|
payload: { kind: "systemEvent", text: "force" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await cron.run(created.id, "force");
|
const result = await cron.run(forceNow.id, "force");
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true, ran: true });
|
expect(result).toEqual({ ok: true, ran: true });
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("force", { agentId: undefined });
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("force", { agentId: undefined });
|
||||||
|
|
||||||
cron.stop();
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("schedules isolated jobs with next wake time", async () => {
|
|
||||||
const store = await makeStorePath();
|
|
||||||
const cron = new CronService({
|
|
||||||
cronEnabled: true,
|
|
||||||
storePath: store.storePath,
|
|
||||||
log: noopLogger,
|
|
||||||
enqueueSystemEvent: vi.fn(),
|
|
||||||
requestHeartbeatNow: vi.fn(),
|
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
|
||||||
});
|
|
||||||
await cron.start();
|
|
||||||
|
|
||||||
const job = await cron.add({
|
const job = await cron.add({
|
||||||
name: "isolated",
|
name: "isolated",
|
||||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
||||||
@@ -142,37 +122,21 @@ describe("Cron issue regressions", () => {
|
|||||||
expect(typeof job.state.nextRunAtMs).toBe("number");
|
expect(typeof job.state.nextRunAtMs).toBe("number");
|
||||||
expect(typeof status.nextWakeAtMs).toBe("number");
|
expect(typeof status.nextWakeAtMs).toBe("number");
|
||||||
|
|
||||||
cron.stop();
|
const unsafeToggle = await cron.add({
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists allowUnsafeExternalContent on agentTurn payload patches", async () => {
|
|
||||||
const store = await makeStorePath();
|
|
||||||
const cron = new CronService({
|
|
||||||
cronEnabled: true,
|
|
||||||
storePath: store.storePath,
|
|
||||||
log: noopLogger,
|
|
||||||
enqueueSystemEvent: vi.fn(),
|
|
||||||
requestHeartbeatNow: vi.fn(),
|
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
|
|
||||||
});
|
|
||||||
await cron.start();
|
|
||||||
|
|
||||||
const created = await cron.add({
|
|
||||||
name: "unsafe toggle",
|
name: "unsafe toggle",
|
||||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
||||||
sessionTarget: "isolated",
|
sessionTarget: "isolated",
|
||||||
payload: { kind: "agentTurn", message: "hi" },
|
payload: { kind: "agentTurn", message: "hi" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await cron.update(created.id, {
|
const patched = await cron.update(unsafeToggle.id, {
|
||||||
payload: { kind: "agentTurn", allowUnsafeExternalContent: true },
|
payload: { kind: "agentTurn", allowUnsafeExternalContent: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updated.payload.kind).toBe("agentTurn");
|
expect(patched.payload.kind).toBe("agentTurn");
|
||||||
if (updated.payload.kind === "agentTurn") {
|
if (patched.payload.kind === "agentTurn") {
|
||||||
expect(updated.payload.allowUnsafeExternalContent).toBe(true);
|
expect(patched.payload.allowUnsafeExternalContent).toBe(true);
|
||||||
expect(updated.payload.message).toBe("hi");
|
expect(patched.payload.message).toBe("hi");
|
||||||
}
|
}
|
||||||
|
|
||||||
cron.stop();
|
cron.stop();
|
||||||
@@ -304,14 +268,10 @@ describe("Cron issue regressions", () => {
|
|||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("#13845: one-shot job with lastStatus=skipped does not re-fire on restart", async () => {
|
it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => {
|
||||||
const store = await makeStorePath();
|
const store = await makeStorePath();
|
||||||
const pastAt = Date.parse("2026-02-06T09:00:00.000Z");
|
const pastAt = Date.parse("2026-02-06T09:00:00.000Z");
|
||||||
// Simulate a one-shot job that was previously skipped (e.g. main session busy).
|
const baseJob = {
|
||||||
// On the old code, runMissedJobs only checked lastStatus === "ok", so a
|
|
||||||
// skipped job would pass through and fire again on every restart.
|
|
||||||
const skippedJob: CronJob = {
|
|
||||||
id: "oneshot-skipped",
|
|
||||||
name: "reminder",
|
name: "reminder",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
deleteAfterRun: true,
|
deleteAfterRun: true,
|
||||||
@@ -321,79 +281,46 @@ describe("Cron issue regressions", () => {
|
|||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
wakeMode: "now",
|
wakeMode: "now",
|
||||||
payload: { kind: "systemEvent", text: "⏰ Reminder" },
|
payload: { kind: "systemEvent", text: "⏰ Reminder" },
|
||||||
state: {
|
} as const;
|
||||||
nextRunAtMs: pastAt,
|
for (const [id, state] of [
|
||||||
lastStatus: "skipped",
|
[
|
||||||
lastRunAtMs: pastAt,
|
"oneshot-skipped",
|
||||||
},
|
{
|
||||||
};
|
nextRunAtMs: pastAt,
|
||||||
await fs.writeFile(
|
lastStatus: "skipped" as const,
|
||||||
store.storePath,
|
lastRunAtMs: pastAt,
|
||||||
JSON.stringify({ version: 1, jobs: [skippedJob] }, null, 2),
|
},
|
||||||
"utf-8",
|
],
|
||||||
);
|
[
|
||||||
|
"oneshot-errored",
|
||||||
|
{
|
||||||
|
nextRunAtMs: pastAt,
|
||||||
|
lastStatus: "error" as const,
|
||||||
|
lastRunAtMs: pastAt,
|
||||||
|
lastError: "heartbeat failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const job: CronJob = { id, ...baseJob, state };
|
||||||
|
await fs.writeFile(
|
||||||
|
store.storePath,
|
||||||
|
JSON.stringify({ version: 1, jobs: [job] }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const enqueueSystemEvent = vi.fn();
|
||||||
|
const cron = new CronService({
|
||||||
|
cronEnabled: true,
|
||||||
|
storePath: store.storePath,
|
||||||
|
log: noopLogger,
|
||||||
|
enqueueSystemEvent,
|
||||||
|
requestHeartbeatNow: vi.fn(),
|
||||||
|
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
|
||||||
|
});
|
||||||
|
|
||||||
const enqueueSystemEvent = vi.fn();
|
await cron.start();
|
||||||
const cron = new CronService({
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
cronEnabled: true,
|
cron.stop();
|
||||||
storePath: store.storePath,
|
}
|
||||||
log: noopLogger,
|
|
||||||
enqueueSystemEvent,
|
|
||||||
requestHeartbeatNow: vi.fn(),
|
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// start() calls runMissedJobs internally
|
|
||||||
await cron.start();
|
|
||||||
|
|
||||||
// The skipped one-shot job must NOT be re-enqueued
|
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
cron.stop();
|
|
||||||
await store.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("#13845: one-shot job with lastStatus=error does not re-fire on restart", async () => {
|
|
||||||
const store = await makeStorePath();
|
|
||||||
const pastAt = Date.parse("2026-02-06T09:00:00.000Z");
|
|
||||||
const errorJob: CronJob = {
|
|
||||||
id: "oneshot-errored",
|
|
||||||
name: "reminder",
|
|
||||||
enabled: true,
|
|
||||||
deleteAfterRun: true,
|
|
||||||
createdAtMs: pastAt - 60_000,
|
|
||||||
updatedAtMs: pastAt,
|
|
||||||
schedule: { kind: "at", at: new Date(pastAt).toISOString() },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "now",
|
|
||||||
payload: { kind: "systemEvent", text: "⏰ Reminder" },
|
|
||||||
state: {
|
|
||||||
nextRunAtMs: pastAt,
|
|
||||||
lastStatus: "error",
|
|
||||||
lastRunAtMs: pastAt,
|
|
||||||
lastError: "heartbeat failed",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(
|
|
||||||
store.storePath,
|
|
||||||
JSON.stringify({ version: 1, jobs: [errorJob] }, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const enqueueSystemEvent = vi.fn();
|
|
||||||
const cron = new CronService({
|
|
||||||
cronEnabled: true,
|
|
||||||
storePath: store.storePath,
|
|
||||||
log: noopLogger,
|
|
||||||
enqueueSystemEvent,
|
|
||||||
requestHeartbeatNow: vi.fn(),
|
|
||||||
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await cron.start();
|
|
||||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
cron.stop();
|
|
||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user