Release / build-and-push (push) Successful in 3m12s
New wake-lock.ts engine (sanctioned island, dependency-injected for tests): feature-detected navigator.wakeLock, silent no-op when unsupported or denied, and the spec-mandated force-release-on-hide → re-acquire-on-visible loop handled via the sentinel release event. The player acquires on play and releases on pause (sleep-timer fades ride the same path); the reader holds the lock for the session. Both gate on the keep_screen_awake preference — data-keep-awake on the player root, keepScreenAwake in readerInitExpr. Hidden-tab playback intentionally does not keep the screen awake: the spec forbids screen wake locks for hidden documents.
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
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<typeof vi.fn<() => Promise<void>>>;
|
|
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<typeof fakeDoc>;
|
|
|
|
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<FakeSentinel>((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<FakeSentinel>((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);
|
|
});
|
|
});
|