From 1a2d8f4ebf41ca630ef18cc33d289e4fbac7dd38 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 6 Jun 2026 22:13:14 +0200 Subject: [PATCH] chore: add tests --- .gitea/workflows/ci.yml | 40 ++ package.json | 3 + pnpm-lock.yaml | 561 +++++++++++++++++- src/components/NoteFreshnessBadge.spec.ts | 74 +++ src/components/UserPill.spec.ts | 98 +++ src/hooks/useEditionMode.spec.ts | 76 +++ src/hooks/useForm.hook.spec.ts | 67 +++ src/modules/atproto/getUrl.spec.ts | 53 ++ src/modules/atproto/parseAtUri.spec.ts | 54 ++ src/modules/atproto/shortDid.spec.ts | 41 ++ src/modules/repo/services/repo.spec.ts | 123 ++++ .../repo/services/runWithAuthRetry.spec.ts | 97 +++ src/modules/repo/store/userRepo.store.spec.ts | 283 +++++++++ src/test/setup.ts | 8 + src/utils/decodeBase64ToUTF8.spec.ts | 25 + src/utils/fileLanguage.spec.ts | 58 ++ .../markdown/markdown-html5-media.spec.ts | 54 ++ src/utils/markdown/markdown-it-regexp.spec.ts | 42 ++ .../markdown/markdown-it-tabler-icons.spec.ts | 31 + src/utils/noteTitle.spec.ts | 59 ++ src/utils/slugify.spec.ts | 34 ++ src/utils/youtube.spec.ts | 59 ++ vite.config.mts | 6 + 23 files changed, 1943 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 src/components/NoteFreshnessBadge.spec.ts create mode 100644 src/components/UserPill.spec.ts create mode 100644 src/hooks/useEditionMode.spec.ts create mode 100644 src/hooks/useForm.hook.spec.ts create mode 100644 src/modules/atproto/getUrl.spec.ts create mode 100644 src/modules/atproto/parseAtUri.spec.ts create mode 100644 src/modules/atproto/shortDid.spec.ts create mode 100644 src/modules/repo/services/repo.spec.ts create mode 100644 src/modules/repo/services/runWithAuthRetry.spec.ts create mode 100644 src/modules/repo/store/userRepo.store.spec.ts create mode 100644 src/test/setup.ts create mode 100644 src/utils/decodeBase64ToUTF8.spec.ts create mode 100644 src/utils/fileLanguage.spec.ts create mode 100644 src/utils/markdown/markdown-html5-media.spec.ts create mode 100644 src/utils/markdown/markdown-it-regexp.spec.ts create mode 100644 src/utils/markdown/markdown-it-tabler-icons.spec.ts create mode 100644 src/utils/noteTitle.spec.ts create mode 100644 src/utils/slugify.spec.ts create mode 100644 src/utils/youtube.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e84a994 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type-check + run: pnpm types + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test --run diff --git a/package.json b/package.json index 4fe1519..7989831 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ }, "devDependencies": { "@babel/core": "^7.28.5", + "@pinia/testing": "^1.0.3", "@tailwindcss/typography": "^0.5.19", "@types/fontfaceobserver": "^2.1.3", "@types/markdown-it": "^14.1.2", @@ -79,6 +80,7 @@ "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-vue": "^5.2.4", "@vue/compiler-sfc": "^3.5.28", + "@vue/test-utils": "^2.4.11", "autoprefixer": "^10.4.24", "daisyui": "^5.5.18", "dotenv": "^17.2.3", @@ -86,6 +88,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.4.1", "esno": "^4.8.0", + "jsdom": "^29.1.1", "oxfmt": "^0.42.0", "oxlint": "^1.57.0", "prettier": "^3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f68f4a6..e0e2996 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: '@babel/core': specifier: ^7.28.5 version: 7.28.5 + '@pinia/testing': + specifier: ^1.0.3 + version: 1.0.3(pinia@2.3.1(typescript@5.9.3)(vue@3.5.18(typescript@5.9.3))) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) @@ -189,6 +192,9 @@ importers: '@vue/compiler-sfc': specifier: ^3.5.28 version: 3.5.28 + '@vue/test-utils': + specifier: ^2.4.11 + version: 2.4.11(@vue/compiler-dom@3.5.28)(@vue/server-renderer@3.5.18(vue@3.5.18(typescript@5.9.3)))(vue@3.5.18(typescript@5.9.3)) autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) @@ -210,6 +216,9 @@ importers: esno: specifier: ^4.8.0 version: 4.8.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 oxfmt: specifier: ^0.42.0 version: 0.42.0 @@ -239,7 +248,7 @@ importers: version: 1.2.0(@vite-pwa/assets-generator@1.0.2)(vite@8.0.1(@types/node@22.15.24)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.20.3))(workbox-build@7.4.0)(workbox-window@7.4.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.24)(jiti@2.6.1)(jsdom@9.12.0)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.24)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.20.3) packages: @@ -269,6 +278,21 @@ packages: '@ark/util@0.56.0': resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@atproto-labs/did-resolver@0.2.6': resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} @@ -897,6 +921,10 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@canvas/image-data@1.1.0': resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} @@ -915,6 +943,42 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -1092,6 +1156,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1283,6 +1356,10 @@ packages: vue-i18n: optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1407,6 +1484,9 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@openpanel/sdk@1.3.0': resolution: {integrity: sha512-VK/1oawBjGdxA+oYtqcWlNXlLT1zRJ9tslHoMvqqsqlcLNOhH26ltcHpyGp5RhtIF7uIkCltiicALfFN7fyldw==} @@ -1748,6 +1828,15 @@ packages: resolution: {integrity: sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==} engines: {node: '>= 10.0.0'} + '@pinia/testing@1.0.3': + resolution: {integrity: sha512-g+qR49GNdI1Z8rZxKrQC3GN+LfnGTNf5Kk8Nz5Cz6mIGva5WRS+ffPXQfzhA0nu6TveWzPNYTjGl4nJqd3Cu9Q==} + peerDependencies: + pinia: '>=3.0.4' + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/utils@2.4.2': resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2468,6 +2557,16 @@ packages: '@vue/shared@3.5.28': resolution: {integrity: sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==} + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + '@vueuse/components@14.2.1': resolution: {integrity: sha512-wB0SvwJ22mNm1hWCMI1wTWz4x55nDTugT5RIg/KCwlWc1vITWL6ry5VTU3SQzsMD2XcazJK8Be1siIsrBb/Vcw==} peerDependencies: @@ -2512,6 +2611,10 @@ packages: resolution: {integrity: sha512-I+Wi+qiE2kUXyrRhNsWv6XsjUTBJjSoVSctKNBfLG5zG/Xe7Rjbxf13+vqYHNTwHaFU+FtSlVxOCTiMEVtPv0A==} deprecated: Use your platform's native atob() and btoa() methods instead + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + acorn-globals@3.1.0: resolution: {integrity: sha512-uWttZCk96+7itPxK8xCzY86PnxKTMrReKDqrHzv42VQY0K30PUO8WY13WMOuI+cOdX4EIdzdvQ8k6jkuGRFMYw==} @@ -2562,6 +2665,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@2.2.1: resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} engines: {node: '>=0.10.0'} @@ -2574,6 +2681,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@2.0.0: resolution: {integrity: sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==} @@ -2785,6 +2896,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} @@ -2980,6 +3094,10 @@ packages: comlink@4.4.2: resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3007,6 +3125,9 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -3059,6 +3180,10 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3236,6 +3361,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3292,6 +3421,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-bmp@0.2.1: resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==} engines: {node: '>=8.6.0'} @@ -3409,9 +3541,17 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3423,6 +3563,12 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -3435,6 +3581,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -3864,6 +4014,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -3985,6 +4140,10 @@ packages: html-encoding-sniffer@1.0.2: resolution: {integrity: sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -4036,6 +4195,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -4173,6 +4335,10 @@ packages: resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} engines: {node: '>=4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -4241,6 +4407,9 @@ packages: resolution: {integrity: sha512-Yu68oeXJ7LeWNmZ3Zov/xg/oDBnBK2RNxwYY1ilNJX+tKKZqgPK+qOn/Gs9jEu66KDY9Netf5XLKNGzas/vPfQ==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-primitive@2.0.0: resolution: {integrity: sha512-N3w1tFaRfk3UrPfqeRyD+GYDASU3W5VinKhlORy8EWVf/sIdDL9GAcew85XmktCfH+ngG7SRXEVDoO18WMdB/Q==} engines: {node: '>=0.10.0'} @@ -4362,6 +4531,9 @@ packages: istanbul-reports@1.5.1: resolution: {integrity: sha512-+cfoZ0UXzWjhAdzosCPP3AN8vvef8XDkWtTfgaN+7L3YTpNYITnCaEkceo5SEYy644VkHka/P1FvkWvrG/rrJw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -4449,6 +4621,14 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-tokens@3.0.2: resolution: {integrity: sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==} @@ -4469,6 +4649,15 @@ packages: jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsdom@9.12.0: resolution: {integrity: sha512-Qw4oqNxo4LyzkSqVIyCnEltTc4xV3g1GBaI88AvYTesWzmWHUSoMNmhBjUBa+6ldXIBJS9xoeLNJPfUAykTyxw==} @@ -4791,6 +4980,10 @@ packages: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -4857,6 +5050,9 @@ packages: math-random@1.0.4: resolution: {integrity: sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -5001,6 +5197,11 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -5175,6 +5376,9 @@ packages: parse5@1.5.1: resolution: {integrity: sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pascalcase@0.1.1: resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} engines: {node: '>=0.10.0'} @@ -5216,6 +5420,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -5388,6 +5596,9 @@ packages: resolution: {integrity: sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==} engines: {node: '>= 0.6'} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -5650,6 +5861,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -5859,6 +6074,14 @@ packages: resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} engines: {node: '>=4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -5891,6 +6114,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@2.0.0: resolution: {integrity: sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==} engines: {node: '>=0.10.0'} @@ -6021,6 +6248,13 @@ packages: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -6051,12 +6285,20 @@ packages: resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} engines: {node: '>=0.8'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-right@1.0.1: resolution: {integrity: sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==} engines: {node: '>=0.10.0'} @@ -6155,6 +6397,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.27.1: + resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -6398,6 +6644,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vue-component-type-helpers@3.3.3: + resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==} + vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -6431,6 +6680,10 @@ packages: vuvuzela@1.0.3: resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6445,6 +6698,10 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -6459,6 +6716,14 @@ packages: whatwg-fetch@3.6.2: resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@4.8.0: resolution: {integrity: sha512-nUvUPuenPFtPfy/X+dAYh/TfRbTBlnXTM5iIfLseJFkkQewmpG9pGR6i87E9qL+lZaJzv+99kkQWoGOtLfkZQQ==} @@ -6564,6 +6829,14 @@ packages: resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6573,6 +6846,13 @@ packages: xml-name-validator@2.0.1: resolution: {integrity: sha512-jRKe/iQYMyVJpzPH+3HL97Lgu5HrCfii+qSo+TfjKHtOnvbnvdVfMYrn9Q34YV81M2e5sviJlI6Ko9y+nByzvA==} + 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==} + y18n@3.2.2: resolution: {integrity: sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==} @@ -6629,6 +6909,26 @@ snapshots: '@ark/util@0.56.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@atproto-labs/did-resolver@0.2.6': dependencies: '@atproto-labs/fetch': 0.2.3 @@ -7518,6 +7818,10 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@canvas/image-data@1.1.0': {} '@chevrotain/cst-dts-gen@11.0.3': @@ -7537,6 +7841,30 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -7651,6 +7979,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.1': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -7815,6 +8145,15 @@ snapshots: vue: 3.5.18(typescript@5.9.3) vue-i18n: 11.1.11(vue@3.5.18(typescript@5.9.3)) + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.12': @@ -7953,6 +8292,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@one-ini/wasm@0.1.1': {} + '@openpanel/sdk@1.3.0': {} '@openpanel/web@1.3.0': @@ -8138,6 +8479,13 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.0 optional: true + '@pinia/testing@1.0.3(pinia@2.3.1(typescript@5.9.3)(vue@3.5.18(typescript@5.9.3)))': + dependencies: + pinia: 2.3.1(typescript@5.9.3)(vue@3.5.18(typescript@5.9.3)) + + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/utils@2.4.2': dependencies: cross-spawn: 7.0.3 @@ -8826,6 +9174,15 @@ snapshots: '@vue/shared@3.5.28': {} + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.28)(@vue/server-renderer@3.5.18(vue@3.5.18(typescript@5.9.3)))(vue@3.5.18(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.28 + js-beautify: 1.15.4 + vue: 3.5.18(typescript@5.9.3) + vue-component-type-helpers: 3.3.3 + optionalDependencies: + '@vue/server-renderer': 3.5.18(vue@3.5.18(typescript@5.9.3)) + '@vueuse/components@14.2.1(vue@3.5.18(typescript@5.9.3))': dependencies: '@vueuse/core': 14.2.1(vue@3.5.18(typescript@5.9.3)) @@ -8868,6 +9225,8 @@ snapshots: abab@1.0.4: {} + abbrev@2.0.0: {} + acorn-globals@3.1.0: dependencies: acorn: 4.0.13 @@ -8906,6 +9265,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@2.2.1: {} ansi-styles@3.2.1: @@ -8916,6 +9277,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + anymatch@2.0.0: dependencies: micromatch: 3.1.10 @@ -9200,6 +9563,10 @@ snapshots: before-after-hook@4.0.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.51: {} binary-extensions@2.2.0: {} @@ -9444,6 +9811,8 @@ snapshots: comlink@4.4.2: {} + commander@10.0.1: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -9460,6 +9829,11 @@ snapshots: confbox@0.2.2: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + consola@3.4.2: {} content-type-parser@1.0.2: {} @@ -9508,6 +9882,11 @@ snapshots: crypto-random-string@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} cssom@0.3.8: {} @@ -9708,6 +10087,13 @@ snapshots: dependencies: assert-plus: 1.0.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -9748,6 +10134,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decode-bmp@0.2.1: dependencies: '@canvas/image-data': 1.1.0 @@ -9867,11 +10255,20 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 safer-buffer: 2.1.2 + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.5 + semver: 7.6.3 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -9880,6 +10277,10 @@ snapshots: electron-to-chromium@1.5.267: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -9889,6 +10290,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + errno@0.1.8: dependencies: prr: 1.0.1 @@ -10466,6 +10869,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -10583,6 +10995,12 @@ snapshots: dependencies: whatwg-encoding: 1.0.5 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -10630,6 +11048,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -10754,6 +11174,8 @@ snapshots: is-fullwidth-code-point@2.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -10809,6 +11231,8 @@ snapshots: is-posix-bracket@0.1.1: {} + is-potential-custom-element-name@1.0.1: {} + is-primitive@2.0.0: {} is-regex@1.2.1: @@ -10948,6 +11372,12 @@ snapshots: dependencies: handlebars: 4.7.8 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -11153,6 +11583,16 @@ snapshots: jose@5.10.0: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + + js-cookie@3.0.8: {} + js-tokens@3.0.2: {} js-tokens@4.0.0: {} @@ -11170,6 +11610,32 @@ snapshots: jsbn@0.1.1: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsdom@9.12.0: dependencies: abab: 1.0.4 @@ -11450,6 +11916,8 @@ snapshots: lru-cache@11.2.7: {} + lru-cache@11.5.1: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -11519,6 +11987,8 @@ snapshots: math-random@1.0.4: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} mem@1.1.0: @@ -11699,6 +12169,10 @@ snapshots: node-releases@2.0.27: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -11910,6 +12384,10 @@ snapshots: parse5@1.5.1: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + pascalcase@0.1.1: {} pastel-color@1.0.3: {} @@ -11934,6 +12412,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 @@ -12105,6 +12588,8 @@ snapshots: private@0.1.8: {} + proto-list@1.2.4: {} + prr@1.0.1: {} pseudomap@1.0.2: {} @@ -12442,6 +12927,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@5.7.2: {} semver@6.3.1: {} @@ -12699,6 +13188,18 @@ snapshots: is-fullwidth-code-point: 2.0.0 strip-ansi: 4.0.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -12756,6 +13257,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@2.0.0: dependencies: is-utf8: 0.2.1 @@ -12861,6 +13366,12 @@ snapshots: titleize@3.0.0: {} + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + tmpl@1.0.5: {} to-data-view@1.1.0: {} @@ -12892,12 +13403,20 @@ snapshots: psl: 1.15.0 punycode: 2.3.1 + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + tr46@0.0.3: {} tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-right@1.0.1: {} ts-api-utils@2.1.0(typescript@5.9.3): @@ -13005,6 +13524,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.27.1: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -13154,7 +13675,7 @@ snapshots: terser: 5.46.0 tsx: 4.20.3 - vitest@3.2.4(@types/node@22.15.24)(jiti@2.6.1)(jsdom@9.12.0)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.20.3): + vitest@3.2.4(@types/node@22.15.24)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.20.3): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -13181,7 +13702,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.15.24 - jsdom: 9.12.0 + jsdom: 29.1.1 transitivePeerDependencies: - jiti - less @@ -13213,6 +13734,8 @@ snapshots: vscode-uri@3.0.8: {} + vue-component-type-helpers@3.3.3: {} + vue-demi@0.14.10(vue@3.5.18(typescript@5.9.3)): dependencies: vue: 3.5.18(typescript@5.9.3) @@ -13241,6 +13764,10 @@ snapshots: vuvuzela@1.0.3: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -13254,6 +13781,8 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.2.3: {} webpack-virtual-modules@0.5.0: {} @@ -13264,6 +13793,16 @@ snapshots: whatwg-fetch@3.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@4.8.0: dependencies: tr46: 0.0.3 @@ -13462,6 +14001,18 @@ snapshots: string-width: 1.0.2 strip-ansi: 3.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@2.4.3: @@ -13472,6 +14023,10 @@ snapshots: xml-name-validator@2.0.1: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@3.2.2: {} yallist@2.1.2: {} diff --git a/src/components/NoteFreshnessBadge.spec.ts b/src/components/NoteFreshnessBadge.spec.ts new file mode 100644 index 0000000..6e9f5ae --- /dev/null +++ b/src/components/NoteFreshnessBadge.spec.ts @@ -0,0 +1,74 @@ +import { mount } from "@vue/test-utils" +import { describe, expect, it } from "vitest" + +import NoteFreshnessBadge from "./NoteFreshnessBadge.vue" + +const factory = (props: { status: string; lastCheckedAt?: Date | null }) => + mount(NoteFreshnessBadge, { + props: { lastCheckedAt: null, ...props } as never + }) + +describe("NoteFreshnessBadge", () => { + it("renders the verified state with the cloud-check icon", () => { + const wrapper = factory({ status: "verified" }) + expect(wrapper.classes()).toContain("state-verified") + expect(wrapper.find(".icon-tabler-cloud-check").exists()).toBe(true) + }) + + it("renders the outdated state with the download icon", () => { + const wrapper = factory({ status: "outdated" }) + expect(wrapper.classes()).toContain("state-outdated") + expect(wrapper.find(".icon-tabler-cloud-download").exists()).toBe(true) + }) + + it("renders the offline state with the cloud-off icon", () => { + const wrapper = factory({ status: "offline" }) + expect(wrapper.classes()).toContain("state-offline") + expect(wrapper.find(".icon-tabler-cloud-off").exists()).toBe(true) + }) + + it("renders the unauthorized state with the lock icon", () => { + const wrapper = factory({ status: "unauthorized" }) + expect(wrapper.classes()).toContain("state-unauthorized") + expect(wrapper.find(".icon-tabler-cloud-lock").exists()).toBe(true) + }) + + it("disables the button while checking", () => { + const wrapper = factory({ status: "checking" }) + expect(wrapper.attributes("disabled")).toBeDefined() + expect(wrapper.find(".icon-tabler-loader-2").exists()).toBe(true) + }) + + it("emits click when not busy", async () => { + const wrapper = factory({ status: "verified" }) + + await wrapper.trigger("click") + + expect(wrapper.emitted("click")).toHaveLength(1) + }) + + it("includes the last-checked time in the verified tooltip", () => { + const wrapper = factory({ + status: "verified", + lastCheckedAt: new Date("2026-01-01T10:30:00") + }) + const tooltip = wrapper.attributes("title") as string + expect(tooltip).toMatch(/Verified at/) + }) + + it("renders the unknown state as default", () => { + const wrapper = factory({ status: "unknown" }) + expect(wrapper.classes()).toContain("state-unknown") + expect(wrapper.find(".icon-tabler-cloud-question").exists()).toBe(true) + }) + + it("offline tooltip prompts a retry", () => { + const wrapper = factory({ status: "offline" }) + expect(wrapper.attributes("title")).toMatch(/retry/i) + }) + + it("unauthorized tooltip prompts a sign-in", () => { + const wrapper = factory({ status: "unauthorized" }) + expect(wrapper.attributes("title")).toMatch(/[Ss]ign in/) + }) +}) diff --git a/src/components/UserPill.spec.ts b/src/components/UserPill.spec.ts new file mode 100644 index 0000000..0716faf --- /dev/null +++ b/src/components/UserPill.spec.ts @@ -0,0 +1,98 @@ +import { mount } from "@vue/test-utils" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { ref } from "vue" + +const ghAccessToken = ref(null) +const ghUsername = ref(null) +const atIsLoggedIn = ref(false) +const atHandle = ref(null) +const atAvatarUrl = ref(null) + +vi.mock("@/hooks/useGitHubLogin.hook", () => ({ + useGitHubLogin: () => ({ + username: ghUsername, + accessToken: ghAccessToken + }) +})) + +vi.mock("@/hooks/useATProtoLogin.hook", () => ({ + useATProtoLogin: () => ({ + isLoggedIn: atIsLoggedIn, + handle: atHandle, + avatarUrl: atAvatarUrl + }) +})) + +import UserPill from "./UserPill.vue" + +describe("UserPill", () => { + beforeEach(() => { + ghAccessToken.value = null + ghUsername.value = null + atIsLoggedIn.value = false + atHandle.value = null + atAvatarUrl.value = null + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("shows the ghost 'Sign in' pill when nobody is logged in", () => { + const wrapper = mount(UserPill) + expect(wrapper.text()).toContain("Sign in") + expect(wrapper.classes()).toContain("profile-chip--ghost") + }) + + it("shows the GitHub username and initial when logged in via GitHub only", () => { + ghAccessToken.value = "tok" + ghUsername.value = "alice" + + const wrapper = mount(UserPill) + + expect(wrapper.text()).toContain("alice") + expect(wrapper.find(".profile-avatar-initial").text()).toBe("A") + expect(wrapper.find("img").exists()).toBe(false) + expect(wrapper.classes()).not.toContain("profile-chip--ghost") + }) + + it("shows the ATProto handle and avatar when logged in via ATProto", () => { + atIsLoggedIn.value = true + atHandle.value = "alice.bsky.social" + atAvatarUrl.value = "https://cdn.example.com/avatar.jpg" + + const wrapper = mount(UserPill) + + expect(wrapper.text()).toContain("alice.bsky.social") + const img = wrapper.find("img") + expect(img.exists()).toBe(true) + expect(img.attributes("src")).toBe("https://cdn.example.com/avatar.jpg") + }) + + it("prefers the ATProto handle over the GitHub username when both are present", () => { + ghAccessToken.value = "tok" + ghUsername.value = "alice-gh" + atIsLoggedIn.value = true + atHandle.value = "alice.bsky.social" + + const wrapper = mount(UserPill) + + expect(wrapper.text()).toContain("alice.bsky.social") + expect(wrapper.text()).not.toContain("alice-gh") + }) + + it("falls back to '?' initial when no name is available but a user is logged in", () => { + atIsLoggedIn.value = true + atHandle.value = "" + + const wrapper = mount(UserPill) + + expect(wrapper.find(".profile-avatar-initial").text()).toBe("?") + }) + + it("emits click when clicked", async () => { + const wrapper = mount(UserPill) + await wrapper.trigger("click") + expect(wrapper.emitted("click")).toHaveLength(1) + }) +}) diff --git a/src/hooks/useEditionMode.spec.ts b/src/hooks/useEditionMode.spec.ts new file mode 100644 index 0000000..0ca70b1 --- /dev/null +++ b/src/hooks/useEditionMode.spec.ts @@ -0,0 +1,76 @@ +import { mount } from "@vue/test-utils" +import { afterEach, describe, expect, it, vi } from "vitest" +import { defineComponent, ref } from "vue" + +const escape = ref(false) +vi.mock("@vueuse/core", () => ({ + useMagicKeys: () => ({ escape }) +})) + +import { useEditionMode } from "./useEditionMode" + +const host = (slot: (api: ReturnType) => void) => + mount( + defineComponent({ + template: "
", + setup() { + const api = useEditionMode() + slot(api) + return api + } + }) + ) + +describe("useEditionMode", () => { + afterEach(() => { + escape.value = false + }) + + it("starts in read mode", () => { + let mode: unknown + host((api) => { + mode = api.mode.value + }) + expect(mode).toBe("read") + }) + + it("toggleMode flips read ↔ edit", () => { + let api: ReturnType | undefined + host((a) => { + api = a + }) + + api!.toggleMode() + expect(api!.mode.value).toBe("edit") + + api!.toggleMode() + expect(api!.mode.value).toBe("read") + }) + + it("escape key exits edit mode", async () => { + let api: ReturnType | undefined + host((a) => { + a.toggleMode() + api = a + }) + + expect(api!.mode.value).toBe("edit") + + escape.value = true + await new Promise((r) => setTimeout(r, 0)) + + expect(api!.mode.value).toBe("read") + }) + + it("escape key is a no-op when already in read mode", async () => { + let api: ReturnType | undefined + host((a) => { + api = a + }) + + escape.value = true + await new Promise((r) => setTimeout(r, 0)) + + expect(api!.mode.value).toBe("read") + }) +}) diff --git a/src/hooks/useForm.hook.spec.ts b/src/hooks/useForm.hook.spec.ts new file mode 100644 index 0000000..1406837 --- /dev/null +++ b/src/hooks/useForm.hook.spec.ts @@ -0,0 +1,67 @@ +import { mount } from "@vue/test-utils" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { defineComponent } from "vue" + +const push = vi.fn() +vi.mock("vue-router", () => ({ + useRouter: () => ({ push }) +})) + +import { useForm } from "./useForm.hook" + +const host = () => { + let api!: ReturnType + mount( + defineComponent({ + template: "
", + setup() { + api = useForm() + return api + } + }) + ) + return api +} + +describe("useForm", () => { + beforeEach(() => { + push.mockReset() + }) + + it("starts with empty user and repo inputs", () => { + const api = host() + expect(api.userInput.value).toBe("") + expect(api.repoInput.value).toBe("") + }) + + it("submit is a no-op when userInput is empty", () => { + const api = host() + api.repoInput.value = "notes" + + api.submit() + + expect(push).not.toHaveBeenCalled() + }) + + it("submit is a no-op when repoInput is empty", () => { + const api = host() + api.userInput.value = "alice" + + api.submit() + + expect(push).not.toHaveBeenCalled() + }) + + it("submit pushes the FluxNoteView route with user/repo params", () => { + const api = host() + api.userInput.value = "alice" + api.repoInput.value = "notes" + + api.submit() + + expect(push).toHaveBeenCalledWith({ + name: "FluxNoteView", + params: { user: "alice", repo: "notes" } + }) + }) +}) diff --git a/src/modules/atproto/getUrl.spec.ts b/src/modules/atproto/getUrl.spec.ts new file mode 100644 index 0000000..4cf0f41 --- /dev/null +++ b/src/modules/atproto/getUrl.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/modules/atproto/getAuthor", () => ({ + getAuthor: vi.fn() +})) + +import { getAuthor } from "@/modules/atproto/getAuthor" + +import { getUrl } from "./getUrl" + +describe("getUrl", () => { + beforeEach(() => { + vi.mocked(getAuthor).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when the author cannot be resolved", async () => { + vi.mocked(getAuthor).mockResolvedValue(null) + expect( + await getUrl({ did: "did:plc:abc", rkey: "r1" }) + ).toBeNull() + }) + + it("builds a getRecord URL with the right query params on the author's PDS", async () => { + vi.mocked(getAuthor).mockResolvedValue({ + handle: "alice.bsky.social", + pds: "https://pds.example.com" + }) + + const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" }) + const parsed = new URL(url as string) + + expect(parsed.origin).toBe("https://pds.example.com") + expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord") + expect(parsed.searchParams.get("repo")).toBe("did:plc:abc") + expect(parsed.searchParams.get("collection")).toBe("space.remanso.note") + expect(parsed.searchParams.get("rkey")).toBe("rkey1") + }) + + it("passes the did to getAuthor", async () => { + vi.mocked(getAuthor).mockResolvedValue({ + handle: "h", + pds: "https://pds.example.com" + }) + + await getUrl({ did: "did:web:example.com", rkey: "r2" }) + + expect(getAuthor).toHaveBeenCalledWith("did:web:example.com") + }) +}) diff --git a/src/modules/atproto/parseAtUri.spec.ts b/src/modules/atproto/parseAtUri.spec.ts new file mode 100644 index 0000000..bb14545 --- /dev/null +++ b/src/modules/atproto/parseAtUri.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" + +import { parseAtUri } from "./parseAtUri" + +describe("parseAtUri", () => { + it("parses a did:plc AT URI", () => { + expect( + parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz") + ).toEqual({ + did: "did:plc:abc123", + rkey: "rkey-xyz" + }) + }) + + it("parses a did:web AT URI", () => { + expect( + parseAtUri("at://did:web:example.com/space.remanso.note/note-1") + ).toEqual({ + did: "did:web:example.com", + rkey: "note-1" + }) + }) + + it("treats rkeys with slashes as a single trailing segment", () => { + expect( + parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment") + ).toEqual({ + did: "did:plc:abc", + rkey: "multi/segment" + }) + }) + + it("throws when the URI does not start with at://", () => { + expect(() => + parseAtUri("https://did:plc:abc/collection/rkey") + ).toThrow(/Invalid AT URI/) + }) + + it("throws when the DID prefix is missing", () => { + expect(() => parseAtUri("at://abc/collection/rkey")).toThrow( + /Invalid AT URI/ + ) + }) + + it("throws when the collection or rkey is missing", () => { + expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow( + /Invalid AT URI/ + ) + }) + + it("throws on empty input", () => { + expect(() => parseAtUri("")).toThrow(/Invalid AT URI/) + }) +}) diff --git a/src/modules/atproto/shortDid.spec.ts b/src/modules/atproto/shortDid.spec.ts new file mode 100644 index 0000000..62ed086 --- /dev/null +++ b/src/modules/atproto/shortDid.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" + +import { fromShortDid, toShortDid } from "./shortDid" + +describe("toShortDid", () => { + it("strips did:plc: prefix", () => { + expect(toShortDid("did:plc:abc123")).toBe("abc123") + }) + + it("strips did: prefix but keeps the method when non-plc", () => { + expect(toShortDid("did:web:example.com")).toBe("web:example.com") + }) + + it("returns input unchanged when there is no did: prefix", () => { + expect(toShortDid("abc123")).toBe("abc123") + }) +}) + +describe("fromShortDid", () => { + it("adds did:plc: prefix to bare identifiers", () => { + expect(fromShortDid("abc123")).toBe("did:plc:abc123") + }) + + it("adds did: prefix when method is already present", () => { + expect(fromShortDid("web:example.com")).toBe("did:web:example.com") + }) + + it("passes through fully-qualified DIDs unchanged", () => { + expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123") + expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com") + }) +}) + +describe("round-trip toShortDid → fromShortDid", () => { + it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])( + "is identity for %s", + (did) => { + expect(fromShortDid(toShortDid(did))).toBe(did) + } + ) +}) diff --git a/src/modules/repo/services/repo.spec.ts b/src/modules/repo/services/repo.spec.ts new file mode 100644 index 0000000..3889881 --- /dev/null +++ b/src/modules/repo/services/repo.spec.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("./octo", () => ({ + getOctokit: vi.fn(), + runWithAuthRetry: vi.fn() +})) + +// Stub heavyweight transitive imports so loading repo.ts doesn't spin up the +// data Web Worker or the full markdown rendering stack. +vi.mock("@/hooks/useMarkdown.hook", () => ({ + markdownBuilder: () => ({ render: (s: string) => s }) +})) +vi.mock("@/modules/note/cache/prepareNoteCache", () => ({ + prepareNoteCache: () => ({ + getCachedNote: async () => ({ note: null }), + saveCacheNote: vi.fn() + }) +})) + +import { getOctokit, runWithAuthRetry } from "./octo" +import { getFiles, queryFileContent } from "./repo" + +const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({ + request: vi.fn(impl) +}) + +describe("getFiles", () => { + beforeEach(() => { + vi.mocked(getOctokit).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns empty array when owner is missing", async () => { + expect(await getFiles("", "repo")).toEqual([]) + expect(getOctokit).not.toHaveBeenCalled() + }) + + it("returns empty array when repo is missing", async () => { + expect(await getFiles("owner", "")).toEqual([]) + expect(getOctokit).not.toHaveBeenCalled() + }) + + it("returns empty array when there are no commits", async () => { + const octokit = makeOctokitWithRequest((route) => { + if (route === "GET /repos/{owner}/{repo}/commits") { + return { data: [] } + } + throw new Error("unexpected route " + route) + }) + vi.mocked(getOctokit).mockResolvedValue(octokit as never) + + expect(await getFiles("owner", "repo")).toEqual([]) + }) + + it("fetches the tree from the latest commit and filters out non-blob entries", async () => { + const octokit = makeOctokitWithRequest((route, params) => { + if (route === "GET /repos/{owner}/{repo}/commits") { + return { + data: [{ commit: { tree: { sha: "TREE_SHA" } } }] + } + } + if (route === "GET /repos/{owner}/{repo}/git/trees/{tree_sha}") { + expect((params as { tree_sha: string }).tree_sha).toBe("TREE_SHA") + expect((params as { recursive: string }).recursive).toBe("true") + return { + data: { + tree: [ + { path: "README.md", type: "blob", sha: "a" }, + { path: "src", type: "tree", sha: "b" }, + { path: "src/note.md", type: "blob", sha: "c" } + ] + } + } + } + throw new Error("unexpected route " + route) + }) + vi.mocked(getOctokit).mockResolvedValue(octokit as never) + + const files = await getFiles("owner", "repo") + + expect(files.map((f) => f.path)).toEqual(["README.md", "src/note.md"]) + }) +}) + +describe("queryFileContent", () => { + beforeEach(() => { + vi.mocked(runWithAuthRetry).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when owner or repo is missing", async () => { + expect(await queryFileContent("", "repo", "sha")).toBeNull() + expect(await queryFileContent("owner", "", "sha")).toBeNull() + expect(runWithAuthRetry).not.toHaveBeenCalled() + }) + + it("returns the blob content via runWithAuthRetry", async () => { + vi.mocked(runWithAuthRetry).mockImplementation(async (call) => { + const octokit = { + request: vi.fn().mockResolvedValue({ + data: { content: "BASE64" } + }) + } + return call(octokit as never) + }) + + expect(await queryFileContent("owner", "repo", "SHA")).toBe("BASE64") + }) + + it("returns null and swallows errors when the call fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("boom")) + + expect(await queryFileContent("owner", "repo", "SHA")).toBeNull() + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/modules/repo/services/runWithAuthRetry.spec.ts b/src/modules/repo/services/runWithAuthRetry.spec.ts new file mode 100644 index 0000000..b36d265 --- /dev/null +++ b/src/modules/repo/services/runWithAuthRetry.spec.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/modules/user/service/signIn", () => ({ + getAccessToken: vi.fn(), + refreshToken: vi.fn() +})) + +import { + getAccessToken, + refreshToken +} from "@/modules/user/service/signIn" + +import { runWithAuthRetry } from "./octo" + +const unauthorized = () => Object.assign(new Error("Bad credentials"), { status: 401 }) +const notFound = () => Object.assign(new Error("Not found"), { status: 404 }) + +describe("runWithAuthRetry", () => { + beforeEach(() => { + vi.mocked(getAccessToken).mockResolvedValue({ + token: "t1" + } as Awaited>) + vi.mocked(refreshToken).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the call result on success without refreshing", async () => { + const call = vi.fn().mockResolvedValue("ok") + + const result = await runWithAuthRetry(call) + + expect(result).toBe("ok") + expect(call).toHaveBeenCalledTimes(1) + expect(refreshToken).not.toHaveBeenCalled() + }) + + it("rethrows non-401 errors immediately without refreshing", async () => { + const err = notFound() + const call = vi.fn().mockRejectedValue(err) + + await expect(runWithAuthRetry(call)).rejects.toBe(err) + expect(refreshToken).not.toHaveBeenCalled() + expect(call).toHaveBeenCalledTimes(1) + }) + + it("refreshes and retries once on 401, returning the retry result", async () => { + vi.mocked(refreshToken).mockResolvedValue({ + token: "t2" + } as Awaited>) + + const call = vi + .fn() + .mockRejectedValueOnce(unauthorized()) + .mockResolvedValueOnce("after-refresh") + + const result = await runWithAuthRetry(call) + + expect(result).toBe("after-refresh") + expect(refreshToken).toHaveBeenCalledWith({ force: true }) + expect(call).toHaveBeenCalledTimes(2) + }) + + it("rethrows the original 401 when refresh returns null", async () => { + vi.mocked(refreshToken).mockResolvedValue(null) + const err = unauthorized() + const call = vi.fn().mockRejectedValue(err) + + await expect(runWithAuthRetry(call)).rejects.toBe(err) + expect(call).toHaveBeenCalledTimes(1) + }) + + it("rethrows the original 401 when refresh itself throws", async () => { + vi.mocked(refreshToken).mockRejectedValue(new Error("network down")) + const err = unauthorized() + const call = vi.fn().mockRejectedValue(err) + + await expect(runWithAuthRetry(call)).rejects.toBe(err) + }) + + it("propagates a retry-time error after a successful refresh", async () => { + vi.mocked(refreshToken).mockResolvedValue({ + token: "t2" + } as Awaited>) + + const retryErr = unauthorized() + const call = vi + .fn() + .mockRejectedValueOnce(unauthorized()) + .mockRejectedValueOnce(retryErr) + + await expect(runWithAuthRetry(call)).rejects.toBe(retryErr) + expect(call).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/modules/repo/store/userRepo.store.spec.ts b/src/modules/repo/store/userRepo.store.spec.ts new file mode 100644 index 0000000..1093e0c --- /dev/null +++ b/src/modules/repo/store/userRepo.store.spec.ts @@ -0,0 +1,283 @@ +import { createPinia, setActivePinia } from "pinia" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/data/data", () => ({ + data: { + get: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(undefined), + add: vi.fn().mockResolvedValue(undefined) + }, + generateId: (type: string, id: string) => `${type}-${id}` +})) + +vi.mock("@/modules/repo/services/repo", () => ({ + getFiles: vi.fn().mockResolvedValue([]), + getMainReadme: vi.fn().mockResolvedValue(null), + getCachedMainReadme: vi.fn().mockResolvedValue(null), + getUserSettingsContent: vi.fn().mockResolvedValue(null) +})) + +vi.mock("@/modules/user/service/signIn", () => ({ + refreshToken: vi.fn().mockResolvedValue(null) +})) + +import { data } from "@/data/data" +import { + getCachedMainReadme, + getFiles, + getMainReadme, + getUserSettingsContent +} from "@/modules/repo/services/repo" + +import { useUserRepoStore } from "./userRepo.store" + +const flushAsync = () => new Promise((r) => setTimeout(r, 0)) + +describe("userRepo store — synchronous mutations", () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("resetUserRepo clears user, repo, files, and settings", () => { + const store = useUserRepoStore() + store.user = "alice" + store.repo = "notes" + store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never + store.userSettings = { $type: 1 } as never + + store.resetUserRepo() + + expect(store.user).toBe("") + expect(store.repo).toBe("") + expect(store.files).toEqual([]) + expect(store.userSettings).toBeUndefined() + }) + + it("resetFiles clears files and sets readme to null", () => { + const store = useUserRepoStore() + store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never + store.readme = "

hi

" + + store.resetFiles() + + expect(store.files).toEqual([]) + expect(store.readme).toBeNull() + }) + + it("addFile appends a new file with a unique sha", () => { + const store = useUserRepoStore() + store.user = "alice" + store.repo = "notes" + store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never + + store.addFile({ sha: "new", path: "b.md", type: "blob" } as never) + + expect(store.files.map((f) => f.sha)).toEqual(["old", "new"]) + expect(vi.mocked(data.update)).toHaveBeenCalled() + }) + + it("addFile is a no-op when the sha already exists", () => { + const store = useUserRepoStore() + store.user = "alice" + store.repo = "notes" + store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never + + store.addFile({ sha: "x", path: "duplicate.md", type: "blob" } as never) + + expect(store.files).toHaveLength(1) + expect(vi.mocked(data.update)).not.toHaveBeenCalled() + }) + + it("addFile is a no-op when sha is missing", () => { + const store = useUserRepoStore() + store.files = [] + + store.addFile({ path: "no-sha.md", type: "blob" } as never) + + expect(store.files).toHaveLength(0) + expect(vi.mocked(data.update)).not.toHaveBeenCalled() + }) + + it("setFontFamily initializes userSettings when absent and persists to localStorage", () => { + const store = useUserRepoStore() + store.user = "alice" + store.repo = "notes" + + store.setFontFamily("Inter") + + expect(store.userSettings?.chosenFontFamily).toBe("Inter") + const persisted = JSON.parse( + localStorage.getItem("remanso:layout:alice:notes") as string + ) + expect(persisted.chosenFontFamily).toBe("Inter") + }) + + it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => { + const store = useUserRepoStore() + store.user = "alice" + store.repo = "notes" + + store.setFontSize("18px") + store.setTitleFont("Serif") + store.setBodyFont("Sans") + + const persisted = JSON.parse( + localStorage.getItem("remanso:layout:alice:notes") as string + ) + expect(persisted.chosenFontSize).toBe("18px") + expect(persisted.chosenTitleFont).toBe("Serif") + expect(persisted.chosenBodyFont).toBe("Sans") + }) +}) + +describe("userRepo store — setUserRepo", () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.clearAllMocks() + vi.mocked(data.get).mockResolvedValue(null as never) + vi.mocked(getFiles).mockResolvedValue([]) + vi.mocked(getMainReadme).mockResolvedValue(null) + vi.mocked(getCachedMainReadme).mockResolvedValue(null) + vi.mocked(getUserSettingsContent).mockResolvedValue(null) + }) + + it("sets user and repo immediately and clears loadError", async () => { + const store = useUserRepoStore() + store.loadError = "auth" + + await store.setUserRepo("alice", "notes") + + expect(store.user).toBe("alice") + expect(store.repo).toBe("notes") + expect(store.loadError).toBeNull() + }) + + it("populates files from getFiles on success", async () => { + vi.mocked(getFiles).mockResolvedValue([ + { sha: "a", path: "x.md", type: "blob" } as never + ]) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.files.map((f) => f.sha)).toEqual(["a"]) + }) + + it("sets readme from getMainReadme on success", async () => { + vi.mocked(getMainReadme).mockResolvedValue("

hi

") + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.readme).toBe("

hi

") + }) + + it("classifies 401 errors from getFiles as auth", async () => { + vi.mocked(getFiles).mockRejectedValue( + Object.assign(new Error("Unauthorized"), { status: 401 }) + ) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.loadError).toBe("auth") + }) + + it("classifies TimeoutError as network", async () => { + vi.mocked(getFiles).mockRejectedValue( + Object.assign(new Error("Timed out"), { name: "TimeoutError" }) + ) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.loadError).toBe("network") + }) + + it("classifies 500-range errors as network", async () => { + vi.mocked(getFiles).mockRejectedValue( + Object.assign(new Error("Server error"), { status: 503 }) + ) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.loadError).toBe("network") + }) + + it("does NOT surface loadError from getMainReadme when a cached readme is present", async () => { + vi.mocked(getCachedMainReadme).mockResolvedValue("

cached

") + vi.mocked(getMainReadme).mockRejectedValue( + Object.assign(new Error("Server error"), { status: 503 }) + ) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.readme).toBe("

cached

") + expect(store.loadError).toBeNull() + }) + + it("surfaces loadError from getMainReadme when no cached readme is present", async () => { + vi.mocked(getCachedMainReadme).mockResolvedValue(null) + vi.mocked(getMainReadme).mockRejectedValue( + Object.assign(new Error("Unauthorized"), { status: 401 }) + ) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + const store = useUserRepoStore() + await store.setUserRepo("alice", "notes") + await flushAsync() + await flushAsync() + + expect(store.readme).toBeNull() + expect(store.loadError).toBe("auth") + }) + + it("ignores stale getFiles results when a newer setUserRepo has been called (race guard)", async () => { + let resolveStale: (files: never[]) => void = () => {} + vi.mocked(getFiles).mockImplementationOnce( + () => + new Promise((r) => { + resolveStale = r + }) + ) + vi.mocked(getFiles).mockImplementationOnce(async () => [ + { sha: "fresh", path: "fresh.md", type: "blob" } as never + ]) + + const store = useUserRepoStore() + + await store.setUserRepo("alice", "stale-repo") + await store.setUserRepo("alice", "fresh-repo") + + resolveStale([{ sha: "stale", path: "stale.md", type: "blob" } as never]) + await flushAsync() + await flushAsync() + + expect(store.repo).toBe("fresh-repo") + expect(store.files.map((f) => f.sha)).toEqual(["fresh"]) + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..df601a8 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,8 @@ +import { config } from "@vue/test-utils" + +config.global.stubs = { + "router-link": true, + "router-view": true, + transition: false, + "transition-group": false +} diff --git a/src/utils/decodeBase64ToUTF8.spec.ts b/src/utils/decodeBase64ToUTF8.spec.ts new file mode 100644 index 0000000..7850c61 --- /dev/null +++ b/src/utils/decodeBase64ToUTF8.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" + +import { decodeBase64ToUTF8, encodeUTF8ToBase64 } from "./decodeBase64ToUTF8" + +describe("base64 ↔ UTF-8 round-trip", () => { + it.each([ + ["ASCII", "Hello, world!"], + ["multi-byte UTF-8", "Café résumé naïve"], + ["CJK characters", "こんにちは世界"], + ["emoji", "👋 🌍 🎉"], + ["empty string", ""], + ["newlines and whitespace", "line1\nline2\tend"], + ["markdown content", "# Title\n\n- [[link]]\n- **bold**"] + ])("round-trips %s", (_label, input) => { + expect(decodeBase64ToUTF8(encodeUTF8ToBase64(input))).toBe(input) + }) + + it("encodes ASCII to standard base64", () => { + expect(encodeUTF8ToBase64("hi")).toBe("aGk=") + }) + + it("decodes standard base64 to ASCII", () => { + expect(decodeBase64ToUTF8("aGk=")).toBe("hi") + }) +}) diff --git a/src/utils/fileLanguage.spec.ts b/src/utils/fileLanguage.spec.ts new file mode 100644 index 0000000..25404e3 --- /dev/null +++ b/src/utils/fileLanguage.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" + +import { getFileLanguage, isMarkdownPath } from "./fileLanguage" + +describe("isMarkdownPath", () => { + it.each(["note.md", "dir/note.md", "note.mdx", "DIR/NOTE.MD"])( + "returns true for %s", + (path) => { + expect(isMarkdownPath(path)).toBe(true) + } + ) + + it.each(["note.txt", "script.ts", "no-extension", "", "image.png"])( + "returns false for %s", + (path) => { + expect(isMarkdownPath(path)).toBe(false) + } + ) +}) + +describe("getFileLanguage", () => { + it.each([ + ["sh", "bash"], + ["bash", "bash"], + ["js", "javascript"], + ["mjs", "javascript"], + ["cjs", "javascript"], + ["ts", "typescript"], + ["mts", "typescript"], + ["md", "markdown"], + ["mdx", "markdown"], + ["html", "html"], + ["htm", "html"], + ["css", "css"], + ["scss", "css"], + ["json", "json"], + ["jsonc", "json"], + ["als", "alloy"] + ])("maps .%s to %s", (ext, lang) => { + expect(getFileLanguage(`file.${ext}`)).toBe(lang) + }) + + it("matches case-insensitively", () => { + expect(getFileLanguage("File.TS")).toBe("typescript") + }) + + it("returns null for unknown extensions", () => { + expect(getFileLanguage("file.xyz")).toBeNull() + }) + + it("returns null for files without an extension", () => { + expect(getFileLanguage("Makefile")).toBeNull() + }) + + it("returns null for empty input", () => { + expect(getFileLanguage("")).toBeNull() + }) +}) diff --git a/src/utils/markdown/markdown-html5-media.spec.ts b/src/utils/markdown/markdown-html5-media.spec.ts new file mode 100644 index 0000000..9f4dbf3 --- /dev/null +++ b/src/utils/markdown/markdown-html5-media.spec.ts @@ -0,0 +1,54 @@ +import MarkdownIt from "markdown-it" +import { describe, expect, it } from "vitest" + +import { html5Media } from "./markdown-html5-media" + +const renderer = () => MarkdownIt().use(html5Media) + +describe("html5Media plugin", () => { + it("renders