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/hooks/useGitHubContent.hook.spec.ts b/src/hooks/useGitHubContent.hook.spec.ts new file mode 100644 index 0000000..ced0045 --- /dev/null +++ b/src/hooks/useGitHubContent.hook.spec.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/modules/repo/services/octo", () => ({ + getOctokit: vi.fn(), + runWithAuthRetry: vi.fn() +})) + +vi.mock("@/utils/notif", () => ({ + confirmMessage: vi.fn(), + errorMessage: vi.fn() +})) + +import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" +import { confirmMessage, errorMessage } from "@/utils/notif" + +import { useGitHubContent } from "./useGitHubContent.hook" + +const make = () => useGitHubContent({ user: "alice", repo: "notes" }) + +describe("useGitHubContent.fetchLatestSha", () => { + beforeEach(() => { + vi.mocked(runWithAuthRetry).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the sha from a file response", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ + data: { sha: "abc123" } + } as never) + + expect(await make().fetchLatestSha("a.md")).toEqual({ + kind: "ok", + sha: "abc123" + }) + }) + + it("returns sha=null when the path resolves to a directory (array response)", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never) + + expect(await make().fetchLatestSha("dir/")).toEqual({ + kind: "ok", + sha: null + }) + }) + + it("returns sha=null when the data object has no sha field", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ + data: { type: "submodule" } + } as never) + + expect(await make().fetchLatestSha("path")).toEqual({ + kind: "ok", + sha: null + }) + }) + + it("returns kind=unauthorized on 401", async () => { + vi.mocked(runWithAuthRetry).mockRejectedValue( + Object.assign(new Error("Unauthorized"), { status: 401 }) + ) + + expect(await make().fetchLatestSha("path")).toEqual({ + kind: "unauthorized" + }) + }) + + it("returns kind=offline on non-401 errors (404, network, etc.)", async () => { + vi.mocked(runWithAuthRetry).mockRejectedValue( + Object.assign(new Error("Not found"), { status: 404 }) + ) + expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" }) + + vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network")) + expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" }) + }) +}) + +describe("useGitHubContent.updateFile / createFile (putFile)", () => { + const mockOctokitRequest = vi.fn() + + beforeEach(() => { + mockOctokitRequest.mockReset() + vi.mocked(getOctokit).mockResolvedValue({ + request: mockOctokitRequest + } as never) + vi.mocked(confirmMessage).mockReset() + vi.mocked(errorMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the new sha and calls confirmMessage on success", async () => { + mockOctokitRequest.mockResolvedValue({ + data: { content: { sha: "new-sha" } } + }) + + const result = await make().updateFile({ + content: "# hello", + path: "a.md", + sha: "old-sha" + }) + + expect(result).toEqual({ sha: "new-sha", conflict: false }) + expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved") + }) + + it("flags conflict=true on 409 and 422 responses", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + + for (const status of [409, 422]) { + mockOctokitRequest.mockRejectedValueOnce( + Object.assign(new Error("Conflict"), { status }) + ) + const result = await make().createFile({ content: "x", path: "a.md" }) + expect(result).toEqual({ sha: null, conflict: true }) + } + expect(errorMessage).toHaveBeenCalledWith( + "⚠ Conflict: this note changed on GitHub" + ) + }) + + it("returns conflict=false and calls errorMessage on non-conflict errors", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + mockOctokitRequest.mockRejectedValue( + Object.assign(new Error("Server error"), { status: 500 }) + ) + + const result = await make().updateFile({ + content: "x", + path: "a.md", + sha: "old" + }) + + expect(result).toEqual({ sha: null, conflict: false }) + expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved") + }) +}) + +describe("useGitHubContent.uploadBinaryFile", () => { + const mockOctokitRequest = vi.fn() + + beforeEach(() => { + mockOctokitRequest.mockReset() + vi.mocked(getOctokit).mockResolvedValue({ + request: mockOctokitRequest + } as never) + vi.mocked(errorMessage).mockReset() + vi.mocked(confirmMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("uploads pre-base64-encoded content without re-encoding", async () => { + mockOctokitRequest.mockResolvedValue({ + data: { content: { sha: "img-sha" } } + }) + + const result = await make().uploadBinaryFile({ + base64: "ZGF0YQ==", + path: "img/cover.png" + }) + + expect(result).toEqual({ sha: "img-sha", conflict: false }) + expect(mockOctokitRequest).toHaveBeenCalledWith( + "PUT /repos/{owner}/{repo}/contents/{+path}", + expect.objectContaining({ + path: "img/cover.png", + content: "ZGF0YQ==" + }) + ) + expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded") + }) + + it("surfaces a conflict on existing file (422)", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + mockOctokitRequest.mockRejectedValue( + Object.assign(new Error("Exists"), { status: 422 }) + ) + + const result = await make().uploadBinaryFile({ + base64: "Zm9v", + path: "img.png" + }) + + expect(result).toEqual({ sha: null, conflict: true }) + expect(errorMessage).toHaveBeenCalledWith( + "⚠ A file already exists at this path on GitHub" + ) + }) +}) diff --git a/src/hooks/useImageUpload.hook.spec.ts b/src/hooks/useImageUpload.hook.spec.ts new file mode 100644 index 0000000..3453ab4 --- /dev/null +++ b/src/hooks/useImageUpload.hook.spec.ts @@ -0,0 +1,164 @@ +import { createPinia, setActivePinia } from "pinia" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const uploadBinaryFile = vi.fn() +const registerUploadedFile = vi.fn() + +vi.mock("@/hooks/useGitHubContent.hook", () => ({ + useGitHubContent: () => ({ uploadBinaryFile }) +})) + +vi.mock("@/modules/repo/store/userRepo.store", () => ({ + useUserRepoStore: () => ({ + files: [{ path: "alice/notes/already-here.png", sha: "x" }], + registerUploadedFile + }) +})) + +vi.mock("@/utils/notif", () => ({ + errorMessage: vi.fn() +})) + +import { errorMessage } from "@/utils/notif" + +import { useImageUpload } from "./useImageUpload.hook" + +const makeFile = (name: string, body = "fake-image-bytes") => + new File([body], name, { type: "image/png" }) + +describe("useImageUpload", () => { + beforeEach(() => { + setActivePinia(createPinia()) + uploadBinaryFile.mockReset() + registerUploadedFile.mockReset() + vi.mocked(errorMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null and shows an error when notePath is missing", async () => { + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: undefined + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed") + expect(uploadBinaryFile).not.toHaveBeenCalled() + }) + + it("uploads to the note's directory using the note basename and the file extension", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "alice/notes/my-note.md" + }) + + const result = await uploadImage(makeFile("photo.PNG")) + + expect(result).toEqual({ filename: "my-note.png" }) + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ + path: "alice/notes/my-note.png" + }) + ) + }) + + it("deduplicates filenames against existing files in the store", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "alice/notes/already-here.md" + }) + + const result = await uploadImage(makeFile("photo.png")) + + expect(result?.filename).toBe("already-here-2.png") + }) + + it("returns null on conflict and does NOT register the file", async () => { + uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(registerUploadedFile).not.toHaveBeenCalled() + }) + + it("registers the uploaded file in the store on success", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + const file = makeFile("img.png", "data") + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + await uploadImage(file) + + expect(registerUploadedFile).toHaveBeenCalledWith({ + path: "x/note.png", + sha: "abc", + type: "blob", + size: file.size + }) + }) + + it("falls back to .png when the file name has no extension", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + await uploadImage(makeFile("noextension")) + + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "x/note.png" }) + ) + }) + + it("uses the root path when the note is at the repo root", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "root.md" + }) + + await uploadImage(makeFile("img.png")) + + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "root.png" }) + ) + }) + + it("returns null and notifies on unexpected errors", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + uploadBinaryFile.mockRejectedValue(new Error("boom")) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed") + }) +}) diff --git a/src/hooks/useNoteFreshness.hook.spec.ts b/src/hooks/useNoteFreshness.hook.spec.ts new file mode 100644 index 0000000..3d32620 --- /dev/null +++ b/src/hooks/useNoteFreshness.hook.spec.ts @@ -0,0 +1,234 @@ +import { createPinia, setActivePinia } from "pinia" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { ref } from "vue" + +const { + fetchLatestSha, + addFile, + getRawContent, + saveCacheNote, + queryFileContent +} = vi.hoisted(() => ({ + fetchLatestSha: vi.fn(), + addFile: vi.fn(), + getRawContent: vi.fn((s: string) => s), + saveCacheNote: vi.fn(), + queryFileContent: vi.fn() +})) + +vi.mock("@/hooks/useGitHubContent.hook", () => ({ + useGitHubContent: () => ({ fetchLatestSha }) +})) + +vi.mock("@/hooks/useMarkdown.hook", () => ({ + markdownBuilder: () => ({ getRawContent, render: (s: string) => s }) +})) + +vi.mock("@/modules/note/cache/prepareNoteCache", () => ({ + prepareNoteCache: () => ({ + getCachedNote: async () => ({ note: null }), + saveCacheNote + }) +})) + +vi.mock("@/modules/repo/services/repo", () => ({ + queryFileContent +})) + +vi.mock("@/modules/repo/store/userRepo.store", () => ({ + useUserRepoStore: () => ({ addFile }) +})) + +import { useNoteFreshness } from "./useNoteFreshness.hook" + +const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => { + setActivePinia(createPinia()) + const sha = ref(overrides.sha ?? "local-sha") + const path = ref(overrides.path ?? "note.md") + const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null) + return useNoteFreshness({ + user: "alice", + repo: "notes", + sha, + path, + getEditedSha + }) +} + +// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the +// 400ms spinner-min-time wait is skipped. +const skipSpinnerWait = () => { + let call = 0 + vi.spyOn(performance, "now").mockImplementation(() => + call++ === 0 ? 0 : 10_000 + ) +} + +describe("useNoteFreshness.check", () => { + beforeEach(() => { + fetchLatestSha.mockReset() + addFile.mockReset() + saveCacheNote.mockReset() + queryFileContent.mockReset() + skipSpinnerWait() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("does nothing when path is empty", async () => { + const { check, status } = setup({ path: "" }) + + await check() + + expect(status.value).toBe("unknown") + expect(fetchLatestSha).not.toHaveBeenCalled() + }) + + it("sets status to 'verified' when remote sha matches local sha", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" }) + + const { check, status, latestSha, lastCheckedAt } = setup() + await check() + + expect(status.value).toBe("verified") + expect(latestSha.value).toBe("local-sha") + expect(lastCheckedAt.value).toBeInstanceOf(Date) + }) + + it("sets status to 'outdated' when remote sha differs from local sha", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("outdated") + }) + + it("prefers the edited sha over the live sha when comparing", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" }) + + const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" }) + await check() + + expect(status.value).toBe("verified") + }) + + it("sets status to 'unauthorized' on auth failure", async () => { + fetchLatestSha.mockResolvedValue({ kind: "unauthorized" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("unauthorized") + }) + + it("sets status to 'offline' on network failure", async () => { + fetchLatestSha.mockResolvedValue({ kind: "offline" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("offline") + }) + + it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("offline") + }) +}) + +describe("useNoteFreshness.pullLatest", () => { + beforeEach(() => { + fetchLatestSha.mockReset() + addFile.mockReset() + saveCacheNote.mockReset() + queryFileContent.mockReset() + getRawContent.mockClear() + skipSpinnerWait() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns raw=null with no failureStatus when path is empty", async () => { + const { pullLatest } = setup({ path: "" }) + + expect(await pullLatest()).toEqual({ raw: null, failureStatus: null }) + expect(fetchLatestSha).not.toHaveBeenCalled() + }) + + it("returns the fetched content and updates state on success", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue("BASE64") + getRawContent.mockReturnValue("# raw") + + const { pullLatest, status, latestSha, lastCheckedAt } = setup() + const result = await pullLatest() + + expect(result).toEqual({ raw: "# raw", failureStatus: null }) + expect(status.value).toBe("verified") + expect(latestSha.value).toBe("remote-sha") + expect(lastCheckedAt.value).toBeInstanceOf(Date) + expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" }) + expect(saveCacheNote).toHaveBeenCalled() + }) + + it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "unauthorized" }) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "unauthorized" + }) + expect(status.value).toBe("unauthorized") + }) + + it("surfaces 'offline' failureStatus when remote resolve is offline", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "offline" }) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "offline" + }) + expect(status.value).toBe("offline") + }) + + it("surfaces 'offline' failureStatus when blob content fetch fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue(null) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "offline" + }) + expect(status.value).toBe("offline") + }) + + it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => { + fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" }) + queryFileContent.mockResolvedValue("BASE64") + + const api = setup() + await api.check() // populates latestSha + expect(fetchLatestSha).toHaveBeenCalledTimes(1) + + fetchLatestSha.mockClear() + await api.pullLatest() + + expect(fetchLatestSha).not.toHaveBeenCalled() + expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha") + }) +}) 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/atproto/withATProtoImages.spec.ts b/src/modules/atproto/withATProtoImages.spec.ts new file mode 100644 index 0000000..e75afbd --- /dev/null +++ b/src/modules/atproto/withATProtoImages.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { withATProtoImages } from "./withATProtoImages" + +const PDS = "https://pds.example.com" +const DID = "did:plc:abc123" + +describe("withATProtoImages", () => { + it("rewrites a bafkrei CID image to a getBlob URL", () => { + const out = withATProtoImages( + "![cover](bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i)", + { pds: PDS, did: DID } + ) + + expect(out).toContain( + `${PDS}/xrpc/com.atproto.sync.getBlob` + ) + expect(out).toContain("did=did%3Aplc%3Aabc123") + expect(out).toContain( + "cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i" + ) + }) + + it("preserves the alt text", () => { + const out = withATProtoImages("![my cover](bafkreiabc)", { + pds: PDS, + did: DID + }) + expect(out).toMatch(/!\[my cover\]/) + }) + + it("rewrites multiple images in one pass", () => { + const md = "![a](bafkreiaaa) and ![b](bafkreibbb)" + const out = withATProtoImages(md, { pds: PDS, did: DID }) + + expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2) + expect(out).toContain("cid=bafkreiaaa") + expect(out).toContain("cid=bafkreibbb") + }) + + it("leaves regular image URLs untouched", () => { + const md = "![pic](https://example.com/a.png)" + expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md) + }) + + it("leaves CIDs that don't match the bafkrei prefix untouched", () => { + const md = "![x](sha256-abc123)" + expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md) + }) + + it("preserves an empty alt text", () => { + const out = withATProtoImages("![](bafkreiabc)", { pds: PDS, did: DID }) + expect(out).toMatch(/^!\[\]/) + }) +}) diff --git a/src/modules/repo/services/repo.spec.ts b/src/modules/repo/services/repo.spec.ts index 318199e..4c30f4a 100644 --- a/src/modules/repo/services/repo.spec.ts +++ b/src/modules/repo/services/repo.spec.ts @@ -1,30 +1,43 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() })) -vi.mock("@/modules/repo/services/octo", () => ({ +vi.mock("./octo", () => ({ getOctokit: vi.fn().mockResolvedValue({ repos: { get: reposGet } }), 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: vi.fn().mockResolvedValue({ note: null }), + getCachedNote: async () => ({ note: null }), saveCacheNote: vi.fn() }) })) -import { getRepoPermission } from "./repo" +import { getOctokit, runWithAuthRetry } from "./octo" +import { + getFiles, + getRepoPermission, + queryFileContent +} from "./repo" + +const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({ + request: vi.fn(impl) +}) describe("getRepoPermission", () => { beforeEach(() => { reposGet.mockReset() + vi.mocked(getOctokit).mockResolvedValue({ + repos: { get: reposGet } + } as never) }) it("returns true when permissions.push is true", async () => { @@ -54,3 +67,101 @@ describe("getRepoPermission", () => { expect(reposGet).not.toHaveBeenCalled() }) }) + +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..b5b505d --- /dev/null +++ b/src/modules/repo/store/userRepo.store.spec.ts @@ -0,0 +1,286 @@ +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), + getRepoPermission: vi.fn().mockResolvedValue(false) +})) + +vi.mock("@/modules/user/service/signIn", () => ({ + refreshToken: vi.fn().mockResolvedValue(null) +})) + +import { data } from "@/data/data" +import { + getCachedMainReadme, + getFiles, + getMainReadme, + getRepoPermission, + 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) + vi.mocked(getRepoPermission).mockResolvedValue(false) + }) + + 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/modules/user/service/oauthReturnPath.spec.ts b/src/modules/user/service/oauthReturnPath.spec.ts new file mode 100644 index 0000000..ff69482 --- /dev/null +++ b/src/modules/user/service/oauthReturnPath.spec.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { + consumeGithubOAuthReturnPath, + GITHUB_OAUTH_RETURN_PATH_KEY +} from "./oauthReturnPath" + +describe("consumeGithubOAuthReturnPath", () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it("returns the stored path and clears the key", () => { + sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/alice/notes") + + expect(consumeGithubOAuthReturnPath()).toBe("/alice/notes") + expect(sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)).toBeNull() + }) + + it("returns null when nothing is stored", () => { + expect(consumeGithubOAuthReturnPath()).toBeNull() + }) + + it("returns null on the second call (consume-once semantics)", () => { + sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/x") + + expect(consumeGithubOAuthReturnPath()).toBe("/x") + expect(consumeGithubOAuthReturnPath()).toBeNull() + }) +}) 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/displayLanguage.spec.ts b/src/utils/displayLanguage.spec.ts new file mode 100644 index 0000000..872ea02 --- /dev/null +++ b/src/utils/displayLanguage.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { displayLanguage } from "./displayLanguage" + +describe("displayLanguage", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when no language code is given", () => { + expect(displayLanguage()).toBeNull() + expect(displayLanguage("")).toBeNull() + }) + + it("returns a human-readable name for a known language code", () => { + expect(displayLanguage("en")).toMatch(/^English/i) + }) + + it("works for French", () => { + expect(displayLanguage("fr")).toBeTruthy() + }) + + it("returns null and logs a warning when Intl.DisplayNames throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const original = Intl.DisplayNames + // Force the constructor to throw + ;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = function () { + throw new Error("boom") + } + + try { + expect(displayLanguage("en")).toBeNull() + expect(warn).toHaveBeenCalled() + } finally { + ;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = original + } + }) +}) 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/link.spec.ts b/src/utils/link.spec.ts new file mode 100644 index 0000000..d766ea2 --- /dev/null +++ b/src/utils/link.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" + +import { isExternalLink } from "./link" + +describe("isExternalLink", () => { + const ORIGIN = window.location.origin + + it("returns false for same-origin absolute URLs", () => { + expect(isExternalLink(`${ORIGIN}/alice/notes`)).toBe(false) + }) + + it("returns true for an https URL on a different origin", () => { + expect(isExternalLink("https://github.com/anywhere")).toBe(true) + }) + + it("returns true for an http URL on a different origin", () => { + expect(isExternalLink("http://example.com")).toBe(true) + }) + + it("returns false for relative paths (no http/https prefix)", () => { + expect(isExternalLink("/alice/notes")).toBe(false) + expect(isExternalLink("./neighbor.md")).toBe(false) + }) + + it("returns false for mailto and other non-http schemes", () => { + expect(isExternalLink("mailto:user@example.com")).toBe(false) + expect(isExternalLink("ftp://files.example.com")).toBe(false) + }) + + it("returns false for an https URL that happens to start with the origin", () => { + expect(isExternalLink(`${ORIGIN}/deep/path`)).toBe(false) + }) +}) 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