diff --git a/eslint.config.js b/eslint.config.js index feba672e6..5060e1633 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 37e521241..6c4ba1310 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@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.1", "@types/express": "^5.0.3", "@types/node": "^24.5.2", @@ -117,6 +118,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", "jsdom": "^27.3.0", "knip": "^5.63.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3ade655c..da7f8656c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@redocly/cli': specifier: ^2.0.8 version: 2.12.5(@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.1 version: 16.3.1(@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) @@ -147,10 +150,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)(jsdom@27.3.0)(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)(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)(jsdom@27.3.0)(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)(tsx@4.21.0)(yaml@2.8.2)) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -169,6 +172,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 @@ -234,7 +240,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)(jsdom@27.3.0)(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)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@mongodb-js/atlas-local': specifier: ^1.1.0 @@ -248,6 +254,9 @@ packages: '@acemir/cssom@0.9.29': resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} + '@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'} @@ -2091,6 +2100,10 @@ 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.1': resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} engines: {node: '>=18'} @@ -2175,6 +2188,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} @@ -2231,6 +2247,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==} @@ -2948,6 +2967,9 @@ packages: 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.5: resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} engines: {node: '>=20'} @@ -3100,6 +3122,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==} @@ -3576,6 +3601,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'} @@ -3686,6 +3715,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'} @@ -4151,6 +4184,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==} @@ -4827,6 +4864,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'} @@ -5184,6 +5225,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'} @@ -5458,6 +5503,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==} @@ -5689,6 +5737,10 @@ packages: 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'} @@ -5863,6 +5915,8 @@ snapshots: '@acemir/cssom@0.9.29': {} + '@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) @@ -7788,6 +7842,15 @@ 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.1(@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 @@ -7886,6 +7949,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -7944,6 +8011,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 @@ -8109,7 +8178,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(jsdom@27.3.0)(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)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8124,18 +8193,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)(jsdom@27.3.0)(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)(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)(jsdom@27.3.0)(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)(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)(jsdom@27.3.0)(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)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -8811,6 +8880,8 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css.escape@1.5.1: {} + cssstyle@5.3.5: dependencies: '@asamuzakjp/css-color': 4.1.1 @@ -9005,6 +9076,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 @@ -9658,6 +9731,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.0.11: + dependencies: + '@types/node': 20.19.27 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -9776,6 +9855,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + index-to-position@1.2.0: {} inherits@2.0.4: {} @@ -10219,6 +10300,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: {} @@ -11009,6 +11092,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.6 @@ -11512,6 +11600,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 @@ -11859,6 +11951,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@6.22.0: {} @@ -11980,7 +12074,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(jsdom@27.3.0)(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)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -12007,6 +12101,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.1 + happy-dom: 20.0.11 jsdom: 27.3.0 transitivePeerDependencies: - jiti @@ -12049,6 +12144,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: diff --git a/tests/integration/ui/mcpUIFeature.test.ts b/tests/integration/ui/mcpUIFeature.test.ts new file mode 100644 index 000000000..70bda8600 --- /dev/null +++ b/tests/integration/ui/mcpUIFeature.test.ts @@ -0,0 +1,261 @@ +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).toBe("ui://list-databases"); + 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", async () => { + const server = integration.mcpServer(); + expectDefined(server.uiRegistry); + + const uiHtml = await server.uiRegistry.get("list-databases"); + expectDefined(uiHtml); + expect(uiHtml).not.toBeNull(); + expect(uiHtml.length).toBeGreaterThan(0); + }); + }); + }, + { + getUserConfig: () => ({ + ...defaultTestConfig, + previewFeatures: ["mcpUI"], + }), + } +); + +describe("mcpUI feature with custom UIs", () => { + const initServerWithCustomUIs = async ( + customUIs: Record + ): Promise<{ server: Server; transport: Transport }> => { + const customUIsFunction = (toolName: string): string | null => customUIs[toolName] ?? null; + 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: customUIsFunction, + }); + + 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); + const uiHtml = await server.uiRegistry.get("list-databases"); + expectDefined(uiHtml); + expect(uiHtml).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); + const uiHtml = await server.uiRegistry.get("custom-tool"); + expectDefined(uiHtml); + expect(uiHtml).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); + + const newToolUI = await server.uiRegistry.get("new-tool"); + expectDefined(newToolUI); + expect(newToolUI).toBe("New Tool UI"); + + const bundledUI = await server.uiRegistry.get("list-databases"); + expectDefined(bundledUI); + expect(bundledUI).not.toBeNull(); + expect(bundledUI.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/setupReact.ts b/tests/setupReact.ts new file mode 100644 index 000000000..f149f27ae --- /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 45d0b2d3d..4a091dd2e 100644 --- a/tests/unit/toolBase.test.ts +++ b/tests/unit/toolBase.test.ts @@ -13,13 +13,14 @@ 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 { UIRegistry } from "../../src/ui/registry/index.js"; +import { expectDefined } from "../integration/helpers.js"; describe("ToolBase", () => { let mockSession: Session; let mockLogger: CompositeLogger; + let mockLoggerWarning: ReturnType; let mockConfig: UserConfig; let mockTelemetry: Telemetry; let mockElicitation: Elicitation; @@ -27,10 +28,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; @@ -260,8 +262,182 @@ 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, + } 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 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).toBe("ui://test-tool-with-output-schema"); + 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); +} + class TestTool extends ToolBase { public name = "test-tool"; static category: ToolCategory = "mongodb"; @@ -297,3 +473,64 @@ 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 {}; + } +} 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 000000000..636f8a32a --- /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 000000000..2b4df81b2 --- /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 000000000..52501a632 --- /dev/null +++ b/tests/unit/ui/registry/registry.test.ts @@ -0,0 +1,71 @@ +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 = (toolName: string): string | null => { + if (toolName === "list-databases") { + return "custom list-databases UI"; + } + return null; + }; + 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 = (toolName: string): string | null => { + if (toolName === "brand-new-tool") { + return "brand new UI"; + } + return null; + }; + 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 = (toolName: string): string | null => { + if (toolName === "any-tool") { + return "custom version"; + } + return null; + }; + 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 = (toolName: string): string | null => { + if (toolName === "cached-tool") { + return "cached UI"; + } + return null; + }; + 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 1097f08c0..ecfeb4d58 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 new file mode 100644 index 000000000..028a65f3f --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/vite.ui.config.ts b/vite.ui.config.ts index 6a9de2acd..73c63746c 100644 --- a/vite.ui.config.ts +++ b/vite.ui.config.ts @@ -101,6 +101,8 @@ function generateUIModule(): Plugin { } const html = readFileSync(htmlFile, "utf-8"); + // Generate as .js file so it works with dynamic imports in both + // production (from dist/) and development/test (from src/) const toolModuleContent = `/** * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY * Generated by: vite build --config vite.ui.config.ts @@ -109,7 +111,7 @@ function generateUIModule(): Plugin { */ export const ${componentName}Html = ${JSON.stringify(html)}; `; - writeFileSync(join(toolsDir, `${toolName}.ts`), toolModuleContent); + writeFileSync(join(toolsDir, `${toolName}.js`), toolModuleContent); generatedTools.push(toolName); } diff --git a/vitest.config.ts b/vitest.config.ts index 168382344..46b706bcb 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"], + }, + }, ], }, });