Files
plainleaf/client/realtime_events.test.ts
T

195 lines
6.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RealtimeEvents, type RealtimeHooks } from "./realtime_events.ts";
function makeHooks() {
return {
probeFile: vi.fn((_name: string) => Promise.resolve()),
syncFile: vi.fn(() => Promise.resolve()),
syncSpace: vi.fn(() => Promise.resolve()),
refreshFileList: vi.fn(() => Promise.resolve()),
serviceWorkerActive: vi.fn(() => false),
} satisfies RealtimeHooks;
}
describe("RealtimeEvents routing", () => {
let hooks: ReturnType<typeof makeHooks>;
let rt: RealtimeEvents;
beforeEach(() => {
vi.useFakeTimers();
hooks = makeHooks();
rt = new RealtimeEvents(hooks);
});
it("probes the file on a change event (no SW)", async () => {
rt.handleEvent({ name: "page.md", action: "change", lastModified: 1 });
await vi.runAllTimersAsync();
expect(hooks.probeFile).toHaveBeenCalledWith("page.md");
expect(hooks.syncFile).not.toHaveBeenCalled();
});
it("syncs then probes when a service worker is active", async () => {
hooks.serviceWorkerActive.mockReturnValue(true);
rt.handleEvent({ name: "page.md", action: "change", lastModified: 1 });
await vi.runAllTimersAsync();
expect(hooks.syncFile).toHaveBeenCalledWith("page.md");
expect(hooks.probeFile).toHaveBeenCalledWith("page.md");
expect(hooks.syncFile.mock.invocationCallOrder[0]).toBeLessThan(
hooks.probeFile.mock.invocationCallOrder[0],
);
});
it("refreshes the file list on delete events", async () => {
rt.handleEvent({ name: "gone.md", action: "delete", lastModified: 0 });
await vi.runAllTimersAsync();
expect(hooks.refreshFileList).toHaveBeenCalled();
expect(hooks.probeFile).not.toHaveBeenCalled();
});
it("collapses a burst into a space sync (SW mode)", async () => {
hooks.serviceWorkerActive.mockReturnValue(true);
for (let i = 0; i < 6; i++) {
rt.handleEvent({ name: `f${i}.md`, action: "change", lastModified: 1 });
}
await vi.runAllTimersAsync();
expect(hooks.syncSpace).toHaveBeenCalledTimes(1);
expect(hooks.syncFile).not.toHaveBeenCalled();
expect(hooks.probeFile).not.toHaveBeenCalled();
});
it("collapses a burst into one list refresh without SW (no per-file probes)", async () => {
for (let i = 0; i < 6; i++) {
rt.handleEvent({ name: `f${i}.md`, action: "change", lastModified: 1 });
}
await vi.runAllTimersAsync();
expect(hooks.refreshFileList).toHaveBeenCalledTimes(1);
expect(hooks.probeFile).not.toHaveBeenCalled();
expect(hooks.syncSpace).not.toHaveBeenCalled();
});
it("treats a resync event as a full refresh", async () => {
hooks.serviceWorkerActive.mockReturnValue(true);
rt.handleEvent({ name: "", action: "resync", lastModified: 0 });
await vi.runAllTimersAsync();
expect(hooks.syncSpace).toHaveBeenCalledTimes(1);
expect(hooks.refreshFileList).toHaveBeenCalledTimes(1);
expect(hooks.probeFile).not.toHaveBeenCalled();
});
it("does not drop remaining files in a batch when one file's probe rejects", async () => {
hooks.probeFile.mockImplementation((name: string) =>
name === "bad.md" ? Promise.reject(new Error("boom")) : Promise.resolve(),
);
rt.handleEvent({ name: "bad.md", action: "change", lastModified: 1 });
rt.handleEvent({ name: "good.md", action: "change", lastModified: 1 });
await vi.runAllTimersAsync();
expect(hooks.probeFile).toHaveBeenCalledWith("bad.md");
expect(hooks.probeFile).toHaveBeenCalledWith("good.md");
});
});
describe("RealtimeEvents.flush re-entrancy", () => {
let hooks: ReturnType<typeof makeHooks>;
let rt: RealtimeEvents;
beforeEach(() => {
vi.useFakeTimers();
hooks = makeHooks();
rt = new RealtimeEvents(hooks);
});
it("does not run overlapping refreshes when a new burst arrives mid-flush", async () => {
let resolveFirst!: () => void;
const firstCall = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
let refreshCalls = 0;
hooks.refreshFileList.mockImplementation(() => {
refreshCalls++;
return refreshCalls === 1 ? firstCall : Promise.resolve();
});
// First burst triggers a flush that hangs mid-flight.
rt.handleEvent({ name: "", action: "resync", lastModified: 0 });
await vi.advanceTimersByTimeAsync(250);
expect(refreshCalls).toBe(1);
// Second burst arrives while the first flush is still awaiting.
rt.handleEvent({ name: "", action: "resync", lastModified: 0 });
await vi.advanceTimersByTimeAsync(1000);
// Must not have started a second, overlapping refresh yet.
expect(refreshCalls).toBe(1);
// Let the first flush settle; the second (queued) burst should now flush.
resolveFirst();
await vi.runAllTimersAsync();
expect(refreshCalls).toBe(2);
});
});
class FakeEventSource {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 2;
readyState: number = FakeEventSource.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {}
close() {
this.readyState = FakeEventSource.CLOSED;
}
}
describe("RealtimeEvents connection lifecycle", () => {
let hooks: ReturnType<typeof makeHooks>;
let instances: FakeEventSource[];
let originalEventSource: typeof EventSource;
beforeEach(() => {
vi.useFakeTimers();
hooks = makeHooks();
instances = [];
originalEventSource = globalThis.EventSource;
(globalThis as any).EventSource = class extends FakeEventSource {
constructor(url: string) {
super(url);
instances.push(this);
}
};
});
afterEach(() => {
globalThis.EventSource = originalEventSource;
});
it("does not retry a never-connected source the browser reports as fatally closed (e.g. 404)", async () => {
const rt = new RealtimeEvents(hooks);
rt.start("http://localhost/.events");
expect(instances).toHaveLength(1);
// A fatal HTTP failure (404, wrong content-type) leaves readyState
// CLOSED by the time onerror fires.
instances[0].readyState = FakeEventSource.CLOSED;
instances[0].onerror?.();
await vi.runAllTimersAsync();
expect(instances).toHaveLength(1);
});
it("retries a never-connected source that failed transiently (readyState still CONNECTING)", async () => {
const rt = new RealtimeEvents(hooks);
rt.start("http://localhost/.events");
expect(instances).toHaveLength(1);
// A transient failure (connection refused, DNS hiccup, server not warm
// yet) leaves readyState at CONNECTING when onerror fires.
instances[0].onerror?.();
await vi.advanceTimersByTimeAsync(1000);
expect(instances).toHaveLength(2);
});
});