Holding the reload back while a compose window had unsaved text protected the text, but it meant a tab could sit on a build the server no longer runs for as long as someone left a draft open -- which is not automatic, and automatic is the point. So the reload is unconditional once the versions differ, and this will sometimes take an unsent draft with it. The trade is deliberate: a tab talking to a server it does not match is the worse failure, and it fails quietly.
127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/staleBuild";
|
|
import { APP_VERSION } from "@/lib/version";
|
|
|
|
function healthReplies(body: unknown, ok = true) {
|
|
return vi.fn().mockResolvedValue({ ok, json: async () => body } as unknown as Response);
|
|
}
|
|
|
|
let reload: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
reload = vi.fn();
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: { ...window.location, reload },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("reloadIfServerRebuilt", () => {
|
|
it("reloads when the server reports a different build", async () => {
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: `${APP_VERSION}-newer` }));
|
|
expect(await reloadIfServerRebuilt()).toBe(true);
|
|
expect(reload).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("leaves the page alone when the versions match", async () => {
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
|
expect(await reloadIfServerRebuilt()).toBe(false);
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reloads once per version, not once per 401", async () => {
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
|
expect(await reloadIfServerRebuilt()).toBe(true);
|
|
expect(await reloadIfServerRebuilt()).toBe(false);
|
|
expect(reload).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("clears the guard once the versions agree again", async () => {
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
|
await reloadIfServerRebuilt();
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: APP_VERSION }));
|
|
await reloadIfServerRebuilt();
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }));
|
|
expect(await reloadIfServerRebuilt()).toBe(true);
|
|
expect(reload).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not reload when the server cannot be reached", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
|
expect(await reloadIfServerRebuilt()).toBe(false);
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reload on a bad response or a missing version", async () => {
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true, version: "9.9.9" }, false));
|
|
expect(await reloadIfServerRebuilt()).toBe(false);
|
|
vi.stubGlobal("fetch", healthReplies({ ok: true }));
|
|
expect(await reloadIfServerRebuilt()).toBe(false);
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("noticing without being asked", () => {
|
|
it("checks when the push stream drops, but not before it has connected", async () => {
|
|
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const onState = makeConnectionWatcher();
|
|
|
|
// never connected: a disconnect is not news
|
|
onState("connecting");
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
onState("connected");
|
|
onState("connecting");
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("asks the server once when several things notice at the same moment", async () => {
|
|
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
await Promise.all([reloadIfServerRebuilt(), reloadIfServerRebuilt(), reloadIfServerRebuilt()]);
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("the poll is what the guarantee rests on", () => {
|
|
it("checks on its own while the tab is visible, with nobody touching it", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = healthReplies({ ok: true, version: "9.9.9" });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
|
|
|
startBuildWatch();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("leaves a hidden tab alone until it is looked at", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = healthReplies({ ok: true, version: APP_VERSION });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
let visibility = "hidden";
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
|
|
|
|
startBuildWatch();
|
|
await vi.advanceTimersByTimeAsync(180_000);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
visibility = "visible";
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
});
|
|
});
|