219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
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<number, () => 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");
|
|
});
|
|
});
|