From 2073af864b2ef98b8f2f587588e5f346e5834cb1 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 1 Jun 2026 12:03:57 +0800 Subject: [PATCH 01/12] fix: resolve pre-existing tsc type errors These were never surfaced because esbuild strips types without checking; adding a typecheck step exposed them. - options.ts: rename top-level `status` to `statusEl` so it no longer shadows the DOM global `window.status` (a string), which broke `.className`/`.textContent` access - pass the generic type arg to `chrome.storage.local.get()` in service-worker.ts and better-top-repos.ts so results are typed instead of defaulting to an index signature (which truthy-narrowed to `{}`) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/better-top-repos.ts | 2 +- src/options.ts | 19 +++++++++++++------ src/service-worker.ts | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/features/better-top-repos.ts b/src/features/better-top-repos.ts index c73f03f..b126275 100644 --- a/src/features/better-top-repos.ts +++ b/src/features/better-top-repos.ts @@ -124,7 +124,7 @@ async function getPinnedRepos(): Promise { return new Promise((resolve) => { try { if (!chrome.runtime?.id) return resolve([]); - chrome.storage.local.get([STORAGE_KEY], (result) => { + chrome.storage.local.get>([STORAGE_KEY], (result) => { resolve(result[STORAGE_KEY] || []); }); } catch { diff --git a/src/options.ts b/src/options.ts index 75c18f9..9dea102 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,7 +1,14 @@ const tokenInput = document.getElementById("token") as HTMLInputElement; const tokenStatus = document.getElementById("tokenStatus") as HTMLDivElement; const saveBtn = document.getElementById("save") as HTMLButtonElement; -const status = document.getElementById("status") as HTMLDivElement; +// Not named `status`: as a top-level script var that would shadow the DOM +// global `window.status` (a string), breaking `.className`/`.textContent`. +const statusEl = document.getElementById("status") as HTMLDivElement; + +interface StoredSettings { + githubToken?: string; + [feature: string]: string | boolean | undefined; +} const FEATURE_KEYS = [ "feature-pr-branch-names", @@ -18,7 +25,7 @@ const FEATURE_KEYS = [ ] as const; // --- Load saved settings --- -chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (result) => { +chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (result) => { if (result.githubToken) { tokenInput.value = result.githubToken; } @@ -76,12 +83,12 @@ tokenInput.addEventListener("blur", () => { let statusTimer: number | undefined; function showStatus(kind: "success" | "error", message: string) { - status.className = `status ${kind}`; - status.textContent = message; + statusEl.className = `status ${kind}`; + statusEl.textContent = message; if (statusTimer) clearTimeout(statusTimer); statusTimer = window.setTimeout(() => { - status.className = "status"; - status.textContent = ""; + statusEl.className = "status"; + statusEl.textContent = ""; }, 2000); } diff --git a/src/service-worker.ts b/src/service-worker.ts index fea9316..46bdf39 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -45,7 +45,7 @@ async function cachedFetch(key: string, fetcher: () => Promise): Promise { return new Promise((resolve) => { - chrome.storage.local.get("githubToken", (result) => { + chrome.storage.local.get<{ githubToken?: string }>("githubToken", (result) => { resolve(result.githubToken || ""); }); }); From 52b9f8e5bd9ff2cfd70048658e02719d05c5eadd Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 1 Jun 2026 12:04:04 +0800 Subject: [PATCH 02/12] test: add vitest suite with happy-dom and CI Set up the first batch of tests for the extension, covering the pure page/URL/string logic in src/lib with zero source changes. - vitest + happy-dom; happy-dom's setURL (wrapped in src/test/url.ts) lets page-detect read `location` without refactoring - 35 unit tests across page-detect, page-marker, and utils - scripts: test, test:watch, typecheck - GitHub Actions CI runs typecheck + test + build on PR/push Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 29 ++ package.json | 9 +- pnpm-lock.yaml | 829 ++++++++++++++++++++++++++++++++++++ src/lib/page-detect.test.ts | 220 ++++++++++ src/lib/page-marker.test.ts | 21 + src/lib/utils.test.ts | 33 ++ src/test/url.ts | 6 + vitest.config.ts | 8 + 8 files changed, 1153 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/lib/page-detect.test.ts create mode 100644 src/lib/page-marker.test.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/test/url.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76647dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm typecheck + + - run: pnpm test + + - run: pnpm build # fail if the bundle no longer builds diff --git a/package.json b/package.json index 6abfae6..f015953 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,18 @@ "scripts": { "build": "node build.mjs", "watch": "node build.mjs --watch", - "pack": "node build.mjs && cd dist && zip -r ../better-github.zip . -x '*icon-preview*' && echo 'Packed: better-github.zip'" + "pack": "node build.mjs && cd dist && zip -r ../better-github.zip . -x '*icon-preview*' && echo 'Packed: better-github.zip'", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/chrome": "^0.1.38", "esbuild": "^0.27.4", + "happy-dom": "^20.9.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32d047c..528cc29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: esbuild: specifier: ^0.27.4 version: 0.27.4 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 oxfmt: specifier: ^0.41.0 version: 0.41.0 @@ -23,9 +26,21 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(happy-dom@20.9.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.4)) packages: + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -182,6 +197,18 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@oxfmt/binding-android-arm-eabi@0.41.0': resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -229,48 +256,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.41.0': resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.41.0': resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.41.0': resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.41.0': resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.41.0': resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.41.0': resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.41.0': resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.41.0': resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==} @@ -343,48 +378,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.56.0': resolution: {integrity: sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.56.0': resolution: {integrity: sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.56.0': resolution: {integrity: sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.56.0': resolution: {integrity: sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.56.0': resolution: {integrity: sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.56.0': resolution: {integrity: sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.56.0': resolution: {integrity: sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.56.0': resolution: {integrity: sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==} @@ -410,9 +453,122 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.1.38': resolution: {integrity: sha512-5aK4m9wZqoWAoB98aElESLm/5pXpqJnFWMNoiCs/XdPsXR6wNdVkJFSdQ9Wr4PnTuUrxD0SuNuDHh3EG5QeBzA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/filesystem@0.0.36': resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} @@ -422,11 +578,181 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oxfmt@0.41.0: resolution: {integrity: sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -442,17 +768,191 @@ packages: oxlint-tsgolint: optional: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + 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@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + snapshots: + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -531,6 +1031,17 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.132.0': {} + '@oxfmt/binding-android-arm-eabi@0.41.0': optional: true @@ -645,11 +1156,78 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.56.0': optional: true + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/chrome@0.1.38': dependencies: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/filesystem@0.0.36': dependencies: '@types/filewriter': 0.0.33 @@ -658,6 +1236,69 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.1 + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.4))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.4) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -687,6 +1328,88 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + happy-dom@20.9.0: + dependencies: + '@types/node': 25.9.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.12: {} + + obug@2.1.1: {} + oxfmt@0.41.0: dependencies: tinypool: 2.1.0 @@ -733,6 +1456,112 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.56.0 '@oxlint/binding-win32-x64-msvc': 1.56.0 + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.1 + esbuild: 0.27.4 + fsevents: 2.3.3 + + vitest@4.1.7(@types/node@25.9.1)(happy-dom@20.9.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.4)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.4)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.1 + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.0: {} diff --git a/src/lib/page-detect.test.ts b/src/lib/page-detect.test.ts new file mode 100644 index 0000000..52fa9bb --- /dev/null +++ b/src/lib/page-detect.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from "vitest"; +import { setUrl } from "../test/url"; +import { + getRepoInfo, + isPRListPage, + isIssueOrPRListPage, + isRepoPage, + isRepoTree, + isReleasesPage, + isPRDetailPage, + isPRFilesChangedPage, + isCommitPage, + isComparePage, + isDiffPage, + getPRNumber, + isCommitsListPage, + getPRListParams, +} from "./page-detect"; + +const GH = "https://github.com"; + +describe("getRepoInfo", () => { + it("parses owner/repo from a repo URL", () => { + setUrl(`${GH}/owner/repo`); + expect(getRepoInfo()).toEqual({ owner: "owner", repo: "repo" }); + }); + + it("parses owner/repo from a deeper repo URL", () => { + setUrl(`${GH}/owner/repo/pull/42/files`); + expect(getRepoInfo()).toEqual({ owner: "owner", repo: "repo" }); + }); + + it("returns null for reserved owners", () => { + setUrl(`${GH}/settings/profile`); + expect(getRepoInfo()).toBeNull(); + setUrl(`${GH}/notifications/index`); + expect(getRepoInfo()).toBeNull(); + }); + + it("returns null when there is no owner/repo pair", () => { + setUrl(`${GH}/explore`); + expect(getRepoInfo()).toBeNull(); + setUrl(`${GH}/`); + expect(getRepoInfo()).toBeNull(); + }); +}); + +describe("isPRListPage", () => { + it("matches the pulls list, with or without query/trailing slash", () => { + setUrl(`${GH}/owner/repo/pulls`); + expect(isPRListPage()).toBe(true); + setUrl(`${GH}/owner/repo/pulls/`); + expect(isPRListPage()).toBe(true); + setUrl(`${GH}/owner/repo/pulls?q=is:pr+is:open`); + expect(isPRListPage()).toBe(true); + }); + + it("rejects a single PR, the issues list, and reserved owners", () => { + setUrl(`${GH}/owner/repo/pull/42`); + expect(isPRListPage()).toBe(false); + setUrl(`${GH}/owner/repo/issues`); + expect(isPRListPage()).toBe(false); + setUrl(`${GH}/settings/pulls`); + expect(isPRListPage()).toBe(false); + }); +}); + +describe("isIssueOrPRListPage", () => { + it("matches both the pulls and issues lists", () => { + setUrl(`${GH}/owner/repo/pulls`); + expect(isIssueOrPRListPage()).toBe(true); + setUrl(`${GH}/owner/repo/issues?q=is:open`); + expect(isIssueOrPRListPage()).toBe(true); + }); + + it("rejects a single issue", () => { + setUrl(`${GH}/owner/repo/issues/7`); + expect(isIssueOrPRListPage()).toBe(false); + }); +}); + +describe("isRepoPage", () => { + it("is true on any repo page and false off-repo", () => { + setUrl(`${GH}/owner/repo/pull/1`); + expect(isRepoPage()).toBe(true); + setUrl(`${GH}/explore`); + expect(isRepoPage()).toBe(false); + }); +}); + +describe("isRepoTree", () => { + it("matches the repo root and tree paths", () => { + setUrl(`${GH}/owner/repo`); + expect(isRepoTree()).toBe(true); + setUrl(`${GH}/owner/repo/tree/main`); + expect(isRepoTree()).toBe(true); + setUrl(`${GH}/owner/repo/tree/main/src/lib`); + expect(isRepoTree()).toBe(true); + }); + + it("rejects blob and pull paths", () => { + setUrl(`${GH}/owner/repo/blob/main/README.md`); + expect(isRepoTree()).toBe(false); + setUrl(`${GH}/owner/repo/pull/1`); + expect(isRepoTree()).toBe(false); + }); +}); + +describe("isReleasesPage", () => { + it("matches the releases listing and a specific tag", () => { + setUrl(`${GH}/owner/repo/releases`); + expect(isReleasesPage()).toBe(true); + setUrl(`${GH}/owner/repo/releases/tag/v1.0.0`); + expect(isReleasesPage()).toBe(true); + }); +}); + +describe("isPRDetailPage", () => { + it("matches a PR and its sub-tabs", () => { + setUrl(`${GH}/owner/repo/pull/42`); + expect(isPRDetailPage()).toBe(true); + setUrl(`${GH}/owner/repo/pull/42/files`); + expect(isPRDetailPage()).toBe(true); + }); + + it("rejects the pulls list", () => { + setUrl(`${GH}/owner/repo/pulls`); + expect(isPRDetailPage()).toBe(false); + }); +}); + +describe("isPRFilesChangedPage", () => { + it("matches the files/changes tab only", () => { + setUrl(`${GH}/owner/repo/pull/42/files`); + expect(isPRFilesChangedPage()).toBe(true); + setUrl(`${GH}/owner/repo/pull/42/changes`); + expect(isPRFilesChangedPage()).toBe(true); + setUrl(`${GH}/owner/repo/pull/42`); + expect(isPRFilesChangedPage()).toBe(false); + }); +}); + +describe("isCommitPage", () => { + it("matches a single commit, not the commits list", () => { + setUrl(`${GH}/owner/repo/commit/abc123def456`); + expect(isCommitPage()).toBe(true); + setUrl(`${GH}/owner/repo/commits`); + expect(isCommitPage()).toBe(false); + }); +}); + +describe("isComparePage", () => { + it("matches a compare URL", () => { + setUrl(`${GH}/owner/repo/compare/main...dev`); + expect(isComparePage()).toBe(true); + }); +}); + +describe("isDiffPage", () => { + it("is true for PR files, commit, and compare pages", () => { + setUrl(`${GH}/owner/repo/pull/42/files`); + expect(isDiffPage()).toBe(true); + setUrl(`${GH}/owner/repo/commit/abc123`); + expect(isDiffPage()).toBe(true); + setUrl(`${GH}/owner/repo/compare/main...dev`); + expect(isDiffPage()).toBe(true); + }); + + it("is false on a plain repo page", () => { + setUrl(`${GH}/owner/repo`); + expect(isDiffPage()).toBe(false); + }); +}); + +describe("getPRNumber", () => { + it("extracts the PR number", () => { + setUrl(`${GH}/owner/repo/pull/123/files`); + expect(getPRNumber()).toBe(123); + }); + + it("returns null when not on a PR page", () => { + setUrl(`${GH}/owner/repo/pulls`); + expect(getPRNumber()).toBeNull(); + }); +}); + +describe("isCommitsListPage", () => { + it("matches the commits list and a branch-scoped list", () => { + setUrl(`${GH}/owner/repo/commits`); + expect(isCommitsListPage()).toBe(true); + setUrl(`${GH}/owner/repo/commits/main`); + expect(isCommitsListPage()).toBe(true); + }); + + it("rejects a single commit page", () => { + setUrl(`${GH}/owner/repo/commit/abc123`); + expect(isCommitsListPage()).toBe(false); + }); +}); + +describe("getPRListParams", () => { + it("defaults to open PRs sorted by updated when no query is present", () => { + setUrl(`${GH}/owner/repo/pulls`); + expect(getPRListParams()).toEqual({ + state: "open", + page: 1, + query: "is:pr is:open sort:updated-desc", + }); + }); + + it("reports closed state when the query contains is:closed", () => { + setUrl(`${GH}/owner/repo/pulls?q=is:pr+is:closed`); + expect(getPRListParams()).toMatchObject({ state: "closed", query: "is:pr is:closed" }); + }); + + it("parses the page number", () => { + setUrl(`${GH}/owner/repo/pulls?page=3`); + expect(getPRListParams().page).toBe(3); + }); +}); diff --git a/src/lib/page-marker.test.ts b/src/lib/page-marker.test.ts new file mode 100644 index 0000000..ed9a46d --- /dev/null +++ b/src/lib/page-marker.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { detectPageMarker } from "./page-marker"; + +describe("detectPageMarker", () => { + it("marks the PR list page", () => { + expect(detectPageMarker("/owner/repo/pulls")).toBe("pr-list"); + expect(detectPageMarker("/owner/repo/pulls/")).toBe("pr-list"); + }); + + it("marks the commits list page", () => { + expect(detectPageMarker("/owner/repo/commits")).toBe("commits-list"); + expect(detectPageMarker("/owner/repo/commits/main")).toBe("commits-list"); + }); + + it("does not mark a single PR or unrelated pages", () => { + expect(detectPageMarker("/owner/repo/pulls/3")).toBeNull(); + expect(detectPageMarker("/owner/repo/issues")).toBeNull(); + expect(detectPageMarker("/owner/repo")).toBeNull(); + expect(detectPageMarker("/")).toBeNull(); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..49336d7 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml, pluralize } from "./utils"; + +describe("pluralize", () => { + it("returns the singular form for exactly 1", () => { + expect(pluralize(1, "file")).toBe("file"); + }); + + it("returns the default plural (+s) for 0 and >1", () => { + expect(pluralize(0, "file")).toBe("files"); + expect(pluralize(2, "file")).toBe("files"); + }); + + it("respects a custom plural form", () => { + expect(pluralize(1, "child", "children")).toBe("child"); + expect(pluralize(3, "child", "children")).toBe("children"); + }); +}); + +describe("escapeHtml", () => { + it("escapes angle brackets and ampersands", () => { + expect(escapeHtml("`; +} + +describe("feature-flags", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("reads the enabled flag set from GitHub's client-env payload", () => { + setClientEnv(JSON.stringify({ featureFlags: ["flag_a", "flag_b"], login: "octocat" })); + + expect(getEnabledFlags()).toEqual(new Set(["flag_a", "flag_b"])); + expect(hasFlag("flag_a")).toBe(true); + expect(hasFlag("flag_missing")).toBe(false); + }); + + it("returns an empty set when client-env is absent", () => { + expect(getEnabledFlags().size).toBe(0); + expect(hasFlag("anything")).toBe(false); + }); + + it("returns an empty set when client-env is not valid JSON", () => { + setClientEnv("{ not json"); + expect(getEnabledFlags().size).toBe(0); + }); + + it("tolerates a payload with no featureFlags field", () => { + setClientEnv(JSON.stringify({ login: "octocat" })); + expect(getEnabledFlags().size).toBe(0); + }); +}); diff --git a/src/lib/github-api.test.ts b/src/lib/github-api.test.ts new file mode 100644 index 0000000..cb2a7d6 --- /dev/null +++ b/src/lib/github-api.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + approvePR, + fetchCommitDiffStats, + fetchForks, + fetchPRBranches, + fetchPRDiffStats, + fetchPRReviewStatuses, + fetchRepoTags, + fetchStargazers, + fetchWatchers, +} from "./github-api"; + +type SendResponse = { ok: boolean; data?: unknown; error?: string }; + +interface RuntimeMock { + id?: string; + lastError?: { message: string }; + sendMessage: ReturnType; +} + +function mockRuntime(opts: { response?: SendResponse; lastError?: { message: string }; id?: string | undefined } = {}): RuntimeMock { + const runtime: RuntimeMock = { + id: "id" in opts ? opts.id : "ext-id", + lastError: opts.lastError, + sendMessage: vi.fn((_req: unknown, cb: (r: SendResponse | undefined) => void) => { + cb(opts.response); + }), + }; + vi.stubGlobal("chrome", { runtime }); + return runtime; +} + +describe("github-api bridge", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("forwards a typed request and resolves the worker's data on success", async () => { + const runtime = mockRuntime({ response: { ok: true, data: [{ number: 7, headRef: "feature/a" }] } }); + + const result = await fetchPRBranches("owner", "repo", "open", 2); + + expect(result).toEqual([{ number: 7, headRef: "feature/a" }]); + expect(runtime.sendMessage).toHaveBeenCalledWith( + { type: "FETCH_PR_BRANCHES", owner: "owner", repo: "repo", state: "open", page: 2 }, + expect.any(Function), + ); + }); + + it("swallows an ok:false response and returns the empty default", async () => { + mockRuntime({ response: { ok: false, error: "boom" } }); + expect(await fetchPRBranches("owner", "repo")).toEqual([]); + }); + + it("treats chrome.runtime.lastError as a failure", async () => { + mockRuntime({ response: { ok: true, data: [] }, lastError: { message: "port closed" } }); + expect(await fetchPRBranches("owner", "repo")).toEqual([]); + }); + + it("rejects without messaging when the extension context is invalidated", async () => { + const runtime = mockRuntime({ id: undefined }); + expect(await fetchPRBranches("owner", "repo")).toEqual([]); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + }); + + it("returns a failed PRApproveResult (not []) on the approve path", async () => { + mockRuntime({ response: { ok: false, error: "Not authorized" } }); + + const result = await approvePR("owner", "repo", 1, "lgtm"); + + expect(result).toEqual({ success: false, error: "Not authorized" }); + }); + + // Every wrapper forwards a distinct request type and resolves the worker's + // data on success — table-driven so each one is exercised. + const cases: Array<{ name: string; type: string; run: () => Promise }> = [ + { name: "fetchPRReviewStatuses", type: "FETCH_PR_REVIEW_STATUSES", run: () => fetchPRReviewStatuses("o", "r", [1]) }, + { name: "fetchPRDiffStats", type: "FETCH_PR_DIFF_STATS", run: () => fetchPRDiffStats("o", "r", [1]) }, + { name: "fetchCommitDiffStats", type: "FETCH_COMMIT_DIFF_STATS", run: () => fetchCommitDiffStats("o", "r", ["abc"]) }, + { name: "fetchRepoTags", type: "FETCH_REPO_TAGS", run: () => fetchRepoTags("o", "r") }, + { name: "fetchStargazers", type: "FETCH_STARGAZERS", run: () => fetchStargazers("o", "r") }, + { name: "fetchWatchers", type: "FETCH_WATCHERS", run: () => fetchWatchers("o", "r") }, + { name: "fetchForks", type: "FETCH_FORKS", run: () => fetchForks("o", "r") }, + ]; + + it.each(cases)("$name forwards a $type request and resolves its data", async ({ type, run }) => { + const runtime = mockRuntime({ response: { ok: true, data: [{ marker: type }] } }); + + const result = await run(); + + expect(result).toEqual([{ marker: type }]); + expect(runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ type, owner: "o", repo: "r" }), + expect.any(Function), + ); + }); + + it.each(cases)("$name returns [] when the bridge fails", async ({ run }) => { + mockRuntime({ response: { ok: false, error: "down" } }); + expect(await run()).toEqual([]); + }); +}); diff --git a/src/lib/navigation.test.ts b/src/lib/navigation.test.ts new file mode 100644 index 0000000..abfcb70 --- /dev/null +++ b/src/lib/navigation.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type NavModule = typeof import("./navigation"); + +async function loadNavigation(): Promise { + vi.resetModules(); + return import("./navigation"); +} + +describe("navigation", () => { + beforeEach(() => { + vi.useFakeTimers(); + // navigation.ts attaches document/window listeners at module load with no + // teardown, so resetModules()+reimport leaves prior listeners attached. + // They fire stale handlers (caught + logged) on later dispatches — silence + // the expected logging so it doesn't spam stderr with stacks. + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("runs all registered handlers synchronously on startNavigation()", async () => { + const { onPageReady, startNavigation } = await loadNavigation(); + const a = vi.fn(); + const b = vi.fn(); + + onPageReady(a); + onPageReady(b); + startNavigation(); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); + + it("debounces rapid turbo:load + turbo:render into a single handler run", async () => { + const { onPageReady } = await loadNavigation(); + const handler = vi.fn(); + onPageReady(handler); + + // Fire several SPA navigation events in quick succession. + document.dispatchEvent(new Event("turbo:load")); + document.dispatchEvent(new Event("turbo:render")); + document.dispatchEvent(new Event("turbo:load")); + + // Nothing should have run yet — the debounce timer is still pending. + expect(handler).not.toHaveBeenCalled(); + + vi.runOnlyPendingTimers(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("runs handlers again on a later, separate navigation burst", async () => { + const { onPageReady } = await loadNavigation(); + const handler = vi.fn(); + onPageReady(handler); + + document.dispatchEvent(new Event("turbo:load")); + vi.runOnlyPendingTimers(); + expect(handler).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new Event("turbo:render")); + vi.runOnlyPendingTimers(); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("re-runs handlers via the polling interval after startNavigation()", async () => { + const { onPageReady, startNavigation } = await loadNavigation(); + const handler = vi.fn(); + onPageReady(handler); + + startNavigation(); + expect(handler).toHaveBeenCalledTimes(1); // immediate run + + vi.advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(2); // one poll tick + + vi.advanceTimersByTime(2000); + expect(handler).toHaveBeenCalledTimes(3); + }); + + it("runs handlers on popstate (back/forward) after the timer flushes", async () => { + const { onPageReady } = await loadNavigation(); + const handler = vi.fn(); + onPageReady(handler); + + window.dispatchEvent(new Event("popstate")); + expect(handler).not.toHaveBeenCalled(); + + vi.runOnlyPendingTimers(); + expect(handler).toHaveBeenCalledTimes(1); + }); + + // Kept last: this registers a throwing handler whose module-level listeners + // leak (see beforeEach), so running it after the others avoids re-firing the + // throw inside their assertions. + it("isolates a throwing handler so later handlers still run, and logs the error", async () => { + const { onPageReady, startNavigation } = await loadNavigation(); + + const boom = vi.fn(() => { + throw new Error("handler blew up"); + }); + const after = vi.fn(); + + onPageReady(boom); + onPageReady(after); + startNavigation(); + + expect(boom).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + "[Better GitHub] Handler error:", + expect.any(Error), + ); + }); +}); diff --git a/src/options.test.ts b/src/options.test.ts new file mode 100644 index 0000000..e14b1c1 --- /dev/null +++ b/src/options.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const FEATURE_KEYS = [ + "feature-pr-branch-names", + "feature-pr-review-status", + "feature-pr-diff-stats", + "feature-release-tab", + "feature-pr-label-position", + "feature-pr-approve-now", + "feature-default-sort", + "feature-commit-tags", + "feature-commit-diff-stats", + "feature-better-top-repos", + "feature-watch-fork-star-popup", + "feature-pr-collapse-expand", +] as const; + +interface ChromeStub { + store: Record; + set: ReturnType; + lastError?: { message: string }; +} + +function buildDom(): void { + const checkboxes = FEATURE_KEYS.map( + (k) => ``, + ).join(""); + document.body.innerHTML = ` + +
+ +
+ + + + + + ${checkboxes} +
+
  • Better Top Repositories
  • +
    +
    +
  • Default Sort
  • +
    + `; +} + +function stubChrome(store: Record = {}): ChromeStub { + const stub: ChromeStub = { + store, + lastError: undefined, + set: vi.fn((items: Record, cb?: () => void) => { + Object.assign(store, items); + cb?.(); + }), + }; + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn((_keys: string[], cb: (r: Record) => void) => cb(store)), + set: stub.set, + }, + }, + runtime: { + getManifest: () => ({ version: "9.9.9" }), + get lastError() { + return stub.lastError; + }, + }, + }); + return stub; +} + +async function loadOptions(store: Record = {}): Promise { + const stub = stubChrome(store); + buildDom(); + vi.resetModules(); + await import("./options"); + return stub; +} + +const $ = (id: string) => document.getElementById(id) as T; + +describe("options page", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("reflects stored settings into the form on load", async () => { + await loadOptions({ githubToken: "ghp_abc", "feature-default-sort": false }); + + expect($("token").value).toBe("ghp_abc"); + // Absent flag defaults to enabled; an explicit false renders unchecked. + expect($("feature-pr-branch-names").checked).toBe(true); + expect($("feature-default-sort").checked).toBe(false); + expect($("footer").innerHTML).toContain("9.9.9"); + }); + + it("persists the token and every flag when Save is clicked", async () => { + const chrome = await loadOptions({}); + $("token").value = " ghp_new "; + $("feature-commit-tags").checked = false; + + $("save").click(); + + expect(chrome.set).toHaveBeenCalledTimes(1); + const saved = chrome.set.mock.calls[0][0] as Record; + expect(saved.githubToken).toBe("ghp_new"); // trimmed + expect(saved["feature-commit-tags"]).toBe(false); + expect(saved["feature-pr-branch-names"]).toBe(true); + expect($("status").textContent).toBe("Saved!"); + }); + + it("surfaces a storage error from Save", async () => { + const chrome = await loadOptions({}); + chrome.lastError = { message: "quota exceeded" }; + + $("save").click(); + + expect($("status").textContent).toBe("quota exceeded"); + expect($("status").className).toContain("error"); + }); + + it("auto-saves a single flag when its checkbox is toggled", async () => { + const chrome = await loadOptions({}); + const box = $("feature-release-tab"); + + box.checked = false; + box.dispatchEvent(new Event("change")); + + expect(chrome.set).toHaveBeenCalledWith({ "feature-release-tab": false }); + }); + + it("filters feature items and hides groups with no match while searching", async () => { + await loadOptions({}); + const input = $("searchInput"); + + input.value = "top"; + input.dispatchEvent(new Event("input")); + + const groups = document.querySelectorAll(".feature-group"); + const items = document.querySelectorAll(".feature-item"); + // "Better Top Repositories" matches; "Default Sort" does not. + expect(items[0].style.display).toBe(""); + expect(items[1].style.display).toBe("none"); + expect(groups[1].style.display).toBe("none"); + }); + + it("validates the token against the GitHub API on blur", async () => { + await loadOptions({}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ login: "octocat" }), { status: 200 }), + ), + ); + + const token = $("token"); + token.value = "ghp_valid"; + token.dispatchEvent(new Event("blur")); + await vi.waitFor(() => + expect($("tokenStatus").textContent).toBe("Valid — authenticated as octocat"), + ); + expect($("tokenStatus").className).toContain("valid"); + }); +}); diff --git a/src/options.ts b/src/options.ts index 80b5b15..7f5b2c6 100644 --- a/src/options.ts +++ b/src/options.ts @@ -171,3 +171,7 @@ searchInput.addEventListener("input", () => filterFeatures(searchInput.value)); searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); }); + +// Marks this entry script as an ES module so tests can `import()` it; emits no +// runtime code. +export {}; diff --git a/src/service-worker.test.ts b/src/service-worker.test.ts index ada9612..0947b90 100644 --- a/src/service-worker.test.ts +++ b/src/service-worker.test.ts @@ -274,4 +274,136 @@ describe("service worker", () => { expect(state.sessionStore["cache:branches:owner/repo:open:1"]).toBeDefined(); }); + + it("maps GraphQL review threads into resolved/total counts", async () => { + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ + data: { + repository: { + pr_1: { reviewThreads: { totalCount: 3, nodes: [{ isResolved: true }, { isResolved: true }, { isResolved: false }] } }, + }, + }, + }), + ); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_PR_REVIEW_STATUSES", + owner: "owner", + repo: "repo", + prNumbers: [1], + }); + + expect(response).toEqual({ ok: true, data: [{ number: 1, totalThreads: 3, resolvedThreads: 2 }] }); + expect(vi.mocked(fetch).mock.calls[0][0]).toBe("https://api.github.com/graphql"); + }); + + it("normalizes commit SHAs and parses GraphQL commit diff stats", async () => { + const SHA = "0123456789abcdef0123456789abcdef01234567"; + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ + data: { repository: { [`c_${SHA}`]: { additions: 10, deletions: 2, changedFilesIfAvailable: 3 } } }, + }), + ); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_COMMIT_DIFF_STATS", + owner: "owner", + repo: "repo", + shas: [SHA.toUpperCase(), "not-a-sha"], + }); + + // Uppercase SHA is lowercased; the invalid one is filtered out before the query. + expect(response).toEqual({ + ok: true, + data: [{ sha: SHA, additions: 10, deletions: 2, changedFiles: 3 }], + }); + }); + + it("maps the stargazers REST payload", async () => { + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValue( + jsonResponse([ + { user: { login: "octocat", avatar_url: "https://a/o.png", name: "Octo Cat" }, starred_at: "2026-01-01T00:00:00Z" }, + { user: { login: "mona", avatar_url: "https://a/m.png", name: null }, starred_at: "2026-01-02T00:00:00Z" }, + ]), + ); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_STARGAZERS", + owner: "owner", + repo: "repo", + }); + + expect(response).toEqual({ + ok: true, + data: [ + { login: "octocat", avatarUrl: "https://a/o.png", name: "Octo Cat", starredAt: "2026-01-01T00:00:00Z" }, + { login: "mona", avatarUrl: "https://a/m.png", name: null, starredAt: "2026-01-02T00:00:00Z" }, + ], + }); + }); + + it("maps the forks REST payload", async () => { + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValue( + jsonResponse([ + { + owner: { login: "octocat", avatar_url: "https://a/o.png" }, + full_name: "octocat/repo", + description: "a fork", + stargazers_count: 5, + }, + ]), + ); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_FORKS", + owner: "owner", + repo: "repo", + }); + + expect(response).toEqual({ + ok: true, + data: [ + { owner: "octocat", ownerAvatarUrl: "https://a/o.png", fullName: "octocat/repo", description: "a fork", stargazersCount: 5 }, + ], + }); + expect(vi.mocked(fetch).mock.calls[0][0]).toContain("/repos/owner/repo/forks"); + }); + + it("reads tag commit OIDs from the GraphQL refs response", async () => { + const state = await loadWorker("token"); + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ + data: { + repository: { + refs: { + nodes: [ + { name: "v2.0.0", target: { oid: "sha-lightweight" } }, + { name: "v1.0.0", target: { target: { oid: "sha-annotated" } } }, + ], + }, + }, + }, + }), + ); + + const response = await sendMessage(state.messageListeners[0], { + type: "FETCH_REPO_TAGS", + owner: "owner", + repo: "repo", + }); + + // Lightweight tags carry oid directly; annotated tags nest it under target.target. + expect(response).toEqual({ + ok: true, + data: [ + { name: "v2.0.0", commitSha: "sha-lightweight" }, + { name: "v1.0.0", commitSha: "sha-annotated" }, + ], + }); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 0547287..8e9135e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,10 +14,10 @@ export default defineConfig({ // enforced when run with `--coverage` (i.e. `pnpm test:coverage`), so the // plain `pnpm test` used in CI is unaffected. thresholds: { - statements: 40, - branches: 32, - functions: 45, - lines: 42, + statements: 78, + branches: 62, + functions: 78, + lines: 82, }, }, },