diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98e2661d..fc45846d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,4 +85,4 @@ jobs: run: bun run typecheck - name: Test - run: bun test + run: bun run test diff --git a/web/bun.lock b/web/bun.lock index 42ab7a23..8e3d0980 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -30,7 +30,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.21", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", @@ -71,23 +71,27 @@ "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.19", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260702.3", "@xyflow/react": "^12.11.1", "embla-carousel-react": "^8.6.0", - "happy-dom": "^20.11.1", + "jsdom": "^29.1.1", "knip": "^6.24.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "shadcn": "^4.12.0", + "vitest": "^4.1.10", }, }, }, "overrides": { "brace-expansion": "2.1.1", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "fast-uri": "3.1.2", "hono": "4.12.22", "ip-address": "10.2.0", @@ -99,6 +103,8 @@ "uuid": "14.0.0", }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f85diFdPMXYJpxCjOYZchMQkRH8h3r6lhK4Q2xmzJ7UA2OQ80L3W7tFu61742xGQK7zHWm5AhxYhNuc50H9SGQ=="], "@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="], @@ -123,6 +129,14 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@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" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -187,6 +201,8 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], @@ -207,6 +223,18 @@ "@codemirror/view": ["@codemirror/view@6.43.6", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -259,6 +287,8 @@ "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -663,6 +693,38 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rsbuild/core": ["@rsbuild/core@2.1.6", "", { "dependencies": { "@rspack/core": "~2.1.4", "@swc/helpers": "^0.5.23" }, "peerDependencies": { "core-js": ">= 3.0.0" }, "optionalPeers": ["core-js"], "bin": { "rsbuild": "./bin/rsbuild.js" } }, "sha512-w2WxblstOgHnDElkqJZVO/jM/EqPaEhg7zqQhON3Xu3Mj9FlVOJx+SgimOtghFzxLt8x7atX4oMUtMTNSZGf0Q=="], "@rsbuild/plugin-react": ["@rsbuild/plugin-react@2.1.0", "", { "dependencies": { "@rspack/plugin-react-refresh": "^2.0.2", "react-refresh": "^0.18.0" }, "peerDependencies": { "@rsbuild/core": "^2.0.0" }, "optionalPeers": ["@rsbuild/core"] }, "sha512-RQTIAWB/CwPjoWt9iAl+8HixeQVgZ7kEIBrWPCixfITyHdiD84h0YpUTpEUuz6kGHw1KXT9mHZ3Rwy6WG7aRDA=="], @@ -801,6 +863,14 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="], "@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="], @@ -827,6 +897,10 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -891,6 +965,8 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -985,6 +1061,20 @@ "@visactor/vutils-extension": ["@visactor/vutils-extension@2.1.4", "", { "dependencies": { "@visactor/vdataset": "~1.0.23", "@visactor/vutils": "~1.0.23" } }, "sha512-gyTxhrTN0Ybzis4FsHniTNQSKo9GWLT2f+0du3E6wEDQ/PijNRS6onkY3HyBXZbsnJlvk/sMZUqrtVJi3cTvzg=="], + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], @@ -1011,7 +1101,9 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], @@ -1023,8 +1115,12 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], @@ -1051,6 +1147,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -1077,6 +1175,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1151,6 +1251,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1229,6 +1333,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], @@ -1237,6 +1343,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -1269,7 +1377,9 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="], "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], @@ -1297,7 +1407,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1307,6 +1417,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -1349,6 +1461,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], @@ -1401,6 +1515,8 @@ "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], @@ -1487,6 +1603,8 @@ "hono": ["hono@4.12.22", "", {}, "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1515,6 +1633,8 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1565,6 +1685,8 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1595,6 +1717,8 @@ "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "jsdom": ["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.3", "@exodus/bytes": "^1.15.0", "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.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "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" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -1671,12 +1795,14 @@ "lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], "lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -1741,6 +1867,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -1841,6 +1969,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1873,6 +2003,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "on-change": ["on-change@4.0.0", "", {}, "sha512-PTu7C9Jsz4b+sNMDpH0eZFTr7uxdOtoDWRnhaVNK50bgrrnW5nvbWI0jm5DG9qOoTnIhBzE9xoKVFPD9xgtbdg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1915,7 +2047,7 @@ "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1963,6 +2095,8 @@ "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -1977,6 +2111,8 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], @@ -2081,6 +2217,8 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], @@ -2139,6 +2277,8 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -2153,6 +2293,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], @@ -2193,6 +2335,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-statistics": ["simple-statistics@7.9.3", "", {}, "sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ=="], @@ -2221,8 +2365,12 @@ "sse.js": ["sse.js@2.8.0", "", {}, "sha512-35RyyFYpzzHZgMw9D5GxwADbL6gnntSwW/rKXcuIy1KkYCPjW6oia0moNdNRhs34oVHU1Sjgovj3l7uIEZjrKA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "stream-markdown-parser": ["stream-markdown-parser@1.1.3", "", { "dependencies": { "markdown-it-container": "^4.0.0", "markdown-it-footnote": "^4.0.0", "markdown-it-ins": "^4.0.0", "markdown-it-mark": "^4.0.0", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "markdown-it-task-checkbox": "^1.0.6", "markdown-it-ts": "^1.0.4" } }, "sha512-tge6aKbOGU36vBLeeroHGBAJ7qiNcsTYyvmgqevVN0+5kMiLOjAyeDmv3PQkniTMbgOsswVHN9izmgmSw5bsBQ=="], @@ -2245,6 +2393,8 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], @@ -2259,6 +2409,8 @@ "swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "systeminformation": ["systeminformation@5.33.0", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA=="], "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], @@ -2275,12 +2427,20 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tldts": ["tldts@7.4.9", "", { "dependencies": { "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA=="], + + "tldts-core": ["tldts-core@7.4.9", "", {}, "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-vfile": ["to-vfile@8.0.0", "", { "dependencies": { "vfile": "^6.0.0" } }, "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg=="], @@ -2293,6 +2453,10 @@ "topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="], + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -2383,26 +2547,42 @@ "virtua": ["virtua@0.49.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-k1Yn988Vz/L40uDtEWPjfdVo15Suumh4tU4/z5Srs0elNcU9DgBskqdh3llpHyAlXrCeHWTfcclYvY1uU4MmIg=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "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.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yace": ["yace@1.1.0", "", {}, "sha512-jB29trAxPBvTIR/lXsgh97q22n/Rq5ZHbDT4DT6WUvXVlJLR7jdwiBKD+0zi3XZAD5N+YKa3GrdIrelqIXj2oQ=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2421,6 +2601,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2483,6 +2665,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@visactor/vdataset/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "@visactor/vlayouts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -2535,6 +2721,14 @@ "geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="], + "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -2563,10 +2757,10 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -2579,6 +2773,8 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -2591,6 +2787,8 @@ "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -2633,10 +2831,12 @@ "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], diff --git a/web/package.json b/web/package.json index d96a6c4b..2893f36b 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,8 @@ "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint -c .oxlintrc.json . --fix", "preview": "rsbuild preview", + "test": "vitest run", + "test:watch": "vitest", "format:check": "node scripts/format-with-protected-headers.mjs --check", "format": "node scripts/format-with-protected-headers.mjs --write", "copyright:check": "node scripts/add-copyright.mjs --check", @@ -85,17 +87,21 @@ "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.19", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260702.3", "@xyflow/react": "^12.11.1", "embla-carousel-react": "^8.6.0", - "happy-dom": "^20.11.1", + "jsdom": "^29.1.1", "knip": "^6.24.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", - "shadcn": "^4.12.0" + "shadcn": "^4.12.0", + "vitest": "^4.1.10" }, "overrides": { "brace-expansion": "2.1.1", diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts index bbac95fd..00651f90 100644 --- a/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts +++ b/web/src/components/json-code-editor/__tests__/json-code-editor-utils.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { applyJsonSmartEnter, @@ -29,42 +28,42 @@ import { describe('json code editor utils', () => { test('treats empty drafts as valid editable JSON drafts', () => { - assert.deepEqual(getJsonValidationState(' \n'), { + expect(getJsonValidationState(' \n')).toEqual({ isValid: true, messageKey: 'JSON', }) }) test('reports invalid JSON without throwing away the draft', () => { - assert.deepEqual(getJsonValidationState('{"model": }'), { + expect(getJsonValidationState('{"model": }')).toEqual({ isValid: false, messageKey: 'Invalid JSON', }) }) test('formats valid JSON with stable two-space indentation', () => { - assert.deepEqual(formatJsonDraft('{"model":{"ratio":2}}'), { + expect(formatJsonDraft('{"model":{"ratio":2}}')).toEqual({ didFormat: true, value: '{\n "model": {\n "ratio": 2\n }\n}', }) }) test('keeps invalid JSON drafts unchanged when formatting is requested', () => { - assert.deepEqual(formatJsonDraft('{"model": }'), { + expect(formatJsonDraft('{"model": }')).toEqual({ didFormat: false, value: '{"model": }', }) }) test('derives the one-based cursor line and column from text offsets', () => { - assert.deepEqual(getCursorLocation('{\n "model": 1\n}', 5), { + expect(getCursorLocation('{\n "model": 1\n}', 5)).toEqual({ line: 2, column: 4, }) }) test('expands paired JSON brackets with a nested indentation line', () => { - assert.deepEqual(applyJsonSmartEnter('{}', 1, 1), { + expect(applyJsonSmartEnter('{}', 1, 1)).toEqual({ value: '{\n \n}', selectionStart: 4, selectionEnd: 4, @@ -90,11 +89,11 @@ describe('json code editor utils', () => { source.scrollTop = 80 synchronizer.sync() - assert.equal(queuedFrames.length, 1) + expect(queuedFrames.length).toBe(1) queuedFrames[0]() - assert.equal(contentLayer.style.transform, 'translate3d(-24px, -80px, 0)') - assert.equal(lineNumberLayer.style.transform, 'translate3d(0, -80px, 0)') + expect(contentLayer.style.transform).toBe('translate3d(-24px, -80px, 0)') + expect(lineNumberLayer.style.transform).toBe('translate3d(0, -80px, 0)') }) }) diff --git a/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx index 24959b2c..9d4b5379 100644 --- a/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx +++ b/web/src/components/json-code-editor/__tests__/json-code-editor.test.tsx @@ -16,165 +16,98 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLTextAreaElement', - 'Node', - 'Element', - 'Event', - 'CustomEvent', - 'MutationObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') -const i18next = (await import('i18next')).default -const { initReactI18next } = await import('react-i18next') -await i18next.use(initReactI18next).init({ - lng: 'en', - resources: { - en: { - translation: { - JSON: 'JSON', - 'Invalid JSON': 'Invalid JSON', - 'Copied to clipboard': 'Copied to clipboard', - 'Failed to copy': 'Failed to copy', - 'Format JSON': 'Format JSON', - }, - }, - }, -}) -const { JsonCodeEditor } = await import('../../json-code-editor') -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - -type RenderedEditor = { - container: HTMLDivElement - root: ReturnType -} - -async function renderEditor( - props: React.ComponentProps -): Promise { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => { - root.render() - }) - - return { container, root } -} - -async function unmountEditor(rendered: RenderedEditor) { - await act(async () => rendered.root.unmount()) - rendered.container.remove() -} +import { JsonCodeEditor } from '../../json-code-editor' describe('JsonCodeEditor component', () => { - after(() => { - domWindow.close() + test('forwards form attributes and the textarea ref', () => { + const textareaRef = vi.fn() + const rendered = render( + undefined} + id='json-input' + name='model_config' + placeholder='{"model":"gpt"}' + disabled + ariaLabel='Model configuration' + aria-describedby='model-help' + aria-invalid + data-form-root='settings-form' + textareaRef={textareaRef} + /> + ) + const textarea = screen.getByRole('textbox', { + name: 'Model configuration', + }) + + expect(textarea).toHaveAttribute('id', 'json-input') + expect(textarea).toHaveAttribute('name', 'model_config') + expect(textarea).toHaveAttribute('placeholder', '{"model":"gpt"}') + expect(textarea).toBeDisabled() + expect(textarea).toHaveAttribute('aria-describedby', 'model-help') + expect(textarea).toHaveAttribute('aria-invalid', 'true') + expect(textarea).toHaveAttribute('data-form-root', 'settings-form') + expect(textareaRef).toHaveBeenCalledWith(textarea) + + rendered.unmount() + expect(textareaRef).toHaveBeenLastCalledWith(null) }) - test('forwards form attributes and lifecycle callbacks to the textarea', async () => { - const blurCalls: number[] = [] - const refValues: Array = [] - const rendered = await renderEditor({ - value: '{"model":"gpt"}', - onChange: () => undefined, - id: 'json-input', - name: 'model_config', - placeholder: '{"model":"gpt"}', - disabled: true, - 'aria-describedby': 'model-help', - 'aria-invalid': true, - 'data-form-root': 'settings-form', - onBlur: () => blurCalls.push(1), - textareaRef: (element) => refValues.push(element), - }) - const textarea = rendered.container.querySelector('textarea') + test('calls onBlur when focus leaves the editor', () => { + const onBlur = vi.fn() + render( + undefined} + onBlur={onBlur} + ariaLabel='Model configuration' + /> + ) - assert.ok(textarea) - assert.equal(textarea.id, 'json-input') - assert.equal(textarea.name, 'model_config') - assert.equal(textarea.placeholder, '{"model":"gpt"}') - assert.equal(textarea.disabled, true) - assert.equal(textarea.getAttribute('aria-describedby'), 'model-help') - assert.equal(textarea.getAttribute('aria-invalid'), 'true') - assert.equal(textarea.getAttribute('data-form-root'), 'settings-form') + fireEvent.blur(screen.getByRole('textbox', { name: 'Model configuration' })) - await act(async () => textarea.dispatchEvent(new Event('blur'))) - assert.deepEqual(blurCalls, [1]) - assert.equal(refValues[0], textarea) - - await unmountEditor(rendered) - assert.equal(refValues.at(-1), null) + expect(onBlur).toHaveBeenCalledOnce() }) - test('emits user edits and synchronizes a controlled value', async () => { - const changes: string[] = [] - const rendered = await renderEditor({ - value: '{"count":1}', - onChange: (value) => changes.push(value), + test('emits user edits and synchronizes a controlled value', () => { + const onChange = vi.fn() + const rendered = render( + + ) + const textarea = screen.getByRole('textbox', { + name: 'Model configuration', }) - const textarea = rendered.container.querySelector('textarea') - assert.ok(textarea) - await act(async () => { - textarea.value = '{"count":2}' - textarea.dispatchEvent(new Event('input', { bubbles: true })) - }) - assert.deepEqual(changes, ['{"count":2}']) + fireEvent.input(textarea, { target: { value: '{"count":2}' } }) + expect(onChange).toHaveBeenCalledWith('{"count":2}') - await act(async () => { - rendered.root.render( - changes.push(value)} - /> - ) - }) - assert.equal(textarea.value, '{"count":3}') - - await unmountEditor(rendered) + rendered.rerender( + + ) + expect(textarea).toHaveValue('{"count":3}') }) test('formats valid JSON through the public toolbar action', async () => { - const changes: string[] = [] - const rendered = await renderEditor({ - value: '{"model":{"ratio":2}}', - onChange: (value) => changes.push(value), - }) - const formatButton = [ - ...rendered.container.querySelectorAll('button'), - ].find((button) => button.textContent?.includes('Format JSON')) + const user = userEvent.setup() + const onChange = vi.fn() + render() - assert.ok(formatButton) - await act(async () => formatButton.click()) - assert.deepEqual(changes, ['{\n "model": {\n "ratio": 2\n }\n}']) + await user.click(screen.getByRole('button', { name: 'Format JSON' })) - await unmountEditor(rendered) + expect(onChange).toHaveBeenCalledWith( + '{\n "model": {\n "ratio": 2\n }\n}' + ) }) }) diff --git a/web/src/components/model-group-selector/__tests__/layout.test.ts b/web/src/components/model-group-selector/__tests__/layout.test.ts index ef5b7e6f..db60dbf6 100644 --- a/web/src/components/model-group-selector/__tests__/layout.test.ts +++ b/web/src/components/model-group-selector/__tests__/layout.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { modelGroupSelectorLayoutClasses, @@ -29,8 +28,8 @@ describe('model group selector layout', () => { const groupScrollClasses = modelGroupSelectorLayoutClasses.groupScroll.split(' ') - assert.ok(groupScrollClasses.includes('auto-rows-[2rem]')) - assert.ok(groupScrollClasses.includes('content-start')) + expect(groupScrollClasses.includes('auto-rows-[2rem]')).toBeTruthy() + expect(groupScrollClasses.includes('content-start')).toBeTruthy() }) test('centers the selected group inside its own scroll container', () => { @@ -50,7 +49,7 @@ describe('model group selector layout', () => { scrollSelectedOptionIntoView(selectedOption, scrollContainer) - assert.deepEqual(scrollCalls, [{ top: 76, behavior: 'auto' }]) + expect(scrollCalls).toEqual([{ top: 76, behavior: 'auto' }]) }) test('falls back to scrollIntoView when no group container is provided', () => { @@ -63,6 +62,6 @@ describe('model group selector layout', () => { scrollSelectedOptionIntoView(selectedOption) - assert.deepEqual(scrollCalls, [{ block: 'center', inline: 'nearest' }]) + expect(scrollCalls).toEqual([{ block: 'center', inline: 'nearest' }]) }) }) diff --git a/web/src/components/ui/dropdown-menu.test.tsx b/web/src/components/ui/dropdown-menu.test.tsx index 71d6afe9..8a3e2d9d 100644 --- a/web/src/components/ui/dropdown-menu.test.tsx +++ b/web/src/components/ui/dropdown-menu.test.tsx @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { handleDropdownMenuItemSelect } from './dropdown-menu-events' @@ -52,8 +51,8 @@ describe('DropdownMenuItem onSelect compatibility', () => { selected = true }) - assert.equal(selected, true) - assert.equal(event.baseUIHandlerPrevented, false) + expect(selected).toBe(true) + expect(event.baseUIHandlerPrevented).toBe(false) }) test('keeps the Base UI menu open when onSelect prevents default', () => { @@ -63,7 +62,7 @@ describe('DropdownMenuItem onSelect compatibility', () => { selectEvent.preventDefault() }) - assert.equal(event.defaultPrevented, true) - assert.equal(event.baseUIHandlerPrevented, true) + expect(event.defaultPrevented).toBe(true) + expect(event.baseUIHandlerPrevented).toBe(true) }) }) diff --git a/web/src/features/auth/api.test.ts b/web/src/features/auth/api.test.ts index 455b6886..c3fcb894 100644 --- a/web/src/features/auth/api.test.ts +++ b/web/src/features/auth/api.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { RefreshOutcome } from '@/lib/api' import type { AuthBundle } from '@/stores/auth-store' @@ -63,8 +62,8 @@ describe('logout coordination', () => { }, }) - assert.deepEqual(result, { success: false, message: 'not revoked' }) - assert.equal(refreshCount, 0) + expect(result).toEqual({ success: false, message: 'not revoked' }) + expect(refreshCount).toBe(0) }) test('recovers a cookie mismatch and retries with the refreshed SID', async () => { @@ -83,8 +82,8 @@ describe('logout coordination', () => { }, }) - assert.deepEqual(result, { success: true, message: '' }) - assert.deepEqual(requestedSIDs, ['session-a', 'session-b']) + expect(result).toEqual({ success: true, message: '' }) + expect(requestedSIDs).toEqual(['session-a', 'session-b']) }) test('treats a mismatch that refresh confirms anonymous as signed out', async () => { @@ -96,7 +95,7 @@ describe('logout coordination', () => { refresh: async () => ({ kind: 'anonymous' }), }) - assert.deepEqual(result, { success: true, message: '' }) + expect(result).toEqual({ success: true, message: '' }) }) test('preserves the active session when mismatch recovery is temporary', async () => { @@ -106,15 +105,14 @@ describe('logout coordination', () => { error: new Error('offline'), } - await assert.rejects( + await expect( executeLogout({ getExpectedSID: () => 'session-a', request: async () => { throw originalError }, refresh: async () => transient, - }), - (error) => error === originalError - ) + }) + ).rejects.toBe(originalError) }) }) diff --git a/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts b/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts index 180e7bf0..75b87c79 100644 --- a/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts +++ b/web/src/features/auth/lib/__tests__/oauth-callback-mode.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { getOAuthSessionStorage, @@ -40,15 +39,14 @@ const bindState = 'bind-state' describe('resolveOAuthCallbackMode', () => { test('matching provider and state mark is treated as a bind flow', () => { const storage = fakeStorage() - assert.equal(markOAuthBindPopup(storage, 'oidc', bindState), true) + expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(true) - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage, - }), - 'bind' - ) + }) + ).toBe('bind') }) // Regression: a tab opened from an external link (Slack, e-mail, another @@ -58,75 +56,69 @@ describe('resolveOAuthCallbackMode', () => { test('login redirect in a tab with a foreign opener stays a login flow', () => { const storage = fakeStorage() - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage, - }), - 'login' - ) + }) + ).toBe('login') }) test('bind marker for another provider does not hijack this callback', () => { const storage = fakeStorage() markOAuthBindPopup(storage, 'github', bindState) - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage, - }), - 'login' - ) + }) + ).toBe('login') }) test('stale bind marker does not hijack a later callback', () => { const storage = fakeStorage() markOAuthBindPopup(storage, 'oidc', 'previous-state') - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage, - }), - 'login' - ) + }) + ).toBe('login') }) test('bind marker without an opener falls back to login', () => { const storage = fakeStorage() markOAuthBindPopup(storage, 'oidc', bindState) - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: null, storage, - }), - 'login' - ) + }) + ).toBe('login') }) test('closed opener falls back to login', () => { const storage = fakeStorage() markOAuthBindPopup(storage, 'oidc', bindState) - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: { closed: true }, storage, - }), - 'login' - ) + }) + ).toBe('login') }) test('missing storage degrades to login instead of throwing', () => { - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage: null, - }), - 'login' - ) + }) + ).toBe('login') }) test('storage read failure degrades to login instead of throwing', () => { @@ -137,13 +129,12 @@ describe('resolveOAuthCallbackMode', () => { setItem: () => undefined, } - assert.equal( + expect( resolveOAuthCallbackMode('oidc', bindState, { opener: openOpener, storage, - }), - 'login' - ) + }) + ).toBe('login') }) }) @@ -155,7 +146,7 @@ describe('OAuth bind popup storage', () => { }, } - assert.equal(getOAuthSessionStorage(owner), null) + expect(getOAuthSessionStorage(owner)).toBe(null) }) test('marking reports unavailable or unwritable storage', () => { @@ -166,9 +157,9 @@ describe('OAuth bind popup storage', () => { }, } - assert.equal(markOAuthBindPopup(null, 'oidc', bindState), false) - assert.equal(markOAuthBindPopup(storage, 'oidc', bindState), false) - assert.equal( + expect(markOAuthBindPopup(null, 'oidc', bindState)).toBe(false) + expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(false) + expect( markOAuthBindPopup( { getItem: () => null, @@ -176,8 +167,7 @@ describe('OAuth bind popup storage', () => { }, 'oidc', bindState - ), - false - ) + ) + ).toBe(false) }) }) diff --git a/web/src/features/auth/lib/auth-redirect.test.ts b/web/src/features/auth/lib/auth-redirect.test.ts index f043c26b..550223db 100644 --- a/web/src/features/auth/lib/auth-redirect.test.ts +++ b/web/src/features/auth/lib/auth-redirect.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { AuthUser } from '@/stores/auth-store' @@ -27,17 +26,15 @@ const origin = 'https://dashboard.example.com' describe('authentication redirect validation', () => { test('preserves safe internal paths, search parameters, and fragments', () => { - assert.equal( - sanitizeAuthRedirect('/console?tab=usage#recent', origin), + expect(sanitizeAuthRedirect('/console?tab=usage#recent', origin)).toBe( '/console?tab=usage#recent' ) - assert.equal( + expect( sanitizeAuthRedirect( 'https://dashboard.example.com/dashboard?tab=quota#daily', origin - ), - '/dashboard?tab=quota#daily' - ) + ) + ).toBe('/dashboard?tab=quota#daily') }) test('rejects external and ambiguously parsed redirect targets', () => { @@ -53,13 +50,13 @@ describe('authentication redirect validation', () => { ] for (const target of unsafeTargets) { - assert.equal(sanitizeAuthRedirect(target, origin), null) + expect(sanitizeAuthRedirect(target, origin)).toBe(null) } }) test('rejects invalid or non-HTTP application origins', () => { - assert.equal(sanitizeAuthRedirect('/dashboard', 'not-an-origin'), null) - assert.equal(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app'), null) + expect(sanitizeAuthRedirect('/dashboard', 'not-an-origin')).toBe(null) + expect(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app')).toBe(null) }) }) @@ -67,31 +64,27 @@ describe('saved authentication language', () => { const user: AuthUser = { id: 1, username: 'user', role: 1 } test('prefers the explicit user language', () => { - assert.equal( + expect( getSavedLanguage({ ...user, language: 'ja', setting: { language: 'fr' }, - }), - 'ja' - ) + }) + ).toBe('ja') }) test('reads object and JSON string settings', () => { - assert.equal( - getSavedLanguage({ ...user, setting: { language: 'fr' } }), + expect(getSavedLanguage({ ...user, setting: { language: 'fr' } })).toBe( 'fr' ) - assert.equal( - getSavedLanguage({ ...user, setting: '{"language":"ru"}' }), + expect(getSavedLanguage({ ...user, setting: '{"language":"ru"}' })).toBe( 'ru' ) }) test('ignores malformed and non-string setting languages', () => { - assert.equal(getSavedLanguage({ ...user, setting: '{' }), undefined) - assert.equal( - getSavedLanguage({ ...user, setting: { language: 123 } }), + expect(getSavedLanguage({ ...user, setting: '{' })).toBe(undefined) + expect(getSavedLanguage({ ...user, setting: { language: 123 } })).toBe( undefined ) }) diff --git a/web/src/features/auth/lib/oauth-bind-window.test.ts b/web/src/features/auth/lib/oauth-bind-window.test.ts index 3ef4ba43..54a33385 100644 --- a/web/src/features/auth/lib/oauth-bind-window.test.ts +++ b/web/src/features/auth/lib/oauth-bind-window.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { parseTelegramBindCallback, @@ -51,51 +50,48 @@ function fakeTimerRuntime() { describe('OAuth bind popup lifecycle', () => { test('parses Telegram success and stable error callbacks', () => { - assert.deepEqual( + expect( parseTelegramBindCallback({ telegram_bind: 'success', flow_token: 'flow-success', - }), - { - kind: 'result', - flowToken: 'flow-success', - success: true, - } - ) - assert.deepEqual( + }) + ).toEqual({ + kind: 'result', + flowToken: 'flow-success', + success: true, + }) + expect( parseTelegramBindCallback({ telegram_bind: 'error', flow_token: 'flow-error', error_code: 'TELEGRAM_BIND_ALREADY_BOUND', - }), - { - kind: 'result', - flowToken: 'flow-error', - success: false, - code: 'TELEGRAM_BIND_ALREADY_BOUND', - } - ) + }) + ).toEqual({ + kind: 'result', + flowToken: 'flow-error', + success: false, + code: 'TELEGRAM_BIND_ALREADY_BOUND', + }) }) test('rejects Telegram callbacks without a flow token and ignores descriptions', () => { - assert.deepEqual(parseTelegramBindCallback({ telegram_bind: 'error' }), { + expect(parseTelegramBindCallback({ telegram_bind: 'error' })).toEqual({ kind: 'invalid', }) - assert.deepEqual( + expect( parseTelegramBindCallback({ telegram_bind: 'error', flow_token: 'flow-error', error_code: 'UNKNOWN_CODE', error_description: 'untrusted message', - } as Parameters[0]), - { - kind: 'result', - flowToken: 'flow-error', - success: false, - code: 'UNKNOWN_CODE', - } - ) - assert.equal(parseTelegramBindCallback({}), null) + } as Parameters[0]) + ).toEqual({ + kind: 'result', + flowToken: 'flow-error', + success: false, + code: 'UNKNOWN_CODE', + }) + expect(parseTelegramBindCallback({})).toBe(null) }) test('posts only complete Telegram bind results to an available opener', () => { @@ -112,11 +108,10 @@ describe('OAuth bind popup lifecycle', () => { error_code: 'UNKNOWN_CODE', }) - assert.equal( - postTelegramBindResult(callback, opener, 'https://dashboard.example.com'), - true - ) - assert.deepEqual(messages, [ + expect( + postTelegramBindResult(callback, opener, 'https://dashboard.example.com') + ).toBe(true) + expect(messages).toEqual([ { message: { type: 'telegram:binding:result', @@ -128,23 +123,17 @@ describe('OAuth bind popup lifecycle', () => { }, ]) - assert.equal( - postTelegramBindResult( - { kind: 'invalid' }, - opener, - 'https://example.com' - ), - false - ) - assert.equal( + expect( + postTelegramBindResult({ kind: 'invalid' }, opener, 'https://example.com') + ).toBe(false) + expect( postTelegramBindResult( callback, { ...opener, closed: true }, 'https://example.com' - ), - false - ) - assert.equal(messages.length, 1) + ) + ).toBe(false) + expect(messages.length).toBe(1) }) test('waits 30 seconds for the opener response and can be cancelled', () => { @@ -158,11 +147,11 @@ describe('OAuth bind popup lifecycle', () => { timer.runtime ) - assert.equal(timer.delay, 30_000) + expect(timer.delay).toBe(30_000) cancel() timer.fire() - assert.equal(timedOut, false) - assert.deepEqual(timer.cancelled, [timer.handle]) + expect(timedOut).toBe(false) + expect(timer.cancelled).toEqual([timer.handle]) }) test('reports a closed popup once and clears its poller', () => { @@ -178,13 +167,13 @@ describe('OAuth bind popup lifecycle', () => { timer.runtime ) - assert.equal(timer.delay, 500) + expect(timer.delay).toBe(500) timer.fire() - assert.equal(closedCount, 0) + expect(closedCount).toBe(0) popup.closed = true timer.fire() timer.fire() - assert.equal(closedCount, 1) - assert.deepEqual(timer.cancelled, [timer.handle]) + expect(closedCount).toBe(1) + expect(timer.cancelled).toEqual([timer.handle]) }) }) diff --git a/web/src/features/auth/lib/telegram-login.test.ts b/web/src/features/auth/lib/telegram-login.test.ts index 95ee310c..ad64e967 100644 --- a/web/src/features/auth/lib/telegram-login.test.ts +++ b/web/src/features/auth/lib/telegram-login.test.ts @@ -16,14 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { pickTelegramAuthorization } from './telegram-login' describe('Telegram login authorization', () => { test('keeps only fields signed by the Telegram login contract', () => { - assert.deepEqual( + expect( pickTelegramAuthorization({ id: 12345, first_name: 'Test', @@ -35,33 +34,27 @@ describe('Telegram login authorization', () => { lang: 'en', admin: true, redirect: 'https://attacker.example', - }), - { - id: 12345, - first_name: 'Test', - last_name: 'User', - username: 'test_user', - photo_url: 'https://t.me/i/userpic/320/test.jpg', - auth_date: 1_900_000_000, - hash: 'signed-hash', - lang: 'en', - } - ) + }) + ).toEqual({ + id: 12345, + first_name: 'Test', + last_name: 'User', + username: 'test_user', + photo_url: 'https://t.me/i/userpic/320/test.jpg', + auth_date: 1_900_000_000, + hash: 'signed-hash', + lang: 'en', + }) }) test('rejects incomplete or structurally invalid callbacks', () => { - assert.equal(pickTelegramAuthorization(null), null) - assert.equal( - pickTelegramAuthorization({ auth_date: 1, hash: 'hash' }), - null - ) - assert.equal( - pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' }), - null - ) - assert.equal( - pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' }), + expect(pickTelegramAuthorization(null)).toBe(null) + expect(pickTelegramAuthorization({ auth_date: 1, hash: 'hash' })).toBe(null) + expect(pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' })).toBe( null ) + expect( + pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' }) + ).toBe(null) }) }) diff --git a/web/src/features/channels/lib/__tests__/channel-field-update.test.ts b/web/src/features/channels/lib/__tests__/channel-field-update.test.ts index d984f656..cfd463ba 100644 --- a/web/src/features/channels/lib/__tests__/channel-field-update.test.ts +++ b/web/src/features/channels/lib/__tests__/channel-field-update.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { CHANNEL_FIELD_UPDATE_DELAY_MS, @@ -31,7 +30,7 @@ function createFakeTimers() { return { timers: { setTimeout: (callback: () => void, delay: number) => { - assert.equal(delay, CHANNEL_FIELD_UPDATE_DELAY_MS) + expect(delay).toBe(CHANNEL_FIELD_UPDATE_DELAY_MS) const id = nextId++ pending.set(id, callback) return id @@ -63,11 +62,11 @@ describe('channel field update scheduler', () => { scheduler.schedule(1) scheduler.schedule(2) scheduler.schedule(3) - assert.deepEqual(updates, []) - assert.equal(fake.pendingCount, 1) + expect(updates).toEqual([]) + expect(fake.pendingCount).toBe(1) fake.fireAll() - assert.deepEqual(updates, [3]) + expect(updates).toEqual([3]) }) test('flush commits the pending value immediately and cancels the timer', () => { @@ -80,11 +79,11 @@ describe('channel field update scheduler', () => { scheduler.schedule(7) scheduler.flush() - assert.deepEqual(updates, [7]) - assert.equal(fake.pendingCount, 0) + expect(updates).toEqual([7]) + expect(fake.pendingCount).toBe(0) fake.fireAll() - assert.deepEqual(updates, [7]) + expect(updates).toEqual([7]) }) test('flush without a pending value does nothing', () => { @@ -99,7 +98,7 @@ describe('channel field update scheduler', () => { scheduler.schedule(5) scheduler.flush() scheduler.flush() - assert.deepEqual(updates, [5]) + expect(updates).toEqual([5]) }) test('preserves a pending value of 0', () => { @@ -112,6 +111,6 @@ describe('channel field update scheduler', () => { scheduler.schedule(0) scheduler.flush() - assert.deepEqual(updates, [0]) + expect(updates).toEqual([0]) }) }) diff --git a/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts b/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts index d0e19baf..2d690627 100644 --- a/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts +++ b/web/src/features/channels/lib/__tests__/channel-table-row-id.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { Channel } from '../../types' import { getChannelTableRowId, type TagRow } from '../channel-utils' @@ -35,12 +34,8 @@ describe('channel table row identity', () => { const beforeUpdate = [first, updated, third].map(getChannelTableRowId) const afterUpdate = [updated, first, third].map(getChannelTableRowId) - assert.deepEqual(beforeUpdate, [ - 'channel:101', - 'channel:202', - 'channel:303', - ]) - assert.deepEqual(afterUpdate, ['channel:202', 'channel:101', 'channel:303']) + expect(beforeUpdate).toEqual(['channel:101', 'channel:202', 'channel:303']) + expect(afterUpdate).toEqual(['channel:202', 'channel:101', 'channel:303']) }) test('uses separate namespaces for tag and channel rows', () => { @@ -50,7 +45,7 @@ describe('channel table row identity', () => { children: [channel(202)], } as TagRow - assert.equal(getChannelTableRowId(tagRow), 'tag:202') - assert.equal(getChannelTableRowId(channel(202)), 'channel:202') + expect(getChannelTableRowId(tagRow)).toBe('tag:202') + expect(getChannelTableRowId(channel(202))).toBe('channel:202') }) }) diff --git a/web/src/features/channels/lib/__tests__/new-api-channel.test.ts b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts index 5e4f6c9b..f24fb3b8 100644 --- a/web/src/features/channels/lib/__tests__/new-api-channel.test.ts +++ b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { CHANNEL_TYPE_NEW_API, @@ -45,45 +44,40 @@ describe('New API channel', () => { (item) => item.value === CHANNEL_TYPE_NEW_API ) - assert.deepEqual(option, { + expect(option).toEqual({ value: CHANNEL_TYPE_NEW_API, label: 'New API', }) - assert.equal( + expect( CHANNEL_TYPE_OPTIONS.findIndex( (item) => item.value === CHANNEL_TYPE_NEW_API - ) + 1, - CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 58) - ) - assert.equal(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_NEW_API), true) - assert.equal(getChannelTypeIcon(CHANNEL_TYPE_NEW_API), 'NewAPI') - assert.equal( - getKeyPromptForType(CHANNEL_TYPE_NEW_API), + ) + 1 + ).toBe(CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 58)) + expect(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_NEW_API)).toBe(true) + expect(getChannelTypeIcon(CHANNEL_TYPE_NEW_API)).toBe('NewAPI') + expect(getKeyPromptForType(CHANNEL_TYPE_NEW_API)).toBe( 'Enter API key for this channel' ) - assert.equal(getChannelTypeConfig(CHANNEL_TYPE_NEW_API).icon, 'NewAPI') + expect(getChannelTypeConfig(CHANNEL_TYPE_NEW_API).icon).toBe('NewAPI') }) test('requires a non-blank Base URL', () => { const blankResult = channelFormSchema.safeParse(newAPIForm(' ')) - assert.equal(blankResult.success, false) + expect(blankResult.success).toBe(false) if (!blankResult.success) { - assert.equal( + expect( blankResult.error.issues.some( (issue) => issue.path[0] === 'base_url' && issue.message === 'Base URL is required for this channel type' - ), - true - ) + ) + ).toBe(true) } - assert.equal( - channelFormSchema.safeParse(newAPIForm('https://new-api.example')) - .success, - true - ) + expect( + channelFormSchema.safeParse(newAPIForm('https://new-api.example')).success + ).toBe(true) }) test('keeps Sub2API Base URL validation unchanged', () => { @@ -92,6 +86,6 @@ describe('New API channel', () => { type: 59, }) - assert.equal(result.success, true) + expect(result.success).toBe(true) }) }) diff --git a/web/src/features/dashboard/lib/flow-selection.test.ts b/web/src/features/dashboard/lib/flow-selection.test.ts index 4317c717..43bbccd7 100644 --- a/web/src/features/dashboard/lib/flow-selection.test.ts +++ b/web/src/features/dashboard/lib/flow-selection.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { FlowUserFilterOption } from '../types' import { @@ -46,89 +45,75 @@ const users: FlowUserFilterOption[] = [ describe('dashboard flow selection helpers', () => { test('limits user chips to currently visible users', () => { - assert.deepEqual( - visibleFlowUsers(users, []).map((user) => user.value), - ['user:1', 'user:2'] - ) - assert.deepEqual( - visibleFlowUsers(users, ['user:2']).map((user) => user.value), - ['user:2'] - ) + expect(visibleFlowUsers(users, []).map((user) => user.value)).toEqual([ + 'user:1', + 'user:2', + ]) + expect( + visibleFlowUsers(users, ['user:2']).map((user) => user.value) + ).toEqual(['user:2']) }) test('filters visible users without mutating the source options', () => { const visible = visibleFlowUsers(users, ['user:1']) - assert.deepEqual( - visible.map((user) => user.value), - ['user:1'] - ) - assert.deepEqual( - users.map((user) => user.value), - ['user:1', 'user:2'] - ) + expect(visible.map((user) => user.value)).toEqual(['user:1']) + expect(users.map((user) => user.value)).toEqual(['user:1', 'user:2']) }) test('formats compact selected counts for flow multiselect summaries', () => { - assert.equal(compactFlowSelectionLabel(0), '*') - assert.equal(compactFlowSelectionLabel(1), '1') - assert.equal(compactFlowSelectionLabel(23), '23') + expect(compactFlowSelectionLabel(0)).toBe('*') + expect(compactFlowSelectionLabel(1)).toBe('1') + expect(compactFlowSelectionLabel(23)).toBe('23') }) test('prioritizes loading and error states before empty flow data', () => { - assert.equal( + expect( flowDisplayState({ isLoading: true, isError: true, linkCount: 0, themeReady: true, - }), - 'loading' - ) - assert.equal( + }) + ).toBe('loading') + expect( flowDisplayState({ isLoading: false, isError: true, linkCount: 0, themeReady: true, - }), - 'error' - ) - assert.equal( + }) + ).toBe('error') + expect( flowDisplayState({ isLoading: false, isError: false, linkCount: 0, themeReady: true, - }), - 'empty' - ) - assert.equal( + }) + ).toBe('empty') + expect( flowDisplayState({ isLoading: false, isError: false, linkCount: 1, themeReady: false, - }), - 'loading' - ) + }) + ).toBe('loading') }) test('throws unsuccessful flow responses instead of treating them as empty data', () => { - assert.throws( - () => - requireSuccessfulFlowRows( - { success: false, data: [], message: 'database unavailable' }, - 'Failed to load' - ), - /database unavailable/ - ) - assert.deepEqual( + expect(() => + requireSuccessfulFlowRows( + { success: false, data: [], message: 'database unavailable' }, + 'Failed to load' + ) + ).toThrow(/database unavailable/) + expect( requireSuccessfulFlowRows( { success: true, data: [{ user_id: 1, quota: 10 }] }, 'Failed to load' - ), - [{ user_id: 1, quota: 10 }] - ) + ) + ).toEqual([{ user_id: 1, quota: 10 }]) }) }) diff --git a/web/src/features/dashboard/lib/flow.test.ts b/web/src/features/dashboard/lib/flow.test.ts index 1645d2f0..724eb367 100644 --- a/web/src/features/dashboard/lib/flow.test.ts +++ b/web/src/features/dashboard/lib/flow.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { FlowQuotaDataItem } from '../types' import { @@ -113,18 +112,16 @@ describe('dashboard flow data', () => { role: 'user', }) - assert.equal(result.summary.quota, 150) - assert.equal(result.summary.tokens, 60) - assert.equal(result.summary.requests, 3) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:vip', 'model:gpt-4.1', 150], - ['token:11', 'group:vip', 150], - ] - ) - assert.equal( - result.flow.nodes.some((node) => node.kind === 'channel'), + expect(result.summary.quota).toBe(150) + expect(result.summary.tokens).toBe(60) + expect(result.summary.requests).toBe(3) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:vip', 'model:gpt-4.1', 150], + ['token:11', 'group:vip', 150], + ]) + expect(result.flow.nodes.some((node) => node.kind === 'channel')).toBe( false ) }) @@ -134,18 +131,17 @@ describe('dashboard flow data', () => { role: 'admin', }) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:default', 'model:claude-4-sonnet', 70], - ['group:vip', 'model:gpt-4.1', 150], - ['model:claude-4-sonnet', 'channel:101', 70], - ['model:gpt-4.1', 'channel:101', 100], - ['model:gpt-4.1', 'channel:102', 50], - ['user:1', 'group:vip', 150], - ['user:2', 'group:default', 70], - ] - ) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:default', 'model:claude-4-sonnet', 70], + ['group:vip', 'model:gpt-4.1', 150], + ['model:claude-4-sonnet', 'channel:101', 70], + ['model:gpt-4.1', 'channel:101', 100], + ['model:gpt-4.1', 'channel:102', 50], + ['user:1', 'group:vip', 150], + ['user:2', 'group:default', 70], + ]) }) test('builds root user-node-token-group-model-channel flow', () => { @@ -153,22 +149,21 @@ describe('dashboard flow data', () => { role: 'root', }) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:default', 'model:claude-4-sonnet', 3], - ['group:vip', 'model:gpt-4.1', 3], - ['model:claude-4-sonnet', 'channel:101', 3], - ['model:gpt-4.1', 'channel:101', 2], - ['model:gpt-4.1', 'channel:102', 1], - ['node:node-a', 'token:11', 3], - ['node:node-b', 'token:22', 3], - ['token:11', 'group:vip', 3], - ['token:22', 'group:default', 3], - ['user:1', 'node:node-a', 3], - ['user:2', 'node:node-b', 3], - ] - ) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:default', 'model:claude-4-sonnet', 3], + ['group:vip', 'model:gpt-4.1', 3], + ['model:claude-4-sonnet', 'channel:101', 3], + ['model:gpt-4.1', 'channel:101', 2], + ['model:gpt-4.1', 'channel:102', 1], + ['node:node-a', 'token:11', 3], + ['node:node-b', 'token:22', 3], + ['token:11', 'group:vip', 3], + ['token:22', 'group:default', 3], + ['user:1', 'node:node-a', 3], + ['user:2', 'node:node-b', 3], + ]) }) test('filters by selected users', () => { @@ -177,15 +172,14 @@ describe('dashboard flow data', () => { selectedUsers: ['user:2'], }) - assert.equal(result.summary.quota, 70) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:default', 'model:claude-4-sonnet', 70], - ['model:claude-4-sonnet', 'channel:101', 70], - ['user:2', 'group:default', 70], - ] - ) + expect(result.summary.quota).toBe(70) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:default', 'model:claude-4-sonnet', 70], + ['model:claude-4-sonnet', 'channel:101', 70], + ['user:2', 'group:default', 70], + ]) }) test('filters rows by selected flow nodes', () => { @@ -194,16 +188,15 @@ describe('dashboard flow data', () => { selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }], }) - assert.equal(result.summary.quota, 150) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:vip', 'model:gpt-4.1', 150], - ['model:gpt-4.1', 'channel:101', 100], - ['model:gpt-4.1', 'channel:102', 50], - ['user:1', 'group:vip', 150], - ] - ) + expect(result.summary.quota).toBe(150) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:vip', 'model:gpt-4.1', 150], + ['model:gpt-4.1', 'channel:101', 100], + ['model:gpt-4.1', 'channel:102', 50], + ['user:1', 'group:vip', 150], + ]) }) test('combines node filters with OR inside a column and AND across columns', () => { @@ -222,20 +215,19 @@ describe('dashboard flow data', () => { ], }) - assert.equal(sameColumn.summary.quota, 220) - assert.equal(crossColumn.summary.quota, 100) - assert.deepEqual( + expect(sameColumn.summary.quota).toBe(220) + expect(crossColumn.summary.quota).toBe(100) + expect( crossColumn.flow.links.map((link) => [ link.source, link.target, link.value, - ]), - [ - ['group:vip', 'model:gpt-4.1', 100], - ['model:gpt-4.1', 'channel:101', 100], - ['user:1', 'group:vip', 100], - ] - ) + ]) + ).toEqual([ + ['group:vip', 'model:gpt-4.1', 100], + ['model:gpt-4.1', 'channel:101', 100], + ['user:1', 'group:vip', 100], + ]) }) test('combines user and node filters', () => { @@ -245,15 +237,14 @@ describe('dashboard flow data', () => { selectedNodes: [{ kind: 'channel', id: 'channel:101' }], }) - assert.equal(result.summary.quota, 100) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:vip', 'model:gpt-4.1', 100], - ['model:gpt-4.1', 'channel:101', 100], - ['user:1', 'group:vip', 100], - ] - ) + expect(result.summary.quota).toBe(100) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:vip', 'model:gpt-4.1', 100], + ['model:gpt-4.1', 'channel:101', 100], + ['user:1', 'group:vip', 100], + ]) }) test('reconnects links when a middle stage is hidden', () => { @@ -262,20 +253,16 @@ describe('dashboard flow data', () => { visibleStages: ['user', 'model', 'channel'], }) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['model:claude-4-sonnet', 'channel:101', 70], - ['model:gpt-4.1', 'channel:101', 100], - ['model:gpt-4.1', 'channel:102', 50], - ['user:1', 'model:gpt-4.1', 150], - ['user:2', 'model:claude-4-sonnet', 70], - ] - ) - assert.equal( - result.flow.nodes.some((node) => node.kind === 'group'), - false - ) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['model:claude-4-sonnet', 'channel:101', 70], + ['model:gpt-4.1', 'channel:101', 100], + ['model:gpt-4.1', 'channel:102', 50], + ['user:1', 'model:gpt-4.1', 150], + ['user:2', 'model:claude-4-sonnet', 70], + ]) + expect(result.flow.nodes.some((node) => node.kind === 'group')).toBe(false) }) test('ignores stage filters that would leave fewer than two columns', () => { @@ -284,26 +271,24 @@ describe('dashboard flow data', () => { visibleStages: ['model'], }) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:vip', 'model:gpt-4.1', 150], - ['token:11', 'group:vip', 150], - ] - ) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:vip', 'model:gpt-4.1', 150], + ['token:11', 'group:vip', 150], + ]) }) test('builds user filter options with stable values', () => { const options = buildFlowFilterOptions(rows, 'quota') - assert.deepEqual( - options.users.map((user) => [user.value, user.label, user.valueLabel]), - [ - ['user:1', 'alice', '150'], - ['user:2', 'bob', '70'], - ] - ) - assert.notEqual(options.users[0].color, options.users[1].color) + expect( + options.users.map((user) => [user.value, user.label, user.valueLabel]) + ).toEqual([ + ['user:1', 'alice', '150'], + ['user:2', 'bob', '70'], + ]) + expect(options.users[0].color).not.toBe(options.users[1].color) }) test('builds node filter options without applying top limits', () => { @@ -313,22 +298,20 @@ describe('dashboard flow data', () => { overflowMode: 'aggregate', }) - assert.equal( + expect( result.filterOptions.nodes.some( (option) => option.kind === 'model' && option.value === 'model:model-c' - ), - true - ) - assert.deepEqual( + ) + ).toBe(true) + expect( result.filterOptions.nodes .filter((option) => option.kind === 'model') - .map((option) => [option.value, option.valueLabel]), - [ - ['model:model-a', '100'], - ['model:model-b', '80'], - ['model:model-c', '10'], - ] - ) + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['model:model-a', '100'], + ['model:model-b', '80'], + ['model:model-c', '10'], + ]) }) test('facets node filter options by selected nodes from other columns', () => { @@ -338,30 +321,27 @@ describe('dashboard flow data', () => { }) const nodeOptions = result.filterOptions.nodes - assert.deepEqual( + expect( nodeOptions .filter((option) => option.kind === 'node') - .map((option) => [option.value, option.valueLabel]), - [ - ['node:node-a', '150'], - ['node:node-b', '70'], - ] - ) - assert.deepEqual( + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['node:node-a', '150'], + ['node:node-b', '70'], + ]) + expect( nodeOptions .filter((option) => option.kind === 'token') - .map((option) => [option.value, option.valueLabel]), - [['token:11', '150']] - ) - assert.deepEqual( + .map((option) => [option.value, option.valueLabel]) + ).toEqual([['token:11', '150']]) + expect( nodeOptions .filter((option) => option.kind === 'channel') - .map((option) => [option.value, option.valueLabel]), - [ - ['channel:101', '100'], - ['channel:102', '50'], - ] - ) + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['channel:101', '100'], + ['channel:102', '50'], + ]) }) test('keeps same-column node options available for OR filtering', () => { @@ -370,24 +350,22 @@ describe('dashboard flow data', () => { selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }], }) - assert.deepEqual( + expect( result.filterOptions.nodes .filter((option) => option.kind === 'model') - .map((option) => [option.value, option.valueLabel]), - [ - ['model:gpt-4.1', '150'], - ['model:claude-4-sonnet', '70'], - ] - ) - assert.deepEqual( + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['model:gpt-4.1', '150'], + ['model:claude-4-sonnet', '70'], + ]) + expect( result.filterOptions.nodes .filter((option) => option.kind === 'channel') - .map((option) => [option.value, option.valueLabel]), - [ - ['channel:101', '100'], - ['channel:102', '50'], - ] - ) + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['channel:101', '100'], + ['channel:102', '50'], + ]) }) test('combines user filters with faceted node filter options', () => { @@ -397,22 +375,20 @@ describe('dashboard flow data', () => { selectedNodes: [{ kind: 'channel', id: 'channel:101' }], }) - assert.equal(result.summary.quota, 100) - assert.deepEqual( + expect(result.summary.quota).toBe(100) + expect( result.filterOptions.nodes .filter((option) => option.kind === 'model') - .map((option) => [option.value, option.valueLabel]), - [['model:gpt-4.1', '100']] - ) - assert.deepEqual( + .map((option) => [option.value, option.valueLabel]) + ).toEqual([['model:gpt-4.1', '100']]) + expect( result.filterOptions.nodes .filter((option) => option.kind === 'channel') - .map((option) => [option.value, option.valueLabel]), - [ - ['channel:101', '100'], - ['channel:102', '50'], - ] - ) + .map((option) => [option.value, option.valueLabel]) + ).toEqual([ + ['channel:101', '100'], + ['channel:102', '50'], + ]) }) test('aggregates overflow nodes into per-column Other buckets', () => { @@ -434,18 +410,18 @@ describe('dashboard flow data', () => { .filter((link) => link.source.startsWith('user:')) .reduce((sum, link) => sum + link.value, 0) - assert.equal(result.summary.quota, 190) - assert.equal(firstStepTotal, 190) - assert.equal(otherUser?.label, 'Other user') - assert.equal(otherFirstStepLink?.value, 10) - assert.equal(nodeIds.has('user:3'), false) - assert.equal(nodeIds.has('group:free'), false) - assert.equal(nodeIds.has('model:model-c'), false) - assert.equal(nodeIds.has('channel:203'), false) - assert.equal(nodeIds.has('user:__other__'), true) - assert.equal(nodeIds.has('group:__other__'), true) - assert.equal(nodeIds.has('model:__other__'), true) - assert.equal(nodeIds.has('channel:__other__'), true) + expect(result.summary.quota).toBe(190) + expect(firstStepTotal).toBe(190) + expect(otherUser?.label).toBe('Other user') + expect(otherFirstStepLink?.value).toBe(10) + expect(nodeIds.has('user:3')).toBe(false) + expect(nodeIds.has('group:free')).toBe(false) + expect(nodeIds.has('model:model-c')).toBe(false) + expect(nodeIds.has('channel:203')).toBe(false) + expect(nodeIds.has('user:__other__')).toBe(true) + expect(nodeIds.has('group:__other__')).toBe(true) + expect(nodeIds.has('model:__other__')).toBe(true) + expect(nodeIds.has('channel:__other__')).toBe(true) }) test('hides overflow paths when overflow mode is hide', () => { @@ -460,11 +436,11 @@ describe('dashboard flow data', () => { .filter((link) => link.source.startsWith('user:')) .reduce((sum, link) => sum + link.value, 0) - assert.equal(result.summary.quota, 190) - assert.equal(firstStepTotal, 180) - assert.equal(nodeIds.has('user:3'), false) - assert.equal(nodeIds.has('user:__other__'), false) - assert.equal(nodeIds.has('model:__other__'), false) + expect(result.summary.quota).toBe(190) + expect(firstStepTotal).toBe(180) + expect(nodeIds.has('user:3')).toBe(false) + expect(nodeIds.has('user:__other__')).toBe(false) + expect(nodeIds.has('model:__other__')).toBe(false) }) test('ranks top nodes using the selected flow metric', () => { @@ -484,18 +460,11 @@ describe('dashboard flow data', () => { overflowMode: 'aggregate', }) - assert.equal( - byQuota.flow.nodes.some((node) => node.id === 'user:1'), - true - ) - assert.equal( - byRequests.flow.nodes.some((node) => node.id === 'user:2'), - true - ) - assert.equal( - byTokens.flow.nodes.some((node) => node.id === 'user:3'), + expect(byQuota.flow.nodes.some((node) => node.id === 'user:1')).toBe(true) + expect(byRequests.flow.nodes.some((node) => node.id === 'user:2')).toBe( true ) + expect(byTokens.flow.nodes.some((node) => node.id === 'user:3')).toBe(true) }) test('applies top limits only to visible stages', () => { @@ -507,19 +476,18 @@ describe('dashboard flow data', () => { }) const nodeIds = new Set(result.flow.nodes.map((node) => node.id)) - assert.equal(nodeIds.has('user:1'), true) - assert.equal(nodeIds.has('user:__other__'), true) - assert.equal(nodeIds.has('model:model-a'), true) - assert.equal(nodeIds.has('model:__other__'), true) - assert.equal(nodeIds.has('group:__other__'), false) - assert.equal(nodeIds.has('channel:__other__'), false) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['user:__other__', 'model:__other__', 90], - ['user:1', 'model:model-a', 100], - ] - ) + expect(nodeIds.has('user:1')).toBe(true) + expect(nodeIds.has('user:__other__')).toBe(true) + expect(nodeIds.has('model:model-a')).toBe(true) + expect(nodeIds.has('model:__other__')).toBe(true) + expect(nodeIds.has('group:__other__')).toBe(false) + expect(nodeIds.has('channel:__other__')).toBe(false) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['user:__other__', 'model:__other__', 90], + ['user:1', 'model:model-a', 100], + ]) }) test('applies top limits after node filters', () => { @@ -531,17 +499,16 @@ describe('dashboard flow data', () => { }) const nodeIds = new Set(result.flow.nodes.map((node) => node.id)) - assert.equal(result.summary.quota, 10) - assert.equal(nodeIds.has('model:model-c'), true) - assert.equal(nodeIds.has('model:__other__'), false) - assert.deepEqual( - result.flow.links.map((link) => [link.source, link.target, link.value]), - [ - ['group:free', 'model:model-c', 10], - ['model:model-c', 'channel:203', 10], - ['user:3', 'group:free', 10], - ] - ) + expect(result.summary.quota).toBe(10) + expect(nodeIds.has('model:model-c')).toBe(true) + expect(nodeIds.has('model:__other__')).toBe(false) + expect( + result.flow.links.map((link) => [link.source, link.target, link.value]) + ).toEqual([ + ['group:free', 'model:model-c', 10], + ['model:model-c', 'channel:203', 10], + ['user:3', 'group:free', 10], + ]) }) test('ignores selected node filters for hidden stages', () => { @@ -551,9 +518,8 @@ describe('dashboard flow data', () => { selectedNodes: [{ kind: 'group', id: 'group:vip' }], }) - assert.equal(result.summary.quota, 220) - assert.equal( - result.flow.nodes.some((node) => node.id === 'group:vip'), + expect(result.summary.quota).toBe(220) + expect(result.flow.nodes.some((node) => node.id === 'group:vip')).toBe( false ) }) @@ -576,35 +542,35 @@ describe('dashboard flow data', () => { ]) ) - assert.deepEqual(nodeState.get('user:1'), { + expect(nodeState.get('user:1')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('node:node-a'), { + expect(nodeState.get('node:node-a')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('model:gpt-4.1'), { + expect(nodeState.get('model:gpt-4.1')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('channel:101'), { + expect(nodeState.get('channel:101')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('user:2'), { + expect(nodeState.get('user:2')).toEqual({ highlighted: false, dimmed: true, }) - assert.deepEqual(linkState.get('user:1->node:node-a'), { + expect(linkState.get('user:1->node:node-a')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), { + expect(linkState.get('model:gpt-4.1->channel:101')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(linkState.get('model:claude-4-sonnet->channel:101'), { + expect(linkState.get('model:claude-4-sonnet->channel:101')).toEqual({ highlighted: false, dimmed: true, }) @@ -628,23 +594,23 @@ describe('dashboard flow data', () => { ]) ) - assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), { + expect(linkState.get('model:gpt-4.1->channel:101')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(linkState.get('model:gpt-4.1->channel:102'), { + expect(linkState.get('model:gpt-4.1->channel:102')).toEqual({ highlighted: false, dimmed: true, }) - assert.deepEqual(nodeState.get('user:1'), { + expect(nodeState.get('user:1')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('node:node-a'), { + expect(nodeState.get('node:node-a')).toEqual({ highlighted: true, dimmed: false, }) - assert.deepEqual(nodeState.get('user:2'), { + expect(nodeState.get('user:2')).toEqual({ highlighted: false, dimmed: true, }) @@ -686,11 +652,11 @@ describe('dashboard flow data', () => { (link) => link.source === 'user:2' && link.target === 'group:vip' ) - assert.equal(sharedLink?.value, 150) - assert.equal(sharedLink?.highlighted, true) - assert.equal(sharedLink?.dimmed, false) - assert.equal(inactiveUserLink?.highlighted, false) - assert.equal(inactiveUserLink?.dimmed, true) + expect(sharedLink?.value).toBe(150) + expect(sharedLink?.highlighted).toBe(true) + expect(sharedLink?.dimmed).toBe(false) + expect(inactiveUserLink?.highlighted).toBe(false) + expect(inactiveUserLink?.dimmed).toBe(true) }) test('does not emit highlight states without a visible active node', () => { @@ -703,30 +669,26 @@ describe('dashboard flow data', () => { activeNode: { kind: 'user', id: 'user:1' }, }) - assert.equal( + expect( withoutActive.flow.nodes.every( (node) => node.highlighted === undefined && node.dimmed === undefined - ), - true - ) - assert.equal( + ) + ).toBe(true) + expect( withoutActive.flow.links.every( (link) => link.highlighted === undefined && link.dimmed === undefined - ), - true - ) - assert.equal( + ) + ).toBe(true) + expect( hiddenActive.flow.nodes.every( (node) => node.highlighted === undefined && node.dimmed === undefined - ), - true - ) - assert.equal( + ) + ).toBe(true) + expect( hiddenActive.flow.links.every( (link) => link.highlighted === undefined && link.dimmed === undefined - ), - true - ) + ) + ).toBe(true) }) test('builds Sankey spec with quota token request tooltips', () => { @@ -743,19 +705,19 @@ describe('dashboard flow data', () => { link.source === 'user:1' && link.target === 'node:node-a' ) - assert.equal(flowSpec.type, 'sankey') - assert.equal(flowSpec.title.text, 'Flow') - assert.deepEqual(flowSpec.emphasis, { enable: false }) - assert.equal(flowSpec.tooltip.mark.visible({ datum: aliceNode }), true) - assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true) - assert.equal(flowSpec.animation, false) - assert.equal(values.nodes.length, 6) - assert.equal(values.links.length, 5) - assert.equal(aliceNode.name, 'alice') - assert.match(userNodeLink.linkColor, /^rgba\(/) + expect(flowSpec.type).toBe('sankey') + expect(flowSpec.title.text).toBe('Flow') + expect(flowSpec.emphasis).toEqual({ enable: false }) + expect(flowSpec.tooltip.mark.visible({ datum: aliceNode })).toBe(true) + expect(flowSpec.tooltip.mark.visible({ datum: userNodeLink })).toBe(true) + expect(flowSpec.animation).toBe(false) + expect(values.nodes.length).toBe(6) + expect(values.links.length).toBe(5) + expect(aliceNode.name).toBe('alice') + expect(userNodeLink.linkColor).toMatch(/^rgba\(/) const tooltipRows = flowSpec.tooltip.mark.content - assert.deepEqual( + expect( tooltipRows .filter((row: Record) => typeof row.visible === 'function' @@ -767,14 +729,13 @@ describe('dashboard flow data', () => { typeof row.value === 'function' ? row.value({ datum: userNodeLink }) : row.value, - ]), - [ - ['Quota', '100'], - ['Tokens', '40'], - ['Requests', '2'], - ['Share', '100.0%'], - ] - ) + ]) + ).toEqual([ + ['Quota', '100'], + ['Tokens', '40'], + ['Requests', '2'], + ['Share', '100.0%'], + ]) }) test('maps active flow highlight states into the Sankey spec', () => { @@ -801,15 +762,15 @@ describe('dashboard flow data', () => { const nodeOpacity = flowSpec.node.style.fillOpacity const linkOpacity = flowSpec.link.style.fillOpacity - assert.deepEqual(flowSpec.emphasis, { enable: false }) - assert.equal(aliceNode.highlighted, true) - assert.equal(bobNode.dimmed, true) - assert.equal(highlightedLink.highlighted, true) - assert.equal(dimmedLink.dimmed, true) - assert.equal(nodeOpacity(aliceNode), 1) - assert.equal(nodeOpacity(bobNode), 0.18) - assert.equal(linkOpacity(highlightedLink), 0.86) - assert.equal(linkOpacity(dimmedLink), 0.08) - assert.equal(highlightedLink.zIndex > dimmedLink.zIndex, true) + expect(flowSpec.emphasis).toEqual({ enable: false }) + expect(aliceNode.highlighted).toBe(true) + expect(bobNode.dimmed).toBe(true) + expect(highlightedLink.highlighted).toBe(true) + expect(dimmedLink.dimmed).toBe(true) + expect(nodeOpacity(aliceNode)).toBe(1) + expect(nodeOpacity(bobNode)).toBe(0.18) + expect(linkOpacity(highlightedLink)).toBe(0.86) + expect(linkOpacity(dimmedLink)).toBe(0.08) + expect(highlightedLink.zIndex > dimmedLink.zIndex).toBe(true) }) }) diff --git a/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx index 5cb64ae5..2f07baa2 100644 --- a/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx +++ b/web/src/features/keys/components/__tests__/api-key-group-cell.test.tsx @@ -16,39 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' +import { render } from '@testing-library/react' +import { describe, expect, test } from 'vitest' -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLButtonElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'CustomEvent', - 'MutationObserver', - 'ResizeObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') const { createInstance } = await import('i18next') const { I18nextProvider, initReactI18next } = await import('react-i18next') const { TooltipProvider } = await import('@/components/ui/tooltip') @@ -70,11 +40,6 @@ await i18n.use(initReactI18next).init({ }, }) -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - function CellHarness(props: { group: string ratio?: number | string @@ -96,141 +61,93 @@ function CellHarness(props: { } describe('API key group table cell', () => { - after(() => { - domWindow.close() - }) - - test('renders two unclipped rings and a localized Auto ratio when API data uses a nonlocalized string', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => - root.render( - - ) + test('renders an unclipped ring and a localized Auto ratio when API data uses a nonlocalized string', () => { + const { container } = render( + ) const badgeCell = container.querySelector( '[data-api-key-group-cell="auto"]' ) - assert.ok(badgeCell) - assert.equal(badgeCell.classList.contains('overflow-visible'), true) - assert.equal(badgeCell.classList.contains('overflow-hidden'), false) + expect(badgeCell).toHaveClass('overflow-visible') + expect(badgeCell).not.toHaveClass('overflow-hidden') const frames = container.querySelectorAll('[data-auto-group-frame]') const movingRings = container.querySelectorAll( '[data-auto-group-flow-border]' ) - assert.equal(frames.length, 2) - assert.equal(movingRings.length, 2) + expect(frames.length).toBe(1) + expect(movingRings.length).toBe(1) for (const frame of frames) { - assert.equal(frame.classList.contains('relative'), true) - assert.equal(frame.classList.contains('overflow-visible'), true) - assert.equal(frame.classList.contains('rounded-4xl'), true) - assert.equal(frame.classList.contains('p-px'), true) + expect(frame).toHaveClass( + 'relative', + 'overflow-visible', + 'rounded-4xl', + 'p-px' + ) } const ratio = container.querySelector( '[data-auto-group-effect="ratio"]' ) - assert.ok(ratio) - assert.equal(ratio.textContent, 'Auto Ratio') - assert.equal(ratio.textContent?.includes('x'), false) - assert.equal(container.textContent?.includes('自动'), false) - assert.equal(container.textContent?.includes('Cross-group'), true) + expect(ratio).toHaveTextContent('Auto Ratio') + expect(ratio).not.toHaveTextContent('x') + expect(container).not.toHaveTextContent('自动') + expect(container).toHaveTextContent('Cross-group') const crossGroupBadge = [ ...container.querySelectorAll('[data-slot="status-badge"]'), ].find((badge) => badge.textContent === 'Cross-group') - assert.ok(crossGroupBadge) - assert.equal(crossGroupBadge.closest('[data-auto-group-frame]'), null) - - await act(async () => root.unmount()) - container.remove() + expect(crossGroupBadge).not.toBeUndefined() + expect(crossGroupBadge?.closest('[data-auto-group-frame]')).toBeNull() }) - test('keeps static Auto frames but omits both moving layers for reduced motion', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => - root.render() + test('keeps the static Auto ratio frame but omits its moving layer for reduced motion', () => { + const { container } = render( + ) - assert.equal( - container.querySelectorAll('[data-auto-group-frame]').length, - 2 - ) - assert.equal( - container.querySelectorAll('[data-auto-group-flow-border]').length, - 0 - ) - - await act(async () => root.unmount()) - container.remove() + expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(1) + expect( + container.querySelectorAll('[data-auto-group-flow-border]').length + ).toBe(0) }) - test('shows only the Auto badge when ratio data is unavailable', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => - root.render() + test('shows only the cross-group badge when ratio data is unavailable', () => { + const { container } = render( + ) - assert.equal( - container.querySelectorAll('[data-auto-group-frame]').length, - 1 - ) - assert.equal( - container.querySelectorAll('[data-auto-group-flow-border]').length, - 1 - ) - assert.equal( - container.querySelector('[data-auto-group-effect="ratio"]'), + expect(container.querySelectorAll('[data-auto-group-frame]').length).toBe(0) + expect( + container.querySelectorAll('[data-auto-group-flow-border]').length + ).toBe(0) + expect(container.querySelector('[data-auto-group-effect="ratio"]')).toBe( null ) - assert.equal(container.textContent?.includes('Auto'), true) - assert.equal(container.textContent?.includes('Ratio'), false) - - await act(async () => root.unmount()) - container.remove() + expect(container).toHaveTextContent('Cross-group') + expect(container).not.toHaveTextContent('Auto') + expect(container).not.toHaveTextContent('Ratio') }) - test('narrows normal group ratios to numbers and never applies Auto rings', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => - root.render( - - ) + test('narrows normal group ratios to numbers and never applies Auto rings', () => { + const { container, rerender } = render( + ) - assert.equal(container.textContent?.includes('vip'), true) - assert.equal(container.textContent?.includes('自动'), false) - assert.equal(container.querySelector('[data-auto-group-frame]'), null) - assert.equal(container.querySelector('[data-auto-group-flow-border]'), null) + expect(container).toHaveTextContent('vip') + expect(container).not.toHaveTextContent('自动') + expect(container.querySelector('[data-auto-group-frame]')).toBe(null) + expect(container.querySelector('[data-auto-group-flow-border]')).toBe(null) - await act(async () => - root.render( - - ) - ) + rerender() - assert.equal(container.textContent?.includes('3x'), true) - assert.equal(container.querySelector('[data-auto-group-frame]'), null) - - await act(async () => root.unmount()) - container.remove() + expect(container).toHaveTextContent('3x') + expect(container.querySelector('[data-auto-group-frame]')).toBe(null) }) }) diff --git a/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx index 5c3b6525..7833c52c 100644 --- a/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx +++ b/web/src/features/keys/components/__tests__/api-key-group-combobox.test.tsx @@ -16,58 +16,26 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' - -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLButtonElement', - 'HTMLInputElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'KeyboardEvent', - 'PointerEvent', - 'CustomEvent', - 'MutationObserver', - 'ResizeObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} +import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, expect, test } from 'vitest' let shouldReduceMotion = false -const reducedMotionMediaQuery = domWindow.matchMedia('(prefers-reduced-motion)') +const reducedMotionMediaQuery = window.matchMedia('(prefers-reduced-motion)') Object.defineProperty(reducedMotionMediaQuery, 'matches', { configurable: true, get: () => shouldReduceMotion, }) -Object.defineProperty(domWindow, 'matchMedia', { +Object.defineProperty(window, 'matchMedia', { configurable: true, value: () => reducedMotionMediaQuery, }) function setReducedMotion(value: boolean) { shouldReduceMotion = value - reducedMotionMediaQuery.dispatchEvent(new domWindow.Event('change')) + reducedMotionMediaQuery.dispatchEvent(new Event('change')) } -const { act, useState } = await import('react') -const { createRoot } = await import('react-dom/client') +const { useState } = await import('react') const { createInstance } = await import('i18next') const { I18nextProvider, initReactI18next } = await import('react-i18next') const { ApiKeyGroupCombobox } = await import('../api-key-group-combobox') @@ -88,11 +56,6 @@ await i18n.use(initReactI18next).init({ }, }) -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - const options = [ { value: 'auto', @@ -119,176 +82,133 @@ function Harness(props: { initialValue: string }) { ) } -function getTrigger(container: ParentNode): HTMLButtonElement { - const trigger = container.querySelector( - 'button[role="combobox"]' - ) - assert.ok(trigger) - return trigger +function getTrigger(): HTMLButtonElement { + return screen.getByRole('combobox') } function getCommandItem(label: string): HTMLElement { const item = [ ...document.querySelectorAll('[data-slot="command-item"]'), ].find((candidate) => candidate.textContent?.includes(label)) - assert.ok(item) + if (!item) { + throw new Error(`Expected command item containing "${label}"`) + } return item } describe('API key group combobox Auto effect', () => { - after(() => { - domWindow.close() - }) - - test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', async () => { + test('rings the selected Auto trigger and its localized ratio without rendering the API ratio text', () => { setReducedMotion(false) - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + render() - await act(async () => root.render()) - - const trigger = getTrigger(container) - assert.equal(trigger.getAttribute('aria-expanded'), 'false') - assert.equal(trigger.dataset.autoGroupEffect, 'trigger') - assert.equal(trigger.classList.contains('bg-linear-to-r'), false) - assert.equal(trigger.classList.contains('overflow-hidden'), false) - assert.equal(trigger.classList.contains('overflow-visible'), true) + const trigger = getTrigger() + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger') + expect(trigger).not.toHaveClass('bg-linear-to-r', 'overflow-hidden') + expect(trigger).toHaveClass('overflow-visible') const triggerFlowBorder = trigger.querySelector( '[data-auto-group-flow-border]' ) - assert.ok(triggerFlowBorder) - assert.equal(triggerFlowBorder.getAttribute('aria-hidden'), 'true') - assert.equal( - triggerFlowBorder.classList.contains('pointer-events-none'), - true - ) - assert.equal( - triggerFlowBorder.classList.contains('auto-group-flow-border'), - true + expect(triggerFlowBorder).toHaveAttribute('aria-hidden', 'true') + expect(triggerFlowBorder).toHaveClass( + 'pointer-events-none', + 'auto-group-flow-border' ) const triggerRatio = trigger.querySelector( '[data-auto-group-effect="ratio"]' ) - assert.ok(triggerRatio) - assert.equal(triggerRatio.textContent, 'Auto Ratio') - assert.equal(triggerRatio.textContent?.includes('Auto'), true) - assert.equal(triggerRatio.textContent?.includes('x'), false) - assert.equal(trigger.textContent?.includes('自动'), false) - assert.equal(triggerRatio.classList.contains('relative'), true) - assert.equal(triggerRatio.classList.contains('overflow-visible'), true) - assert.equal(triggerRatio.classList.contains('rounded-4xl'), true) - assert.ok(triggerRatio.querySelector('[data-auto-group-flow-border]')) + expect(triggerRatio).toHaveTextContent('Auto Ratio') + expect(triggerRatio).not.toHaveTextContent('x') + expect(trigger).not.toHaveTextContent('自动') + expect(triggerRatio).toHaveClass( + 'relative', + 'overflow-visible', + 'rounded-4xl' + ) + expect( + triggerRatio?.querySelector('[data-auto-group-flow-border]') + ).toBeInTheDocument() - await act(async () => trigger.click()) - assert.equal(trigger.getAttribute('aria-expanded'), 'true') + fireEvent.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'true') const autoOption = getCommandItem('Global automatic routing') - assert.equal(autoOption.dataset.autoGroupEffect, 'option') - assert.equal(autoOption.getAttribute('aria-selected'), 'true') - assert.equal(autoOption.classList.contains('bg-linear-to-r'), false) - assert.equal(autoOption.classList.contains('overflow-visible'), true) - assert.ok(autoOption.querySelector('[data-auto-group-flow-border]')) + expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option') + expect(autoOption).toHaveAttribute('aria-selected', 'true') + expect(autoOption).not.toHaveClass('bg-linear-to-r') + expect(autoOption).toHaveClass('overflow-visible') + expect( + autoOption.querySelector('[data-auto-group-flow-border]') + ).toBeInTheDocument() const optionRatio = autoOption.querySelector( '[data-auto-group-effect="ratio"]' ) - assert.ok(optionRatio) - assert.equal(optionRatio.textContent, 'Auto Ratio') - assert.ok(optionRatio.querySelector('[data-auto-group-flow-border]')) + expect(optionRatio).toHaveTextContent('Auto Ratio') + expect( + optionRatio?.querySelector('[data-auto-group-flow-border]') + ).toBeInTheDocument() const defaultOption = getCommandItem('User group') - assert.equal(defaultOption.hasAttribute('data-auto-group-effect'), false) - assert.equal( - defaultOption.querySelector('[data-auto-group-flow-border]'), + expect(defaultOption).not.toHaveAttribute('data-auto-group-effect') + expect(defaultOption.querySelector('[data-auto-group-flow-border]')).toBe( null ) - assert.equal(defaultOption.textContent?.includes('1x Ratio'), true) - assert.equal( - defaultOption.querySelector('[data-auto-group-effect="ratio"]'), - null - ) - - await act(async () => root.unmount()) - container.remove() + expect(defaultOption).toHaveTextContent('1x Ratio') + expect( + defaultOption.querySelector('[data-auto-group-effect="ratio"]') + ).toBe(null) }) test('keeps search and selection behavior while leaving normal groups unstyled', async () => { setReducedMotion(false) - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + const { container } = render() - await act(async () => root.render()) + const trigger = getTrigger() + fireEvent.click(trigger) - const trigger = getTrigger(container) - await act(async () => trigger.click()) - - const searchInput = document.querySelector( - 'input[placeholder="Search..."]' - ) - assert.ok(searchInput) - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - domWindow.HTMLInputElement.prototype, - 'value' - )?.set - assert.ok(valueSetter) - valueSetter.call(searchInput, 'vip') - searchInput.dispatchEvent( - new domWindow.Event('input', { bubbles: true }) as unknown as Event - ) + fireEvent.input(screen.getByPlaceholderText('Search...'), { + target: { value: 'vip' }, }) const visibleOptions = [ ...document.querySelectorAll('[data-slot="command-item"]'), ] - assert.equal( + expect( visibleOptions.some((option) => option.textContent?.includes('Global automatic routing') - ), - false - ) + ) + ).toBe(false) const vipOption = getCommandItem('Priority group') - await act(async () => vipOption.click()) + fireEvent.click(vipOption) - assert.equal( - container.querySelector('[data-testid="selected-group"]')?.textContent, + expect(within(container).getByTestId('selected-group')).toHaveTextContent( 'vip' ) - assert.equal(trigger.getAttribute('aria-expanded'), 'false') - assert.equal(trigger.hasAttribute('data-auto-group-effect'), false) - assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null) - - await act(async () => root.unmount()) - container.remove() + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(trigger).not.toHaveAttribute('data-auto-group-effect') + expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null) }) test('preserves the static Auto treatment but omits moving layers for reduced motion', async () => { setReducedMotion(true) - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + render() - await act(async () => root.render()) + const trigger = getTrigger() + expect(trigger).toHaveAttribute('data-auto-group-effect', 'trigger') + expect(trigger.querySelector('[data-auto-group-flow-border]')).toBe(null) + expect( + trigger.querySelector('[data-auto-group-effect="ratio"]') + ).toBeInTheDocument() - const trigger = getTrigger(container) - assert.equal(trigger.dataset.autoGroupEffect, 'trigger') - assert.equal(trigger.querySelector('[data-auto-group-flow-border]'), null) - assert.ok(trigger.querySelector('[data-auto-group-effect="ratio"]')) - - await act(async () => trigger.click()) + fireEvent.click(trigger) const autoOption = getCommandItem('Global automatic routing') - assert.equal(autoOption.dataset.autoGroupEffect, 'option') - assert.equal( - autoOption.querySelector('[data-auto-group-flow-border]'), - null - ) - assert.ok(autoOption.querySelector('[data-auto-group-effect="ratio"]')) - - await act(async () => root.unmount()) - container.remove() + expect(autoOption).toHaveAttribute('data-auto-group-effect', 'option') + expect(autoOption.querySelector('[data-auto-group-flow-border]')).toBe(null) + expect( + autoOption.querySelector('[data-auto-group-effect="ratio"]') + ).toBeInTheDocument() setReducedMotion(false) }) }) diff --git a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx index 238d0b21..e57fcbfa 100644 --- a/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx +++ b/web/src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx @@ -16,45 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, afterEach, describe, test } from 'node:test' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, test } from 'vitest' -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLButtonElement', - 'HTMLInputElement', - 'HTMLFormElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'KeyboardEvent', - 'PointerEvent', - 'MouseEvent', - 'FocusEvent', - 'CustomEvent', - 'MutationObserver', - 'ResizeObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') const { createInstance } = await import('i18next') const { I18nextProvider, initReactI18next } = await import('react-i18next') const { QueryClient, QueryClientProvider } = @@ -69,20 +33,13 @@ await i18n.use(initReactI18next).init({ resources: { en: { translation: {} } }, }) -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }> type MockableApi = { get: ApiMethod post: ApiMethod } type RenderedDrawer = { - host: HTMLDivElement queryClient: InstanceType - root: ReturnType } const apiClient = api as unknown as MockableApi @@ -120,44 +77,14 @@ function installApiFixtures(createdPayloads: Array>) { } } apiClient.post = async (url, data) => { - assert.equal(url, '/api/token/') - assert.ok(data && typeof data === 'object') + expect(url).toBe('/api/token/') + expect(data && typeof data === 'object').toBeTruthy() createdPayloads.push(data as Record) return { data: { success: true, data: {} } } } } -async function waitForCondition( - condition: () => boolean, - failureMessage: string -): Promise { - if (condition()) return - - await new Promise((resolve, reject) => { - const observer = new MutationObserver(() => { - if (!condition()) return - clearTimeout(timeoutId) - observer.disconnect() - resolve() - }) - const timeoutId = setTimeout(() => { - observer.disconnect() - reject(new Error(`${failureMessage}: ${document.body.textContent}`)) - }, 1500) - - observer.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true, - }) - }) -} - async function renderCreateDrawer(): Promise { - const host = document.createElement('div') - document.body.append(host) - const root = createRoot(host) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) @@ -192,43 +119,49 @@ async function renderCreateDrawer(): Promise { }, { updatedAt: freshAt } ) - renderedDrawer = { host, queryClient, root } + renderedDrawer = { queryClient } - await act(async () => - root.render( - - - - undefined} /> - - - - ) + render( + + + + undefined} /> + + + ) - await act(async () => - waitForCondition(() => { + await waitFor( + () => { const saveButton = findButton('Save changes', false) - return saveButton !== null && !saveButton.disabled - }, 'API key drawer did not finish initializing') + expect(saveButton).toBeEnabled() + }, + { timeout: 1500 } ) } function findButton(text: string, required: true): HTMLButtonElement function findButton(text: string, required: false): HTMLButtonElement | null function findButton(text: string, required = true): HTMLButtonElement | null { - const button = [ - ...document.querySelectorAll('button'), - ].find((candidate) => candidate.textContent?.includes(text)) - if (required) assert.ok(button, `Expected button containing "${text}"`) + const button = screen + .queryAllByRole('button') + .find((candidate) => candidate.textContent?.includes(text)) + if (required && !button) { + throw new Error(`Expected button containing "${text}"`) + } return button ?? null } -function getControlByLabel(labelText: string): T { +function getControlByLabel(labelText: 'Name' | 'Quantity'): HTMLInputElement +function getControlByLabel(labelText: 'Group'): HTMLButtonElement +function getControlByLabel(labelText: 'Auto group order'): HTMLElement +function getControlByLabel(labelText: string): HTMLElement { const label = [...document.querySelectorAll('label')].find( (candidate) => candidate.textContent?.trim() === labelText ) - assert.ok(label, `Expected label "${labelText}"`) - assert.ok(label.htmlFor) + if (!label) { + throw new Error(`Expected label "${labelText}"`) + } + const control = label.control ?? label @@ -236,51 +169,38 @@ function getControlByLabel(labelText: string): T { ?.querySelector( '[data-slot="form-control"], input, textarea, button[role="combobox"], [role="group"]' ) - assert.ok(control) - return control as T + if (!control) { + throw new Error(`Expected control for label "${labelText}"`) + } + return control } -async function changeInput(input: HTMLInputElement, value: string) { - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - domWindow.HTMLInputElement.prototype, - 'value' - )?.set - assert.ok(valueSetter) - valueSetter.call(input, value) - input.dispatchEvent( - new domWindow.Event('input', { bubbles: true }) as unknown as Event - ) - }) +function changeInput(input: HTMLInputElement, value: string): void { + fireEvent.input(input, { target: { value } }) } -async function selectComboboxOption( +function selectComboboxOption( trigger: HTMLButtonElement, optionDescription: string -) { - await act(async () => trigger.click()) +): void { + fireEvent.click(trigger) const option = [ ...document.querySelectorAll('[data-slot="command-item"]'), ].find((candidate) => candidate.textContent?.includes(optionDescription)) - assert.ok(option, `Expected option containing "${optionDescription}"`) - await act(async () => option.click()) + if (!option) { + throw new Error(`Expected option containing "${optionDescription}"`) + } + fireEvent.click(option) } -afterEach(async () => { +afterEach(() => { apiClient.get = originalGet apiClient.post = originalPost - domWindow.localStorage.clear() + localStorage.clear() if (renderedDrawer) { - await act(async () => renderedDrawer?.root.unmount()) renderedDrawer.queryClient.clear() - renderedDrawer.host.remove() renderedDrawer = null } - document.body.replaceChildren() -}) - -after(() => { - domWindow.close() }) describe('API keys mutate drawer Auto group integration', () => { @@ -289,38 +209,31 @@ describe('API keys mutate drawer Auto group integration', () => { installApiFixtures(createdPayloads) await renderCreateDrawer() - const groupTrigger = getControlByLabel('Group') - assert.equal(groupTrigger.textContent?.includes('auto'), true) - assert.equal( + const groupTrigger = getControlByLabel('Group') + expect(groupTrigger.textContent?.includes('auto')).toBe(true) + expect( document.body.textContent?.includes( 'Using the complete global Auto order (2 groups)' - ), - true - ) - assert.deepEqual( + ) + ).toBe(true) + expect( [ ...document.querySelectorAll('[data-slot="global-auto-order-name"]'), - ].map((item) => item.textContent), - ['vip', 'default'] - ) - assert.equal(findButton('Restore global Auto', true).disabled, true) + ].map((item) => item.textContent) + ).toEqual(['vip', 'default']) + expect(findButton('Restore global Auto', true).disabled).toBe(true) - await changeInput(getControlByLabel('Name'), 'batch') - await changeInput(getControlByLabel('Quantity'), '2') - await act(async () => findButton('Save changes', true).click()) - await act(async () => - waitForCondition( - () => createdPayloads.length === 2, - 'batch API keys were not created' - ) - ) + changeInput(getControlByLabel('Name'), 'batch') + changeInput(getControlByLabel('Quantity'), '2') + fireEvent.click(findButton('Save changes', true)) + await waitFor(() => expect(createdPayloads).toHaveLength(2)) - assert.equal(createdPayloads.length, 2) - assert.equal(createdPayloads[0]?.name, 'batch') + expect(createdPayloads.length).toBe(2) + expect(createdPayloads[0]?.name).toBe('batch') for (const payload of createdPayloads) { - assert.equal(payload.group, 'auto') - assert.deepEqual(payload.auto_groups, []) - assert.equal(payload.cross_group_retry, true) + expect(payload.group).toBe('auto') + expect(payload.auto_groups).toEqual([]) + expect(payload.cross_group_retry).toBe(true) } }) @@ -329,43 +242,39 @@ describe('API keys mutate drawer Auto group integration', () => { installApiFixtures(createdPayloads) await renderCreateDrawer() - const autoOrderControl = getControlByLabel('Auto group order') + const autoOrderControl = getControlByLabel('Auto group order') const addGroupTrigger = autoOrderControl.querySelector( 'button[role="combobox"]' ) - assert.ok(addGroupTrigger) - await selectComboboxOption(addGroupTrigger, 'Priority access') + if (!addGroupTrigger) { + throw new Error('Expected Auto group order combobox') + } + selectComboboxOption(addGroupTrigger, 'Priority access') - assert.ok(document.querySelector('button[aria-label="Remove vip"]')) - assert.equal( - document.body.textContent?.includes('1 / 3 groups selected'), + expect( + document.querySelector('button[aria-label="Remove vip"]') + ).toBeTruthy() + expect(document.body.textContent?.includes('1 / 3 groups selected')).toBe( true ) - assert.equal(findButton('Restore global Auto', true).disabled, false) + expect(findButton('Restore global Auto', true).disabled).toBe(false) - const groupTrigger = getControlByLabel('Group') - await selectComboboxOption(groupTrigger, 'Standard access') - assert.equal( - document.querySelector('button[aria-label="Remove vip"]'), - null - ) - await selectComboboxOption(groupTrigger, 'Automatic routing') + const groupTrigger = getControlByLabel('Group') + selectComboboxOption(groupTrigger, 'Standard access') + expect(document.querySelector('button[aria-label="Remove vip"]')).toBe(null) + selectComboboxOption(groupTrigger, 'Automatic routing') - assert.ok(document.querySelector('button[aria-label="Remove vip"]')) - assert.equal( - document.body.textContent?.includes('1 / 3 groups selected'), + expect( + document.querySelector('button[aria-label="Remove vip"]') + ).toBeTruthy() + expect(document.body.textContent?.includes('1 / 3 groups selected')).toBe( true ) - assert.equal(findButton('Restore global Auto', true).disabled, false) + expect(findButton('Restore global Auto', true).disabled).toBe(false) - await changeInput(getControlByLabel('Name'), 'custom') - await act(async () => findButton('Save changes', true).click()) - await act(async () => - waitForCondition( - () => createdPayloads.length === 1, - 'custom-order API key was not created' - ) - ) - assert.deepEqual(createdPayloads[0]?.auto_groups, ['vip']) + changeInput(getControlByLabel('Name'), 'custom') + fireEvent.click(findButton('Save changes', true)) + await waitFor(() => expect(createdPayloads).toHaveLength(1)) + expect(createdPayloads[0]?.auto_groups).toEqual(['vip']) }) }) diff --git a/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx index f37f5075..3df0d191 100644 --- a/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx +++ b/web/src/features/keys/components/__tests__/auto-group-order-editor.test.tsx @@ -16,42 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' +import { fireEvent, render, within } from '@testing-library/react' +import { describe, expect, test } from 'vitest' -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLButtonElement', - 'HTMLInputElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'KeyboardEvent', - 'PointerEvent', - 'CustomEvent', - 'MutationObserver', - 'ResizeObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act, useState } = await import('react') -const { createRoot } = await import('react-dom/client') +const { useState } = await import('react') const { createInstance } = await import('i18next') const { I18nextProvider, initReactI18next } = await import('react-i18next') const { AutoGroupOrderEditor } = await import('../auto-group-order-editor') @@ -90,11 +58,6 @@ await i18n.use(initReactI18next).init({ }, }) -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - const globalOptions = [ { value: 'vip', label: 'VIP', desc: 'Priority access', ratio: 3 }, { value: 'default', label: 'Default', desc: 'Standard access', ratio: 1 }, @@ -176,163 +139,104 @@ function CustomEmptyHarness() { ) } -function findButton(container: ParentNode, label: string): HTMLButtonElement { - const button = container.querySelector( - `button[aria-label="${label}"]` - ) - assert.ok(button) - return button +function findButton(container: HTMLElement, label: string): HTMLButtonElement { + return within(container).getByRole('button', { name: label }) +} + +function getCommandItem(label: string): HTMLElement { + const item = [ + ...document.querySelectorAll('[data-slot="command-item"]'), + ].find((candidate) => candidate.textContent?.includes(label)) + if (!item) { + throw new Error(`Expected command item containing "${label}"`) + } + return item } describe('Auto group order editor', () => { - after(() => { - domWindow.close() - }) + test('enforces the limit and exposes accessible reorder controls', () => { + const { container } = render() - test('enforces the limit and exposes accessible reorder controls', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + const addButton = within(container).getByRole('combobox') + expect(addButton).toBeDisabled() + expect(container).toHaveTextContent('2 / 2 groups selected') + expect( + within(container).getByRole('group', { name: 'Auto group order' }) + ).toBeInTheDocument() + expect(findButton(container, 'Drag default to reorder').type).toBe('button') - await act(async () => root.render()) - - const addButton = container.querySelector( - 'button[role="combobox"]' - ) - assert.ok(addButton) - assert.equal(addButton.disabled, true) - assert.equal(container.textContent?.includes('2 / 2 groups selected'), true) - assert.ok( - container.querySelector('[role="group"][aria-label="Auto group order"]') - ) - assert.equal( - findButton(container, 'Drag default to reorder').type, - 'button' - ) - - await act(async () => findButton(container, 'Move default down').click()) - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, + fireEvent.click(findButton(container, 'Move default down')) + expect(within(container).getByTestId('order')).toHaveTextContent( 'vip,default' ) - await act(async () => { - findButton(container, 'Drag vip to reorder').dispatchEvent( - new domWindow.KeyboardEvent('keydown', { - key: 'ArrowDown', - bubbles: true, - }) as unknown as KeyboardEvent - ) + fireEvent.keyDown(findButton(container, 'Drag vip to reorder'), { + key: 'ArrowDown', }) - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, + expect(within(container).getByTestId('order')).toHaveTextContent( 'default,vip' ) - - await act(async () => root.unmount()) - container.remove() }) - test('adds and removes groups, then restores inheritance as an empty value', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('adds and removes groups, then restores inheritance as an empty value', () => { + const { container } = render() + fireEvent.click(findButton(container, 'Remove vip')) - await act(async () => root.render()) - await act(async () => findButton(container, 'Remove vip').click()) + expect(within(container).getByTestId('order')).toHaveTextContent('default') + const addButton = within(container).getByRole('combobox') + expect(addButton).toBeEnabled() - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, - 'default' - ) - const addButton = container.querySelector( - 'button[role="combobox"]' - ) - assert.ok(addButton) - assert.equal(addButton.disabled, false) - - await act(async () => addButton.click()) - const teamOption = [ - ...document.querySelectorAll('[data-slot="command-item"]'), - ].find((option) => option.textContent?.includes('team')) - assert.ok(teamOption) - await act(async () => teamOption.click()) - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, + fireEvent.click(addButton) + fireEvent.click(getCommandItem('team')) + expect(within(container).getByTestId('order')).toHaveTextContent( 'default,team' ) - assert.equal(addButton.disabled, true) + expect(addButton).toBeDisabled() - const restoreButton = [...container.querySelectorAll('button')].find( - (button) => button.textContent?.includes('Restore global Auto') + fireEvent.click( + within(container).getByRole('button', { name: 'Restore global Auto' }) ) - assert.ok(restoreButton) - await act(async () => restoreButton.click()) - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, - '' - ) - assert.equal( - container.querySelector('[data-testid="mode"]')?.textContent, - 'inherit' - ) - assert.equal( - container.textContent?.includes( - 'Using the complete global Auto order (3 groups)' - ), - true + expect(within(container).getByTestId('order')).toBeEmptyDOMElement() + expect(within(container).getByTestId('mode')).toHaveTextContent('inherit') + expect(container).toHaveTextContent( + 'Using the complete global Auto order (3 groups)' ) const inheritedItems = container.querySelectorAll( '[data-slot="global-auto-order"] > li' ) - assert.deepEqual( + expect( [...inheritedItems].map( (item) => item.querySelector('[data-slot="global-auto-order-name"]') ?.textContent - ), - ['VIP', 'Default', 'Team'] - ) - - await act(async () => root.unmount()) - container.remove() + ) + ).toEqual(['VIP', 'Default', 'Team']) }) - test('shows the complete inherited order with metadata beyond the custom limit', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('shows the complete inherited order with metadata beyond the custom limit', () => { + const { container } = render() - await act(async () => root.render()) - - assert.equal( - container.textContent?.includes( - 'Using the complete global Auto order (3 groups)' - ), - true - ) - assert.equal( - container.textContent?.includes('0 / 2 groups selected'), - false + expect(container).toHaveTextContent( + 'Using the complete global Auto order (3 groups)' ) + expect(container).not.toHaveTextContent('0 / 2 groups selected') const order = container.querySelector( '[data-slot="global-auto-order"]' ) - assert.ok(order) - assert.equal(order.classList.contains('overflow-y-auto'), true) - assert.equal(order.classList.contains('flex-wrap'), true) + if (!order) { + throw new Error('Expected inherited Auto group order') + } + expect(order).toHaveClass('overflow-y-auto', 'flex-wrap') const items = [...order.querySelectorAll('li')] - assert.equal(items.length, 3) - assert.equal( - order.querySelectorAll('[data-slot="global-auto-order-connector"]') - .length, - 2 - ) - assert.deepEqual( + expect(items.length).toBe(3) + expect( + order.querySelectorAll('[data-slot="global-auto-order-connector"]').length + ).toBe(2) + expect( items.map((item) => ({ index: item.querySelector('[data-slot="global-auto-order-index"]') ?.textContent, @@ -345,196 +249,117 @@ describe('Auto group order editor', () => { '[data-slot="global-auto-order-description"]' )?.textContent, ratio: item.querySelector('[data-slot="badge"]')?.textContent, - })), - [ - { - index: '1', - name: 'VIP', - title: 'Priority access', - description: 'Priority access', - ratio: '3x Ratio', - }, - { - index: '2', - name: 'Default', - title: 'Standard access', - description: 'Standard access', - ratio: '1x Ratio', - }, - { - index: '3', - name: 'Team', - title: 'Shared access', - description: 'Shared access', - ratio: '2x Ratio', - }, - ] - ) + })) + ).toEqual([ + { + index: '1', + name: 'VIP', + title: 'Priority access', + description: 'Priority access', + ratio: '3x Ratio', + }, + { + index: '2', + name: 'Default', + title: 'Standard access', + description: 'Standard access', + ratio: '1x Ratio', + }, + { + index: '3', + name: 'Team', + title: 'Shared access', + description: 'Shared access', + ratio: '2x Ratio', + }, + ]) for (const item of items) { const chip = item.querySelector('[data-slot="global-auto-order-chip"]') - assert.ok(chip) + expect(chip).toBeInTheDocument() const description = item.querySelector( '[data-slot="global-auto-order-description"]' ) - assert.ok(description) - assert.equal(description.classList.contains('sr-only'), true) + expect(description).toHaveClass('sr-only') } - assert.equal( - items[0]?.querySelector('[data-slot="global-auto-order-connector"]'), - null - ) + expect( + items[0]?.querySelector('[data-slot="global-auto-order-connector"]') + ).toBe(null) for (const item of items.slice(1)) { const connector = item.querySelector( '[data-slot="global-auto-order-connector"]' ) - assert.ok(connector) - assert.equal(connector.getAttribute('aria-hidden'), 'true') + expect(connector).toHaveAttribute('aria-hidden', 'true') } - assert.equal(container.querySelector('[aria-label^="Drag "]'), null) - assert.equal(container.querySelector('[aria-label^="Move "]'), null) - assert.equal(container.querySelector('[aria-label^="Remove "]'), null) + expect(container.querySelector('[aria-label^="Drag "]')).toBe(null) + expect(container.querySelector('[aria-label^="Move "]')).toBe(null) + expect(container.querySelector('[aria-label^="Remove "]')).toBe(null) - const restoreButton = [...container.querySelectorAll('button')].find( - (button) => button.textContent?.includes('Restore global Auto') - ) - assert.ok(restoreButton) - assert.equal(restoreButton.disabled, true) - - await act(async () => root.unmount()) - container.remove() + expect( + within(container).getByRole('button', { name: 'Restore global Auto' }) + ).toBeDisabled() }) - test('shows an explicit empty state when the global Auto order has no groups', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('shows an explicit empty state when the global Auto order has no groups', () => { + const { container } = render() - await act(async () => - root.render() + expect(container).toHaveTextContent( + 'Using the complete global Auto order (0 groups)' ) + expect(container).toHaveTextContent( + 'No available groups in the global Auto order.' + ) + expect(container.querySelector('[data-slot="global-auto-order"]')).toBe( + null + ) + }) - assert.equal( - container.textContent?.includes( - 'Using the complete global Auto order (0 groups)' - ), - true + test('keeps an empty custom order distinct from global inheritance', () => { + const { container } = render() + + expect(within(container).getByTestId('mode')).toHaveTextContent('custom') + expect(container).toHaveTextContent( + 'No valid custom Auto groups remain. Add a group or restore global Auto.' ) - assert.equal( - container.textContent?.includes( - 'No available groups in the global Auto order.' - ), - true - ) - assert.equal( - container.querySelector('[data-slot="global-auto-order"]'), + expect(container.querySelector('[data-slot="global-auto-order"]')).toBe( null ) - await act(async () => root.unmount()) - container.remove() + const restoreButton = within(container).getByRole('button', { + name: 'Restore global Auto', + }) + expect(restoreButton).toBeEnabled() + fireEvent.click(restoreButton) + + expect(within(container).getByTestId('mode')).toHaveTextContent('inherit') + expect( + container.querySelector('[data-slot="global-auto-order"]') + ).toBeInTheDocument() }) - test('keeps an empty custom order distinct from global inheritance', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('adding a group from inheritance explicitly creates a custom order', () => { + const { container } = render() - await act(async () => root.render()) + fireEvent.click(within(container).getByRole('combobox')) + fireEvent.click(getCommandItem('VIP')) - assert.equal( - container.querySelector('[data-testid="mode"]')?.textContent, - 'custom' - ) - assert.equal( - container.textContent?.includes( - 'No valid custom Auto groups remain. Add a group or restore global Auto.' - ), - true - ) - assert.equal( - container.querySelector('[data-slot="global-auto-order"]'), + expect(within(container).getByTestId('mode')).toHaveTextContent('custom') + expect(within(container).getByTestId('order')).toHaveTextContent('vip') + expect(container.querySelector('[data-slot="global-auto-order"]')).toBe( null ) - - const restoreButton = [...container.querySelectorAll('button')].find( - (button) => button.textContent?.includes('Restore global Auto') - ) - assert.ok(restoreButton) - assert.equal(restoreButton.disabled, false) - await act(async () => restoreButton.click()) - - assert.equal( - container.querySelector('[data-testid="mode"]')?.textContent, - 'inherit' - ) - assert.ok(container.querySelector('[data-slot="global-auto-order"]')) - - await act(async () => root.unmount()) - container.remove() }) - test('adding a group from inheritance explicitly creates a custom order', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('removing the last custom group does not silently enable inheritance', () => { + const { container } = render() + fireEvent.click(findButton(container, 'Remove default')) - await act(async () => root.render()) - - const addButton = container.querySelector( - 'button[role="combobox"]' + expect(within(container).getByTestId('order')).toBeEmptyDOMElement() + expect(within(container).getByTestId('mode')).toHaveTextContent('custom') + expect(container).toHaveTextContent( + 'No valid custom Auto groups remain. Add a group or restore global Auto.' ) - assert.ok(addButton) - await act(async () => addButton.click()) - const vipOption = [ - ...document.querySelectorAll('[data-slot="command-item"]'), - ].find((option) => option.textContent?.includes('VIP')) - assert.ok(vipOption) - await act(async () => vipOption.click()) - - assert.equal( - container.querySelector('[data-testid="mode"]')?.textContent, - 'custom' - ) - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, - 'vip' - ) - assert.equal( - container.querySelector('[data-slot="global-auto-order"]'), - null - ) - - await act(async () => root.unmount()) - container.remove() - }) - - test('removing the last custom group does not silently enable inheritance', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => root.render()) - await act(async () => findButton(container, 'Remove default').click()) - - assert.equal( - container.querySelector('[data-testid="order"]')?.textContent, - '' - ) - assert.equal( - container.querySelector('[data-testid="mode"]')?.textContent, - 'custom' - ) - assert.equal( - container.textContent?.includes( - 'No valid custom Auto groups remain. Add a group or restore global Auto.' - ), - true - ) - - await act(async () => root.unmount()) - container.remove() }) }) diff --git a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts index 296023b6..479ff594 100644 --- a/web/src/features/keys/lib/__tests__/auto-group-form.test.ts +++ b/web/src/features/keys/lib/__tests__/auto-group-form.test.ts @@ -16,10 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' - import type { TFunction } from 'i18next' +import { describe, expect, test } from 'vitest' import { apiKeySchema, type ApiKey } from '../../types' import { @@ -60,16 +58,16 @@ describe('API key Auto group form mapping', () => { const legacyApiKey: Record = { ...baseApiKey } delete legacyApiKey.auto_groups - assert.equal(apiKeySchema.parse(legacyApiKey).auto_groups, null) + expect(apiKeySchema.parse(legacyApiKey).auto_groups).toBe(null) }) test('creates an Auto token that inherits the global order', () => { const defaults = getApiKeyFormDefaultValues(true) - assert.equal(defaults.group, 'auto') - assert.equal(defaults.auto_groups_mode, 'inherit') - assert.deepEqual(defaults.auto_groups, []) - assert.deepEqual(transformFormDataToPayload(defaults).auto_groups, []) + expect(defaults.group).toBe('auto') + expect(defaults.auto_groups_mode).toBe('inherit') + expect(defaults.auto_groups).toEqual([]) + expect(transformFormDataToPayload(defaults).auto_groups).toEqual([]) }) test('maps omitted, null, and empty snapshots to inheritance on edit', () => { @@ -88,8 +86,8 @@ describe('API key Auto group form mapping', () => { 2 ) - assert.equal(defaults.auto_groups_mode, 'inherit') - assert.deepEqual(defaults.auto_groups, []) + expect(defaults.auto_groups_mode).toBe('inherit') + expect(defaults.auto_groups).toEqual([]) } }) @@ -103,8 +101,8 @@ describe('API key Auto group form mapping', () => { 2 ) - assert.equal(defaults.auto_groups_mode, 'custom') - assert.deepEqual(defaults.auto_groups, ['vip', 'default']) + expect(defaults.auto_groups_mode).toBe('custom') + expect(defaults.auto_groups).toEqual(['vip', 'default']) }) test('keeps a fully filtered snapshot custom and rejects it until resolved', () => { @@ -114,15 +112,14 @@ describe('API key Auto group form mapping', () => { 2 ) - assert.equal(defaults.auto_groups_mode, 'custom') - assert.deepEqual(defaults.auto_groups, []) + expect(defaults.auto_groups_mode).toBe('custom') + expect(defaults.auto_groups).toEqual([]) const result = getApiKeyFormSchema(t, 2).safeParse(defaults) - assert.equal(result.success, false) + expect(result.success).toBe(false) if (result.success) return - assert.deepEqual(result.error.issues[0]?.path, ['auto_groups']) - assert.equal( - result.error.issues[0]?.message, + expect(result.error.issues[0]?.path).toEqual(['auto_groups']) + expect(result.error.issues[0]?.message).toBe( 'Select at least one Auto group or restore global Auto.' ) }) @@ -134,7 +131,7 @@ describe('API key Auto group form mapping', () => { auto_groups: ['vip', 'default'], } - assert.deepEqual(transformFormDataToPayload(custom).auto_groups, [ + expect(transformFormDataToPayload(custom).auto_groups).toEqual([ 'vip', 'default', ]) @@ -142,7 +139,7 @@ describe('API key Auto group form mapping', () => { test('submits an empty array for inheritance and for non-Auto groups', () => { const inherited = getApiKeyFormDefaultValues(true) - assert.deepEqual(transformFormDataToPayload(inherited).auto_groups, []) + expect(transformFormDataToPayload(inherited).auto_groups).toEqual([]) const nonAuto = { ...inherited, @@ -150,8 +147,8 @@ describe('API key Auto group form mapping', () => { auto_groups_mode: 'custom' as const, auto_groups: ['vip'], } - assert.deepEqual(transformFormDataToPayload(nonAuto).auto_groups, []) - assert.equal(transformFormDataToPayload(nonAuto).cross_group_retry, false) + expect(transformFormDataToPayload(nonAuto).auto_groups).toEqual([]) + expect(transformFormDataToPayload(nonAuto).cross_group_retry).toBe(false) }) test('rejects snapshots over the configured limit', () => { @@ -162,13 +159,10 @@ describe('API key Auto group form mapping', () => { auto_groups: ['default', 'vip'], }) - assert.equal(result.success, false) + expect(result.success).toBe(false) if (result.success) return - assert.equal(result.error.issues[0]?.path[0], 'auto_groups') - assert.equal( - result.error.issues[0]?.message, - 'Select at most 1 Auto groups' - ) + expect(result.error.issues[0]?.path[0]).toBe('auto_groups') + expect(result.error.issues[0]?.message).toBe('Select at most 1 Auto groups') }) test('rejects duplicate custom groups', () => { @@ -179,10 +173,9 @@ describe('API key Auto group form mapping', () => { auto_groups: ['vip', 'vip'], }) - assert.equal(result.success, false) + expect(result.success).toBe(false) if (result.success) return - assert.equal( - result.error.issues[0]?.message, + expect(result.error.issues[0]?.message).toBe( 'Auto groups must not contain duplicates' ) }) diff --git a/web/src/features/playground/hooks/use-stream-request.test.ts b/web/src/features/playground/hooks/use-stream-request.test.ts index a913fec3..29bcde76 100644 --- a/web/src/features/playground/hooks/use-stream-request.test.ts +++ b/web/src/features/playground/hooks/use-stream-request.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { ChatCompletionRequest } from '../types' import { createStreamRequestController } from './use-stream-request' @@ -103,12 +102,12 @@ describe('latest-wins stream request coordination', () => { const second = controller.send(payload, noopCallbacks) firstHeaders.resolve({ Authorization: 'Bearer stale' }) await first - assert.equal(sources.length, 0) + expect(sources.length).toBe(0) secondHeaders.resolve({ Authorization: 'Bearer current' }) await second - assert.equal(sources.length, 1) - assert.equal(sources[0]?.streamed, true) + expect(sources.length).toBe(1) + expect(sources[0]?.streamed).toBe(true) }) test('stop cancels a request that is still waiting for headers', async () => { @@ -128,7 +127,7 @@ describe('latest-wins stream request coordination', () => { headers.resolve({ Authorization: 'Bearer ignored' }) await request - assert.equal(sourceCount, 0) + expect(sourceCount).toBe(0) }) test('dispose cancels a pending header request without a state update', async () => { @@ -149,8 +148,8 @@ describe('latest-wins stream request coordination', () => { headers.resolve({ Authorization: 'Bearer ignored' }) await request - assert.equal(sourceCount, 0) - assert.deepEqual(streamingStates, [false]) + expect(sourceCount).toBe(0) + expect(streamingStates).toEqual([false]) }) test('closes the previous source and ignores all of its later events', async () => { @@ -182,7 +181,7 @@ describe('latest-wins stream request coordination', () => { await controller.send(payload, callbacks) const second = controller.send(payload, callbacks) - assert.equal(sources[0]?.closed, true) + expect(sources[0]?.closed).toBe(true) sources[0]?.emit( 'message', JSON.stringify({ choices: [{ delta: { content: 'stale' } }] }) @@ -195,6 +194,6 @@ describe('latest-wins stream request coordination', () => { JSON.stringify({ choices: [{ delta: { content: 'current' } }] }) ) - assert.deepEqual(updates, ['current']) + expect(updates).toEqual(['current']) }) }) diff --git a/web/src/features/profile/components/__tests__/login-session-utils.test.ts b/web/src/features/profile/components/__tests__/login-session-utils.test.ts index c75df750..f97c0b3e 100644 --- a/web/src/features/profile/components/__tests__/login-session-utils.test.ts +++ b/web/src/features/profile/components/__tests__/login-session-utils.test.ts @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next' /* Copyright (C) 2023-2026 QuantumNous @@ -16,10 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' - -import type { TFunction } from 'i18next' +import { describe, expect, test } from 'vitest' import { loginMethodLabel, sessionDevice } from '../login-session-utils' @@ -27,14 +25,10 @@ const translate = ((key: string) => key) as TFunction describe('login session presentation', () => { test('labels built-in and provider OAuth login methods', () => { - assert.equal(loginMethodLabel('password', translate), 'Password') - assert.equal( - loginMethodLabel('2fa', translate), - 'Two-factor Authentication' - ) - assert.equal(loginMethodLabel('oauth:github', translate), 'OAuth · GitHub') - assert.equal( - loginMethodLabel('oauth:custom-provider', translate), + expect(loginMethodLabel('password', translate)).toBe('Password') + expect(loginMethodLabel('2fa', translate)).toBe('Two-factor Authentication') + expect(loginMethodLabel('oauth:github', translate)).toBe('OAuth · GitHub') + expect(loginMethodLabel('oauth:custom-provider', translate)).toBe( 'OAuth · custom-provider' ) }) @@ -43,8 +37,7 @@ describe('login session presentation', () => { const userAgent = 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' - assert.equal( - sessionDevice(userAgent, 'Unknown device', 'Browser'), + expect(sessionDevice(userAgent, 'Unknown device', 'Browser')).toBe( 'Safari · iOS' ) }) @@ -53,8 +46,7 @@ describe('login session presentation', () => { const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' - assert.equal( - sessionDevice(userAgent, 'Unknown device', 'Browser', 5), + expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 5)).toBe( 'Safari · iOS' ) }) @@ -63,8 +55,7 @@ describe('login session presentation', () => { const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' - assert.equal( - sessionDevice(userAgent, 'Unknown device', 'Browser', 10), + expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 10)).toBe( 'Chrome · Windows' ) }) @@ -73,8 +64,7 @@ describe('login session presentation', () => { const userAgent = 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36' - assert.equal( - sessionDevice(userAgent, 'Unknown device', 'Browser', 5), + expect(sessionDevice(userAgent, 'Unknown device', 'Browser', 5)).toBe( 'Chrome · Android' ) }) @@ -83,15 +73,13 @@ describe('login session presentation', () => { const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' - assert.equal( - sessionDevice(userAgent, 'Unknown device', 'Browser'), + expect(sessionDevice(userAgent, 'Unknown device', 'Browser')).toBe( 'Safari · macOS' ) }) test('falls back to the unknown-device label for an empty user agent', () => { - assert.equal( - sessionDevice('', 'Unknown device', 'Browser'), + expect(sessionDevice('', 'Unknown device', 'Browser')).toBe( 'Unknown device' ) }) diff --git a/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx index 46008e10..b100c55c 100644 --- a/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx +++ b/web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx @@ -16,56 +16,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' - -import { Window } from 'happy-dom' +import { + fireEvent, + render, + screen, + waitFor, + type RenderResult, +} from '@testing-library/react' +import { afterEach, describe, expect, test } from 'vitest' import type { Redemption } from '../../types' -// Use Bun's runner at runtime while reusing the Node test types installed here. -const bunTestModule = 'bun:test' -const { afterAll, afterEach, test } = (await import(bunTestModule)) as { - afterAll: typeof import('node:test').after - afterEach: typeof import('node:test').afterEach - test: typeof import('node:test').test -} - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLButtonElement', - 'HTMLInputElement', - 'HTMLFormElement', - 'HTMLLabelElement', - 'HTMLFieldSetElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'KeyboardEvent', - 'PointerEvent', - 'MouseEvent', - 'FocusEvent', - 'CustomEvent', - 'MutationObserver', - 'ResizeObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') const i18n = (await import('i18next')).default const { I18nextProvider, initReactI18next } = await import('react-i18next') const { Toaster, toast } = await import('sonner') @@ -88,19 +49,13 @@ await i18n.use(initReactI18next).init({ }, }) -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - type ApiMethod = (url: string, data?: unknown) => Promise<{ data: unknown }> type MockableApi = { get: ApiMethod put: ApiMethod } type RenderedDrawer = { - host: HTMLDivElement - root: ReturnType + result: RenderResult } type CurrencyFixture = { quotaDisplayType: 'USD' | 'CNY' @@ -171,269 +126,189 @@ async function renderDrawer( }, }) - const host = document.createElement('div') - document.body.append(host) - const root = createRoot(host) - renderedDrawer = { host, root } - - await act(async () => root.render(drawerTree(currentRow))) + renderedDrawer = { result: render(drawerTree(currentRow)) } } async function rerenderDrawer(currentRow: Redemption): Promise { - assert.ok(renderedDrawer) - await act(async () => renderedDrawer?.root.render(drawerTree(currentRow))) -} - -async function waitForCondition( - condition: () => boolean, - failureMessage: string -): Promise { - if (condition()) return - - await new Promise((resolve, reject) => { - const observer = new MutationObserver(() => { - if (!condition()) return - clearTimeout(timeoutId) - observer.disconnect() - resolve() - }) - const timeoutId = setTimeout(() => { - observer.disconnect() - reject(new Error(`${failureMessage}: ${document.body.textContent}`)) - }, 1500) - - observer.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true, - }) - }) + if (!renderedDrawer) { + throw new Error('Expected a rendered redemption drawer') + } + renderedDrawer.result.rerender(drawerTree(currentRow)) } function getSaveButton(): HTMLButtonElement { - const button = document.querySelector( - 'button[form="redemption-form"][type="submit"]' - ) - assert.ok(button) - return button + return screen.getByRole('button', { name: 'Save changes' }) } -function getControlByLabel(labelText: string): T { +function getControlByLabel(labelText: 'Name'): HTMLInputElement +function getControlByLabel(labelText: 'Quota (CNY)'): HTMLInputElement +function getControlByLabel(labelText: 'Quota (USD)'): HTMLInputElement +function getControlByLabel(labelText: string): HTMLElement { const label = [...document.querySelectorAll('label')].find( (candidate) => candidate.textContent?.trim() === labelText ) - assert.ok(label, `Expected label "${labelText}"`) - assert.ok(label.htmlFor) + if (!label) { + throw new Error(`Expected label "${labelText}"`) + } const control = label.control ?? label .closest('[data-slot="form-item"]') ?.querySelector('[data-slot="form-control"], input') - assert.ok(control) - return control as T + if (!control) { + throw new Error(`Expected control for label "${labelText}"`) + } + return control } -async function changeInput(input: HTMLInputElement, value: string) { - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - domWindow.HTMLInputElement.prototype, - 'value' - )?.set - assert.ok(valueSetter) - valueSetter.call(input, value) - input.dispatchEvent( - new domWindow.Event('input', { bubbles: true }) as unknown as Event - ) - }) +function changeInput(input: HTMLInputElement, value: string): void { + fireEvent.input(input, { target: { value } }) } -async function submitForm(): Promise { +function submitForm(): void { const form = document.querySelector('#redemption-form') - assert.ok(form) - await act(async () => - form.dispatchEvent( - new domWindow.Event('submit', { - bubbles: true, - cancelable: true, - }) as unknown as Event - ) - ) + if (!form) { + throw new Error('Expected redemption form') + } + fireEvent.submit(form) } async function waitForLoadedForm(): Promise { - await act(async () => - waitForCondition(() => { - const saveButton = getSaveButton() - return ( - saveButton.textContent?.includes('Save changes') === true && - !saveButton.disabled - ) - }, 'redemption drawer did not finish loading') - ) + await waitFor(() => expect(getSaveButton()).toBeEnabled()) } -afterEach(async () => { +afterEach(() => { apiClient.get = originalGet apiClient.put = originalPut Reflect.set(console, 'log', originalConsoleLog) toast.dismiss() - domWindow.localStorage.clear() - if (renderedDrawer) { - await act(async () => renderedDrawer?.root.unmount()) - renderedDrawer.host.remove() - renderedDrawer = null - } - document.body.replaceChildren() + localStorage.clear() + renderedDrawer = null }) -afterAll(() => { - domWindow.close() -}) +describe('redemption drawer', () => { + test('shows the reported CNY quota without floating-point noise', async () => { + const original = redemption(1, 13888889) + apiClient.get = async () => ({ data: { success: true, data: original } }) -test('redemption drawer shows the reported CNY quota without floating-point noise', async () => { - const original = redemption(1, 13888889) - apiClient.get = async () => ({ data: { success: true, data: original } }) + await renderDrawer(original, { + quotaDisplayType: 'CNY', + usdExchangeRate: 7.2, + }) + await waitForLoadedForm() - await renderDrawer(original, { - quotaDisplayType: 'CNY', - usdExchangeRate: 7.2, - }) - await waitForLoadedForm() - - assert.equal(getControlByLabel('Quota (CNY)').value, '200') -}) - -test('redemption drawer blocks updates and reports an error when loading rejects', async () => { - const updates: unknown[] = [] - Reflect.set(console, 'log', () => undefined) - apiClient.get = async () => { - throw new Error('network failure') - } - apiClient.put = async (_url, data) => { - updates.push(data) - return { data: { success: true } } - } - - await renderDrawer(redemption(1)) - await act(async () => - waitForCondition( - () => - document.body.textContent?.includes('Something went wrong!') === true, - 'load error toast was not shown' - ) - ) - - assert.equal(getSaveButton().disabled, true) - await submitForm() - assert.deepEqual(updates, []) -}) - -test('redemption drawer blocks updates and uses localized feedback for unsuccessful responses', async () => { - apiClient.get = async () => ({ - data: { success: false, message: 'raw server message' }, + expect(getControlByLabel('Quota (CNY)').value).toBe('200') }) - await renderDrawer(redemption(1)) - await act(async () => - waitForCondition( - () => document.body.textContent?.includes('Failed to load') === true, - 'unsuccessful-load toast was not shown' + test('blocks updates and reports an error when loading rejects', async () => { + const updates: unknown[] = [] + Reflect.set(console, 'log', () => undefined) + apiClient.get = async () => { + throw new Error('network failure') + } + apiClient.put = async (_url, data) => { + updates.push(data) + return { data: { success: true } } + } + + await renderDrawer(redemption(1)) + await waitFor(() => + expect(document.body).toHaveTextContent('Something went wrong!') ) - ) - assert.equal(getSaveButton().disabled, true) - assert.equal(document.body.textContent?.includes('raw server message'), false) -}) + expect(getSaveButton()).toBeDisabled() + submitForm() + expect(updates).toEqual([]) + }) -test('redemption drawer keeps the original quota when another field changes', async () => { - const original = redemption(1) - const updates: Array> = [] - apiClient.get = async () => ({ data: { success: true, data: original } }) - apiClient.put = async (_url, data) => { - assert.ok(data && typeof data === 'object') - updates.push(data as Record) - return { data: { success: true, data: original } } - } + test('blocks updates and uses localized feedback for unsuccessful responses', async () => { + apiClient.get = async () => ({ + data: { success: false, message: 'raw server message' }, + }) - await renderDrawer(original) - await waitForLoadedForm() - assert.equal(getControlByLabel('Quota (USD)').value, '1') - - await changeInput(getControlByLabel('Name'), 'renamed') - await submitForm() - await act(async () => - waitForCondition(() => updates.length === 1, 'update was not submitted') - ) - - assert.equal(updates[0]?.name, 'renamed') - assert.equal(updates[0]?.quota, 500001) -}) - -test('redemption drawer recalculates quota when the quota field changes', async () => { - const original = redemption(1) - const updates: Array> = [] - apiClient.get = async () => ({ data: { success: true, data: original } }) - apiClient.put = async (_url, data) => { - assert.ok(data && typeof data === 'object') - updates.push(data as Record) - return { data: { success: true, data: original } } - } - - await renderDrawer(original) - await waitForLoadedForm() - await changeInput(getControlByLabel('Quota (USD)'), '2') - await submitForm() - await act(async () => - waitForCondition(() => updates.length === 1, 'update was not submitted') - ) - - assert.equal(updates[0]?.quota, 1000000) -}) - -test('redemption drawer ignores an older response after switching records', async () => { - const first = redemption(1, 500001) - const second = redemption(2, 1000001) - const firstRequest = deferred<{ data: unknown }>() - const secondRequest = deferred<{ data: unknown }>() - const requestedUrls: string[] = [] - const updates: Array> = [] - apiClient.get = (url) => { - requestedUrls.push(url) - if (url === '/api/redemption/1') return firstRequest.promise - if (url === '/api/redemption/2') return secondRequest.promise - throw new Error(`Unexpected GET ${url}`) - } - apiClient.put = async (_url, data) => { - assert.ok(data && typeof data === 'object') - updates.push(data as Record) - return { data: { success: true, data: second } } - } - - await renderDrawer(first) - await rerenderDrawer(second) - await act(async () => - waitForCondition( - () => requestedUrls.includes('/api/redemption/2'), - 'second redemption was not requested' + await renderDrawer(redemption(1)) + await waitFor(() => + expect(document.body).toHaveTextContent('Failed to load') ) - ) - await act(async () => + + expect(getSaveButton()).toBeDisabled() + expect(document.body).not.toHaveTextContent('raw server message') + }) + + test('keeps the original quota when another field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + expect(data && typeof data === 'object').toBeTruthy() + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + expect(getControlByLabel('Quota (USD)').value).toBe('1') + + changeInput(getControlByLabel('Name'), 'renamed') + submitForm() + await waitFor(() => expect(updates).toHaveLength(1)) + + expect(updates[0]?.name).toBe('renamed') + expect(updates[0]?.quota).toBe(500001) + }) + + test('recalculates quota when the quota field changes', async () => { + const original = redemption(1) + const updates: Array> = [] + apiClient.get = async () => ({ data: { success: true, data: original } }) + apiClient.put = async (_url, data) => { + expect(data && typeof data === 'object').toBeTruthy() + updates.push(data as Record) + return { data: { success: true, data: original } } + } + + await renderDrawer(original) + await waitForLoadedForm() + changeInput(getControlByLabel('Quota (USD)'), '2') + submitForm() + await waitFor(() => expect(updates).toHaveLength(1)) + + expect(updates[0]?.quota).toBe(1000000) + }) + + test('ignores an older response after switching records', async () => { + const first = redemption(1, 500001) + const second = redemption(2, 1000001) + const firstRequest = deferred<{ data: unknown }>() + const secondRequest = deferred<{ data: unknown }>() + const requestedUrls: string[] = [] + const updates: Array> = [] + apiClient.get = (url) => { + requestedUrls.push(url) + if (url === '/api/redemption/1') return firstRequest.promise + if (url === '/api/redemption/2') return secondRequest.promise + throw new Error(`Unexpected GET ${url}`) + } + apiClient.put = async (_url, data) => { + expect(data && typeof data === 'object').toBeTruthy() + updates.push(data as Record) + return { data: { success: true, data: second } } + } + + await renderDrawer(first) + await rerenderDrawer(second) + await waitFor(() => expect(requestedUrls).toContain('/api/redemption/2')) secondRequest.resolve({ data: { success: true, data: second } }) - ) - await waitForLoadedForm() + await waitForLoadedForm() - await act(async () => firstRequest.resolve({ data: { success: true, data: first } }) - ) - assert.equal(getControlByLabel('Name').value, 'code-2') + expect(getControlByLabel('Name').value).toBe('code-2') - await changeInput(getControlByLabel('Name'), 'second') - await submitForm() - await act(async () => - waitForCondition(() => updates.length === 1, 'update was not submitted') - ) + changeInput(getControlByLabel('Name'), 'second') + submitForm() + await waitFor(() => expect(updates).toHaveLength(1)) - assert.equal(updates[0]?.id, 2) - assert.equal(updates[0]?.quota, 1000001) + expect(updates[0]?.id).toBe(2) + expect(updates[0]?.quota).toBe(1000001) + }) }) diff --git a/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts b/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts index 4b2a5b6e..62e60e9f 100644 --- a/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts +++ b/web/src/features/system-settings/models/__tests__/group-auto-limit-validation.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { positiveIntegerSchema } from '../../utils/numeric-field' @@ -26,15 +25,15 @@ const schema = positiveIntegerSchema(t('Enter a positive integer')) describe('per-token Auto group limit validation', () => { test('accepts any positive integer without a product upper bound', () => { - assert.equal(schema.safeParse(1000).success, true) + expect(schema.safeParse(1000).success).toBe(true) }) test('rejects zero, negative, and fractional limits', () => { for (const maxTokenAutoGroups of [0, -1, 1.5]) { const result = schema.safeParse(maxTokenAutoGroups) - assert.equal(result.success, false) + expect(result.success).toBe(false) if (result.success) continue - assert.equal(result.error.issues[0]?.message, 'Enter a positive integer') + expect(result.error.issues[0]?.message).toBe('Enter a positive integer') } }) }) diff --git a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx index fc6b8a88..5544d4ff 100644 --- a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx +++ b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx @@ -16,128 +16,49 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import i18next from 'i18next' +import { beforeAll, describe, expect, test } from 'vitest' -import { Window } from 'happy-dom' - -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'HTMLInputElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'CustomEvent', - 'MutationObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const - -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} - -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') -const { QueryClient, QueryClientProvider } = - await import('@tanstack/react-query') -const { createInstance } = await import('i18next') -const { I18nextProvider, initReactI18next } = await import('react-i18next') -const { ToolPriceSettings } = await import('../tool-price-settings') - -const i18n = createInstance() -await i18n.use(initReactI18next).init({ - lng: 'en', - resources: { - en: { - translation: { - 'Price ($/1K calls)': 'Price ($/1K calls)', - 'Please enter a valid number': 'Please enter a valid number', - 'Tool identifier': 'Tool identifier', - }, - }, - }, -}) - -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - -function changeInputValue(input: HTMLInputElement, value: string) { - const valueSetter = Object.getOwnPropertyDescriptor( - domWindow.HTMLInputElement.prototype, - 'value' - )?.set - assert.ok(valueSetter) - valueSetter.call(input, value) - input.dispatchEvent( - new domWindow.Event('input', { bubbles: true }) as unknown as Event - ) -} +import { ToolPriceSettings } from '../tool-price-settings' describe('tool price validation', () => { - after(() => { - domWindow.close() + beforeAll(() => { + i18next.addResourceBundle('en', 'translation', { + 'Price ($/1K calls)': 'Price ($/1K calls)', + 'Please enter a valid number': 'Please enter a valid number', + 'Tool identifier': 'Tool identifier', + }) }) - test('blocks an empty price without converting it to an explicit zero', async () => { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) + test('blocks an empty price without converting it to an explicit zero', () => { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, }) - await act(async () => { - root.render( - - - - - - ) - }) - - const priceInput = container.querySelector( - 'input[aria-label="Price ($/1K calls): web_search"]' + render( + + + ) - assert.ok(priceInput) - await act(async () => { - changeInputValue(priceInput, '') + const priceInput = screen.getByRole('spinbutton', { + name: 'Price ($/1K calls): web_search', }) + const saveButton = screen.getByRole('button', { name: 'Save tool prices' }) - assert.equal(priceInput.getAttribute('aria-invalid'), 'true') - assert.equal( - priceInput.closest('[data-slot="field"]')?.querySelector('[role="alert"]') - ?.textContent, - 'Please enter a valid number' - ) - const saveButton = [...container.querySelectorAll('button')].find( - (button) => button.textContent === 'Save tool prices' - ) - assert.ok(saveButton) - assert.equal(saveButton.disabled, true) + fireEvent.change(priceInput, { target: { value: '' } }) - await act(async () => { - changeInputValue(priceInput, '0') - }) + expect(priceInput).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByText('Please enter a valid number')).toBeInTheDocument() + expect(saveButton).toBeDisabled() - assert.equal(priceInput.getAttribute('aria-invalid'), 'false') - assert.equal(saveButton.disabled, false) + fireEvent.change(priceInput, { target: { value: '0' } }) + + expect(priceInput).toHaveAttribute('aria-invalid', 'false') + expect(saveButton).toBeEnabled() - await act(async () => root.unmount()) - container.remove() queryClient.clear() }) }) diff --git a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx index fc6294f6..457a48da 100644 --- a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx @@ -16,88 +16,19 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { after, describe, test } from 'node:test' - -import { Window } from 'happy-dom' +import { render, screen } from '@testing-library/react' +import i18next from 'i18next' import type React from 'react' +import { beforeAll, describe, expect, test } from 'vitest' -const domWindow = new Window() -const domGlobals = [ - 'window', - 'document', - 'navigator', - 'HTMLElement', - 'SVGElement', - 'Node', - 'Element', - 'Event', - 'CustomEvent', - 'MutationObserver', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'getComputedStyle', -] as const +import { formatLogQuota } from '@/lib/format' -for (const key of domGlobals) { - Object.defineProperty(globalThis, key, { - configurable: true, - value: domWindow[key], - }) -} +import { LogCostDisplay } from '../log-cost-display' -const { act } = await import('react') -const { createRoot } = await import('react-dom/client') -const { createInstance } = await import('i18next') -const { I18nextProvider, initReactI18next } = await import('react-i18next') - -const i18n = createInstance() -await i18n.use(initReactI18next).init({ - lng: 'en', - resources: { - en: { - translation: { - Subscription: 'Subscription', - 'Deducted by subscription': 'Deducted by subscription', - 'Includes tool-call surcharge': 'Includes tool-call surcharge', - }, - }, - }, -}) - -const { LogCostDisplay } = await import('../log-cost-display') -const { formatLogQuota } = await import('@/lib/format') -const reactTestGlobals = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} -reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true - -type RenderedCost = { - container: HTMLDivElement - root: ReturnType -} - -async function renderCost( +function renderCost( props: React.ComponentProps -): Promise { - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - await act(async () => { - root.render( - - - - ) - }) - - return { container, root } -} - -async function unmountCost(rendered: RenderedCost) { - await act(async () => rendered.root.unmount()) - rendered.container.remove() +): ReturnType { + return render() } function normalizedText(value: string | null): string { @@ -105,39 +36,36 @@ function normalizedText(value: string | null): string { } describe('log cost display', () => { - after(() => { - domWindow.close() + beforeAll(() => { + i18next.addResourceBundle('en', 'translation', { + Subscription: 'Subscription', + 'Deducted by subscription': 'Deducted by subscription', + 'Includes tool-call surcharge': 'Includes tool-call surcharge', + }) }) - test('keeps the regular cost visible and adds an accessible surcharge marker', async () => { - const rendered = await renderCost({ + test('keeps the regular cost visible and adds an accessible surcharge marker', () => { + const rendered = renderCost({ quota: 12500, other: { tool_surcharges: [{ name: 'lookup_customer', count: 1, price: 5 }], }, }) - assert.equal( + expect( normalizedText(rendered.container.textContent).includes( normalizedText(formatLogQuota(12500)) - ), - true - ) - const marker = rendered.container.querySelector( - '[data-tool-surcharge-indicator="true"]' - ) - assert.ok(marker) - assert.equal( - marker.getAttribute('aria-label'), - 'Includes tool-call surcharge' - ) - assert.equal(marker.getAttribute('tabindex'), '0') - - await unmountCost(rendered) + ) + ).toBe(true) + const marker = screen.getByRole('img', { + name: 'Includes tool-call surcharge', + }) + expect(marker).toHaveAttribute('data-tool-surcharge-indicator', 'true') + expect(marker).toHaveAttribute('tabindex', '0') }) - test('preserves the subscription badge and adds the same legacy surcharge marker', async () => { - const rendered = await renderCost({ + test('preserves the subscription badge and adds the same legacy surcharge marker', () => { + renderCost({ quota: 5000, other: { billing_source: 'subscription', @@ -147,11 +75,9 @@ describe('log cost display', () => { }, }) - assert.equal(rendered.container.textContent?.includes('Subscription'), true) - assert.ok( - rendered.container.querySelector('[data-tool-surcharge-indicator="true"]') - ) - - await unmountCost(rendered) + expect(screen.getByText('Subscription')).toBeInTheDocument() + expect( + screen.getByRole('img', { name: 'Includes tool-call surcharge' }) + ).toHaveAttribute('data-tool-surcharge-indicator', 'true') }) }) diff --git a/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts b/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts index fb3ea348..f5b80949 100644 --- a/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts +++ b/web/src/features/usage-logs/lib/__tests__/tool-surcharge.test.ts @@ -16,20 +16,18 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import type { LogOtherData } from '../../types' import { hasToolSurcharge } from '../format' describe('tool surcharge detection', () => { test('shows the marker for a charged structured tool surcharge', () => { - assert.equal( + expect( hasToolSurcharge({ tool_surcharges: [{ name: 'lookup_customer', count: 2, price: 5 }], - }), - true - ) + }) + ).toBe(true) }) const legacyCases: Array<{ @@ -63,7 +61,7 @@ describe('tool surcharge detection', () => { for (const scenario of legacyCases) { test(`keeps the marker visible for legacy ${scenario.name} charges`, () => { - assert.equal(hasToolSurcharge(scenario.other), true) + expect(hasToolSurcharge(scenario.other)).toBe(true) }) } @@ -93,7 +91,7 @@ describe('tool surcharge detection', () => { ] for (const other of invalidCases) { - assert.equal(hasToolSurcharge(other), false) + expect(hasToolSurcharge(other)).toBe(false) } }) }) diff --git a/web/src/features/wallet/hooks/use-payment.test.ts b/web/src/features/wallet/hooks/use-payment.test.ts index ebd90a14..210e873f 100644 --- a/web/src/features/wallet/hooks/use-payment.test.ts +++ b/web/src/features/wallet/hooks/use-payment.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { PAYMENT_TYPES } from '../constants' import { requestPaymentAmount } from './use-payment' @@ -44,7 +43,7 @@ describe('payment amount routing', () => { }, }) - assert.equal(amount, 18.75) - assert.deepEqual(calls, ['waffo:120']) + expect(amount).toBe(18.75) + expect(calls).toEqual(['waffo:120']) }) }) diff --git a/web/src/features/wallet/lib/payment.test.ts b/web/src/features/wallet/lib/payment.test.ts index 10e8eaba..99f22ce3 100644 --- a/web/src/features/wallet/lib/payment.test.ts +++ b/web/src/features/wallet/lib/payment.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { PAYMENT_TYPES } from '../constants' import { @@ -29,11 +28,11 @@ import { describe('payment type classification', () => { test('keeps Waffo and Waffo Pancake on their dedicated flows', () => { - assert.equal(isWaffoPayment(PAYMENT_TYPES.WAFFO), true) - assert.equal(isWaffoPayment(PAYMENT_TYPES.WAFFO_PANCAKE), false) - assert.equal(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO_PANCAKE), true) - assert.equal(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO), false) - assert.equal(isStripePayment(PAYMENT_TYPES.STRIPE), true) + expect(isWaffoPayment(PAYMENT_TYPES.WAFFO)).toBe(true) + expect(isWaffoPayment(PAYMENT_TYPES.WAFFO_PANCAKE)).toBe(false) + expect(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO_PANCAKE)).toBe(true) + expect(isWaffoPancakePayment(PAYMENT_TYPES.WAFFO)).toBe(false) + expect(isStripePayment(PAYMENT_TYPES.STRIPE)).toBe(true) }) }) @@ -60,8 +59,8 @@ describe('payment dispatch', () => { } ) - assert.equal(success, true) - assert.deepEqual(calls, ['waffo:120:3']) + expect(success).toBe(true) + expect(calls).toEqual(['waffo:120:3']) }) test('does not create a Waffo order without a selected method index', async () => { @@ -80,7 +79,7 @@ describe('payment dispatch', () => { } ) - assert.equal(success, false) - assert.equal(called, false) + expect(success).toBe(false) + expect(called).toBe(false) }) }) diff --git a/web/src/lib/auth-session.test.ts b/web/src/lib/auth-session.test.ts index 4a97d3c2..17055ee4 100644 --- a/web/src/lib/auth-session.test.ts +++ b/web/src/lib/auth-session.test.ts @@ -16,10 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { afterEach, describe, test } from 'node:test' - import { QueryClient } from '@tanstack/react-query' +import { afterEach, describe, expect, test } from 'vitest' import { useAuthStore, type AuthBundle } from '../stores/auth-store' import { @@ -59,10 +57,10 @@ afterEach(() => { describe('authentication session coordination', () => { test('bootstrap distinguishes a completed anonymous check from an active session', async () => { useAuthStore.getState().auth.reset('complete') - assert.deepEqual(await bootstrapAuthentication(), { kind: 'anonymous' }) + expect(await bootstrapAuthentication()).toEqual({ kind: 'anonymous' }) useAuthStore.getState().auth.setBundle(bundle) - assert.deepEqual(await bootstrapAuthentication(), { + expect(await bootstrapAuthentication()).toEqual({ kind: 'authenticated', bundle, }) @@ -97,10 +95,10 @@ describe('authentication session coordination', () => { const outcome = await createRefreshRunner(runtime)() - assert.equal(outcome.kind, 'authenticated') - assert.deepEqual(requestedSIDs, [bundle.session.sid, undefined]) - assert.deepEqual(clears, [[false, 'idle']]) - assert.deepEqual(accepted, [bundle]) + expect(outcome.kind).toBe('authenticated') + expect(requestedSIDs).toEqual([bundle.session.sid, undefined]) + expect(clears).toEqual([[false, 'idle']]) + expect(accepted).toEqual([bundle]) }) test('a rejected refresh confirms anonymous state and synchronizes sign-out', async () => { @@ -117,10 +115,10 @@ describe('authentication session coordination', () => { wait: async () => undefined, } - assert.deepEqual(await createRefreshRunner(runtime)(), { + expect(await createRefreshRunner(runtime)()).toEqual({ kind: 'anonymous', }) - assert.deepEqual(clears, [[true, undefined]]) + expect(clears).toEqual([[true, undefined]]) }) test('a temporary refresh failure remains retryable without clearing the session', async () => { @@ -142,9 +140,9 @@ describe('authentication session coordination', () => { const outcome = await createRefreshRunner(runtime)() - assert.equal(outcome.kind, 'transient_error') - assert.equal(clearCount, 0) - assert.equal(transientCount, 1) + expect(outcome.kind).toBe('transient_error') + expect(clearCount).toBe(0) + expect(transientCount).toBe(1) }) test('a rate limited refresh remains retryable without clearing the session', async () => { @@ -166,9 +164,9 @@ describe('authentication session coordination', () => { const outcome = await createRefreshRunner(runtime)() - assert.equal(outcome.kind, 'transient_error') - assert.equal(clearCount, 0) - assert.equal(transientCount, 1) + expect(outcome.kind).toBe('transient_error') + expect(clearCount).toBe(0) + expect(transientCount).toBe(1) }) test('an exhausted refresh race clears the unusable local session', async () => { @@ -191,12 +189,12 @@ describe('authentication session coordination', () => { }, } - assert.deepEqual(await createRefreshRunner(runtime)(), { + expect(await createRefreshRunner(runtime)()).toEqual({ kind: 'out_of_sync', code: 'AUTH_REFRESH_RACE', }) - assert.deepEqual(requestedDelays, [80, 200, 500]) - assert.deepEqual(clears, [[false, undefined]]) + expect(requestedDelays).toEqual([80, 200, 500]) + expect(clears).toEqual([[false, undefined]]) }) test('an unexpected successful response is treated as out of sync', async () => { @@ -213,11 +211,11 @@ describe('authentication session coordination', () => { wait: async () => undefined, } - assert.deepEqual(await createRefreshRunner(runtime)(), { + expect(await createRefreshRunner(runtime)()).toEqual({ kind: 'out_of_sync', code: 'AUTH_INVALID_REFRESH_RESPONSE', }) - assert.equal(cleared, true) + expect(cleared).toBe(true) }) test('a refresh response cannot restore credentials after a newer auth operation', async () => { @@ -241,8 +239,8 @@ describe('authentication session coordination', () => { const outcome = await createRefreshRunner(runtime)() - assert.equal(outcome.kind, 'transient_error') - assert.equal(accepted, false) + expect(outcome.kind).toBe('transient_error') + expect(accepted).toBe(false) }) test('explicit rotations update only the current session', () => { @@ -254,41 +252,35 @@ describe('authentication session coordination', () => { session: { ...bundle.session, last_active_at: 200 }, }) - assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token') - assert.strictEqual(useAuthStore.getState().auth.user, bundle.user) + expect(useAuthStore.getState().auth.accessToken).toBe('rotated-token') + expect(useAuthStore.getState().auth.user).toBe(bundle.user) - assert.throws( - () => - applyAuthRotation({ - access_token: 'non-bearer-token', - token_type: 'Custom', - access_expires_at: bundle.access_expires_at + 120, - session: bundle.session, - }), - /Invalid authentication rotation response/ - ) - assert.throws( - () => - applyAuthRotation({ - access_token: 'non-current-token', - token_type: 'Bearer', - access_expires_at: bundle.access_expires_at + 120, - session: { ...bundle.session, current: false }, - }), - /Invalid authentication rotation response/ - ) + expect(() => + applyAuthRotation({ + access_token: 'non-bearer-token', + token_type: 'Custom', + access_expires_at: bundle.access_expires_at + 120, + session: bundle.session, + }) + ).toThrow(/Invalid authentication rotation response/) + expect(() => + applyAuthRotation({ + access_token: 'non-current-token', + token_type: 'Bearer', + access_expires_at: bundle.access_expires_at + 120, + session: { ...bundle.session, current: false }, + }) + ).toThrow(/Invalid authentication rotation response/) - assert.throws( - () => - applyAuthRotation({ - access_token: 'wrong-session-token', - token_type: 'Bearer', - access_expires_at: bundle.access_expires_at + 120, - session: { ...bundle.session, sid: 'session-b' }, - }), - /session mismatch/ - ) - assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token') + expect(() => + applyAuthRotation({ + access_token: 'wrong-session-token', + token_type: 'Bearer', + access_expires_at: bundle.access_expires_at + 120, + session: { ...bundle.session, sid: 'session-b' }, + }) + ).toThrow(/session mismatch/) + expect(useAuthStore.getState().auth.accessToken).toBe('rotated-token') }) test('sign-out clears user-scoped query, mutation, and authentication state', () => { @@ -305,13 +297,13 @@ describe('authentication session coordination', () => { clearAuthenticatedClientState(queryClient, false) - assert.equal(queryClient.getQueryCache().getAll().length, 0) - assert.equal(queryClient.getMutationCache().getAll().length, 0) - assert.equal(useAuthStore.getState().auth.user, null) - assert.equal(useAuthStore.getState().auth.accessToken, null) - assert.equal(useAuthStore.getState().auth.session, null) - assert.equal(useAuthStore.getState().auth.pending2FAFlowToken, null) - assert.equal(useAuthStore.getState().auth.bootstrapState, 'complete') + expect(queryClient.getQueryCache().getAll().length).toBe(0) + expect(queryClient.getMutationCache().getAll().length).toBe(0) + expect(useAuthStore.getState().auth.user).toBe(null) + expect(useAuthStore.getState().auth.accessToken).toBe(null) + expect(useAuthStore.getState().auth.session).toBe(null) + expect(useAuthStore.getState().auth.pending2FAFlowToken).toBe(null) + expect(useAuthStore.getState().auth.bootstrapState).toBe('complete') const nextBundle: AuthBundle = { ...bundle, @@ -320,8 +312,7 @@ describe('authentication session coordination', () => { session: { ...bundle.session, sid: 'session-b' }, } useAuthStore.getState().auth.setBundle(nextBundle) - assert.equal( - queryClient.getQueryData(['account', bundle.user.id]), + expect(queryClient.getQueryData(['account', bundle.user.id])).toBe( undefined ) }) diff --git a/web/src/lib/legacy-route.test.ts b/web/src/lib/legacy-route.test.ts index f14a94b1..737a4850 100644 --- a/web/src/lib/legacy-route.test.ts +++ b/web/src/lib/legacy-route.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { resolveLegacyRoute } from './legacy-route' @@ -43,17 +42,15 @@ describe('legacy frontend route migration', () => { } for (const [source, target] of Object.entries(routes)) { - assert.equal(resolveLegacyRoute(source), target) + expect(resolveLegacyRoute(source)).toBe(target) } }) test('preserves search and hash while applying route-specific behavior', () => { - assert.equal( - resolveLegacyRoute('/login?redirect=%2Fkeys#continue'), + expect(resolveLegacyRoute('/login?redirect=%2Fkeys#continue')).toBe( '/sign-in?redirect=%2Fkeys#continue' ) - assert.equal( - resolveLegacyRoute('/console/topup?source=email#orders'), + expect(resolveLegacyRoute('/console/topup?source=email#orders')).toBe( '/wallet?source=email#orders' ) }) @@ -75,23 +72,20 @@ describe('legacy frontend route migration', () => { } for (const [tab, target] of Object.entries(settingsTabs)) { - assert.equal( - resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`), - `${target}?tab=${tab}&from=bookmark#form` - ) + expect( + resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`) + ).toBe(`${target}?tab=${tab}&from=bookmark#form`) } - assert.equal( - resolveLegacyRoute('/console/setting?tab=unknown'), + expect(resolveLegacyRoute('/console/setting?tab=unknown')).toBe( '/system-settings?tab=unknown' ) }) test('safely redirects unknown console locations without touching new routes', () => { - assert.equal( - resolveLegacyRoute('/console/removed?page=2#old'), + expect(resolveLegacyRoute('/console/removed?page=2#old')).toBe( '/dashboard?page=2#old' ) - assert.equal(resolveLegacyRoute('/dashboard'), null) - assert.equal(resolveLegacyRoute('/api/status'), null) + expect(resolveLegacyRoute('/dashboard')).toBe(null) + expect(resolveLegacyRoute('/api/status')).toBe(null) }) }) diff --git a/web/src/lib/server-error-message.test.ts b/web/src/lib/server-error-message.test.ts index 9e59f423..f3b1eb84 100644 --- a/web/src/lib/server-error-message.test.ts +++ b/web/src/lib/server-error-message.test.ts @@ -16,8 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { describe, expect, test } from 'vitest' import { getServerErrorMessageKey } from './server-error-message' @@ -25,8 +24,8 @@ describe('server error message mapping', () => { test('maps the active-session limit to recovery instructions', () => { const message = getServerErrorMessageKey({ code: 'AUTH_SESSION_LIMIT' }) - assert.match(message ?? '', /Sign out other sessions/) - assert.match(message ?? '', /reset your password/) + expect(message ?? '').toMatch(/Sign out other sessions/) + expect(message ?? '').toMatch(/reset your password/) }) test('maps an Axios-shaped issuance limit to rolling-window guidance', () => { @@ -34,8 +33,8 @@ describe('server error message mapping', () => { response: { data: { code: 'AUTH_SESSION_ISSUANCE_LIMIT' } }, }) - assert.match(message ?? '', /rolling window/) - assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null) + expect(message ?? '').toMatch(/rolling window/) + expect(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' })).toBe(null) }) test('maps stable Telegram bind errors without exposing server text', () => { @@ -55,16 +54,15 @@ describe('server error message mapping', () => { } for (const [code, message] of Object.entries(expected)) { - assert.equal(getServerErrorMessageKey({ code }), message) + expect(getServerErrorMessageKey({ code })).toBe(message) } - assert.equal( + expect( getServerErrorMessageKey({ response: { data: { code: 'TELEGRAM_BIND_INTERNAL_ERROR', message: 'raw detail' }, }, - }), - expected.TELEGRAM_BIND_INTERNAL_ERROR - ) + }) + ).toBe(expected.TELEGRAM_BIND_INTERNAL_ERROR) }) }) diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 00000000..cf898360 --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1,73 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import i18next from 'i18next' +import { initReactI18next } from 'react-i18next' +import { afterEach, beforeAll } from 'vitest' + +beforeAll(async () => { + await i18next.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { + translation: {}, + }, + }, + }) +}) + +afterEach(() => { + cleanup() +}) + +Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}) + +window.requestAnimationFrame = (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +window.cancelAnimationFrame = (handle: number) => window.clearTimeout(handle) + +class ResizeObserverMock { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: ResizeObserverMock, +}) + +Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: () => undefined, +}) diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index e5b90396..f576ef0d 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -20,5 +20,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["rsbuild.config.ts"] + "include": ["rsbuild.config.ts", "vitest.config.ts"] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000..144e0f44 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,39 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + clearMocks: true, + restoreMocks: true, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, +})