fix(client): parse iOS browsers in parseUserAgent
CriOS, FxiOS and EdgiOS carry Safari/ as well, and the token order only tested the desktop names, so every iPhone and iPad client was reported as Safari with an empty version - and a hook is invited to branch on browser. Fold the tokens into one ordered table (mobile token before its desktop twin) so the family and its version always come from the same token, and add the legacy Edge token (Edge/18.x) which the Edg[A-Z]?/ pattern missed. Table-driven spec over real user agent strings: desktop Edge (both tokens), Opera, Firefox, Chrome and Safari, Android Chrome, the three iOS browsers, a non-browser client and an empty string. Red before the fix: all three iOS cases returned Safari with an empty version.
This commit is contained in:
1 parent
200ee8931d
commit
eec3438de9
2 files changed
+162
-16
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import { parseUserAgent } from './parse-user-agent'
|
||||
|
||||
/**
|
||||
* Table-driven: the point of the parser is the token ORDER, and the only way
|
||||
* to pin an order is to feed it real user agent strings that carry several
|
||||
* tokens at once. Every case below is a verbatim UA from the product named
|
||||
* (the iOS ones are the ones that used to fall through to Safari).
|
||||
*/
|
||||
describe('parseUserAgent', () => {
|
||||
const cases: Array<{
|
||||
label: string
|
||||
ua: string
|
||||
browser: string
|
||||
version: string
|
||||
platform: string
|
||||
}> = [
|
||||
{
|
||||
label: 'desktop Edge (Edg)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.87',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Edge (legacy EdgeHTML)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763',
|
||||
browser: 'Edge',
|
||||
version: '18.17763',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Opera (OPR)',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/111.0.0.0',
|
||||
browser: 'Opera',
|
||||
version: '111.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Firefox',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
||||
browser: 'Firefox',
|
||||
version: '140.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Chrome',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Safari',
|
||||
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'macOS'
|
||||
},
|
||||
{
|
||||
label: 'Android Chrome',
|
||||
ua: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Android'
|
||||
},
|
||||
{
|
||||
label: 'iOS Chrome (CriOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.6478.153',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Firefox (FxiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15',
|
||||
browser: 'Firefox',
|
||||
version: '127.0',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Edge (EdgiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/126.0.2592.86 Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.86',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'a non-browser client',
|
||||
ua: 'curl/8.5.0',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
},
|
||||
{
|
||||
label: 'an empty user agent',
|
||||
ua: '',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
}
|
||||
]
|
||||
|
||||
cases.forEach((c) => {
|
||||
it(`reports ${c.label} as ${c.browser} ${c.version || '(no version)'} on ${c.platform}`, () => {
|
||||
expect(parseUserAgent(c.ua)).toEqual({
|
||||
browser: c.browser,
|
||||
browserVersion: c.version,
|
||||
platform: c.platform
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,11 @@
|
||||
* than to write its own user agent parsing.
|
||||
*
|
||||
* Order matters: Edge and Opera both claim to be Chrome, and Chrome claims to
|
||||
* be Safari, so the most specific token has to be tested first.
|
||||
* be Safari, so the most specific token has to be tested first. The mobile
|
||||
* tokens are the sharpest case - an iOS browser carries its own token
|
||||
* (`CriOS`, `FxiOS`, `EdgiOS`) *and* `Safari/`, so without the mobile token
|
||||
* being tested first every iPhone and iPad client was reported as Safari with
|
||||
* an empty version.
|
||||
*/
|
||||
export interface UserAgentSummary {
|
||||
browser: string
|
||||
@@ -15,6 +19,38 @@ export interface UserAgentSummary {
|
||||
platform: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Most specific token first. Each entry carries the version pattern for the
|
||||
* same token, so the family and its version can never be read from different
|
||||
* products.
|
||||
*/
|
||||
const BROWSER_TOKENS: Array<{ name: string; token: RegExp; version: RegExp }> =
|
||||
[
|
||||
{
|
||||
name: 'Edge',
|
||||
token: /(?:EdgiOS|Edg[A-Z]?|Edge)\//,
|
||||
version: /(?:EdgiOS|Edg[A-Z]?|Edge)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Opera',
|
||||
token: /(?:OPiOS|OPR)\//,
|
||||
version: /(?:OPiOS|OPR)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Firefox',
|
||||
token: /(?:FxiOS|Firefox)\//,
|
||||
version: /(?:FxiOS|Firefox)\/([\d.]+)/
|
||||
},
|
||||
{
|
||||
name: 'Chrome',
|
||||
token: /(?:CriOS|Chrome)\//,
|
||||
version: /(?:CriOS|Chrome)\/([\d.]+)/
|
||||
},
|
||||
// Safari is the fallback token: everything else that reaches here is
|
||||
// WebKit wearing another product's name.
|
||||
{ name: 'Safari', token: /Safari\//, version: /Version\/([\d.]+)/ }
|
||||
]
|
||||
|
||||
export function parseUserAgent(userAgent: string): UserAgentSummary {
|
||||
const ua = userAgent || ''
|
||||
const match = (re: RegExp) => (ua.match(re) || [])[1] || ''
|
||||
@@ -22,21 +58,12 @@ export function parseUserAgent(userAgent: string): UserAgentSummary {
|
||||
let browser = 'Unknown'
|
||||
let browserVersion = ''
|
||||
|
||||
if (/Edg[A-Z]?\//.test(ua)) {
|
||||
browser = 'Edge'
|
||||
browserVersion = match(/Edg[A-Z]?\/([\d.]+)/)
|
||||
} else if (/OPR\//.test(ua)) {
|
||||
browser = 'Opera'
|
||||
browserVersion = match(/OPR\/([\d.]+)/)
|
||||
} else if (/Firefox\//.test(ua)) {
|
||||
browser = 'Firefox'
|
||||
browserVersion = match(/Firefox\/([\d.]+)/)
|
||||
} else if (/Chrome\//.test(ua)) {
|
||||
browser = 'Chrome'
|
||||
browserVersion = match(/Chrome\/([\d.]+)/)
|
||||
} else if (/Safari\//.test(ua)) {
|
||||
browser = 'Safari'
|
||||
browserVersion = match(/Version\/([\d.]+)/)
|
||||
for (const entry of BROWSER_TOKENS) {
|
||||
if (entry.token.test(ua)) {
|
||||
browser = entry.name
|
||||
browserVersion = match(entry.version)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let platform = 'Unknown'
|
||||
|
||||
Reference in new issue
Block a user