diff --git a/src/__tests__/utils/event-utils.test.ts b/src/__tests__/utils/event-utils.test.ts index 013df2ae7..1e996c227 100644 --- a/src/__tests__/utils/event-utils.test.ts +++ b/src/__tests__/utils/event-utils.test.ts @@ -4,29 +4,216 @@ import * as globals from '../../utils/globals' jest.mock('../../utils/globals') describe(`event-utils`, () => { - it('should have $host and $pathname in properties', () => { - const properties = _info.properties() - expect(properties['$current_url']).toBeDefined() - expect(properties['$host']).toBeDefined() - expect(properties['$pathname']).toBeDefined() - }) + describe('properties', () => { + it('should have $host and $pathname in properties', () => { + const properties = _info.properties() + expect(properties['$current_url']).toBeDefined() + expect(properties['$host']).toBeDefined() + expect(properties['$pathname']).toBeDefined() + }) + + it('should have user agent in properties', () => { + // TS doesn't like it but we can assign userAgent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globals['userAgent'] = 'blah' + const properties = _info.properties() + expect(properties['$raw_user_agent']).toBe('blah') + }) - it('should have user agent in properties', () => { - // TS doesn't like it but we can assign userAgent - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - globals['userAgent'] = 'blah' - const properties = _info.properties() - expect(properties['$raw_user_agent']).toBe('blah') + it('should truncate very long user agents in properties', () => { + // TS doesn't like it but we can assign userAgent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globals['userAgent'] = 'a'.repeat(1001) + const properties = _info.properties() + expect(properties['$raw_user_agent'].length).toBe(1000) + expect(properties['$raw_user_agent'].substring(995)).toBe('aa...') + }) }) - it('should truncate very long user agents in properties', () => { - // TS doesn't like it but we can assign userAgent - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - globals['userAgent'] = 'a'.repeat(1001) - const properties = _info.properties() - expect(properties['$raw_user_agent'].length).toBe(1000) - expect(properties['$raw_user_agent'].substring(995)).toBe('aa...') + describe('user agent', () => { + // can use https://user-agents.net/ or $raw_user_agent property on events to get new test cases + const browserTestcases: { + name: string + userAgent: string + vendor: string + expectedVersion: number | null + expectedBrowser: string + }[] = [ + { + name: 'Chrome 91', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + vendor: '', + expectedVersion: 91.0, + expectedBrowser: 'Chrome', + }, + { + name: 'Firefox 89', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + vendor: '', + expectedVersion: 89.0, + expectedBrowser: 'Firefox', + }, + { + name: 'unknown browser', + userAgent: 'UnknownBrowser/5.0', + vendor: '', + expectedVersion: null, + expectedBrowser: '', + }, + { + name: 'invalid chrome', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari/537.36', + vendor: '', + expectedVersion: null, + expectedBrowser: 'Chrome', + }, + { + name: 'Internet Explorer Mobile', + userAgent: + 'Mozilla/5.0 (Windows Phone 8.1; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; 909) like Gecko', + vendor: '', + expectedVersion: 11.0, + expectedBrowser: 'Internet Explorer Mobile', + }, + { + name: 'Microsoft Edge 44', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/44.17763.831.0', + vendor: '', + expectedVersion: 44.17763, + expectedBrowser: 'Microsoft Edge', + }, + { + name: 'Chrome 21 iOS', + userAgent: + 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/21.0.1180.82 Mobile/9B206 Safari/7534.48.3', + vendor: '', + expectedVersion: 21.0, + expectedBrowser: 'Chrome iOS', + }, + { + name: 'UC Browser', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.2.2; en-US; Micromax A116 Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/10.7.5.658 U3/0.8.0 Mobile Safari/534.30', + vendor: '', + expectedVersion: 10.7, + expectedBrowser: 'UC Browser', + }, + { + name: 'Safari', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', + vendor: 'Apple', + expectedVersion: 17.1, + expectedBrowser: 'Safari', + }, + { + name: 'Opera', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.69 Safari/537.36 OPR/34.0.2036.25', + vendor: '', + expectedVersion: 34.0, + expectedBrowser: 'Opera', + }, + { + name: 'Firefox iOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) FxiOS/8.3b5826 Mobile/14E5239e Safari/602.1.50', + vendor: '', + expectedVersion: 8.3, + expectedBrowser: 'Firefox iOS', + }, + { + name: 'Konqueror (lowercase)', + userAgent: 'Mozilla/5.0 (X11; U; U; DragonFly amd64) KIO/5.97 konqueror/22.08.0', + vendor: '', + expectedVersion: 22.08, + expectedBrowser: 'Konqueror', + }, + { + name: 'Konqueror (uppercase)', + userAgent: 'Mozilla/5.0 (X11; Linux i686) KHTML/5.20 (like Gecko) Konqueror/5.20', + vendor: '', + expectedVersion: 5.2, + expectedBrowser: 'Konqueror', + }, + { + name: 'BlackBerry Bold 9790', + userAgent: + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9790; es) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.569 Mobile Safari/534.11+', + vendor: '', + // TODO should we match 9790 the model and not 7.1 the browser version? + expectedVersion: 9790, + expectedBrowser: 'BlackBerry', + }, + { + name: 'BlackBerry BB10 version v10.1', + userAgent: + 'Mozilla/5.0 (BB10; Kbd) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.1.0.1720 Mobile Safari/537.10+', + vendor: '', + expectedVersion: 10.1, + expectedBrowser: 'BlackBerry', + }, + { + name: 'Android Mobile', + userAgent: + 'Mozilla/5.0 (Linux; StarOS Must use __system_property_read_callback() to read; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.127 Mobile Safari/537.36', + vendor: '', + // TODO should we detect this as Chrome or Android Mobile? + expectedVersion: 100, + expectedBrowser: 'Chrome', + }, + { + name: 'Samsung Internet', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0.2; SAMSUNG SM-T550 Build/LRX22G) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/3.5 Chrome/38.0.2125.102 Safari/537.36', + vendor: '', + expectedVersion: 3.5, + expectedBrowser: 'Samsung Internet', + }, + { + name: 'Internet Explorer', + userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0)', + vendor: '', + expectedVersion: 10.0, + expectedBrowser: 'Internet Explorer', + }, + { + name: 'mobile safari (with vendor)', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + vendor: 'Apple', + expectedVersion: 16.6, + expectedBrowser: 'Mobile Safari', + }, + { + name: 'mobile safari (without vendor)', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + vendor: '', // vendor is deprecated, and we see this user agent not matching in the wild + expectedVersion: 16.6, + expectedBrowser: 'Mobile Safari', + }, + { + name: 'firefox for ios', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/106.0 Mobile/15E148 Safari/605.1.15', + vendor: '', + expectedVersion: 106.0, + expectedBrowser: 'Firefox iOS', + }, + ] + + test.each(browserTestcases)('browser version %s', ({ userAgent, vendor, expectedVersion }) => { + expect(_info.browserVersion(userAgent, vendor, '')).toBe(expectedVersion) + }) + + test.each(browserTestcases)('browser %s', ({ userAgent, vendor, expectedBrowser }) => { + expect(_info.browser(userAgent, vendor, '')).toBe(expectedBrowser) + }) }) }) diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts index 9b46ee3de..b3a629d57 100644 --- a/src/utils/event-utils.ts +++ b/src/utils/event-utils.ts @@ -5,6 +5,15 @@ import Config from '../config' import { _each, _extend, _includes, _strip_empty_properties, _timestamp } from './index' import { document, userAgent } from './globals' +/** + * Safari detection turns out to be complicted. For e.g. https://stackoverflow.com/a/29696509 + * We can be slightly loose because some options have been ruled out (e.g. firefox on iOS) + * before this check is made + */ +function isSafari(userAgent: string): boolean { + return _includes(userAgent, 'Safari') && !_includes(userAgent, 'Chrome') && !_includes(userAgent, 'Android') +} + export const _info = { campaignParams: function (customParams?: string[]): Record { const campaign_keywords = [ @@ -68,7 +77,7 @@ export const _info = { * The order of the checks are important since many user agents * include key words used in later checks. */ - browser: function (user_agent: string, vendor: string, opera?: any): string { + browser: function (user_agent: string, vendor: string | undefined, opera?: any): string { vendor = vendor || '' // vendor is undefined for at least IE9 if (opera || _includes(user_agent, ' OPR/')) { if (_includes(user_agent, 'Mini')) { @@ -94,14 +103,14 @@ export const _info = { return 'UC Browser' } else if (_includes(user_agent, 'FxiOS')) { return 'Firefox iOS' - } else if (_includes(vendor, 'Apple')) { + } else if (_includes(vendor, 'Apple') || isSafari(user_agent)) { if (_includes(user_agent, 'Mobile')) { return 'Mobile Safari' } return 'Safari' } else if (_includes(user_agent, 'Android')) { return 'Android Mobile' - } else if (_includes(user_agent, 'Konqueror')) { + } else if (_includes(user_agent, 'Konqueror') || _includes(user_agent, 'konqueror')) { return 'Konqueror' } else if (_includes(user_agent, 'Firefox')) { return 'Firefox' @@ -118,36 +127,44 @@ export const _info = { * This function detects which browser version is running this script, * parsing major and minor version (e.g., 42.1). User agent strings from: * http://www.useragentstring.com/pages/useragentstring.php + * + * `navigator.vendor` is passed in and used to help with detecting certain browsers + * NB `navigator.vendor` is deprecated and not present in every browser */ - browserVersion: function (userAgent: string, vendor: string, opera: string): number | null { + browserVersion: function (userAgent: string, vendor: string | undefined, opera: string): number | null { const browser = _info.browser(userAgent, vendor, opera) - const versionRegexs = { - 'Internet Explorer Mobile': /rv:(\d+(\.\d+)?)/, - 'Microsoft Edge': /Edge?\/(\d+(\.\d+)?)/, - Chrome: /Chrome\/(\d+(\.\d+)?)/, - 'Chrome iOS': /CriOS\/(\d+(\.\d+)?)/, - 'UC Browser': /(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/, - Safari: /Version\/(\d+(\.\d+)?)/, - 'Mobile Safari': /Version\/(\d+(\.\d+)?)/, - Opera: /(Opera|OPR)\/(\d+(\.\d+)?)/, - Firefox: /Firefox\/(\d+(\.\d+)?)/, - 'Firefox iOS': /FxiOS\/(\d+(\.\d+)?)/, - Konqueror: /Konqueror:(\d+(\.\d+)?)/, - BlackBerry: /BlackBerry (\d+(\.\d+)?)/, - 'Android Mobile': /android\s(\d+(\.\d+)?)/, - 'Samsung Internet': /SamsungBrowser\/(\d+(\.\d+)?)/, - 'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)?)/, - Mozilla: /rv:(\d+(\.\d+)?)/, + const versionRegexes: Record = { + 'Internet Explorer Mobile': [/rv:(\d+(\.\d+)?)/], + 'Microsoft Edge': [/Edge?\/(\d+(\.\d+)?)/], + Chrome: [/Chrome\/(\d+(\.\d+)?)/], + 'Chrome iOS': [/CriOS\/(\d+(\.\d+)?)/], + 'UC Browser': [/(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/], + Safari: [/Version\/(\d+(\.\d+)?)/], + 'Mobile Safari': [/Version\/(\d+(\.\d+)?)/], + Opera: [/(Opera|OPR)\/(\d+(\.\d+)?)/], + Firefox: [/Firefox\/(\d+(\.\d+)?)/], + 'Firefox iOS': [/FxiOS\/(\d+(\.\d+)?)/], + Konqueror: [/Konqueror[:/]?(\d+(\.\d+)?)/i], + // not every blackberry user agent has the version after the name + BlackBerry: [/BlackBerry (\d+(\.\d+)?)/, /Version\/(\d+(\.\d+)?)/], + 'Android Mobile': [/android\s(\d+(\.\d+)?)/], + 'Samsung Internet': [/SamsungBrowser\/(\d+(\.\d+)?)/], + 'Internet Explorer': [/(rv:|MSIE )(\d+(\.\d+)?)/], + Mozilla: [/rv:(\d+(\.\d+)?)/], } - const regex: RegExp | undefined = versionRegexs[browser as keyof typeof versionRegexs] - if (_isUndefined(regex)) { + const regexes: RegExp[] | undefined = versionRegexes[browser as keyof typeof versionRegexes] + if (_isUndefined(regexes)) { return null } - const matches = userAgent.match(regex) - if (!matches) { - return null + + for (let i = 0; i < regexes.length; i++) { + const regex = regexes[i] + const matches = userAgent.match(regex) + if (matches) { + return parseFloat(matches[matches.length - 2]) + } } - return parseFloat(matches[matches.length - 2]) + return null }, browserLanguage: function (): string {