diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb5c891..f2ad141 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - check: [lint, typecheck, format] + check: [lint, typecheck, format, test] runs-on: ubuntu-latest diff --git a/package.json b/package.json index e25aa6e..db962fe 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "dev": "electron-vite dev", "build": "pnpm run check && electron-vite build", "preview": "electron-vite preview", + "test": "vitest run", + "test:watch": "vitest", "start": "electron-vite preview", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", - "check": "pnpm run lint && pnpm run typecheck && pnpm run format", + "check": "pnpm run lint && pnpm run typecheck && pnpm run format && pnpm run test", "lint": "eslint .", "format": "prettier --check .", "format:fix": "prettier --write .", @@ -61,7 +63,8 @@ "tailwindcss": "^4.3.1", "typescript": "^5.7.3", "typescript-eslint": "^8.62.0", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.9" }, "packageManager": "pnpm@10.33.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91749d..c53eb08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: vite: specifier: ^6.0.7 version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) packages: @@ -948,6 +951,9 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -1065,9 +1071,15 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1183,6 +1195,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} @@ -1277,6 +1318,10 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1384,6 +1429,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1631,6 +1680,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1709,10 +1761,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -2392,6 +2451,10 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2442,6 +2505,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -2642,6 +2708,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2687,10 +2756,16 @@ packages: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2743,10 +2818,21 @@ packages: tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -2875,6 +2961,47 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2890,6 +3017,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} @@ -3624,6 +3756,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -3726,10 +3860,17 @@ snapshots: '@types/node': 22.20.0 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -3889,6 +4030,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xmldom/xmldom@0.9.10': {} abbrev@1.1.1: {} @@ -4027,6 +4209,8 @@ snapshots: assert-plus@1.0.0: optional: true + assertion-error@2.0.1: {} + astral-regex@2.0.0: optional: true @@ -4175,6 +4359,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4461,6 +4647,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.2.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4613,8 +4801,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + exponential-backoff@3.1.3: {} extract-zip@2.0.1: @@ -5281,6 +5475,8 @@ snapshots: object-keys@1.1.1: optional: true + obug@2.1.3: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5337,6 +5533,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + pathe@2.0.3: {} + pe-library@0.4.1: {} pend@1.2.0: {} @@ -5561,6 +5759,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -5607,8 +5807,12 @@ snapshots: dependencies: minipass: 3.3.6 + stackback@0.0.2: {} + stat-mode@1.0.0: {} + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5675,11 +5879,17 @@ snapshots: tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tmp-promise@3.0.3: dependencies: tmp: 0.2.7 @@ -5776,6 +5986,33 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 + vitest@4.1.9(@types/node@22.20.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vrchat@2.21.8-nightly.16: @@ -5799,6 +6036,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wide-align@1.1.5: dependencies: string-width: 4.2.3 diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..efde0f5 --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TtlCache } from "../src/main/cache/cache"; + +const policy = { ttl: 1000, staleWhileRevalidate: 5000 }; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("TtlCache.get — fresh vs miss", () => { + it("calls the loader once and serves the cached value while fresh", async () => { + const cache = new TtlCache(); + const loader = vi.fn().mockResolvedValue("v1"); + + expect(await cache.get("k", policy, loader)).toBe("v1"); + expect(await cache.get("k", policy, loader)).toBe("v1"); + expect(loader).toHaveBeenCalledTimes(1); + expect(cache.stats().hits).toBe(1); + expect(cache.stats().misses).toBe(1); + }); +}); + +describe("TtlCache.get — single-flight de-dup", () => { + it("shares one in-flight loader across concurrent gets of the same key", async () => { + const cache = new TtlCache(); + let resolve!: (v: string) => void; + const loader = vi.fn(() => new Promise((r) => (resolve = r))); + + const a = cache.get("k", policy, loader); + const b = cache.get("k", policy, loader); + resolve("shared"); + + expect(await a).toBe("shared"); + expect(await b).toBe("shared"); + expect(loader).toHaveBeenCalledTimes(1); + }); +}); + +describe("TtlCache.get — stale-while-revalidate", () => { + it("returns the stale value and revalidates in the background past ttl", async () => { + const cache = new TtlCache(); + const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2"); + + expect(await cache.get("k", policy, loader)).toBe("v1"); + + vi.setSystemTime(Date.now() + 2000); + expect(await cache.get("k", policy, loader)).toBe("v1"); + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)); + expect(await cache.get("k", policy, loader)).toBe("v2"); + }); + + it("reloads synchronously once past the hard expiry", async () => { + const cache = new TtlCache(); + const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2"); + + expect(await cache.get("k", policy, loader)).toBe("v1"); + vi.setSystemTime(Date.now() + 7000); + expect(await cache.get("k", policy, loader)).toBe("v2"); + }); +}); + +describe("TtlCache.get — rate-limit backoff", () => { + it("after a 429 loader failure, serves stale and suppresses revalidation", async () => { + const cache = new TtlCache(); + const loader = vi + .fn() + .mockResolvedValueOnce("v1") + .mockRejectedValueOnce({ status: 429, response: { headers: { "retry-after": "8" } } }); + + expect(await cache.get("k", policy, loader)).toBe("v1"); + + vi.setSystemTime(Date.now() + 2000); + await cache.get("k", policy, loader); + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)); + + await cache.get("k", policy, loader); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it("throws 429 on a cold miss while rate limited", async () => { + const cache = new TtlCache(); + const failing = vi.fn().mockRejectedValue({ status: 429 }); + + await expect(cache.get("a", policy, failing)).rejects.toMatchObject({ status: 429 }); + await expect(cache.get("b", policy, vi.fn())).rejects.toMatchObject({ status: 429 }); + }); +}); + +describe("TtlCache.set / patch / invalidate / clear", () => { + it("patch merges into an existing entry", async () => { + const cache = new TtlCache(); + cache.set("k", { a: 1, b: 2 }, policy); + cache.patch("k", { b: 3 }); + const val = await cache.get("k", policy, async () => ({ a: 0, b: 0 })); + expect(val).toEqual({ a: 1, b: 3 }); + }); + + it("patch is a no-op on a missing key", () => { + const cache = new TtlCache(); + cache.patch("missing", { x: 1 }); + expect(cache.stats().entries).toBe(0); + }); + + it("invalidate forces the next get to reload", async () => { + const cache = new TtlCache(); + const loader = vi.fn().mockResolvedValueOnce("v1").mockResolvedValueOnce("v2"); + await cache.get("k", policy, loader); + cache.invalidate("k"); + expect(await cache.get("k", policy, loader)).toBe("v2"); + }); + + it("clear empties the store", async () => { + const cache = new TtlCache(); + cache.set("k", "v", policy); + cache.clear(); + expect(cache.stats().entries).toBe(0); + }); +}); + +describe("TtlCache — LRU eviction", () => { + it("evicts least-recently-accessed entries past maxEntries", async () => { + const cache = new TtlCache(2); + cache.set("a", 1, policy); + vi.setSystemTime(Date.now() + 10); + cache.set("b", 2, policy); + vi.setSystemTime(Date.now() + 10); + await cache.get("a", policy, async () => 1); + vi.setSystemTime(Date.now() + 10); + cache.set("c", 3, policy); + + expect(cache.stats().entries).toBe(2); + const loader = vi.fn().mockResolvedValue(99); + await cache.get("b", policy, loader); + expect(loader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..e1b7c11 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { httpStatusOf, isTransientError, toApiError } from "../src/main/vrchat/errors"; + +describe("httpStatusOf", () => { + it("reads status from the many shapes vrchat/SDK errors take", () => { + expect(httpStatusOf({ status: 404 })).toBe(404); + expect(httpStatusOf({ statusCode: 401 })).toBe(401); + expect(httpStatusOf({ status_code: 500 })).toBe(500); + expect(httpStatusOf({ response: { status: 429 } })).toBe(429); + expect(httpStatusOf({ error: { status_code: 403 } })).toBe(403); + }); + + it("returns undefined for a shapeless error", () => { + expect(httpStatusOf(null)).toBeUndefined(); + expect(httpStatusOf({})).toBeUndefined(); + expect(httpStatusOf("boom")).toBeUndefined(); + }); +}); + +describe("isTransientError", () => { + it("treats 429 and 5xx as transient", () => { + expect(isTransientError({ status: 429 })).toBe(true); + expect(isTransientError({ status: 500 })).toBe(true); + expect(isTransientError({ status: 503 })).toBe(true); + }); + + it("treats 4xx (other than 429) as permanent", () => { + expect(isTransientError({ status: 404 })).toBe(false); + expect(isTransientError({ status: 401 })).toBe(false); + }); + + it("treats a status-less error (network failure) as transient", () => { + expect(isTransientError(new Error("ECONNRESET"))).toBe(true); + }); +}); + +describe("toApiError", () => { + it("maps well-known statuses to codes", () => { + expect(toApiError({ status: 401 }).code).toBe("unauthorized"); + expect(toApiError({ status: 404 }).code).toBe("not_found"); + expect(toApiError({ status: 429 }).code).toBe("rate_limited"); + }); + + it("classifies status-less network-ish messages as network", () => { + expect(toApiError({ message: "fetch failed: ENOTFOUND" }).code).toBe("network"); + }); + + it("passes through a pre-coded error and its 2FA methods", () => { + const e = toApiError({ code: "two_factor_required", methods: ["totp"] }); + expect(e.code).toBe("two_factor_required"); + expect(e.methods).toEqual(["totp"]); + }); + + it("uses a friendly rate-limit message and parses retry-after", () => { + const e = toApiError({ status: 429, response: { headers: { "retry-after": "12" } } }); + expect(e.code).toBe("rate_limited"); + expect(e.retryAfter).toBe(12); + expect(e.message).toMatch(/rate limit/i); + }); + + it("gives a temporary-unavailable message for 5xx", () => { + expect(toApiError({ status: 502 }).message).toMatch(/temporarily unavailable/i); + }); + + it("falls back to unknown with the original message", () => { + const e = toApiError({ message: "weird" }); + expect(e.code).toBe("unknown"); + expect(e.message).toBe("weird"); + }); +}); diff --git a/tests/mappers.test.ts b/tests/mappers.test.ts new file mode 100644 index 0000000..86b8995 --- /dev/null +++ b/tests/mappers.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { + trustRankFromTags, + toIso, + toUserProfile, + toWorld, + toAvatar, +} from "../src/main/vrchat/mappers"; + +describe("trustRankFromTags", () => { + it("maps each trust tag to the rank one level below its label", () => { + expect(trustRankFromTags([])).toBe("visitor"); + expect(trustRankFromTags(["system_trust_basic"])).toBe("new"); + expect(trustRankFromTags(["system_trust_known"])).toBe("user"); + expect(trustRankFromTags(["system_trust_trusted"])).toBe("known"); + expect(trustRankFromTags(["system_trust_veteran"])).toBe("trusted"); + expect(trustRankFromTags(["system_trust_legend"])).toBe("veteran"); + }); + + it("defaults to visitor when no trust tag present", () => { + expect(trustRankFromTags(["language_eng", "something_else"])).toBe("visitor"); + expect(trustRankFromTags()).toBe("visitor"); + }); + + it("picks the highest rank when several trust tags are present", () => { + expect(trustRankFromTags(["system_trust_basic", "system_trust_legend"])).toBe("veteran"); + }); + + it("troll tags override everything", () => { + expect(trustRankFromTags(["system_trust_legend", "system_troll"])).toBe("troll"); + expect(trustRankFromTags(["system_probable_troll"])).toBe("troll"); + }); +}); + +describe("toIso", () => { + it("returns undefined for empty/null values", () => { + expect(toIso(undefined)).toBeUndefined(); + expect(toIso(null)).toBeUndefined(); + expect(toIso("")).toBeUndefined(); + }); + + it("passes Date objects through to ISO", () => { + const d = new Date("2024-01-02T03:04:05.000Z"); + expect(toIso(d)).toBe("2024-01-02T03:04:05.000Z"); + }); + + it("normalizes parseable date strings to ISO", () => { + expect(toIso("2024-01-02T03:04:05Z")).toBe("2024-01-02T03:04:05.000Z"); + }); + + it("returns undefined for unparseable strings", () => { + expect(toIso("not a date")).toBeUndefined(); + }); +}); + +describe("toUserProfile", () => { + const base = { id: "usr_1", displayName: "Alice" }; + + it("fills defaults for missing fields", () => { + const p = toUserProfile({ ...base }, "self"); + expect(p.bio).toBe(""); + expect(p.tags).toEqual([]); + expect(p.badges).toEqual([]); + expect(p.isFriend).toBe(false); + expect(p.status).toBe("offline"); + expect(p.trustRank).toBe("visitor"); + }); + + it("sets isSelf when the id matches selfId", () => { + expect(toUserProfile({ ...base, id: "self" }, "self").isSelf).toBe(true); + expect(toUserProfile({ ...base, id: "other" }, "self").isSelf).toBe(false); + }); + + it("accepts both camelCase and snake_case timestamp aliases", () => { + const camel = toUserProfile({ ...base, lastLogin: "2024-01-01T00:00:00Z" }, "self"); + const snake = toUserProfile({ ...base, last_login: "2024-01-01T00:00:00Z" }, "self"); + expect(camel.lastLogin).toBe("2024-01-01T00:00:00.000Z"); + expect(snake.lastLogin).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("extracts languages from language_ tags", () => { + const p = toUserProfile( + { ...base, tags: ["language_eng", "language_jpn", "system_trust_known"] }, + "self", + ); + expect(p.languages).toEqual(["eng", "jpn"]); + }); + + it("normalizes an unknown status to offline", () => { + expect(toUserProfile({ ...base, status: "bogus" }, "self").status).toBe("offline"); + expect(toUserProfile({ ...base, status: "join me" }, "self").status).toBe("join me"); + }); + + it("blanks a falsy note to undefined", () => { + expect(toUserProfile({ ...base, note: "" }, "self").note).toBeUndefined(); + expect(toUserProfile({ ...base, note: "hi" }, "self").note).toBe("hi"); + }); +}); + +describe("toWorld", () => { + const base = { id: "wrld_1", authorId: "usr_1" }; + + it("blanks vrchat's '???' placeholder for hidden name/author", () => { + const w = toWorld({ ...base, name: "???", authorName: "???" } as never); + expect(w.name).toBe(""); + expect(w.authorName).toBe(""); + }); + + it("marks a world detailed only when it carries visit counts", () => { + expect(toWorld({ ...base, name: "W", visits: 10 } as never).detailed).toBe(true); + expect(toWorld({ ...base, name: "W" } as never).detailed).toBe(false); + }); + + it("derives platforms from unity packages", () => { + const w = toWorld({ + ...base, + name: "W", + unityPackages: [{ platform: "standalonewindows" }, { platform: "android" }], + } as never); + expect(w.platforms).toEqual({ pc: true, android: true }); + }); + + it("defaults releaseStatus to private", () => { + expect(toWorld({ ...base, name: "W" } as never).releaseStatus).toBe("private"); + }); +}); + +describe("toAvatar", () => { + const base = { id: "avtr_1", name: "Av" }; + + it("treats a 'None' performance rating as no build for that platform", () => { + const a = toAvatar({ + ...base, + performance: { standalonewindows: "Excellent", android: "None" }, + }); + expect(a.platforms).toEqual({ pc: true, android: false }); + expect(a.performance.pc).toBe("Excellent"); + expect(a.performance.android).toBeUndefined(); + }); + + it("defaults releaseStatus and empty collections", () => { + const a = toAvatar({ ...base }); + expect(a.releaseStatus).toBe("private"); + expect(a.tags).toEqual([]); + expect(a.favorites).toBe(0); + }); +}); diff --git a/tests/presence.test.ts b/tests/presence.test.ts new file mode 100644 index 0000000..e1181c8 --- /dev/null +++ b/tests/presence.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { + isOnline, + locationLabel, + presenceOf, + avatarOf, + bannerOf, + regionFlag, + languageLabel, +} from "../src/renderer/src/lib/vrchat"; + +describe("isOnline", () => { + it("counts self as always online", () => { + expect(isOnline({ isSelf: true, state: "offline" })).toBe(true); + }); + + it("counts online/active state as online", () => { + expect(isOnline({ state: "online" })).toBe(true); + expect(isOnline({ state: "active" })).toBe(true); + }); + + it("counts a real location as online even without state", () => { + expect(isOnline({ location: "wrld_1:12345" })).toBe(true); + }); + + it("treats the 'offline' location sentinel and empty location as offline", () => { + expect(isOnline({ state: "offline", location: "offline" })).toBe(false); + expect(isOnline({ state: "offline" })).toBe(false); + }); +}); + +describe("locationLabel", () => { + it("returns undefined for null/empty/offline sentinels", () => { + expect(locationLabel(undefined)).toBeUndefined(); + expect(locationLabel("")).toBeUndefined(); + expect(locationLabel("offline")).toBeUndefined(); + }); + + it("labels the private and traveling sentinels", () => { + expect(locationLabel("private")).toBe("In a private world"); + expect(locationLabel("traveling")).toBe("Traveling…"); + }); + + it("labels a real location generically", () => { + expect(locationLabel("wrld_1:12345~region(us)")).toBe("In a world"); + }); +}); + +describe("presenceOf", () => { + it("shows the real status color when online", () => { + const p = presenceOf({ status: "join me", state: "online" }); + expect(p.online).toBe(true); + expect(p.effective.label).toBe("Join Me"); + }); + + it("falls back to the offline swatch when offline, keeping the raw status", () => { + const p = presenceOf({ status: "join me", state: "offline" }); + expect(p.online).toBe(false); + expect(p.effective.label).toBe("Offline"); + expect(p.status.label).toBe("Join Me"); + }); +}); + +describe("avatar/banner fallback chains", () => { + it("prefers userIcon, then thumbnail, then full image", () => { + expect( + avatarOf({ + userIcon: "icon", + currentAvatarThumbnailImageUrl: "thumb", + currentAvatarImageUrl: "full", + }), + ).toBe("icon"); + expect( + avatarOf({ + userIcon: "", + currentAvatarThumbnailImageUrl: "thumb", + currentAvatarImageUrl: "full", + }), + ).toBe("thumb"); + expect( + avatarOf({ userIcon: "", currentAvatarThumbnailImageUrl: "", currentAvatarImageUrl: "full" }), + ).toBe("full"); + }); + + it("banner prefers the profile override, then full image", () => { + expect( + bannerOf({ + profilePicOverride: "ovr", + currentAvatarImageUrl: "full", + currentAvatarThumbnailImageUrl: "thumb", + }), + ).toBe("ovr"); + expect( + bannerOf({ + profilePicOverride: "", + currentAvatarImageUrl: "full", + currentAvatarThumbnailImageUrl: "thumb", + }), + ).toBe("full"); + }); +}); + +describe("regionFlag / languageLabel", () => { + it("maps known regions case-insensitively and unknown to undefined", () => { + expect(regionFlag("US")).toBe("🇺🇸"); + expect(regionFlag("jp")).toBe("🇯🇵"); + expect(regionFlag("xx")).toBeUndefined(); + expect(regionFlag(undefined)).toBeUndefined(); + }); + + it("maps known language codes and falls back to uppercased code", () => { + expect(languageLabel("eng")).toBe("English"); + expect(languageLabel("zzz")).toBe("ZZZ"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ba29bd1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + }, +});