import { describe, expect, it, vi } from "vitest"; import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js"; import type { Scheduler } from "./events.js"; import type { DaemonInfo } from "./types.js"; function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo { return { hostname: "konishi", version: "0.1.0", presets: [], build: { commit, commit_date: date, dirty: false }, instance, }; } /** Scheduler whose timers only fire when the test says so. */ function manual(): Scheduler & { run(): void; pending(): number } { let next = 1; const timers = new Map void>(); return { setTimer(fn) { const h = next++; timers.set(h, fn); return h; }, clearTimer(h) { timers.delete(h); }, run() { const due = [...timers.entries()]; timers.clear(); for (const [, fn] of due) fn(); }, pending: () => timers.size, }; } describe("compareIdentity", () => { it("reads a changed commit as an upgrade", () => { expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded"); }); it("reads a changed instance on the same commit as a plain restart", () => { expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted"); }); it("reads an unchanged process as no event at all", () => { expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same"); }); it("falls back to the instance when the build is unknown", () => { // A daemon built outside a checkout reports empty commits; the instance is // still enough to see the restart, and never claims a phantom upgrade. expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted"); expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same"); }); }); describe("formatRelease", () => { it("pairs the short hash with a readable date", () => { expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe( "f89be4a716c7 ยท 2026-09-02", ); }); it("marks a dirty build", () => { expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)"); }); it("says so when the release is unknown", () => { expect(formatRelease(undefined)).toBe("unknown release"); }); }); describe("DaemonLink", () => { it("starts online once the first probe lands", async () => { const link = new DaemonLink({ fetchInfo: async () => info("aaa", "i1"), scheduler: manual(), reload: () => {}, }); await link.start(); expect(link.status().state).toBe("online"); expect(link.status().build?.commit).toBe("aaa"); }); it("holds the modal back during the grace period, then blocks", async () => { const sched = manual(); const link = new DaemonLink({ fetchInfo: async () => { throw new Error("down"); }, scheduler: sched, reload: () => {}, graceMs: 1000, }); link.noteDisconnected(); // Still inside the grace window: the dot goes amber, nothing blocks. expect(link.status().state).toBe("reconnecting"); sched.run(); await Promise.resolve(); expect(link.status().state).toBe("down"); }); it("unlocks without reloading when the same build comes back", async () => { const sched = manual(); const reload = vi.fn(); let reply: DaemonInfo | null = info("aaa", "i1"); const link = new DaemonLink({ fetchInfo: async () => { if (!reply) throw new Error("down"); return reply; }, scheduler: sched, reload, }); await link.start(); reply = null; link.noteDisconnected(); sched.run(); // grace expires โ†’ blocked, poll scheduled await Promise.resolve(); expect(link.status().state).toBe("down"); // Same commit, new process: a restart, not an upgrade โ€” the page stays. reply = info("aaa", "i2"); await link.probe(); expect(reload).not.toHaveBeenCalled(); expect(link.status().state).toBe("online"); }); it("rechecks immediately when the browser returns from the background", async () => { const sched = manual(); let reply: DaemonInfo | null = info("aaa", "i1"); const link = new DaemonLink({ fetchInfo: async () => { if (!reply) throw new Error("down"); return reply; }, scheduler: sched, reload: () => {}, }); await link.start(); reply = null; link.noteDisconnected(); sched.run(); await Promise.resolve(); expect(link.status().state).toBe("down"); reply = info("aaa", "i1"); link.recover(); await Promise.resolve(); expect(link.status().state).toBe("online"); }); it("reloads the page when the daemon comes back on a new commit", async () => { const sched = manual(); const reload = vi.fn(); let reply = info("aaa", "i1"); const link = new DaemonLink({ fetchInfo: async () => reply, scheduler: sched, reload, }); await link.start(); link.noteDisconnected(); reply = info("bbb", "i2"); await link.probe(); expect(reload).toHaveBeenCalledTimes(1); // Left blocked deliberately: the reload is what ends this page's life, and // flashing an unlocked UI in between would let the user act on stale code. expect(link.status().state).toBe("updating"); }); it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => { const sched = manual(); let attempts = 0; const link = new DaemonLink({ fetchInfo: async () => { attempts += 1; throw new Error("down"); }, scheduler: sched, reload: () => {}, }); link.noteDisconnected(); sched.run(); // grace โ†’ first poll await Promise.resolve(); await Promise.resolve(); const first = attempts; expect(first).toBeGreaterThan(0); sched.run(); // next poll tick await Promise.resolve(); await Promise.resolve(); expect(attempts).toBeGreaterThan(first); link.stop(); }); it("notifies subscribers on every state change", async () => { const sched = manual(); const seen: string[] = []; const link = new DaemonLink({ fetchInfo: async () => info("aaa", "i1"), scheduler: sched, reload: () => {}, }); link.subscribe((s) => seen.push(s.state)); await link.start(); link.noteDisconnected(); sched.run(); await Promise.resolve(); expect(seen).toContain("reconnecting"); expect(seen[0]).toBe("connecting"); }); });