import { fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx' const IPHONE_SAFARI = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1' const IPAD_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15' const MAC_SAFARI = IPAD_SAFARI const IPHONE_CHROME = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1' const IPHONE_FIREFOX = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15' const IPHONE_EDGE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15' const IPHONE_OPERA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0' const IPHONE_MQQBROWSER = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1' const ANDROID_CHROME = 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36' const DESKTOP_CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' const copy = 'Tap Share, then Add to Home Screen to get notifications' type Device = { userAgent: string maxTouchPoints?: number // display-mode: standalone displayStandalone?: boolean // navigator.standalone (Safari only; undefined elsewhere) navigatorStandalone?: boolean } // mockDevice pretends to be a browser with the given user agent and // display mode; jsdom has no matchMedia, navigator.maxTouchPoints or // navigator.standalone. function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent) Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints }) Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone }) vi.stubGlobal('matchMedia', (query: string) => { if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`) return { matches: displayStandalone, media: query } as MediaQueryList }) } afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() Reflect.deleteProperty(navigator, 'maxTouchPoints') Reflect.deleteProperty(navigator, 'standalone') localStorage.clear() }) describe('InstallHint', () => { it('shows on iPhone Safari in a browser tab', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) render() expect(screen.getByRole('note')).toHaveTextContent(copy) }) it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => { mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) render() expect(screen.getByRole('note')).toHaveTextContent(copy) }) it('is hidden when navigator.standalone is true', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true }) render() expect(screen.queryByRole('note')).toBeNull() }) it('is hidden when display-mode is standalone', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true }) render() expect(screen.queryByRole('note')).toBeNull() }) it.each([ ['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }], ['desktop Chrome', { userAgent: DESKTOP_CHROME }], ['desktop Safari on a Mac', { userAgent: MAC_SAFARI }], ['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }], ['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }], ['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }], ['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }], ['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }], ])('is hidden on %s', (_name, device: Device) => { mockDevice(device) render() expect(screen.queryByRole('note')).toBeNull() }) it('stays dismissed on this device', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) const first = render() fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) expect(screen.queryByRole('note')).toBeNull() expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') first.unmount() render() expect(screen.queryByRole('note')).toBeNull() }) it('stays up and reports the error when the dismissal cannot be saved', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('quota exceeded', 'QuotaExceededError') }) render() fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) expect(screen.getByRole('note')).toHaveTextContent(copy) expect(screen.getByRole('alert')).toHaveTextContent( 'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded', ) setItem.mockRestore() fireEvent.click(screen.getByRole('button', { name: 'Retry' })) expect(screen.queryByRole('note')).toBeNull() expect(screen.queryByRole('alert')).toBeNull() expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') }) it('shows and reports the error when storage cannot be read', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('storage blocked', 'SecurityError') }) render() expect(screen.getByRole('note')).toHaveTextContent(copy) expect(screen.getByRole('alert')).toHaveTextContent( 'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked', ) getItem.mockRestore() fireEvent.click(screen.getByRole('button', { name: 'Retry' })) expect(screen.getByRole('note')).toHaveTextContent(copy) expect(screen.queryByRole('alert')).toBeNull() }) })