This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/daemonLink.test.ts
T

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");
});
});