DaemonLink.tsx holds the link state machine as a Zustand store (same shape as catalogStore/connectionStore) plus a thin controller component wiring the catalog store's `connected` field to it. RestartOverlay.tsx renders the blocking modal for down/updating, reusing the existing CSS. Closes #242
216 lines
6.8 KiB
TypeScript
216 lines
6.8 KiB
TypeScript
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<number, () => 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]);
|
|
});
|
|
});
|