diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c443ad4..bd3f25d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,54 @@ jobs: name: dist path: dist retention-days: 14 + + e2e: + name: Playwright E2E + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Install Playwright Chromium + run: pnpm exec playwright install chromium --with-deps + + - name: Run unit tests + run: pnpm test:unit + + - name: Run E2E + env: + E2E_SKIP_BUILD: '1' + CI: '1' + run: pnpm exec playwright test + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + retention-days: 7 + + - name: Upload trace and screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: test-results + retention-days: 7 diff --git a/.gitignore b/.gitignore index e631fd0..099aa38 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,13 @@ lerna-debug.log* # Crxjs .crx-build/ + +# Playwright / test artifacts +playwright-report/ +test-results/ +playwright/.cache +.pnpm-store/ + +# OS-specific Playwright snapshots — only *-linux.png is committed (CI canonical) +tests/e2e/**/*-darwin.png +tests/e2e/**/*-win32.png diff --git a/CHANGELOG.md b/CHANGELOG.md index d37146c..c0d8ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- Add Playwright E2E and Vitest unit test harnesses. + ### Fixed - Reminder banner now renders in GitHub's system font stack. Previously the inline `host.style.all = 'initial'` override took precedence over the `:host { font-family }` rule in `banner.css`, so the host element fell back to the browser's default serif and shadow-tree descendants inherited it. diff --git a/eslint.config.js b/eslint.config.js index d23675d..ef71625 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,4 +23,13 @@ export default tseslint.config( '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], }, }, + { + // Test files: relax React-specific rules that don't apply in Playwright/Vitest context. + // Playwright fixtures use a `use` callback that triggers react-hooks/rules-of-hooks. + files: ['tests/e2e/fixtures/**/*.ts'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + 'react-refresh/only-export-components': 'off', + }, + }, ); diff --git a/package.json b/package.json index 3ef026f..12c37d1 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,13 @@ "icons": "node scripts/generate-icons.mjs", "zip": "node scripts/zip-dist.mjs", "screenshots": "node scripts/format-screenshots.mjs", + "test": "pnpm test:unit && pnpm test:e2e", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "E2E_HEADED=1 playwright test --headed", + "pretest:e2e": "pnpm build", "release:patch": "pnpm version patch -m \"chore: release v%s\"", "release:minor": "pnpm version minor -m \"chore: release v%s\"", "release:major": "pnpm version major -m \"chore: release v%s\"" @@ -64,24 +71,29 @@ "devDependencies": { "@crxjs/vite-plugin": "^2.0.0-beta.28", "@eslint/js": "^9.14.0", + "@playwright/test": "^1.59.1", "@types/chrome": "^0.0.283", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", + "@vitest/ui": "^2.1.9", "autoprefixer": "^10.4.20", "eslint": "^9.14.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.12.0", + "jsdom": "^25.0.1", "postcss": "^8.4.49", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", "sharp": "^0.33.5", + "sinon-chrome": "^3.0.1", "tailwindcss": "^3.4.14", "typescript": "^5.6.3", "typescript-eslint": "^8.13.0", - "vite": "^5.4.10" + "vite": "^5.4.10", + "vitest": "^2.1.9" }, "packageManager": "pnpm@10.33.0", "pnpm": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9e3313c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : [['list'], ['html', { open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + }, + ], + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb4a06a..a8ccc75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@eslint/js': specifier: ^9.14.0 version: 9.39.4 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@types/chrome': specifier: ^0.0.283 version: 0.0.283 @@ -60,6 +63,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.3 version: 4.7.0(vite@5.4.21(@types/node@22.19.17)) + '@vitest/ui': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.14) @@ -75,6 +81,9 @@ importers: globals: specifier: ^15.12.0 version: 15.15.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 postcss: specifier: ^8.4.49 version: 8.5.14 @@ -87,6 +96,9 @@ importers: sharp: specifier: ^0.33.5 version: 0.33.5 + sinon-chrome: + specifier: ^3.0.1 + version: 3.0.1 tailwindcss: specifier: ^3.4.14 version: 3.4.19 @@ -99,6 +111,9 @@ importers: vite: specifier: ^5.4.10 version: 5.4.21(@types/node@22.19.17) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) packages: @@ -106,6 +121,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -194,6 +212,34 @@ packages: peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -538,6 +584,14 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -832,6 +886,21 @@ packages: cpu: [x64] os: [win32] + '@sinonjs/commons@1.8.6': + resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} + + '@sinonjs/formatio@3.2.2': + resolution: {integrity: sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==} + + '@sinonjs/samsam@3.3.3': + resolution: {integrity: sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==} + + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + deprecated: |- + Deprecated: no longer maintained and no longer used by Sinon packages. See + https://github.com/sinonjs/nise/issues/243 for replacement details. + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -944,6 +1013,40 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/ui@2.1.9': + resolution: {integrity: sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==} + peerDependencies: + vitest: 2.1.9 + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@webcomponents/custom-elements@1.6.0': resolution: {integrity: sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==} @@ -961,6 +1064,10 @@ 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==} @@ -981,6 +1088,16 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-from@2.1.1: + resolution: {integrity: sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1023,6 +1140,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1034,10 +1159,18 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1063,6 +1196,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1092,9 +1229,17 @@ packages: engines: {node: '>=4'} hasBin: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1104,9 +1249,20 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1114,6 +1270,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@3.5.1: + resolution: {integrity: sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==} + engines: {node: '>=0.3.1'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -1130,6 +1290,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.352: resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} @@ -1137,6 +1301,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -1144,6 +1316,17 @@ packages: es-module-lexer@0.10.5: resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1213,10 +1396,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1242,6 +1432,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1261,6 +1454,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1268,6 +1465,11 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1280,6 +1482,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1296,13 +1506,29 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -1311,6 +1537,22 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + 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'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1350,6 +1592,12 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1364,6 +1612,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1386,6 +1643,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + just-extend@4.2.1: + resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1407,10 +1667,25 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lolex@4.2.0: + resolution: {integrity: sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==} + + lolex@5.1.2: + resolution: {integrity: sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1422,6 +1697,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1430,6 +1709,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1437,6 +1724,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1451,6 +1742,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nise@1.5.3: + resolution: {integrity: sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==} + node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} @@ -1464,6 +1758,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1488,6 +1785,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1499,9 +1799,19 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@1.9.0: + resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1521,6 +1831,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -1692,12 +2012,25 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} rxjs@7.5.7: resolution: {integrity: sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1722,13 +2055,33 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sinon-chrome@3.0.1: + resolution: {integrity: sha512-NTEFhyuiWEMnRmIqldUiA2DhKn2EqnZxyEk5Ez5rBXj+Nl54aJ0MEmF4wjltrxecxd8zlNLxyE0HyLabev9JsQ==} + + sinon@7.5.0: + resolution: {integrity: sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==} + deprecated: 16.1.1 + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1738,6 +2091,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1746,6 +2103,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -1761,14 +2121,51 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1785,6 +2182,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + typescript-eslint@8.59.2: resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1813,9 +2214,17 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1847,15 +2256,85 @@ packages: terser: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1867,6 +2346,14 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2001,6 +2488,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -2243,6 +2750,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': @@ -2438,6 +2951,23 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@sinonjs/commons@1.8.6': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/formatio@3.2.2': + dependencies: + '@sinonjs/commons': 1.8.6 + '@sinonjs/samsam': 3.3.3 + + '@sinonjs/samsam@3.3.3': + dependencies: + '@sinonjs/commons': 1.8.6 + array-from: 2.1.1 + lodash: 4.18.1 + + '@sinonjs/text-encoding@0.7.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -2596,6 +3126,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/ui@2.1.9(vitest@2.1.9)': + dependencies: + '@vitest/utils': 2.1.9 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 1.1.2 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@webcomponents/custom-elements@1.6.0': {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -2608,6 +3189,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2630,6 +3213,12 @@ snapshots: argparse@2.0.1: {} + array-from@2.1.1: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -2670,17 +3259,34 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + callsites@3.1.0: {} camelcase-css@2.0.1: {} caniuse-lite@1.0.30001792: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -2715,6 +3321,10 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -2741,18 +3351,36 @@ snapshots: cssesc@3.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} didyoumean@1.2.2: {} + diff@3.5.1: {} + dlv@1.1.3: {} dom-serializer@2.0.0: @@ -2773,14 +3401,37 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + electron-to-chromium@1.5.352: {} entities@4.5.0: {} + entities@6.0.1: {} + + es-define-property@1.0.1: {} + es-errors@1.3.0: {} es-module-lexer@0.10.5: {} + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2889,8 +3540,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2913,6 +3570,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2933,6 +3592,14 @@ snapshots: flatted@3.4.2: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + fraction.js@5.3.4: {} fs-extra@10.1.0: @@ -2941,6 +3608,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2948,6 +3618,24 @@ snapshots: gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2960,16 +3648,48 @@ snapshots: globals@15.15.0: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.3: dependencies: function-bind: 1.1.2 he@1.2.0: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + 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 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2999,6 +3719,10 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + + isarray@0.0.1: {} + isexe@2.0.0: {} jiti@1.21.7: {} @@ -3009,6 +3733,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3025,6 +3777,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + just-extend@4.2.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3044,10 +3798,22 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + + lolex@4.2.0: {} + + lolex@5.1.2: + dependencies: + '@sinonjs/commons': 1.8.6 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3060,6 +3826,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3067,6 +3835,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -3075,6 +3849,8 @@ snapshots: dependencies: brace-expansion: 1.1.14 + mrmime@2.0.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -3087,6 +3863,14 @@ snapshots: natural-compare@1.4.0: {} + nise@1.5.3: + dependencies: + '@sinonjs/formatio': 3.2.2 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 4.2.1 + lolex: 5.1.2 + path-to-regexp: 1.9.0 + node-html-parser@7.1.0: dependencies: css-select: 5.2.2 @@ -3100,6 +3884,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -3125,14 +3911,26 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} path-parse@1.0.7: {} + path-to-regexp@1.9.0: + dependencies: + isarray: 0.0.1 + + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3143,6 +3941,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.5.14): dependencies: postcss: 8.5.14 @@ -3260,6 +4066,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3268,6 +4078,12 @@ snapshots: dependencies: tslib: 2.8.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -3308,12 +4124,40 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 + sinon-chrome@3.0.1: + dependencies: + lodash: 4.18.1 + sinon: 7.5.0 + urijs: 1.19.11 + + sinon@7.5.0: + dependencies: + '@sinonjs/commons': 1.8.6 + '@sinonjs/formatio': 3.2.2 + '@sinonjs/samsam': 3.3.3 + diff: 3.5.1 + lolex: 4.2.0 + nise: 1.5.3 + supports-color: 5.5.0 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + strip-json-comments@3.1.1: {} sucrase@3.35.1: @@ -3326,12 +4170,18 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwindcss@3.4.19: @@ -3370,15 +4220,41 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3391,6 +4267,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + typescript-eslint@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) @@ -3418,8 +4296,28 @@ snapshots: dependencies: punycode: 2.3.1 + urijs@1.19.11: {} + util-deprecate@1.0.2: {} + vite-node@2.1.9(@types/node@22.19.17): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.17) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@22.19.17): dependencies: esbuild: 0.21.5 @@ -3429,12 +4327,77 @@ snapshots: '@types/node': 22.19.17 fsevents: 2.3.3 + vitest@2.1.9(@types/node@22.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.17)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.17) + vite-node: 2.1.9(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + '@vitest/ui': 2.1.9(vitest@2.1.9) + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + ws@8.20.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/background/scheduler.ts b/src/background/scheduler.ts new file mode 100644 index 0000000..7fa66df --- /dev/null +++ b/src/background/scheduler.ts @@ -0,0 +1,46 @@ +import type { Prefs } from '@/lib/defaults'; +import type { TabState } from '@/lib/storage'; + +export const MIN_MS = 60_000; + +export type RefreshDecision = + | { kind: 'skip'; reason: 'disabled' | 'active' | 'never-unfocused' | 'too-soon' } + | { kind: 'refresh' }; + +export function decideRefresh(args: { + prefs: Pick; + state: Pick; + isActive: boolean; + now: number; +}): RefreshDecision { + const { prefs, state, isActive, now } = args; + if (!prefs.enabled) return { kind: 'skip', reason: 'disabled' }; + if (isActive) return { kind: 'skip', reason: 'active' }; + if (state.lastUnfocusedAt === null) return { kind: 'skip', reason: 'never-unfocused' }; + const idleMs = now - state.lastUnfocusedAt; + if (idleMs < prefs.refreshThresholdMin * MIN_MS) return { kind: 'skip', reason: 'too-soon' }; + return { kind: 'refresh' }; +} + +export type RemindDecision = + | { kind: 'skip'; reason: 'inactive' | 'too-soon' | 'dismissed-recently' | 'reminded-recently' } + | { kind: 'remind'; minutes: number }; + +export function decideRemind(args: { + prefs: Pick; + state: Pick; + isActive: boolean; + now: number; +}): RemindDecision { + const { prefs, state, isActive, now } = args; + if (!isActive) return { kind: 'skip', reason: 'inactive' }; + const remindMs = prefs.remindThresholdMin * MIN_MS; + if (now - state.lastReloadedAt < remindMs) return { kind: 'skip', reason: 'too-soon' }; + const dismissedRecently = + state.bannerDismissedAt !== null && state.bannerDismissedAt > state.lastReloadedAt; + if (dismissedRecently) return { kind: 'skip', reason: 'dismissed-recently' }; + const lastRemind = state.lastRemindedAt ?? 0; + if (now - lastRemind < remindMs) return { kind: 'skip', reason: 'reminded-recently' }; + const minutes = Math.round((now - state.lastReloadedAt) / MIN_MS); + return { kind: 'remind', minutes }; +} diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index df721cf..162de41 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -9,17 +9,30 @@ import { patchTabState, setTabState, } from '@/lib/storage'; - -const MIN = 60_000; +import { decideRefresh, decideRemind } from './scheduler'; const NOTIFICATION_PREFIX = 'gh-refresh-remind:'; +const log = (...args: unknown[]) => console.log('[gh-refresh]', ...args); + +async function ensureAlarm(): Promise { + const existing = await chrome.alarms.get(ALARM_TICK); + if (!existing) { + await chrome.alarms.create(ALARM_TICK, { periodInMinutes: 1, delayInMinutes: 1 }); + log('alarm created'); + } else { + log('alarm already scheduled', existing); + } +} + +void ensureAlarm(); + chrome.runtime.onInstalled.addListener(() => { - void chrome.alarms.create(ALARM_TICK, { periodInMinutes: 0.5, delayInMinutes: 0.5 }); + void ensureAlarm(); void seedExistingTabs(); }); chrome.runtime.onStartup.addListener(() => { - void chrome.alarms.create(ALARM_TICK, { periodInMinutes: 0.5, delayInMinutes: 0.5 }); + void ensureAlarm(); void seedExistingTabs(); }); @@ -60,6 +73,7 @@ if (chrome.webNavigation?.onHistoryStateUpdated) { } chrome.alarms.onAlarm.addListener((alarm) => { + log('alarm fired', alarm.name); if (alarm.name === ALARM_TICK) void tick(); }); @@ -121,6 +135,7 @@ async function seedExistingTabs(): Promise { async function onTabActivated(tabId: number, windowId: number): Promise { const now = Date.now(); + log('activated', tabId, 'window', windowId); const tabsInWindow = await chrome.tabs.query({ windowId }); for (const t of tabsInWindow) { @@ -165,6 +180,7 @@ async function onWindowFocused(windowId: number): Promise { async function markAllUnfocused(): Promise { const now = Date.now(); + log('window blur — marking all unfocused'); const all = await getAllTabStates(); for (const [id, state] of all) { if (state.lastUnfocusedAt === null) { @@ -177,6 +193,7 @@ async function onTabReloaded(tabId: number, url: string): Promise { const now = Date.now(); const tab = await chrome.tabs.get(tabId).catch(() => null); const isActive = !!tab?.active; + log('reloaded', tabId, url); await setTabState(tabId, { url, lastUnfocusedAt: isActive ? null : now, @@ -189,10 +206,13 @@ async function onTabReloaded(tabId: number, url: string): Promise { async function tick(): Promise { const prefs = await getPrefs(); + log('tick start', { + enabled: prefs.enabled, + refreshMin: prefs.refreshThresholdMin, + remindMin: prefs.remindThresholdMin, + }); if (!prefs.enabled) return; - const refreshMs = prefs.refreshThresholdMin * MIN; - const remindMs = prefs.remindThresholdMin * MIN; const now = Date.now(); const liveTabs = await chrome.tabs.query({}); @@ -211,6 +231,7 @@ async function tick(): Promise { if (!matchesAny(tab.url, prefs.patterns)) continue; let state = await getTabState(tab.id); + log('tab', tab.id, { active: tab.active, url: tab.url, state }); if (!state) { state = { url: tab.url, @@ -220,14 +241,21 @@ async function tick(): Promise { bannerDismissedAt: null, }; await setTabState(tab.id, state); + log('tab', tab.id, 'seeded fresh state'); continue; } - if ( - !tab.active && - state.lastUnfocusedAt !== null && - now - state.lastUnfocusedAt >= refreshMs - ) { + const isActive = !!tab.active; + const refresh = decideRefresh({ prefs, state, isActive, now }); + + if (refresh.kind === 'refresh') { + log( + 'reloading', + tab.id, + 'idle for', + Math.round((now - (state.lastUnfocusedAt ?? now)) / 1000), + 's', + ); try { await chrome.tabs.reload(tab.id); } catch { @@ -244,22 +272,21 @@ async function tick(): Promise { await setTabState(tab.id, state); await chrome.notifications.clear(`${NOTIFICATION_PREFIX}${tab.id}`); continue; + } else { + log('tab', tab.id, 'skip refresh:', refresh.reason); } - if (tab.active && now - state.lastReloadedAt >= remindMs) { - const lastRemind = state.lastRemindedAt ?? 0; - const dismissedRecently = - state.bannerDismissedAt !== null && state.bannerDismissedAt > state.lastReloadedAt; - if (!dismissedRecently && now - lastRemind >= remindMs) { - const minutes = Math.round((now - state.lastReloadedAt) / MIN); - try { - await chrome.tabs.sendMessage(tab.id, { type: 'show-banner', minutes }); - } catch { - // Content script not ready yet (e.g., tab loading); skip silently. - } - if (prefs.notificationsEnabled) await fireNotification(tab.id, minutes); - await patchTabState(tab.id, { lastRemindedAt: now }); + const remind = decideRemind({ prefs, state, isActive, now }); + if (remind.kind === 'remind') { + try { + await chrome.tabs.sendMessage(tab.id, { type: 'show-banner', minutes: remind.minutes }); + } catch { + // Content script not ready yet (e.g., tab loading); skip silently. } + if (prefs.notificationsEnabled) await fireNotification(tab.id, remind.minutes); + await patchTabState(tab.id, { lastRemindedAt: now }); + } else { + log('tab', tab.id, 'skip remind:', remind.reason); } } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..52e00d6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,104 @@ +# Testing + +This project has two test suites: Vitest for unit tests and Playwright for E2E tests. + +## Setup + +After cloning, install dependencies and the Playwright browser: + +```bash +pnpm install +pnpm exec playwright install chromium +``` + +The Chromium download is ~100 MB and only needs to happen once. + +## Running tests + +```bash +# Unit tests only (no browser required) +pnpm test:unit + +# E2E tests (requires a built dist/) +pnpm test:e2e + +# Both suites in sequence +pnpm test + +# E2E in headed mode (useful for debugging) +E2E_HEADED=1 pnpm test:e2e + +# E2E with Playwright's interactive UI +pnpm test:e2e:ui +``` + +`pnpm test:e2e` automatically runs `pnpm build` first via the `pretest:e2e` hook. +To skip the build (e.g. when `dist/` is already up to date): + +```bash +E2E_SKIP_BUILD=1 pnpm exec playwright test +``` + +## Snapshot workflow + +The banner E2E test uses `toHaveScreenshot()` to guard against visual regressions. + +Playwright names snapshots per OS: `*-linux.png`, `*-darwin.png`, `*-win32.png`. + +**The Linux baseline is canonical and committed** at +`tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png`. +CI verifies against it — CI does **not** auto-generate it. A missing or stale baseline +causes CI to go red immediately. + +macOS-local snapshots (`*-darwin.png`) are gitignored and regenerated on every local +`pnpm test:e2e` run. They are **not** the source of truth; they only exist so local runs +pass on macOS. + +### Regenerating the Linux baseline + +When banner styling changes (fonts, colours, layout), macOS and Windows contributors must +regenerate the Linux PNG using the official Playwright Docker image so the OS matches CI: + +```bash +# Read the exact Playwright version from package.json (no jq required, no pnpm-list flake) +PW_VERSION=$(node -p "require('./package.json').devDependencies['@playwright/test'].replace(/^\D+/, '')") + +docker run --rm \ + --ipc=host \ + -v "$PWD:/work" -w /work \ + -e CI=1 \ + "mcr.microsoft.com/playwright:v${PW_VERSION}-jammy" \ + bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm exec playwright test --update-snapshots tests/e2e/banner.spec.ts" + +# After the container exits, your host node_modules/ now has Linux-only platform binaries. +# Restore your local install before running anything else (build, dev, tests): +rm -rf node_modules +pnpm install +``` + +> **Notes**: +> - `--ipc=host` prevents Chromium from OOM-crashing in the container (per [Playwright official Docker docs](https://playwright.dev/docs/docker)). +> - Reading the version via `node -p` is deterministic and dependency-free — no `jq` needed. +> - `pnpm install --frozen-lockfile` inside Linux replaces host macOS/Windows native binaries; restoring afterward is mandatory. +> - The Docker recipe mounts the repo root as `/work`, so pnpm's store ends up at `.pnpm-store/` inside the repo. That directory is gitignored and safe to delete afterwards. + +After the container finishes, commit the updated +`tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png`. + +### No Docker? CI artifact fallback + +If you can't run Docker locally: + +1. Push your branch with the banner change. +2. Let the `e2e` CI job fail on the snapshot mismatch. +3. Download the `playwright-report` artifact from the failed run. +4. Find the actual screenshot at `playwright-report/data/-actual.png` (or under `test-results/`). +5. Copy it to `tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png` and commit. + +Slower than Docker, but works without local Linux. + +## CI + +The `e2e` job in `.github/workflows/ci.yml` runs after the `build` job completes. +It downloads the `dist` artifact, skips the rebuild (`E2E_SKIP_BUILD=1`), and uploads +`playwright-report/` and `test-results/` as artifacts on failure for inspection. diff --git a/tests/e2e/banner.spec.ts b/tests/e2e/banner.spec.ts new file mode 100644 index 0000000..80c25e7 --- /dev/null +++ b/tests/e2e/banner.spec.ts @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from './fixtures/extension'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const STUB = readFileSync(resolve(__dirname, 'fixtures/github-stub.html'), 'utf8'); + +test.describe('banner injection via mocked github.com', () => { + test('banner shows on show-banner message and dismisses on Dismiss click', async ({ + page, + serviceWorker, + }) => { + // Route all github.com requests to our stub HTML + await page.route('https://github.com/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: STUB }), + ); + + await page.goto('https://github.com/ivanmaierg/github-refresh'); + + // Wait for the banner host to attach (content script runs at document_idle) + await page.locator('#gh-refresh-banner-host').waitFor({ state: 'attached', timeout: 5_000 }); + + // Find the tab ID so the SW can send a message to it + const tabId = await serviceWorker.evaluate(async () => { + const tabs = await chrome.tabs.query({ active: true }); + return tabs[0]?.id ?? null; + }); + expect(tabId).not.toBeNull(); + + // Trigger banner via SW sending show-banner to the tab + await serviceWorker.evaluate( + async ({ tabId, minutes }: { tabId: number; minutes: number }) => { + await chrome.tabs.sendMessage(tabId, { type: 'show-banner', minutes }); + }, + { tabId: tabId as number, minutes: 12 }, + ); + + // Banner [role="status"] should now be visible (Playwright pierces open shadow roots) + const banner = page.locator('[role="status"]'); + await expect(banner).toBeVisible({ timeout: 3_000 }); + + // Visual regression guard for the banner font/style. The Linux baseline at + // tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png is the + // committed source of truth; macOS *-darwin.png variants are local-only and + // gitignored. To regenerate the Linux baseline, see tests/README.md (Docker recipe). + await expect(banner).toHaveScreenshot('reminder-banner.png', { + maxDiffPixelRatio: 0.01, + }); + + // Verify "Refresh now" button is present + await expect(page.getByRole('button', { name: /refresh now/i })).toBeVisible(); + + // Click Dismiss — banner should disappear + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect(banner).not.toBeVisible({ timeout: 2_000 }); + }); + + test('Refresh now button triggers a real tab reload via SW handler', async ({ + page, + serviceWorker, + }) => { + await page.route('https://github.com/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: STUB }), + ); + + await page.goto('https://github.com/ivanmaierg/github-refresh'); + await page.locator('#gh-refresh-banner-host').waitFor({ state: 'attached', timeout: 5_000 }); + + // Stamp a sentinel on `window` so we can prove the page actually reloaded + // (a fresh document means our sentinel is gone). + await page.evaluate(() => { + (window as unknown as { __ghRefreshTestSentinel?: boolean }).__ghRefreshTestSentinel = true; + }); + + const tabId = await serviceWorker.evaluate(async () => { + const tabs = await chrome.tabs.query({ active: true }); + return tabs[0]?.id ?? null; + }); + expect(tabId).not.toBeNull(); + + await serviceWorker.evaluate( + async ({ tabId, minutes }: { tabId: number; minutes: number }) => { + await chrome.tabs.sendMessage(tabId, { type: 'show-banner', minutes }); + }, + { tabId: tabId as number, minutes: 7 }, + ); + + const refreshButton = page.getByRole('button', { name: /refresh now/i }); + await expect(refreshButton).toBeVisible({ timeout: 3_000 }); + + // Click the banner's Refresh — content script sends `refresh-now` with sender.tab populated, + // SW calls chrome.tabs.reload(senderTabId), and the page navigates fresh. + const reloadComplete = page.waitForEvent('load', { timeout: 5_000 }); + await refreshButton.click(); + await reloadComplete; + + // Sentinel is gone → this is a fresh document, not the same one we stamped. + const sentinelSurvivedReload = await page.evaluate(() => + Boolean((window as unknown as { __ghRefreshTestSentinel?: boolean }).__ghRefreshTestSentinel), + ); + expect(sentinelSurvivedReload).toBe(false); + + // Content script should re-attach on the reloaded document. + await page.locator('#gh-refresh-banner-host').waitFor({ state: 'attached', timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png b/tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png new file mode 100644 index 0000000..a028629 Binary files /dev/null and b/tests/e2e/banner.spec.ts-snapshots/reminder-banner-chromium-linux.png differ diff --git a/tests/e2e/fixtures/extension.ts b/tests/e2e/fixtures/extension.ts new file mode 100644 index 0000000..7f74a0c --- /dev/null +++ b/tests/e2e/fixtures/extension.ts @@ -0,0 +1,83 @@ +import { test as base, chromium, type BrowserContext, type Worker } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIST_DIR = resolve(__dirname, '../../../dist'); +const SKIP_BUILD = process.env.E2E_SKIP_BUILD === '1'; +const HEADLESS = process.env.E2E_HEADED !== '1'; + +function ensureBuilt(): void { + if (SKIP_BUILD) return; + if (existsSync(join(DIST_DIR, 'manifest.json'))) return; + // Build on demand when dist is missing. CI sets E2E_SKIP_BUILD=1 to avoid double build. + const result = spawnSync('pnpm', ['build'], { + stdio: 'inherit', + cwd: resolve(__dirname, '../../../'), + }); + if (result.status !== 0) { + throw new Error(`pnpm build failed with exit code ${String(result.status)}`); + } +} + +type Fixtures = { + context: BrowserContext; + serviceWorker: Worker; + extensionId: string; + openPopup: (path?: string) => Promise; +}; + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + ensureBuilt(); + const userDataDir = mkdtempSync(join(tmpdir(), 'ghr-e2e-')); + const args = [ + `--disable-extensions-except=${DIST_DIR}`, + `--load-extension=${DIST_DIR}`, + '--no-first-run', + '--no-default-browser-check', + ]; + if (HEADLESS) args.push('--headless=new'); + + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, // controlled via args above + args, + }); + + await use(context); + try { + await context.close(); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } + }, + + serviceWorker: async ({ context }, use) => { + let [worker] = context.serviceWorkers(); + if (!worker) { + worker = await context.waitForEvent('serviceworker', { timeout: 10_000 }); + } + await use(worker); + }, + + extensionId: async ({ serviceWorker }, use) => { + // sw.url() looks like: chrome-extension:///service-worker-loader.js + const url = new URL(serviceWorker.url()); + await use(url.host); + }, + + openPopup: async ({ context, extensionId }, use) => { + const open = async (path = 'src/popup/index.html') => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/${path}`); + return page; + }; + await use(open); + }, +}); + +export const expect = test.expect; diff --git a/tests/e2e/fixtures/github-stub.html b/tests/e2e/fixtures/github-stub.html new file mode 100644 index 0000000..25a3753 --- /dev/null +++ b/tests/e2e/fixtures/github-stub.html @@ -0,0 +1,13 @@ + + + + + github-refresh test stub + + +
+
+

Stub repo for tests

+
+ + diff --git a/tests/e2e/popup.spec.ts b/tests/e2e/popup.spec.ts new file mode 100644 index 0000000..d30181c --- /dev/null +++ b/tests/e2e/popup.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from './fixtures/extension'; + +test('popup renders heading and Enabled toggle', async ({ openPopup }) => { + const page = await openPopup(); + await expect(page.getByRole('heading', { name: 'GitHub Auto-Refresh' })).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByRole('switch', { name: 'Enable extension' })).toBeVisible(); +}); + +test('toggling Enabled persists to storage and shows Saved', async ({ + openPopup, + serviceWorker, +}) => { + const page = await openPopup(); + // Wait for popup to finish loading + await expect(page.getByRole('switch', { name: 'Enable extension' })).toBeVisible(); + + // Read current enabled state from storage + const before = await serviceWorker.evaluate(async () => { + const result = await chrome.storage.sync.get('prefs'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (result['prefs'] as any)?.enabled ?? true; + }); + + // Click the toggle + await page.getByRole('switch', { name: 'Enable extension' }).click(); + + // Verify Saved feedback appears + await expect(page.getByText('Saved')).toBeVisible({ timeout: 2_000 }); + + // Verify storage was updated + const after = await serviceWorker.evaluate(async () => { + const result = await chrome.storage.sync.get('prefs'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (result['prefs'] as any)?.enabled ?? true; + }); + expect(after).toBe(!before); +}); diff --git a/tests/e2e/service-worker.spec.ts b/tests/e2e/service-worker.spec.ts new file mode 100644 index 0000000..50cf6c3 --- /dev/null +++ b/tests/e2e/service-worker.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from './fixtures/extension'; + +test('alarm gh-refresh-tick exists after SW initialises', async ({ serviceWorker }) => { + await expect + .poll( + async () => + serviceWorker.evaluate(async () => { + const a = await chrome.alarms.get('gh-refresh-tick'); + return a?.name ?? null; + }), + { timeout: 5_000, message: 'gh-refresh-tick alarm should be registered after SW init' }, + ) + .toBe('gh-refresh-tick'); +}); + +test('prefs-updated message returns { ok: true }', async ({ context, extensionId }) => { + const page = await context.newPage(); + // Navigate to a non-extension page so we can send runtime messages + await page.goto(`chrome-extension://${extensionId}/src/popup/index.html`); + + const response = await page.evaluate(async () => { + return chrome.runtime.sendMessage({ type: 'prefs-updated' }); + }); + + expect(response).toEqual({ ok: true }); + await page.close(); +}); + +test('refresh-now from a sender without sender.tab is a no-op but still acks', async ({ + openPopup, +}) => { + // Sender without `sender.tab.id` (the popup) → handler must NOT reload anything + // but must still respond { ok: true } so the caller never hangs. + // The full reload path (banner click → SW reloads tab) lives in banner.spec.ts. + const popupPage = await openPopup(); + await expect(popupPage.getByRole('heading', { name: 'GitHub Auto-Refresh' })).toBeVisible(); + + const response = await popupPage.evaluate(async () => { + return chrome.runtime.sendMessage({ type: 'refresh-now' }); + }); + + expect(response).toEqual({ ok: true }); + await popupPage.close(); +}); diff --git a/tests/unit/scheduler.spec.ts b/tests/unit/scheduler.spec.ts new file mode 100644 index 0000000..c246d98 --- /dev/null +++ b/tests/unit/scheduler.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import { decideRefresh, decideRemind, MIN_MS } from '@/background/scheduler'; +import type { Prefs } from '@/lib/defaults'; +import type { TabState } from '@/lib/storage'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +const NOW = 1_000_000_000_000; + +function makeRefreshPrefs( + overrides: Partial> = {}, +): Pick { + return { enabled: true, refreshThresholdMin: 5, ...overrides }; +} + +function makeRefreshState( + overrides: Partial> = {}, +): Pick { + return { lastUnfocusedAt: NOW - 10 * MIN_MS, ...overrides }; +} + +function makeRemindPrefs( + overrides: Partial> = {}, +): Pick { + return { remindThresholdMin: 10, ...overrides }; +} + +function makeRemindState( + overrides: Partial> = {}, +): Pick { + return { + lastReloadedAt: NOW - 15 * MIN_MS, + lastRemindedAt: null, + bannerDismissedAt: null, + ...overrides, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// decideRefresh +// ────────────────────────────────────────────────────────────────────────────── + +describe('decideRefresh', () => { + it('skips when prefs.enabled is false', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs({ enabled: false }), + state: makeRefreshState(), + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'disabled' }); + }); + + it('skips when the tab is active', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs(), + state: makeRefreshState(), + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'active' }); + }); + + it('skips when lastUnfocusedAt is null (tab never lost focus)', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs(), + state: makeRefreshState({ lastUnfocusedAt: null }), + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'never-unfocused' }); + }); + + it('skips when idle time is below the threshold', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs({ refreshThresholdMin: 5 }), + state: makeRefreshState({ lastUnfocusedAt: NOW - 3 * MIN_MS }), // only 3 min idle + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'too-soon' }); + }); + + it('returns refresh when threshold is exceeded', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs({ refreshThresholdMin: 5 }), + state: makeRefreshState({ lastUnfocusedAt: NOW - 6 * MIN_MS }), // 6 min idle + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'refresh' }); + }); + + it('refreshes when idle equals threshold exactly (>= boundary)', () => { + const result = decideRefresh({ + prefs: makeRefreshPrefs({ refreshThresholdMin: 5 }), + state: makeRefreshState({ lastUnfocusedAt: NOW - 5 * MIN_MS }), // exactly 5 min + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'refresh' }); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// decideRemind +// ────────────────────────────────────────────────────────────────────────────── + +describe('decideRemind', () => { + it('skips when tab is not active', () => { + const result = decideRemind({ + prefs: makeRemindPrefs(), + state: makeRemindState(), + isActive: false, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'inactive' }); + }); + + it('skips when not enough time has passed since last reload', () => { + const result = decideRemind({ + prefs: makeRemindPrefs({ remindThresholdMin: 10 }), + state: makeRemindState({ lastReloadedAt: NOW - 5 * MIN_MS }), // only 5 min ago + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'too-soon' }); + }); + + it('skips when banner was dismissed after the last reload', () => { + const lastReloadedAt = NOW - 15 * MIN_MS; + const result = decideRemind({ + prefs: makeRemindPrefs(), + state: makeRemindState({ + lastReloadedAt, + bannerDismissedAt: lastReloadedAt + MIN_MS, // dismissed after reload + }), + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'dismissed-recently' }); + }); + + it('skips when reminded too recently', () => { + const result = decideRemind({ + prefs: makeRemindPrefs({ remindThresholdMin: 10 }), + state: makeRemindState({ + lastRemindedAt: NOW - 3 * MIN_MS, // reminded only 3 min ago + }), + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'skip', reason: 'reminded-recently' }); + }); + + it('returns remind with correct minutes when all conditions are met', () => { + const lastReloadedAt = NOW - 12 * MIN_MS; + const result = decideRemind({ + prefs: makeRemindPrefs({ remindThresholdMin: 10 }), + state: makeRemindState({ lastReloadedAt }), + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'remind', minutes: 12 }); + }); + + it('does not skip when bannerDismissedAt predates last reload', () => { + const lastReloadedAt = NOW - 12 * MIN_MS; + const result = decideRemind({ + prefs: makeRemindPrefs(), + state: makeRemindState({ + lastReloadedAt, + bannerDismissedAt: lastReloadedAt - MIN_MS, // dismissed BEFORE this reload cycle + }), + isActive: true, + now: NOW, + }); + expect(result).toEqual({ kind: 'remind', minutes: 12 }); + }); +}); diff --git a/tests/unit/setup.ts b/tests/unit/setup.ts new file mode 100644 index 0000000..ba4fe55 --- /dev/null +++ b/tests/unit/setup.ts @@ -0,0 +1,8 @@ +import chrome from 'sinon-chrome'; +import { vi, afterEach } from 'vitest'; + +vi.stubGlobal('chrome', chrome); + +afterEach(() => { + chrome.flush(); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1cebfc5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import path, { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['tests/unit/**/*.spec.ts'], + setupFiles: ['tests/unit/setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});