1import { createForegroundRecovery } from "./foreground-recovery.ts";2// Package contract tests.3import { assert, assertCount } from "./test-assert.ts";45const test = (globalThis as unknown as {6 Deno: { test(name: string, body: () => void | Promise<void>): void };7}).Deno.test;89function event(name: string, values: Record<string, unknown> = {}): Event {10 return Object.assign(new Event(name), values);11}1213test("foreground signals single-flight a managed reconnect", async () => {14 const page = new EventTarget();15 const document = new EventTarget();16 let resolveReconnect: (() => void) | undefined;17 let reconnects = 0;18 const manager = createForegroundRecovery({19 enabled: true,20 pageTarget: page,21 visibilityTarget: document,22 isVisible: () => true,23 isOnline: () => true,24 reconnect: () => {25 reconnects += 1;26 return new Promise<void>((resolve) => resolveReconnect = resolve);27 },28 });29 document.dispatchEvent(event("visibilitychange"));30 page.dispatchEvent(event("online"));31 assertCount(reconnects, 1, "overlapping foreground signals stacked reconnect calls");32 resolveReconnect?.();33 await Promise.resolve();34 await Promise.resolve();35 assert(manager.getState().status === "completed", "completed reconnect was not observable");36 assertCount(manager.getState().attempts, 1, "single-flight reconnect counted twice");37 manager.dispose();38});3940test("offline foreground recovery defers until the online event", async () => {41 const page = new EventTarget();42 const document = new EventTarget();43 let online = false;44 let reconnects = 0;45 const manager = createForegroundRecovery({46 enabled: true,47 pageTarget: page,48 visibilityTarget: document,49 isVisible: () => true,50 isOnline: () => online,51 reconnect: () => {52 reconnects += 1;53 return Promise.resolve();54 },55 });56 document.dispatchEvent(event("visibilitychange"));57 assert(manager.getState().status === "offline-deferred", "offline state was not retained");58 assertCount(reconnects, 0, "offline foreground signal attempted reconnect");59 online = true;60 page.dispatchEvent(event("online"));61 await Promise.resolve();62 await Promise.resolve();63 assertCount(reconnects, 1, "online recovery did not request reconnect");64 manager.dispose();65});6667test("pageshow recovers only a restored BFCache document", async () => {68 const page = new EventTarget();69 const document = new EventTarget();70 let reconnects = 0;71 const manager = createForegroundRecovery({72 enabled: true,73 pageTarget: page,74 visibilityTarget: document,75 isVisible: () => true,76 isOnline: () => true,77 reconnect: () => {78 reconnects += 1;79 return Promise.resolve();80 },81 });82 page.dispatchEvent(event("pageshow", { persisted: false }));83 page.dispatchEvent(event("pageshow", { persisted: true }));84 await Promise.resolve();85 assertCount(reconnects, 1, "pageshow did not distinguish initial load from BFCache recovery");86 manager.dispose();87});8889test("local-only lifecycle attaches no reconnect behavior", async () => {90 const page = new EventTarget();91 const document = new EventTarget();92 let reconnects = 0;93 const manager = createForegroundRecovery({94 enabled: false,95 pageTarget: page,96 visibilityTarget: document,97 isVisible: () => true,98 isOnline: () => true,99 reconnect: () => {100 reconnects += 1;101 return Promise.resolve();102 },103 });104 page.dispatchEvent(event("online"));105 document.dispatchEvent(event("visibilitychange"));106 await manager.request("online");107 assertCount(reconnects, 0, "local-only mode invented a transport recovery action");108 assert(manager.getState().mode === "local-only", "local-only lifecycle mode was hidden");109 manager.dispose();110});111112test("event recovery records a reconnect rejection without leaving it unhandled", async () => {113 const page = new EventTarget();114 const document = new EventTarget();115 const manager = createForegroundRecovery({116 enabled: true,117 pageTarget: page,118 visibilityTarget: document,119 isVisible: () => true,120 isOnline: () => true,121 reconnect: () => Promise.reject(new Error("sentinel reconnect failure")),122 });123 page.dispatchEvent(event("online"));124 await Promise.resolve();125 await Promise.resolve();126 assert(manager.getState().status === "failed", "reconnect failure was not observable");127 manager.dispose();128});129130test("disposed lifecycle removes every browser event listener", async () => {131 const page = new EventTarget();132 const document = new EventTarget();133 let reconnects = 0;134 const manager = createForegroundRecovery({135 enabled: true,136 pageTarget: page,137 visibilityTarget: document,138 isVisible: () => true,139 isOnline: () => true,140 reconnect: () => {141 reconnects += 1;142 return Promise.resolve();143 },144 });145 manager.dispose();146 page.dispatchEvent(event("online"));147 page.dispatchEvent(event("pageshow", { persisted: true }));148 document.dispatchEvent(event("visibilitychange"));149 await Promise.resolve();150 assertCount(reconnects, 0, "disposed lifecycle retained a browser listener");151});152153test("recovery is idle while the live gate withholds consent", async () => {154 const page = new EventTarget();155 const document = new EventTarget();156 let elected = false;157 let reconnects = 0;158 const manager = createForegroundRecovery({159 enabled: true,160 pageTarget: page,161 visibilityTarget: document,162 isVisible: () => true,163 isOnline: () => true,164 shouldReconnect: () => elected,165 reconnect: () => {166 reconnects += 1;167 return Promise.resolve();168 },169 });170 // A configured server is not consent: with sync stopped or the transport171 // paused, foreground signals must not silently resume replication.172 document.dispatchEvent(event("visibilitychange"));173 page.dispatchEvent(event("online"));174 await Promise.resolve();175 assertCount(reconnects, 0, "recovery reconnected without an election");176 assert(manager.getState().status === "idle", "a withheld gate must leave recovery idle");177 assertCount(manager.getState().attempts, 0, "a withheld gate must not count attempts");178 elected = true;179 page.dispatchEvent(event("online"));180 await Promise.resolve();181 await Promise.resolve();182 assertCount(reconnects, 1, "an elected account must recover on the next signal");183 manager.dispose();184});