From c45c08064ad3a796455f13d963dc569fde7f82e2 Mon Sep 17 00:00:00 2001 From: "arrz.dev" <55147716+arrzdev@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:36:18 +0000 Subject: [PATCH] test(nativ): coverage script and floors, tests for the service worker, routes and utils modules --- packages/nativ/package.json | 4 +- .../nativ/src/routes/nativ-routes.test.ts | 33 ++++ packages/nativ/src/sw/sw.lifecycle.test.ts | 66 +++++++ packages/nativ/src/sw/sw.matchers.test.ts | 86 +++++++++ .../nativ/src/sw/sw.navigation-match.test.ts | 52 ++++++ packages/nativ/src/sw/sw.routes.test.ts | 174 ++++++++++++++++++ packages/nativ/src/sw/sw.strategies.test.ts | 112 +++++++++++ packages/nativ/src/sw/sw.warm-routes.test.ts | 105 +++++++++++ .../utils/install-vibrate-polyfill.test.ts | 119 ++++++++++++ packages/nativ/src/utils/utils.test.ts | 113 ++++++++++++ packages/nativ/vitest.config.ts | 21 +++ pnpm-lock.yaml | 3 + 12 files changed, 887 insertions(+), 1 deletion(-) create mode 100644 packages/nativ/src/routes/nativ-routes.test.ts create mode 100644 packages/nativ/src/sw/sw.lifecycle.test.ts create mode 100644 packages/nativ/src/sw/sw.matchers.test.ts create mode 100644 packages/nativ/src/sw/sw.navigation-match.test.ts create mode 100644 packages/nativ/src/sw/sw.routes.test.ts create mode 100644 packages/nativ/src/sw/sw.strategies.test.ts create mode 100644 packages/nativ/src/sw/sw.warm-routes.test.ts create mode 100644 packages/nativ/src/utils/install-vibrate-polyfill.test.ts create mode 100644 packages/nativ/src/utils/utils.test.ts diff --git a/packages/nativ/package.json b/packages/nativ/package.json index 77d0bce9..d3b7e6ab 100644 --- a/packages/nativ/package.json +++ b/packages/nativ/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "sideEffects": [ "**/*.css" @@ -79,6 +80,7 @@ "@testing-library/react": "16.3.2", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@vitest/coverage-v8": "4.1.12", "clsx": "2.1.1", "happy-dom": "21.0.4", "motion": "12.35.0", diff --git a/packages/nativ/src/routes/nativ-routes.test.ts b/packages/nativ/src/routes/nativ-routes.test.ts new file mode 100644 index 00000000..de3b46de --- /dev/null +++ b/packages/nativ/src/routes/nativ-routes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" +import { index, rootRoute, route } from "#nativ/routes/nativ-routes" + +describe("rootRoute", () => { + it("wires the generated root file when given only children", () => { + const tree = rootRoute([index("pages/home.tsx")]) + + expect(tree).toEqual({ + type: "root", + file: "layouts/__root.gen.tsx", + children: [{ type: "index", file: "pages/home.tsx" }], + }) + }) + + it("wires the generated root file when given nothing", () => { + expect(rootRoute()).toEqual({ + type: "root", + file: "layouts/__root.gen.tsx", + children: undefined, + }) + }) + + it("keeps an explicit root file so an app can eject", () => { + const tree = rootRoute("layouts/_root.tsx", [ + route("/settings", "pages/settings.tsx"), + ]) + + expect(tree.file).toBe("layouts/_root.tsx") + expect(tree.children).toEqual([ + { type: "route", path: "/settings", file: "pages/settings.tsx" }, + ]) + }) +}) diff --git a/packages/nativ/src/sw/sw.lifecycle.test.ts b/packages/nativ/src/sw/sw.lifecycle.test.ts new file mode 100644 index 00000000..5a5f21d0 --- /dev/null +++ b/packages/nativ/src/sw/sw.lifecycle.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { registerServiceWorkerLifecycle } from "#nativ/sw/sw.lifecycle" + +type Listener = (event: unknown) => void + +//a service worker global with just what the lifecycle helpers touch +function createScope() { + const listeners = new Map() + return { + skipWaiting: vi.fn(async () => {}), + clients: { claim: vi.fn(async () => {}) }, + addEventListener(type: string, listener: Listener) { + listeners.set(type, [...(listeners.get(type) ?? []), listener]) + }, + dispatch(type: string, event: unknown) { + for (const listener of listeners.get(type) ?? []) listener(event) + }, + listenerCount(type: string) { + return listeners.get(type)?.length ?? 0 + }, + } +} + +let scope: ReturnType + +beforeEach(() => { + scope = createScope() + vi.stubGlobal("self", scope) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("registerServiceWorkerLifecycle", () => { + it("skips waiting only on a SKIP_WAITING message", () => { + registerServiceWorkerLifecycle() + + scope.dispatch("message", { data: { type: "OTHER" } }) + scope.dispatch("message", { data: null }) + expect(scope.skipWaiting).not.toHaveBeenCalled() + + scope.dispatch("message", { data: { type: "SKIP_WAITING" } }) + expect(scope.skipWaiting).toHaveBeenCalledOnce() + }) + + it("claims clients on activate and holds the event until done", () => { + registerServiceWorkerLifecycle() + const waitUntil = vi.fn() + + scope.dispatch("activate", { waitUntil }) + + expect(scope.clients.claim).toHaveBeenCalledOnce() + expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise)) + }) + + it("registers nothing when both behaviours are turned off", () => { + registerServiceWorkerLifecycle({ + claimClients: false, + skipWaitingOnMessage: false, + }) + + expect(scope.listenerCount("message")).toBe(0) + expect(scope.listenerCount("activate")).toBe(0) + }) +}) diff --git a/packages/nativ/src/sw/sw.matchers.test.ts b/packages/nativ/src/sw/sw.matchers.test.ts new file mode 100644 index 00000000..c2883cf8 --- /dev/null +++ b/packages/nativ/src/sw/sw.matchers.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createStaticAssetMatcher } from "#nativ/sw/sw.matchers" + +const ORIGIN = "https://app.test" + +function assetRequest( + path: string, + request: Partial> = {}, +) { + return [ + new URL(path, ORIGIN), + { + method: "GET", + mode: "cors", + destination: "", + ...request, + } as Request, + ] as const +} + +beforeEach(() => { + vi.stubGlobal("self", { location: { origin: ORIGIN } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("createStaticAssetMatcher", () => { + it("matches same origin scripts, styles, fonts and images", () => { + const matches = createStaticAssetMatcher() + + for (const destination of [ + "script", + "style", + "font", + "image", + ] as const) { + expect(matches(...assetRequest("/x", { destination }))).toBe(true) + } + }) + + it("matches anything under /assets/ whatever its destination", () => { + const matches = createStaticAssetMatcher() + + expect(matches(...assetRequest("/assets/board.json"))).toBe(true) + }) + + it("skips other destinations outside /assets/", () => { + const matches = createStaticAssetMatcher() + + expect(matches(...assetRequest("/data.json"))).toBe(false) + }) + + it("skips other origins, non GET requests and navigations", () => { + const matches = createStaticAssetMatcher() + + expect( + matches(new URL("https://cdn.test/a.js"), { + method: "GET", + mode: "cors", + destination: "script", + } as Request), + ).toBe(false) + expect( + matches(...assetRequest("/assets/a.js", { method: "POST" })), + ).toBe(false) + expect( + matches(...assetRequest("/assets/a.js", { mode: "navigate" })), + ).toBe(false) + }) + + it("skips /api/ and any extra excluded prefix", () => { + const matches = createStaticAssetMatcher({ + excludePathPrefixes: ["/assets/private/"], + }) + + expect( + matches( + ...assetRequest("/api/avatar.png", { destination: "image" }), + ), + ).toBe(false) + expect(matches(...assetRequest("/assets/private/a.js"))).toBe(false) + expect(matches(...assetRequest("/assets/public/a.js"))).toBe(true) + }) +}) diff --git a/packages/nativ/src/sw/sw.navigation-match.test.ts b/packages/nativ/src/sw/sw.navigation-match.test.ts new file mode 100644 index 00000000..95bc67be --- /dev/null +++ b/packages/nativ/src/sw/sw.navigation-match.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" +import { createNavigationRouteMatch } from "#nativ/sw/sw.navigation-match" + +function navigationTo(path: string, mode: RequestMode = "navigate") { + return { + url: new URL(path, "https://app.test"), + request: { mode } as Request, + } +} + +describe("createNavigationRouteMatch", () => { + it("never matches a request that is not a navigation", () => { + const matches = createNavigationRouteMatch() + + expect(matches(navigationTo("/", "cors"))).toBe(false) + }) + + it("matches every navigation when given no options", () => { + const matches = createNavigationRouteMatch() + + expect(matches(navigationTo("/anything/at/all"))).toBe(true) + }) + + it("matches the prefix itself and paths under it, not lookalikes", () => { + const matches = createNavigationRouteMatch({ pathPrefix: "/products" }) + + expect(matches(navigationTo("/products"))).toBe(true) + expect(matches(navigationTo("/products/123"))).toBe(true) + expect(matches(navigationTo("/products-sale"))).toBe(false) + expect(matches(navigationTo("/"))).toBe(false) + }) + + it("matches by pattern before prefix", () => { + const matches = createNavigationRouteMatch({ + pathPattern: /^\/game\/\d+$/, + pathPrefix: "/rules", + }) + + expect(matches(navigationTo("/game/42"))).toBe(true) + expect(matches(navigationTo("/rules"))).toBe(false) + }) + + it("hands full control to a custom match", () => { + const matches = createNavigationRouteMatch({ + match: (url) => url.searchParams.has("cache"), + pathPrefix: "/products", + }) + + expect(matches(navigationTo("/products"))).toBe(false) + expect(matches(navigationTo("/other?cache=1"))).toBe(true) + }) +}) diff --git a/packages/nativ/src/sw/sw.routes.test.ts b/packages/nativ/src/sw/sw.routes.test.ts new file mode 100644 index 00000000..806ca175 --- /dev/null +++ b/packages/nativ/src/sw/sw.routes.test.ts @@ -0,0 +1,174 @@ +import type { Strategy } from "workbox-strategies" +import { NetworkFirst } from "workbox-strategies" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { registerIncrementalNavigationRoute } from "#nativ/sw/sw.navigation" +import { setupPrecache } from "#nativ/sw/sw.precache" +import { registerStaticAssetsRoute } from "#nativ/sw/sw.static-assets" + +//workbox's routing and precache keep global state inside a real service +//worker; here they are spies so the tests read what nativ registers +const workbox = vi.hoisted(() => ({ + registerRoute: vi.fn(), + matchPrecache: vi.fn(), + precacheAndRoute: vi.fn(), + cleanupOutdatedCaches: vi.fn(), +})) + +vi.mock("workbox-routing", () => ({ + registerRoute: workbox.registerRoute, +})) +vi.mock("workbox-precaching", () => ({ + matchPrecache: workbox.matchPrecache, + precacheAndRoute: workbox.precacheAndRoute, + cleanupOutdatedCaches: workbox.cleanupOutdatedCaches, +})) + +const ORIGIN = "https://app.test" + +type Handler = (options: { + request: Request + event: ExtendableEvent +}) => Promise + +function registeredRoute() { + const [match, handler] = workbox.registerRoute.mock.calls.at(-1) ?? [] + return { + match: match as (options: { url: URL; request: Request }) => boolean, + handler: handler as Handler, + } +} + +function strategyResolving(handle: () => Promise) { + return { handle: vi.fn(handle) } as unknown as Strategy +} + +const handlerOptions = { + request: {} as Request, + event: {} as ExtendableEvent, +} + +beforeEach(() => { + vi.stubGlobal("self", { location: { origin: ORIGIN } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe("registerIncrementalNavigationRoute", () => { + it("defaults to a network first pages cache named by build tag", () => { + const { cacheName, strategy } = registerIncrementalNavigationRoute({ + buildTag: "v1", + }) + + expect(cacheName).toBe("pages-v1") + expect(strategy).toBeInstanceOf(NetworkFirst) + expect(strategy.cacheName).toBe("pages-v1") + }) + + it("routes only the navigations its match options own", () => { + registerIncrementalNavigationRoute({ + buildTag: "v1", + pathPrefix: "/game", + }) + const { match } = registeredRoute() + const navigate = { mode: "navigate" } as Request + + expect( + match({ url: new URL("/game/1", ORIGIN), request: navigate }), + ).toBe(true) + expect( + match({ url: new URL("/rules", ORIGIN), request: navigate }), + ).toBe(false) + }) + + it("serves what the strategy returns", async () => { + const page = new Response("page") + const strategy = strategyResolving(async () => page) + registerIncrementalNavigationRoute({ buildTag: "v1", strategy }) + + await expect(registeredRoute().handler(handlerOptions)).resolves.toBe( + page, + ) + expect(workbox.matchPrecache).not.toHaveBeenCalled() + }) + + it("falls back to the precached offline page when network and cache miss", async () => { + const offline = new Response("offline") + workbox.matchPrecache.mockResolvedValue(offline) + const strategy = strategyResolving(async () => { + throw new Error("no-response") + }) + registerIncrementalNavigationRoute({ + buildTag: "v1", + strategy, + offlineFallbackPath: "/offline", + }) + + await expect(registeredRoute().handler(handlerOptions)).resolves.toBe( + offline, + ) + expect(workbox.matchPrecache).toHaveBeenCalledWith(`${ORIGIN}/offline`) + }) + + it("returns a network error when there is nothing to fall back to", async () => { + const strategy = strategyResolving(async () => undefined) + registerIncrementalNavigationRoute({ buildTag: "v1", strategy }) + + const response = await registeredRoute().handler(handlerOptions) + + expect(response.type).toBe("error") + }) + + it("returns a network error when the offline page is not precached", async () => { + workbox.matchPrecache.mockResolvedValue(undefined) + const strategy = strategyResolving(async () => undefined) + registerIncrementalNavigationRoute({ + buildTag: "v1", + strategy, + offlineFallbackPath: "/offline", + }) + + const response = await registeredRoute().handler(handlerOptions) + + expect(response.type).toBe("error") + }) +}) + +describe("registerStaticAssetsRoute", () => { + it("routes static assets to a stale while revalidate cache", () => { + const { strategy } = registerStaticAssetsRoute({ + buildTag: "v1", + excludePathPrefixes: ["/private/"], + }) + const [match, registeredStrategy] = + workbox.registerRoute.mock.calls.at(-1) ?? [] + const script = { + method: "GET", + mode: "cors", + destination: "script", + } as Request + + expect(registeredStrategy).toBe(strategy) + expect(strategy.cacheName).toBe("static-v1") + expect( + match({ url: new URL("/app.js", ORIGIN), request: script }), + ).toBe(true) + expect( + match({ url: new URL("/private/app.js", ORIGIN), request: script }), + ).toBe(false) + }) +}) + +describe("setupPrecache", () => { + it("precaches the build manifest and drops outdated caches", () => { + const manifest = [{ url: "/index.html", revision: "1" }] + + setupPrecache(manifest) + + expect(workbox.precacheAndRoute).toHaveBeenCalledWith(manifest) + expect(workbox.precacheAndRoute.mock.calls[0]?.[0]).not.toBe(manifest) + expect(workbox.cleanupOutdatedCaches).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/nativ/src/sw/sw.strategies.test.ts b/packages/nativ/src/sw/sw.strategies.test.ts new file mode 100644 index 00000000..e4a7574d --- /dev/null +++ b/packages/nativ/src/sw/sw.strategies.test.ts @@ -0,0 +1,112 @@ +import { ExpirationPlugin } from "workbox-expiration" +import { + CacheFirst, + NetworkFirst, + StaleWhileRevalidate, +} from "workbox-strategies" +import { describe, expect, it } from "vitest" +import { createCacheName } from "#nativ/sw/sw.cache-name" +import { createExpirationPlugins } from "#nativ/sw/sw.expiration" +import { createCacheOkResponsesPlugin } from "#nativ/sw/sw.plugins" +import { + createCacheFirstStrategy, + createNetworkFirstStrategy, + createPagesNetworkFirstStrategy, + createStaleWhileRevalidateStrategy, + createStaticStaleWhileRevalidateStrategy, +} from "#nativ/sw/sw.strategies" + +describe("createCacheName", () => { + it("namespaces the bucket by build tag", () => { + expect(createCacheName("abc123", "pages")).toBe("pages-abc123") + }) +}) + +describe("createCacheOkResponsesPlugin", () => { + const { cacheWillUpdate } = createCacheOkResponsesPlugin() + const willCache = (response: Response) => + cacheWillUpdate?.({ response } as Parameters< + NonNullable + >[0]) + + it("keeps a 200 response", async () => { + const response = new Response("ok", { status: 200 }) + + await expect(willCache(response)).resolves.toBe(response) + }) + + it("drops anything that is not a 200", async () => { + await expect( + willCache(new Response("", { status: 404 })), + ).resolves.toBe(null) + await expect( + willCache(new Response("", { status: 302 })), + ).resolves.toBe(null) + }) +}) + +describe("createExpirationPlugins", () => { + it("adds nothing for a permanent cache", () => { + expect(createExpirationPlugins(undefined)).toEqual([]) + }) + + it("adds one expiration plugin for a rolling window", () => { + const plugins = createExpirationPlugins({ maxEntries: 10 }) + + expect(plugins).toHaveLength(1) + expect(plugins[0]).toBeInstanceOf(ExpirationPlugin) + }) +}) + +describe("strategy factories", () => { + it("builds each strategy with the ok-only plugin and ignoreVary on", () => { + const options = { cacheName: "c" } + + for (const [strategy, type] of [ + [createNetworkFirstStrategy(options), NetworkFirst], + [createStaleWhileRevalidateStrategy(options), StaleWhileRevalidate], + [createCacheFirstStrategy(options), CacheFirst], + ] as const) { + expect(strategy).toBeInstanceOf(type) + expect(strategy.cacheName).toBe("c") + expect(strategy.plugins).toHaveLength(1) + expect(strategy.matchOptions).toEqual({ ignoreVary: true }) + } + }) + + it("adds expiration and caller plugins after the ok-only plugin", () => { + const callerPlugin = {} + const strategy = createCacheFirstStrategy({ + cacheName: "c", + expiration: { maxAgeSeconds: 60 }, + plugins: [callerPlugin], + matchOptions: { ignoreSearch: true }, + }) + + expect(strategy.plugins).toHaveLength(3) + expect(strategy.plugins[1]).toBeInstanceOf(ExpirationPlugin) + expect(strategy.plugins[2]).toBe(callerPlugin) + expect(strategy.matchOptions).toEqual({ + ignoreVary: true, + ignoreSearch: true, + }) + }) + + it("names the pages and static caches by bucket and build tag", () => { + expect(createPagesNetworkFirstStrategy("v1").cacheName).toBe( + "pages-v1", + ) + expect( + createPagesNetworkFirstStrategy("v1", { cacheBucket: "docs" }) + .cacheName, + ).toBe("docs-v1") + expect(createStaticStaleWhileRevalidateStrategy("v1").cacheName).toBe( + "static-v1", + ) + expect( + createStaticStaleWhileRevalidateStrategy("v1", { + cacheBucket: "img", + }).cacheName, + ).toBe("img-v1") + }) +}) diff --git a/packages/nativ/src/sw/sw.warm-routes.test.ts b/packages/nativ/src/sw/sw.warm-routes.test.ts new file mode 100644 index 00000000..9d79c0fd --- /dev/null +++ b/packages/nativ/src/sw/sw.warm-routes.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + createInstallRouteWarmer, + registerInstallRouteWarmer, +} from "#nativ/sw/sw.warm-routes" + +const ORIGIN = "https://app.test" + +let cache: { put: ReturnType } +let openCache: ReturnType +let installListener: ((event: unknown) => void) | undefined + +beforeEach(() => { + cache = { put: vi.fn(async () => {}) } + openCache = vi.fn(async () => cache) + installListener = undefined + vi.stubGlobal("self", { + location: { origin: ORIGIN }, + addEventListener: ( + type: string, + listener: (event: unknown) => void, + ) => { + if (type === "install") installListener = listener + }, + }) + vi.stubGlobal("caches", { open: openCache }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function cachedUrls() { + return cache.put.mock.calls.map(([request]) => (request as Request).url) +} + +describe("createInstallRouteWarmer", () => { + it("caches each distinct route once in the pages bucket", async () => { + const fetchDocument = vi.fn(async () => new Response("")) + vi.stubGlobal("fetch", fetchDocument) + + await createInstallRouteWarmer({ + buildTag: "v1", + routes: ["/", "/rules", "/rules"], + })() + + expect(openCache).toHaveBeenCalledWith("pages-v1") + expect(fetchDocument).toHaveBeenCalledTimes(2) + expect(cachedUrls()).toEqual([`${ORIGIN}/`, `${ORIGIN}/rules`]) + }) + + it("asks for an HTML document with same origin credentials", async () => { + const fetchDocument = vi.fn( + async (_request: Request) => new Response(""), + ) + vi.stubGlobal("fetch", fetchDocument) + + await createInstallRouteWarmer({ buildTag: "v1", routes: ["/"] })() + + const request = fetchDocument.mock.calls[0]?.[0] + expect(request?.method).toBe("GET") + expect(request?.credentials).toBe("same-origin") + expect(request?.headers.get("Accept")).toMatch(/^text\/html/) + }) + + it("skips failed responses and offline errors without failing install", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + if (request.url.endsWith("/missing")) { + return new Response("", { status: 404 }) + } + if (request.url.endsWith("/offline")) + throw new TypeError("offline") + return new Response("") + }), + ) + + await createInstallRouteWarmer({ + buildTag: "v1", + cacheBucket: "docs", + routes: ["/missing", "/offline", "/ok"], + })() + + expect(openCache).toHaveBeenCalledWith("docs-v1") + expect(cachedUrls()).toEqual([`${ORIGIN}/ok`]) + }) +}) + +describe("registerInstallRouteWarmer", () => { + it("holds install until the routes are warmed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("")), + ) + registerInstallRouteWarmer({ buildTag: "v1", routes: ["/"] }) + const waitUntil = vi.fn() + + installListener?.({ waitUntil }) + + expect(waitUntil).toHaveBeenCalledOnce() + await waitUntil.mock.calls[0]?.[0] + expect(cachedUrls()).toEqual([`${ORIGIN}/`]) + }) +}) diff --git a/packages/nativ/src/utils/install-vibrate-polyfill.test.ts b/packages/nativ/src/utils/install-vibrate-polyfill.test.ts new file mode 100644 index 00000000..d1451c77 --- /dev/null +++ b/packages/nativ/src/utils/install-vibrate-polyfill.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const SAFARI_18 = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1" +const SAFARI_17 = SAFARI_18.replace("Version/18.0", "Version/17.4") +const CHROME = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36" + +//the polyfill installs once per page, so each test loads a fresh copy of the +//module against its own navigator +async function install(navigator: Partial) { + vi.stubGlobal("navigator", navigator) + vi.resetModules() + const { installVibratePolyfill } = await import( + "#nativ/utils/install-vibrate-polyfill" + ) + installVibratePolyfill() + return navigator as Navigator +} + +function switchLabel() { + return document.head.querySelector("label") +} + +beforeEach(() => { + document.head.innerHTML = "" +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("installVibratePolyfill with native vibrate", () => { + it("cancels the running pattern before each new one", async () => { + const nativeVibrate = vi.fn(() => true) + const navigator = await install({ + userAgent: CHROME, + vibrate: nativeVibrate, + }) + + expect(navigator.vibrate([10, 20, 10])).toBe(true) + + expect(nativeVibrate.mock.calls).toEqual([[0], [[10, 20, 10]]]) + }) + + it("only cancels for a zero or empty pattern", async () => { + const nativeVibrate = vi.fn(() => true) + const navigator = await install({ + userAgent: CHROME, + vibrate: nativeVibrate, + }) + + expect(navigator.vibrate(0)).toBe(true) + expect(navigator.vibrate([])).toBe(true) + + expect(nativeVibrate.mock.calls).toEqual([[0], [0]]) + }) + + it("accepts any iterable pattern", async () => { + const nativeVibrate = vi.fn(() => true) + const navigator = await install({ + userAgent: CHROME, + vibrate: nativeVibrate, + }) + + navigator.vibrate(new Set([30, 10]) as unknown as number[]) + + expect(nativeVibrate).toHaveBeenLastCalledWith([30, 10]) + }) + + it("installs once even if called again", async () => { + const nativeVibrate = vi.fn(() => true) + const navigator = await install({ + userAgent: CHROME, + vibrate: nativeVibrate, + }) + const { installVibratePolyfill } = await import( + "#nativ/utils/install-vibrate-polyfill" + ) + const wrapped = navigator.vibrate + + installVibratePolyfill() + + expect(navigator.vibrate).toBe(wrapped) + }) +}) + +describe("installVibratePolyfill on Safari without vibrate", () => { + it("clicks a hidden switch for a pulse on Safari 18", async () => { + const navigator = await install({ userAgent: SAFARI_18 }) + const label = switchLabel() + const click = vi.spyOn(label as HTMLLabelElement, "click") + + expect(label?.querySelector("input")?.hasAttribute("switch")).toBe( + true, + ) + expect(label?.style.display).toBe("none") + expect(navigator.vibrate(15)).toBe(true) + expect(click).toHaveBeenCalledOnce() + }) + + it("does not click for a pattern that starts with no pulse", async () => { + const navigator = await install({ userAgent: SAFARI_18 }) + const click = vi.spyOn(switchLabel() as HTMLLabelElement, "click") + + expect(navigator.vibrate([0, 50])).toBe(false) + expect(click).not.toHaveBeenCalled() + }) + + it("leaves Safari 17 and other browsers untouched", async () => { + const safari17 = await install({ userAgent: SAFARI_17 }) + expect(safari17.vibrate).toBeUndefined() + + const chromeWithoutVibrate = await install({ userAgent: CHROME }) + expect(chromeWithoutVibrate.vibrate).toBeUndefined() + + expect(switchLabel()).toBeNull() + }) +}) diff --git a/packages/nativ/src/utils/utils.test.ts b/packages/nativ/src/utils/utils.test.ts new file mode 100644 index 00000000..47828368 --- /dev/null +++ b/packages/nativ/src/utils/utils.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { clamp } from "#nativ/utils/clamp" +import { cn } from "#nativ/utils/cn" +import { dynamicValues } from "#nativ/utils/dynamic-values" +import { isTouchDevice } from "#nativ/utils/is-touch-device" +import { isIOS } from "#nativ/utils/platform" + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("clamp", () => { + it("keeps a value inside the range and pins one outside it", () => { + expect(clamp(5, 0, 10)).toBe(5) + expect(clamp(-1, 0, 10)).toBe(0) + expect(clamp(11, 0, 10)).toBe(10) + }) +}) + +describe("cn", () => { + it("joins conditional classes and lets the last tailwind conflict win", () => { + expect(cn("p-2", false, ["text-sm", { hidden: true }], "p-4")).toBe( + "text-sm hidden p-4", + ) + }) + + it("treats the safe area padding as a padding utility", () => { + expect(cn("pb-0", "pb-safe")).toBe("pb-safe") + expect(cn("px-safe", "px-2")).toBe("px-2") + }) + + it("keeps one class per touch and scroll behaviour group", () => { + expect(cn("clickable", "non-clickable")).toBe("non-clickable") + expect(cn("scrollable-x", "scrollable-y")).toBe("scrollable-y") + }) +}) + +describe("dynamicValues", () => { + it("derives from the size in tailwind spacing units by default", () => { + const derived = dynamicValues({ size: 8, derive: (ctx) => ctx }) + + expect(derived).toEqual({ size: 8, edgeRem: 2 }) + }) + + it("uses a custom rem per unit", () => { + const edge = dynamicValues({ + size: 4, + unitRem: 0.5, + derive: ({ edgeRem }) => `${edgeRem}rem`, + }) + + expect(edge).toBe("2rem") + }) +}) + +describe("isTouchDevice", () => { + it("is false without a window, as during server rendering", () => { + vi.stubGlobal("window", undefined) + + expect(isTouchDevice()).toBe(false) + }) + + it("is true when the window takes touch events", () => { + vi.stubGlobal("window", { ontouchstart: null }) + + expect(isTouchDevice()).toBe(true) + }) + + it("follows the number of touch points otherwise", () => { + vi.stubGlobal("window", {}) + vi.stubGlobal("navigator", { maxTouchPoints: 5 }) + expect(isTouchDevice()).toBe(true) + + vi.stubGlobal("navigator", { maxTouchPoints: 0 }) + expect(isTouchDevice()).toBe(false) + }) +}) + +describe("isIOS", () => { + it("is false without a navigator", () => { + vi.stubGlobal("navigator", undefined) + + expect(isIOS()).toBe(false) + }) + + it("is true for an iPhone user agent", () => { + vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)", + }) + + expect(isIOS()).toBe(true) + }) + + it("is true for an iPad that reports itself as a touch Mac", () => { + vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + platform: "MacIntel", + maxTouchPoints: 5, + }) + + expect(isIOS()).toBe(true) + }) + + it("is false for a desktop Mac", () => { + vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + platform: "MacIntel", + maxTouchPoints: 0, + }) + + expect(isIOS()).toBe(false) + }) +}) diff --git a/packages/nativ/vitest.config.ts b/packages/nativ/vitest.config.ts index af329922..34e58eee 100644 --- a/packages/nativ/vitest.config.ts +++ b/packages/nativ/vitest.config.ts @@ -13,5 +13,26 @@ export default defineConfig({ }, test: { environment: "happy-dom", + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + //service-worker-shell imports a virtual module only the nativ vite + //plugin resolves, so coverage cannot load it outside an app build + exclude: [ + "src/**/*.test.{ts,tsx}", + "src/**/*.d.ts", + "src/shell/service-worker-shell.ts", + ], + reporter: ["text", "json-summary"], + //today's totals rounded down: most of the shell is DOM and gesture code + //that the e2e suite covers, so this floor only stops it slipping. the CI + //gate runs this script instead of the plain test one + thresholds: { + lines: 14, + branches: 14, + functions: 14, + statements: 14, + }, + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3b2b10f..39bc955b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,6 +330,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.7) + '@vitest/coverage-v8': + specifier: 4.1.12 + version: 4.1.12(vitest@4.1.12) clsx: specifier: 2.1.1 version: 2.1.1