import { beforeEach, describe, expect, it, vi } from "vitest"; import { WakeLockManager, type WakeLockAPILike, type WakeLockDoc, type WakeLockSentinelLike, } from "./wake-lock"; // The manager is intentionally dependency-injected (api + doc) so every // browser boundary is a fake here: sentinel lifecycles, the visibility // state machine, and the force-release-then-reacquire loop the spec // mandates when the page hides. type FakeSentinel = WakeLockSentinelLike & { release: ReturnType Promise>>; emitRelease: () => void; }; function fakeSentinel(): FakeSentinel { const listeners: Array<() => void> = []; const sentinel: FakeSentinel = { release: vi.fn(async () => { sentinel.emitRelease(); }), // Narrow, structural (not a bare vi.fn) to satisfy the interface. addEventListener: (_type: "release", listener: () => void) => { listeners.push(listener); }, emitRelease: () => { for (const l of listeners) l(); }, }; return sentinel; } function fakeDoc(initial: DocumentVisibilityState): WakeLockDoc & { setVisible: (state: DocumentVisibilityState) => void; fireVisibility: () => void; } { const listeners: Array<() => void> = []; const doc = { visibilityState: initial, addEventListener: (_type: "visibilitychange", listener: () => void) => { listeners.push(listener); }, setVisible(state: DocumentVisibilityState) { doc.visibilityState = state; }, fireVisibility() { for (const l of listeners) l(); }, }; return doc; } function fakeAPI(sentinel: FakeSentinel | null = fakeSentinel()) { const request = vi.fn(async (_type: "screen") => { if (!sentinel) throw new DOMException("denied", "NotAllowedError"); return sentinel; }); return { request, sentinel }; } describe("WakeLockManager", () => { let doc: ReturnType; beforeEach(() => { doc = fakeDoc("visible"); }); it("acquires a screen lock on acquire()", async () => { const api = fakeAPI(); const manager = new WakeLockManager(api, doc); await manager.acquire(); expect(api.request).toHaveBeenCalledWith("screen"); expect(manager.held).toBe(true); }); it("is a silent no-op when the API is unsupported", async () => { const manager = new WakeLockManager(null, doc); await expect(manager.acquire()).resolves.toBeUndefined(); expect(manager.held).toBe(false); }); it("does not double-request while a lock is held", async () => { const api = fakeAPI(); const manager = new WakeLockManager(api, doc); await manager.acquire(); await manager.acquire(); expect(api.request).toHaveBeenCalledTimes(1); }); it("never requests while the page is hidden, but wants the lock on return", async () => { doc.setVisible("hidden"); const api = fakeAPI(); const manager = new WakeLockManager(api, doc); await manager.acquire(); expect(api.request).not.toHaveBeenCalled(); // Tab visible again — visibilitychange re-acquires while wanted. doc.setVisible("visible"); manager.syncVisibility(); await vi.waitFor(() => expect(manager.held).toBe(true)); expect(api.request).toHaveBeenCalledTimes(1); }); it("re-acquires after the browser force-releases on page hide", async () => { const api = fakeAPI(); const manager = new WakeLockManager(api, doc); await manager.acquire(); // Tab hides: browser releases the sentinel; the manager's release // listener drops the stale handle (spec behavior, not ours). doc.setVisible("hidden"); doc.fireVisibility(); api.sentinel!.emitRelease(); expect(manager.held).toBe(false); // Tab returns with playback still going: lock comes back. doc.setVisible("visible"); doc.fireVisibility(); await vi.waitFor(() => expect(manager.held).toBe(true)); expect(api.request).toHaveBeenCalledTimes(2); }); it("release() drops the lock and stops re-acquisition", async () => { const api = fakeAPI(); const manager = new WakeLockManager(api, doc); await manager.acquire(); manager.release(); expect(api.sentinel!.release).toHaveBeenCalled(); expect(manager.held).toBe(false); // Hide/return cycles do not bring the lock back once released — // only a fresh acquire() (next play event) may. doc.setVisible("hidden"); manager.syncVisibility(); doc.setVisible("visible"); manager.syncVisibility(); expect(api.request).toHaveBeenCalledTimes(1); }); it("drops a lock that resolves after release() won the race", async () => { const sentinel = fakeSentinel(); let resolveRequest: (s: FakeSentinel) => void = () => {}; const api: WakeLockAPILike = { request: vi.fn( () => new Promise((resolve) => { resolveRequest = resolve; }), ), }; const manager = new WakeLockManager(api, doc); const acquiring = manager.acquire(); manager.release(); resolveRequest(sentinel); await acquiring; // The stale sentinel is released immediately, never adopted. expect(sentinel.release).toHaveBeenCalled(); expect(manager.held).toBe(false); }); it("absorbs NotAllowedError (power saver, low battery) silently", async () => { const api = fakeAPI(null); const manager = new WakeLockManager(api, doc); await expect(manager.acquire()).resolves.toBeUndefined(); expect(manager.held).toBe(false); }); it("drops a lock that resolves while the page went hidden", async () => { const sentinel = fakeSentinel(); let resolveRequest: (s: FakeSentinel) => void = () => {}; const api: WakeLockAPILike = { request: vi.fn( () => new Promise((resolve) => { resolveRequest = resolve; }), ), }; const manager = new WakeLockManager(api, doc); const acquiring = manager.acquire(); doc.setVisible("hidden"); resolveRequest(sentinel); await acquiring; expect(sentinel.release).toHaveBeenCalled(); expect(manager.held).toBe(false); }); });