├── .babelrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.ts ├── logo.svg ├── package.json ├── tests └── index.test.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ ["@babel/preset-env", {"targets": {"node": "current"}}], "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [14.x, 16.x, 18.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: yarn install 19 | - run: yarn typecheck 20 | - run: yarn build 21 | - run: yarn test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | lib 4 | .yarn/cache 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browserslist/browserslist-useragent/b767e4d53dc712715ca9fe2d0e440b755da652c2/.npmignore -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn/cache -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 3 | spec: "@yarnpkg/plugin-interactive-tools" 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Please use [github releases page](https://github.com/browserslist/browserslist-useragent/releases) to see changes for future versions. 2 | 3 | # v3.0.0 4 | 5 | - **Fix**: Don't crash when `options` parameter is not specified 6 | - **Fix**: Use `loose` option to parse browsers versions. Doesn't crash on versions like `6.00.1` or 6.0.0005` etc. 7 | 8 | - **Feature**: Added support for Samsung Internet 9 | 10 | - **Breaking**: Safari on desktop and on iOS are now considered as separate browsers. 11 | You'll need to use separate browserslist queries to target both of them - `['Safari >= 10', 'iOS >= 10.1']`. See #23 12 | - **Breaking**: Removing support for undocumented option to `matchesUA` - `all`. 13 | This was used internally, and shouldn't be breaking change for you unless you poked around code to discover it. 14 | - **Breaking**: Remove deprecated `_allowHigherVersions` flag. This has been replaced by `allowHigherVersions` in the past. 15 | 16 | # v2.0.1 17 | 18 | - Remove deprecated \_allowHigherVersions option from README 19 | 20 | # v2.0.0 21 | 22 | - **Feature**: Added Yandex to list of supported browsers 23 | - **Feature**: Added support for Yandex Browser 24 | - **Deprecation**: Deprecated `_allowHigherVersions` 25 | 26 | #v1.2.1 27 | 28 | - Fixed build link 29 | 30 | #v1.2.0 31 | 32 | - **Feature**: Updated packages and added support for headless chrome and chromium 33 | - Fixed links in badges 34 | 35 | #v1.1.0 36 | 37 | - **Feature**: Added support for selecting environment 38 | - Updated browserslist version 39 | 40 | #v1.0.4 41 | 42 | - delete gitignore 43 | 44 | #v1.0.3 45 | 46 | - Update README.md 47 | 48 | #v1.0.2 49 | 50 | - Second try at fixing logo 51 | 52 | #v1.0.1 53 | 54 | - Updated browserslist version 55 | 56 | #v1.0.0 57 | 58 | - Create LICENSE 59 | - /browserlist/ => browserslist 60 | - Refactored code and rewrote Readme 61 | 62 | #v0.0.2 63 | 64 | - Added install instructions 65 | - Initial commit 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Shubham Kanodia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Browserslist Useragent 2 | 3 | ![build](https://github.com/pastelsky/browserslist-useragent/actions/workflows/main.yml/badge.svg) 4 | [![npm](https://img.shields.io/npm/v/browserslist-useragent.svg)](https://www.npmjs.com/package/browserslist-useragent) 5 | 6 | Browserslist Useragent logo (original by Anton Lovchikov) 8 | 9 | Find if a given user agent string satisfies a [browserslist](https://github.com/ai/browserslist) query. 10 | 11 | It automatically reads the browserslist configuration specified in your project, 12 | but you can also specify the same using the `options` parameter. 13 | 14 | **If you wish to target modern browsers, read [this](#when-querying-for-modern-browsers).** 15 | 16 | ## Installation 17 | 18 | Note, `browserslist` is a peer dependency, so make sure you have that installed in your project. 19 | 20 | ```bash 21 | npm install browserslist-useragent 22 | # or 23 | yarn add browserslist-useragent 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```js 29 | const { matchesUA } = require('browserslist-useragent') 30 | 31 | matchesUA(userAgentString, options) 32 | 33 | // with browserslist config inferred 34 | matchesUA('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0') 35 | //returns boolean 36 | 37 | // with explicit browserslist 38 | matchesUA( 39 | 'Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0', 40 | { browsers: ['Firefox > 53'] } 41 | ) 42 | // returns true 43 | ``` 44 | 45 | | Option | Default Value | Description | 46 | | ------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 47 | | browsers | — | Manually provide a browserslist query (or an array of queries). Specifying this overrides the browserslist configuration specified in your project. | 48 | | env | — | When multiple browserslist [environments](https://github.com/ai/browserslist#environments) are specified, pick the config belonging to this environment. | 49 | | path | `process.cwd()` | Specify a folder to search for the browserslist config (if it differs from the current working directory) | 50 | | ignorePatch | `true` | Ignore differences in patch browser numbers | 51 | | ignoreMinor | `false` | Ignore differences in minor browser versions | 52 | | allowHigherVersions | `false` | For all the browsers in the browserslist query, return a match if the user agent version is equal to or higher than the one specified in browserslist. See [why](#when-querying-for-modern-browsers) this might be useful. | 53 | 54 | ## Supported browsers 55 | 56 | - Chrome (Chrome / Chromium / Yandex) as `and_chr` | `ChromeAndroid` | `Chrome` 57 | - Samsung Internet as `Samsung` 58 | - Firefox as `ff` | `and_ff` | `FirefoxAndroid` | `Firefox` 59 | - Safari iOS as `ios_saf` | `iOS` 60 | - Safari Desktop as `Safari` 61 | - IE as `ie` | `ie_mob` 62 | - Edge as `Edge` 63 | - Electron as `Electron` 64 | 65 | PRs to add more _browserslist supported_ browsers are welcome 👋 66 | 67 | ## Notes 68 | 69 | - All browsers on iOS (Chrome, Firefox etc) use Safari's WebKit as the underlying engine, and hence will be resolved to Safari. Since `browserslist` is usually used for 70 | transpiling / autoprefixing for browsers, this behaviour is what's intended in most cases, but might surprise you otherwise. 71 | 72 | - Right now, Chrome for Android and Firefox for Android are resolved to their desktop equivalents. The `caniuse` database does not currently store historical data for these browsers separately (except the last version) See [#156](https://github.com/ai/browserslist/issues/156). However, 73 | safari for iOS and desktop can be matched separately, since this data is available for both. 74 | 75 | ## When querying for modern browsers 76 | 77 | - It is a good idea to update this package often so that browser definitions are upto date. 78 | - It is also a good idea to add `unreleased versions` to your browserslist query, and set `ignoreMinor` and `ignorePatch` to true so that alpha / beta / canary versions of browsers are matched. 79 | - In case you're unable to keep this package up-to-date, you can set the `allowHigherVersions` to `true`. For all the browsers specified in your browserslist query, this will return a match if the user agent version is equal to or higher than those specified in your browserslist query. Use this with care though, since it's a wildcard, and only lightly tested. 80 | 81 | ## Further reads 82 | 83 | - [Smart Bundling: Shipping legacy code to only legacy browsers](https://www.smashingmagazine.com/2018/10/smart-bundling-legacy-code-browsers/) 84 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import browserslist from 'browserslist'; 2 | import semver from 'semver'; 3 | import UAParser from 'ua-parser-js'; 4 | 5 | // @see https://github.com/ai/browserslist#browsers 6 | 7 | // map of equivalent browsers, 8 | // see https://github.com/ai/browserslist/issues/156 9 | 10 | const browserNameMap: Record = { 11 | bb: 'BlackBerry', 12 | and_chr: 'Chrome', 13 | ChromeAndroid: 'Chrome', 14 | FirefoxAndroid: 'Firefox', 15 | ff: 'Firefox', 16 | ie_mob: 'ExplorerMobile', 17 | ie: 'Explorer', 18 | and_ff: 'Firefox', 19 | ios_saf: 'iOS', 20 | op_mini: 'OperaMini', 21 | op_mob: 'OperaMobile', 22 | and_qq: 'QQAndroid', 23 | and_uc: 'UCAndroid', 24 | } 25 | 26 | function resolveUserAgent(uaString: string): { family: string | null, version: string | null } { 27 | const parsedUA = UAParser(uaString) 28 | const parsedBrowserVersion = semverify(parsedUA.browser.version) 29 | const parsedOSVersion = semverify(parsedUA.os.version) 30 | const parsedEngineVersion = semverify(parsedUA.engine.version) 31 | 32 | // Case A: For Safari on iOS, the use the browser version 33 | if ( 34 | parsedUA.browser.name === 'Safari' && parsedUA.os.name === 'iOS') { 35 | return { 36 | family: 'iOS', 37 | version: parsedBrowserVersion, 38 | } 39 | } 40 | 41 | // Case B: The browser on iOS didn't report as safari, 42 | // so we use the iOS version as a proxy to the browser 43 | // version. This is based on the assumption that the 44 | // underlying Safari Engine used will be *atleast* equal 45 | // to the iOS version it's running on. 46 | if (parsedUA.os.name === 'iOS') { 47 | return { 48 | family: 'iOS', 49 | version: parsedOSVersion 50 | } 51 | } 52 | 53 | if ( 54 | (parsedUA.browser.name === 'Opera' && parsedUA.device.type === 'mobile') || 55 | parsedUA.browser.name === 'Opera Mobi' 56 | ) { 57 | return { 58 | family: 'OperaMobile', 59 | version: parsedBrowserVersion 60 | } 61 | } 62 | 63 | if (parsedUA.browser.name === 'Samsung Browser') { 64 | return { 65 | family: 'Samsung', 66 | version: parsedBrowserVersion 67 | } 68 | } 69 | 70 | if (parsedUA.browser.name === 'IE') { 71 | return { 72 | family: 'Explorer', 73 | version: parsedBrowserVersion 74 | } 75 | } 76 | 77 | if (parsedUA.browser.name === 'IEMobile') { 78 | return { 79 | family: 'ExplorerMobile', 80 | version: parsedBrowserVersion 81 | } 82 | } 83 | 84 | // Use engine version for gecko-based browsers 85 | if (parsedUA.engine.name === 'Gecko') { 86 | return { 87 | family: 'Firefox', 88 | version: parsedEngineVersion 89 | } 90 | } 91 | 92 | // Use engine version for blink-based browsers 93 | if (parsedUA.engine.name === 'Blink') { 94 | return { 95 | family: 'Chrome', 96 | version: parsedEngineVersion 97 | } 98 | } 99 | 100 | // Chrome based browsers pre-blink (WebKit) 101 | if ( 102 | parsedUA.browser.name && 103 | ['Chrome', 'Chromium', 'Chrome WebView', 'Chrome Headless'].includes(parsedUA.browser.name) 104 | ) { 105 | return { 106 | family: 'Chrome', 107 | version: parsedBrowserVersion 108 | } 109 | } 110 | 111 | if (parsedUA.browser.name === 'Android Browser') { 112 | // Versions prior to Blink were based 113 | // on the OS version. Only after this 114 | // did android start using system chrome for web-views 115 | return { 116 | family: 'Android', 117 | version: parsedOSVersion 118 | } 119 | } 120 | 121 | return { 122 | family: parsedUA.browser.name || null, 123 | version: parsedBrowserVersion 124 | } 125 | } 126 | 127 | // Convert version to a semver value. 128 | // 2.5 -> 2.5.0; 1 -> 1.0.0; 129 | const semverify = (version: string | undefined | null) => { 130 | if (!version) { 131 | return null 132 | } 133 | const cooerced = semver.coerce(version, { loose: true }) 134 | if (!cooerced) { 135 | return null 136 | } 137 | return cooerced.version 138 | } 139 | 140 | // 10.0-10.2 -> 10.0, 10.1, 10.2 141 | function generateSemversInRange(versionRange: string) { 142 | const [start, end] = versionRange.split('-') 143 | const startSemver = semverify(start) 144 | const endSemver = semverify(end) 145 | 146 | if (!startSemver || !endSemver) { 147 | return [] 148 | } 149 | const versionsInRange = [] 150 | let curVersion = startSemver 151 | 152 | while (semver.gte(endSemver, curVersion)) { 153 | versionsInRange.push(curVersion) 154 | curVersion = semver.inc(curVersion, 'minor') as string 155 | } 156 | 157 | return versionsInRange 158 | } 159 | 160 | function normalizeQuery(query: string) { 161 | let normalizedQuery = query 162 | const regex = `(${Object.keys(browserNameMap).join('|')})` 163 | const match = query.match(new RegExp(regex)) 164 | 165 | if (match) { 166 | normalizedQuery = query.replace(match[0], browserNameMap[match[0]]) 167 | } 168 | 169 | return normalizedQuery 170 | } 171 | 172 | const parseBrowsersList = (browsersList: string[]): { family: string, version: string | null }[] => { 173 | const browsers = browsersList 174 | .map((browser) => { 175 | const [name, version] = browser.split(' ') 176 | return { name, version } 177 | }) 178 | // #38 Filter out non-numerical browser versions 179 | .filter((browser) => browser.version !== 'TP') 180 | .map((browser) => { 181 | let normalizedName = browser.name 182 | let normalizedVersion = browser.version 183 | 184 | if (browser.name in browserNameMap) { 185 | normalizedName = browserNameMap[browser.name] 186 | } 187 | 188 | // browserslist might return ranges (9.0-9.2), unwrap them 189 | // see https://github.com/browserslist/browserslist-useragent/issues/41 190 | if (browser.version.indexOf('-') > 0) { 191 | return generateSemversInRange(browser.version).map((version) => ({ 192 | family: normalizedName, 193 | version, 194 | })) 195 | } else { 196 | return { 197 | family: normalizedName, 198 | version: normalizedVersion, 199 | } 200 | } 201 | }) 202 | 203 | return browsers.flat() 204 | } 205 | 206 | const compareBrowserSemvers = (versionA: string, versionB: string, options: Options) => { 207 | const semverifiedA = semverify(versionA) 208 | const semverifiedB = semverify(versionB) 209 | 210 | if (!semverifiedA || !semverifiedB) { 211 | return false 212 | } 213 | let referenceVersion = semverifiedB 214 | 215 | if (options.ignorePatch) { 216 | referenceVersion = `~${semverifiedB}` 217 | } 218 | 219 | if (options.ignoreMinor) { 220 | referenceVersion = `^${semverifiedB}` 221 | } 222 | 223 | if (options.allowHigherVersions) { 224 | return semver.gte(semverifiedA, semverifiedB) 225 | } else { 226 | return semver.satisfies(semverifiedA, referenceVersion) 227 | } 228 | } 229 | 230 | type Options = { 231 | browsers?: string[], 232 | env?: string, 233 | path?: string, 234 | ignoreMinor?: boolean, 235 | ignorePatch?: boolean, 236 | allowHigherVersions?: boolean 237 | } 238 | 239 | const matchesUA = (uaString: string, opts: Options = {}) => { 240 | // bail out early if the user agent is invalid 241 | if (!uaString) { 242 | return false 243 | } 244 | 245 | let normalizedQuery 246 | if (opts.browsers) { 247 | normalizedQuery = opts.browsers.map(normalizeQuery) 248 | } 249 | const browsers = browserslist(normalizedQuery, { 250 | env: opts.env, 251 | path: opts.path || process.cwd(), 252 | }) 253 | 254 | 255 | const parsedBrowsers = parseBrowsersList(browsers) 256 | 257 | 258 | const resolvedUserAgent = resolveUserAgent(uaString) 259 | 260 | const options = { 261 | ignoreMinor: false, 262 | ignorePatch: true, 263 | ...opts, 264 | } 265 | 266 | return parsedBrowsers.some((browser) => { 267 | if (!resolvedUserAgent.family) return false 268 | if (!resolvedUserAgent.version) return false 269 | if (!browser.version) return false 270 | 271 | 272 | return ( 273 | browser.family.toLowerCase() === 274 | resolvedUserAgent.family.toLocaleLowerCase() && 275 | compareBrowserSemvers(resolvedUserAgent.version, browser.version, options) 276 | ) 277 | }) 278 | } 279 | 280 | export { 281 | matchesUA, 282 | resolveUserAgent, 283 | normalizeQuery, 284 | } 285 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browserslist-useragent", 3 | "version": "4.0.0", 4 | "description": "A utility to match a browselist query to browser user agents", 5 | "main": "lib/index.js", 6 | "repository": "https://github.com/pastelsky/browserslist-useragent", 7 | "author": "Shubham Kanodia ", 8 | "publishConfig": { 9 | "registry": "https://registry.npmjs.org" 10 | }, 11 | "license": "MIT", 12 | "files": [ 13 | "./lib/index.js", 14 | "./lib/index.d.ts", 15 | "tsconfig.json" 16 | ], 17 | "engines": { 18 | "node": ">= 6.x.x" 19 | }, 20 | "scripts": { 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "typecheck": "tsc --noEmit", 24 | "prepublish": "yarn run build", 25 | "build": "mkdir -p lib && tsc" 26 | }, 27 | "dependencies": { 28 | "semver": "^7.3.5", 29 | "ua-parser-js": "^1.0.32" 30 | }, 31 | "peerDependencies": { 32 | "browserslist": "^4.0.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/preset-env": "^7.19.4", 36 | "@babel/preset-typescript": "^7.18.6", 37 | "@types/jest": "^29.1.2", 38 | "@types/node": "^18.11.0", 39 | "@types/semver": "^7.3.12", 40 | "@types/ua-parser-js": "^0.7.36", 41 | "@types/useragent": "^2.3.1", 42 | "babel-cli": "^6.26.0", 43 | "browserslist": "^4.19.1", 44 | "jest": "^29.2.0", 45 | "prettier": "^2.5.1", 46 | "typescript": "^4.8.4", 47 | "useragent-generator": "^1.1.1-amkt-22079-finish.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | const ua = require('useragent-generator') 2 | 3 | const { resolveUserAgent, matchesUA, normalizeQuery } = require('../index') 4 | 5 | const CustomUserAgentString = { 6 | YANDEX: 7 | 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 YaBrowser/18.1.1.839 Yowser/2.5 Safari/537.36', 8 | YANDEX_SEARCH: 9 | 'Mozilla/5.0 (Linux; Android 4.4.4; GT-I9300I Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile Safari/537.36 YandexSearch/7.16', 10 | SAMSUNG_BROWSER_7_2: 11 | 'Mozilla/5.0 (Linux; Android 6.0.1; SAMSUNG SM-N930F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/7.2 Chrome/63.0.3239.111 Mobile Safari/537.36', 12 | FACEBOOK_WEBVIEW_CHROME_ANDROID: 13 | 'Mozilla/5.0 (Linux; Android 8.0.0; LG-H930 Build/OPR1.170623.026; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/69.0.3497.100 Mobile Safari/537.36 [FB_IAB/Orca-Android;FBAV/189.0.0.27.99;]', 14 | FACEBOOK_WEBVIEW_IOS: 15 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_2 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12D508 [FBAN/FBIOS;FBAV/27.0.0.10.12;FBBV/8291884;FBDV/iPhone7,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/8.2;FBSS/3; FBCR/vodafoneIE;FBID/phone;FBLC/en_US;FBOP/5]', 16 | OPERA_MOBILE: 17 | 'Opera/9.80 (Android 2.3.3; Linux; Opera Mobi/ADR-1111101157; U; es-ES) Presto/2.9.201 Version/11.50', 18 | ELECTRON: 19 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_4_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Electron/12.0.13 Safari/537.36', 20 | } 21 | 22 | it('normalizes queries properly', () => { 23 | expect(normalizeQuery('and_chr >= 61')).toBe('Chrome >= 61') 24 | 25 | expect(normalizeQuery('ChromeAndroid >= 61')).toBe('Chrome >= 61') 26 | 27 | expect(normalizeQuery('FirefoxAndroid < 54')).toBe('Firefox < 54') 28 | 29 | expect(normalizeQuery('ff < 54')).toBe('Firefox < 54') 30 | 31 | expect(normalizeQuery('ios_saf < 10.1.0')).toBe('iOS < 10.1.0') 32 | 33 | expect(normalizeQuery('last 10 and_chr versions')).toBe( 34 | 'last 10 Chrome versions' 35 | ) 36 | 37 | expect(normalizeQuery('>= 5%')).toBe('>= 5%') 38 | }) 39 | 40 | it('resolves all browsers in iOS to safari with correct platform version', () => { 41 | expect(resolveUserAgent(ua.chrome.iOS('10.3.0'))).toEqual({ 42 | family: 'iOS', 43 | version: '10.3.0', 44 | }) 45 | 46 | expect(resolveUserAgent(ua.firefox.iOS('10.3.0'))).toEqual({ 47 | family: 'iOS', 48 | version: '10.3.0', 49 | }) 50 | 51 | expect(resolveUserAgent(ua.safari.iOSWebview('10.3.0'))).toEqual({ 52 | family: 'iOS', 53 | version: '10.3.0', 54 | }) 55 | 56 | expect(resolveUserAgent(ua.safari.iOS('8.3.0'))).toEqual({ 57 | family: 'iOS', 58 | version: '8.3.0', 59 | }) 60 | }) 61 | 62 | it('resolves desktop safari on osx properly', () => { 63 | expect(resolveUserAgent(ua.safari('10.1.0'))).toEqual({ 64 | family: 'Safari', 65 | version: '10.1.0', 66 | }) 67 | }) 68 | 69 | it('resolves IE/Edge properly', () => { 70 | expect(resolveUserAgent(ua.ie('11.0.0'))).toEqual({ 71 | family: 'Explorer', 72 | version: '11.0.0', 73 | }) 74 | 75 | expect(resolveUserAgent(ua.ie.windowsPhone('10.0.0'))).toEqual({ 76 | family: 'ExplorerMobile', 77 | version: '10.0.0', 78 | }) 79 | 80 | expect(resolveUserAgent(ua.edge('14.1.0'))).toEqual({ 81 | family: 'Edge', 82 | version: '14.1.0', 83 | }) 84 | }) 85 | 86 | it('resolves chrome/android properly', () => { 87 | expect(resolveUserAgent(ua.chrome('41.0.228.90'))).toEqual({ 88 | family: 'Chrome', 89 | version: '41.0.228', 90 | }) 91 | 92 | expect(resolveUserAgent(ua.chrome.androidWebview('2.3.3'))).toEqual({ 93 | family: 'Android', 94 | version: '2.3.3', 95 | }) 96 | 97 | 98 | expect( 99 | resolveUserAgent( 100 | ua.chrome.androidWebview({ 101 | androidVersion: '4.4.1', 102 | chromeVersion: '44.0.0', 103 | }) 104 | ) 105 | ).toEqual({ 106 | family: 'Chrome', 107 | version: '44.0.0', 108 | }) 109 | 110 | expect( 111 | resolveUserAgent( 112 | ua.chrome.androidWebview({ 113 | androidVersion: '6.0.0', 114 | chromeVersion: '60.0.0', 115 | }) 116 | ) 117 | ).toEqual({ 118 | family: 'Chrome', 119 | version: '60.0.0', 120 | }) 121 | 122 | expect( 123 | resolveUserAgent( 124 | ua.chrome('41.0.228.90').replace('Chrome', 'HeadlessChrome') 125 | ) 126 | ).toEqual({ 127 | family: 'Chrome', 128 | version: '41.0.228', 129 | }) 130 | 131 | expect(resolveUserAgent(ua.chromium('41.0.228.90'))).toEqual({ 132 | family: 'Chrome', 133 | version: '41.0.228', 134 | }) 135 | 136 | expect( 137 | matchesUA(CustomUserAgentString.YANDEX, { browsers: ['Chrome >= 63'] }) 138 | ).toBeTruthy() 139 | 140 | expect( 141 | matchesUA(CustomUserAgentString.YANDEX_SEARCH, { 142 | browsers: ['Chrome >= 33'], 143 | }) 144 | ).toBeTruthy() 145 | 146 | expect( 147 | resolveUserAgent(CustomUserAgentString.FACEBOOK_WEBVIEW_CHROME_ANDROID) 148 | ).toEqual({ 149 | family: 'Chrome', 150 | version: '69.0.3497', 151 | }) 152 | 153 | expect( 154 | matchesUA(CustomUserAgentString.FACEBOOK_WEBVIEW_CHROME_ANDROID, { 155 | browsers: ['Chrome >= 63'], 156 | }) 157 | ).toBeTruthy() 158 | 159 | expect(resolveUserAgent(CustomUserAgentString.FACEBOOK_WEBVIEW_IOS)).toEqual({ 160 | family: 'iOS', 161 | version: '8.2.0', 162 | }) 163 | 164 | expect( 165 | matchesUA(CustomUserAgentString.FACEBOOK_WEBVIEW_IOS, { 166 | browsers: ['iOS >= 8'], 167 | }) 168 | ).toBeTruthy() 169 | }) 170 | 171 | it('resolves firefox properly', () => { 172 | expect(resolveUserAgent(ua.firefox('41.0.0'))).toEqual({ 173 | family: 'Firefox', 174 | version: '41.0.0', 175 | }) 176 | 177 | expect(resolveUserAgent(ua.firefox.androidPhone('44.0.0'))).toEqual({ 178 | family: 'Firefox', 179 | version: '44.0.0', 180 | }) 181 | }) 182 | 183 | it('resolves samsung browser properly', () => { 184 | expect(resolveUserAgent(CustomUserAgentString.SAMSUNG_BROWSER_7_2)).toEqual({ 185 | family: 'Samsung', 186 | version: '7.2.0', 187 | }) 188 | }) 189 | 190 | it('resolves electron properly', () => { 191 | // Electron 12 -> Chrome 89 192 | expect(resolveUserAgent(CustomUserAgentString.ELECTRON)).toEqual({ 193 | family: 'Chrome', 194 | version: '89.0.4389', 195 | }) 196 | }) 197 | 198 | it('detects if browserslist matches UA', () => { 199 | expect( 200 | matchesUA(ua.firefox.androidPhone('40.0.1'), { 201 | browsers: ['Firefox >= 40'], 202 | }) 203 | ).toBeTruthy() 204 | 205 | expect( 206 | matchesUA(ua.firefox('30.0.0'), { browsers: ['Firefox >= 10.0.0'] }) 207 | ).toBeTruthy() 208 | 209 | expect( 210 | matchesUA(ua.chrome.iOS('11.0.0'), { browsers: ['iOS >= 10.3.0'] }) 211 | ).toBeTruthy() 212 | 213 | expect( 214 | matchesUA(ua.safari.iOS('11.0.0'), { browsers: ['iOS >= 10.3.0'] }) 215 | ).toBeTruthy() 216 | 217 | expect( 218 | matchesUA(CustomUserAgentString.SAMSUNG_BROWSER_7_2, { 219 | browsers: ['Samsung >= 7'], 220 | }) 221 | ).toBeTruthy() 222 | 223 | expect( 224 | matchesUA(CustomUserAgentString.OPERA_MOBILE, { 225 | browsers: ['OperaMobile >= 7'], 226 | ignoreMinor: true, 227 | }) 228 | ).toBeTruthy() 229 | 230 | const modernList = [ 231 | 'Firefox >= 53', 232 | 'Edge >= 15', 233 | 'Chrome >= 58', 234 | 'iOS >= 10', 235 | 'Safari >= 11', 236 | 'Samsung >= 7', 237 | ] 238 | 239 | expect(matchesUA(ua.safari.iOS(9), { browsers: modernList })).toBeFalsy() 240 | 241 | expect( 242 | matchesUA(ua.chrome.androidPhone(57), { browsers: modernList }) 243 | ).toBeFalsy() 244 | 245 | expect( 246 | matchesUA(ua.firefox.androidPhone(52), { browsers: modernList }) 247 | ).toBeFalsy() 248 | 249 | expect(matchesUA(ua.firefox(56), { browsers: modernList })).toBeTruthy() 250 | 251 | expect(matchesUA(ua.edge(14), { browsers: modernList })).toBeFalsy() 252 | 253 | expect(matchesUA(ua.chrome(64), { browsers: modernList })).toBeTruthy() 254 | 255 | expect( 256 | matchesUA(ua.chrome.androidWebview('4.3.3'), { browsers: modernList }) 257 | ).toBeFalsy() 258 | 259 | expect(matchesUA(ua.safari('12.0.0'), { browsers: modernList })).toBeTruthy() 260 | 261 | expect(matchesUA(ua.safari('10.0.0'), { browsers: modernList })).toBeFalsy() 262 | 263 | expect( 264 | matchesUA(CustomUserAgentString.SAMSUNG_BROWSER_7_2, { 265 | browsers: modernList, 266 | }) 267 | ).toBeTruthy() 268 | }) 269 | 270 | it('can interpret various variations in specifying browser names', () => { 271 | expect(matchesUA(ua.chrome(49), { browsers: ['and_chr >= 49'] })).toBeTruthy() 272 | 273 | expect( 274 | matchesUA(ua.safari.iOS('10.3.0'), { browsers: ['ios_saf >= 10.1.0'] }) 275 | ).toBeTruthy() 276 | 277 | expect( 278 | matchesUA(ua.firefox.androidPhone('46.0.0'), { 279 | browsers: ['FirefoxAndroid >= 41.1.0'], 280 | }) 281 | ).toBeTruthy() 282 | }) 283 | 284 | it('ignorePatch option works correctly', () => { 285 | expect( 286 | matchesUA(ua.firefox('49.0.1'), { 287 | browsers: ['ff >= 44'], 288 | ignorePatch: false, 289 | }) 290 | ).toBeFalsy() 291 | 292 | expect( 293 | matchesUA(ua.firefox('49.0.1'), { 294 | browsers: ['ff >= 44'], 295 | ignorePatch: true, 296 | }) 297 | ).toBeTruthy() 298 | 299 | expect( 300 | matchesUA(ua.firefox('49.1.1'), { 301 | browsers: ['ff >= 44'], 302 | ignorePatch: true, 303 | ignoreMinor: false, 304 | }) 305 | ).toBeFalsy() 306 | }) 307 | 308 | it('ignoreMinor option works correctly', () => { 309 | expect( 310 | matchesUA(ua.firefox('49.1.0'), { 311 | browsers: ['ff >= 44'], 312 | ignoreMinor: false, 313 | }) 314 | ).toBeFalsy() 315 | 316 | expect( 317 | matchesUA(ua.firefox('49.1.0'), { 318 | browsers: ['ff >= 44'], 319 | ignoreMinor: true, 320 | }) 321 | ).toBeTruthy() 322 | 323 | expect( 324 | matchesUA(ua.firefox('49.1.3'), { 325 | browsers: ['ff >= 44'], 326 | ignoreMinor: true, 327 | ignorePatch: false, 328 | }) 329 | ).toBeTruthy() 330 | }) 331 | 332 | it('allowHigherVersions works correctly', () => { 333 | expect( 334 | matchesUA(ua.chrome('1000'), { 335 | browsers: ['chrome >= 60'], 336 | allowHigherVersions: false, 337 | }) 338 | ).toBeFalsy() 339 | 340 | expect( 341 | matchesUA(ua.chrome('1000'), { 342 | browsers: ['chrome >= 60'], 343 | allowHigherVersions: true, 344 | }) 345 | ).toBeTruthy() 346 | }) 347 | 348 | it('parses semvers liberally', () => { 349 | expect( 350 | matchesUA( 351 | 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.00', 352 | { browsers: ['opera >= 12'] } 353 | ) 354 | ).toBeTruthy() 355 | 356 | expect( 357 | matchesUA( 358 | 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.000.1', 359 | { browsers: ['opera >= 12'] } 360 | ) 361 | ).toBeTruthy() 362 | 363 | expect( 364 | matchesUA( 365 | 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.0.0001', 366 | { browsers: ['opera >= 12'] } 367 | ) 368 | ).toBeTruthy() 369 | }) 370 | 371 | it('can deal with version ranges (if returned by browserslist)', () => { 372 | expect( 373 | matchesUA(ua.safari.iOS('9.1.0'), { browsers: ['ios_saf >= 9'] }) 374 | ).toBeTruthy() 375 | 376 | expect( 377 | matchesUA(ua.safari.iOS('9.0.0'), { browsers: ['ios_saf >= 9'] }) 378 | ).toBeTruthy() 379 | 380 | // This should fail 381 | // see https://github.com/browserslist/browserslist/issues/402 382 | expect( 383 | matchesUA(ua.safari.iOS('9.1.0'), { browsers: ['ios_saf >= 9.2'] }) 384 | ).toBeTruthy() // <-- should actually be falsy 385 | }) 386 | 387 | it('can deal with non-numerical version numbers returned by browserslist for safari technology preview', () => { 388 | expect( 389 | matchesUA(ua.safari('18.1.0'), { 390 | browsers: ['unreleased Safari versions'], 391 | ignorePatch: true, 392 | ignoreMinor: true, 393 | allowHigherVersions: true, 394 | }) 395 | ).toBeTruthy() 396 | }) 397 | 398 | it('gracefully fails on invalid inputs', () => { 399 | expect(matchesUA(undefined)).toBeFalsy() 400 | 401 | expect(matchesUA(null)).toBeFalsy() 402 | }) 403 | 404 | it('deals with minor safari', () => { 405 | const UA_ios_saf_10_3_1 = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E8301 Safari/602.1'; 406 | 407 | expect(matchesUA(UA_ios_saf_10_3_1, { browsers: ['ios_saf >= 10.1'] })) 408 | .toBeTruthy() 409 | 410 | }) 411 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "lib", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["./index.ts"] 104 | } 105 | --------------------------------------------------------------------------------