UAT-3: browser gate (hard stop) + iPad Safari fix

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
This commit is contained in:
Ty
2026-09-14 07:44:49 -07:00
parent a83c9239ff
commit 0d06e6f7b7
4 changed files with 470 additions and 5 deletions
+109
View File
@@ -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 <slot> 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 <main> 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 <main> 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);
});
+201
View File
@@ -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');
});
});