import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { readFileSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { ZERO_INSETS, parseNativeInsets, safeAreaCssVars, applySafeAreaInsets, readNativeInsets, initSafeArea, INSETS_CHANGED_EVENT, } from "./safeArea"; /** * Safe-area (window inset) plumbing for Android. * * TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097 * * Regression guard for "the bottom nav is off the bottom of the screen on some * devices (Motorola) but not others (Fairphone)". * * Two independent defects produced it: * * 1. `src/app.html` shipped `` — no `viewport-fit=cover`. Per the CSS Env spec, every * `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into * `cover`. So the `env()` padding in app.css and BottomUi.svelte was a * no-op on every device. * 2. Even with `viewport-fit=cover`, Android WebView only maps the **display * cutout** into `env(safe-area-inset-*)` — never the status bar or the * navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the * app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out * is ignored from SDK 36), so the WebView always spans the full window * including the system bars. CSS alone can never learn about them. * * The device split was only in how much the bars intrude: a thin translucent * gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav * outright. Both devices were equally unpadded. * * The fix pushes real `WindowInsets` from Kotlin into CSS custom properties. * These tests pin the frontend half of that contract. */ describe("parseNativeInsets", () => { it("parses the JSON payload the native bridge returns", () => { expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({ top: 24, right: 0, bottom: 48, left: 0, }); }); it("accepts an already-parsed object", () => { expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({ top: 1, right: 2, bottom: 3, left: 4, }); }); it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => { // A NaN would serialise to "NaNpx" and silently kill the whole padding // declaration, which is exactly the failure mode being guarded against. expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({ top: 0, right: 0, bottom: 48, left: 0, }); expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS); }); it("returns null for input that is not an inset payload at all", () => { expect(parseNativeInsets("not json")).toBeNull(); expect(parseNativeInsets(null)).toBeNull(); expect(parseNativeInsets(undefined)).toBeNull(); expect(parseNativeInsets(42)).toBeNull(); }); }); describe("safeAreaCssVars / applySafeAreaInsets", () => { it("emits px-suffixed custom properties for all four edges", () => { expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({ "--jt-inset-top": "24px", "--jt-inset-right": "0px", "--jt-inset-bottom": "48px", "--jt-inset-left": "12px", }); }); it("writes the custom properties onto the target element", () => { const el = document.createElement("div"); applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 }); expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px"); expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px"); expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px"); expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px"); }); }); describe("readNativeInsets", () => { beforeEach(() => { delete (window as unknown as Record).AndroidInsets; vi.restoreAllMocks(); }); it("returns null when the bridge is absent (desktop, iOS, dev server)", () => { expect(readNativeInsets()).toBeNull(); }); it("reads and parses the bridge payload", () => { window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' }; expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 }); }); it("returns null when the bridge object is a stale WebView proxy", () => { // Same failure mode as the background-audio bridge: the injected object // stays truthy across a page load while its methods vanish. Must not throw // out of layout init. vi.spyOn(console, "warn").mockImplementation(() => {}); window.AndroidInsets = {} as unknown as { get(): string }; expect(readNativeInsets()).toBeNull(); window.AndroidInsets = { get: () => { throw new TypeError("get is not a function"); }, }; expect(readNativeInsets()).toBeNull(); }); }); describe("initSafeArea", () => { let stop: (() => void) | null = null; beforeEach(() => { delete (window as unknown as Record).AndroidInsets; document.documentElement.removeAttribute("style"); vi.restoreAllMocks(); }); afterEach(() => { stop?.(); stop = null; }); it("primes the document element from the bridge on start", () => { window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' }; stop = initSafeArea(); expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px"); }); it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => { let payload = '{"top":24,"right":0,"bottom":48,"left":0}'; window.AndroidInsets = { get: () => payload }; stop = initSafeArea(); expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px"); // Rotated to landscape: the cutout moves to the left edge, the gesture bar // shrinks. Native re-pushes and fires the change event. payload = '{"top":0,"right":0,"bottom":24,"left":44}'; window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT)); expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px"); expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px"); expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px"); }); it("leaves the custom properties unset with no bridge, so env() keeps the field", () => { // On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win; // writing an explicit 0px here would clobber it. stop = initSafeArea(); expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe(""); }); it("stops listening once torn down", () => { let payload = '{"top":24,"right":0,"bottom":48,"left":0}'; window.AndroidInsets = { get: () => payload }; const teardown = initSafeArea(); teardown(); payload = '{"top":99,"right":99,"bottom":99,"left":99}'; window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT)); expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px"); }); }); /** * Static guards. The two root-cause defects were both single lines of markup / * CSS that no runtime test could reach, so pin them at the source level. */ describe("safe-area wiring in source", () => { const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8"); it("app.html opts the viewport into viewport-fit=cover", () => { const viewport = read("src/app.html").match(/]*>/i)?.[0]; expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy(); expect(viewport).toMatch(/viewport-fit\s*=\s*cover/); }); it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => { const css = read("src/app.css"); for (const edge of ["top", "right", "bottom", "left"]) { expect(css).toMatch( new RegExp( `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`, ), ); } }); it("no component pads directly from env() — everything goes through --safe-*", () => { // A bare env() is 0 in Android WebView for the system bars, which is the // bug. app.css is the one legal place it may appear (inside the max()). const offenders = [ "src/lib/components/BottomUi.svelte", "src/routes/+layout.svelte", "src/lib/components/player/VideoPlayer.svelte", "src/lib/components/player/AudioPlayer.svelte", ].filter((f) => read(f).includes("env(safe-area-inset")); expect(offenders).toEqual([]); }); it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => { expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/); }); it("only the app shell measures itself against the viewport", () => { // The shell is `h-screen` AND inset-padded, so its content box is // `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore // taller than the space it was given and overflows by exactly the inset — // the library column's `h-screen` clipped its own BottomUi that way. Nested // full-height boxes must use `h-full`/`min-h-full` and inherit the shell's // already-inset height. const svelteFilesIn = (dir: string): string[] => readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" }) .filter((f) => f.endsWith(".svelte")) .map((f) => `${dir}/${f}`); const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")] .filter((f) => f !== "src/routes/+layout.svelte") .filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f))); expect(offenders).toEqual([]); }); });