diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..173cf4d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +### What does this PR do? + + + +Related to: # + +### What are the observable changes? + + + + + + + + +### Good PR checklist + +- [ ] Title makes sense +- [ ] Is against the correct branch +- [ ] Only addresses one issue +- [ ] Properly assigned +- [ ] Added/updated tests +- [ ] Added/updated documentation +- [ ] Properly labeled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bcd9f2..642dfbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: tags: - v* pull_request: + workflow_dispatch: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -16,6 +17,35 @@ permissions: packages: write jobs: + web-baseline: + name: Web Baseline Coverage + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + GA_PROPERTY_ID: ${{ secrets.GA_PROPERTY_ID }} + GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64 }} + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v3 + with: + node-version: '${{ env.NODE_VERSION }}' + cache: 'pnpm' + - name: Install Dependencies + run: pnpm install + - name: Validate Web Baseline Target + run: pnpm web-baseline:check:target + - name: Test Web Baseline Checker + run: pnpm web-baseline:test + - name: Check Web Baseline Traffic Coverage + if: env.GA_PROPERTY_ID != '' && env.GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64 != '' + run: pnpm web-baseline:check + - name: Skip Web Baseline Traffic Coverage + if: env.GA_PROPERTY_ID == '' || env.GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64 == '' + run: echo "Skipping GA4 traffic coverage check because GA secrets are not configured." + release: name: Release Package runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index 90f1fc3..a71c833 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,76 @@ pnpm add -D @upfluence/w-conf You can extend your project's configuration files with the shared configurations provided by `@upfluence/w-conf`. +### Browsers + +The shared browser target is exported as JSON. It is intended for use with Ember `config/targets.js` files: + +```js +// config/targets.js +'use strict'; + +const browsers = require('@upfluence/w-conf/browsers/web-baseline.json'); + +module.exports = { browsers }; + +The current baseline uses Web Platform Baseline Widely Available, including +compatible downstream browsers, with an explicit iOS Safari safety floor: + +```json +["baseline widely available with downstream", "ios_saf >= 15.6"] +``` + +`baseline widely available with downstream` tracks features that are broadly +available across the Baseline core browser set and includes compatible +downstream browsers, such as browsers derived from Chromium or Gecko. + +`ios_saf >= 15.6` is intentionally added as a mobile safety rail. Based on our +traffic analysis, older iOS/Safari versions are the main unsupported pocket, and +mobile Safari traffic is important enough for us to keep this explicit floor. + +This removes legacy targets such as IE 11 while keeping a stable, shared target +set across Ember apps, addons, engines, and host apps. + +Consumer projects need a Browserslist toolchain that supports Baseline queries. +If a build fails with `Unknown browser query`, update the consumer's +Browserslist-related dependencies before using this shared target. + +`pnpm dlx update-browserslist-db latest` + + +#### Web Baseline Coverage Check + +`w-conf` also ships a repository-level check that compares the shared browser +target against real Google Analytics traffic: + +```bash +pnpm web-baseline:check +``` + +The GA-backed check uses a 180-day window, `Sessions` as the primary metric, and +fails when known-session coverage drops below 95% or unknown traffic rises above +1%. + +For local CSV exports matching the GA browser export shape: + +```bash +pnpm web-baseline:check:csv /path/to/analytics-export.csv +``` + +To validate that the configured target can be parsed by the bundled Browserslist +version: + +```bash +pnpm web-baseline:check:target +``` + +GA4 mode requires a property ID and a base64-encoded service-account JSON value: + +```bash +export GA_PROPERTY_ID=123456789 +export GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64="$(base64 -i service-account.json)" +``` + ### Prettier The base Prettier configuration includes sensible defaults for code formatting that align with Upfluence's coding standards, including settings for Handlebars files and import sorting. diff --git a/package.json b/package.json index ea1f91f..8460e74 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,17 @@ "engines": { "node": ">=20.0.0" }, + "scripts": { + "web-baseline:check": "node scripts/check-web-baseline-coverage.mjs", + "web-baseline:check:csv": "node scripts/check-web-baseline-coverage.mjs --csv", + "web-baseline:check:target": "node scripts/check-web-baseline-coverage.mjs --check-target-only", + "web-baseline:test": "node tests/web-baseline/coverage-test.mjs" + }, "devDependencies": { + "@google-analytics/data": "^7.0.0", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "baseline-browser-mapping": "^2.11.13", + "browserslist": "^4.28.8", "eslint": "^10.5.0", "prettier": "^3.6.2", "typescript": "^6.0.3" @@ -48,6 +57,7 @@ ], "exports": { "./prettier": "./src/prettier/index.mjs", - "./eslint": "./src/eslint/index.mjs" + "./eslint": "./src/eslint/index.mjs", + "./browsers/web-baseline.json": "./src/browsers/web-baseline.json" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3072a2..2b5da9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,18 @@ importers: specifier: ^8.62.0 version: 8.62.0(eslint@10.5.0)(typescript@6.0.3) devDependencies: + '@google-analytics/data': + specifier: ^7.0.0 + version: 7.0.0 '@trivago/prettier-plugin-sort-imports': specifier: ^5.2.2 version: 5.2.2(prettier@3.8.1) + baseline-browser-mapping: + specifier: ^2.11.13 + version: 2.11.13 + browserslist: + specifier: ^4.28.8 + version: 4.28.8 eslint: specifier: ^10.5.0 version: 10.5.0 @@ -148,6 +157,19 @@ packages: '@glimmer/wire-format@0.94.8': resolution: {integrity: sha512-A+Cp5m6vZMAEu0Kg/YwU2dJZXyYxVJs2zI57d3CP6NctmX7FsT8WjViiRUmt5abVmMmRH5b8BUovqY6GSMAdrw==} + '@google-analytics/data@7.0.0': + resolution: {integrity: sha512-pAucE4Ek6i6KDprqYXioAhKl2aiKUuM6i2IThOtZV/Andr95+XF3fBsUW67MgT1a+LcwJTCCJLhabzZWGDp9pA==} + engines: {node: '>=22'} + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@handlebars/parser@2.2.2': resolution: {integrity: sha512-n/SZW+12rwikx/f8YcSv9JCi5p9vn1Bnts9ZtVvfErG4h0gbjHI1H1ZMhVUnaOC7yzFc6PtsCKIK8XeTnL90Gw==} engines: {node: ^18 || ^20 || ^22 || >=24} @@ -185,6 +207,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -324,6 +349,33 @@ packages: '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@simple-dom/interface@1.4.0': resolution: {integrity: sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA==} @@ -355,6 +407,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -424,9 +479,21 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -439,10 +506,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -458,6 +558,10 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -473,11 +577,20 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editorconfig@3.0.2: resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} engines: {node: '>=20'} hasBin: true + electron-to-chromium@1.5.405: + resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} + ember-eslint-parser@0.13.0: resolution: {integrity: sha512-i8mt96+yxFQaTNcz/2+SAkwpeLYU+VOFyHBshRyNL3HOciZhPpX3WszJK0/9GhVi8hQVGNtt21Iv7tsui3x0cQ==} engines: {node: '>=16.0.0'} @@ -496,10 +609,20 @@ packages: ember-rfc176-data@0.3.18: resolution: {integrity: sha512-JtuLoYGSjay1W3MQAxt3eINWXNYYQliK90tLwtb8aeCuQK8zKGCRbBodVIrkcTqshULMnRuTOS6t1P7oQk3g6Q==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.24.0: resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} engines: {node: '>=10.13.0'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -603,6 +726,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -621,6 +747,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -636,6 +766,22 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + gaxios@7.3.0: + resolution: {integrity: sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==} + engines: {node: '>=18'} + + gcp-metadata@9.0.2: + resolution: {integrity: sha512-zzoIz7qKIxyDp7Zghb12kl+n5Y1ofqYX97O/5MelKuFuDolvmT9RuIBGpEnHjqS4GBzPgMZFqH+WSUafRkn5dQ==} + engines: {node: '>=22'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -654,6 +800,18 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@11.0.1: + resolution: {integrity: sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==} + engines: {node: '>=22'} + + google-gax@6.0.1: + resolution: {integrity: sha512-alzECOlNct1KTBeL9PQ6GIxJHvUQAp1MtffaSMx5+8f4hGRoDaET9oIWbHUtzfrFXRUsf6lnznum09yAKhU7AA==} + engines: {node: '>=22'} + + google-logging-utils@2.0.1: + resolution: {integrity: sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==} + engines: {node: '>=22'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -665,6 +823,14 @@ packages: resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} engines: {node: '>=20.10'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -677,10 +843,17 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -699,6 +872,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -708,6 +884,12 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -735,6 +917,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -757,6 +942,26 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -797,10 +1002,26 @@ packages: engines: {node: '>=14'} hasBin: true + proto3-json-serializer@4.0.1: + resolution: {integrity: sha512-Y7YP6oERw5XsP+eFw0Pxe2DPjC6/lAW/ktxAKX+Sgb9fAWV4sl6hHIrBtq4taTkD7x3wLpgPS9ACn/czMp9rbw==} + engines: {node: '>=22'} + + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -808,6 +1029,13 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry-request@9.0.1: + resolution: {integrity: sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ==} + engines: {node: '>=22'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -831,6 +1059,26 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} @@ -838,6 +1086,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + teeny-request@11.0.1: + resolution: {integrity: sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA==} + engines: {node: '>=22'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -871,9 +1123,25 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -883,6 +1151,25 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1011,6 +1298,24 @@ snapshots: dependencies: '@glimmer/interfaces': 0.94.6 + '@google-analytics/data@7.0.0': + dependencies: + google-gax: 6.0.1 + transitivePeerDependencies: + - supports-color + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@handlebars/parser@2.2.2': {} '@humanfs/core@0.19.2': @@ -1043,6 +1348,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1118,6 +1425,26 @@ snapshots: '@oxc-project/types@0.130.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@simple-dom/interface@1.4.0': {} '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.8.1)': @@ -1143,6 +1470,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1240,6 +1571,8 @@ snapshots: acorn@8.17.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -1247,16 +1580,52 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-query@5.3.2: {} axobject-query@4.1.0: {} balanced-match@4.0.4: {} + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.13: {} + + bignumber.js@9.3.1: {} + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.405 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + buffer-equal-constant-time@1.0.1: {} + + caniuse-lite@1.0.30001809: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@14.0.3: {} content-tag@4.2.0: {} @@ -1272,6 +1641,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + data-uri-to-buffer@4.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1283,6 +1654,17 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + editorconfig@3.0.2: dependencies: '@one-ini/wasm': 0.2.1 @@ -1290,6 +1672,8 @@ snapshots: minimatch: 10.2.5 semver: 7.8.5 + electron-to-chromium@1.5.405: {} + ember-eslint-parser@0.13.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0)(typescript@6.0.3))(typescript@6.0.3): dependencies: '@glimmer/syntax': 0.95.0 @@ -1314,11 +1698,19 @@ snapshots: ember-rfc176-data@0.3.18: {} + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.24.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} eslint-compat-utils@0.5.1(eslint@10.5.0): @@ -1454,6 +1846,8 @@ snapshots: esutils@2.0.3: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1464,6 +1858,11 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -1480,6 +1879,28 @@ snapshots: flatted@3.4.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + gaxios@7.3.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@9.0.2: + dependencies: + gaxios: 7.3.0 + google-logging-utils: 2.0.1 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-caller-file@2.0.5: {} + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -1494,20 +1915,66 @@ snapshots: globrex@0.1.2: {} + google-auth-library@11.0.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.0 + gcp-metadata: 9.0.2 + google-logging-utils: 2.0.1 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-gax@6.0.1: + dependencies: + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.8.1 + duplexify: 4.1.3 + google-auth-library: 11.0.1 + google-logging-utils: 2.0.1 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 4.0.1 + protobufjs: 7.6.5 + retry-request: 9.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@2.0.1: {} + graceful-fs@4.2.11: {} html-tags@3.3.1: {} html-tags@5.1.0: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + inherits@2.0.4: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -1520,12 +1987,27 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -1551,6 +2033,8 @@ snapshots: lodash@4.17.23: {} + long@5.3.2: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -1572,6 +2056,22 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.53: {} + + object-hash@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1626,12 +2126,47 @@ snapshots: prettier@3.8.1: {} + proto3-json-serializer@4.0.1: + dependencies: + protobufjs: 7.6.5 + + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.2.0 + long: 5.3.2 + punycode@2.3.1: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + requireindex@1.2.0: {} resolve-pkg-maps@1.0.0: {} + retry-request@9.0.1: + dependencies: + extend: 3.0.2 + teeny-request: 11.0.1 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + semver@7.8.5: {} shebang-command@2.0.0: @@ -1649,10 +2184,41 @@ snapshots: source-map-js@1.2.1: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stubs@3.0.0: {} + svg-tags@1.0.0: {} tapable@2.3.3: {} + teeny-request@11.0.1: + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -1683,14 +2249,48 @@ snapshots: typescript@6.0.3: {} + undici-types@8.3.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + + web-streams-polyfill@3.3.3: {} + which@2.0.2: dependencies: isexe: 2.0.0 word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} diff --git a/scripts/check-web-baseline-coverage.mjs b/scripts/check-web-baseline-coverage.mjs new file mode 100644 index 0000000..e4b003b --- /dev/null +++ b/scripts/check-web-baseline-coverage.mjs @@ -0,0 +1,639 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { BetaAnalyticsDataClient } from '@google-analytics/data'; +import { getCompatibleVersions } from 'baseline-browser-mapping'; +import browserslist from 'browserslist'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const policyPath = path.join(root, 'src/browsers/web-baseline-policy.json'); +const analyticsScope = 'https://www.googleapis.com/auth/analytics.readonly'; +const baselineBrowserAliases = { + chrome: 'chrome', + chrome_android: 'and_chr', + edge: 'edge', + firefox: 'firefox', + firefox_android: 'and_ff', + opera: 'opera', + opera_android: 'op_mob', + safari: 'safari', + safari_ios: 'ios_saf', + samsunginternet_android: 'samsung', + webview_android: 'android' +}; +const coreBaselineBrowsers = new Set(['chrome', 'edge', 'firefox', 'safari', 'ios_saf']); +const browserDisplayNames = { + and_chr: 'Chrome Android', + and_ff: 'Firefox Android', + android: 'Android WebView', + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + ios_saf: 'iOS Safari', + op_mob: 'Opera Android', + opera: 'Opera', + safari: 'Safari', + samsung: 'Samsung Internet' +}; + +const browserAliases = { + 'android webview': 'android', + chrome: 'chrome', + edge: 'edge', + firefox: 'firefox', + opera: 'opera', + safari: 'safari', + 'safari (in-app)': 'safari', + 'samsung internet': 'samsung' +}; + +main().catch((error) => { + console.error(`\nWeb baseline check failed: ${error.message}`); + process.exit(1); +}); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const policy = await readJson(policyPath); + validatePolicy(policy); + const targetQueries = await readTargetQueries(policy); + const supportedTargets = resolveSupportedTargets(targetQueries); + + if (args.help) { + printHelp(); + return; + } + + if (args['check-target-only']) { + printTargetSummary(targetQueries, supportedTargets); + return; + } + + const source = await loadTrafficRows(args, policy); + const result = analyzeTraffic(source.rows, supportedTargets, policy); + + printReport({ + dateRange: source.dateRange, + policy, + result, + targetQueries, + supportedTargets + }); + + const failures = validateResult(result); + const knownCoverage = result.covered.sessions / result.known.sessions; + const unknownRate = result.unknown.sessions / result.total.sessions; + + if (knownCoverage < policy.minimumSessionCoverage) { + failures.push( + `known session coverage ${formatPercent(knownCoverage)} is below ${formatPercent(policy.minimumSessionCoverage)}` + ); + } + + if (unknownRate > policy.maximumUnknownTraffic) { + failures.push( + `unknown session traffic ${formatPercent(unknownRate)} is above ${formatPercent(policy.maximumUnknownTraffic)}` + ); + } + + if (failures.length > 0) { + console.log('\nResult: FAIL'); + failures.forEach((failure) => console.log(`- ${failure}`)); + process.exit(1); + } + + console.log('\nResult: PASS'); +} + +function validatePolicy(policy) { + if (policy.primaryMetric !== 'sessions') { + throw new Error('web-baseline-policy.json primaryMetric must be "sessions".'); + } + + if (policy.secondaryMetric !== 'screenPageViews') { + throw new Error('web-baseline-policy.json secondaryMetric must be "screenPageViews".'); + } +} + +function validateResult(result) { + const failures = []; + + if (result.total.sessions === 0) { + failures.push('traffic result contains zero total sessions'); + } + + if (result.known.sessions === 0) { + failures.push('traffic result contains zero known sessions'); + } + + return failures; +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + args.help = true; + } else if (arg === '--csv') { + const next = argv[index + 1]; + if (next && !next.startsWith('--')) { + args.csv = next; + index += 1; + } else { + args.csv = process.env.WEB_BASELINE_CSV_PATH || true; + } + } else if (arg === '--check-target-only') { + args['check-target-only'] = true; + } else if (arg === '--start-date') { + args.startDate = argv[index + 1]; + index += 1; + } else if (arg === '--end-date') { + args.endDate = argv[index + 1]; + index += 1; + } else if (!args.csv && !arg.startsWith('--')) { + args.csv = arg; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function printHelp() { + console.log(` +Usage: + pnpm web-baseline:check + pnpm web-baseline:check:csv + node scripts/check-web-baseline-coverage.mjs --check-target-only + +Environment: + GA_PROPERTY_ID + GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64 + WEB_BASELINE_CSV_PATH +`); +} + +async function loadTrafficRows(args, policy) { + if (args.csv && args.csv !== true) { + return parseCsvExport(await readFile(path.resolve(args.csv), 'utf8')); + } + + if (args.csv === true) { + throw new Error('CSV mode needs a path argument or WEB_BASELINE_CSV_PATH.'); + } + + return fetchAnalyticsRows(args, policy); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf8')); +} + +async function readTargetQueries(policy) { + const targetPath = path.resolve(path.dirname(policyPath), policy.target); + return readJson(targetPath); +} + +function resolveSupportedTargets(targetQueries) { + const targets = browserslist(targetQueries); + const minimums = new Map(); + const minimumDetails = new Map(); + + for (const target of getCompatibleVersions({ includeDownstreamBrowsers: true })) { + const browser = baselineBrowserAliases[target.browser]; + const candidate = parseVersion(target.version); + + if (!browser || !candidate) { + continue; + } + + mergeMinimum(minimums, minimumDetails, { + browser, + version: candidate, + releaseDate: target.release_date, + source: coreBaselineBrowsers.has(browser) ? 'Baseline Widely Available' : 'downstream' + }); + } + + for (const query of targetQueries.filter((targetQuery) => !targetQuery.startsWith('baseline '))) { + for (const target of browserslist(query)) { + const [browser, version] = target.split(' '); + const candidate = parseVersion(version); + + if (!candidate) { + continue; + } + + mergeMinimum(minimums, minimumDetails, { + browser, + version: candidate, + releaseDate: null, + source: 'explicit override' + }); + } + } + + return { minimumDetails, minimums, targets }; +} + +function mergeMinimum(minimums, minimumDetails, detail) { + const current = minimums.get(detail.browser); + + if (!current || compareVersions(detail.version, current) < 0) { + minimums.set(detail.browser, detail.version); + minimumDetails.set(detail.browser, detail); + } +} + +function printTargetSummary(targetQueries, supportedTargets) { + console.log('Web baseline target parsed successfully.'); + console.log(`Queries: ${targetQueries.join(', ')}`); + printBrowserMinimums(supportedTargets); +} + +async function fetchAnalyticsRows(args, policy) { + const propertyId = process.env.GA_PROPERTY_ID; + const credentialsBase64 = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64; + + if (!propertyId) { + throw new Error('GA_PROPERTY_ID is required for GA4 mode.'); + } + + if (!credentialsBase64) { + throw new Error('GOOGLE_APPLICATION_CREDENTIALS_JSON_BASE64 is required for GA4 mode.'); + } + + const credentials = JSON.parse(Buffer.from(credentialsBase64, 'base64').toString('utf8')); + const client = new BetaAnalyticsDataClient({ + credentials, + scopes: [analyticsScope] + }); + const endDate = args.endDate || 'yesterday'; + const startDate = args.startDate || `${policy.windowDays}daysAgo`; + const request = { + property: `properties/${propertyId}`, + dateRanges: [{ startDate, endDate }], + dimensions: [ + { name: 'browser' }, + { name: 'browserVersion' }, + { name: 'deviceCategory' }, + { name: 'operatingSystem' }, + { name: 'operatingSystemVersion' } + ], + metrics: [{ name: policy.primaryMetric }, { name: policy.secondaryMetric }], + limit: 250000 + }; + const [response] = await client.runReport(request); + const metricIndexes = buildMetricIndexes(response.metricHeaders || []); + + return { + dateRange: `${startDate} to ${endDate}`, + rows: (response.rows || []).map((row) => ({ + browser: row.dimensionValues[0]?.value || '', + browserVersion: row.dimensionValues[1]?.value || '', + deviceCategory: row.dimensionValues[2]?.value || '', + operatingSystem: row.dimensionValues[3]?.value || '', + osVersion: row.dimensionValues[4]?.value || '', + views: readMetricValue(row, metricIndexes, policy.secondaryMetric), + sessions: readMetricValue(row, metricIndexes, policy.primaryMetric) + })) + }; +} + +function buildMetricIndexes(metricHeaders) { + return new Map(metricHeaders.map((header, index) => [header.name, index])); +} + +function readMetricValue(row, metricIndexes, metricName) { + const index = metricIndexes.get(metricName); + + if (index === undefined) { + throw new Error(`GA4 response did not include metric "${metricName}".`); + } + + return Number(row.metricValues[index]?.value || 0); +} + +function parseCsvExport(input) { + const lines = input.split(/\r?\n/).filter(Boolean); + const dateRange = lines.find((line) => /^#\s*\d{8}-\d{8}/.test(line))?.replace(/^#\s*/, '') || 'CSV export'; + const headerIndex = lines.findIndex((line) => line.startsWith('Browser,Browser version,')); + + if (headerIndex === -1) { + throw new Error('CSV header not found.'); + } + + const headers = parseCsvLine(lines[headerIndex]); + const rows = []; + + for (const line of lines.slice(headerIndex + 1)) { + const values = parseCsvLine(line); + const record = Object.fromEntries(headers.map((header, index) => [header, values[index] || ''])); + + if (values.includes('Grand total')) { + continue; + } + + rows.push({ + browser: record.Browser, + browserVersion: record['Browser version'], + deviceCategory: record['Device category'], + operatingSystem: record['Operating system'], + osVersion: record['OS version'], + views: parseMetric(record.Views), + sessions: parseMetric(record.Sessions) + }); + } + + return { dateRange, rows }; +} + +function parseCsvLine(line) { + const values = []; + let value = ''; + let quoted = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + + if (char === '"' && quoted && next === '"') { + value += '"'; + index += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + values.push(value); + value = ''; + } else { + value += char; + } + } + + values.push(value); + return values; +} + +function parseMetric(value) { + return Number(String(value || '0').replace(/,/g, '')); +} + +function analyzeTraffic(rows, supportedTargets, policy) { + const result = { + total: emptyMetrics(), + known: emptyMetrics(), + covered: emptyMetrics(), + unsupported: emptyMetrics(), + unknown: emptyMetrics(), + devices: new Map(), + unsupportedRows: [] + }; + + for (const row of rows) { + const metrics = { sessions: row.sessions, views: row.views }; + addMetrics(result.total, metrics); + + const classification = classifyRow(row, supportedTargets.minimums); + const device = normalize(row.deviceCategory) || 'unknown'; + const deviceResult = result.devices.get(device) || { + total: emptyMetrics(), + covered: emptyMetrics(), + unsupported: emptyMetrics(), + unknown: emptyMetrics() + }; + + result.devices.set(device, deviceResult); + addMetrics(deviceResult.total, metrics); + + if (classification.status === 'covered') { + addMetrics(result.known, metrics); + addMetrics(result.covered, metrics); + addMetrics(deviceResult.covered, metrics); + } else if (classification.status === 'unsupported') { + addMetrics(result.known, metrics); + addMetrics(result.unsupported, metrics); + addMetrics(deviceResult.unsupported, metrics); + result.unsupportedRows.push({ ...row, reason: classification.reason }); + } else { + addMetrics(result.unknown, metrics); + addMetrics(deviceResult.unknown, metrics); + } + } + + result.unsupportedRows.sort((left, right) => right.sessions - left.sessions); + return result; +} + +function classifyRow(row, minimums) { + const browser = normalize(row.browser); + const operatingSystem = normalize(row.operatingSystem); + + if (!browser || browser === '(not set)' || browser === 'mozilla compatible agent') { + return { status: 'unknown', reason: 'unmapped browser' }; + } + + const targetBrowser = resolveTargetBrowser(row, browser, operatingSystem); + const minimum = minimums.get(targetBrowser); + + if (!targetBrowser || !minimum) { + return { status: 'unknown', reason: `no target mapping for ${row.browser}` }; + } + + const version = targetBrowser === 'ios_saf' ? parseVersion(row.osVersion) : parseVersion(row.browserVersion); + + if (!version) { + return { status: 'unknown', reason: 'missing version' }; + } + + if (compareVersions(version, minimum) >= 0) { + return { status: 'covered' }; + } + + return { + status: 'unsupported', + reason: `${targetBrowser} ${formatVersion(version)} < ${formatVersion(minimum)}` + }; +} + +function resolveTargetBrowser(row, browser, operatingSystem) { + if (isIos(row, operatingSystem)) { + return 'ios_saf'; + } + + if (browser === 'chrome' && normalize(row.deviceCategory) === 'mobile') { + return 'and_chr'; + } + + if (browser === 'firefox' && normalize(row.deviceCategory) === 'mobile') { + return 'and_ff'; + } + + return browserAliases[browser]; +} + +function isIos(row, operatingSystem) { + return ( + operatingSystem === 'ios' || + operatingSystem === 'ipados' || + normalize(row.osVersion).startsWith('ios ') || + normalize(row.osVersion).startsWith('ipados ') + ); +} + +function parseVersion(value) { + const match = String(value || '').match(/\d+(?:\.\d+)*/); + + if (!match) { + return null; + } + + return match[0].split('.').map((part) => Number(part)); +} + +function compareVersions(left, right) { + const length = Math.max(left.length, right.length); + + for (let index = 0; index < length; index += 1) { + const leftPart = left[index] || 0; + const rightPart = right[index] || 0; + + if (leftPart !== rightPart) { + return leftPart - rightPart; + } + } + + return 0; +} + +function emptyMetrics() { + return { sessions: 0, views: 0 }; +} + +function addMetrics(target, source) { + target.sessions += source.sessions; + target.views += source.views; +} + +function printReport({ dateRange, policy, result, supportedTargets, targetQueries }) { + const knownCoverage = result.covered.sessions / result.known.sessions; + const totalCoverage = result.covered.sessions / result.total.sessions; + const unknownRate = result.unknown.sessions / result.total.sessions; + + console.log('Web baseline coverage check'); + console.log(`Date range: ${dateRange}`); + console.log(`Target: ${targetQueries.join(', ')}`); + console.log(`Minimum known-session coverage: ${formatPercent(policy.minimumSessionCoverage)}`); + console.log(`Maximum unknown-session traffic: ${formatPercent(policy.maximumUnknownTraffic)}`); + printBrowserMinimums(supportedTargets); + console.log(''); + console.log('Sessions'); + console.log(`- total: ${formatNumber(result.total.sessions)}`); + console.log(`- covered: ${formatNumber(result.covered.sessions)} (${formatPercent(totalCoverage)} including unknown, ${formatPercent(knownCoverage)} excluding unknown)`); + console.log(`- unsupported: ${formatNumber(result.unsupported.sessions)}`); + console.log(`- unknown: ${formatNumber(result.unknown.sessions)} (${formatPercent(unknownRate)})`); + console.log(''); + console.log('Views'); + console.log(`- total: ${formatNumber(result.total.views)}`); + console.log(`- covered: ${formatNumber(result.covered.views)}`); + console.log(`- unsupported: ${formatNumber(result.unsupported.views)}`); + console.log(`- unknown: ${formatNumber(result.unknown.views)}`); + + if (policy.deviceBreakdown) { + console.log(''); + console.log('Device split'); + [...result.devices.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .forEach(([device, metrics]) => { + const covered = metrics.covered.sessions / metrics.total.sessions; + console.log( + `- ${device}: ${formatNumber(metrics.covered.sessions)} / ${formatNumber(metrics.total.sessions)} sessions covered (${formatPercent(covered)})` + ); + }); + } + + if (result.unsupportedRows.length > 0) { + console.log(''); + console.log('Top unsupported traffic'); + result.unsupportedRows.slice(0, 10).forEach((row) => { + console.log( + `- ${formatNumber(row.sessions)} sessions, ${formatNumber(row.views)} views: ${row.browser} ${row.browserVersion}, ${row.deviceCategory}, ${row.operatingSystem} ${row.osVersion} (${row.reason})` + ); + }); + } +} + +function printBrowserMinimums(supportedTargets) { + const rows = [...supportedTargets.minimumDetails.values()] + .sort((left, right) => getBrowserSortIndex(left.browser) - getBrowserSortIndex(right.browser)) + .map((detail) => ({ + Browser: browserDisplayNames[detail.browser] || detail.browser, + Minimum: formatVersion(detail.version), + Released: detail.releaseDate || 'override', + Period: detail.releaseDate ? formatReleasePeriod(detail.releaseDate) : getOverridePeriod(detail), + Source: detail.source + })); + + console.log(''); + console.log('Resolved browser minimums'); + printTable(['Browser', 'Minimum', 'Released', 'Period', 'Source'], rows); +} + +function printTable(headers, rows) { + const widths = headers.map((header) => { + return Math.max(header.length, ...rows.map((row) => String(row[header]).length)); + }); + const formatRow = (row) => { + return headers.map((header, index) => String(row[header]).padEnd(widths[index])).join(' '); + }; + + console.log(formatRow(Object.fromEntries(headers.map((header) => [header, header])))); + console.log(widths.map((width) => '-'.repeat(width)).join(' ')); + rows.forEach((row) => console.log(formatRow(row))); +} + +function getBrowserSortIndex(browser) { + return ['chrome', 'edge', 'firefox', 'safari', 'ios_saf', 'and_chr', 'and_ff', 'android', 'opera', 'op_mob', 'samsung'].indexOf(browser); +} + +function formatReleasePeriod(releaseDate) { + const date = new Date(`${releaseDate}T00:00:00Z`); + const quarter = Math.floor(date.getUTCMonth() / 3) + 1; + + return `Q${quarter} ${date.getUTCFullYear()}`; +} + +function getOverridePeriod(detail) { + if (detail.browser === 'ios_saf') { + return 'iOS safety floor'; + } + + return 'explicit override'; +} + +function normalize(value) { + return String(value || '').trim().toLowerCase(); +} + +function formatPercent(value) { + if (!Number.isFinite(value)) { + return '0.00%'; + } + + return `${(value * 100).toFixed(2)}%`; +} + +function formatNumber(value) { + return new Intl.NumberFormat('en-US').format(value); +} + +function formatVersion(version) { + return version.join('.'); +} diff --git a/src/browsers/web-baseline-policy.json b/src/browsers/web-baseline-policy.json new file mode 100644 index 0000000..f5610eb --- /dev/null +++ b/src/browsers/web-baseline-policy.json @@ -0,0 +1,9 @@ +{ + "target": "./web-baseline.json", + "primaryMetric": "sessions", + "secondaryMetric": "screenPageViews", + "windowDays": 180, + "minimumSessionCoverage": 0.95, + "maximumUnknownTraffic": 0.01, + "deviceBreakdown": true +} diff --git a/src/browsers/web-baseline.json b/src/browsers/web-baseline.json new file mode 100644 index 0000000..b0f493c --- /dev/null +++ b/src/browsers/web-baseline.json @@ -0,0 +1,4 @@ +[ + "baseline widely available with downstream", + "ios_saf >= 15.6" +] diff --git a/tests/web-baseline/coverage-test.mjs b/tests/web-baseline/coverage-test.mjs new file mode 100644 index 0000000..fdb88b9 --- /dev/null +++ b/tests/web-baseline/coverage-test.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '../..'); +const checker = path.join(root, 'scripts/check-web-baseline-coverage.mjs'); + +const cases = [ + { + name: 'target parses', + args: ['--check-target-only'], + expectedStatus: 0, + expectedOutput: [ + 'Web baseline target parsed successfully.', + 'Resolved browser minimums', + 'Chrome', + 'Q1 2024', + 'Safari', + 'Q4 2023', + 'iOS safety floor' + ] + }, + { + name: 'passing traffic passes', + args: ['--csv', 'tests/web-baseline/fixtures/pass.csv'], + expectedStatus: 0, + expectedOutput: 'Result: PASS' + }, + { + name: 'unsupported traffic fails', + args: ['--csv', 'tests/web-baseline/fixtures/fail-unsupported.csv'], + expectedStatus: 1, + expectedOutput: 'known session coverage 90.00% is below 95.00%' + }, + { + name: 'unknown traffic fails', + args: ['--csv', 'tests/web-baseline/fixtures/fail-unknown.csv'], + expectedStatus: 1, + expectedOutput: 'unknown session traffic 2.00% is above 1.00%' + }, + { + name: 'empty traffic fails', + args: ['--csv', 'tests/web-baseline/fixtures/fail-empty.csv'], + expectedStatus: 1, + expectedOutput: 'traffic result contains zero total sessions' + } +]; + +for (const testCase of cases) { + const result = spawnSync(process.execPath, [checker, ...testCase.args], { + cwd: root, + encoding: 'utf8' + }); + const output = `${result.stdout}\n${result.stderr}`; + + assert.equal(result.status, testCase.expectedStatus, `${testCase.name}: unexpected exit status\n${output}`); + const expectedOutputs = Array.isArray(testCase.expectedOutput) ? testCase.expectedOutput : [testCase.expectedOutput]; + + for (const expectedOutput of expectedOutputs) { + assert.match(output, new RegExp(escapeRegExp(expectedOutput)), `${testCase.name}: missing expected output\n${output}`); + } +} + +console.log(`web-baseline tests passed (${cases.length})`); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/tests/web-baseline/fixtures/fail-empty.csv b/tests/web-baseline/fixtures/fail-empty.csv new file mode 100644 index 0000000..752eedb --- /dev/null +++ b/tests/web-baseline/fixtures/fail-empty.csv @@ -0,0 +1,5 @@ +# Tech Chapter - Frontend +# Web baseline empty fixture +# 20260715-20260811 +Browser,Browser version,Device category,Operating system,OS version,Views,Sessions +Grand total,,,,,0,0 diff --git a/tests/web-baseline/fixtures/fail-unknown.csv b/tests/web-baseline/fixtures/fail-unknown.csv new file mode 100644 index 0000000..888de0f --- /dev/null +++ b/tests/web-baseline/fixtures/fail-unknown.csv @@ -0,0 +1,7 @@ +# Tech Chapter - Frontend +# Web baseline unknown-heavy fixture +# 20260715-20260811 +Browser,Browser version,Device category,Operating system,OS version,Views,Sessions +Grand total,,,,,1000,1000 +Chrome,130.0.0.0,desktop,Windows,11,980,980 +(not set),(not set),smart tv,(not set),(not set),20,20 diff --git a/tests/web-baseline/fixtures/fail-unsupported.csv b/tests/web-baseline/fixtures/fail-unsupported.csv new file mode 100644 index 0000000..8759e36 --- /dev/null +++ b/tests/web-baseline/fixtures/fail-unsupported.csv @@ -0,0 +1,7 @@ +# Tech Chapter - Frontend +# Web baseline unsupported-heavy fixture +# 20260715-20260811 +Browser,Browser version,Device category,Operating system,OS version,Views,Sessions +Grand total,,,,,1000,1000 +Chrome,130.0.0.0,desktop,Windows,11,900,900 +Chrome,99.0.0.0,desktop,Windows,10,100,100 diff --git a/tests/web-baseline/fixtures/pass.csv b/tests/web-baseline/fixtures/pass.csv new file mode 100644 index 0000000..73ad317 --- /dev/null +++ b/tests/web-baseline/fixtures/pass.csv @@ -0,0 +1,8 @@ +# Tech Chapter - Frontend +# Web baseline fixture +# 20260715-20260811 +Browser,Browser version,Device category,Operating system,OS version,Views,Sessions +Grand total,,,,,1000,1000 +Chrome,130.0.0.0,desktop,Windows,11,800,800 +Safari,16.5,mobile,iOS,15.7.2,180,180 +Chrome,99.0.0.0,desktop,Windows,10,20,20