| 1 | // Run: tsx src/__tests__/heartbeat-mutation-queue.test.ts |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types"; |
| 5 | |
| 6 | let passed = 0; |
| 7 | let failed = 0; |
| 8 | |
| 9 | function ok(value: unknown, label: string) { |
| 10 | if (value) { |
| 11 | process.stdout.write(` PASS ${label}\n`); |
| 12 | passed += 1; |
| 13 | } else { |
| 14 | process.stdout.write(` FAIL ${label}\n`); |
| 15 | failed += 1; |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | function flush(): Promise<void> { |
| 20 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 21 | } |
| 22 | |
| 23 | const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" }); |
| 24 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 25 | globalThis.document = dom.window.document; |
| 26 | |
| 27 | type ConfigUpdate = { revision: number; etag: string; tasks: HeartbeatTask[] }; |
| 28 | type SaveCall = ConfigUpdate & { resolve: (view: ConfigUpdate) => void }; |
| 29 | |
| 30 | const initial: HeartbeatTask[] = [ |
| 31 | { id: "a", title: "A", prompt: "a", interval: "1h", enabled: true }, |
| 32 | { id: "b", title: "B", prompt: "b", interval: "1h", enabled: true }, |
| 33 | ]; |
| 34 | const saveCalls: SaveCall[] = []; |
| 35 | |
| 36 | const { installDesktopHostStub } = await import("./desktopHostStub"); |
| 37 | installDesktopHostStub({ |
| 38 | async HeartbeatReloadConfig() { |
| 39 | return { revision: 1, etag: "etag-1", tasks: initial }; |
| 40 | }, |
| 41 | HeartbeatSaveConfig(update: ConfigUpdate) { |
| 42 | return new Promise<ConfigUpdate>((resolve) => saveCalls.push({ ...update, resolve })); |
| 43 | }, |
| 44 | }); |
| 45 | |
| 46 | const { heartbeatListTasks, heartbeatMutateTasks } = await import("../custom/features/heartbeat/heartbeat.bridge"); |
| 47 | |
| 48 | console.log("\nheartbeat mutation queue"); |
| 49 | |
| 50 | await heartbeatListTasks(); |
| 51 | const toggle = heartbeatMutateTasks((tasks) => tasks.map((task) => ( |
| 52 | task.id === "a" ? { ...task, enabled: !task.enabled } : task |
| 53 | ))); |
| 54 | const remove = heartbeatMutateTasks((tasks) => tasks.filter((task) => task.id !== "b")); |
| 55 | await flush(); |
| 56 | |
| 57 | ok(saveCalls.length === 1, "only one full-table mutation is in flight"); |
| 58 | ok(saveCalls[0]?.revision === 1 && saveCalls[0]?.etag === "etag-1", "first mutation uses the loaded config token"); |
| 59 | const firstTasks = saveCalls[0]?.tasks ?? []; |
| 60 | saveCalls[0]?.resolve({ revision: 2, etag: "etag-2", tasks: firstTasks }); |
| 61 | await flush(); |
| 62 | |
| 63 | ok(saveCalls.length === 2, "second mutation starts after the first save settles"); |
| 64 | ok(saveCalls[1]?.revision === 2 && saveCalls[1]?.etag === "etag-2", "second mutation uses the new config token"); |
| 65 | ok(saveCalls[1]?.tasks.length === 1 && saveCalls[1]?.tasks[0]?.id === "a", "second mutation rebases deletion onto the saved task list"); |
| 66 | ok(saveCalls[1]?.tasks[0]?.enabled === false, "rebased deletion preserves the earlier toggle"); |
| 67 | const secondTasks = saveCalls[1]?.tasks ?? []; |
| 68 | saveCalls[1]?.resolve({ revision: 3, etag: "etag-3", tasks: secondTasks }); |
| 69 | |
| 70 | const [afterToggle, afterDelete] = await Promise.all([toggle, remove]); |
| 71 | ok(afterToggle.find((task) => task.id === "a")?.enabled === false, "first caller receives its authoritative save"); |
| 72 | ok(afterDelete.length === 1 && afterDelete[0]?.id === "a", "second caller receives the combined authoritative state"); |
| 73 | |
| 74 | dom.window.close(); |
| 75 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 76 | if (failed > 0) process.exit(1); |
| 77 |