import { isCoarsePointer, isKnownSlowScene, isKnownSoftwareWebGL, isMobileViewport, isSaveData, isSoftwareRenderer, isSoftwareWebGL, isWebKit, MOBILE_QUERY, rememberSlowScene, rememberSoftwareWebGL, sceneTier, } from "./device"; const originalMatchMedia = window.matchMedia; function mockMedia(active: string[]) { window.matchMedia = jest.fn().mockImplementation((query: string) => ({ matches: active.includes(query), media: query, addEventListener() {}, removeEventListener() {}, })); } function mockNavigator(values: Record) { for (const [key, value] of Object.entries(values)) Object.defineProperty(navigator, key, { configurable: true, value }); } beforeEach(() => { mockMedia([]); mockNavigator({ hardwareConcurrency: 8, deviceMemory: undefined, connection: undefined, }); }); afterEach(() => { window.matchMedia = originalMatchMedia; }); const TOUCH = "(pointer: coarse)"; describe("device hints", () => { it("treats a capable desktop as full power", () => { expect(sceneTier()).toBe("full"); expect(isCoarsePointer()).toBe(false); expect(isSaveData()).toBe(false); expect(isMobileViewport()).toBe(false); }); it("reads the phone breakpoint from the shared query", () => { mockMedia([MOBILE_QUERY]); expect(isMobileViewport()).toBe(true); expect(MOBILE_QUERY).toBe("(max-width: 767px)"); }); it.each([ ["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])], ["the data saver", () => mockNavigator({ connection: { saveData: true } })], ["four cores", () => mockNavigator({ hardwareConcurrency: 4 })], ["two cores", () => mockNavigator({ hardwareConcurrency: 2 })], ["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })], ["2 GB of memory", () => mockNavigator({ deviceMemory: 2 })], ])("shows only the poster with %s", (_label, arrange) => { arrange(); expect(sceneTier()).toBe("static"); }); it("does not penalize a browser that hides the hardware hints", () => { mockNavigator({ hardwareConcurrency: undefined, deviceMemory: undefined }); expect(sceneTier()).toBe("full"); }); it("keeps 8 GB and 6 cores on the full path", () => { mockNavigator({ hardwareConcurrency: 6, deviceMemory: 8 }); expect(sceneTier()).toBe("full"); }); }); describe("sceneTier(): one case per row of the decision table", () => { it("row 1: no navigator (server) -> static", () => { const original = Object.getOwnPropertyDescriptor(globalThis, "navigator"); Object.defineProperty(globalThis, "navigator", { configurable: true, value: undefined, }); try { expect(sceneTier()).toBe("static"); } finally { if (original) Object.defineProperty(globalThis, "navigator", original); else Reflect.deleteProperty(globalThis, "navigator"); } }); it.each([ ["reduced motion", [TOUCH, "(prefers-reduced-motion: reduce)"], {}], ["the data saver", [TOUCH], { connection: { saveData: true } }], ])("row 2: a capable phone with %s -> static", (_label, media, hints) => { mockMedia(media); mockNavigator({ hardwareConcurrency: 8, deviceMemory: 8, ...hints }); expect(sceneTier()).toBe("static"); }); it("row 3: 4 cores with plenty of memory -> static even with a mouse", () => { mockNavigator({ hardwareConcurrency: 4, deviceMemory: 8 }); expect(sceneTier()).toBe("static"); }); it.each([ ["8 cores and 4 GB", { hardwareConcurrency: 8, deviceMemory: 4 }, "lite"], ["8 cores and 2 GB", { hardwareConcurrency: 8, deviceMemory: 2 }, "static"], ["5 cores", { hardwareConcurrency: 5 }, "static"], ["6 cores and 4 GB", { hardwareConcurrency: 6, deviceMemory: 4 }, "lite"], [ "no hints reported", { hardwareConcurrency: undefined, deviceMemory: undefined }, "lite", ], ])("row 4: touch with %s -> %s", (_label, hints, tier) => { mockMedia([TOUCH]); mockNavigator(hints); expect(sceneTier()).toBe(tier); }); it.each([ ["8 cores and 4 GB", { hardwareConcurrency: 8, deviceMemory: 4 }, "static"], ["8 cores and 8 GB", { hardwareConcurrency: 8, deviceMemory: 8 }, "full"], ["5 cores and no memory hint", { hardwareConcurrency: 5 }, "full"], ])("row 5: mouse with %s -> %s", (_label, hints, tier) => { mockNavigator(hints); expect(sceneTier()).toBe(tier); }); }); describe("software WebGL", () => { it.each([ "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", "llvmpipe (LLVM 15.0.7, 256 bits)", "ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)", "SWIFTSHADER", ])("recognizes the software renderer %s", (name) => { expect(isSoftwareRenderer(name)).toBe(true); }); it.each([ "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x00002504) Direct3D11 vs_5_0 ps_5_0, D3D11)", "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)", "Mesa Intel(R) UHD Graphics 620 (KBL GT2)", "", ])("does not mistake the GPU %s for software", (name) => { expect(isSoftwareRenderer(name)).toBe(false); }); /** Um contexto WebGL de mentira: a extensão de depuração e o nome do renderizador. */ function context(renderer: unknown, hasExtension = true) { return { getExtension: jest.fn((name: string) => hasExtension && name === "WEBGL_debug_renderer_info" ? { UNMASKED_RENDERER_WEBGL: 37446 } : null, ), getParameter: jest.fn((parameter: number) => parameter === 37446 ? renderer : null, ), } as unknown as WebGL2RenderingContext; } it("reads the unmasked renderer through WEBGL_debug_renderer_info", () => { expect(isSoftwareWebGL(context("llvmpipe (LLVM 15.0.7, 256 bits)"))).toBe( true, ); expect(isSoftwareWebGL(context("ANGLE (NVIDIA GeForce RTX 3060)"))).toBe( false, ); }); it("does not count against a browser that hides the extension or returns no name", () => { expect(isSoftwareWebGL(context("llvmpipe", false))).toBe(false); expect(isSoftwareWebGL(context(null))).toBe(false); }); }); describe("the software WebGL verdict kept for the session", () => { beforeEach(() => sessionStorage.clear()); afterEach(() => { jest.restoreAllMocks(); sessionStorage.clear(); }); it("is unknown until someone remembers it, then known for the rest of the session", () => { expect(isKnownSoftwareWebGL()).toBe(false); rememberSoftwareWebGL(); expect(isKnownSoftwareWebGL()).toBe(true); // fica no sessionStorage: some com a aba, e não vale para outra aparelhagem expect(sessionStorage.getItem("webgl-software")).toBe("1"); }); it("ignores any other value in the key (only the verdict this module writes counts)", () => { sessionStorage.setItem("webgl-software", "0"); expect(isKnownSoftwareWebGL()).toBe(false); }); it("counts a blocked sessionStorage as unknown, and remembering then does not throw", () => { jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("blocked", "SecurityError"); }); jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("blocked", "SecurityError"); }); expect(isKnownSoftwareWebGL()).toBe(false); expect(() => rememberSoftwareWebGL()).not.toThrow(); }); }); describe("the slow scene verdict kept for the session", () => { beforeEach(() => sessionStorage.clear()); afterEach(() => { jest.restoreAllMocks(); sessionStorage.clear(); }); it("is unknown until someone remembers it, then known for the rest of the session", () => { expect(isKnownSlowScene()).toBe(false); rememberSlowScene(); expect(isKnownSlowScene()).toBe(true); expect(sessionStorage.getItem("scene-slow")).toBe("1"); }); it("ignores any other value in the key (only the verdict this module writes counts)", () => { sessionStorage.setItem("scene-slow", "0"); expect(isKnownSlowScene()).toBe(false); }); it("counts a blocked sessionStorage as unknown, and remembering then does not throw", () => { jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("blocked", "SecurityError"); }); jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("blocked", "SecurityError"); }); expect(isKnownSlowScene()).toBe(false); expect(() => rememberSlowScene()).not.toThrow(); }); }); describe("isWebKit (the only engine that needs the showcase video to play once)", () => { const iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; const iphoneChrome = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1"; const macSafari = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"; const chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; const edge = `${chrome} Edg/126.0.0.0`; const androidChrome = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"; const firefox = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0"; afterEach(() => { mockNavigator({ platform: "", maxTouchPoints: 0 }); Reflect.deleteProperty(navigator, "userAgent"); }); it.each([ ["Safari on the iPhone", iphone], [ "Chrome on the iPhone (iOS runs WebKit under every browser)", iphoneChrome, ], ["Safari on the Mac", macSafari], ])("is true for %s", (_name, userAgent) => { mockNavigator({ userAgent }); expect(isWebKit()).toBe(true); }); it.each([ ["Chrome", chrome], ["Edge", edge], ["Chrome on Android", androidChrome], ["Firefox", firefox], ])("is false for %s", (_name, userAgent) => { mockNavigator({ userAgent }); expect(isWebKit()).toBe(false); }); it("is true for an iPad that presents itself as a Mac (a Mac with a touch screen)", () => { mockNavigator({ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101", platform: "MacIntel", maxTouchPoints: 5, }); expect(isWebKit()).toBe(true); mockNavigator({ maxTouchPoints: 0 }); expect(isWebKit()).toBe(false); }); });