diff --git a/eslint.config.js b/eslint.config.js index feba672e..5060e163 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,7 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import vitestPlugin from "@vitest/eslint-plugin"; import enforceZodV4 from "./eslint-rules/enforce-zod-v4.js"; -const testFiles = ["tests/**/*.test.ts", "tests/**/*.ts"]; +const testFiles = ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.ts", "tests/**/*.tsx"]; const files = [...testFiles, "src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts"]; diff --git a/package.json b/package.json index dd7a3344..e29917a4 100644 --- a/package.json +++ b/package.json @@ -96,13 +96,13 @@ "@modelcontextprotocol/inspector": "^0.17.1", "@mongodb-js/oidc-mock-provider": "^0.12.0", "@redocly/cli": "^2.0.8", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/express": "^5.0.3", "@types/node": "^24.5.2", "@types/proper-lockfile": "^4.1.4", "@types/react": "^18.3.0", "@types/react-dom": "^19.2.3", - "react": "^18.3.0", - "react-dom": "^18.3.0", "@types/semver": "^7.7.0", "@types/yargs-parser": "^21.0.3", "@typescript-eslint/parser": "^8.44.0", @@ -115,6 +115,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "globals": "^16.3.0", + "happy-dom": "^20.0.11", "husky": "^9.1.7", "knip": "^5.63.1", "mongodb": "^6.21.0", @@ -123,6 +124,8 @@ "openapi-typescript": "^7.9.1", "prettier": "^3.6.2", "proper-lockfile": "^4.1.2", + "react": "^18.3.0", + "react-dom": "^18.3.0", "semver": "^7.7.2", "simple-git": "^3.28.0", "testcontainers": "^11.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99d17540..d0c24fe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,12 @@ importers: '@redocly/cli': specifier: ^2.0.8 version: 2.11.1(@opentelemetry/api@1.9.0)(ajv@8.17.1)(core-js@3.47.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@9.3.1)(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/express': specifier: ^5.0.3 version: 5.0.6 @@ -135,10 +141,10 @@ importers: version: 5.1.1(vite@5.4.21(@types/node@24.10.1)) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)) '@vitest/eslint-plugin': specifier: ^1.3.4 - version: 1.4.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.4.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -157,6 +163,9 @@ importers: globals: specifier: ^16.3.0 version: 16.5.0 + happy-dom: + specifier: ^20.0.11 + version: 20.0.11 husky: specifier: ^9.1.7 version: 9.1.7 @@ -219,7 +228,7 @@ importers: version: 2.3.0(rollup@4.53.3)(vite@5.4.21(@types/node@24.10.1)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@mongodb-js/atlas-local': specifier: ^1.1.0 @@ -230,6 +239,12 @@ importers: packages: + '@acemir/cssom@0.9.28': + resolution: {integrity: sha512-LuS6IVEivI75vKN8S04qRD+YySP0RmU/cV8UNukhQZvprxF+76Z43TNo/a08eCodaGhT1Us8etqS1ZRY9/Or0A==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/azure@2.0.71': resolution: {integrity: sha512-AMwgXMHcs9uJoM+TaR6mPlmyUlP4JRcPV27Evou57StYWO9kUu/ygU2yjPMFwcaouu/Nl9mQki59mFNzF+03qQ==} engines: {node: '>=18'} @@ -280,6 +295,15 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@4.1.0': + resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -378,6 +402,40 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.14': + resolution: {integrity: sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -2031,6 +2089,25 @@ packages: resolution: {integrity: sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==} engines: {node: '>=14'} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -2100,6 +2177,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} @@ -2156,6 +2236,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -2543,6 +2626,9 @@ packages: peerDependencies: ajv: 4.11.8 - 8 + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2874,6 +2960,17 @@ packages: css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@5.3.4: + resolution: {integrity: sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2888,6 +2985,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2909,6 +3010,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decko@1.2.0: resolution: {integrity: sha512-m8FnyHXV1QX+S1cl+KPFDIl6NMkxtKsy6+U/aYyjrOqWMuwAwYWu7ePqrsUHtDR5Y8Yk2pi/KIDSgF+vT4cPOQ==} @@ -3015,6 +3119,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -3064,6 +3171,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3487,6 +3598,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.0.11: + resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3532,6 +3647,10 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3566,6 +3685,10 @@ packages: engines: {node: '>=18'} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.0: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} @@ -3589,6 +3712,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} @@ -3714,6 +3841,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3834,6 +3964,15 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@27.3.0: + resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3937,6 +4076,10 @@ packages: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3988,6 +4131,9 @@ packages: md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -4039,6 +4185,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -4394,6 +4544,9 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4712,6 +4865,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redoc@2.5.1: resolution: {integrity: sha512-LmqA+4A3CmhTllGG197F0arUpmChukAj9klfSdxNRemT9Hr07xXr7OGKu4PHzBs359sgrJ+4JwmOlM7nxLPGMg==} engines: {node: '>=6.9', npm: '>=3.0.0'} @@ -4816,6 +4973,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -5065,6 +5226,10 @@ packages: strip-dirs@2.1.0: resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5116,6 +5281,9 @@ packages: resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5189,6 +5357,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -5205,6 +5380,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5212,6 +5391,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5317,6 +5500,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -5524,6 +5710,10 @@ packages: zod: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -5539,10 +5729,30 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5622,6 +5832,13 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@2.1.2: resolution: {integrity: sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==} engines: {node: '>=0.4'} @@ -5696,6 +5913,11 @@ packages: snapshots: + '@acemir/cssom@0.9.28': + optional: true + + '@adobe/css-tools@4.4.4': {} + '@ai-sdk/azure@2.0.71(zod@3.25.76)': dependencies: '@ai-sdk/openai': 2.0.69(zod@3.25.76) @@ -5752,6 +5974,27 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@4.1.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + optional: true + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5874,6 +6117,36 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': + optional: true + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-syntax-patches-for-csstree@1.0.14(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + optional: true + + '@csstools/css-tokenizer@3.0.4': + optional: true + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -7573,6 +7846,25 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.1.3 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@9.3.1)(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 9.3.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tsconfig/node10@1.0.12': {} @@ -7661,6 +7953,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -7719,6 +8015,8 @@ snapshots: '@types/webidl-conversions@7.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -7884,7 +8182,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -7899,18 +8197,18 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.4.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/eslint-plugin@1.4.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@typescript-eslint/scope-manager': 8.47.0 '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -8186,6 +8484,11 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + optional: true + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -8581,6 +8884,23 @@ snapshots: css-color-keywords: 1.0.0 postcss-value-parser: 4.2.0 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + optional: true + + css.escape@1.5.1: {} + + cssstyle@5.3.4(postcss@8.5.6): + dependencies: + '@asamuzakjp/css-color': 4.1.0 + '@csstools/css-syntax-patches-for-csstree': 1.0.14(postcss@8.5.6) + css-tree: 3.1.0 + transitivePeerDependencies: + - postcss + optional: true + csstype@3.1.3: {} csstype@3.2.3: {} @@ -8589,6 +8909,12 @@ snapshots: data-uri-to-buffer@6.0.2: {} + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + optional: true + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8613,6 +8939,9 @@ snapshots: optionalDependencies: supports-color: 10.2.2 + decimal.js@10.6.0: + optional: true + decko@1.2.0: {} decompress-response@6.0.0: @@ -8762,6 +9091,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -8815,6 +9146,9 @@ snapshots: dependencies: once: 1.4.0 + entities@6.0.1: + optional: true + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -9413,6 +9747,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.0.11: + dependencies: + '@types/node': 20.19.25 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -9460,6 +9800,11 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + optional: true + html-escaper@2.0.2: {} html-tokenize@2.0.1: @@ -9506,6 +9851,11 @@ snapshots: husky@9.1.7: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 @@ -9523,6 +9873,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + index-to-position@1.2.0: {} inherits@2.0.4: {} @@ -9643,6 +9995,9 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: + optional: true + is-promise@4.0.0: {} is-regex@1.2.1: @@ -9754,6 +10109,35 @@ snapshots: jsbn@1.1.0: {} + jsdom@27.3.0(postcss@8.5.6): + dependencies: + '@acemir/cssom': 0.9.28 + '@asamuzakjp/dom-selector': 6.7.6 + cssstyle: 5.3.4(postcss@8.5.6) + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - postcss + - supports-color + - utf-8-validate + optional: true + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -9846,6 +10230,9 @@ snapshots: lru-cache@11.2.2: {} + lru-cache@11.2.4: + optional: true + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9896,6 +10283,9 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + mdn-data@2.12.2: + optional: true + media-typer@1.1.0: {} memory-pager@1.5.0: {} @@ -9935,6 +10325,8 @@ snapshots: mimic-response@3.1.0: optional: true + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -10386,6 +10778,11 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + optional: true + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -10721,6 +11118,11 @@ snapshots: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redoc@2.5.1(core-js@3.47.0)(mobx@6.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@redocly/openapi-core': 1.34.5(supports-color@10.2.2) @@ -10888,6 +11290,11 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + optional: true + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -11220,6 +11627,10 @@ snapshots: dependencies: is-natural-number: 4.0.1 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: optional: true @@ -11279,6 +11690,9 @@ snapshots: transitivePeerDependencies: - encoding + symbol-tree@3.2.4: + optional: true + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -11406,6 +11820,14 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.19: + optional: true + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + optional: true + tmp@0.2.5: {} to-buffer@1.2.2: @@ -11420,12 +11842,22 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + optional: true + tr46@0.0.3: {} tr46@5.1.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + optional: true + tree-kill@1.2.2: {} ts-algebra@1.2.2: {} @@ -11549,6 +11981,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@6.22.0: {} @@ -11672,7 +12106,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/node@24.10.1)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -11699,6 +12133,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.1 + happy-dom: 20.0.11 + jsdom: 27.3.0(postcss@8.5.6) transitivePeerDependencies: - jiti - less @@ -11722,6 +12158,11 @@ snapshots: optionalDependencies: zod: 3.25.76 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + optional: true + walk-up-path@4.0.0: {} web-streams-polyfill@3.3.3: {} @@ -11730,11 +12171,30 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.0: + optional: true + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + optional: true + + whatwg-mimetype@3.0.0: {} + + whatwg-mimetype@4.0.0: + optional: true + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + optional: true + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -11822,6 +12282,12 @@ snapshots: dependencies: is-wsl: 3.1.0 + xml-name-validator@5.0.0: + optional: true + + xmlchars@2.2.0: + optional: true + xtend@2.1.2: dependencies: object-keys: 0.4.0 diff --git a/tests/integration/ui/mcpUIFeature.test.ts b/tests/integration/ui/mcpUIFeature.test.ts new file mode 100644 index 00000000..a7c2f767 --- /dev/null +++ b/tests/integration/ui/mcpUIFeature.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, afterAll } from "vitest"; +import { describeWithMongoDB } from "../tools/mongodb/mongodbHelpers.js"; +import { defaultTestConfig, expectDefined, getResponseElements } from "../helpers.js"; +import { CompositeLogger } from "../../../src/common/logger.js"; +import { ExportsManager } from "../../../src/common/exportsManager.js"; +import { Session } from "../../../src/common/session.js"; +import { Telemetry } from "../../../src/telemetry/telemetry.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Server } from "../../../src/server.js"; +import { MCPConnectionManager } from "../../../src/common/connectionManager.js"; +import { DeviceId } from "../../../src/helpers/deviceId.js"; +import { connectionErrorHandler } from "../../../src/common/connectionErrorHandler.js"; +import { Keychain } from "../../../src/common/keychain.js"; +import { Elicitation } from "../../../src/elicitation.js"; +import { VectorSearchEmbeddingsManager } from "../../../src/common/search/vectorSearchEmbeddingsManager.js"; +import { defaultCreateAtlasLocalClient } from "../../../src/common/atlasLocal.js"; +import { InMemoryTransport } from "../../../src/transports/inMemoryTransport.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +describeWithMongoDB( + "mcpUI feature with feature disabled (default)", + (integration) => { + describe("list-databases tool", () => { + it("should NOT return UIResource content when mcpUI feature is disabled", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "list-databases", + arguments: {}, + }); + + expect(response.content).toBeDefined(); + expect(Array.isArray(response.content)).toBe(true); + + const elements = response.content as Array<{ type: string }>; + const resourceElements = elements.filter((e) => e.type === "resource"); + expect(resourceElements).toHaveLength(0); + + const textElements = getResponseElements(response.content); + expect(textElements.length).toBeGreaterThan(0); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + previewFeatures: [], // mcpUI is NOT enabled + }), + } +); + +describeWithMongoDB( + "mcpUI feature with feature enabled", + (integration) => { + describe("list-databases tool with mcpUI enabled", () => { + it("should return UIResource content when mcpUI feature is enabled", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "list-databases", + arguments: {}, + }); + + expect(response.content).toBeDefined(); + expect(Array.isArray(response.content)).toBe(true); + + const elements = response.content as Array<{ type: string; resource?: unknown }>; + + const textElements = elements.filter((e) => e.type === "text"); + expect(textElements.length).toBeGreaterThan(0); + + const resourceElements = elements.filter((e) => e.type === "resource"); + expect(resourceElements).toHaveLength(1); + + const uiResource = resourceElements[0] as { + type: string; + resource: { + uri: string; + mimeType: string; + text: string; + _meta?: Record; + }; + }; + + expect(uiResource.type).toBe("resource"); + expectDefined(uiResource.resource); + expect(uiResource.resource.uri).toMatch(/^ui:\/\/list-databases\/\d+$/); + expect(uiResource.resource.mimeType).toBe("text/html"); + expect(typeof uiResource.resource.text).toBe("string"); + expect(uiResource.resource.text.length).toBeGreaterThan(0); + + expectDefined(uiResource.resource._meta); + expect(uiResource.resource._meta["mcpui.dev/ui-initial-render-data"]).toBeDefined(); + + const renderData = uiResource.resource._meta["mcpui.dev/ui-initial-render-data"] as { + databases: Array<{ name: string; size: number }>; + totalCount: number; + }; + expect(renderData.databases).toBeInstanceOf(Array); + expect(typeof renderData.totalCount).toBe("number"); + expect(renderData.totalCount).toBe(renderData.databases.length); + + for (const db of renderData.databases) { + expect(typeof db.name).toBe("string"); + expect(typeof db.size).toBe("number"); + } + }); + + it("should include system databases in the response", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "list-databases", + arguments: {}, + }); + + const elements = response.content as Array<{ + type: string; + resource?: { _meta?: Record }; + }>; + const resourceElement = elements.find((e) => e.type === "resource"); + expectDefined(resourceElement); + + const renderData = resourceElement.resource?._meta?.["mcpui.dev/ui-initial-render-data"] as { + databases: Array<{ name: string; size: number }>; + }; + + const dbNames = renderData.databases.map((db) => db.name); + + expect(dbNames).toContain("admin"); + expect(dbNames).toContain("local"); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + previewFeatures: ["mcpUI"], // mcpUI IS enabled + }), + } +); + +describeWithMongoDB( + "mcpUI feature - UIRegistry initialization", + (integration) => { + describe("server UIRegistry", () => { + it("should have UIRegistry initialized with bundled UIs", () => { + const server = integration.mcpServer(); + expectDefined(server.uiRegistry); + + expect(server.uiRegistry.has("list-databases")).toBe(true); + + const uiHtml = server.uiRegistry.get("list-databases"); + expectDefined(uiHtml); + expect(uiHtml.length).toBeGreaterThan(0); + }); + + it("should return list of available tools with UIs", () => { + const server = integration.mcpServer(); + const availableTools = server.uiRegistry.getAvailableTools(); + + expect(Array.isArray(availableTools)).toBe(true); + expect(availableTools).toContain("list-databases"); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + previewFeatures: ["mcpUI"], + }), + } +); + +describe("mcpUI feature with custom UIs", () => { + const initServerWithCustomUIs = async ( + customUIs: Record + ): Promise<{ server: Server; transport: Transport }> => { + const userConfig = { + ...defaultTestConfig, + previewFeatures: ["mcpUI" as const], + }; + const logger = new CompositeLogger(); + const deviceId = DeviceId.create(logger); + const connectionManager = new MCPConnectionManager(userConfig, logger, deviceId); + const exportsManager = ExportsManager.init(userConfig, logger); + + const session = new Session({ + userConfig, + logger, + exportsManager, + connectionManager, + keychain: Keychain.root, + vectorSearchEmbeddingsManager: new VectorSearchEmbeddingsManager(userConfig, connectionManager), + atlasLocalClient: await defaultCreateAtlasLocalClient(), + }); + + const telemetry = Telemetry.create(session, userConfig, deviceId); + const mcpServerInstance = new McpServer({ name: "test", version: "1.0" }); + const elicitation = new Elicitation({ server: mcpServerInstance.server }); + + const server = new Server({ + session, + userConfig, + telemetry, + mcpServer: mcpServerInstance, + elicitation, + connectionErrorHandler, + customUIs, + }); + + const transport = new InMemoryTransport(); + + return { transport, server }; + }; + + let server: Server | undefined; + let transport: Transport | undefined; + + afterAll(async () => { + await transport?.close(); + await server?.close(); + }); + + it("should use custom UI when provided via server options", async () => { + const customUIs = { + "list-databases": "Custom Test UI", + }; + + ({ server, transport } = await initServerWithCustomUIs(customUIs)); + await server.connect(transport); + + expectDefined(server.uiRegistry); + expect(server.uiRegistry.has("list-databases")).toBe(true); + expect(server.uiRegistry.get("list-databases")).toBe("Custom Test UI"); + }); + + it("should add new custom UIs for tools without bundled UIs", async () => { + const customUIs = { + "custom-tool": "Custom Tool UI", + }; + + ({ server, transport } = await initServerWithCustomUIs(customUIs)); + await server.connect(transport); + + expectDefined(server.uiRegistry); + expect(server.uiRegistry.has("custom-tool")).toBe(true); + expect(server.uiRegistry.get("custom-tool")).toBe("Custom Tool UI"); + }); + + it("should merge custom UIs with bundled UIs", async () => { + const customUIs = { + "new-tool": "New Tool UI", + }; + + ({ server, transport } = await initServerWithCustomUIs(customUIs)); + await server.connect(transport); + + expectDefined(server.uiRegistry); + + expect(server.uiRegistry.has("new-tool")).toBe(true); + expect(server.uiRegistry.get("new-tool")).toBe("New Tool UI"); + + expect(server.uiRegistry.has("list-databases")).toBe(true); + const bundledUI = server.uiRegistry.get("list-databases"); + expectDefined(bundledUI); + expect(bundledUI.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/setupReact.ts b/tests/setupReact.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/tests/setupReact.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/tests/unit/toolBase.test.ts b/tests/unit/toolBase.test.ts index 28fa2ddf..08cb7aa6 100644 --- a/tests/unit/toolBase.test.ts +++ b/tests/unit/toolBase.test.ts @@ -13,12 +13,18 @@ import type { CompositeLogger } from "../../src/common/logger.js"; import type { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Server } from "../../src/server.js"; import type { TelemetryToolMetadata, ToolEvent } from "../../src/telemetry/types.js"; -import { expectDefined } from "../integration/helpers.js"; import type { PreviewFeature } from "../../src/common/schemas.js"; +import { type UIRegistry } from "../../src/ui/registry/index.js"; + +function expectDefined(arg: T): asserts arg is Exclude { + expect(arg).toBeDefined(); + expect(arg).not.toBeNull(); +} describe("ToolBase", () => { let mockSession: Session; let mockLogger: CompositeLogger; + let mockLoggerWarning: ReturnType; let mockConfig: UserConfig; let mockTelemetry: Telemetry; let mockElicitation: Elicitation; @@ -26,10 +32,11 @@ describe("ToolBase", () => { let testTool: TestTool; beforeEach(() => { + mockLoggerWarning = vi.fn(); mockLogger = { info: vi.fn(), debug: vi.fn(), - warning: vi.fn(), + warning: mockLoggerWarning, error: vi.fn(), } as unknown as CompositeLogger; @@ -258,8 +265,238 @@ describe("ToolBase", () => { } }); }); + + describe("appendUIResource", () => { + let mockUIRegistry: UIRegistry; + let mockUIRegistryGet: ReturnType; + let toolWithUI: TestToolWithOutputSchema; + let mockCallback: ToolCallback<(typeof toolWithUI)["argsShape"]>; + + beforeEach(() => { + mockUIRegistryGet = vi.fn(); + mockUIRegistry = { + get: mockUIRegistryGet, + has: vi.fn(), + getAvailableTools: vi.fn(), + } as unknown as UIRegistry; + }); + + function createToolWithUI(previewFeatures: PreviewFeature[] = []): TestToolWithOutputSchema { + mockConfig.previewFeatures = previewFeatures; + const constructorParams: ToolConstructorParams = { + category: TestToolWithOutputSchema.category, + operationType: TestToolWithOutputSchema.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + uiRegistry: mockUIRegistry, + }; + return new TestToolWithOutputSchema(constructorParams); + } + + function registerTool(tool: TestToolWithOutputSchema): void { + const mockServer = { + mcpServer: { + registerTool: ( + _name: string, + _config: { + description: string; + inputSchema: ZodRawShape; + outputSchema?: ZodRawShape; + annotations: ToolAnnotations; + }, + cb: ToolCallback + ): { enabled: boolean; disable: () => void; enable: () => void } => { + mockCallback = cb; + return { enabled: true, disable: vi.fn(), enable: vi.fn() }; + }, + }, + }; + tool.register(mockServer as unknown as Server); + } + + it("should not append UIResource when mcpUI feature is disabled", async () => { + toolWithUI = createToolWithUI([]); + (mockUIRegistry.get as Mock).mockReturnValue("test UI"); + registerTool(toolWithUI); + + const result = await mockCallback({ input: "test" }, {} as never); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ type: "text", text: "Tool with output schema executed" }); + expect(result.content.some((c: { type: string }) => c.type === "resource")).toBe(false); + }); + + it("should not append UIResource when no UI is registered for the tool", async () => { + toolWithUI = createToolWithUI(["mcpUI"]); + (mockUIRegistry.get as Mock).mockReturnValue(undefined); + registerTool(toolWithUI); + + const result = await mockCallback({ input: "test" }, {} as never); + + expect(result.content).toHaveLength(1); + expect(mockUIRegistryGet).toHaveBeenCalledWith("test-tool-with-output-schema"); + }); + + it("should not append UIResource when structuredContent is missing", async () => { + const toolWithoutStructured = createToolWithoutStructuredContent( + ["mcpUI"], + mockSession, + mockConfig, + mockTelemetry, + mockElicitation, + mockUIRegistry + ); + (mockUIRegistry.get as Mock).mockReturnValue("test UI"); + + let noStructuredCallback: ToolCallback | undefined; + const mockServer = { + mcpServer: { + registerTool: ( + _name: string, + _config: unknown, + cb: ToolCallback + ): { enabled: boolean; disable: () => void; enable: () => void } => { + noStructuredCallback = cb; + return { enabled: true, disable: vi.fn(), enable: vi.fn() }; + }, + }, + }; + toolWithoutStructured.register(mockServer as unknown as Server); + + expectDefined(noStructuredCallback); + const result = await noStructuredCallback({ input: "test" }, {} as never); + + expect(result.content).toHaveLength(1); + expect(result.structuredContent).toBeUndefined(); + }); + + it("should not append UIResource when outputSchema validation fails", async () => { + const toolWithInvalidOutput = createToolWithInvalidStructuredContent( + ["mcpUI"], + mockSession, + mockConfig, + mockTelemetry, + mockElicitation, + mockUIRegistry + ); + (mockUIRegistry.get as Mock).mockReturnValue("test UI"); + + let invalidCallback: ToolCallback | undefined; + const mockServer = { + mcpServer: { + registerTool: ( + _name: string, + _config: unknown, + cb: ToolCallback + ): { enabled: boolean; disable: () => void; enable: () => void } => { + invalidCallback = cb; + return { enabled: true, disable: vi.fn(), enable: vi.fn() }; + }, + }, + }; + toolWithInvalidOutput.register(mockServer as unknown as Server); + + expectDefined(invalidCallback); + const result = await invalidCallback({ input: "test" }, {} as never); + + expect(result.content).toHaveLength(1); + expect(mockLoggerWarning).toHaveBeenCalled(); + }); + + it("should append UIResource correctly when all conditions are met", async () => { + toolWithUI = createToolWithUI(["mcpUI"]); + (mockUIRegistry.get as Mock).mockReturnValue("test UI"); + registerTool(toolWithUI); + + const result = await mockCallback({ input: "test" }, {} as never); + + expect(result.content).toHaveLength(2); + expect(result.content[0]).toEqual({ type: "text", text: "Tool with output schema executed" }); + + const uiResource = result.content[1] as { + type: string; + resource: { uri: string; text: string; mimeType: string; _meta?: Record }; + }; + expect(uiResource.type).toBe("resource"); + expect(uiResource.resource.uri).toMatch(/^ui:\/\/test-tool-with-output-schema\/\d+$/); + expect(uiResource.resource.text).toBe("test UI"); + expect(uiResource.resource.mimeType).toBe("text/html"); + expect(uiResource.resource._meta).toEqual({ + "mcpui.dev/ui-initial-render-data": { value: "test", count: 42 }, + }); + }); + + it("should use structuredContent as initial-render-data in UIResource metadata", async () => { + toolWithUI = createToolWithUI(["mcpUI"]); + (mockUIRegistry.get as Mock).mockReturnValue("custom UI"); + registerTool(toolWithUI); + + const result = await mockCallback({ input: "custom-input" }, {} as never); + + const uiResource = result.content[1] as { resource: { _meta?: Record } }; + expect(uiResource.resource._meta?.["mcpui.dev/ui-initial-render-data"]).toEqual({ + value: "custom-input", + count: 42, + }); + }); + + it("should preserve original result properties when appending UIResource", async () => { + toolWithUI = createToolWithUI(["mcpUI"]); + (mockUIRegistry.get as Mock).mockReturnValue("test UI"); + registerTool(toolWithUI); + + const result = await mockCallback({ input: "test" }, {} as never); + + expect(result.structuredContent).toEqual({ value: "test", count: 42 }); + expect(result.isError).toBeUndefined(); + }); + }); }); +function createToolWithoutStructuredContent( + previewFeatures: PreviewFeature[], + mockSession: Session, + mockConfig: UserConfig, + mockTelemetry: Telemetry, + mockElicitation: Elicitation, + mockUIRegistry: UIRegistry +): TestToolWithoutStructuredContent { + mockConfig.previewFeatures = previewFeatures; + const constructorParams: ToolConstructorParams = { + category: TestToolWithoutStructuredContent.category, + operationType: TestToolWithoutStructuredContent.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + uiRegistry: mockUIRegistry, + }; + return new TestToolWithoutStructuredContent(constructorParams); +} + +function createToolWithInvalidStructuredContent( + previewFeatures: PreviewFeature[], + mockSession: Session, + mockConfig: UserConfig, + mockTelemetry: Telemetry, + mockElicitation: Elicitation, + mockUIRegistry: UIRegistry +): TestToolWithInvalidStructuredContent { + mockConfig.previewFeatures = previewFeatures; + const constructorParams: ToolConstructorParams = { + category: TestToolWithInvalidStructuredContent.category, + operationType: TestToolWithInvalidStructuredContent.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + uiRegistry: mockUIRegistry, + }; + return new TestToolWithInvalidStructuredContent(constructorParams); +} + class TestTool extends ToolBase { public name = "test-tool"; static category: ToolCategory = "mongodb"; @@ -295,3 +532,96 @@ class TestTool extends ToolBase { return {}; } } + +class TestToolWithOutputSchema extends ToolBase { + public name = "test-tool-with-output-schema"; + static category: ToolCategory = "mongodb"; + static operationType: OperationType = "metadata"; + protected description = "A test tool with output schema"; + protected argsShape = { + input: z.string().describe("Test input"), + }; + protected override outputSchema = { + value: z.string(), + count: z.number(), + }; + + protected async execute(args: ToolArgs): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: "Tool with output schema executed", + }, + ], + structuredContent: { + value: args.input, + count: 42, + }, + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} + +class TestToolWithoutStructuredContent extends ToolBase { + public name = "test-tool-without-structured"; + static category: ToolCategory = "mongodb"; + static operationType: OperationType = "metadata"; + protected description = "A test tool without structured content"; + protected argsShape = { + input: z.string().describe("Test input"), + }; + protected override outputSchema = { + value: z.string(), + }; + + protected async execute(): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: "Tool without structured content executed", + }, + ], + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} + +class TestToolWithInvalidStructuredContent extends ToolBase { + public name = "test-tool-with-invalid-structured"; + static category: ToolCategory = "mongodb"; + static operationType: OperationType = "metadata"; + protected description = "A test tool with invalid structured content"; + protected argsShape = { + input: z.string().describe("Test input"), + }; + protected override outputSchema = { + value: z.string(), + requiredField: z.number(), + }; + + protected async execute(): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: "Tool with invalid structured content executed", + }, + ], + structuredContent: { + value: "test", + }, + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} diff --git a/tests/unit/ui/components/ListDatabases/ListDatabases.test.tsx b/tests/unit/ui/components/ListDatabases/ListDatabases.test.tsx new file mode 100644 index 00000000..636f8a32 --- /dev/null +++ b/tests/unit/ui/components/ListDatabases/ListDatabases.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, waitFor, act, cleanup, within } from "@testing-library/react"; +import { ListDatabases } from "../../../../../src/ui/components/ListDatabases/ListDatabases.js"; + +/** + * Helper to simulate the parent window sending render data via postMessage + */ +function sendRenderData(data: unknown): void { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: { + renderData: data, + }, + }, + }) + ); +} + +describe("ListDatabases", () => { + afterEach(() => { + cleanup(); + }); + + it("should show loading state initially", () => { + render(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("should render table with database data", async () => { + render(); + + act(() => { + sendRenderData({ + databases: [ + { name: "admin", size: 1024 }, + { name: "local", size: 2048 }, + ], + totalCount: 2, + }); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + + const table = screen.getByTestId("lg-table"); + expect(table).toBeInTheDocument(); + expect(within(table).getByText("admin")).toBeInTheDocument(); + expect(within(table).getByText("local")).toBeInTheDocument(); + expect(within(table).getByText("1 KB")).toBeInTheDocument(); + expect(within(table).getByText("2 KB")).toBeInTheDocument(); + }); + + it("should render empty table with no databases", async () => { + render(); + + act(() => { + sendRenderData({ + databases: [], + totalCount: 0, + }); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + + const table = screen.getByTestId("lg-table"); + expect(table).toBeInTheDocument(); + expect(within(table).queryAllByTestId("lg-table-row")).toHaveLength(0); + }); + + it("should format bytes correctly for various sizes", async () => { + render(); + + act(() => { + sendRenderData({ + databases: [ + { name: "tiny", size: 0 }, + { name: "small", size: 512 }, + { name: "medium", size: 1048576 }, // 1 MB + { name: "large", size: 1073741824 }, // 1 GB + ], + totalCount: 4, + }); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + + const table = screen.getByTestId("lg-table"); + expect(within(table).getByText("0 Bytes")).toBeInTheDocument(); + expect(within(table).getByText("512 Bytes")).toBeInTheDocument(); + expect(within(table).getByText("1 MB")).toBeInTheDocument(); + expect(within(table).getByText("1 GB")).toBeInTheDocument(); + }); + + it("should show error when data loading fails", async () => { + render(); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: "invalid-payload", + }, + }) + ); + }); + + await waitFor(() => { + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + }); + + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + }); + + it("should return null for invalid data structure", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { container } = render(); + + act(() => { + sendRenderData({ + // Missing required fields + invalidField: "test", + }); + }); + + await waitFor(() => { + // Component should render null after validation fails + expect(container.firstChild).toBeNull(); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/tests/unit/ui/hooks/useRenderData.test.tsx b/tests/unit/ui/hooks/useRenderData.test.tsx new file mode 100644 index 00000000..2b4df81b --- /dev/null +++ b/tests/unit/ui/hooks/useRenderData.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useRenderData } from "../../../../src/ui/hooks/useRenderData.js"; + +describe("useRenderData", () => { + let postMessageSpy: ReturnType; + + beforeEach(() => { + postMessageSpy = vi.spyOn(window.parent, "postMessage"); + }); + + afterEach(() => { + postMessageSpy.mockRestore(); + }); + + it("should start in loading state", () => { + const { result } = renderHook(() => useRenderData()); + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("should post ready message on mount", () => { + renderHook(() => useRenderData()); + expect(postMessageSpy).toHaveBeenCalledWith({ type: "ui-lifecycle-iframe-ready" }, "*"); + }); + + it("should receive and set render data from postMessage", async () => { + const { result } = renderHook(() => useRenderData<{ items: string[] }>()); + const testData = { items: ["a", "b", "c"] }; + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: { + renderData: testData, + }, + }, + }) + ); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(testData); + expect(result.current.error).toBeNull(); + }); + + it("should ignore messages with different type", () => { + const { result } = renderHook(() => useRenderData()); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "some-other-message", + payload: { renderData: { test: true } }, + }, + }) + ); + }); + + // Should still be loading since we ignored the message + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + }); + + it("should set error for invalid payload structure", async () => { + const { result } = renderHook(() => useRenderData()); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: "invalid-not-an-object", + }, + }) + ); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe("Invalid payload structure received"); + expect(result.current.data).toBeNull(); + }); + + it("should set error when renderData is not an object", async () => { + const { result } = renderHook(() => useRenderData()); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: { + renderData: "string-not-object", + }, + }, + }) + ); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe("Expected object but received string"); + expect(result.current.data).toBeNull(); + }); + + it("should handle null renderData without error", async () => { + const { result } = renderHook(() => useRenderData()); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: { + renderData: null, + }, + }, + }) + ); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Null is intentionally allowed - not an error + expect(result.current.error).toBeNull(); + expect(result.current.data).toBeNull(); + }); + + it("should handle undefined renderData without error", async () => { + const { result } = renderHook(() => useRenderData()); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "ui-lifecycle-iframe-render-data", + payload: {}, + }, + }) + ); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.data).toBeNull(); + }); + + it("should clean up message listener on unmount", () => { + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = renderHook(() => useRenderData()); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function)); + removeEventListenerSpy.mockRestore(); + }); +}); diff --git a/tests/unit/ui/registry/registry.test.ts b/tests/unit/ui/registry/registry.test.ts new file mode 100644 index 00000000..a3226530 --- /dev/null +++ b/tests/unit/ui/registry/registry.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UIRegistry } from "../../../../src/ui/registry/registry.js"; + +describe("UIRegistry", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("get()", () => { + it("should return custom UI when set", async () => { + const customUIs = { + "list-databases": "custom list-databases UI", + }; + const registry = new UIRegistry({ customUIs }); + + expect(await registry.get("list-databases")).toBe("custom list-databases UI"); + }); + + it("should return null when no UI exists for the tool", async () => { + const registry = new UIRegistry(); + + expect(await registry.get("non-existent-tool")).toBeNull(); + }); + + it("should return custom UI for new tools", async () => { + const customUIs = { + "brand-new-tool": "brand new UI", + }; + const registry = new UIRegistry({ customUIs }); + + expect(await registry.get("brand-new-tool")).toBe("brand new UI"); + }); + + it("should prefer custom UI over bundled UI", async () => { + const customUIs = { + "any-tool": "custom version", + }; + const registry = new UIRegistry({ customUIs }); + + // Custom should be returned without attempting to load bundled + expect(await registry.get("any-tool")).toBe("custom version"); + }); + + it("should cache results after first load", async () => { + const customUIs = { + "cached-tool": "cached UI", + }; + const registry = new UIRegistry({ customUIs }); + + // First call + const first = await registry.get("cached-tool"); + // Second call should return same result + const second = await registry.get("cached-tool"); + + expect(first).toBe(second); + expect(first).toBe("cached UI"); + }); + }); +}); diff --git a/tests/vitest.d.ts b/tests/vitest.d.ts index 1097f08c..ecfeb4d5 100644 --- a/tests/vitest.d.ts +++ b/tests/vitest.d.ts @@ -1,3 +1,4 @@ +/// import "vitest"; declare module "vitest" { diff --git a/tsconfig.test.json b/tsconfig.test.json index 388094c3..028a65f3 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "isolatedModules": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "types": ["node"] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/vitest.config.ts b/vitest.config.ts index 16838234..46b706bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -80,6 +80,15 @@ export default defineConfig({ hookTimeout: 7200000, }, }, + { + extends: true, + test: { + name: "ui-components", + include: ["tests/unit/ui/**/*.test.tsx"], + environment: "happy-dom", + setupFiles: ["./tests/setup.ts", "./tests/setupReact.ts"], + }, + }, ], }, });