import { describe, expect, it, vi } from "vitest"; import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.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, recording the * delay each was scheduled with. */ function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } { let next = 1; const timers = new Map void>(); const delays: number[] = []; return { delays, setTimer(fn, ms) { const h = next++; timers.set(h, fn); delays.push(ms); 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("daemon link store", () => { it("starts in the connecting state until the first probe lands", async () => { const store = createDaemonLinkStore({ fetchInfo: async () => info("aaa", "i1"), scheduler: manual(), reload: () => {}, }); expect(store.getState().state).toBe("connecting"); await store.getState().probe(); expect(store.getState().state).toBe("online"); expect(store.getState().build?.commit).toBe("aaa"); }); it("holds the modal back during the grace period, then blocks", async () => { const sched = manual(); const store = createDaemonLinkStore({ fetchInfo: async () => { throw new Error("down"); }, scheduler: sched, reload: () => {}, graceMs: 1000, }); store.getState().noteDisconnected(); // Still inside the grace window: the dot goes amber, nothing blocks. expect(store.getState().state).toBe("reconnecting"); sched.run(); await Promise.resolve(); expect(store.getState().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 store = createDaemonLinkStore({ fetchInfo: async () => { if (!reply) throw new Error("down"); return reply; }, scheduler: sched, reload, }); await store.getState().probe(); reply = null; store.getState().noteDisconnected(); sched.run(); // grace expires → blocked, poll scheduled await Promise.resolve(); expect(store.getState().state).toBe("down"); // Same commit, new process: a restart, not an upgrade — the page stays. reply = info("aaa", "i2"); await store.getState().probe(); expect(reload).not.toHaveBeenCalled(); expect(store.getState().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 store = createDaemonLinkStore({ fetchInfo: async () => reply, scheduler: sched, reload, }); await store.getState().probe(); store.getState().noteDisconnected(); reply = info("bbb", "i2"); await store.getState().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(store.getState().state).toBe("updating"); }); it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => { const sched = manual(); let attempts = 0; const store = createDaemonLinkStore({ fetchInfo: async () => { attempts += 1; throw new Error("down"); }, scheduler: sched, reload: () => {}, }); store.getState().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); store.getState().stop(); }); it("notifies subscribers on every state change", async () => { const sched = manual(); const seen: string[] = []; const store = createDaemonLinkStore({ fetchInfo: async () => info("aaa", "i1"), scheduler: sched, reload: () => {}, }); store.subscribe((s) => seen.push(s.state)); await store.getState().probe(); store.getState().noteDisconnected(); sched.run(); await Promise.resolve(); expect(seen).toContain("reconnecting"); }); it("schedules the grace timer and poll at the documented defaults", async () => { const sched = manual(); const store = createDaemonLinkStore({ fetchInfo: async () => { throw new Error("down"); }, scheduler: sched, reload: () => {}, }); expect(GRACE_MS).toBe(1200); expect(POLL_MS).toBe(700); store.getState().noteDisconnected(); expect(sched.delays).toEqual([GRACE_MS]); sched.run(); // grace fires → schedules the first poll await Promise.resolve(); await Promise.resolve(); expect(sched.delays).toEqual([GRACE_MS, POLL_MS]); }); });