diff --git a/jest.config.ts b/jest.config.ts index 6b3bb733c7fc70..b4f0764612d4e6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -253,7 +253,7 @@ if ( * node_modules, but some packages which use ES6 syntax only NEED to be * transformed. */ -const ESM_NODE_MODULES = ['screenfull', 'cbor2', 'nuqs', 'color']; +const ESM_NODE_MODULES = ['screenfull', 'cbor2', 'nuqs', 'color', 'until-async']; const config: Config.InitialOptions = { verbose: false, @@ -292,6 +292,7 @@ const config: Config.InitialOptions = { setupFilesAfterEnv: [ '/tests/js/setup.ts', '/tests/js/setupFramework.ts', + '/tests/js/setupMsw.ts', ], testMatch: testMatch || ['/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'], testPathIgnorePatterns: ['/tests/sentry/lang/javascript/'], diff --git a/package.json b/package.json index 981975c68a8d0d..8e5942db1e965c 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,6 @@ "idb-keyval": "6.2.2", "invariant": "^2.2.4", "jed": "^1.1.0", - "jest-fetch-mock": "^3.0.3", "js-beautify": "^1.15.1", "js-cookie": "3.0.5", "jsonrepair": "^3.8.0", @@ -200,6 +199,7 @@ "@types/gettext-parser": "8.0.0", "@types/node": "^22.9.1", "babel-jest": "30.0.4", + "cross-fetch": "^4.1.0", "eslint": "9.34.0", "eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "^3.8.3", @@ -224,6 +224,7 @@ "jest-fail-on-console": "3.3.1", "jest-junit": "16.0.0", "knip": "5.64.0", + "msw": "^2.12.0", "postcss-styled-syntax": "0.7.0", "react-refresh": "0.18.0", "stylelint": "16.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d6bacc2d18c47..3ae5b3000a2937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,9 +381,6 @@ importers: jed: specifier: ^1.1.0 version: 1.1.1 - jest-fetch-mock: - specifier: ^3.0.3 - version: 3.0.3(encoding@0.1.13) js-beautify: specifier: ^1.15.1 version: 1.15.1 @@ -583,6 +580,9 @@ importers: babel-jest: specifier: 30.0.4 version: 30.0.4(@babel/core@7.28.0) + cross-fetch: + specifier: ^4.1.0 + version: 4.1.0(encoding@0.1.13) eslint: specifier: 9.34.0 version: 9.34.0(jiti@2.5.1) @@ -655,6 +655,9 @@ importers: knip: specifier: 5.64.0 version: 5.64.0(@types/node@22.15.21)(typescript@5.9.2) + msw: + specifier: ^2.12.0 + version: 2.12.0(@types/node@22.15.21)(typescript@5.9.2) postcss-styled-syntax: specifier: 0.7.0 version: 0.7.0(postcss@8.5.3) @@ -1896,6 +1899,41 @@ packages: prettier-plugin-ember-template-tag: optional: true + '@inquirer/ansi@1.0.1': + resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.0': + resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.14': + resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.9': + resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@internationalized/date@3.8.2': resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} @@ -2140,6 +2178,10 @@ packages: '@module-federation/webpack-bundler-runtime@0.18.0': resolution: {integrity: sha512-TEvErbF+YQ+6IFimhUYKK3a5wapD90d90sLsNpcu2kB3QGT7t4nIluE25duXuZDVUKLz86tEPrza/oaaCWTpvQ==} + '@mswjs/interceptors@0.40.0': + resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + engines: {node: '>=18'} + '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} @@ -2240,6 +2282,15 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.204.0': resolution: {integrity: sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw==} engines: {node: '>=8.0.0'} @@ -3703,6 +3754,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/supports-color@8.1.3': resolution: {integrity: sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg==} @@ -4415,6 +4469,10 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4548,6 +4606,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + copy-anything@2.0.3: resolution: {integrity: sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ==} @@ -4587,8 +4649,8 @@ packages: resolution: {integrity: sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==} hasBin: true - cross-fetch@3.1.8: - resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -5654,6 +5716,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.12.0: + resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -5725,6 +5791,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -6041,6 +6110,9 @@ packages: resolution: {integrity: sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==} engines: {node: '>=16'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -6226,9 +6298,6 @@ packages: jest-fail-on-console@3.3.1: resolution: {integrity: sha512-dmq/dmh5OBgJlD1MJdpznzwFQP8S7msf3ghTGWQLGhagWwHKzGtqXza76nuJUKOK7BdwqcTK6CCE49Xxv4ckUQ==} - jest-fetch-mock@3.0.3: - resolution: {integrity: sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==} - jest-haste-map@30.0.2: resolution: {integrity: sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6902,10 +6971,24 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.12.0: + resolution: {integrity: sha512-jzf2eVnd8+iWXN74dccLrHUw3i3hFVvNVQRWS4vBl2KxaUt7Tdur0Eyda/DODGFkZDu2P5MXaeLe/9Qx8PZkrg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + multicast-dns@7.2.5: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7101,6 +7184,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -7203,6 +7289,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7390,9 +7479,6 @@ packages: bluebird: optional: true - promise-polyfill@8.3.0: - resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==} - promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -7748,6 +7834,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rettime@0.7.0: + resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7913,6 +8002,10 @@ packages: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} engines: {node: '>=14'} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -8023,10 +8116,17 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -8241,10 +8341,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -8267,6 +8374,10 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -8496,6 +8607,9 @@ packages: unrs-resolver@1.7.13: resolution: {integrity: sha512-QUjCYKAgrdJpf3wA73zWjOrO7ra19lfnwQ8HRkNOLah5AVDqOS38UunnyhzsSL8AE+2/AGnAHxlr8cGshCP35A==} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -8750,6 +8864,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8847,6 +8965,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zod-validation-error@3.4.0: resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} engines: {node: '>=18.0.0'} @@ -10332,6 +10454,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@inquirer/ansi@1.0.1': {} + + '@inquirer/confirm@5.1.19(@types/node@22.15.21)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@22.15.21) + '@inquirer/type': 3.0.9(@types/node@22.15.21) + optionalDependencies: + '@types/node': 22.15.21 + + '@inquirer/core@10.3.0(@types/node@22.15.21)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@22.15.21) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.15.21 + + '@inquirer/figures@1.0.14': {} + + '@inquirer/type@3.0.9(@types/node@22.15.21)': + optionalDependencies: + '@types/node': 22.15.21 + '@internationalized/date@3.8.2': dependencies: '@swc/helpers': 0.5.15 @@ -10732,6 +10882,15 @@ snapshots: '@module-federation/runtime': 0.18.0 '@module-federation/sdk': 0.18.0 + '@mswjs/interceptors@0.40.0': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@0.2.11': dependencies: '@emnapi/core': 1.4.3 @@ -10879,6 +11038,15 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@opentelemetry/api-logs@0.204.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -12755,6 +12923,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/statuses@2.0.6': {} + '@types/supports-color@8.1.3': {} '@types/tapable@2.2.7': @@ -13592,6 +13762,8 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -13706,6 +13878,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + copy-anything@2.0.3: dependencies: is-what: 3.14.1 @@ -13749,7 +13923,7 @@ snapshots: cronstrue@2.50.0: {} - cross-fetch@3.1.8(encoding@0.1.13): + cross-fetch@4.1.0(encoding@0.1.13): dependencies: node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: @@ -15069,6 +15243,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.12.0: {} + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -15207,6 +15383,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -15520,6 +15698,8 @@ snapshots: is-network-error@1.1.0: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -15814,13 +15994,6 @@ snapshots: jest-fail-on-console@3.3.1: {} - jest-fetch-mock@3.0.3(encoding@0.1.13): - dependencies: - cross-fetch: 3.1.8(encoding@0.1.13) - promise-polyfill: 8.3.0 - transitivePeerDependencies: - - encoding - jest-haste-map@30.0.2: dependencies: '@jest/types': 30.0.1 @@ -16878,11 +17051,38 @@ snapshots: ms@2.1.3: {} + msw@2.12.0(@types/node@22.15.21)(typescript@5.9.2): + dependencies: + '@inquirer/confirm': 5.1.19(@types/node@22.15.21) + '@mswjs/interceptors': 0.40.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.0.2 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.7.0 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 4.37.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - '@types/node' + multicast-dns@7.2.5: dependencies: dns-packet: 5.6.1 thunky: 1.1.0 + mute-stream@2.0.0: {} + nanoid@3.3.11: {} napi-postinstall@0.2.4: {} @@ -17065,6 +17265,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -17213,6 +17415,8 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} peggy@4.1.1: @@ -17364,8 +17568,6 @@ snapshots: promise-inflight@1.0.1: {} - promise-polyfill@8.3.0: {} - promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -17845,6 +18047,8 @@ snapshots: retry@0.13.1: {} + rettime@0.7.0: {} + reusify@1.0.4: {} rrweb-cssom@0.8.0: {} @@ -18046,6 +18250,8 @@ snapshots: signal-exit@4.0.2: {} + signal-exit@4.1.0: {} + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 @@ -18175,11 +18381,15 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + strict-event-emitter@0.5.1: {} + strict-uri-encode@2.0.0: {} string-length@4.0.2: @@ -18444,10 +18654,16 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.0.17: {} + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.0.17: + dependencies: + tldts-core: 7.0.17 + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -18464,6 +18680,10 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.17 + tr46@0.0.3: {} tr46@5.1.1: @@ -18758,6 +18978,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.7.13 '@unrs/resolver-binding-win32-x64-msvc': 1.7.13 + until-async@3.0.2: {} + update-browserslist-db@1.1.1(browserslist@4.24.4): dependencies: browserslist: 4.24.4 @@ -19096,6 +19318,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -19154,6 +19382,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zod-validation-error@3.4.0(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/static/app/__mocks__/api.tsx b/static/app/__mocks__/api.tsx index 423bf0d931b8fd..201ab9fb8dd1d3 100644 --- a/static/app/__mocks__/api.tsx +++ b/static/app/__mocks__/api.tsx @@ -1,313 +1,51 @@ -import isEqual from 'lodash/isEqual'; +import {Client as MockClient} from './mockApi'; -import type * as ApiNamespace from 'sentry/api'; -import RequestError from 'sentry/utils/requestError/requestError'; - -const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api'); +const RealApi = jest.requireActual('sentry/api'); +const RealClient = RealApi.Client; export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling; export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed; -const respond = ( - asyncDelay: AsyncDelay, - fn: FunctionCallback | undefined, - ...args: any[] -): void => { - if (!fn) { - return; - } - - if (asyncDelay !== undefined) { - setTimeout(() => fn(...args), asyncDelay); - return; - } - - fn(...args); -}; - -type FunctionCallback = (...args: Args) => void; +export class Client extends MockClient { + private realClient?: InstanceType; -/** - * Callables for matching requests based on arbitrary conditions. - */ -type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean; - -type AsyncDelay = undefined | number; -interface ResponseType extends ApiNamespace.ResponseMeta { - body: any; - callCount: 0; - headers: Record; - host: string; - match: MatchCallable[]; - method: string; - statusCode: number; - url: string; - /** - * Whether to return mocked api responses directly, or with a setTimeout delay. - * - * Set to `null` to disable the async delay - * Set to a `number` which will be the amount of time (ms) for the delay - * - * This will override `MockApiClient.asyncDelay` for this request. - */ - asyncDelay?: AsyncDelay; - query?: Record; -} - -type MockResponse = [resp: ResponseType, mock: jest.Mock]; - -/** - * Compare two records. `want` is all the entries we want to have the same value in `check` - */ -function compareRecord(want: Record, check: Record): boolean { - for (const entry of Object.entries(want)) { - const [key, value] = entry; - if (!isEqual(check[key], value)) { - return false; - } + constructor(...args: ConstructorParameters) { + super(...args); + // Initialize real client for when __USE_REAL_API__ is true + this.realClient = new RealClient( + ...(args as ConstructorParameters) + ); } - return true; -} -afterEach(() => { - // if any errors are caught we console.error them - const errors = Object.values(Client.errors); - if (errors.length > 0) { - for (const err of errors) { - // eslint-disable-next-line no-console - console.error(err); + clear(): void { + if (globalThis.__USE_REAL_API__) { + return this.realClient?.clear(); } - Client.errors = {}; - } - - // Mock responses are removed between tests - Client.clearMockResponses(); -}); - -class Client implements ApiNamespace.Client { - activeRequests: Record = {}; - baseUrl = ''; - // uses the default client json headers. Sadly, we cannot refernce the real client - // because it will cause a circular dependency and explode, hence the copy/paste - headers = { - Accept: 'application/json; charset=utf-8', - 'Content-Type': 'application/json', - }; - - static mockResponses: MockResponse[] = []; - - /** - * Whether to return mocked api responses directly, or with a setTimeout delay. - * - * Set to `null` to disable the async delay - * Set to a `number` which will be the amount of time (ms) for the delay - * - * This is the global/default value. `addMockResponse` can override per request. - */ - static asyncDelay: AsyncDelay = undefined; - - static clearMockResponses() { - Client.mockResponses = []; - } - - /** - * Create a query string match callable. - * - * Only keys/values defined in `query` are checked. - */ - static matchQuery(query: Record): MatchCallable { - const queryMatcher: MatchCallable = (_url, options) => { - return compareRecord(query, options.query ?? {}); - }; - - return queryMatcher; - } - - /** - * Create a data match callable. - * - * Only keys/values defined in `data` are checked. - */ - static matchData(data: Record): MatchCallable { - const dataMatcher: MatchCallable = (_url, options) => { - return compareRecord(data, options.data ?? {}); - }; - - return dataMatcher; - } - - // Returns a jest mock that represents Client.request calls - static addMockResponse(response: Partial) { - const mock = jest.fn(); - - Client.mockResponses.unshift([ - { - host: '', - url: '', - status: 200, - statusCode: 200, - statusText: 'OK', - responseText: '', - responseJSON: '', - body: '', - method: 'GET', - callCount: 0, - match: [], - ...response, - asyncDelay: response.asyncDelay ?? Client.asyncDelay, - headers: response.headers ?? {}, - getResponseHeader: (key: string) => response.headers?.[key] ?? null, - }, - mock, - ]); - - return mock; - } - - static findMockResponse(url: string, options: Readonly) { - return Client.mockResponses.find(([response]) => { - if (response.host && (options.host || '') !== response.host) { - return false; - } - if (url !== response.url) { - return false; - } - if ((options.method || 'GET') !== response.method) { - return false; - } - return response.match.every(matcher => matcher(url, options)); - }); - } - - uniqueId() { - return '123'; - } - - /** - * In the real client, this clears in-flight responses. It's NOT - * clearMockResponses. You probably don't want to call this from a test. - */ - clear() { - Object.values(this.activeRequests).forEach(r => r.cancel()); + return super.clear(); } wrapCallback( - _id: string, - func: FunctionCallback | undefined, - _cleanup = false + id: string, + func: ((...args: T) => void) | undefined, + cleanup = false ) { - const asyncDelay = Client.asyncDelay; - - return (...args: T) => { - if ((RealApi.hasProjectBeenRenamed as any)(...args)) { - return; - } - respond(asyncDelay, func, ...args); - }; + if (globalThis.__USE_REAL_API__) { + return this.realClient?.wrapCallback(id, func, cleanup); + } + return super.wrapCallback(id, func, cleanup); } - requestPromise( - path: string, - { - includeAllArgs, - ...options - }: {includeAllArgs?: boolean} & Readonly = {} - ): any { - return new Promise((resolve, reject) => { - this.request(path, { - ...options, - success: (data, ...args) => { - resolve(includeAllArgs ? [data, ...args] : data); - }, - error: (error, ..._args) => { - reject(error); - }, - }); - }); + requestPromise(path: string, options?: any): Promise { + if (globalThis.__USE_REAL_API__) { + return this.realClient?.requestPromise(path, options); + } + return super.requestPromise(path, options); } - static errors: Record = {}; - - // XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul - request(url: string, options: Readonly = {}): any { - const [response, mock] = Client.findMockResponse(url, options) || [ - undefined, - undefined, - ]; - if (!response || !mock) { - const methodAndUrl = `${options.method || 'GET'} ${url}`; - // Endpoints need to be mocked - const err = new Error(`No mocked response found for request: ${methodAndUrl}`); - - // Mutate stack to drop frames since test file so that we know where in the test - // this needs to be mocked - const lines = err.stack?.split('\n'); - const startIndex = lines?.findIndex(line => line.includes('.spec.')); - err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n'); - - // Throwing an error here does not do what we want it to do.... - // Because we are mocking an API client, we generally catch errors to show - // user-friendly error messages, this means in tests this error gets gobbled - // up and developer frustration ensues. - // We track the errors on a static member and warn afterEach test. - Client.errors[methodAndUrl] = err; - } else { - // has mocked response - - // mock gets returned when we add a mock response, will represent calls to api.request - mock(url, options); - - const body = - typeof response.body === 'function' ? response.body(url, options) : response.body; - - if (response.statusCode >= 300) { - response.callCount++; - - const errorResponse = Object.assign( - new RequestError(options.method || 'GET', url, new Error('Request failed')), - { - status: response.statusCode, - responseText: JSON.stringify(body), - responseJSON: body, - }, - { - overrideMimeType: () => {}, - abort: () => {}, - then: () => {}, - error: () => {}, - } - ); - - this.handleRequestError( - { - id: '1234', - path: url, - requestOptions: options, - }, - errorResponse as any, - 'error', - 'error' - ); - } else { - response.callCount++; - respond( - response.asyncDelay, - options.success, - body, - {}, - { - getResponseHeader: (key: string) => response.headers[key], - statusCode: response.statusCode, - status: response.statusCode, - } - ); - } + request(url: string, options?: any): any { + if (globalThis.__USE_REAL_API__) { + return this.realClient?.request(url, options); } - - respond(response?.asyncDelay, options.complete); + return super.request(url, options); } - - handleRequestError = RealApi.Client.prototype.handleRequestError; } - -export {Client}; diff --git a/static/app/__mocks__/mockApi.tsx b/static/app/__mocks__/mockApi.tsx new file mode 100644 index 00000000000000..6795457a5a350e --- /dev/null +++ b/static/app/__mocks__/mockApi.tsx @@ -0,0 +1,310 @@ +import isEqual from 'lodash/isEqual'; + +import type * as ApiNamespace from 'sentry/api'; +import RequestError from 'sentry/utils/requestError/requestError'; + +const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api'); + +const respond = ( + asyncDelay: AsyncDelay, + fn: FunctionCallback | undefined, + ...args: any[] +): void => { + if (!fn) { + return; + } + + if (asyncDelay !== undefined) { + setTimeout(() => fn(...args), asyncDelay); + return; + } + + fn(...args); +}; + +type FunctionCallback = (...args: Args) => void; + +/** + * Callables for matching requests based on arbitrary conditions. + */ +type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean; + +type AsyncDelay = undefined | number; +interface ResponseType extends ApiNamespace.ResponseMeta { + body: any; + callCount: 0; + headers: Record; + host: string; + match: MatchCallable[]; + method: string; + statusCode: number; + url: string; + /** + * Whether to return mocked api responses directly, or with a setTimeout delay. + * + * Set to `null` to disable the async delay + * Set to a `number` which will be the amount of time (ms) for the delay + * + * This will override `MockApiClient.asyncDelay` for this request. + */ + asyncDelay?: AsyncDelay; + query?: Record; +} + +type MockResponse = [resp: ResponseType, mock: jest.Mock]; + +/** + * Compare two records. `want` is all the entries we want to have the same value in `check` + */ +function compareRecord(want: Record, check: Record): boolean { + for (const entry of Object.entries(want)) { + const [key, value] = entry; + if (!isEqual(check[key], value)) { + return false; + } + } + return true; +} + +afterEach(() => { + // if any errors are caught we console.error them + const errors = Object.values(Client.errors); + if (errors.length > 0) { + for (const err of errors) { + // eslint-disable-next-line no-console + console.error(err); + } + Client.errors = {}; + } + + // Mock responses are removed between tests + Client.clearMockResponses(); +}); + +class Client implements ApiNamespace.Client { + activeRequests: Record = {}; + baseUrl = ''; + // uses the default client json headers. Sadly, we cannot refernce the real client + // because it will cause a circular dependency and explode, hence the copy/paste + headers = { + Accept: 'application/json; charset=utf-8', + 'Content-Type': 'application/json', + }; + + static mockResponses: MockResponse[] = []; + + /** + * Whether to return mocked api responses directly, or with a setTimeout delay. + * + * Set to `null` to disable the async delay + * Set to a `number` which will be the amount of time (ms) for the delay + * + * This is the global/default value. `addMockResponse` can override per request. + */ + static asyncDelay: AsyncDelay = undefined; + + static clearMockResponses() { + Client.mockResponses = []; + } + + /** + * Create a query string match callable. + * + * Only keys/values defined in `query` are checked. + */ + static matchQuery(query: Record): MatchCallable { + const queryMatcher: MatchCallable = (_url, options) => { + return compareRecord(query, options.query ?? {}); + }; + + return queryMatcher; + } + + /** + * Create a data match callable. + * + * Only keys/values defined in `data` are checked. + */ + static matchData(data: Record): MatchCallable { + const dataMatcher: MatchCallable = (_url, options) => { + return compareRecord(data, options.data ?? {}); + }; + + return dataMatcher; + } + + // Returns a jest mock that represents Client.request calls + static addMockResponse(response: Partial) { + const mock = jest.fn(); + + Client.mockResponses.unshift([ + { + host: '', + url: '', + status: 200, + statusCode: 200, + statusText: 'OK', + responseText: '', + responseJSON: '', + body: '', + method: 'GET', + callCount: 0, + match: [], + ...response, + asyncDelay: response.asyncDelay ?? Client.asyncDelay, + headers: response.headers ?? {}, + getResponseHeader: (key: string) => response.headers?.[key] ?? null, + }, + mock, + ]); + + return mock; + } + + static findMockResponse(url: string, options: Readonly) { + return Client.mockResponses.find(([response]) => { + if (response.host && (options.host || '') !== response.host) { + return false; + } + if (url !== response.url) { + return false; + } + if ((options.method || 'GET') !== response.method) { + return false; + } + return response.match.every(matcher => matcher(url, options)); + }); + } + + uniqueId() { + return '123'; + } + + /** + * In the real client, this clears in-flight responses. It's NOT + * clearMockResponses. You probably don't want to call this from a test. + */ + clear() { + Object.values(this.activeRequests).forEach(r => r.cancel()); + } + + wrapCallback( + _id: string, + func: FunctionCallback | undefined, + _cleanup = false + ) { + const asyncDelay = Client.asyncDelay; + + return (...args: T) => { + if ((RealApi.hasProjectBeenRenamed as any)(...args)) { + return; + } + respond(asyncDelay, func, ...args); + }; + } + + requestPromise( + path: string, + { + includeAllArgs, + ...options + }: {includeAllArgs?: boolean} & Readonly = {} + ): any { + return new Promise((resolve, reject) => { + this.request(path, { + ...options, + success: (data, ...args) => { + resolve(includeAllArgs ? [data, ...args] : data); + }, + error: (error, ..._args) => { + reject(error); + }, + }); + }); + } + + static errors: Record = {}; + + // XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul + request(url: string, options: Readonly = {}): any { + const [response, mock] = Client.findMockResponse(url, options) || [ + undefined, + undefined, + ]; + if (!response || !mock) { + const methodAndUrl = `${options.method || 'GET'} ${url}`; + // Endpoints need to be mocked + const err = new Error(`No mocked response found for request: ${methodAndUrl}`); + + // Mutate stack to drop frames since test file so that we know where in the test + // this needs to be mocked + const lines = err.stack?.split('\n'); + const startIndex = lines?.findIndex(line => line.includes('.spec.')); + err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n'); + + // Throwing an error here does not do what we want it to do.... + // Because we are mocking an API client, we generally catch errors to show + // user-friendly error messages, this means in tests this error gets gobbled + // up and developer frustration ensues. + // We track the errors on a static member and warn afterEach test. + Client.errors[methodAndUrl] = err; + } else { + // has mocked response + + // mock gets returned when we add a mock response, will represent calls to api.request + mock(url, options); + + const body = + typeof response.body === 'function' ? response.body(url, options) : response.body; + + if (response.statusCode >= 300) { + response.callCount++; + + const errorResponse = Object.assign( + new RequestError(options.method || 'GET', url, new Error('Request failed')), + { + status: response.statusCode, + responseText: JSON.stringify(body), + responseJSON: body, + }, + { + overrideMimeType: () => {}, + abort: () => {}, + then: () => {}, + error: () => {}, + } + ); + + this.handleRequestError( + { + id: '1234', + path: url, + requestOptions: options, + }, + errorResponse as any, + 'error', + 'error' + ); + } else { + response.callCount++; + respond( + response.asyncDelay, + options.success, + body, + {}, + { + getResponseHeader: (key: string) => response.headers[key], + statusCode: response.statusCode, + status: response.statusCode, + } + ); + } + } + + respond(response?.asyncDelay, options.complete); + } + + handleRequestError = RealApi.Client.prototype.handleRequestError; +} + +export {Client}; diff --git a/static/app/components/prevent/virtualRenderers/virtualDiffRenderer.spec.tsx b/static/app/components/prevent/virtualRenderers/virtualDiffRenderer.spec.tsx index a1dd1adf5e915f..6417019bdc82f1 100644 --- a/static/app/components/prevent/virtualRenderers/virtualDiffRenderer.spec.tsx +++ b/static/app/components/prevent/virtualRenderers/virtualDiffRenderer.spec.tsx @@ -24,6 +24,8 @@ const scrollToMock = jest.fn(); window.scrollTo = scrollToMock; window.scrollY = 100; +const rafTimeout = 50; + class ResizeObserverMock { callback = (_x: any) => null; @@ -84,7 +86,7 @@ describe('VirtualFileRenderer', () => { .mockImplementation((cb: FrameRequestCallback) => { setTimeout(() => { cb(1); - }, 50); + }, rafTimeout); return 1; }); cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame'); @@ -244,25 +246,38 @@ describe('VirtualFileRenderer', () => { }); describe('toggling pointer events', () => { - it('disables pointer events on scroll and resets after timeout', async () => { - render( - , - {} - ); + describe('with fake timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); - const lines = await screen.findAllByText(/{ pageName: 'repo', text: repo },/); - expect(lines[0]).toBeInTheDocument(); + it('disables pointer events on scroll and resets after timeout', async () => { + render( + , + {} + ); - fireEvent.scroll(window, {target: {scrollX: 100}}); + const lines = await screen.findAllByText(/{ pageName: 'repo', text: repo },/); + expect(lines[0]).toBeInTheDocument(); + + fireEvent.scroll(window, {target: {scrollX: 100}}); - const codeRenderer = screen.getByTestId('virtual-diff-renderer'); - await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: none')); - await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: auto')); + const codeRenderer = screen.getByTestId('virtual-diff-renderer'); + await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: none')); + + // Advance timers to complete the setTimeout in the mocked requestAnimationFrame + jest.advanceTimersByTime(rafTimeout); + + await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: auto')); + }); }); it('calls cancelAnimationFrame', async () => { diff --git a/static/app/components/prevent/virtualRenderers/virtualFileRenderer.spec.tsx b/static/app/components/prevent/virtualRenderers/virtualFileRenderer.spec.tsx index e008bf6ffc6a18..3127150f5d2985 100644 --- a/static/app/components/prevent/virtualRenderers/virtualFileRenderer.spec.tsx +++ b/static/app/components/prevent/virtualRenderers/virtualFileRenderer.spec.tsx @@ -32,6 +32,8 @@ const scrollToMock = jest.fn(); window.scrollTo = scrollToMock; window.scrollY = 100; +const rafTimeout = 50; + let scrollWidth = 100; let clientWidth = 100; @@ -90,7 +92,7 @@ describe('VirtualFileRenderer', () => { .mockImplementation(cb => { setTimeout(() => { cb(1); - }, 50); + }, rafTimeout); return 1; }); cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame'); @@ -207,19 +209,32 @@ describe('VirtualFileRenderer', () => { }); describe('toggling pointer events', () => { - it('disables pointer events on scroll and resets after timeout', async () => { - render( - - ); + describe('with fake timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); - const lines = await screen.findAllByText(/{ pageName: 'repo', text: repo },/); - expect(lines[0]).toBeInTheDocument(); + it('disables pointer events on scroll and resets after timeout', async () => { + render( + + ); - fireEvent.scroll(window, {target: {scrollX: 100}}); + const lines = await screen.findAllByText(/{ pageName: 'repo', text: repo },/); + expect(lines[0]).toBeInTheDocument(); - const codeRenderer = screen.getByTestId('virtual-file-renderer'); - await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: none')); - await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: auto')); + fireEvent.scroll(window, {target: {scrollX: 100}}); + + const codeRenderer = screen.getByTestId('virtual-file-renderer'); + await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: none')); + + // Advance timers to complete the setTimeout in the mocked requestAnimationFrame + jest.advanceTimersByTime(rafTimeout); + + await waitFor(() => expect(codeRenderer).toHaveStyle('pointer-events: auto')); + }); }); it('calls cancelAnimationFrame', async () => { diff --git a/static/app/gettingStartedDocs/node-awslambda/onboarding.spec.tsx b/static/app/gettingStartedDocs/node-awslambda/onboarding.spec.tsx index 48447197fad73b..92ec5330109ce1 100644 --- a/static/app/gettingStartedDocs/node-awslambda/onboarding.spec.tsx +++ b/static/app/gettingStartedDocs/node-awslambda/onboarding.spec.tsx @@ -1,3 +1,6 @@ +import {http, HttpResponse} from 'msw'; + +import {server} from 'sentry-test/msw'; import {renderWithOnboardingLayout} from 'sentry-test/onboarding/renderWithOnboardingLayout'; import {screen} from 'sentry-test/reactTestingLibrary'; import {textWithMarkupMatcher} from 'sentry-test/utils'; @@ -8,6 +11,13 @@ import {InstallationMethod} from './utils'; import docs from '.'; describe('awslambda onboarding docs', () => { + beforeEach(() => { + server.use( + http.get('https://release-registry.services.sentry.io/aws-lambda-layers', () => + HttpResponse.json({}) + ) + ); + }); describe('Lambda Layer', () => { it('renders onboarding docs correctly', () => { renderWithOnboardingLayout(docs); diff --git a/static/app/stores/groupingStore.spec.tsx b/static/app/stores/groupingStore.spec.tsx index 5c8778a35b8527..e4b3049d3f8c00 100644 --- a/static/app/stores/groupingStore.spec.tsx +++ b/static/app/stores/groupingStore.spec.tsx @@ -1,107 +1,115 @@ +import {http, HttpResponse} from 'msw'; + +import {server} from 'sentry-test/msw'; +import {localUrl} from 'sentry-test/utils'; + import * as GroupActionCreators from 'sentry/actionCreators/group'; import GroupingStore from 'sentry/stores/groupingStore'; +import getApiUrl from 'sentry/utils/api/getApiUrl'; describe('Grouping Store', () => { let trigger!: jest.SpyInstance; - beforeAll(() => { - MockApiClient.asyncDelay = 1; - }); - - afterAll(() => { - MockApiClient.asyncDelay = undefined; - }); - beforeEach(() => { GroupingStore.init(); trigger = jest.spyOn(GroupingStore, 'trigger'); - MockApiClient.addMockResponse({ - url: '/issues/groupId/hashes/', - body: [ - { - latestEvent: { - eventID: 'event-1', - }, - state: 'locked', - id: '1', - }, - { - latestEvent: { - eventID: 'event-2', - }, - state: 'unlocked', - id: '2', - mergedBySeer: true, - }, - { - latestEvent: { - eventID: 'event-3', - }, - state: 'unlocked', - id: '3', - }, - { - latestEvent: { - eventID: 'event-4', - }, - state: 'unlocked', - id: '4', - mergedBySeer: true, - }, - { - latestEvent: { - eventID: 'event-5', - }, - state: 'locked', - id: '5', - }, - ], - }); - MockApiClient.addMockResponse({ - url: '/issues/groupId/similar/', - body: [ - [ - { - id: '274', - }, - { - 'exception:stacktrace:pairs': 0.375, - 'exception:stacktrace:application-chunks': 0.175, - 'message:message:character-shingles': 0.775, - }, - ], - [ - { - id: '275', - }, - {'exception:stacktrace:pairs': 1.0}, - ], - [ - { - id: '216', - }, - { - 'exception:stacktrace:application-chunks': 0.000235, - 'exception:stacktrace:pairs': 0.001488, - }, - ], - [ - { - id: '217', - }, - { - 'exception:message:character-shingles': null, - 'exception:stacktrace:application-chunks': 0.25, - 'exception:stacktrace:pairs': 0.25, - 'message:message:character-shingles': 0.7, - }, - ], - ], - }); + + globalThis.__USE_REAL_API__ = true; + + server.use( + http.get( + localUrl(getApiUrl('/issues/$issueId/hashes/', {path: {issueId: 'groupId'}})), + () => { + return HttpResponse.json([ + { + latestEvent: { + eventID: 'event-1', + }, + state: 'locked', + id: '1', + }, + { + latestEvent: { + eventID: 'event-2', + }, + state: 'unlocked', + id: '2', + mergedBySeer: true, + }, + { + latestEvent: { + eventID: 'event-3', + }, + state: 'unlocked', + id: '3', + }, + { + latestEvent: { + eventID: 'event-4', + }, + state: 'unlocked', + id: '4', + mergedBySeer: true, + }, + { + latestEvent: { + eventID: 'event-5', + }, + state: 'locked', + id: '5', + }, + ]); + } + ), + + http.get( + localUrl(getApiUrl('/issues/$issueId/similar/', {path: {issueId: 'groupId'}})), + () => { + return HttpResponse.json([ + [ + { + id: '274', + }, + { + 'exception:stacktrace:pairs': 0.375, + 'exception:stacktrace:application-chunks': 0.175, + 'message:message:character-shingles': 0.775, + }, + ], + [ + { + id: '275', + }, + {'exception:stacktrace:pairs': 1.0}, + ], + [ + { + id: '216', + }, + { + 'exception:stacktrace:application-chunks': 0.000235, + 'exception:stacktrace:pairs': 0.001488, + }, + ], + [ + { + id: '217', + }, + { + 'exception:message:character-shingles': null, + 'exception:stacktrace:application-chunks': 0.25, + 'exception:stacktrace:pairs': 0.25, + 'message:message:character-shingles': 0.7, + }, + ], + ]); + } + ) + ); }); afterEach(() => { - MockApiClient.clearMockResponses(); + globalThis.__USE_REAL_API__ = false; jest.resetAllMocks(); jest.restoreAllMocks(); }); @@ -177,12 +185,19 @@ describe('Grouping Store', () => { }); it('unsuccessfully fetches list of similar items', () => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - url: '/issues/groupId/similar/', - statusCode: 500, - body: {message: 'failed'}, - }); + server.use( + http.get( + localUrl(getApiUrl('/issues/$issueId/similar/', {path: {issueId: 'groupId'}})), + () => { + return HttpResponse.json( + {message: 'failed'}, + { + status: 500, + } + ); + } + ) + ); const promise = GroupingStore.onFetch([ {dataKey: 'similar', endpoint: '/issues/groupId/similar/'}, @@ -267,12 +282,19 @@ describe('Grouping Store', () => { }); it('unsuccessfully fetches list of hashes items', () => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - url: '/issues/groupId/hashes/', - statusCode: 500, - body: {message: 'failed'}, - }); + server.use( + http.get( + localUrl(getApiUrl('/issues/$issueId/hashes/', {path: {issueId: 'groupId'}})), + () => { + return HttpResponse.json( + {message: 'failed'}, + { + status: 500, + } + ); + } + ) + ); const promise = GroupingStore.onFetch([ {dataKey: 'merged', endpoint: '/issues/groupId/hashes/'}, @@ -342,11 +364,11 @@ describe('Grouping Store', () => { describe('onMerge', () => { beforeEach(() => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - method: 'PUT', - url: '/projects/orgId/projectId/issues/', - }); + server.use( + http.put('*/projects/orgId/projectId/issues/', () => { + return HttpResponse.json({}); + }) + ); GroupingStore.init(); }); @@ -467,13 +489,16 @@ describe('Grouping Store', () => { }); it('resets busy state and has same items checked after error when trying to merge', async () => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - method: 'PUT', - url: '/projects/orgId/projectId/issues/', - statusCode: 500, - body: {}, - }); + server.use( + http.put('*/projects/orgId/projectId/issues/', () => { + return HttpResponse.json( + {}, + { + status: 500, + } + ); + }) + ); GroupingStore.onToggleMerge('1'); mergeList = ['1']; @@ -626,11 +651,11 @@ describe('Grouping Store', () => { // add a beforeEach(() => GroupingStore.init()) describe('onUnmerge', () => { beforeEach(() => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - method: 'PUT', - url: '/organizations/org-slug/issues/groupId/hashes/', - }); + server.use( + http.put('*/organizations/org-slug/issues/groupId/hashes/', () => { + return HttpResponse.json({}); + }) + ); }); it('can not toggle unmerge for a locked item', () => { @@ -728,13 +753,16 @@ describe('Grouping Store', () => { }); it('resets busy state and has same items checked after error when trying to merge', async () => { - MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - method: 'PUT', - url: '/organizations/org-slug/issues/groupId/hashes/', - statusCode: 500, - body: {}, - }); + server.use( + http.put('*/organizations/org-slug/issues/groupId/hashes/', () => { + return HttpResponse.json( + {}, + { + status: 500, + } + ); + }) + ); GroupingStore.onToggleUnmerge(['2', 'event-2']); unmergeList.set('2', 'event-2'); diff --git a/static/app/utils/api/getApiUrl.ts b/static/app/utils/api/getApiUrl.ts index 3fcb6523e36d52..1a36525cc97217 100644 --- a/static/app/utils/api/getApiUrl.ts +++ b/static/app/utils/api/getApiUrl.ts @@ -26,7 +26,7 @@ export type OptionalPathParams = const paramRegex = /\$([a-zA-Z0-9_-]+)/g; -type ApiUrl = string & {__apiUrl: true}; +export type ApiUrl = string & {__apiUrl: true}; export default function getApiUrl( path: TApiPath, diff --git a/static/app/utils/queryClient.spec.tsx b/static/app/utils/queryClient.spec.tsx index 620b588a629b64..ad4086c0d30f69 100644 --- a/static/app/utils/queryClient.spec.tsx +++ b/static/app/utils/queryClient.spec.tsx @@ -1,5 +1,7 @@ import {Fragment} from 'react'; +import {http, HttpResponse} from 'msw'; +import {server} from 'sentry-test/msw'; import {render, screen} from 'sentry-test/reactTestingLibrary'; import { @@ -73,13 +75,24 @@ describe('queryClient', () => { }); describe('useQuery', () => { + beforeEach(() => { + globalThis.__USE_REAL_API__ = true; + }); + afterEach(() => { + globalThis.__USE_REAL_API__ = false; + }); it('can do a simple fetch', async () => { - const mock = MockApiClient.addMockResponse({ - url: '/some/test/path/', - body: {value: 5}, - headers: {'Custom-Header': 'header value'}, + const resolver = jest.fn(() => { + return HttpResponse.json( + { + value: 5, + }, + {headers: {'Custom-Header': 'header value'}} + ); }); + server.use(http.get('*/some/test/path/', resolver)); + function TestComponent() { const {data, getResponseHeader} = useApiQuery( ['/some/test/path/'], @@ -103,14 +116,23 @@ describe('queryClient', () => { expect(await screen.findByText('5')).toBeInTheDocument(); expect(screen.getByText('header value')).toBeInTheDocument(); - expect(mock).toHaveBeenCalledWith('/some/test/path/', expect.anything()); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + url: expect.stringContaining('/some/test/path/'), + }), + }) + ); }); it('can do a fetch with provided query object', async () => { - const mock = MockApiClient.addMockResponse({ - url: '/some/test/path/', - body: {value: 5}, + const resolver = jest.fn(() => { + return HttpResponse.json({ + value: 5, + }); }); + server.use(http.get('*/some/test/path/', resolver)); function TestComponent() { const {data} = useApiQuery( @@ -129,20 +151,25 @@ describe('queryClient', () => { expect(await screen.findByText('5')).toBeInTheDocument(); - expect(mock).toHaveBeenCalledWith( - '/some/test/path/', - expect.objectContaining({query: {filter: 'red'}}) + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + url: expect.stringContaining('some/test/path/?filter=red'), + }), + }) ); }); it('can return error state', async () => { - MockApiClient.addMockResponse({ - url: '/some/test/path', - statusCode: 500, - }); + server.use( + http.get('*/some/test/path/', () => { + return HttpResponse.error(); + }) + ); function TestComponent() { - const query = useApiQuery(['/some/test/path'], { + const query = useApiQuery(['/some/test/path/'], { staleTime: 0, }); diff --git a/static/app/views/nav/primary/serviceIncidents.spec.tsx b/static/app/views/nav/primary/serviceIncidents.spec.tsx index 3bc1c1438efa98..2e0a16bbbd7e63 100644 --- a/static/app/views/nav/primary/serviceIncidents.spec.tsx +++ b/static/app/views/nav/primary/serviceIncidents.spec.tsx @@ -1,6 +1,7 @@ -import fetchMock from 'jest-fetch-mock'; +import {http, HttpResponse} from 'msw'; import {ServiceIncidentFixture} from 'sentry-fixture/serviceIncident'; +import {server} from 'sentry-test/msw'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import ConfigStore from 'sentry/stores/configStore'; @@ -14,21 +15,14 @@ describe('PrimaryNavigationServiceIncidents', () => { }); }); - afterEach(() => { - fetchMock.resetMocks(); - }); - it('should not show anything if there are no incidents', async () => { - const mockFetchIncidents = fetchMock.mockResponse(req => - req.url.endsWith('incidents/unresolved.json') - ? Promise.resolve(JSON.stringify({incidents: []})) - : Promise.reject() - ); + const resolver = jest.fn(() => HttpResponse.json({incidents: []})); + server.use(http.get('*incidents/unresolved.json', resolver)); render(); await waitFor(() => { - expect(mockFetchIncidents).toHaveBeenCalled(); + expect(resolver).toHaveBeenCalled(); }); expect( @@ -39,10 +33,10 @@ describe('PrimaryNavigationServiceIncidents', () => { it('displays button and list of incidents when clicked', async () => { const incident = ServiceIncidentFixture(); - fetchMock.mockResponse(req => - req.url.endsWith('incidents/unresolved.json') - ? Promise.resolve(JSON.stringify({incidents: [incident]})) - : Promise.reject() + server.use( + http.get('*incidents/unresolved.json', () => + HttpResponse.json({incidents: [incident]}) + ) ); render(); diff --git a/tests/js/sentry-test/msw.ts b/tests/js/sentry-test/msw.ts new file mode 100644 index 00000000000000..10c87de5f0542f --- /dev/null +++ b/tests/js/sentry-test/msw.ts @@ -0,0 +1,3 @@ +import {setupServer} from 'msw/node'; + +export const server = setupServer(); diff --git a/tests/js/sentry-test/utils.tsx b/tests/js/sentry-test/utils.tsx index 3be7f206153f6b..89b75b0172a7e8 100644 --- a/tests/js/sentry-test/utils.tsx +++ b/tests/js/sentry-test/utils.tsx @@ -1,5 +1,7 @@ import MockDate from 'mockdate'; +import type {ApiUrl} from 'sentry/utils/api/getApiUrl'; + // Taken from https://stackoverflow.com/a/56859650/1015027 function findTextWithMarkup(contentNode: null | Element, textMatch: string | RegExp) { const hasText = (node: Element): boolean => { @@ -67,3 +69,8 @@ export function mockMatchMedia(matches: boolean) { dispatchEvent: jest.fn(), })); } + +export function localUrl(path: ApiUrl) { + // first character is always '/', so slice it off + return new URL(path.slice(1), 'http://localhost/api/0/').href; +} diff --git a/tests/js/setup.ts b/tests/js/setup.ts index 059a98a2032bd9..8de2e3dc2ccf24 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -1,13 +1,16 @@ 'use strict'; +import 'cross-fetch/polyfill'; import '@testing-library/jest-dom'; import {webcrypto} from 'node:crypto'; +import {TransformStream, WritableStream} from 'node:stream/web'; import {TextDecoder, TextEncoder} from 'node:util'; +import {BroadcastChannel} from 'node:worker_threads'; import {type ReactElement} from 'react'; import {configure as configureRtl} from '@testing-library/react'; // eslint-disable-line no-restricted-imports -import {enableFetchMocks} from 'jest-fetch-mock'; + import {ConfigFixture} from 'sentry-fixture/config'; import {resetMockDate} from 'sentry-test/utils'; @@ -26,11 +29,6 @@ import * as performanceForSentry from 'sentry/utils/performanceForSentry'; */ setLocale(DEFAULT_LOCALE_DATA); -/** - * Setup fetch mocks (needed to define the `Request` global) - */ -enableFetchMocks(); - // @ts-expect-error XXX(epurkhiser): Gross hack to fix a bug in jsdom which makes testing of // framer-motion SVG components fail // See https://github.com/jsdom/jsdom/issues/1330 @@ -254,6 +252,10 @@ declare global { * Used to mock API requests */ var MockApiClient: typeof Client; + /** + * Flag to use real API client instead of mock for MSW tests + */ + var __USE_REAL_API__: boolean; } // needed by cbor-web for webauthn @@ -335,3 +337,8 @@ Object.defineProperty(global.self, 'crypto', { subtle: webcrypto.subtle, }, }); + +(globalThis as any).BroadcastChannel ??= BroadcastChannel; + +(globalThis as any).WritableStream ??= WritableStream; +(globalThis as any).TransformStream ??= TransformStream; diff --git a/tests/js/setupMsw.ts b/tests/js/setupMsw.ts new file mode 100644 index 00000000000000..7eae71a0b9e754 --- /dev/null +++ b/tests/js/setupMsw.ts @@ -0,0 +1,26 @@ +import {http, passthrough} from 'msw'; + +import {server} from 'sentry-test/msw'; + +beforeAll(() => + server.listen({ + // This tells MSW to throw an error whenever it + // encounters a request that doesn't have a + // matching request handler. + onUnhandledRequest: 'error', + }) +); + +beforeEach(() => { + server.use( + // jest project under Sentry organization (dev productivity team) + http.all('https://o1.ingest.us.sentry.io/*', () => { + passthrough(); + }) + ); +}); + +afterEach(() => { + server.resetHandlers(); +}); +afterAll(() => server.close());