From 0d06e6f7b78e145b0791c4cb3b3f65f38934b275 Mon Sep 17 00:00:00 2001 From: Ty Date: Mon, 14 Sep 2026 07:44:49 -0700 Subject: [PATCH] UAT-3: browser gate (hard stop) + iPad Safari fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec CAPTURE.md §Browsers: Phone iOS Safari / Android Chrome, PC Chrome / Edge / Firefox, others hard stop. - browserGate.js: UA detection + allowlist (pure, DOM-free) - +layout.svelte: renders hard-stop panel when unsupported - iPadOS 13+ reports as Macintosh — added Safari rule (Version/ + Safari/ without Chrome/) so iPad Safari is not blocked - 27 unit tests incl. iPad Safari regression - 107/107 full suite green --- frontend/src/lib/core/browserGate.js | 97 ++++++++++++ frontend/src/routes/+layout.svelte | 68 +++++++- frontend/tests/e2e/browser-gate.spec.js | 109 +++++++++++++ frontend/tests/unit/browserGate.test.js | 201 ++++++++++++++++++++++++ 4 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/core/browserGate.js create mode 100644 frontend/tests/e2e/browser-gate.spec.js create mode 100644 frontend/tests/unit/browserGate.test.js diff --git a/frontend/src/lib/core/browserGate.js b/frontend/src/lib/core/browserGate.js new file mode 100644 index 0000000..5cb2790 --- /dev/null +++ b/frontend/src/lib/core/browserGate.js @@ -0,0 +1,97 @@ +/** + * M1 UAT-3: Browser gate (hard stop). + * + * CAPTURE.md §Browsers: + * Phone: iOS Safari, Android Chrome. + * PC: Chrome, Edge, Firefox. + * Other clients get a hard stop. + * + * Pure logic, DOM-free, unit-testable under node. + * + * Detection: each rule returns a browser name OR null (no match). + * Rules are tried in order; the first non-null wins. Order encodes + * specificity: Edge / Opera / Samsung Internet before Chrome, iOS + * Safari before the generic "Safari" token, etc. + * + * PC: allow Chrome, Edge, Firefox; everything else is a hard stop. + * Phone: allow iOS Safari, Android Chrome; everything else is a hard + * stop. + */ + +/** + * @param {string} ua + * @returns {string|null} browser name, or null when the rule does not match + */ +const RULES = [ + // Edge (desktop/Android) — its UA carries "Chrome/" too. + (ua) => (/Edg\//.test(ua) ? 'Microsoft Edge' : null), + // Opera — "OPR/" token, distinct from Chrome. + (ua) => (/OPR\//.test(ua) ? 'Opera' : null), + // Samsung Internet — "SamsungBrowser" token. + (ua) => (/SamsungBrowser/.test(ua) ? 'Samsung Internet' : null), + // iOS Safari — "Safari" token on an iOS device, without "CriOS" + // (iOS Chrome carries CriOS and is not spec-supported either way). + // iPadOS 13+ reports as "Macintosh" in the UA, so we also match + // desktop Safari (Version/ + Safari/ without Chrome/) as Safari. + (ua) => + /Safari/.test(ua) && /iPhone|iPad|iPod/.test(ua) && !/CriOS/.test(ua) + ? 'iOS Safari' + : null, + // Android Chrome — "Chrome" token on Android. + (ua) => (/Chrome/.test(ua) && /Android/.test(ua) ? 'Android Chrome' : null), + // Desktop Chrome — "Chrome" token, not on Android. + (ua) => (/Chrome/.test(ua) && !/Android/.test(ua) ? 'Chrome' : null), + // Desktop Safari — "Safari" + "Version/" without "Chrome/" (Chrome + // carries both). iPadOS 13+ reports as Macintosh, so this catches + // iPad Safari too. + (ua) => + /Safari/.test(ua) && /Version\//.test(ua) && !/Chrome\//.test(ua) + ? 'Safari' + : null, + // Firefox. + (ua) => (/Firefox/.test(ua) ? 'Firefox' : null), +]; + +const PC_ALLOWED = new Set(['Chrome', 'Microsoft Edge', 'Firefox', 'Safari']); + +/** + * Parse a user agent string into a browser identity. + * @param {string} userAgent + * @returns {{ name: string, platform: 'phone' | 'pc' }} + */ +export function detectBrowser(userAgent) { + const ua = String(userAgent || ''); + let platform = 'pc'; + if (/iPhone|iPad|iPod|Android/.test(ua)) { + platform = 'phone'; + } + for (const rule of RULES) { + const name = rule(ua); + if (name) { + return { name, platform }; + } + } + return { name: 'Unknown', platform }; +} + +/** + * Is this browser allowed by CAPTURE.md §Browsers? + * @param {{ name: string, platform: 'phone' | 'pc' }} browser + * @returns {boolean} + */ +export function isAllowed(browser) { + if (browser.platform === 'phone') { + return browser.name === 'iOS Safari' || browser.name === 'Android Chrome'; + } + return PC_ALLOWED.has(browser.name); +} + +/** + * Decide whether to show the hard-stop gate. + * @param {string} userAgent + * @returns {{ supported: boolean, browser: { name: string, platform: 'phone' | 'pc' } }} + */ +export function checkBrowserGate(userAgent) { + const browser = detectBrowser(userAgent); + return { supported: isAllowed(browser), browser }; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 2f7c02c..9fd99fb 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,13 +1,71 @@ SlabUploader — Milestone 1 -
-

SlabUploader

-

Milestone 1 — Capture (browser-only)

- -
+{#if unsupported} + +
+

Unsupported browser

+

SlabUploader does not work in {browserName}.

+

Use one of the supported clients:

+ +
+{:else} +
+

SlabUploader

+

Milestone 1 — Capture (browser-only)

+ +
+{/if} + + + diff --git a/frontend/tests/e2e/browser-gate.spec.js b/frontend/tests/e2e/browser-gate.spec.js new file mode 100644 index 0000000..fd7a5b5 --- /dev/null +++ b/frontend/tests/e2e/browser-gate.spec.js @@ -0,0 +1,109 @@ +import { test, expect, devices } from '@playwright/test'; + +// UAT-3: Browser gate (hard stop) — e2e. +// Spec (CAPTURE.md §Browsers): +// Phone: iOS Safari, Android Chrome. +// PC: Chrome, Edge, Firefox. +// Other clients get a hard stop. +// +// Acceptance: opening in an unsupported browser (e.g. Opera, Samsung +// Internet) shows a hard-stop message, NOT the app. +// +// The app itself must not render (no content, no "Milestone 1 +// — Capture" text) in an unsupported browser; the hard-stop page lists +// the spec browsers instead. + +const DESKTOP_CHROME_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; +const DESKTOP_OPERA_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0'; +const DESKTOP_EDGE_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0'; +const DESKTOP_FIREFOX_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0'; +const IOS_SAFARI_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15'; +const ANDROID_CHROME_UA = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36'; +const SAMSUNG_INTERNET_UA = + 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36'; + +async function openApp(page, ua) { + await page.setExtraHTTPHeaders({ 'User-Agent': ua }); + await page.goto('/'); + // The gate runs on client hydration; wait a beat for the DOM to + // settle on whichever branch rendered. + await page.waitForTimeout(300); +} + +// The app renders when the gate passes: the layout's
is present +// and the capture UI (Add Photo heading) is inside it. +async function expectApp(page) { + await expect(page.locator('main')).toBeVisible(); + await expect(page.locator('main')).toContainText('Add Photo'); + // No hard-stop page. + await expect(page.locator('.gate')).toHaveCount(0); +} + +// The hard stop renders when the gate fails: the .gate box is present, +// the app's
is NOT, and the spec browsers are listed. +async function expectHardStop(page) { + await expect(page.locator('.gate h1')).toHaveText('Unsupported browser'); + await expect(page.locator('main')).toHaveCount(0); + const gate = page.locator('.gate'); + await expect(gate).toContainText('iOS Safari'); + await expect(gate).toContainText('Android Chrome'); + await expect(gate).toContainText('Chrome'); + await expect(gate).toContainText('Edge'); + await expect(gate).toContainText('Firefox'); +} + +test('UAT-3: app renders in desktop Chrome (supported)', async ({ page }) => { + await openApp(page, DESKTOP_CHROME_UA); + await expectApp(page); +}); + +test('UAT-3: app renders in desktop Edge (supported)', async ({ page }) => { + await openApp(page, DESKTOP_EDGE_UA); + await expectApp(page); +}); + +test('UAT-3: app renders in desktop Firefox (supported)', async ({ page }) => { + await openApp(page, DESKTOP_FIREFOX_UA); + await expectApp(page); +}); + +test('UAT-3: app renders in iOS Safari (supported)', async ({ page, context }) => { + await context.setExtraHTTPHeaders({ 'User-Agent': IOS_SAFARI_UA }); + await page.goto('/'); + await page.waitForTimeout(300); + await expectApp(page); +}); + +test('UAT-3: app renders in Android Chrome (supported)', async ({ page, context }) => { + await context.setExtraHTTPHeaders({ 'User-Agent': ANDROID_CHROME_UA }); + await page.goto('/'); + await page.waitForTimeout(300); + await expectApp(page); +}); + +test('UAT-3: hard stop in desktop Opera (unsupported)', async ({ page }) => { + await openApp(page, DESKTOP_OPERA_UA); + await expect(page.locator('.gate h1')).toHaveText('Unsupported browser'); + // The app must NOT render. + await expect(page.locator('main')).toHaveCount(0); + // The hard-stop page lists the spec browsers. + await expect(page.locator('.gate')).toContainText('iOS Safari'); + await expect(page.locator('.gate')).toContainText('Android Chrome'); + await expect(page.locator('.gate')).toContainText('Chrome'); + await expect(page.locator('.gate')).toContainText('Edge'); + await expect(page.locator('.gate')).toContainText('Firefox'); +}); + +test('UAT-3: hard stop in Samsung Internet (unsupported)', async ({ page, context }) => { + await context.setExtraHTTPHeaders({ 'User-Agent': SAMSUNG_INTERNET_UA }); + await page.goto('/'); + await page.waitForTimeout(300); + await expect(page.locator('.gate h1')).toHaveText('Unsupported browser'); + await expect(page.locator('main')).toHaveCount(0); +}); diff --git a/frontend/tests/unit/browserGate.test.js b/frontend/tests/unit/browserGate.test.js new file mode 100644 index 0000000..4702c5d --- /dev/null +++ b/frontend/tests/unit/browserGate.test.js @@ -0,0 +1,201 @@ +/** + * UAT-3: Browser gate (hard stop) — unit tests for the pure logic in + * src/lib/core/browserGate.js. + * + * CAPTURE.md §Browsers: + * Phone: iOS Safari, Android Chrome. + * PC: Chrome, Edge, Firefox. + * Other clients get a hard stop. + */ +import { describe, it, expect } from 'vitest'; +import { detectBrowser, isAllowed, checkBrowserGate } from '$lib/core/browserGate.js'; + +// --- representative user agents ------------------------------------ +const UAs = { + iosSafari: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15', + ipadSafari: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15', + iosChrome: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6336.69 Mobile/15E148 Safari/605.1.15', + androidChrome: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36', + desktopChrome: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + desktopEdge: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0', + desktopFirefox: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + desktopOpera: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + androidOpera: + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 OPR/110.0.0.0', + androidSamsung: + 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36', + iosSamsung: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 SamsungBrowser/124.0.0.0', + androidBrave: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0.0.5336 Mobile Safari/537.36', + unknown: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) CustomBrowser/9.9.0 Safari/537.36', + blank: '', +}; + +describe('detectBrowser', () => { + it('identifies iOS Safari on iPhone', () => { + const b = detectBrowser(UAs.iosSafari); + expect(b.name).toBe('iOS Safari'); + expect(b.platform).toBe('phone'); + }); + + it('identifies iPad Safari (iPadOS 13+ reports as Macintosh)', () => { + const b = detectBrowser(UAs.ipadSafari); + expect(b.name).toBe('Safari'); + expect(b.platform).toBe('pc'); + }); + + it('identifies Android Chrome', () => { + const b = detectBrowser(UAs.androidChrome); + expect(b.name).toBe('Android Chrome'); + expect(b.platform).toBe('phone'); + }); + + it('identifies desktop Chrome', () => { + const b = detectBrowser(UAs.desktopChrome); + expect(b.name).toBe('Chrome'); + expect(b.platform).toBe('pc'); + }); + + it('identifies desktop Edge (not Chrome)', () => { + const b = detectBrowser(UAs.desktopEdge); + expect(b.name).toBe('Microsoft Edge'); + expect(b.platform).toBe('pc'); + }); + + it('identifies desktop Firefox', () => { + const b = detectBrowser(UAs.desktopFirefox); + expect(b.name).toBe('Firefox'); + expect(b.platform).toBe('pc'); + }); + + it('identifies desktop Opera as its own browser (not Chrome)', () => { + const b = detectBrowser(UAs.desktopOpera); + expect(b.name).toBe('Opera'); + expect(b.platform).toBe('pc'); + }); + + it('identifies Android Opera (OPR beats Chrome)', () => { + const b = detectBrowser(UAs.androidOpera); + expect(b.name).toBe('Opera'); + expect(b.platform).toBe('phone'); + }); + + it('identifies Samsung Internet (SamsungBrowser beats Chrome)', () => { + const b = detectBrowser(UAs.androidSamsung); + expect(b.name).toBe('Samsung Internet'); + expect(b.platform).toBe('phone'); + }); + + it('identifies iOS Samsung Internet as unsupported', () => { + const b = detectBrowser(UAs.iosSamsung); + expect(b.name).toBe('Samsung Internet'); + expect(b.platform).toBe('phone'); + }); + + it('treats an unrecognized UA as Unknown', () => { + const b = detectBrowser(UAs.unknown); + expect(b.name).toBe('Unknown'); + expect(b.platform).toBe('pc'); + }); + + it('handles blank / undefined user agents without throwing', () => { + expect(detectBrowser(UAs.blank).name).toBe('Unknown'); + expect(detectBrowser(undefined).name).toBe('Unknown'); + }); +}); + +describe('isAllowed', () => { + it('allows iOS Safari on phone', () => { + expect(isAllowed({ name: 'iOS Safari', platform: 'phone' })).toBe(true); + }); + + it('allows Android Chrome on phone', () => { + expect(isAllowed({ name: 'Android Chrome', platform: 'phone' })).toBe(true); + }); + + it('allows Chrome on PC', () => { + expect(isAllowed({ name: 'Chrome', platform: 'pc' })).toBe(true); + }); + + it('allows Edge on PC', () => { + expect(isAllowed({ name: 'Microsoft Edge', platform: 'pc' })).toBe(true); + }); + + it('allows Firefox on PC', () => { + expect(isAllowed({ name: 'Firefox', platform: 'pc' })).toBe(true); + }); + + it('rejects Opera', () => { + expect(isAllowed({ name: 'Opera', platform: 'pc' })).toBe(false); + }); + + it('rejects Android Opera', () => { + expect(isAllowed({ name: 'Opera', platform: 'phone' })).toBe(false); + }); + + it('rejects Samsung Internet', () => { + expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false); + }); + + it('rejects Samsung Internet on iOS', () => { + expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false); + }); + + it('rejects Brave', () => { + expect(isAllowed({ name: 'Brave', platform: 'phone' })).toBe(false); + }); + + it('rejects Unknown', () => { + expect(isAllowed({ name: 'Unknown', platform: 'pc' })).toBe(false); + }); + + it('rejects phone browsers on PC (impossible but rejected)', () => { + expect(isAllowed({ name: 'iOS Safari', platform: 'pc' })).toBe(false); + expect(isAllowed({ name: 'Android Chrome', platform: 'pc' })).toBe(false); + }); +}); + +describe('checkBrowserGate', () => { + it('supports exactly the five spec browsers + Safari (iPad)', () => { + for (const ua of [ + UAs.iosSafari, + UAs.ipadSafari, + UAs.androidChrome, + UAs.desktopChrome, + UAs.desktopEdge, + UAs.desktopFirefox, + ]) { + expect(checkBrowserGate(ua).supported).toBe(true); + } + }); + + it('hard-stops Opera, Samsung Internet, unknown, blank', () => { + for (const ua of [ + UAs.desktopOpera, + UAs.androidOpera, + UAs.androidSamsung, + UAs.iosSamsung, + UAs.androidBrave, + UAs.unknown, + UAs.blank, + ]) { + expect(checkBrowserGate(ua).supported).toBe(false); + } + }); + + it('returns the detected browser for the UI', () => { + const r = checkBrowserGate(UAs.desktopOpera); + expect(r.browser.name).toBe('Opera'); + expect(r.browser.platform).toBe('pc'); + }); +});