diff --git a/frontend/bun.lock b/frontend/bun.lock index 8c7974d908..890f17274b 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -18,6 +18,7 @@ "@rjsf/utils": "^5.8.1", "@rjsf/validator-ajv8": "^5.8.1", "@stripe/stripe-js": "^4.2.0", + "@tanstack/react-query": "5.104.5", "@tanstack/react-table": "^8.21.3", "axios": "1.16.0", "class-variance-authority": "^0.7.1", @@ -79,9 +80,12 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.4.0", + "@vitest/coverage-v8": "3.2.6", "autoprefixer": "^10.5.4", + "axe-core": "4.14.0", "baseline-browser-mapping": "^2.9.19", "happy-dom": "^20.10.2", + "msw": "2.15.0", "postcss": "^8.5.23", "postcss-value-parser": "^4.2.0", "tailwindcss": "^4", @@ -104,6 +108,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], @@ -144,6 +150,8 @@ "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], @@ -252,6 +260,20 @@ "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.10", "", {}, "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.3.5", "", { "dependencies": { "@inquirer/core": "^12.0.6", "@inquirer/type": "4.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w=="], + + "@inquirer/core": ["@inquirer/core@12.0.6", "", { "dependencies": { "@inquirer/ansi": "^2.0.10", "@inquirer/figures": "^2.0.10", "@inquirer/type": "4.2.1", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.10", "", {}, "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A=="], + + "@inquirer/type": ["@inquirer/type@4.2.1", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -268,6 +290,14 @@ "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -290,6 +320,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@posthog/core": ["@posthog/core@1.15.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-n2/Yy0+qc8xhmlcOFiYqTcGHBZuuaQjVolfFXk7yTCynzdMe8Fx1zYvPPUrbdQK5tWwXyilkzybpqhK6I7aV4Q=="], "@posthog/types": ["@posthog/types@1.336.1", "", {}, "sha512-KSGst/a/HK7GhfLSbwAy35HtU3KjDqjLtq3+PoDlGfbz9SbO0owjc6jo6hAHnMz67QTSvrn/r0xgimDO4NQ+rA=="], @@ -612,6 +644,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.104.5", "", {}, "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.104.5", "", { "dependencies": { "@tanstack/query-core": "5.104.5" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], @@ -680,6 +716,10 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -732,6 +772,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.6", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.6", "vitest": "3.2.6" }, "optionalPeers": ["@vitest/browser"] }, "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw=="], + "@vitest/expect": ["@vitest/expect@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], "@vitest/mocker": ["@vitest/mocker@3.2.6", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="], @@ -770,21 +812,25 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="], + "axe-core": ["axe-core@4.14.0", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="], + "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], @@ -822,10 +868,18 @@ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -842,6 +896,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], "cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], @@ -918,6 +974,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], "emoji-picker-react": ["emoji-picker-react@4.17.2", "", { "dependencies": { "flairup": "1.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-d70sXdqaorBsvYwNAjRUkV8VQUQTafN/XTjucl6lH2O/B+aFUiIZy5hpRJ0FxuzgVnkeJaydnRCrYpuGd0zhgQ=="], @@ -952,7 +1010,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -962,8 +1020,14 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], @@ -976,6 +1040,8 @@ "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], @@ -994,22 +1060,28 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "happy-dom": ["happy-dom@20.10.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1024,8 +1096,12 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], @@ -1064,10 +1140,22 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], @@ -1144,7 +1232,9 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], + "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -1246,11 +1336,11 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], @@ -1270,6 +1360,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msw": ["msw@2.15.0", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "hasInstallScript": true, "bin": { "msw": "cli/index.js" } }, "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], @@ -1292,6 +1386,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1304,6 +1402,10 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "path2d": ["path2d@0.1.1", "", {}, "sha512-/+S03c8AGsDYKKBtRDqieTJv2GlkMb0bWjnqOgtF6MkjdUQ9a8ARAtxWf9NgKLGm2+WQr6+/tqJdU8HNGsIDoA=="], @@ -1412,6 +1514,8 @@ "remark-rehype": ["remark-rehype@10.1.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/mdast": "^3.0.0", "mdast-util-to-hast": "^12.1.0", "unified": "^10.0.0" } }, "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -1420,6 +1524,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rettime": ["rettime@0.11.12", "", {}, "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A=="], + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], @@ -1434,6 +1540,8 @@ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.2.2", "", {}, "sha512-df017qMeYuXo4emL09nlyMpi2i2MtEIKS/6jtXH3vmjFgIRsOi852/5RmhJ4EkvN3ZF8jXadM/EkTPXgc8yPIw=="], + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1442,7 +1550,7 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], @@ -1470,14 +1578,22 @@ "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], @@ -1486,10 +1602,14 @@ "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "svg-parser": ["svg-parser@2.0.4", "", {}, "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -1500,6 +1620,8 @@ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1514,6 +1636,12 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1522,6 +1650,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], @@ -1542,6 +1672,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="], + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1596,16 +1728,26 @@ "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1616,8 +1758,18 @@ "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@mapbox/node-pre-gyp/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], + "@mapbox/node-pre-gyp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], @@ -1634,6 +1786,8 @@ "@reduxjs/toolkit/redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -1652,7 +1806,7 @@ "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], @@ -1664,6 +1818,10 @@ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "hast-util-parse-selector/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "hastscript/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], @@ -1672,6 +1830,8 @@ "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1680,6 +1840,10 @@ "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "path-scurry/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -1688,26 +1852,54 @@ "refractor/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "uvu/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="], "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.5.0", "", {}, "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@mapbox/node-pre-gyp/make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + "recharts/react-redux/@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/frontend/package.json b/frontend/package.json index f9e88ea682..a106bc9e54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@rjsf/utils": "^5.8.1", "@rjsf/validator-ajv8": "^5.8.1", "@stripe/stripe-js": "^4.2.0", + "@tanstack/react-query": "5.104.5", "@tanstack/react-table": "^8.21.3", "axios": "1.16.0", "class-variance-authority": "^0.7.1", @@ -81,7 +82,7 @@ "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", - "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" + "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs" }, "browserslist": { "production": [ @@ -109,9 +110,12 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.4.0", + "@vitest/coverage-v8": "3.2.6", "autoprefixer": "^10.5.4", + "axe-core": "4.14.0", "baseline-browser-mapping": "^2.9.19", "happy-dom": "^20.10.2", + "msw": "2.15.0", "postcss": "^8.5.23", "postcss-value-parser": "^4.2.0", "tailwindcss": "^4", diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 0bf54e99f7..39c24d786b 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -93,7 +93,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | | `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | | `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | -| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | +| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/` and `test-fixtures/v1-harness/`. The harness is documented in `test/README.md` | Biome (`biome.json`) also reports the banned imports and the plain spellings of the v0 ↔ v1 boundary, so `biome ci` and editors flag them too: `@/`, `../`, diff --git a/frontend/src/v1/test/README.md b/frontend/src/v1/test/README.md new file mode 100644 index 0000000000..f1eec5558e --- /dev/null +++ b/frontend/src/v1/test/README.md @@ -0,0 +1,189 @@ +# v1 test harness + +Shared helpers for v1 unit tests, imported as `@v1/test`. Only test files +(`*.test.ts(x)`, `*.spec.ts(x)`) and this folder may import it. + +## Running + +| Command | Runs | +| --- | --- | +| `bun run test:v1` | `vitest.v1.config.mts` (src/v1 minus `plugins/`), `vitest.v1-plugins.config.mts` (cloud v1 plugins), then the guardrail fixtures | +| `npx vitest --config vitest.v1.config.mts` | v1 in watch mode | +| `python -m tests.rig run frontend-v1-unit` | the rig group CI runs | + +v0 (`bun run test`, the rig's `frontend` group) excludes `src/v1/`, so a v1 +result never changes a v0 one and the reverse. Cloud v1 plugins +(`src/v1/plugins//`) run in the cloud overlay's `frontend-v1-unit-cloud` +group, never in `frontend-v1-unit`. + +Each v1 config enforces its own coverage threshold, 80% on lines, statements, +functions and branches. It writes to `coverage/v1` (and `coverage/v1-plugins`) +as text summary, `json-summary` and `lcov`. A miss fails the v1 group only. +Raise the threshold in `vitest.v1.config.mts` as v1 grows. Never lower it to +land a change. + +Coverage is measured on whole-suite runs only: in CI (anywhere `CI` is set, +which includes the rig in CI) and by `bun run test:v1`, which passes +`--coverage`. The threshold is global, so a focused run such as +`npx vitest run --config vitest.v1.config.mts src/v1/lib/cn.test.ts` skips it +and passes or fails on its tests alone. Add `--coverage` to measure one +anyway. + +What the config does for every test (`setup.ts`): + +- `globals: false`. Import `it`, `expect` and `vi` from `vitest`. +- jest-dom matchers, Testing Library cleanup, and removal of `#v1-portal`. +- `unstubGlobals`, `unstubEnvs`, `restoreMocks`. A `vi.stubGlobal`, + `vi.stubEnv` or `vi.spyOn` is undone after each test, so tests need no + `afterEach` for it. +- MSW handlers reset after each test, and **the test fails if it made a + request that no handler matched** (see below). +- No v0 transforms. A missing module fails to import (v1 has no optional + plugin imports), and `@/` does not resolve. + +## Rendering + +```tsx +import { screen } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; + +const { user, queryClient, portal } = renderV1(, { + route: "/adapters/llm", // MemoryRouter start; default "/" + path: "/adapters/:type", // mount under a pattern so useParams works + theme: "dark", // "light" (default) | "dark" | "unstract" +}); +await user.click(screen.getByRole("button", { name: "Add" })); +``` + +`renderV1` returns everything Testing Library's `render` does, plus: + +- `user`: a `userEvent.setup()` instance. Pass `userEventOptions`, for example + `{ advanceTimers: vi.advanceTimersByTime }` with fake timers. +- `queryClient`: a fresh client per render, with no retries, no refetch on + focus and no garbage collection mid-test. Pass `queryClient` to share one. +- `portal`: the `#v1-portal` container, themed like the root. +- `theme`: what the test rendered with. + +`renderHookV1(hook, { initialProps, ...sameOptions })` does the same for hooks. + +**No `session` or `distribution` option yet.** No v1 code reads either, so an +option that set one would reach nothing, and a test of an admin-only or +cloud-only branch would pass without exercising it. `renderV1` throws if +either is passed. Each comes back with the slot that reads it, when the +session store or plugin registry lands (see the table below). `TestSession` / +`makeSession` exist only as fixture data for network mocks (`apiPath`). + +### Providers, and swapping in the real ones + +`providers.tsx` is the only file that knows the provider chain. Each slot is +the app's own provider once its task has landed, and a stand-in until then: + +| Slot | Today | Replace with, when it lands | +| --- | --- | --- | +| query | stand-in: `QueryClientProvider` over `createTestQueryClient()` | the data layer's provider (P0-T05, UN-4313) | +| router | stand-in: `MemoryRouter` (+ `Routes` when `path` is set) | the app router in memory mode, with the route registry (P0-T03, UN-4271) | +| theme | **real**: `app/theme/ThemeProvider` (P0-T04, #2399) | — | +| session | none: no v1 code reads a session | the session store or provider, when it lands. Add `session` to `ProviderContext` and `RenderV1Options` with it | +| distribution | none: no v1 code reads a distribution | the v1 plugin registry's distribution (UN-4285 / UN-4286). Add `distribution` the same way | + +To swap one, change its component in `PROVIDER_SLOTS`. It receives the +resolved `ProviderContext` (query client, route, path, theme). Global state a +provider reads goes in `prepareProviders` (set before render) and +`resetProviders` (undone after each test). The theme slot shows the pattern: +the preference is stored, then `ThemeProvider` hydrates it, and the store and +its storage key are reset afterwards. A new option arrives with the slot that +reads it, plus a test that changing it changes what renders. Remove it from +`NOT_YET_CONSUMED` in `render.tsx` at the same time. + +## Network + +Every HTTP request (fetch and XHR, so axios too) and every WebSocket goes +through MSW. Register handlers per test with `mockApi` (`http.*` for HTTP, +`ws.link(url)` for sockets). They are dropped after the test. + +```ts +import { apiPath, DEFAULT_SESSION, HttpResponse, http, mockApi } from "@v1/test"; + +mockApi( + http.get(apiPath(DEFAULT_SESSION, "/adapter/"), () => + HttpResponse.json(adapterFixtures.list), + ), +); +``` + +`apiPath(session, "/adapter/")` builds the org-scoped +`/api/v1/unstract//adapter/`. + +**An unmocked request fails the test**, even when the code under test catches +the error. An HTTP request gets a network error and a WebSocket is closed +(code 1011), so nothing leaves the process. The test then fails with: + +```text +Error: Unmocked request (add a handler with mockApi() or server.use()): + GET http://localhost:3000/api/v1/unstract/org-1/adapter/?adapter_type=LLM +``` + +This covers requests made after the last assertion too. Ones started by an +unmount effect fail the test that unmounted. Ones made after a file's last test +(a timer firing during its `afterAll`) fail the file. Catch-all handlers do +this, and they always match last, even after `server.resetHandlers(...)` with +arguments. A test that leaves fake timers on gets real timers back after it. + +### Typed fixtures per service module + +Each service module in `services/` exports its request and response types. +Its fixtures live in `test/fixtures/.ts`, typed against those types +so a contract change breaks the fixture at typecheck: + +```ts +// test/fixtures/adapters.ts +import type { Adapter } from "@v1/services/adapters"; +import { apiPath, HttpResponse, http, type TestSession } from "@v1/test"; + +export const adapterFixtures = { + list: [{ id: "a1", adapter_name: "OpenAI", adapter_type: "LLM" }], +} satisfies Record; + +export const adapterHandlers = (session: TestSession) => [ + http.get(apiPath(session, "/adapter/"), () => + HttpResponse.json(adapterFixtures.list), + ), +]; +``` + +## Accessibility + +```ts +await expect(container).toHaveNoA11yViolations(); +await expectNoA11yViolations(screen.getByRole("dialog")); +const violations = await axeViolations(container); // inspect them yourself +``` + +These run axe-core. A failure lists each node as `[rule] (impact) selector: +help`, with the rule's URL. `color-contrast` is off (tests load no CSS) and so +is `region` (a component rendered alone is outside a page's landmarks). Pass +`{ rules: { region: { enabled: true } } }` for a whole-page test. Always +`await` the matcher. + +## Browser APIs happy-dom does not drive + +```ts +const size = installResizeObserver(); +size.resize(element, { width: 640, height: 480 }); + +const view = installIntersectionObserver(); +view.intersect(element, true); + +const media = mockMatchMedia({ dark: false }); +media.setDark(true); // fires (prefers-color-scheme: dark) listeners +``` + +Each wraps its callbacks in `act()` and is undone after the test. + +## Proof the harness fails when it should + +`test-fixtures/v1-harness/` runs real vitest processes, under `node --test` in +`bun run test:v1`. The known-bad cases are an unmocked fetch, XHR and +WebSocket, a request on unmount (and which test it fails), a request after the +last test, and `resetHandlers` with arguments. The known-good cases are a mocked +fetch and fake timers left on. diff --git a/frontend/src/v1/test/a11y.ts b/frontend/src/v1/test/a11y.ts new file mode 100644 index 0000000000..f6457f6097 --- /dev/null +++ b/frontend/src/v1/test/a11y.ts @@ -0,0 +1,97 @@ +import axe from "axe-core"; +import { expect } from "vitest"; + +/* + * Accessibility assertions on axe-core. Two rules are off by default: + * `color-contrast` (tests load no CSS, so every colour is the browser + * default) and `region` (a component rendered alone is not inside a page's + * landmarks). Pass `rules` to turn either back on for a whole-page test. + */ + +export type A11yOptions = axe.RunOptions; + +const DEFAULT_RULES: NonNullable = { + "color-contrast": { enabled: false }, + region: { enabled: false }, +}; + +// axe-core allows one run at a time ("Axe is already running"), so runs are +// chained: overlapping assertions wait their turn instead of throwing. +let queue: Promise = Promise.resolve(); + +/** Runs axe over `target` and returns its violations. */ +export function axeViolations( + target: Element = document.body, + options: A11yOptions = {}, +): Promise { + const run = queue.then(async () => { + const results = await axe.run(target, { + ...options, + rules: { ...DEFAULT_RULES, ...options.rules }, + }); + return results.violations; + }); + queue = run.catch(() => undefined); + return run; +} + +/** One line per failing node: rule, impact, selector and axe's summary. */ +export function formatViolations(violations: readonly axe.Result[]): string { + return violations + .flatMap((violation) => + violation.nodes.map( + (node) => + ` [${violation.id}] (${violation.impact ?? "unknown"}) ${node.target.join(" ")}: ${violation.help}\n ${violation.helpUrl}`, + ), + ) + .join("\n"); +} + +/** Throws, listing every violation, when axe finds any in `target`. */ +export async function expectNoA11yViolations( + target: Element = document.body, + options: A11yOptions = {}, +): Promise { + const violations = await axeViolations(target, options); + if (violations.length > 0) { + throw new Error( + `Expected no accessibility violations, found:\n${formatViolations(violations)}`, + ); + } +} + +type A11yMatcherResult = { pass: boolean; message: () => string }; + +async function toHaveNoA11yViolations( + received: unknown, + options: A11yOptions = {}, +): Promise { + if (!(received instanceof Element)) { + return { + pass: false, + message: () => "toHaveNoA11yViolations expects a DOM element", + }; + } + const violations = await axeViolations(received, options); + return { + pass: violations.length === 0, + message: () => + violations.length === 0 + ? "Expected accessibility violations, found none" + : `Expected no accessibility violations, found:\n${formatViolations(violations)}`, + }; +} + +/** Registers `await expect(element).toHaveNoA11yViolations()`; setup.ts calls it. */ +export function registerA11yMatchers(): void { + expect.extend({ toHaveNoA11yViolations }); +} + +interface A11yMatchers { + toHaveNoA11yViolations(options?: A11yOptions): Promise; +} + +declare module "vitest" { + interface Assertion extends A11yMatchers {} + interface AsymmetricMatchersContaining extends A11yMatchers {} +} diff --git a/frontend/src/v1/test/dom.ts b/frontend/src/v1/test/dom.ts new file mode 100644 index 0000000000..7c5c72f7bc --- /dev/null +++ b/frontend/src/v1/test/dom.ts @@ -0,0 +1,213 @@ +import { act } from "react"; +import { vi } from "vitest"; + +/* + * Controllable stand-ins for browser APIs happy-dom does not drive. Each + * install uses vi.stubGlobal, which the v1 config undoes after every test + * (`unstubGlobals: true`), so no test needs its own afterEach. + */ + +type Size = Readonly<{ width: number; height: number }>; + +export type ResizeObserverControl = Readonly<{ + /** Elements currently observed, across every observer. */ + observed: () => Element[]; + /** Reports a new size for `target` to every observer watching it. */ + resize: (target: Element, size: Size) => void; +}>; + +/** Replaces ResizeObserver with one the test drives through `resize`. */ +export function installResizeObserver(): ResizeObserverControl { + const observers = new Set(); + + class FakeResizeObserver implements ResizeObserver { + readonly targets = new Set(); + constructor(private readonly callback: ResizeObserverCallback) { + observers.add(this); + } + observe(target: Element): void { + // Re-registers an instance reused after disconnect(), as browsers allow. + observers.add(this); + this.targets.add(target); + } + unobserve(target: Element): void { + this.targets.delete(target); + } + disconnect(): void { + this.targets.clear(); + observers.delete(this); + } + notify(target: Element, { width, height }: Size): void { + const contentRect = new DOMRect(0, 0, width, height); + const boxSize = [{ inlineSize: width, blockSize: height }]; + const entry = { + target, + contentRect, + borderBoxSize: boxSize, + contentBoxSize: boxSize, + devicePixelContentBoxSize: boxSize, + } as unknown as ResizeObserverEntry; + this.callback([entry], this); + } + } + + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + return { + observed: () => [...observers].flatMap((o) => [...o.targets]), + resize: (target, size) => { + act(() => { + for (const observer of observers) { + if (observer.targets.has(target)) { + observer.notify(target, size); + } + } + }); + }, + }; +} + +export type IntersectionObserverControl = Readonly<{ + /** Elements currently observed, across every observer. */ + observed: () => Element[]; + /** Reports `target` entering (true) or leaving (false) the viewport. */ + intersect: (target: Element, isIntersecting: boolean) => void; +}>; + +/** Replaces IntersectionObserver with one the test drives through `intersect`. */ +export function installIntersectionObserver(): IntersectionObserverControl { + const observers = new Set(); + + class FakeIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null; + readonly rootMargin: string; + readonly scrollMargin = "0px"; + readonly thresholds: readonly number[]; + readonly targets = new Set(); + constructor( + private readonly callback: IntersectionObserverCallback, + options: IntersectionObserverInit = {}, + ) { + this.root = options.root ?? null; + this.rootMargin = options.rootMargin ?? "0px"; + const threshold = options.threshold ?? 0; + this.thresholds = Array.isArray(threshold) ? threshold : [threshold]; + observers.add(this); + } + observe(target: Element): void { + // Re-registers an instance reused after disconnect(), as browsers allow. + observers.add(this); + this.targets.add(target); + } + unobserve(target: Element): void { + this.targets.delete(target); + } + disconnect(): void { + this.targets.clear(); + observers.delete(this); + } + takeRecords(): IntersectionObserverEntry[] { + return []; + } + notify(target: Element, isIntersecting: boolean): void { + const rect = target.getBoundingClientRect(); + const entry = { + target, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + boundingClientRect: rect, + intersectionRect: isIntersecting ? rect : new DOMRect(), + rootBounds: null, + time: performance.now(), + } as IntersectionObserverEntry; + this.callback([entry], this); + } + } + + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + return { + observed: () => [...observers].flatMap((o) => [...o.targets]), + intersect: (target, isIntersecting) => { + act(() => { + for (const observer of observers) { + if (observer.targets.has(target)) { + observer.notify(target, isIntersecting); + } + } + }); + }, + }; +} + +export type MatchMediaControl = Readonly<{ + /** Flips `(prefers-color-scheme: dark)` and notifies its listeners. */ + setDark: (dark: boolean) => void; + /** Number of change listeners currently attached, across all queries. */ + listenerCount: () => number; +}>; + +type MediaListener = (event: MediaQueryListEvent) => void; + +const DARK_QUERY = "(prefers-color-scheme: dark)"; + +/** + * Replaces matchMedia. Only `(prefers-color-scheme: dark)` is ever true, and + * only while `dark` is set; every other query reports false. + */ +export function mockMatchMedia({ + dark = false, +}: { + dark?: boolean; +} = {}): MatchMediaControl { + let prefersDark = dark; + const listeners = new Map>(); + const listenersFor = (query: string) => { + let set = listeners.get(query); + if (!set) { + set = new Set(); + listeners.set(query, set); + } + return set; + }; + const matches = (query: string) => query === DARK_QUERY && prefersDark; + + vi.stubGlobal( + "matchMedia", + (query: string): MediaQueryList => + ({ + media: query, + get matches() { + return matches(query); + }, + onchange: null, + addEventListener: (_type: string, listener: MediaListener) => { + listenersFor(query).add(listener); + }, + removeEventListener: (_type: string, listener: MediaListener) => { + listenersFor(query).delete(listener); + }, + addListener: (listener: MediaListener) => { + listenersFor(query).add(listener); + }, + removeListener: (listener: MediaListener) => { + listenersFor(query).delete(listener); + }, + dispatchEvent: () => true, + }) as unknown as MediaQueryList, + ); + + return { + setDark: (value) => { + prefersDark = value; + act(() => { + for (const listener of listenersFor(DARK_QUERY)) { + listener({ + matches: value, + media: DARK_QUERY, + } as MediaQueryListEvent); + } + }); + }, + listenerCount: () => + [...listeners.values()].reduce((sum, set) => sum + set.size, 0), + }; +} diff --git a/frontend/src/v1/test/harness.test.tsx b/frontend/src/v1/test/harness.test.tsx new file mode 100644 index 0000000000..652bc4bee8 --- /dev/null +++ b/frontend/src/v1/test/harness.test.tsx @@ -0,0 +1,412 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { screen } from "@testing-library/react"; +import { + apiPath, + axeViolations, + DEFAULT_SESSION, + expectNoA11yViolations, + HttpResponse, + http, + installIntersectionObserver, + installResizeObserver, + makeSession, + mockApi, + mockMatchMedia, + PORTAL_CONTAINER_ID, + renderHookV1, + renderV1, + server, + ws, +} from "@v1/test"; +import axe from "axe-core"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLocation, useParams } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { takeUnhandledRequests, unhandledRequestsMessage } from "./network"; + +describe("renderV1", () => { + it("wraps the UI in the v1 root with the theme, and creates a themed portal", () => { + const { portal } = renderV1(

hello

, { theme: "dark" }); + const root = screen.getByText("hello").parentElement as HTMLElement; + expect(root).toHaveClass("v1"); + expect(root.dataset.uiVersion).toBe("v1"); + expect(root.dataset.theme).toBe("dark"); + expect(portal.id).toBe(PORTAL_CONTAINER_ID); + expect(portal).toHaveClass("v1"); + expect(portal.dataset.theme).toBe("dark"); + }); + + it("removes the portal between tests", () => { + expect(document.getElementById(PORTAL_CONTAINER_ID)).toBeNull(); + renderV1(

again

); + expect(document.getElementById(PORTAL_CONTAINER_ID)?.dataset.theme).toBe( + "light", + ); + }); + + it("renders portalled content into #v1-portal", () => { + function Menu() { + return createPortal( +
items
, + document.getElementById(PORTAL_CONTAINER_ID) as HTMLElement, + ); + } + const { portal } = renderV1(); + expect(portal).toContainElement(screen.getByRole("menu")); + }); + + it("starts the router at `route` and resolves params under `path`", () => { + function Page() { + const { id } = useParams(); + const location = useLocation(); + return ( +

+ {id} at {location.pathname} +

+ ); + } + renderV1(, { route: "/workflows/42", path: "/workflows/:id" }); + expect(screen.getByText("42 at /workflows/42")).toBeInTheDocument(); + }); + + it("refuses session and distribution, which no provider reads yet", () => { + // @ts-expect-error: not a render option until a slot consumes it. + expect(() => renderV1(

x

, { session: null })).toThrow( + /renderV1: session is not supported yet; no v1 provider reads it/, + ); + // @ts-expect-error: not a render option until a slot consumes it. + expect(() => renderV1(

x

, { distribution: "cloud" })).toThrow( + /renderV1: distribution is not supported yet/, + ); + const both = { session: DEFAULT_SESSION, distribution: "onprem" }; + expect(() => renderHookV1(() => 1, both as object)).toThrow( + /session and distribution are not supported yet; no v1 provider reads them/, + ); + }); + + it("changes what renders for each option it accepts", () => { + // One at a time: the theme is app-wide, so a second root re-stamps all. + for (const theme of ["light", "dark", "unstract"] as const) { + const { portal, unmount } = renderV1(

{theme}

, { theme }); + expect(screen.getByText(theme).parentElement?.dataset.theme).toBe(theme); + expect(portal.dataset.theme).toBe(theme); + unmount(); + } + }); + + it("builds org-scoped API paths from a session fixture", () => { + const session = makeSession({ organization: { id: "org-9" } }); + expect(session.organization.name).toBe(DEFAULT_SESSION.organization.name); + expect(apiPath(session, "/adapter/")).toBe( + "/api/v1/unstract/org-9/adapter/", + ); + }); + + it("gives each render a fresh query client with retries off", () => { + const first = renderV1(

a

).queryClient; + const second = renderV1(

b

).queryClient; + expect(first).not.toBe(second); + expect(first.getDefaultOptions().queries?.retry).toBe(false); + expect(first.getDefaultOptions().mutations?.retry).toBe(false); + }); + + it("returns a user-event instance bound to the render", async () => { + function Counter() { + const [count, setCount] = useState(0); + return ( + + ); + } + const { user } = renderV1(); + await user.click(screen.getByRole("button")); + expect(screen.getByRole("button")).toHaveTextContent("1"); + }); +}); + +describe("renderHookV1", () => { + it("runs the hook inside the providers", () => { + const { result, queryClient } = renderHookV1(() => useQueryClient()); + expect(result.current).toBe(queryClient); + }); + + it("passes initialProps", () => { + const { result, rerender } = renderHookV1((n: number) => n * 2, { + initialProps: 2, + }); + expect(result.current).toBe(4); + rerender(5); + expect(result.current).toBe(10); + }); +}); + +describe("network mocking", () => { + it("serves typed handlers registered with mockApi", async () => { + type Adapter = { id: string; name: string }; + const url = apiPath(DEFAULT_SESSION, "/adapter/"); + expect(url).toBe("/api/v1/unstract/org-1/adapter/"); + mockApi( + http.get(url, () => + HttpResponse.json([{ id: "a1", name: "OpenAI" }]), + ), + ); + function Adapters() { + const { data } = useQuery({ + queryKey: ["adapters"], + queryFn: async (): Promise => (await fetch(url)).json(), + }); + return ( +
    + {data?.map((a) => ( +
  • {a.name}
  • + ))} +
+ ); + } + renderV1(); + expect(await screen.findByText("OpenAI")).toBeInTheDocument(); + }); + + it("records an unmocked request with its method and absolute URL", async () => { + await expect(fetch("/api/v1/unmocked?x=1")).rejects.toThrow(); + const unhandled = takeUnhandledRequests(); + expect(unhandled).toEqual([ + "GET http://localhost:3000/api/v1/unmocked?x=1", + ]); + expect(unhandledRequestsMessage(unhandled)).toContain( + "Unmocked request (add a handler", + ); + }); + + it("errors an unmocked request made by a component, and names it", async () => { + function Loader() { + const [state, setState] = useState("loading"); + useEffect(() => { + fetch("/api/v1/unstract/org-1/missing/").then( + () => setState("loaded"), + () => setState("failed"), + ); + }, []); + return

{state}

; + } + renderV1(); + expect(await screen.findByText("failed")).toBeInTheDocument(); + // Drained here so this test passes; setup.ts would otherwise fail it. + expect(takeUnhandledRequests()).toEqual([ + "GET http://localhost:3000/api/v1/unstract/org-1/missing/", + ]); + }); + + it("answers an unmocked XHR (axios's transport) with a network error", async () => { + const outcome = await new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/v1/xhr"); + xhr.onload = () => resolve(`loaded ${xhr.status}`); + xhr.onerror = () => resolve("error"); + xhr.send("{}"); + }); + expect(outcome).toBe("error"); + expect(takeUnhandledRequests()).toEqual([ + "POST http://localhost:3000/api/v1/xhr", + ]); + }); + + it("closes an unmocked WebSocket and records its URL", async () => { + const code = await new Promise((resolve) => { + const socket = new WebSocket("ws://localhost:3000/api/v1/socket"); + socket.onclose = (event) => resolve(event.code); + }); + expect(code).toBe(1011); + expect(takeUnhandledRequests()).toEqual([ + "WEBSOCKET ws://localhost:3000/api/v1/socket", + ]); + }); + + it("leaves a mocked WebSocket open and unrecorded", async () => { + const socket = ws.link("ws://localhost:3000/api/v1/socket"); + mockApi( + socket.addEventListener("connection", ({ client }) => { + client.addEventListener("message", (event) => { + client.send(`echo ${String(event.data)}`); + }); + }), + ); + const reply = await new Promise((resolve, reject) => { + const client = new WebSocket("ws://localhost:3000/api/v1/socket"); + client.onopen = () => client.send("hi"); + client.onmessage = (event) => resolve(String(event.data)); + client.onclose = (event) => reject(new Error(`closed ${event.code}`)); + }); + expect(reply).toBe("echo hi"); + expect(takeUnhandledRequests()).toEqual([]); + }); + + it("keeps the catch-all when handlers are reset with arguments", async () => { + server.resetHandlers(http.get("/api/v1/known", () => HttpResponse.json(1))); + expect(await (await fetch("/api/v1/known")).json()).toBe(1); + await expect(fetch("/api/v1/other")).rejects.toThrow(); + expect(takeUnhandledRequests()).toEqual([ + "GET http://localhost:3000/api/v1/other", + ]); + }); + + it("pluralises the message for several requests", () => { + expect(unhandledRequestsMessage(["GET /a", "POST /b"])).toBe( + "Unmocked requests (add a handler with mockApi() or server.use()):\n GET /a\n POST /b", + ); + }); +}); + +describe("accessibility assertions", () => { + it("passes accessible markup", async () => { + const { container } = renderV1( + , + ); + await expect(container).toHaveNoA11yViolations(); + await expectNoA11yViolations(container); + }); + + it("names the rule and the node for a violation", async () => { + const { container } = renderV1(); + const violations = await axeViolations(container); + expect(violations.map((v) => v.id)).toContain("label"); + await expect(expectNoA11yViolations(container)).rejects.toThrow( + /\[label\] \(critical\) input/, + ); + await expect(expect(container).toHaveNoA11yViolations()).rejects.toThrow( + /Expected no accessibility violations/, + ); + }); + + it("rejects a non-element", async () => { + await expect(expect("text").toHaveNoA11yViolations()).rejects.toThrow( + /expects a DOM element/, + ); + }); + + it("supports .not for a known violation", async () => { + // A button with no accessible name: axe's `button-name`. + const { container } = renderV1(); + const results = await Promise.all([ + axeViolations(container), + axeViolations(container), + expectNoA11yViolations(container), + ]); + expect(results.slice(0, 2)).toEqual([[], []]); + }); + + it("keeps the queue going after a failed run", async () => { + vi.spyOn(axe, "run").mockRejectedValueOnce(new Error("axe broke")); + await expect(axeViolations(container())).rejects.toThrow("axe broke"); + expect(await axeViolations(container())).toEqual([]); + }); +}); + +function container(): Element { + return renderV1(

text

).container; +} + +describe("DOM stand-ins", () => { + it("drives ResizeObserver callbacks", () => { + const control = installResizeObserver(); + const target = document.createElement("div"); + const sizes: number[] = []; + const observer = new ResizeObserver((entries) => { + sizes.push(entries[0]?.contentRect.width ?? -1); + }); + observer.observe(target); + expect(control.observed()).toEqual([target]); + control.resize(target, { width: 640, height: 480 }); + control.resize(document.createElement("span"), { width: 1, height: 1 }); + expect(sizes).toEqual([640]); + observer.unobserve(target); + control.resize(target, { width: 320, height: 240 }); + expect(sizes).toEqual([640]); + observer.disconnect(); + expect(control.observed()).toEqual([]); + }); + + it("drives IntersectionObserver callbacks", () => { + const control = installIntersectionObserver(); + const target = document.createElement("div"); + const seen: boolean[] = []; + const observer = new IntersectionObserver( + (entries) => { + seen.push(entries[0]?.isIntersecting ?? false); + }, + { threshold: [0, 1], rootMargin: "10px" }, + ); + expect(observer.thresholds).toEqual([0, 1]); + expect(observer.rootMargin).toBe("10px"); + expect(observer.takeRecords()).toEqual([]); + observer.observe(target); + control.intersect(target, true); + control.intersect(target, false); + expect(seen).toEqual([true, false]); + observer.unobserve(target); + control.intersect(target, true); + expect(seen).toEqual([true, false]); + expect(new IntersectionObserver(() => undefined).thresholds).toEqual([0]); + observer.disconnect(); + expect(control.observed()).toEqual([]); + }); + + it("keeps driving an observer reused after disconnect()", () => { + const size = installResizeObserver(); + const view = installIntersectionObserver(); + const target = document.createElement("div"); + const seen: string[] = []; + const resize = new ResizeObserver(() => seen.push("resize")); + const intersect = new IntersectionObserver(() => seen.push("intersect")); + for (const observer of [resize, intersect]) { + observer.observe(target); + observer.disconnect(); + observer.observe(target); + } + size.resize(target, { width: 1, height: 1 }); + view.intersect(target, true); + expect(seen).toEqual(["resize", "intersect"]); + }); + + it("drives the dark-scheme media query", () => { + const control = mockMatchMedia(); + const query = window.matchMedia("(prefers-color-scheme: dark)"); + expect(query.matches).toBe(false); + expect(window.matchMedia("(min-width: 1px)").matches).toBe(false); + const seen: boolean[] = []; + const listener = (event: MediaQueryListEvent) => seen.push(event.matches); + query.addEventListener("change", listener); + query.addListener(listener); + expect(control.listenerCount()).toBe(1); + control.setDark(true); + expect(query.matches).toBe(true); + expect(seen).toEqual([true]); + query.removeEventListener("change", listener); + query.removeListener(listener); + expect(control.listenerCount()).toBe(0); + }); + + it("starts dark when asked", () => { + mockMatchMedia({ dark: true }); + expect(window.matchMedia("(prefers-color-scheme: dark)").matches).toBe( + true, + ); + }); + + it("restores the real globals after each test", () => { + // Stubbed by the tests above; `unstubGlobals: true` undid them. + expect(window.ResizeObserver.name).not.toBe("FakeResizeObserver"); + expect(window.IntersectionObserver.name).not.toBe( + "FakeIntersectionObserver", + ); + }); +}); diff --git a/frontend/src/v1/test/index.ts b/frontend/src/v1/test/index.ts new file mode 100644 index 0000000000..54196ce0ae --- /dev/null +++ b/frontend/src/v1/test/index.ts @@ -0,0 +1,50 @@ +/* + * The v1 test harness, imported by tests as `@v1/test`. Only test files + * (`*.test.ts(x)`, `*.spec.ts(x)`) and test/ itself may import it. + */ + +export { PORTAL_CONTAINER_ID, V1_ROOT_CLASS } from "@v1/lib/portal"; +export { + type A11yOptions, + axeViolations, + expectNoA11yViolations, + formatViolations, +} from "./a11y"; +export { + type IntersectionObserverControl, + installIntersectionObserver, + installResizeObserver, + type MatchMediaControl, + mockMatchMedia, + type ResizeObserverControl, +} from "./dom"; +export { + apiPath, + delay, + HttpResponse, + http, + mockApi, + server, + ws, +} from "./network"; +export { + createTestQueryClient, + type ProviderContext, + type ProviderSlot, +} from "./providers"; +export { + type RenderV1Options, + renderHookV1, + renderV1, + type V1Harness, +} from "./render"; +export { + DEFAULT_SESSION, + DEFAULT_THEME, + makeSession, + type TestOrganization, + type TestSession, + type TestSessionOverrides, + type TestUser, + type ThemeMode, +} from "./session"; diff --git a/frontend/src/v1/test/network.ts b/frontend/src/v1/test/network.ts new file mode 100644 index 0000000000..303f84c756 --- /dev/null +++ b/frontend/src/v1/test/network.ts @@ -0,0 +1,87 @@ +import { + HttpResponse, + http, + type RequestHandler, + type WebSocketHandler, + ws, +} from "msw"; +import { setupServer } from "msw/node"; +import type { TestSession } from "./session"; + +/* + * Network mocking at the HTTP boundary. MSW intercepts fetch, XHR (so axios + * too) and WebSocket. setup.ts starts the server, resets handlers after each + * test, and fails the test if any request went unmocked. + */ + +export { delay, HttpResponse, http, ws } from "msw"; + +const unhandledRequests: string[] = []; + +/* + * Unmocked requests are answered by catch-all handlers, not by MSW's + * `onUnhandledRequest: "error"`. Under happy-dom that strategy does not stop + * an HTTP request: happy-dom's fetch falls through to Node's http, which MSW + * intercepts a second time, and then the request really goes out. For a + * WebSocket it only logs, and the mocked socket opens. A handler that + * returns a network error, or closes the socket, ends it in-process. + */ +const unmockedSocketHandler: WebSocketHandler = ws + .link(/.*/) + .addEventListener("connection", ({ client }) => { + // MSW runs EVERY matching WebSocket handler (HTTP stops at the first), so + // step aside when a test's own ws.link() handles this URL. + const mocked = server + .listHandlers() + .some( + (handler) => + handler !== unmockedSocketHandler && + handler.kind === "websocket" && + handler.test(client.url), + ); + if (mocked) { + return; + } + unhandledRequests.push(`WEBSOCKET ${client.url.href}`); + client.close(1011, "Unmocked WebSocket"); + }); + +const unmockedHandlers: (RequestHandler | WebSocketHandler)[] = [ + http.all("*", ({ request }) => { + unhandledRequests.push(`${request.method} ${request.url}`); + return HttpResponse.error(); + }), + unmockedSocketHandler, +]; + +export const server = setupServer(...unmockedHandlers); + +/* + * server.use() prepends, so the catch-alls always match last. resetHandlers + * with arguments would replace them, so it is pinned to re-append them. + */ +const resetHandlers = server.resetHandlers.bind(server); +server.resetHandlers = (...handlers) => + resetHandlers(...handlers, ...unmockedHandlers); + +/** Returns and clears the requests no handler matched since the last call. */ +export function takeUnhandledRequests(): string[] { + return unhandledRequests.splice(0); +} + +/** The message a test fails with when it made unmocked requests. */ +export function unhandledRequestsMessage(requests: readonly string[]): string { + return [ + `Unmocked request${requests.length === 1 ? "" : "s"} (add a handler with mockApi() or server.use()):`, + ...requests.map((request) => ` ${request}`), + ].join("\n"); +} + +/** Adds handlers for the current test only; they are reset after it. */ +export const mockApi: typeof server.use = (...handlers) => + server.use(...handlers); + +/** v0's org-scoped API prefix, `/api/v1/unstract/`. */ +export function apiPath(session: TestSession, path: `/${string}`): string { + return `/api/v1/unstract/${session.organization.id}${path}`; +} diff --git a/frontend/src/v1/test/providers.tsx b/frontend/src/v1/test/providers.tsx new file mode 100644 index 0000000000..296fcead21 --- /dev/null +++ b/frontend/src/v1/test/providers.tsx @@ -0,0 +1,113 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@v1/app/theme/ThemeProvider"; +import { getPortalContainer, PORTAL_CONTAINER_ID } from "@v1/lib/portal"; +import { THEME_STORAGE_KEY } from "@v1/lib/theme"; +import { useThemeStore } from "@v1/stores/theme"; +import type { JSX, ReactNode } from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { ThemeMode } from "./session"; + +/* + * The v1 provider chain for tests: the ONLY file to edit when a real provider + * lands. A slot is the app's own provider once its task has merged, and a + * stand-in until then: + * + * query stand-in P0-T05 data layer (UN-4313) → the app's QueryClient provider + * router stand-in P0-T03 router + registry (UN-4271) → the app's router, in memory + * theme real app/theme/ThemeProvider (P0-T04, #2399) + * + * Swap a slot by changing its entry in PROVIDER_SLOTS (and, if it holds + * global state, prepareProviders / resetProviders); renderV1 and every test + * stay as they are. See README.md in this folder. + * + * Every field here is read by a slot. A new one (a session, a distribution) + * arrives together with the slot that reads it, never ahead of it. + */ + +/** Everything a slot may need, resolved from renderV1's options. */ +export type ProviderContext = Readonly<{ + queryClient: QueryClient; + /** The URL the router starts at. */ + route: string; + /** A route pattern to mount the UI under, so `useParams` resolves. */ + path: string | undefined; + theme: ThemeMode; +}>; + +export type ProviderSlot = ( + props: Readonly<{ context: ProviderContext; children: ReactNode }>, +) => JSX.Element; + +/** A QueryClient for one test: no retries, no refetch on focus, no GC mid-test. */ +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }); +} + +/** + * Global state the providers read, set before render: the theme preference + * ThemeProvider hydrates from, and the #v1-portal container (created by the + * app's own getPortalContainer, themed like the root). Returns the portal. + */ +export function prepareProviders(context: ProviderContext): HTMLElement { + useThemeStore.getState().setPreference(context.theme); + const portal = getPortalContainer(); + portal.dataset.theme = context.theme; + return portal; +} + +/** Undoes prepareProviders; setup.ts calls it after each test. */ +export function resetProviders(): void { + document.getElementById(PORTAL_CONTAINER_ID)?.remove(); + try { + window.localStorage.removeItem(THEME_STORAGE_KEY); + } catch { + // No storage, so nothing was persisted. + } + useThemeStore.setState(useThemeStore.getInitialState(), true); +} + +const QuerySlot: ProviderSlot = ({ context, children }) => ( + + {children} + +); + +const RouterSlot: ProviderSlot = ({ context, children }) => ( + + {context.path === undefined ? ( + children + ) : ( + + + + )} + +); + +// The app's v1 root; it hydrates the preference prepareProviders stored. +const ThemeSlot: ProviderSlot = ({ children }) => ( + {children} +); + +/** Outermost first. */ +export const PROVIDER_SLOTS: readonly ProviderSlot[] = [ + QuerySlot, + RouterSlot, + ThemeSlot, +]; + +export function V1TestProviders({ + context, + children, +}: Readonly<{ context: ProviderContext; children: ReactNode }>): JSX.Element { + let tree: ReactNode = children; + for (const Slot of [...PROVIDER_SLOTS].reverse()) { + tree = {tree}; + } + return <>{tree}; +} diff --git a/frontend/src/v1/test/render.tsx b/frontend/src/v1/test/render.tsx new file mode 100644 index 0000000000..e72a253db8 --- /dev/null +++ b/frontend/src/v1/test/render.tsx @@ -0,0 +1,103 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + type RenderHookResult, + type RenderResult, + render, + renderHook, +} from "@testing-library/react"; +import userEvent, { type UserEvent } from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { + createTestQueryClient, + type ProviderContext, + prepareProviders, + V1TestProviders, +} from "./providers"; +import { DEFAULT_THEME, type ThemeMode } from "./session"; + +export type RenderV1Options = Readonly<{ + /** The URL the router starts at. Default `/`. */ + route?: string; + /** Mount the UI under this route pattern, so `useParams` resolves. */ + path?: string; + /** Default `light`. */ + theme?: ThemeMode; + /** Pass one to share a cache across renders; default is a fresh client. */ + queryClient?: QueryClient; + /** Options for the returned `user` (e.g. `{ advanceTimers }` with fake timers). */ + userEventOptions?: Parameters[0]; +}>; + +export type V1Harness = Readonly<{ + user: UserEvent; + queryClient: QueryClient; + theme: ThemeMode; + /** The #v1-portal container menus, dialogs and toasts render into. */ + portal: HTMLElement; +}>; + +/* + * No v1 code reads a session or a distribution yet, so renderV1 takes + * neither: an option no provider consumes would let a test of an admin-only + * or cloud-only branch pass without reaching it. Each comes back, with the + * slot that reads it, when the session store or plugin registry lands (see + * README.md). Until then a caller that passes one anyway (a cast, plain JS) + * fails loudly. + */ +const NOT_YET_CONSUMED = ["session", "distribution"] as const; + +function rejectUnconsumedOptions(options: object): void { + const passed = NOT_YET_CONSUMED.filter((key) => key in options); + if (passed.length > 0) { + throw new Error( + `renderV1: ${passed.join(" and ")} ${passed.length === 1 ? "is" : "are"} not supported yet; no v1 provider reads ${passed.length === 1 ? "it" : "them"}, so the test would not exercise what it sets. See src/v1/test/README.md.`, + ); + } +} + +function resolveContext(options: RenderV1Options): ProviderContext { + rejectUnconsumedOptions(options); + return { + queryClient: options.queryClient ?? createTestQueryClient(), + route: options.route ?? "/", + path: options.path, + theme: options.theme ?? DEFAULT_THEME, + }; +} + +function prepare(options: RenderV1Options) { + const context = resolveContext(options); + const harness: V1Harness = { + user: userEvent.setup(options.userEventOptions), + queryClient: context.queryClient, + theme: context.theme, + portal: prepareProviders(context), + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { harness, wrapper }; +} + +/** Renders `ui` inside the v1 providers. */ +export function renderV1( + ui: ReactNode, + options: RenderV1Options = {}, +): RenderResult & V1Harness { + const { harness, wrapper } = prepare(options); + const result: RenderResult = render(ui, { wrapper }); + return { ...result, ...harness }; +} + +/** Renders a hook inside the v1 providers. */ +export function renderHookV1( + hook: (props: Props) => Result, + options: RenderV1Options & Readonly<{ initialProps?: Props }> = {}, +): RenderHookResult & V1Harness { + const { harness, wrapper } = prepare(options); + const result: RenderHookResult = renderHook( + hook, + { wrapper, initialProps: options.initialProps }, + ); + return { ...result, ...harness }; +} diff --git a/frontend/src/v1/test/session.ts b/frontend/src/v1/test/session.ts new file mode 100644 index 0000000000..11949953c7 --- /dev/null +++ b/frontend/src/v1/test/session.ts @@ -0,0 +1,52 @@ +import type { ThemeMode } from "@v1/lib/theme"; + +/** A colour mode the v1 root can carry (#2399's ThemeMode). */ +export type { ThemeMode }; + +export type TestUser = Readonly<{ + id: string; + email: string; + displayName: string; +}>; + +export type TestOrganization = Readonly<{ + id: string; + name: string; +}>; + +/** + * A signed-in user and organization, as fixture data for network mocks + * (`apiPath(session, …)` builds the org-scoped URL). Not a render option: no + * v1 provider reads a session yet. + */ +export type TestSession = Readonly<{ + user: TestUser; + organization: TestOrganization; + role: "admin" | "user"; +}>; + +export type TestSessionOverrides = Readonly<{ + user?: Partial; + organization?: Partial; + role?: TestSession["role"]; +}>; + +export const DEFAULT_SESSION: TestSession = { + user: { id: "user-1", email: "ada@example.com", displayName: "Ada" }, + organization: { id: "org-1", name: "Example Org" }, + role: "admin", +}; + +export const DEFAULT_THEME: ThemeMode = "light"; + +/** DEFAULT_SESSION with `overrides` merged one level deep. */ +export function makeSession(overrides: TestSessionOverrides = {}): TestSession { + return { + user: { ...DEFAULT_SESSION.user, ...overrides.user }, + organization: { + ...DEFAULT_SESSION.organization, + ...overrides.organization, + }, + role: overrides.role ?? DEFAULT_SESSION.role, + }; +} diff --git a/frontend/src/v1/test/setup.ts b/frontend/src/v1/test/setup.ts new file mode 100644 index 0000000000..81f3dbd720 --- /dev/null +++ b/frontend/src/v1/test/setup.ts @@ -0,0 +1,70 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; +import { registerA11yMatchers } from "./a11y"; +import { + server, + takeUnhandledRequests, + unhandledRequestsMessage, +} from "./network"; +import { resetProviders } from "./providers"; + +/* + * Runs before every v1 test file (vitest.v1.config.mts `setupFiles`). + * `globals: false` means Testing Library cannot register its own cleanup, so + * it is done here. + */ + +registerA11yMatchers(); + +beforeAll(() => { + // The catch-all handlers in network.ts answer every unmocked HTTP request + // and WebSocket, so this only fires for what they cannot match. + server.listen({ onUnhandledRequest: "error" }); +}); + +/** Lets requests already started (e.g. by an unmount effect) reach MSW. */ +async function settle(): Promise { + // A test that left fake timers on would stall the wait below forever. + if (vi.isFakeTimers()) { + vi.useRealTimers(); + } + // Two macrotask turns, one after the other; the unmount cases in + // test-fixtures/v1-harness fail if this stops being enough. + await nextTurn(); + await nextTurn(); +} + +function nextTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function failOnUnhandledRequests(): void { + const unhandled = takeUnhandledRequests(); + if (unhandled.length > 0) { + throw new Error(unhandledRequestsMessage(unhandled)); + } +} + +afterEach(async () => { + // Unmount first: cleanup effects may make requests, which belong to this + // test and must still see its handlers. + cleanup(); + resetProviders(); + await settle(); + server.resetHandlers(); + // Thrown from afterEach so it fails the test even when the code under test + // caught the network error. + failOnUnhandledRequests(); +}); + +afterAll(async () => { + // Requests made after the last test's afterEach (a timer firing during the + // file's own afterAll) fail the file rather than vanish. + await settle(); + try { + failOnUnhandledRequests(); + } finally { + server.close(); + } +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts index feb52d7f4f..2852e6a726 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts @@ -1,4 +1,4 @@ -import { renderLabel } from "@v1/test/render"; +import { renderLabel } from "@v1/test/case-helper"; import { expect, it } from "vitest"; import { label } from "./Label"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts index 73138391d0..1eca5673eb 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts @@ -1,4 +1,4 @@ -import { renderLabel } from "@v1/test/render"; +import { renderLabel } from "@v1/test/case-helper"; import { title } from "./title"; export const rendered = renderLabel(title); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/case-helper.ts similarity index 100% rename from frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts rename to frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/case-helper.ts diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts index 3ae7c334e1..ddaf650c5a 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts @@ -1,3 +1,3 @@ -import { render } from "@v1/test/render"; +import { render } from "@v1/test/case-helper"; export const helper = render("x"); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/case-helper.ts similarity index 100% rename from frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts rename to frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/case-helper.ts diff --git a/frontend/test-fixtures/v1-harness/cases/fake-timers-left-on.test.ts b/frontend/test-fixtures/v1-harness/cases/fake-timers-left-on.test.ts new file mode 100644 index 0000000000..8b3bb32a10 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/fake-timers-left-on.test.ts @@ -0,0 +1,11 @@ +import { expect, it, vi } from "vitest"; + +// Known good: a test that leaves fake timers on must not hang the harness. +it("leaves fake timers on", () => { + vi.useFakeTimers(); + expect(vi.isFakeTimers()).toBe(true); +}); + +it("runs on real timers afterwards", () => { + expect(vi.isFakeTimers()).toBe(false); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/mocked-fetch.test.ts b/frontend/test-fixtures/v1-harness/cases/mocked-fetch.test.ts new file mode 100644 index 0000000000..03e01d1af8 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/mocked-fetch.test.ts @@ -0,0 +1,16 @@ +import { + apiPath, + DEFAULT_SESSION, + HttpResponse, + http, + mockApi, +} from "@v1/test"; +import { expect, it } from "vitest"; + +// Known good: the same request with a handler passes. +it("loads adapters with a handler", async () => { + const url = apiPath(DEFAULT_SESSION, "/adapter/"); + mockApi(http.get(url, () => HttpResponse.json([{ id: "a1" }]))); + const response = await fetch(url); + expect(await response.json()).toEqual([{ id: "a1" }]); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/reset-handlers-with-args.test.ts b/frontend/test-fixtures/v1-harness/cases/reset-handlers-with-args.test.ts new file mode 100644 index 0000000000..5b76c36f7b --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/reset-handlers-with-args.test.ts @@ -0,0 +1,9 @@ +import { HttpResponse, http, server } from "@v1/test"; +import { expect, it } from "vitest"; + +// Known bad: resetHandlers with arguments must not disable the guard. +it("resets to its own handlers, then calls an unmocked URL", async () => { + server.resetHandlers(http.get("/api/v1/known", () => HttpResponse.json({}))); + await (await fetch("/api/v1/known")).json(); + await expect(fetch("/api/v1/unstract/org-1/unknown/")).rejects.toThrow(); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-after-last-test.test.ts b/frontend/test-fixtures/v1-harness/cases/unmocked-after-last-test.test.ts new file mode 100644 index 0000000000..9409dd6faf --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-after-last-test.test.ts @@ -0,0 +1,12 @@ +import { afterAll, it } from "vitest"; + +// Known bad: the request fires from a timer after the last test's afterEach. +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); +}); + +it("schedules a late poll without a handler", () => { + setTimeout(() => { + fetch("/api/v1/unstract/org-1/poll/").catch(() => undefined); + }, 30); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-fetch.test.tsx b/frontend/test-fixtures/v1-harness/cases/unmocked-fetch.test.tsx new file mode 100644 index 0000000000..fce489dd5a --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-fetch.test.tsx @@ -0,0 +1,21 @@ +import { screen } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import { useEffect, useState } from "react"; +import { expect, it } from "vitest"; + +// Known bad: the component swallows the network error, so only the harness +// can fail this test. +it("loads adapters without a handler", async () => { + function Adapters() { + const [state, setState] = useState("loading"); + useEffect(() => { + fetch("/api/v1/unstract/org-1/adapter/?type=LLM").then( + () => setState("loaded"), + () => setState("failed"), + ); + }, []); + return

{state}

; + } + renderV1(); + expect(await screen.findByText("failed")).toBeInTheDocument(); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount-attribution.test.tsx b/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount-attribution.test.tsx new file mode 100644 index 0000000000..ae33a8f569 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount-attribution.test.tsx @@ -0,0 +1,21 @@ +import { renderV1 } from "@v1/test"; +import { useEffect } from "react"; +import { it } from "vitest"; + +// Known bad, first test only: its unmount request must fail it, not the next. +it("first: requests on unmount", () => { + function Editor() { + useEffect( + () => () => { + fetch("/api/v1/unstract/org-1/unmount/").catch(() => undefined); + }, + [], + ); + return

editing

; + } + renderV1(); +}); + +it("second: makes no request", async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount.test.tsx b/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount.test.tsx new file mode 100644 index 0000000000..6ebfc105bf --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-on-unmount.test.tsx @@ -0,0 +1,19 @@ +import { renderV1 } from "@v1/test"; +import { useEffect } from "react"; +import { it } from "vitest"; + +// Known bad: the only test in the file; its request starts during cleanup(). +it("reports a draft on unmount without a handler", () => { + function Editor() { + useEffect( + () => () => { + fetch("/api/v1/unstract/org-1/draft/", { method: "POST" }).catch( + () => undefined, + ); + }, + [], + ); + return

editing

; + } + renderV1(); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-websocket.test.ts b/frontend/test-fixtures/v1-harness/cases/unmocked-websocket.test.ts new file mode 100644 index 0000000000..2cc4424506 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-websocket.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest"; + +// Known bad: an unmocked WebSocket (v0's socket.io uses this transport). +it("opens a socket without a handler", async () => { + const closed = await new Promise((resolve) => { + const socket = new WebSocket("ws://localhost:3000/api/v1/socket"); + socket.onclose = () => resolve(true); + setTimeout(() => resolve(false), 1000); + }); + expect(closed).toBe(true); +}); diff --git a/frontend/test-fixtures/v1-harness/cases/unmocked-xhr.test.ts b/frontend/test-fixtures/v1-harness/cases/unmocked-xhr.test.ts new file mode 100644 index 0000000000..5f642969d4 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/cases/unmocked-xhr.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; + +// Known bad: an unmocked XHR (the transport axios uses), error handled. +it("posts without a handler", async () => { + const outcome = await new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/v1/unstract/org-1/workflow/"); + xhr.onload = () => resolve("loaded"); + xhr.onerror = () => resolve("error"); + xhr.send("{}"); + }); + expect(outcome).toBe("error"); +}); diff --git a/frontend/test-fixtures/v1-harness/harness.fixtures.mjs b/frontend/test-fixtures/v1-harness/harness.fixtures.mjs new file mode 100644 index 0000000000..38111e82ae --- /dev/null +++ b/frontend/test-fixtures/v1-harness/harness.fixtures.mjs @@ -0,0 +1,104 @@ +/* + * Proves the v1 test harness fails a test when it should. Run by + * `bun run test:v1` with `node --test`: each case is a real vitest run of the + * v1 config (via ./vitest.config.mts) over one file in ./cases, which live + * outside src/ so the v1 suite, lint and typecheck never pick them up. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CONFIG = path.join("test-fixtures", "v1-harness", "vitest.config.mts"); +// The lockfile's vitest, run by this same node: nothing resolved from PATH. +const VITEST = path.join(FRONTEND, "node_modules", "vitest", "vitest.mjs"); + +function runCase(file) { + const result = spawnSync( + process.execPath, + [ + VITEST, + "run", + "--config", + CONFIG, + path.join("test-fixtures", "v1-harness", "cases", file), + ], + { cwd: FRONTEND, encoding: "utf8", env: { ...process.env, CI: "1" } }, + ); + // Colour codes would split the reporter's lines the assertions match on. + const output = `${result.stdout}${result.stderr}`.replace( + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes + /\u001b\[[0-9;]*m/g, + "", + ); + return { status: result.status, output }; +} + +const CASES = [ + { + file: "unmocked-fetch.test.tsx", + fails: true, + names: "GET http://localhost:3000/api/v1/unstract/org-1/adapter/?type=LLM", + }, + { + file: "unmocked-xhr.test.ts", + fails: true, + names: "POST http://localhost:3000/api/v1/unstract/org-1/workflow/", + }, + { + file: "unmocked-websocket.test.ts", + fails: true, + names: "WEBSOCKET ws://localhost:3000/api/v1/socket", + }, + { + // The request starts in cleanup(), after the test body, in the last test. + file: "unmocked-on-unmount.test.tsx", + fails: true, + names: "POST http://localhost:3000/api/v1/unstract/org-1/draft/", + }, + { + // ...and fails the test that unmounted, not the one after it. + file: "unmocked-on-unmount-attribution.test.tsx", + fails: true, + names: "GET http://localhost:3000/api/v1/unstract/org-1/unmount/", + summary: /Tests\s+1 failed \| 1 passed/, + failed: "first: requests on unmount", + }, + { + // Fired by a timer after the last test's afterEach. + file: "unmocked-after-last-test.test.ts", + fails: true, + names: "GET http://localhost:3000/api/v1/unstract/org-1/poll/", + }, + { + // resetHandlers(...handlers) must keep the catch-all. + file: "reset-handlers-with-args.test.ts", + fails: true, + names: "GET http://localhost:3000/api/v1/unstract/org-1/unknown/", + }, + { file: "mocked-fetch.test.ts", fails: false }, + // A test that leaves fake timers on must not hang the next one's teardown. + { file: "fake-timers-left-on.test.ts", fails: false }, +]; + +for (const { file, fails, names, summary, failed } of CASES) { + test(`${file} ${fails ? "fails, naming the request" : "passes"}`, () => { + const { status, output } = runCase(file); + if (fails) { + assert.notEqual(status, 0, output); + assert.match(output, /Unmocked request \(add a handler/); + assert.ok(output.includes(names), output); + // Answered by the harness, never sent: no connection was attempted. + assert.doesNotMatch(output, /ECONNREFUSED/); + } else { + assert.equal(status, 0, output); + } + if (summary) { + assert.match(output, summary); + } + if (failed) { + assert.match(output, new RegExp(`FAIL .*> ${failed}`)); + } + }); +} diff --git a/frontend/test-fixtures/v1-harness/vitest.config.mts b/frontend/test-fixtures/v1-harness/vitest.config.mts new file mode 100644 index 0000000000..af36841ef3 --- /dev/null +++ b/frontend/test-fixtures/v1-harness/vitest.config.mts @@ -0,0 +1,19 @@ +import path from "node:path"; +import { defineV1Config } from "../../vitest.v1.config.mts"; + +/* + * The real v1 config (same setup file, same harness), pointed at the + * known-bad cases in ./cases instead of src/v1. Used only by + * harness.fixtures.mjs; coverage is off because it measures nothing here. + */ +const base = defineV1Config("core"); + +export default { + ...base, + root: path.resolve(import.meta.dirname, "..", ".."), + test: { + ...base.test, + include: ["test-fixtures/v1-harness/cases/*.test.{ts,tsx}"], + coverage: { enabled: false }, + }, +}; diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 25cbe07f68..5d6b2a8f0c 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -113,12 +113,16 @@ export default defineConfig({ * known-good and known-bad trees (some with `*.test.ts` files) that * test-fixtures/v1-boundary/boundary.fixtures.mjs lints and type-checks; * they are inputs, not tests. + * + * `src/v1/` has its own config (vitest.v1.config.mts) and rig groups + * (`frontend-v1-*`), so a v1 result never changes this suite's. */ exclude: [ "**/node_modules/**", "**/dist/**", "**/build/**", "test-fixtures/**", + "src/v1/**", ], }, }); diff --git a/frontend/vitest.v1-plugins.config.mts b/frontend/vitest.v1-plugins.config.mts new file mode 100644 index 0000000000..014d4d7d9a --- /dev/null +++ b/frontend/vitest.v1-plugins.config.mts @@ -0,0 +1,4 @@ +import { defineV1Config } from "./vitest.v1.config.mts"; + +// Cloud v1 plugins under src/v1/plugins (the rig's `frontend-v1-unit-cloud`). +export default defineV1Config("plugins"); diff --git a/frontend/vitest.v1.config.mts b/frontend/vitest.v1.config.mts new file mode 100644 index 0000000000..52e9adf06f --- /dev/null +++ b/frontend/vitest.v1.config.mts @@ -0,0 +1,107 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import svgr from "vite-plugin-svgr"; +import { defineConfig } from "vitest/config"; + +/* + * Vitest config for the v1 frontend (src/v1), run by the rig's + * `frontend-v1-unit` group and by `bun run test:v1`. The v0 config excludes + * src/v1, so the two suites never share a run, a result or a coverage report. + * + * A separate file rather than Vitest `projects`: coverage is root-level in + * projects mode, so v1 could not have its own report directory or threshold. + * + * Deliberately NOT built from vitest.config.mjs. v0's jsxInJs() serves .js + * files v1 may not contain, and its optionalPluginImports() stubs any missing + * import under a `/plugins/` path, while in v1 a missing module is a bug + * (UN-4286). Only `@v1` is aliased, so an `@/` import fails here as it does + * in typecheck:v1. svgr serves `@v1/assets/*.svg?react`, as in vite.config.js. + */ + +type Scope = "core" | "plugins"; + +const V1_ROOT = "src/v1"; +const PLUGINS_ROOT = `${V1_ROOT}/plugins`; + +type ScopeSpec = { + root: string; + name: string; + reports: string; + covers: string; +}; + +const SCOPES: Record = { + // Everything under src/v1 except plugins/, which the cloud overlay fills. + core: { + root: V1_ROOT, + name: "frontend-v1", + reports: "coverage/v1", + covers: `${V1_ROOT}/**/*.{ts,tsx}`, + }, + // Cloud v1 plugins only (`frontend-v1-unit-cloud`), so a cloud plugin + // failure never reddens the OSS group. Coverage counts the plugin folders + // (plugins//), not OSS's placeholder plugins/manifest.ts, which no + // plugin test exercises. + plugins: { + root: PLUGINS_ROOT, + name: "frontend-v1-plugins", + reports: "coverage/v1-plugins", + covers: `${PLUGINS_ROOT}/*/**/*.{ts,tsx}`, + }, +}; + +// Ratchet upward as v1 grows; see src/v1/test/README.md. +const COVERAGE_THRESHOLDS = { + lines: 80, + statements: 80, + functions: 80, + branches: 80, +}; + +export function defineV1Config(scope: Scope) { + const { root, name, reports, covers } = SCOPES[scope]; + const scopeExclude = scope === "core" ? [`${PLUGINS_ROOT}/**`] : []; + return defineConfig({ + plugins: [react(), svgr()], + resolve: { + alias: { "@v1": path.resolve(import.meta.dirname, V1_ROOT) }, + }, + test: { + name, + environment: "happy-dom", + // Explicit `import { it, expect } from "vitest"`: the v1 tsconfig has no + // vitest globals, so a global would type-check nowhere. + globals: false, + css: false, + setupFiles: [`./${V1_ROOT}/test/setup.ts`], + include: [`${root}/**/*.{test,spec}.{ts,tsx}`], + exclude: ["**/node_modules/**", ...scopeExclude], + // OSS has no plugin tests, and v1 is young; an empty run is not a failure. + passWithNoTests: true, + unstubGlobals: true, + unstubEnvs: true, + restoreMocks: true, + coverage: { + // The threshold is global, so a focused run (one file, one folder) + // would fail it however its tests do. Measured only for whole-suite + // runs: CI (the rig and every workflow set CI) and `bun run test:v1`, + // which passes --coverage. Locally, add --coverage to measure. + enabled: Boolean(process.env.CI), + provider: "v8", + reportsDirectory: reports, + reporter: ["text-summary", "json-summary", "lcov"], + reportOnFailure: true, + include: [covers], + exclude: [ + ...scopeExclude, + `${V1_ROOT}/test/**`, + "**/*.{test,spec}.{ts,tsx}", + "**/*.d.ts", + ], + thresholds: COVERAGE_THRESHOLDS, + }, + }, + }); +} + +export default defineV1Config("core"); diff --git a/tests/groups.yaml b/tests/groups.yaml index cdfbcf823f..d2bf05925d 100644 --- a/tests/groups.yaml +++ b/tests/groups.yaml @@ -57,12 +57,28 @@ groups: # `npx --no-install vitest` fails and turns the group red. A host with no # npx at all (cloud's self-hosted runner) still skips via the runner's # exit 5, which folds green for non-pytest groups. + # + # Excludes src/v1, which vitest.config.mjs leaves to `frontend-v1-unit`. tier: unit runner: vitest workdir: frontend paths: [src] timeout_seconds: 900 + frontend-v1-unit: + # The v1 frontend's unit tests (epic UN-4257, D10 prefix): src/v1 minus + # plugins/, under its own config (vitest.v1.config.mts), so a v1 result + # never changes the `frontend` group's and the reverse. The config also + # enforces v1's own coverage threshold, reported to frontend/coverage/v1. + # Cloud v1 plugins run in the cloud overlay's `frontend-v1-unit-cloud`. + # Same Node prerequisites as `frontend`. + tier: unit + runner: vitest + workdir: frontend + vitest_config: vitest.v1.config.mts + paths: [src/v1] + timeout_seconds: 900 + unit-rig: # Rig self-tests. Run from repo root so the `tests.rig` import resolves. tier: unit diff --git a/tests/rig/cli.py b/tests/rig/cli.py index 6c4f2e1605..a6e2c83762 100644 --- a/tests/rig/cli.py +++ b/tests/rig/cli.py @@ -19,6 +19,7 @@ import sys import time import uuid +import xml.etree.ElementTree as ET from functools import lru_cache from pathlib import Path from urllib.parse import urlsplit @@ -952,6 +953,11 @@ def _execute_group( # backfill one to keep a red run visible in the summary. if not junit.exists(): _write_synthetic_junit_safe(junit, group.name, exit_code) + elif exit_code == 0 and _junit_test_count(junit) == 0: + # vitest under `passWithNoTests` exits 0 having run nothing. Report + # it with the rig's "nothing collected" code, as pytest would, so + # it reads ⚪ rather than a green row that verified nothing. + exit_code = 5 elif (isolation_error := _config_isolation_error(group, workdir)) is not None: print(isolation_error, file=sys.stderr) # 4 is pytest's usage-error code, so this gates like any hard failure. @@ -1247,6 +1253,8 @@ def _node_command( "--reporter=junit", f"--outputFile={junit}", ] + if group.vitest_config is not None: + cmd += ["--config", group.vitest_config] # `paths` are optional for vitest: with none it runs the whole suite, # which is what the `frontend` group wants. cmd += [str(p) for p in group.paths if p not in (".", "")] @@ -1261,6 +1269,24 @@ def _node_command( return cmd +def _junit_test_count(junit: Path) -> int | None: + """The `tests` total a junit report declares, or None if it can't tell.""" + try: + root = ET.parse(junit).getroot() + except (ET.ParseError, OSError): + return None + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + counted = [s for s in suites if "tests" in s.attrib] or ( + [root] if "tests" in root.attrib else [] + ) + if not counted: + return None + try: + return sum(int(s.attrib["tests"]) for s in counted) + except ValueError: + return None + + def _hurl_command(group: GroupDefinition, workdir: Path) -> list[str]: files: list[str] = [] for p in group.paths: diff --git a/tests/rig/groups.py b/tests/rig/groups.py index 46084b7a7a..bf189bf25a 100644 --- a/tests/rig/groups.py +++ b/tests/rig/groups.py @@ -80,6 +80,10 @@ class GroupDefinition: timeout_seconds: int = 600 parallel: bool = True optional: bool = False + # vitest only: the config file, relative to `workdir`, passed as + # `--config`. Unset runs vitest's default config, as before. Lets one + # workdir hold suites that must never share a run (frontend v0 and v1). + vitest_config: str | None = None def __post_init__(self) -> None: # Freeze env: a frozen dataclass with a mutable dict still lets callers @@ -251,6 +255,18 @@ def _build_group( f"group {name!r}: `timeout_seconds` must be an integer " f"(got {spec.get('timeout_seconds')!r})" ) from exc + vitest_config = spec.get("vitest_config") + if vitest_config is not None: + if runner != "vitest": + raise ValueError( + f"group {name!r}: `vitest_config` applies only to runner " + f"`vitest` (runner is {runner!r})" + ) + if not isinstance(vitest_config, str) or not vitest_config.strip(): + raise ValueError( + f"group {name!r}: `vitest_config` must be a non-empty path " + f"(got {vitest_config!r})" + ) return GroupDefinition( name=name, @@ -272,6 +288,7 @@ def _build_group( timeout_seconds=timeout, parallel=bool(spec.get("parallel", defaults.get("parallel", True))), optional=bool(spec.get("optional", False)), + vitest_config=vitest_config, ) @@ -330,6 +347,25 @@ def _validate_paths(groups: dict[str, GroupDefinition]) -> None: for p in g.absolute_paths(): if not p.exists(): raise ValueError(f"group {name!r}: test path does not exist: {p}") + if g.vitest_config is not None: + _validate_vitest_config(name, g.vitest_config, wd) + + +def _validate_vitest_config(name: str, config: str, workdir: Path) -> None: + """The config must be a file inside the group's workdir. + + Checked at load, like the paths: vitest given a missing `--config` exits + before writing JUnit, which surfaces as one synthetic red row with no hint + that the manifest, not the suite, is wrong. + """ + resolved = (workdir / config).resolve() + if not resolved.is_relative_to(workdir.resolve()): + raise ValueError( + f"group {name!r}: `vitest_config` must be inside workdir {workdir} " + f"(got {config!r})" + ) + if not resolved.is_file(): + raise ValueError(f"group {name!r}: `vitest_config` does not exist: {resolved}") def _validate_platform_groups_depend_on_gate( diff --git a/tests/rig/reporting.py b/tests/rig/reporting.py index 897b9f895e..eac50d9ce5 100644 --- a/tests/rig/reporting.py +++ b/tests/rig/reporting.py @@ -106,6 +106,11 @@ def parse_junit(group_name: str, tier: str, reports_dir: Path) -> GroupResult | root = tree.getroot() suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + if not suites and "tests" in root.attrib: + # vitest's report for a run with no test files: the counters sit on + # the root and there is no at all. That is a + # complete report of zero tests, not a truncated write. + suites = [root] passed = failed = errors = skipped = 0 duration = 0.0 diff --git a/tests/rig/tests/conftest.py b/tests/rig/tests/conftest.py index 8ac21c9f23..cc8f3f7bc3 100644 --- a/tests/rig/tests/conftest.py +++ b/tests/rig/tests/conftest.py @@ -6,3 +6,21 @@ bypass path validation by always marking synthetic groups as ``optional`` (or by creating the directories the test references). """ + +import pytest + +from tests.rig.groups import EXTRA_MANIFESTS_ENV + + +@pytest.fixture(autouse=True) +def _no_inherited_extra_manifests(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the runner's overlay manifests out of the synthetic ones. + + Cloud CI exports UNSTRACT_RIG_EXTRA_MANIFESTS for the whole job, and + load_groups() merges it into whatever DEFAULT_MANIFEST a test patched in. + An overlay group that depends on an OSS group (every `requires_platform` + one depends on `e2e-smoke`) then fails validation against a synthetic + manifest that never defines it. Tests that exercise overlays set the + variable themselves. + """ + monkeypatch.delenv(EXTRA_MANIFESTS_ENV, raising=False) diff --git a/tests/rig/tests/test_cli.py b/tests/rig/tests/test_cli.py index 9f755f93b7..720e453574 100644 --- a/tests/rig/tests/test_cli.py +++ b/tests/rig/tests/test_cli.py @@ -869,6 +869,105 @@ def test_node_command_vitest_points_reporter_at_group_junit( assert cmd[-1] == "src" +def test_node_command_vitest_passes_the_group_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`vitest_config` reaches vitest as `--config`, ahead of the paths; without + it the command is exactly as before (vitest's default config). + """ + import tests.rig.cli as cli_mod + from tests.rig.groups import GroupDefinition + + monkeypatch.setattr( + cli_mod.shutil, "which", lambda name: "/usr/bin/npx" if name == "npx" else None + ) + junit = tmp_path / "junit.xml" + group = GroupDefinition( + name="frontend-v1-unit", tier="unit", paths=("src/v1",), runner="vitest", + workdir="frontend", vitest_config="vitest.v1.config.mts", + ) + assert cli_mod._node_command(group, junit=junit) == [ + "npx", "--no-install", "vitest", "run", "--reporter=junit", + f"--outputFile={junit}", "--config", "vitest.v1.config.mts", "src/v1", + ] + plain = GroupDefinition( + name="frontend", tier="unit", paths=("src",), runner="vitest", + workdir="frontend", + ) + assert "--config" not in cli_mod._node_command(plain, junit=junit) + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + # vitest, no test files: counters on the root only. + ('', 0), + ( + '' + "", + 3, + ), + ('', 4), + ("", None), + ("', None), + ], +) +def test_junit_test_count(tmp_path: Path, content: str, expected: int | None) -> None: + import tests.rig.cli as cli_mod + + junit = tmp_path / "junit.xml" + junit.write_text(content) + assert cli_mod._junit_test_count(junit) == expected + + +def test_vitest_group_that_ran_nothing_reports_exit_5( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """vitest exits 0 under passWithNoTests; the rig records exit 5 ("nothing + collected") so the row is ⚪ and an empty plugins tree never reads as a + verified green run. + """ + import tests.rig.cli as cli_mod + from tests.rig.groups import GroupDefinition + + workdir = tmp_path / "frontend" + workdir.mkdir() + (workdir / "src").mkdir() + + def fake_spawn(cmd, *, env, cwd, timeout): + out = next(c for c in cmd if c.startswith("--outputFile=")) + Path(out.split("=", 1)[1]).write_text( + '' + ) + return 0 + + monkeypatch.setattr(cli_mod, "_spawn", fake_spawn) + monkeypatch.setattr(cli_mod, "_prepare_group_env", lambda group, *, env: None) + monkeypatch.setattr( + cli_mod.shutil, "which", lambda name: "/usr/bin/npx" if name == "npx" else None + ) + group = GroupDefinition( + name="frontend-v1-unit-cloud", tier="unit", paths=("src",), runner="vitest", + workdir=str(workdir), + ) + result, exit_code = cli_mod._execute_group( + group, + reports_dir=tmp_path / "reports", + marker=None, + paths_override=None, + coverage=False, + parallel=False, + workers="1", + timeout=60, + endpoints=None, + ) + assert exit_code == 5 + assert result is not None + assert result.status == "empty" + assert (result.passed, result.failed, result.errors) == (0, 0, 0) + + def test_node_command_playwright_takes_junit_from_env_not_flag( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/rig/tests/test_groups.py b/tests/rig/tests/test_groups.py index 0eaf9e438f..22c7601522 100644 --- a/tests/rig/tests/test_groups.py +++ b/tests/rig/tests/test_groups.py @@ -465,3 +465,73 @@ def test_postgres_migrate_requires_workdir(tmp_path: Path) -> None: ) with pytest.raises(ValueError, match="workdir"): load_groups(manifest) + + +def _vitest_manifest(tmp_path: Path, extra: str, *, runner: str = "vitest") -> Path: + workdir = tmp_path / "frontend" + workdir.mkdir(exist_ok=True) + (workdir / "src").mkdir(exist_ok=True) + return _write_manifest( + tmp_path, + f""" + version: 1 + groups: + web: + tier: unit + runner: {runner} + workdir: {workdir} + paths: [src] + {extra} + """, + ) + + +def test_vitest_config_is_loaded(tmp_path: Path) -> None: + manifest = _vitest_manifest(tmp_path, "vitest_config: vitest.v1.config.mts") + (tmp_path / "frontend" / "vitest.v1.config.mts").write_text("export default {}") + assert load_groups(manifest).get("web").vitest_config == "vitest.v1.config.mts" + + +def test_vitest_config_defaults_to_none(tmp_path: Path) -> None: + assert load_groups(_vitest_manifest(tmp_path, "")).get("web").vitest_config is None + + +def test_vitest_config_rejected_on_other_runners(tmp_path: Path) -> None: + manifest = _vitest_manifest( + tmp_path, "vitest_config: vitest.config.mjs", runner="playwright" + ) + with pytest.raises(ValueError, match="applies only to runner `vitest`"): + load_groups(manifest) + + +@pytest.mark.parametrize("value", ['""', "[a.mts]", "3"]) +def test_vitest_config_must_be_a_path(tmp_path: Path, value: str) -> None: + manifest = _vitest_manifest(tmp_path, f"vitest_config: {value}") + with pytest.raises(ValueError, match="must be a non-empty path"): + load_groups(manifest) + + +def test_vitest_config_must_exist(tmp_path: Path) -> None: + manifest = _vitest_manifest(tmp_path, "vitest_config: missing.config.mts") + with pytest.raises(ValueError, match="`vitest_config` does not exist"): + load_groups(manifest) + + +def test_vitest_config_must_stay_inside_workdir(tmp_path: Path) -> None: + (tmp_path / "outside.config.mts").write_text("export default {}") + manifest = _vitest_manifest(tmp_path, "vitest_config: ../outside.config.mts") + with pytest.raises(ValueError, match="must be inside workdir"): + load_groups(manifest) + + +def test_frontend_groups_run_separate_vitest_configs() -> None: + """v0 and v1 share a workdir, so only the config keeps one suite's result + out of the other's (UN-4329). `frontend` must stay on vitest's default + config, which excludes src/v1; `frontend-v1-unit` runs the v1 one. + """ + manifest = load_groups() + assert manifest.get("frontend").vitest_config is None + v1 = manifest.get("frontend-v1-unit") + assert v1.runner == "vitest" + assert v1.workdir == "frontend" + assert v1.vitest_config == "vitest.v1.config.mts" diff --git a/tests/rig/tests/test_reporting.py b/tests/rig/tests/test_reporting.py index 499ce58ba8..888a0e49bc 100644 --- a/tests/rig/tests/test_reporting.py +++ b/tests/rig/tests/test_reporting.py @@ -175,3 +175,32 @@ def test_passed_critical_path_ids_missing_or_malformed_junit(tmp_path: Path) -> assert passed_critical_path_ids("absent", tmp_path) == set() _write_junit(tmp_path / "broken", " root, no child. Verbatim shape from vitest 3.2.6. +VITEST_ZERO_TESTS = ( + '\n' + '\n' + "\n" +) + + +def test_vitest_zero_test_report_is_empty_not_truncated(tmp_path: Path) -> None: + """The rig maps a zero-test vitest run to exit 5, which must read as ⚪, + not as the ❌ "no counters" truncated-report error. + """ + _write_junit(tmp_path / "g", VITEST_ZERO_TESTS, exit_code=5) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert (result.passed, result.failed, result.errors) == (0, 0, 0) + assert result.status == "empty" + + +def test_counterless_testsuites_root_is_still_an_error(tmp_path: Path) -> None: + """Only a root that carries counters is a complete zero-test report.""" + _write_junit(tmp_path / "g", '', exit_code=0) + result = parse_junit("g", "unit", tmp_path) + assert result is not None + assert result.errors >= 1 + assert result.status == "fail"