diff --git a/packages/core/src/tools/utils/browserDetection.spec.ts b/packages/core/src/tools/utils/browserDetection.spec.ts new file mode 100644 index 0000000000..ba80ac1374 --- /dev/null +++ b/packages/core/src/tools/utils/browserDetection.spec.ts @@ -0,0 +1,134 @@ +import { combine } from '../mergeInto' +import { Browser, detectBrowser } from './browserDetection' + +describe('browserDetection', () => { + it('detects IE', () => { + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; rv:11.0) like Gecko', + }, + document: { documentMode: 11 }, + }) + ) + ).toBe(Browser.IE) + }) + + it('detects Safari', () => { + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + vendor: 'Apple Computer, Inc.', + }, + }) + ) + ).toBe(Browser.SAFARI) + + // Emulates Safari detection if 'navigator.vendor' is removed one day + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + }, + }) + ) + ).toBe(Browser.SAFARI) + + // Webview on iOS + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/20B110 [FBAN/FBIOS;FBDV/iPhone14,5;FBMD/iPhone;FBSN/iOS;FBSV/16.1.2;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5]', + vendor: 'Apple Computer, Inc.', + }, + }) + ) + ).toBe(Browser.SAFARI) + }) + + it('detects Chromium', () => { + // Google Chrome 118 + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36', + vendor: 'Google Inc.', + }, + chrome: {}, + }) + ) + ).toBe(Browser.CHROMIUM) + + // Headless chrome + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/92.0.4512.0 Safari/537.36', + vendor: 'Google Inc.', + }, + }) + ) + ).toBe(Browser.CHROMIUM) + + // Microsoft Edge 89 + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 Edg/89.0.774.54', + vendor: 'Google Inc.', + }, + chrome: {}, + }) + ) + ).toBe(Browser.CHROMIUM) + }) + + it('other browsers', () => { + // Firefox 10 + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { userAgent: 'Mozilla/5.0 (X11; Linux i686; rv:10.0) Gecko/20100101 Firefox/10.0' }, + }) + ) + ).toBe(Browser.OTHER) + + // Firefox 120 + expect( + detectBrowser( + fakeWindowWithDefaults({ + navigator: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0', + }, + }) + ) + ).toBe(Browser.OTHER) + }) + + function fakeWindowWithDefaults(partial: any): Window { + return combine( + { + navigator: { + userAgent: '', + }, + document: {}, + }, + partial + ) as Window + } +}) diff --git a/packages/core/src/tools/utils/browserDetection.ts b/packages/core/src/tools/utils/browserDetection.ts index 0c5194e17d..e9e5d2ed90 100644 --- a/packages/core/src/tools/utils/browserDetection.ts +++ b/packages/core/src/tools/utils/browserDetection.ts @@ -1,17 +1,48 @@ -let browserIsIE: boolean | undefined +// Exported only for tests +export const enum Browser { + IE, + CHROMIUM, + SAFARI, + OTHER, +} + export function isIE() { - return browserIsIE ?? (browserIsIE = Boolean((document as any).documentMode)) + return detectBrowserCached() === Browser.IE } -let browserIsChromium: boolean | undefined export function isChromium() { - return ( - browserIsChromium ?? - (browserIsChromium = !!(window as any).chrome || /HeadlessChrome/.test(window.navigator.userAgent)) - ) + return detectBrowserCached() === Browser.CHROMIUM } -let browserIsSafari: boolean | undefined export function isSafari() { - return browserIsSafari ?? (browserIsSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)) + return detectBrowserCached() === Browser.SAFARI +} + +let browserCache: Browser | undefined +function detectBrowserCached() { + return browserCache ?? (browserCache = detectBrowser()) +} + +// Exported only for tests +export function detectBrowser(browserWindow: Window = window) { + const userAgent = browserWindow.navigator.userAgent + if ((browserWindow as any).chrome || /HeadlessChrome/.test(userAgent)) { + return Browser.CHROMIUM + } + + if ( + // navigator.vendor is deprecated, but it is the most resilient way we found to detect + // "Apple maintained browsers" (AKA Safari). If one day it gets removed, we still have the + // useragent test as a semi-working fallback. + browserWindow.navigator.vendor?.indexOf('Apple') === 0 || + (/safari/i.test(userAgent) && !/chrome|android/i.test(userAgent)) + ) { + return Browser.SAFARI + } + + if ((browserWindow.document as any).documentMode) { + return Browser.IE + } + + return Browser.OTHER }