perf(test): consolidate cron and canvas regression setups

This commit is contained in:
Peter Steinberger
2026-02-14 01:32:56 +00:00
parent 748d6821d2
commit 53055aeafe
2 changed files with 70 additions and 156 deletions

View File

@@ -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();
} }

View File

@@ -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();
}); });