From 114e7f953b0d662f467990088f89a55dc0e86bad Mon Sep 17 00:00:00 2001 From: Alexandre Date: Wed, 10 Jun 2026 22:31:47 +0200 Subject: [PATCH 1/3] test(#463): scaffold a2hs-ios pure decision tests --- ...decide-ios-bottom-sheet-visibility.test.ts | 165 ++++++++++++++++++ .../decide-ios-standalone-heuristic.test.ts | 114 ++++++++++++ .../src/lib/a2hs-ios/is-ios-safari.test.ts | 114 ++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 apps/web/src/lib/a2hs-ios/decide-ios-bottom-sheet-visibility.test.ts create mode 100644 apps/web/src/lib/a2hs-ios/decide-ios-standalone-heuristic.test.ts create mode 100644 apps/web/src/lib/a2hs-ios/is-ios-safari.test.ts diff --git a/apps/web/src/lib/a2hs-ios/decide-ios-bottom-sheet-visibility.test.ts b/apps/web/src/lib/a2hs-ios/decide-ios-bottom-sheet-visibility.test.ts new file mode 100644 index 0000000..d703e4c --- /dev/null +++ b/apps/web/src/lib/a2hs-ios/decide-ios-bottom-sheet-visibility.test.ts @@ -0,0 +1,165 @@ +/** + * PWA-S11 (#463) — tests for `decideIosBottomSheetVisibility`, the pure + * decision the `` consults to know whether to render + * on `/c/[orderId]` (decisions-log Q4 « iOS bottom-sheet GIF instructions + * sur page Tracking T+0 », US 58). + * + * Same shape as `decideA2hsButtonVisibility` (#462 / S10) — discriminated + * union out, every branch pinnable in node env. 4 conditions ALL required + * to SHOW: + * 1. `isIosSafari = true` — only Safari has the Share → A2HS path + * 2. `isStandalone = false` — already installed → nothing to instruct + * 3. `a2hsStatus !== "enrolled"` — backend already saw the install + * (heuristique S11 — once flipped, retire) + * 4. `dismissed = false` — session-scoped « OK plus tard » respect + * + * Precedence rationale (strongest backend/device truth first), mirrors the + * #462 sibling so the two A2HS surfaces fail/log consistently: + * already-enrolled > already-standalone > not-ios-safari > dismissed + * + * Why no « tracking-step » gate here : + * The PRD/issue places the bottom-sheet at the T+0 « Cmd reçue » state. + * The MOUNT POINT enforces that — the sheet is only RENDERED on + * `/c/[orderId]` and the route component handles « tracking page only ». + * The pure decision intentionally doesn't carry an `orderStatus` input — + * keeping it free of order-domain coupling means the same decision can + * later drive a different mount point without a contract change. + */ +import { describe, expect, it } from "vitest"; +import { decideIosBottomSheetVisibility } from "./decide-ios-bottom-sheet-visibility"; + +const SHOW = { kind: "show" } as const; + +describe("decideIosBottomSheetVisibility — show", () => { + it("renders on iOS Safari, not standalone, not enrolled, not dismissed", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: false, + a2hsStatus: undefined, + dismissed: false, + }), + ).toEqual(SHOW); + }); + + it("renders with a2hsStatus = not_enrolled (same as undefined — not yet enrolled)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: false, + a2hsStatus: "not_enrolled", + dismissed: false, + }), + ).toEqual(SHOW); + }); + + it('renders with a2hsStatus = "revoked" (re-offer install — same shape as wallet-prompt revoked)', () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: false, + a2hsStatus: "revoked", + dismissed: false, + }), + ).toEqual(SHOW); + }); +}); + +describe("decideIosBottomSheetVisibility — hidden", () => { + it("hides when a2hsStatus = enrolled (backend knows we installed — AC 3)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: false, + a2hsStatus: "enrolled", + dismissed: false, + }), + ).toEqual({ kind: "hidden", reason: "already-enrolled" }); + }); + + it("hides when running in PWA standalone (already installed — nothing to instruct)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: true, + a2hsStatus: undefined, + dismissed: false, + }), + ).toEqual({ kind: "hidden", reason: "already-standalone" }); + }); + + it("hides on Android (AC 4 « bottom sheet n'apparaît jamais » — détection iOS-only)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: false, + isStandalone: false, + a2hsStatus: undefined, + dismissed: false, + }), + ).toEqual({ kind: "hidden", reason: "not-ios-safari" }); + }); + + it("hides on desktop Safari (no Add-to-Home-Screen on macOS Safari)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: false, + isStandalone: false, + a2hsStatus: undefined, + dismissed: false, + }), + ).toEqual({ kind: "hidden", reason: "not-ios-safari" }); + }); + + it("hides when the user tapped « OK plus tard » this session (sessionStorage)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: true, + isStandalone: false, + a2hsStatus: undefined, + dismissed: true, + }), + ).toEqual({ kind: "hidden", reason: "dismissed" }); + }); +}); + +describe("decideIosBottomSheetVisibility — precedence", () => { + // Precedence pins the reason surfaced when multiple hide conditions apply, + // useful for the analytics breadcrumb. Order mirrors #462: + // 1. enrolled — loudest backend truth (we already saw the install) + // 2. standalone — live device truth (running INSIDE the PWA right now) + // 3. not-ios-safari — we literally can't instruct (other browser) + // 4. dismissed — soft session preference (last to surface) + + it("enrolled wins over standalone + not-ios + dismissed", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: false, + isStandalone: true, + a2hsStatus: "enrolled", + dismissed: true, + }), + ).toEqual({ kind: "hidden", reason: "already-enrolled" }); + }); + + it("standalone wins over not-ios + dismissed (when not enrolled yet)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: false, + isStandalone: true, + a2hsStatus: undefined, + dismissed: true, + }), + ).toEqual({ kind: "hidden", reason: "already-standalone" }); + }); + + it("not-ios-safari wins over dismissed (when not enrolled, not standalone)", () => { + expect( + decideIosBottomSheetVisibility({ + isIosSafari: false, + isStandalone: false, + a2hsStatus: undefined, + dismissed: true, + }), + ).toEqual({ kind: "hidden", reason: "not-ios-safari" }); + }); +}); diff --git a/apps/web/src/lib/a2hs-ios/decide-ios-standalone-heuristic.test.ts b/apps/web/src/lib/a2hs-ios/decide-ios-standalone-heuristic.test.ts new file mode 100644 index 0000000..89cd497 --- /dev/null +++ b/apps/web/src/lib/a2hs-ios/decide-ios-standalone-heuristic.test.ts @@ -0,0 +1,114 @@ +/** + * PWA-S11 (#463) — tests for `decideIosStandaloneHeuristic`, the pure + * decision the `` consults to know whether to + * fire the `recordA2hsAccepted` mutation on a visit (decisions-log Q4 / + * US 59 « heuristique standalone à la visite suivante pour tracker enrolled »). + * + * On iOS Safari, A2HS install completes OUTSIDE our app (user goes to the + * Share menu, taps « Sur l'écran d'accueil », confirms — no JS event fires). + * The only signal we get on the NEXT visit is the standalone display-mode + * media query — that's the heuristic. We fire the mutation EXACTLY ONCE per + * fiche per install: re-running on every page load would write redundant + * audit rows. The backend mutation IS idempotent, but the heuristic decision + * is the one place we can avoid the round-trip noise. + * + * Decision matrix: + * - standalone + NOT enrolled → flip (fire the mutation, attribute the install). + * - standalone + already enrolled → noop (already-flipped, save the round-trip). + * - NOT standalone → noop (regular browser tab, never the install signal). + * + * Why NOT gate on iOS-only here : + * The standalone heuristic ALSO fires for the user who skipped the bottom + * sheet on /c/[orderId] and installed via the Chrome menu on Android (US 59 + * « heuristique standalone à la visite suivante »). The #462 sibling + * handles the Android `appinstalled` window event INSIDE the active session; + * the runner here is the safety net for the cross-session signal on EVERY + * platform. Excluding non-iOS would create a backend signal gap where a + * user installs Android via the browser menu, closes the tab before the + * `appinstalled` listener fires (rare race), and re-opens later in + * standalone — without this runner the `a2hsStatus` would never flip. + */ +import { describe, expect, it } from "vitest"; +import { decideIosStandaloneHeuristic } from "./decide-ios-standalone-heuristic"; + +describe("decideIosStandaloneHeuristic — flip", () => { + it("flips when standalone + a2hsStatus undefined (first revisit after install)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: true, + a2hsStatus: undefined, + }), + ).toEqual({ kind: "flip" }); + }); + + it("flips when standalone + a2hsStatus = not_enrolled (Convex sub loaded the fiche but no install signal yet)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: true, + a2hsStatus: "not_enrolled", + }), + ).toEqual({ kind: "flip" }); + }); + + it('flips when standalone + a2hsStatus = "revoked" (re-install after uninstall)', () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: true, + a2hsStatus: "revoked", + }), + ).toEqual({ kind: "flip" }); + }); +}); + +describe("decideIosStandaloneHeuristic — noop", () => { + it("noops when standalone + already enrolled (save the redundant round-trip)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: true, + a2hsStatus: "enrolled", + }), + ).toEqual({ kind: "noop", reason: "already-enrolled" }); + }); + + it("noops when NOT standalone + undefined (regular browser tab)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: false, + a2hsStatus: undefined, + }), + ).toEqual({ kind: "noop", reason: "not-standalone" }); + }); + + it("noops when NOT standalone + already enrolled (consistent baseline)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: false, + a2hsStatus: "enrolled", + }), + ).toEqual({ kind: "noop", reason: "not-standalone" }); + }); + + it("noops when NOT standalone + not_enrolled (still in browser tab)", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: false, + a2hsStatus: "not_enrolled", + }), + ).toEqual({ kind: "noop", reason: "not-standalone" }); + }); +}); + +describe("decideIosStandaloneHeuristic — precedence", () => { + // `not-standalone` wins over `already-enrolled` when both apply because + // « not standalone » is the loudest signal (we're literally not in the PWA + // right now — nothing to attribute). The reason ordering matches: + // not-standalone > already-enrolled. + it("not-standalone wins over already-enrolled when both apply", () => { + expect( + decideIosStandaloneHeuristic({ + isStandalone: false, + a2hsStatus: "enrolled", + }), + ).toEqual({ kind: "noop", reason: "not-standalone" }); + }); +}); diff --git a/apps/web/src/lib/a2hs-ios/is-ios-safari.test.ts b/apps/web/src/lib/a2hs-ios/is-ios-safari.test.ts new file mode 100644 index 0000000..eabe20e --- /dev/null +++ b/apps/web/src/lib/a2hs-ios/is-ios-safari.test.ts @@ -0,0 +1,114 @@ +/** + * PWA-S11 (#463) — tests for `isIosSafari`, the pure UA sniff the iOS A2HS + * surface relies on (decisions-log Q4 « iOS bottom-sheet trigger sur page + * Tracking T+0 », US 58). + * + * iOS Safari has no `beforeinstallprompt` event (Apple limitation, cf. #462) + * so the install hint MUST be served via instructions. We only show the + * bottom-sheet on iPhone / iPad in Safari (NOT Chrome iOS / Firefox iOS — + * those are WebKit wrappers but their UA strings are distinct and Safari is + * the only browser that exposes the share-sheet → "Add to Home Screen" path + * the GIF illustrates). + * + * The function is intentionally PERMISSIVE on the « is iOS device » side + * (iPhone | iPad | iPod) and STRICT on the « is Safari » side (excludes + * `CriOS` / `FxiOS` / `EdgiOS` / `OPiOS` / `Instagram` / `FBAN`/`FBAV`/ + * embedded WebViews). False positives (showing the sheet on a non-Safari + * browser) would dead-end the user (the share menu they see isn't the + * Safari one). False negatives (not showing on a fringe browser) are safe + * — the user just doesn't see the install hint that wouldn't work anyway. + */ +import { describe, expect, it } from "vitest"; +import { isIosSafari } from "./is-ios-safari"; + +describe("isIosSafari — true on iOS Safari", () => { + it("matches iPhone Safari iOS 17 (real-world UA)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(true); + }); + + it("matches iPad Safari iPadOS 17", () => { + const ua = + "Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(true); + }); + + it("matches iPod Touch Safari (legacy device, still in field)", () => { + const ua = + "Mozilla/5.0 (iPod touch; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(true); + }); + + it("matches older iOS 16.4 Safari (Web Push minimum, common in field)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(true); + }); +}); + +describe("isIosSafari — false on iOS non-Safari (would dead-end install hint)", () => { + it("rejects Chrome iOS (CriOS — WebKit but different share menu)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects Firefox iOS (FxiOS)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/121.0 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects Edge iOS (EdgiOS)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 EdgiOS/120.0.2210.61 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects Opera iOS (OPiOS)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 OPiOS/16.0.13.131967 Mobile/15E148 Safari/604.1"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects Instagram in-app browser (no share-sheet → add to home)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 318.0.0.20.107 (iPhone15,3; iOS 17_2; en_US; en; scale=3.00; 1290x2796; 532493226)"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects Facebook in-app WebView (FBAN/FBAV)", () => { + const ua = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBDV/iPhone15,3;FBMD/iPhone;FBSN/iOS;FBSV/17.2;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5]"; + expect(isIosSafari(ua)).toBe(false); + }); +}); + +describe("isIosSafari — false on non-iOS", () => { + it("rejects Android Chrome", () => { + const ua = + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects desktop Safari macOS (no iPhone/iPad/iPod token)", () => { + const ua = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects desktop Chrome", () => { + const ua = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + expect(isIosSafari(ua)).toBe(false); + }); + + it("rejects empty UA defensively (server snapshot, exotic clients)", () => { + expect(isIosSafari("")).toBe(false); + }); + + it("rejects undefined UA defensively", () => { + expect(isIosSafari(undefined)).toBe(false); + }); +}); From 40d90085f270ef6079e4055ce9a0336173b594b4 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Wed, 10 Jun 2026 22:37:32 +0200 Subject: [PATCH 2/3] feat(#463): A2HS iOS bottom-sheet on tracking T+0 + standalone heuristique runner --- apps/web/src/app/c/[orderId]/page.tsx | 8 + apps/web/src/app/layout.tsx | 20 + apps/web/src/components/a2hs-ios/index.ts | 26 ++ .../a2hs-ios/ios-install-bottom-sheet.tsx | 407 ++++++++++++++++++ .../ios-standalone-heuristic-runner.tsx | 136 ++++++ .../decide-ios-bottom-sheet-visibility.ts | 104 +++++ .../decide-ios-standalone-heuristic.ts | 70 +++ apps/web/src/lib/a2hs-ios/index.ts | 33 ++ apps/web/src/lib/a2hs-ios/is-ios-safari.ts | 57 +++ apps/web/src/lib/a2hs-ios/session-keys.ts | 26 ++ 10 files changed, 887 insertions(+) create mode 100644 apps/web/src/components/a2hs-ios/index.ts create mode 100644 apps/web/src/components/a2hs-ios/ios-install-bottom-sheet.tsx create mode 100644 apps/web/src/components/a2hs-ios/ios-standalone-heuristic-runner.tsx create mode 100644 apps/web/src/lib/a2hs-ios/decide-ios-bottom-sheet-visibility.ts create mode 100644 apps/web/src/lib/a2hs-ios/decide-ios-standalone-heuristic.ts create mode 100644 apps/web/src/lib/a2hs-ios/index.ts create mode 100644 apps/web/src/lib/a2hs-ios/is-ios-safari.ts create mode 100644 apps/web/src/lib/a2hs-ios/session-keys.ts diff --git a/apps/web/src/app/c/[orderId]/page.tsx b/apps/web/src/app/c/[orderId]/page.tsx index eec4898..97aaa95 100644 --- a/apps/web/src/app/c/[orderId]/page.tsx +++ b/apps/web/src/app/c/[orderId]/page.tsx @@ -28,6 +28,7 @@ import { fetchQuery } from "convex/nextjs"; import { api } from "@packages/backend/convex/_generated/api"; import type { Id } from "@packages/backend/convex/_generated/dataModel"; import { TrackingView } from "@/components/tracking/tracking-view"; +import { IOSInstallBottomSheet } from "@/components/a2hs-ios"; const TENANT_COOKIE = "__Host-kb_tenant"; @@ -83,6 +84,13 @@ export default async function TrackingPage(props: { restoName={restoName} /> + {/* PWA-S11 (#463) — iOS A2HS instructional bottom-sheet, rendered + on the tracking page T+0 (« Cmd reçue ») state per decisions-log + Q4 + US 58. Self-gates on iOS Safari + not standalone + not + enrolled + not dismissed — renders nothing on Android (#462 + handles that platform) / desktop / non-Safari iOS browsers. */} + +