From 66417d4789585eb5ad8adcb8dbb7f67674164ecb Mon Sep 17 00:00:00 2001 From: "Michael \"Z\" Goddard" Date: Thu, 29 Jun 2023 13:00:57 -0400 Subject: [PATCH] Add mdn-bcd-collector update script (#19971) --- package-lock.json | 284 +++++- package.json | 8 +- scripts/update.test.ts | 1851 +++++++++++++++++++++++++++++++++++++++ scripts/update.ts | 755 ++++++++++++++++ utils/ua-parser.test.ts | 539 ++++++++++++ utils/ua-parser.ts | 160 ++++ 6 files changed, 3591 insertions(+), 6 deletions(-) create mode 100644 scripts/update.test.ts create mode 100644 scripts/update.ts create mode 100644 utils/ua-parser.test.ts create mode 100644 utils/ua-parser.ts diff --git a/package-lock.json b/package-lock.json index 37a491e9b17761..ab26cf63d98b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,10 @@ "@swc/cli": "^0.1.62", "@swc/core": "^1.3.36", "@types/deep-diff": "~1.0.1", + "@types/minimatch": "^5.1.2", "@types/mocha": "~10.0.0", "@types/node": "~20.3.0", + "@types/sinon": "^10.0.15", "@types/yargs": "~17.0.10", "@typescript-eslint/eslint-plugin": "~5.60.0", "@typescript-eslint/parser": "^5.44.0", @@ -46,12 +48,15 @@ "husky": "^8.0.1", "json-schema-to-typescript": "~13.0.1", "lint-staged": "^13.0.3", + "minimatch": "^5.1.6", "mocha": "~10.2.0", "open-cli": "~7.2.0", "ora": "~6.3.0", "prettier": "~2.8.0", + "sinon": "^15.1.0", "ts-node": "~10.9.1", "typescript": "~5.1.5", + "ua-parser-js": "1.0.35", "web-specs": "^2.41.0", "yargs": "~17.7.0" }, @@ -650,6 +655,18 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -685,6 +702,18 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -920,6 +949,50 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@swc/cli": { "version": "0.1.62", "resolved": "https://registry.npmjs.org/@swc/cli/-/cli-0.1.62.tgz", @@ -1338,6 +1411,21 @@ "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, + "node_modules/@types/sinon": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.15.tgz", + "integrity": "sha512-3lrFNQG0Kr2LDzvjyjB6AMJk4ge+8iYhQfdnSwIwlG88FUOV43kPcQqDZkDa/h3WSZy6i8Fr0BSjfQtB1B3xuQ==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -4058,6 +4146,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-jsdoc": { "version": "46.4.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.4.2.tgz", @@ -4153,6 +4253,19 @@ "node": ">=10" } }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-n/node_modules/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", @@ -4239,6 +4352,18 @@ "node": ">=4" } }, + "node_modules/eslint-plugin-node/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-prefer-arrow-functions": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/eslint-plugin-prefer-arrow-functions/-/eslint-plugin-prefer-arrow-functions-3.1.4.tgz", @@ -4523,6 +4648,18 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -5173,6 +5310,18 @@ "glob": "^7.1.6" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -5936,6 +6085,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6092,6 +6247,12 @@ "node": ">=0.10.0" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", @@ -6460,6 +6621,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7032,15 +7199,24 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=10" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" } }, "node_modules/minimist": { @@ -7306,6 +7482,28 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, + "node_modules/nise": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz", + "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -7831,6 +8029,15 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8614,6 +8821,33 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sinon": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", + "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^10.3.0", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.4", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9032,6 +9266,18 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9293,6 +9539,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -9332,6 +9587,25 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index d6997de453b55f..e3c3be65229892 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,10 @@ "@swc/cli": "^0.1.62", "@swc/core": "^1.3.36", "@types/deep-diff": "~1.0.1", + "@types/minimatch": "^5.1.2", "@types/mocha": "~10.0.0", "@types/node": "~20.3.0", + "@types/sinon": "^10.0.15", "@types/yargs": "~17.0.10", "@typescript-eslint/eslint-plugin": "~5.60.0", "@typescript-eslint/parser": "^5.44.0", @@ -64,12 +66,15 @@ "husky": "^8.0.1", "json-schema-to-typescript": "~13.0.1", "lint-staged": "^13.0.3", + "minimatch": "^5.1.6", "mocha": "~10.2.0", "open-cli": "~7.2.0", "ora": "~6.3.0", "prettier": "~2.8.0", + "sinon": "^15.1.0", "ts-node": "~10.9.1", "typescript": "~5.1.5", + "ua-parser-js": "1.0.35", "web-specs": "^2.41.0", "yargs": "~17.7.0" }, @@ -90,6 +95,7 @@ "remove-redundant-flags": "ts-node scripts/remove-redundant-flags.ts", "show-errors": "npm test 1> /dev/null", "test": "npm run format && npm run lint && npm run unittest", - "traverse": "ts-node scripts/traverse.ts" + "traverse": "ts-node scripts/traverse.ts", + "update": "ts-node scripts/update.ts" } } diff --git a/scripts/update.test.ts b/scripts/update.test.ts new file mode 100644 index 00000000000000..01fccd167813dc --- /dev/null +++ b/scripts/update.test.ts @@ -0,0 +1,1851 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +import assert from 'node:assert'; + +import sinon from 'sinon'; +import _minimatch from 'minimatch'; + +import { Browsers, CompatData, Identifier } from '../types/types'; + +import { + ManualOverride, + Report, + findEntry, + getSupportMap, + getSupportMatrix, + inferSupportStatements, + logger, + splitRange, + update, +} from './update.js'; + +const { Minimatch } = _minimatch; + +const clone = (value) => JSON.parse(JSON.stringify(value)); +const chromeAndroid86UaString = + 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.5112.97 Mobile Safari/537.36'; +const firefox92UaString = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:92.0) Gecko/20100101 Firefox/92.0'; + +const overrides = [ + ['css.properties.font-family', 'safari', '5.1', false], + ['css.properties.font-family', 'chrome', '83', false], + ['css.properties.font-face', 'chrome', '*', null], + ['css.properties.font-style', 'chrome', '82-84', false], +] as ManualOverride[]; + +const bcd = { + api: { + AbortController: { + __compat: { + support: { + chrome: { version_added: '80' }, + safari: { version_added: null }, + }, + }, + AbortController: { + __compat: { support: { chrome: { version_added: null } } }, + }, + abort: { + __compat: { support: { chrome: { version_added: '85' } } }, + }, + dummy: { + __compat: { support: { chrome: { version_added: null } } }, + }, + signal: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + AudioContext: { + __compat: { + support: { + chrome: [ + { + version_added: null, + }, + { + version_added: '1', + prefix: 'webkit', + }, + ], + }, + }, + close: { + __compat: { support: {} }, + }, + }, + DeprecatedInterface: { + __compat: { support: { chrome: { version_added: null } } }, + }, + DummyAPI: { + __compat: { support: { chrome: { version_added: null } } }, + dummy: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + ExperimentalInterface: { + __compat: { + support: { + chrome: [ + { + version_added: '70', + notes: 'Not supported on Windows XP.', + }, + { + version_added: '64', + version_removed: '70', + flags: {}, + notes: 'Not supported on Windows XP.', + }, + { + version_added: '50', + version_removed: '70', + alternative_name: 'TryingOutInterface', + notes: 'Not supported on Windows XP.', + }, + ], + }, + }, + }, + UnflaggedInterface: { + __compat: { + support: { + chrome: [ + { + version_added: '83', + flags: {}, + notes: 'Not supported on Windows XP.', + }, + ], + }, + }, + }, + UnprefixedInterface: { + __compat: { + support: { + chrome: [ + { + version_added: '83', + prefix: 'webkit', + notes: 'Not supported on Windows XP.', + }, + ], + }, + }, + }, + NullAPI: { + __compat: { support: { chrome: { version_added: '80' } } }, + }, + RemovedInterface: { + __compat: { support: { chrome: { version_added: null } } }, + }, + SuperNewInterface: { + __compat: { support: { chrome: { version_added: '100' } } }, + }, + }, + browsers: { + chrome: { name: 'Chrome', releases: { 82: {}, 83: {}, 84: {}, 85: {} } }, + chrome_android: { name: 'Chrome Android', releases: { 85: {} } }, + edge: { name: 'Edge', releases: { 16: {}, 84: {} } }, + safari: { name: 'Safari', releases: { 13: {}, 13.1: {}, 14: {} } }, + safari_ios: { + name: 'iOS Safari', + releases: { 13: {}, 13.3: {}, 13.4: {}, 14: {} }, + }, + samsunginternet_android: { + name: 'Samsung Internet', + releases: { + '10.0': {}, + 10.2: {}, + '11.0': {}, + 11.2: {}, + '12.0': {}, + 12.1: {}, + }, + }, + }, + css: { + properties: { + 'font-family': { + __compat: { support: { chrome: { version_added: null } } }, + }, + 'font-face': { + __compat: { support: { chrome: { version_added: null } } }, + }, + 'font-style': { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + }, + javascript: { + builtins: { + Array: { + __compat: { support: { chrome: { version_added: null } } }, + }, + Date: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + }, +} as any as CompatData; + +const reports: Report[] = [ + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + { + name: 'api.AbortController.abort', + exposure: 'Window', + result: null, + }, + { + name: 'api.AbortController.AbortController', + exposure: 'Window', + result: false, + }, + { + name: 'api.AudioContext', + exposure: 'Window', + result: false, + }, + { + name: 'api.AudioContext.close', + exposure: 'Window', + result: null, + message: 'threw ReferenceError: AbortController is not defined', + }, + { + name: 'api.DeprecatedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.ExperimentalInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.UnflaggedInterface', + exposure: 'Window', + result: null, + }, + { + name: 'api.UnprefixedInterface', + exposure: 'Window', + result: null, + }, + { + name: 'api.NullAPI', + exposure: 'Window', + result: null, + }, + { + name: 'api.RemovedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.SuperNewInterface', + exposure: 'Window', + result: false, + }, + { + name: 'css.properties.font-family', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-face', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-style', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + { + name: 'api.AbortController.abort', + exposure: 'Window', + result: false, + }, + { + name: 'api.AbortController.abort', + exposure: 'Worker', + result: true, + }, + { + name: 'api.AbortController.AbortController', + exposure: 'Window', + result: false, + }, + { + name: 'api.AudioContext', + exposure: 'Window', + result: false, + }, + { + name: 'api.AudioContext.close', + exposure: 'Window', + result: null, + message: 'threw ReferenceError: AbortController is not defined', + }, + { + name: 'api.DeprecatedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.ExperimentalInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.UnflaggedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.UnprefixedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.NewInterfaceNotInBCD', + exposure: 'Window', + result: false, + }, + { + name: 'api.NullAPI', + exposure: 'Window', + result: null, + }, + { + name: 'api.RemovedInterface', + exposure: 'Window', + result: false, + }, + { + name: 'api.SuperNewInterface', + exposure: 'Window', + result: false, + }, + { + name: 'css.properties.font-family', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-face', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-style', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + { + name: 'api.AbortController.abort', + exposure: 'Window', + result: true, + }, + { + name: 'api.AbortController.AbortController', + exposure: 'Window', + result: true, + }, + { + name: 'api.AudioContext', + exposure: 'Window', + result: true, + }, + { + name: 'api.AudioContext.close', + exposure: 'Window', + result: true, + }, + { + name: 'api.DeprecatedInterface', + exposure: 'Window', + result: false, + }, + { + name: 'api.ExperimentalInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.UnflaggedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.UnprefixedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.NewInterfaceNotInBCD', + exposure: 'Window', + result: true, + }, + { + name: 'api.NullAPI', + exposure: 'Window', + result: null, + }, + { + name: 'api.RemovedInterface', + exposure: 'Window', + result: true, + }, + { + name: 'api.SuperNewInterface', + exposure: 'Window', + result: false, + }, + { + name: 'css.properties.font-family', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-face', + exposure: 'Window', + result: true, + }, + { + name: 'css.properties.font-style', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 YaBrowser/17.6.1.749 Yowser/2.5 Safari/537.36', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1000.1.4183.83 Safari/537.36', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + }, + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: 'node-superagent/1.2.3', + }, +]; + +describe('BCD updater', () => { + describe('findEntry', () => { + it('equal', () => { + assert.strictEqual( + findEntry(bcd as any, 'api.AbortController'), + bcd.api.AbortController, + ); + }); + + it('no path', () => { + assert.strictEqual(findEntry(bcd as any, ''), null); + }); + + it('invalid path', () => { + assert.strictEqual(findEntry(bcd as any, 'api.MissingAPI'), undefined); + }); + }); + + describe('getSupportMap', () => { + it('normal', () => { + assert.deepEqual( + getSupportMap(reports[0]), + new Map([ + ['api.AbortController', true], + ['api.AbortController.abort', null], + ['api.AbortController.AbortController', false], + ['api.AudioContext', false], + ['api.AudioContext.close', false], + ['api.DeprecatedInterface', true], + ['api.ExperimentalInterface', true], + ['api.UnflaggedInterface', null], + ['api.UnprefixedInterface', null], + ['api.NullAPI', null], + ['api.RemovedInterface', true], + ['api.SuperNewInterface', false], + ['css.properties.font-family', true], + ['css.properties.font-face', true], + ['css.properties.font-style', true], + ]), + ); + }); + + it('support in only one exposure', () => { + assert.deepEqual( + getSupportMap(reports[1]), + new Map([ + ['api.AbortController', true], + ['api.AbortController.abort', true], + ['api.AbortController.AbortController', false], + ['api.AudioContext', false], + ['api.AudioContext.close', false], + ['api.DeprecatedInterface', true], + ['api.ExperimentalInterface', true], + ['api.UnflaggedInterface', true], + ['api.UnprefixedInterface', true], + ['api.NewInterfaceNotInBCD', false], + ['api.NullAPI', null], + ['api.RemovedInterface', false], + ['api.SuperNewInterface', false], + ['css.properties.font-family', true], + ['css.properties.font-face', true], + ['css.properties.font-style', true], + ]), + ); + }); + + it('no results', () => { + assert.throws( + () => { + getSupportMap({ + __version: 'test', + results: {}, + userAgent: 'abc/1.2.3-beta', + }); + }, + { message: 'Report for "abc/1.2.3-beta" has no results!' }, + ); + }); + }); + + describe('getSupportMatrix', () => { + beforeEach(() => { + sinon.stub(logger, 'warn'); + }); + + it('normal', () => { + assert.deepEqual( + getSupportMatrix(reports, bcd.browsers, overrides), + new Map([ + [ + 'api.AbortController', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', true], + ['84', true], + ['85', true], + ]), + ], + [ + 'safari', + new Map([ + ['13', null], + ['13.1', true], + ['14', null], + ]), + ], + ]), + ], + [ + 'api.AbortController.abort', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', true], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.AbortController.AbortController', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', false], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.AudioContext', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', false], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.AudioContext.close', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', false], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.DeprecatedInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', true], + ['84', true], + ['85', false], + ]), + ], + ]), + ], + [ + 'api.ExperimentalInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', true], + ['84', true], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.UnflaggedInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', true], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.UnprefixedInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', true], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.NewInterfaceNotInBCD', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.NullAPI', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', null], + ['85', null], + ]), + ], + ]), + ], + [ + 'api.RemovedInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', true], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + [ + 'api.SuperNewInterface', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', false], + ['84', false], + ['85', false], + ]), + ], + ]), + ], + [ + 'css.properties.font-family', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', false], + ['84', true], + ['85', true], + ]), + ], + ]), + ], + [ + 'css.properties.font-face', + new Map([ + [ + 'chrome', + new Map([ + ['82', null], + ['83', null], + ['84', null], + ['85', null], + ]), + ], + ]), + ], + [ + 'css.properties.font-style', + new Map([ + [ + 'chrome', + new Map([ + ['82', false], + ['83', false], + ['84', false], + ['85', true], + ]), + ], + ]), + ], + ]), + ); + + assert.ok( + (logger.warn as any).calledWith( + 'Ignoring unknown browser Yandex 17.6 (Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 YaBrowser/17.6.1.749 Yowser/2.5 Safari/537.36)', + ), + ); + assert.ok( + (logger.warn as any).calledWith( + 'Ignoring unknown Chrome version 1000.1 (Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1000.1.4183.83 Safari/537.36)', + ), + ); + assert.ok( + (logger.warn as any).calledWith( + 'Unable to parse browser from UA node-superagent/1.2.3', + ), + ); + }); + + it('Invalid results', () => { + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: 87 as any, + }, + ], + }, + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36', + }; + + assert.throws( + () => { + getSupportMatrix([report], bcd.browsers, overrides); + }, + { message: 'result not true/false/null; got 87' }, + ); + }); + + afterEach(() => { + (logger.warn as any).restore(); + }); + }); + + describe('inferSupportStatements', () => { + const expectedResults = { + 'api.AbortController': { + chrome: [{ version_added: '≤83' }], + safari: [{ version_added: '≤13.1' }], + }, + 'api.AbortController.abort': { chrome: [{ version_added: '≤84' }] }, + 'api.AbortController.AbortController': { + chrome: [{ version_added: '85' }], + }, + 'api.AudioContext': { chrome: [{ version_added: '85' }] }, + 'api.AudioContext.close': { chrome: [{ version_added: '85' }] }, + 'api.DeprecatedInterface': { + chrome: [{ version_added: '≤83', version_removed: '85' }], + }, + 'api.ExperimentalInterface': { chrome: [{ version_added: '≤83' }] }, + 'api.UnflaggedInterface': { chrome: [{ version_added: '≤84' }] }, + 'api.UnprefixedInterface': { chrome: [{ version_added: '≤84' }] }, + 'api.NewInterfaceNotInBCD': { chrome: [{ version_added: '85' }] }, + 'api.NullAPI': { chrome: [] }, + 'api.RemovedInterface': { + chrome: [ + { version_added: '≤83', version_removed: '84' }, + { version_added: '85' }, + ], + }, + 'api.SuperNewInterface': { + chrome: [{ version_added: false }], + }, + 'css.properties.font-family': { chrome: [{ version_added: '84' }] }, + 'css.properties.font-face': { chrome: [] }, + 'css.properties.font-style': { chrome: [{ version_added: '85' }] }, + }; + + const supportMatrix = getSupportMatrix(reports, bcd.browsers, overrides); + for (const [path, browserMap] of supportMatrix.entries()) { + for (const [browser, versionMap] of browserMap.entries()) { + it(`${path}: ${browser}`, () => { + assert.deepEqual( + inferSupportStatements(versionMap), + expectedResults[path][browser], + ); + }); + } + } + + it('Invalid results', () => { + const versionMap = new Map([ + ['82', null], + ['83', 87 as any], + ['84', true], + ['85', true], + ]); + + assert.throws( + () => { + inferSupportStatements(versionMap); + }, + { message: 'result not true/false/null; got 87' }, + ); + }); + + it('non-contiguous data, support added', () => { + const versionMap = new Map([ + ['82', false], + ['83', null], + ['84', true], + ]); + + assert.deepEqual(inferSupportStatements(versionMap), [ + { + version_added: '82> ≤84', + }, + ]); + }); + + it('non-contiguous data, support removed', () => { + const versionMap = new Map([ + ['82', true], + ['83', null], + ['84', false], + ]); + + assert.deepEqual(inferSupportStatements(versionMap), [ + { + version_added: '82', + version_removed: '82> ≤84', + }, + ]); + }); + }); + + describe('splitRange', () => { + it('fails for single versions', () => { + assert.throws( + () => { + splitRange('23'); + }, + { message: 'Unrecognized version range value: "23"' }, + ); + }); + }); + + describe('update', () => { + const supportMatrix = getSupportMatrix(reports, bcd.browsers, overrides); + let bcdCopy; + + beforeEach(() => { + bcdCopy = clone(bcd); + }); + + it('normal', () => { + update(bcdCopy, supportMatrix, {}); + assert.deepEqual(bcdCopy, { + api: { + AbortController: { + __compat: { + support: { + chrome: { version_added: '80' }, + safari: { version_added: '≤13.1' }, + }, + }, + AbortController: { + __compat: { support: { chrome: { version_added: '85' } } }, + }, + abort: { + __compat: { support: { chrome: { version_added: '≤84' } } }, + }, + dummy: { + __compat: { support: { chrome: { version_added: null } } }, + }, + signal: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + AudioContext: { + __compat: { + support: { + chrome: [ + { + version_added: '85', + }, + { + version_added: '1', + prefix: 'webkit', + }, + ], + }, + }, + close: { + __compat: { support: { chrome: { version_added: '85' } } }, + }, + }, + DeprecatedInterface: { + __compat: { + support: { + chrome: { + version_added: '≤83', + version_removed: '85', + }, + }, + }, + }, + DummyAPI: { + __compat: { support: { chrome: { version_added: null } } }, + dummy: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + ExperimentalInterface: { + __compat: { + support: { + chrome: [ + { + version_added: '70', + notes: 'Not supported on Windows XP.', + }, + { + version_added: '64', + version_removed: '70', + flags: {}, + notes: 'Not supported on Windows XP.', + }, + { + version_added: '50', + version_removed: '70', + alternative_name: 'TryingOutInterface', + notes: 'Not supported on Windows XP.', + }, + ], + }, + }, + }, + UnflaggedInterface: { + __compat: { + support: { + chrome: { + version_added: '≤84', + }, + }, + }, + }, + UnprefixedInterface: { + __compat: { + support: { + chrome: [ + { + version_added: '≤84', + }, + { + version_added: '83', + prefix: 'webkit', + notes: 'Not supported on Windows XP.', + }, + ], + }, + }, + }, + NullAPI: { + __compat: { support: { chrome: { version_added: '80' } } }, + }, + RemovedInterface: { + // TODO: handle more complicated scenarios + // __compat: {support: {chrome: [ + // {version_added: '85'}, + // {version_added: '≤83', version_removed: '84'} + // ]}} + __compat: { support: { chrome: { version_added: null } } }, + }, + SuperNewInterface: { + __compat: { support: { chrome: { version_added: '100' } } }, + }, + }, + browsers: { + chrome: { + name: 'Chrome', + releases: { 82: {}, 83: {}, 84: {}, 85: {} }, + }, + chrome_android: { name: 'Chrome Android', releases: { 85: {} } }, + edge: { name: 'Edge', releases: { 16: {}, 84: {} } }, + safari: { name: 'Safari', releases: { 13: {}, 13.1: {}, 14: {} } }, + safari_ios: { + name: 'iOS Safari', + releases: { 13: {}, 13.3: {}, 13.4: {}, 14: {} }, + }, + samsunginternet_android: { + name: 'Samsung Internet', + releases: { + '10.0': {}, + 10.2: {}, + '11.0': {}, + 11.2: {}, + '12.0': {}, + 12.1: {}, + }, + }, + }, + css: { + properties: { + 'font-family': { + __compat: { support: { chrome: { version_added: '84' } } }, + }, + 'font-face': { + __compat: { support: { chrome: { version_added: null } } }, + }, + 'font-style': { + __compat: { support: { chrome: { version_added: '85' } } }, + }, + }, + }, + javascript: { + builtins: { + Array: { + __compat: { support: { chrome: { version_added: null } } }, + }, + Date: { + __compat: { support: { chrome: { version_added: null } } }, + }, + }, + }, + }); + }); + + it('limit browsers', () => { + update(bcdCopy, supportMatrix, { browser: ['chrome'] }); + assert.deepEqual(bcdCopy.api.AbortController.__compat.support.safari, { + version_added: null, + }); + }); + + describe('mirror', () => { + const chromeAndroid86UaString = + 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.5112.97 Mobile Safari/537.36'; + const browsers: any = { + chrome: { name: 'Chrome', releases: { 85: {}, 86: {} } }, + chrome_android: { + name: 'Chrome Android', + upstream: 'chrome', + releases: { 86: {} }, + }, + }; + + const bcdFromSupport = (support) => ({ + api: { FakeInterface: { __compat: { support } } }, + }); + + /** + * Create a BCD data structure for an arbitrary web platform feature + * based on support data for Chrome and Chrome Android and test result + * data for Chrome Android. This utility invokes the `update` function + * and is designed to observe the behavior of the "mirror" support value. + * + * @returns {Identifier} + */ + const mirroringCase = ({ support, downstreamResult }): Identifier => { + const reports: Report[] = [ + { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.FakeInterface', + exposure: 'Window', + result: downstreamResult, + }, + ], + }, + userAgent: chromeAndroid86UaString, + }, + ]; + const supportMatrix = getSupportMatrix(reports, browsers, []); + const bcd = bcdFromSupport(support); + update(bcd, supportMatrix, {}); + return bcd; + }; + + describe('supported upstream (without flags)', () => { + it('supported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '86' }, + chrome_android: 'mirror', + }, + downstreamResult: true, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '86' }, + chrome_android: 'mirror', + }), + ); + }); + + it('unsupported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '85' }, + chrome_android: 'mirror', + }, + downstreamResult: false, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '85' }, + chrome_android: { version_added: false }, + }), + ); + }); + + it('omitted from downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '85' }, + chrome_android: 'mirror', + }, + downstreamResult: null, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '85' }, + chrome_android: 'mirror', + }), + ); + }); + }); + + describe('supported upstream (with flags)', () => { + it('supported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '85', flags: [{}] }, + chrome_android: 'mirror', + }, + downstreamResult: true, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '85', flags: [{}] }, + chrome_android: { version_added: '86' }, + }), + ); + }); + + it('unsupported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '85', flags: [{}] }, + chrome_android: 'mirror', + }, + downstreamResult: false, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '85', flags: [{}] }, + chrome_android: 'mirror', + }), + ); + }); + + it('omitted from downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: '85', flags: [{}] }, + chrome_android: 'mirror', + }, + downstreamResult: null, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: '85', flags: [{}] }, + chrome_android: 'mirror', + }), + ); + }); + }); + + describe('partially supported upstream', () => { + it('supported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { + version_added: '85', + partial_implementation: true, + notes: 'This only works on Tuesdays', + }, + chrome_android: 'mirror', + }, + downstreamResult: true, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { + version_added: '85', + partial_implementation: true, + notes: 'This only works on Tuesdays', + }, + chrome_android: 'mirror', + }), + ); + }); + + it('unsupported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: [ + { + version_added: '85', + partial_implementation: true, + impl_url: 'http://zombo.com', + notes: 'This only works on Wednesdays', + }, + { version_added: '84', flags: [{}] }, + ], + chrome_android: 'mirror', + }, + downstreamResult: false, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: [ + { + version_added: '85', + partial_implementation: true, + impl_url: 'http://zombo.com', + notes: 'This only works on Wednesdays', + }, + { version_added: '84', flags: [{}] }, + ], + chrome_android: { + version_added: false, + }, + }), + ); + }); + + it('omitted from downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { + version_added: '85', + partial_implementation: true, + notes: 'This only works on Thursdays', + }, + chrome_android: 'mirror', + }, + downstreamResult: null, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { + version_added: '85', + partial_implementation: true, + notes: 'This only works on Thursdays', + }, + chrome_android: 'mirror', + }), + ); + }); + }); + + describe('unsupported upstream', () => { + it('supported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: false }, + chrome_android: 'mirror', + }, + downstreamResult: true, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: false }, + chrome_android: { version_added: '86' }, + }), + ); + }); + + it('unsupported in downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: false }, + chrome_android: 'mirror', + }, + downstreamResult: false, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: false }, + chrome_android: 'mirror', + }), + ); + }); + + it('omitted from downstream test results', () => { + const actual = mirroringCase({ + support: { + chrome: { version_added: false }, + chrome_android: 'mirror', + }, + downstreamResult: null, + }); + assert.deepEqual( + actual, + bcdFromSupport({ + chrome: { version_added: false }, + chrome_android: 'mirror', + }), + ); + }); + }); + }); + + it('does not report a modification when results corroborate existing data', () => { + const firefox92UaString = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:92.0) Gecko/20100101 Firefox/92.0'; + const initialBcd = { + api: { + AbortController: { + __compat: { + support: { + firefox: { version_added: '92' }, + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 92: {} } }, + } as unknown as Browsers, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: firefox92UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + + it('retains flag data for unsupported features', () => { + const initialBcd = { + api: { + AbortController: { + __compat: { + support: { + firefox: { version_added: '91', flags: [{}] }, + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 91: {}, 92: {}, 93: {} } }, + } as unknown as Browsers, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: firefox92UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + + it('no update given partial confirmation of complex support scenario', () => { + const initialBcd: any = { + api: { + AbortController: { + __compat: { + support: { + firefox: [ + { version_added: '92' }, + { + version_added: '91', + partial_implementation: true, + notes: '', + }, + ], + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 91: {}, 92: {}, 93: {} } }, + }, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: firefox92UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + + it('skips complex support scenarios', () => { + const initialBcd: any = { + api: { + AbortController: { + __compat: { + support: { + firefox: [ + { version_added: '94' }, + { + version_added: '93', + partial_implementation: true, + notes: '', + }, + ], + }, + }, + }, + }, + browsers: { + firefox: { + name: 'Firefox', + releases: { 91: {}, 92: {}, 93: {}, 94: {} }, + }, + }, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: firefox92UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + + it('skips removed features', () => { + const initialBcd: any = { + api: { + AbortController: { + __compat: { + support: { + firefox: { version_added: '90', version_removed: '91' }, + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 90: {}, 91: {}, 92: {} } }, + }, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: firefox92UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + + it('persists non-default statements', () => { + const initialBcd: any = { + api: { + AbortController: { + __compat: { + support: { + firefox: { version_added: '91', prefix: 'moz' }, + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 91: {}, 92: {}, 93: {} } }, + }, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: firefox92UaString, + }; + const expectedBcd = clone(initialBcd); + expectedBcd.api.AbortController.__compat.support.firefox = [ + { + version_added: '≤92', + }, + { + prefix: 'moz', + version_added: '91', + }, + ]; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert(modified, 'modified'); + assert.deepEqual(finalBcd, expectedBcd); + }); + + it('overrides existing support information in response to negative test results', () => { + const initialBcd: any = { + api: { + AbortController: { + __compat: { + support: { + firefox: { version_added: '91' }, + }, + }, + }, + }, + browsers: { + firefox: { name: 'Firefox', releases: { 91: {}, 92: {}, 93: {} } }, + }, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: false, + }, + ], + }, + userAgent: firefox92UaString, + }; + const expectedBcd = clone(initialBcd); + expectedBcd.api.AbortController.__compat.support.firefox.version_added = + false; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert(modified, 'modified'); + assert.deepEqual(finalBcd, expectedBcd); + }); + + describe('filtering', () => { + let expectedBcd; + beforeEach(() => { + expectedBcd = clone(bcd); + }); + + it('path', () => { + const filter = { + path: new Minimatch('css.properties.*'), + }; + expectedBcd.css.properties[ + 'font-family' + ].__compat.support.chrome.version_added = '84'; + expectedBcd.css.properties[ + 'font-style' + ].__compat.support.chrome.version_added = '85'; + + const modified = update(bcdCopy, supportMatrix, filter); + + assert(modified, 'modified'); + assert.deepEqual(bcdCopy, expectedBcd); + }); + + it('release', () => { + const filter = { release: '84' }; + expectedBcd.css.properties[ + 'font-family' + ].__compat.support.chrome.version_added = '84'; + + const modified = update(bcdCopy, supportMatrix, filter); + + assert(modified, 'modified'); + assert.deepEqual(bcdCopy, expectedBcd); + }); + }); + + it('persists "mirror" when test results align with support data', () => { + const initialBcd = { + api: { + AbortController: { + __compat: { + support: { + chrome: { version_added: '86' }, + chrome_android: 'mirror', + }, + }, + }, + }, + browsers: { + chrome: { name: 'Chrome', releases: { 85: {}, 86: {} } }, + chrome_android: { + name: 'Chrome Android', + upstream: 'chrome', + releases: { 86: {} }, + }, + } as unknown as Browsers, + }; + const finalBcd = clone(initialBcd); + const report: Report = { + __version: '0.3.1', + results: { + 'https://mdn-bcd-collector.gooborg.com/tests/': [ + { + name: 'api.AbortController', + exposure: 'Window', + result: true, + }, + ], + }, + userAgent: chromeAndroid86UaString, + }; + + const sm = getSupportMatrix([report], initialBcd.browsers, []); + + const modified = update(finalBcd, sm, {}); + + assert.equal(modified, false, 'modified'); + assert.deepEqual(finalBcd, initialBcd); + }); + }); +}); diff --git a/scripts/update.ts b/scripts/update.ts new file mode 100644 index 00000000000000..6a66678bcd8df6 --- /dev/null +++ b/scripts/update.ts @@ -0,0 +1,755 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +import assert from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; + +import { + compare as compareVersions, + compareVersions as compareVersionsSort, +} from 'compare-versions'; +import esMain from 'es-main'; +import _minimatch from 'minimatch'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import chalk from 'chalk-template'; +import { fdir } from 'fdir'; + +import { + Browsers, + BrowserName, + SimpleSupportStatement, + Identifier, + SupportStatement, +} from '../types/types.js'; +import { parseUA } from '../utils/ua-parser.js'; + +const { Minimatch } = _minimatch; + +type Exposure = 'Window' | 'Worker' | 'SharedWorker' | 'ServiceWorker'; + +type TestResultValue = boolean | null; + +interface TestResult { + exposure: Exposure; + name: string; + result: TestResultValue; + message?: string; +} + +interface TestResults { + [key: string]: TestResult[]; +} + +export interface Report { + __version: string; + results: TestResults; + userAgent: string; +} + +type BrowserSupportMap = Map; +type SupportMap = Map; +type SupportMatrix = Map; + +export type ManualOverride = [string, string, string, TestResultValue]; + +type Overrides = Array; + +type InternalSupportStatement = SupportStatement | 'mirror'; + +const BASE_DIR = new URL('..', import.meta.url); + +const BCD_DIR = process.env.BCD_DIR + ? path.resolve(process.env.BCD_DIR) + : fileURLToPath(BASE_DIR); + +const MDN_COLLECTOR_DIR = process.env.MDN_COLLECTOR_DIR + ? path.resolve(process.env.MDN_COLLECTOR_DIR) + : fileURLToPath(new URL('../mdn-bcd-collector', BASE_DIR)); + +const CATEGORIES = ['api', 'css.properties', 'javascript.builtins']; + +const { default: mirror } = await import( + `${BCD_DIR}/scripts/release/mirror.js` +); + +export const logger = { + warn: (message: string) => { + console.warn(chalk`{yellow warn}: ${message}`); + }, + info: (message: string) => { + console.info(chalk`{green warn}: ${message}`); + }, +}; + +export const findEntry = ( + bcd: Identifier, + ident: string, +): Identifier | null => { + if (!ident) { + return null; + } + const keys: string[] = ident.split('.'); + let entry: any = bcd; + while (entry && keys.length) { + entry = entry[keys.shift() as string]; + } + return entry; +}; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const combineResults = (results: TestResultValue[]): TestResultValue => { + let supported: TestResultValue = null; + for (const result of results) { + if (result === true) { + // If any result is true, the flattened support should be true. There + // can be contradictory results with multiple exposure scopes, but here + // we treat support in any scope as support of the feature. + return true; + } else if (result === false) { + // This may yet be overruled by a later result (above). + supported = false; + } else if (result === null) { + // Leave supported as it is. + } else { + throw new Error(`result not true/false/null; got ${result}`); + } + } + return supported; +}; + +// Create a string represenation of a version range, optimized for human +// legibility. +const joinRange = (lower: string, upper: string) => + lower === '0' ? `≤${upper}` : `${lower}> ≤${upper}`; + +// Parse a version range string produced by `joinRange` into a lower and upper +// boundary. +export const splitRange = (range: string) => { + const match = range.match(/(?:(.*)> )?(?:≤(.*))/); + if (!match) { + throw new Error(`Unrecognized version range value: "${range}"`); + } + return { lower: match[1] || '0', upper: match[2] }; +}; + +// Get support map from BCD path to test result (null/true/false) for a single +// report. +export const getSupportMap = (report: Report): BrowserSupportMap => { + // Transform `report` to map from test name (BCD path) to array of results. + const testMap = new Map(); + for (const tests of Object.values(report.results)) { + for (const test of tests) { + // TODO: If test.exposure.endsWith('Worker'), then map this to a + // worker_support feature. + const tests = testMap.get(test.name) || []; + tests.push(test.result); + testMap.set(test.name, tests); + } + } + + if (testMap.size === 0) { + throw new Error(`Report for "${report.userAgent}" has no results!`); + } + + // Transform `testMap` to map from test name (BCD path) to flattened support. + const supportMap = new Map(); + for (const [name, results] of testMap.entries()) { + let supported = combineResults(results); + + if (supported === null) { + // If the parent feature support is false, copy that. + // TODO: This assumes that the parent feature came first when iterating + // the report, which isn't guaranteed. Move this to a second phase. + const parentName = name.split('.').slice(0, -1).join('.'); + const parentSupport = supportMap.get(parentName); + if (parentSupport === false) { + supported = false; + } + } + + supportMap.set(name, supported); + } + return supportMap; +}; + +// Load all reports and build a map from BCD path to browser + version +// and test result (null/true/false) for that version. +export const getSupportMatrix = ( + reports: Report[], + browsers: Browsers, + overrides: ManualOverride[], +): SupportMatrix => { + const supportMatrix = new Map(); + + for (const report of reports) { + const { browser, version, inBcd } = parseUA(report.userAgent, browsers); + if (!inBcd) { + if (inBcd === false) { + logger.warn( + `Ignoring unknown ${browser.name} version ${version} (${report.userAgent})`, + ); + } else if (browser.name) { + logger.warn( + `Ignoring unknown browser ${browser.name} ${version} (${report.userAgent})`, + ); + } else { + logger.warn(`Unable to parse browser from UA ${report.userAgent}`); + } + + continue; + } + + const supportMap = getSupportMap(report); + + // Merge `supportMap` into `supportMatrix`. + for (const [name, supported] of supportMap.entries()) { + let browserMap = supportMatrix.get(name); + if (!browserMap) { + browserMap = new Map(); + supportMatrix.set(name, browserMap); + } + let versionMap = browserMap.get(browser.id); + if (!versionMap) { + versionMap = new Map(); + for (const browserVersion of Object.keys( + browsers[browser.id].releases, + )) { + versionMap.set(browserVersion, null); + } + browserMap.set(browser.id, versionMap); + } + assert(versionMap.has(version), `${browser.id} ${version} missing`); + + // In case of multiple reports for a single version it's possible we + // already have (non-null) support information. Combine results to deal + // with this possibility. + const combined = combineResults([supported, versionMap.get(version)]); + versionMap.set(version, combined); + } + } + + // apply manual overrides + for (const [path, browser, version, supported] of overrides) { + const browserMap = supportMatrix.get(path); + if (!browserMap) { + continue; + } + const versionMap = browserMap.get(browser); + if (!versionMap) { + continue; + } + + if (version === '*') { + // All versions of a browser + for (const v of versionMap.keys()) { + versionMap.set(v, supported); + } + } else if (version.includes('+')) { + // Browser versions from x onwards (inclusive) + for (const v of versionMap.keys()) { + if (compareVersions(version.replace('+', ''), v, '<=')) { + versionMap.set(v, supported); + } + } + } else if (version.includes('-')) { + // Browser versions between x and y (inclusive) + const versions = version.split('-'); + for (const v of versionMap.keys()) { + if ( + compareVersions(versions[0], v, '<=') && + compareVersions(versions[1], v, '>=') + ) { + versionMap.set(v, supported); + } + } + } else { + // Single browser versions + versionMap.set(version, supported); + } + } + + return supportMatrix; +}; + +export const inferSupportStatements = ( + versionMap: BrowserSupportMap, +): SimpleSupportStatement[] => { + const versions = Array.from(versionMap.keys()).sort(compareVersionsSort); + + const statements: SimpleSupportStatement[] = []; + const lastKnown: { version: string; support: TestResultValue } = { + version: '0', + support: null, + }; + let lastWasNull = false; + + for (const version of versions) { + const supported = versionMap.get(version); + const lastStatement = statements[statements.length - 1]; + + if (supported === true) { + if (!lastStatement) { + statements.push({ + version_added: + lastWasNull || lastKnown.support === false + ? joinRange(lastKnown.version, version) + : version, + }); + } else if (!lastStatement.version_added) { + lastStatement.version_added = lastWasNull + ? joinRange(lastKnown.version, version) + : version; + } else if (lastStatement.version_removed) { + // added back again + statements.push({ + version_added: version, + }); + } + + lastKnown.version = version; + lastKnown.support = true; + lastWasNull = false; + } else if (supported === false) { + if ( + lastStatement && + lastStatement.version_added && + !lastStatement.version_removed + ) { + lastStatement.version_removed = lastWasNull + ? joinRange(lastKnown.version, version) + : version; + } else if (!lastStatement) { + statements.push({ version_added: false }); + } + + lastKnown.version = version; + lastKnown.support = false; + lastWasNull = false; + } else if (supported === null) { + lastWasNull = true; + // TODO + } else { + throw new Error(`result not true/false/null; got ${supported}`); + } + } + + return statements; +}; + +export const update = ( + bcd: Identifier, + supportMatrix: SupportMatrix, + filter: any, +): boolean => { + let modified = false; + + for (const [path, browserMap] of supportMatrix.entries()) { + if (filter.path) { + if (filter.path.constructor === Minimatch) { + if (!filter.path.match(path)) { + // If filter.path does not match glob + continue; + } + } else if (path !== filter.path && !path.startsWith(`${filter.path}.`)) { + continue; + } + } + + const entry = findEntry(bcd, path); + if (!entry || !entry.__compat) { + continue; + } + + const support = entry.__compat.support; + // Stringified then parsed to deep clone the support statements + const originalSupport = clone(support); + + for (const [browser, versionMap] of browserMap.entries()) { + if ( + filter.browser && + filter.browser.length && + !filter.browser.includes(browser) + ) { + continue; + } + const inferredStatements = inferSupportStatements(versionMap); + if (inferredStatements.length !== 1) { + // TODO: handle more complicated scenarios + logger.warn( + `${path} skipped for ${browser} due to multiple inferred statements`, + ); + continue; + } + + const inferredStatement = inferredStatements[0]; + + // If there's a version number filter + if (filter.release || filter.release === false) { + const filterMatch = + filter.release && filter.release.match(/([\d.]+)-([\d.]+)/); + if (filterMatch) { + if (typeof inferredStatement.version_added !== 'string') { + // If the version_added is not a string, it must be false and won't + // match our + continue; + } + if ( + compareVersions( + inferredStatement.version_added.replace(/(([\d.]+)> )?≤/, ''), + filterMatch[1], + '<', + ) || + compareVersions( + inferredStatement.version_added.replace(/(([\d.]+)> )?≤/, ''), + filterMatch[2], + '>', + ) + ) { + // If version_added is outside of filter range + continue; + } + if ( + typeof inferredStatement.version_removed === 'string' && + (compareVersions( + inferredStatement.version_removed.replace(/(([\d.]+)> )?≤/, ''), + filterMatch[1], + '<', + ) || + compareVersions( + inferredStatement.version_removed.replace(/(([\d.]+)> )?≤/, ''), + filterMatch[2], + '>', + )) + ) { + // If version_removed and it's outside of filter range + continue; + } + } else { + if (filter.release !== inferredStatement.version_added) { + // If version_added doesn't match filter + continue; + } + if ( + inferredStatement.version_removed && + filter.release !== inferredStatement.version_removed + ) { + // If version_removed and it doesn't match filter + continue; + } + } + } + + // Update the support data with a new value. + const persist = (statements: SimpleSupportStatement[]) => { + // Check for ranges and ignore them if we specify `exact-only` argument + if (filter.exactOnly) { + for (const statement of statements) { + if ( + (typeof statement.version_added === 'string' && + statement.version_added.includes('≤')) || + (typeof statement.version_removed === 'string' && + statement.version_removed.includes('≤')) + ) { + return; + } + } + } + + support[browser] = statements.length === 1 ? statements[0] : statements; + modified = true; + }; + + let allStatements = + (support[browser] as InternalSupportStatement) === 'mirror' + ? mirror(browser, originalSupport) + : // Although non-mirrored support data could be modified in-place, + // working with a cloned version forces the subsequent code to + // explicitly assign it back to the originating data structure. + // This reduces the likelihood of inconsistencies in the handling + // of mirrored and non-mirrored support data. + clone(support[browser] || null); + + if (!allStatements) { + allStatements = []; + } else if (!Array.isArray(allStatements)) { + allStatements = [allStatements]; + } + + // Filter to the statements representing the feature being enabled by + // default under the default name and no flags. + const defaultStatements = allStatements.filter((statement) => { + if ('flags' in statement) { + return false; + } + if ('prefix' in statement || 'alternative_name' in statement) { + // TODO: map the results for aliases to these statements. + return false; + } + return true; + }); + + if (defaultStatements.length === 0) { + // Prepend |inferredStatement| to |allStatements|, since there were no + // relevant statements to begin with... + if (inferredStatement.version_added === false) { + // ... but not if the new statement just claims no support, since + // that is implicit in no statement. + continue; + } + // Remove flag data for features which are enabled by default. + // + // See https://github.com/mdn/browser-compat-data/pull/16637 + const nonFlagStatements = allStatements.filter( + (statement) => !('flags' in statement), + ); + persist([inferredStatement, ...nonFlagStatements]); + + continue; + } + + if (defaultStatements.length !== 1) { + // TODO: handle more complicated scenarios + logger.warn( + `${path} skipped for ${browser} due to multiple default statements`, + ); + continue; + } + + const simpleStatement = defaultStatements[0]; + + if (simpleStatement.version_removed) { + // TODO: handle updating existing added+removed entries. + logger.warn( + `${path} skipped for ${browser} due to added+removed statement`, + ); + continue; + } + + // If we infer no support but BCD currently has a version number, check to make sure + // our data is not older than BCD (ex. BCD says 79 but we have results for 40-78) + if ( + inferredStatement.version_added === false && + typeof simpleStatement.version_added === 'string' + ) { + let latestNonNullVersion = ''; + + for (const [version, result] of Array.from( + versionMap.entries(), + ).reverse()) { + if (result === null) { + // Ignore null values + continue; + } + + if ( + !latestNonNullVersion || + compareVersions(version, latestNonNullVersion, '>') + ) { + latestNonNullVersion = version; + } + } + + if ( + simpleStatement.version_added === 'preview' || + compareVersions( + latestNonNullVersion, + simpleStatement.version_added.replace('≤', ''), + '<', + ) + ) { + logger.warn( + `${path} skipped for ${browser}; BCD says support was added in a version newer than there are results for`, + ); + continue; + } + } + + if ( + typeof simpleStatement.version_added === 'string' && + typeof inferredStatement.version_added === 'string' && + inferredStatement.version_added.includes('≤') + ) { + const { lower, upper } = splitRange(inferredStatement.version_added); + const simpleAdded = simpleStatement.version_added.replace('≤', ''); + if ( + simpleStatement.version_added === 'preview' || + compareVersions(simpleAdded, lower, '<=') || + compareVersions(simpleAdded, upper, '>') + ) { + simpleStatement.version_added = inferredStatement.version_added; + persist(allStatements); + } + } else if ( + !( + typeof simpleStatement.version_added === 'string' && + inferredStatement.version_added === true + ) && + simpleStatement.version_added !== inferredStatement.version_added + ) { + // When a "mirrored" statement will be replaced with a statement + // documenting lack of support, notes describing partial implementation + // status are no longer relevant. + if ( + !inferredStatement.version_added && + simpleStatement.partial_implementation + ) { + persist([{ version_added: false }]); + + // Positive test results do not conclusively indicate that a partial + // implementation has been completed. + } else if (!simpleStatement.partial_implementation) { + simpleStatement.version_added = inferredStatement.version_added; + persist(allStatements); + } + } + + if (typeof inferredStatement.version_removed === 'string') { + simpleStatement.version_removed = inferredStatement.version_removed; + persist(allStatements); + } + } + } + + return modified; +}; + +/* c8 ignore start */ +/** + * Read a file and parse it as JSON. + * @param {string} file Path to json file + * @returns {Promise} Parsed JSON object + */ +const readJson = async (file: string): Promise => + JSON.parse(await fs.readFile(file, 'utf8')); + +// |paths| can be files or directories. Returns an object mapping +// from (absolute) path to the parsed file content. +export const loadJsonFiles = async ( + paths: string[], +): Promise<{ [filename: string]: any }> => { + const jsonCrawler = new fdir() + .withFullPaths() + .filter((item) => { + // Ignores .DS_Store, .git, etc. + const basename = path.basename(item); + return basename === '.' || basename[0] !== '.'; + }) + .filter((item) => item.endsWith('.json')); + + const jsonFiles: string[] = []; + + for (const p of paths) { + for (const item of await jsonCrawler.crawl(p).withPromise()) { + jsonFiles.push(item); + } + } + + const entries: [string, JSON][] = []; + + for (const file of jsonFiles) { + entries.push([file, await readJson(file)]); + } + + return Object.fromEntries(entries); +}; + +export const main = async ( + reportPaths: string[], + filter: any, + browsers: Browsers, + overrides: Overrides, +): Promise => { + // Replace filter.path with a minimatch object. + if (filter.path && filter.path.includes('*')) { + filter.path = new Minimatch(filter.path); + } + + if (filter.release === 'false') { + filter.release = false; + } + + const bcdFiles = (await loadJsonFiles( + filter.addNewFeatures + ? [path.join(BCD_DIR, '__missing')] + : CATEGORIES.map((cat) => path.join(BCD_DIR, ...cat.split('.'))), + )) as { [key: string]: Identifier }; + + const reports = Object.values(await loadJsonFiles(reportPaths)) as Report[]; + const supportMatrix = getSupportMatrix( + reports, + browsers, + overrides.filter( + Array.isArray as (item: unknown) => item is ManualOverride, + ), + ); + + // Should match https://github.com/mdn/browser-compat-data/blob/f10bf2cc7d1b001a390e70b7854cab9435ffb443/test/linter/test-style.js#L63 + // TODO: https://github.com/mdn/browser-compat-data/issues/3617 + for (const [file, data] of Object.entries(bcdFiles)) { + const modified = update(data, supportMatrix, filter); + if (!modified) { + continue; + } + logger.info(`Updating ${path.relative(BCD_DIR, file)}`); + const json = JSON.stringify(data, null, ' ') + '\n'; + await fs.writeFile(file, json); + } +}; + +if (esMain(import.meta)) { + const { + default: { browsers }, + } = await import(`${BCD_DIR}/index.js`); + const overrides = await readJson( + path.join(MDN_COLLECTOR_DIR, 'custom/overrides.json'), + ); + + const { argv }: { argv: any } = yargs(hideBin(process.argv)).command( + '$0 [reports..]', + 'Update BCD from a specified set of report files', + (yargs) => { + yargs + .positional('reports', { + describe: 'The report files to update from (also accepts folders)', + type: 'string', + array: true, + default: ['../mdn-bcd-results/'], + }) + .option('path', { + alias: 'p', + describe: + 'The BCD path to update (includes children, ex. "api.Document" will also update "api.Document.body")', + type: 'string', + default: null, + }) + .option('browser', { + alias: 'b', + describe: 'The browser to update', + type: 'array', + choices: Object.keys(browsers), + default: [], + }) + .option('release', { + alias: 'r', + describe: + 'Only update when version_added or version_removed is set to the given value (can be an inclusive range, ex. xx-yy, or `false` for changes that set no support)', + type: 'string', + default: null, + }) + .option('exact-only', { + alias: 'e', + describe: + 'Only update when versions are a specific number (or "false"), disallowing ranges', + type: 'boolean', + default: false, + }); + }, + ); + + await main(argv.reports, argv, browsers, overrides); +} +/* c8 ignore stop */ diff --git a/utils/ua-parser.test.ts b/utils/ua-parser.test.ts new file mode 100644 index 00000000000000..de23caa9d2d2cb --- /dev/null +++ b/utils/ua-parser.test.ts @@ -0,0 +1,539 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +import assert from 'node:assert'; + +import { getMajorMinorVersion, parseUA } from './ua-parser.js'; + +const browsers = { + chrome: { name: 'Chrome', releases: { 82: {}, 83: {}, 84: {}, 85: {} } }, + chrome_android: { name: 'Chrome Android', releases: { 85: {} } }, + edge: { name: 'Edge', releases: { 16: {}, 84: {} } }, + firefox: { name: 'Firefox', releases: { 3.6: {} } }, + ie: { name: 'Internet Explorer', releases: { 8: {}, 11: {} } }, + safari: { + name: 'Safari', + releases: { 13: {}, 13.1: {}, 14: {}, 15: {}, 15.1: {}, 15.2: {} }, + }, + safari_ios: { + name: 'iOS Safari', + releases: { 13: {}, 13.3: {}, 13.4: {}, 14: {} }, + }, + samsunginternet_android: { + name: 'Samsung Internet', + releases: { + '10.0': {}, + 10.2: {}, + '11.0': {}, + 11.2: {}, + '12.0': {}, + 12.1: {}, + }, + }, + webview_android: { + name: 'WebView Android', + releases: { 1.1: {}, 4.4: {}, '4.4.3': {}, 37: {}, 86: {} }, + }, +}; + +describe('getMajorMinorVersion', () => { + it('1.2.3', () => { + assert.strictEqual(getMajorMinorVersion('1.2.3'), '1.2'); + }); + + it('10', () => { + assert.strictEqual(getMajorMinorVersion('10'), '10.0'); + }); + + it('10.0', () => { + assert.strictEqual(getMajorMinorVersion('10.0'), '10.0'); + }); + + it('10.01', () => { + assert.strictEqual(getMajorMinorVersion('10.01'), '10.01'); + }); + + it('10.1', () => { + assert.strictEqual(getMajorMinorVersion('10.1'), '10.1'); + }); + + it('58.0.3029.110', () => { + assert.strictEqual(getMajorMinorVersion('58.0.3029.110'), '58.0'); + }); +}); + +describe('parseUA', () => { + it('Chrome', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36', + browsers, + ), + { + browser: { id: 'chrome', name: 'Chrome' }, + version: '85', + fullVersion: '85.0.4183.121', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: true, + }, + ); + }); + + it('Chrome 1000.1 (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1000.1.4183.121 Safari/537.36', + browsers, + ), + { + browser: { id: 'chrome', name: 'Chrome' }, + version: '1000.1', + fullVersion: '1000.1.4183.121', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: false, + }, + ); + }); + + it('Chrome Android', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 11; Pixel 2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.101 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'chrome_android', name: 'Chrome Android' }, + version: '85', + fullVersion: '85.0.4183.101', + os: { name: 'Android', version: '11' }, + inBcd: true, + }, + ); + }); + + it('Edge (EdgeHTML)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299', + browsers, + ), + { + browser: { id: 'edge', name: 'Edge' }, + version: '16', + fullVersion: '16.16299', + os: { name: 'Windows', version: '10' }, + inBcd: true, + }, + ); + }); + + it('Edge (Chromium)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36 Edg/84.0.522.59', + browsers, + ), + { + browser: { id: 'edge', name: 'Edge' }, + version: '84', + fullVersion: '84.0.522.59', + os: { name: 'Windows', version: '10' }, + inBcd: true, + }, + ); + }); + + it('Firefox 3.6.17', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Windows; U; Windows NT 5.2; en-US; rv:1.9.2.17) Gecko/20110420 Firefox/3.6.17 (.NET CLR 3.5.21022)', + browsers, + ), + { + browser: { id: 'firefox', name: 'Firefox' }, + version: '3.6', + fullVersion: '3.6.17', + os: { name: 'Windows', version: 'XP' }, + inBcd: true, + }, + ); + }); + + it('Internet Explorer (Windows XP)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)', + browsers, + ), + { + browser: { id: 'ie', name: 'Internet Explorer' }, + version: '8', + fullVersion: '8.0', + os: { name: 'Windows', version: 'XP' }, + inBcd: true, + }, + ); + }); + + it('Internet Explorer (Windows 7)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko', + browsers, + ), + { + browser: { id: 'ie', name: 'Internet Explorer' }, + version: '11', + fullVersion: '11.0', + os: { name: 'Windows', version: '7' }, + inBcd: true, + }, + ); + }); + + it('Oculus Browser', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 7.0; SM-G920I Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) OculusBrowser/3.4.9 SamsungBrowser/4.0 Chrome/57.0.2987.146 Mobile VR Safari/537.36', + browsers, + ), + { + browser: { id: 'oculus', name: 'Oculus Browser' }, + version: '3.4', + fullVersion: '3.4.9', + os: { name: 'Android', version: '7.0' }, + inBcd: undefined, + }, + ); + }); + + it('Safari 14', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '14', + fullVersion: '14.0', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: true, + }, + ); + }); + + it('Safari 14.1 (read as Safari 14)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '14', + fullVersion: '14.1', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: true, + }, + ); + }); + + it('Safari 15', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '15', + fullVersion: '15.0', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: true, + }, + ); + }); + + it('Safari 15.2', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '15.2', + fullVersion: '15.2', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: true, + }, + ); + }); + + it('Safari 15.3 (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '15.3', + fullVersion: '15.3', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: false, + }, + ); + }); + + it('Safari 16 (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '16.0', + fullVersion: '16.0', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: false, + }, + ); + }); + + it('Safari 7.1 (ignored)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/600.8.9 (KHTML, like Gecko) Version/7.1 Safari/600.8.9', + browsers, + ), + { + browser: { id: 'safari', name: 'Safari' }, + version: '7.1', + fullVersion: '7.1', + os: { name: 'Mac OS', version: '10.15.6' }, + inBcd: false, + }, + ); + }); + + it('Safari iOS', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1', + browsers, + ), + { + browser: { id: 'safari_ios', name: 'iOS Safari' }, + version: '13.4', + fullVersion: '13.5.1', + os: { name: 'iOS', version: '13.5.1' }, + inBcd: true, + }, + ); + }); + + it('Samsung Internet 10.1 (read as 10.0)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/10.1 Chrome/71.0.3578.99 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'samsunginternet_android', name: 'Samsung Internet' }, + version: '10.0', + fullVersion: '10.1', + os: { name: 'Android', version: '9' }, + inBcd: true, + }, + ); + }); + + it('Samsung Internet 12.0', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 11; Pixel 2) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/12.0 Chrome/79.0.3945.136 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'samsunginternet_android', name: 'Samsung Internet' }, + version: '12.0', + fullVersion: '12.0', + os: { name: 'Android', version: '11' }, + inBcd: true, + }, + ); + }); + + it('Samsung Internet 12.1', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 11; Pixel 2) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/12.1 Chrome/79.0.3945.136 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'samsunginternet_android', name: 'Samsung Internet' }, + version: '12.1', + fullVersion: '12.1', + os: { name: 'Android', version: '11' }, + inBcd: true, + }, + ); + }); + + it('Samsung Internet 12.2 (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 11; Pixel 2) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/12.2 Chrome/79.0.3945.136 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'samsunginternet_android', name: 'Samsung Internet' }, + version: '12.2', + fullVersion: '12.2', + os: { name: 'Android', version: '11' }, + inBcd: false, + }, + ); + }); + + it('WebView Android (Android Browser, 1.1)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; U; Android 1.1; en-us; generic) AppleWebKit/525.10+ (KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '1.1', + fullVersion: '1.1', + os: { name: 'Android', version: '1.1' }, + inBcd: true, + }, + ); + }); + + it('WebView Android (Android Browser, 4.4.2, Chrome 30)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 4.4.2; Android SDK built for x86 Build/KK) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '4.4', + fullVersion: '4.4.2', + os: { name: 'Android', version: '4.4.2' }, + inBcd: true, + }, + ); + }); + + it('WebView Android (Android Browser, 4.4.3, Chrome 33)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; U; Android 4.4.3; en-us; HTC_0P6B130 Build/KTU84L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '4.4.3', + fullVersion: '4.4.3', + os: { name: 'Android', version: '4.4.3' }, + inBcd: true, + }, + ); + }); + + it('WebView Android (Android Browser, 4.4.4, Chrome 33)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; U; Android 4.4.4; en-us; HTC_0P6B130 Build/KTU84L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '4.4.3', + fullVersion: '4.4.4', + os: { name: 'Android', version: '4.4.4' }, + inBcd: true, + }, + ); + }); + + it('WebView Android (Android Browser, 5.0.2, Chrome 37)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 5.0.2; Android SDK built for x86_64 Build/LSY66K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/37.0.0.0 Mobile Safari/537.36', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '37', + fullVersion: '37.0.0.0', + os: { name: 'Android', version: '5.0.2' }, + inBcd: true, + }, + ); + }); + + it('WebView Android (11, Chrome 86)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Linux; Android 11; Pixel 2 Build/RP1A.200720.009; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/86.0.4240.198 Mobile Safari/537.36 WEBVIEW TEST/1.2.1.80 (Phone; anonymous)', + browsers, + ), + { + browser: { id: 'webview_android', name: 'WebView Android' }, + version: '86', + fullVersion: '86.0.4240.198', + os: { name: 'Android', version: '11' }, + inBcd: true, + }, + ); + }); + + it('Chrome on iOS (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/91.0.4472.80 Mobile/15E148 Safari/604.1', + browsers, + ), + { + browser: { id: 'chrome_ios', name: 'Chrome iOS' }, + version: '14.6', + fullVersion: '14.6', + os: { name: 'iOS', version: '14.6' }, + inBcd: undefined, + }, + ); + }); + + it('Yandex Browser (not in BCD)', () => { + assert.deepEqual( + parseUA( + 'Mozilla/5.0 (Windows NT 6.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 YaBrowser/17.6.1.749 Yowser/2.5 Safari/537.36', + browsers, + ), + { + browser: { id: 'yandex', name: 'Yandex' }, + version: '17.6', + fullVersion: '17.6.1.749', + os: { name: 'Windows', version: '8.1' }, + inBcd: undefined, + }, + ); + }); + + it('node-superagent (unparseable)', () => { + assert.deepEqual(parseUA('node-superagent/1.2.3', browsers), { + browser: { id: '', name: '' }, + version: '', + fullVersion: '', + os: { name: '', version: '' }, + inBcd: undefined, + }); + }); +}); diff --git a/utils/ua-parser.ts b/utils/ua-parser.ts new file mode 100644 index 00000000000000..b3e15920dad039 --- /dev/null +++ b/utils/ua-parser.ts @@ -0,0 +1,160 @@ +/* This file is a part of @mdn/browser-compat-data + * See LICENSE file for more information. */ + +import { + compare as compareVersions, + compareVersions as compareVersionsSort, +} from 'compare-versions'; +import uaParser from 'ua-parser-js'; + +const getMajorVersion = (version) => version.split('.')[0]; + +const getMajorMinorVersion = (version) => { + const [major, minor] = version.split('.'); + return `${major}.${minor || 0}`; +}; + +const parseUA = (userAgent, browsers) => { + const ua = uaParser(userAgent); + + const data: { + browser: { + id: string; + name: string; + }; + version: string; + fullVersion: string; + os: { + name: string; + version: string; + }; + inBcd: boolean | undefined; + } = { + browser: { id: '', name: '' }, + version: '', + fullVersion: '', + os: { name: '', version: '' }, + inBcd: undefined, + }; + + if (!ua.browser.name) { + return data; + } + + data.browser.id = ua.browser.name.toLowerCase().replace(/ /g, '_'); + data.browser.name = ua.browser.name; + data.os.name = ua.os.name || ''; + data.os.version = ua.os.version || ''; + + switch (data.browser.id) { + case 'mobile_safari': + data.browser.id = 'safari'; + break; + case 'oculus_browser': + data.browser.id = 'oculus'; + break; + case 'samsung_browser': + data.browser.id = 'samsunginternet'; + break; + case 'android_browser': + case 'chrome_webview': + data.browser.id = 'webview'; + break; + } + + const os = data.os.name.toLowerCase(); + if (os === 'android' && data.browser.id !== 'oculus') { + data.browser.id += '_android'; + data.browser.name += ' Android'; + + if (ua.browser.name === 'Android Browser') { + // For early WebView Android, use the OS version + data.fullVersion = compareVersions(ua.os.version, '5.0', '<') + ? ua.os.version + : ua.engine.version; + } + } else if (os === 'ios') { + data.browser.id += '_ios'; + data.browser.name += ' iOS'; + + // https://github.com/mdn/browser-compat-data/blob/main/docs/data-guidelines.md#safari-for-ios-versioning + data.fullVersion = ua.os.version; + } + + data.fullVersion = data.fullVersion || ua.browser.version; + data.version = getMajorMinorVersion(data.fullVersion); + + if (!(data.browser.id in browsers)) { + return data; + } + + data.browser.name = browsers[data.browser.id].name; + data.inBcd = false; + + const versions = Object.keys(browsers[data.browser.id].releases); + versions.sort(compareVersionsSort); + + // Android 4.4.3 needs to be handled as a special case, because its data + // differs from 4.4, and the code below will strip out the patch versions from + // our version numbers. + if ( + data.browser.id === 'webview_android' && + compareVersions(data.fullVersion, '4.4.3', '>=') && + compareVersions(data.fullVersion, '5.0', '<') + ) { + data.version = '4.4.3'; + data.inBcd = true; + return data; + } + + // Certain Safari versions are backports of newer versions, but contain less + // features, particularly ones involving OS integration. We are explicitly + // marking these versions as "not in BCD" to avoid confusion. + if ( + data.browser.id === 'safari' && + ['4.1', '6.1', '6.2', '7.1'].includes(data.version) + ) { + return data; + } + + // The |version| from the UA string is typically more precise than |versions| + // from BCD, and some "uninteresting" releases are missing from BCD. To deal + // with this, find the pair of versions in |versions| that sandwiches + // |version|, and use the first of this pair. For example, given |version| + // "10.1" and |versions| entries "10.0" and "10.2", return "10.0". + for (let i = 0; i < versions.length - 1; i++) { + const current = versions[i]; + const next = versions[i + 1]; + if ( + compareVersions(data.version, current, '>=') && + compareVersions(data.version, next, '<') + ) { + data.inBcd = true; + data.version = current; + break; + } + } + + // We reached the last entry in |versions|. With no |next| to compare against + // we have to check if it looks like a significant release or not. By default + // that means a new major version, but for Safari and Samsung Internet the + // major and minor version are significant. + let normalize = getMajorVersion; + if ( + data.browser.id.startsWith('safari') || + data.browser.id === 'samsunginternet_android' + ) { + normalize = getMajorMinorVersion; + } + if ( + data.inBcd == false && + normalize(data.version) === normalize(versions[versions.length - 1]) + ) { + data.inBcd = true; + data.version = versions[versions.length - 1]; + } + + return data; +}; + +export { getMajorMinorVersion, parseUA };