From a7f97e40b3cbdee70dbf636f539698bee2a49f36 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 18 Oct 2024 23:40:52 +0200 Subject: [PATCH] cli: add option to reject network requets in frontend tests Signed-off-by: Patrik Oldsberg --- .changeset/late-kings-wave.md | 15 +++ package.json | 3 + packages/app/package.json | 4 +- packages/app/src/test-reject-network.test.ts | 109 ++++++++++++++++++ packages/cli/config/jest.js | 70 +++++++---- .../cli/config/jestRejectNetworkRequests.js | 59 ++++++++++ yarn.lock | 4 +- 7 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 .changeset/late-kings-wave.md create mode 100644 packages/app/src/test-reject-network.test.ts create mode 100644 packages/cli/config/jestRejectNetworkRequests.js diff --git a/.changeset/late-kings-wave.md b/.changeset/late-kings-wave.md new file mode 100644 index 0000000000..01877f2732 --- /dev/null +++ b/.changeset/late-kings-wave.md @@ -0,0 +1,15 @@ +--- +'@backstage/cli': patch +--- + +Added a new `"rejectFrontendNetworkRequests"` configuration flag that can be set in the `"jest"` field in the root `package.json`: + +```json +{ + "jest": { + "rejectFrontendNetworkRequests": true + } +} +``` + +This flag causes rejection of any form of network requests that are attempted to be made in frontend or common package tests. This flag can only be set in the root `package.json` and can not be overridden in individual package configurations. diff --git a/package.json b/package.json index 36e89062f0..7e267c7cfc 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,9 @@ "sort-package-json": "^2.8.0", "typescript": "~5.2.0" }, + "jest": { + "rejectFrontendNetworkRequests": true + }, "packageManager": "yarn@3.8.1", "engines": { "node": "18 || 20" diff --git a/packages/app/package.json b/packages/app/package.json index f36980707a..2012f9888d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -98,7 +98,9 @@ "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", - "cross-env": "^7.0.0" + "axios": "^1.7.7", + "cross-env": "^7.0.0", + "msw": "^1.0.0" }, "bundled": true } diff --git a/packages/app/src/test-reject-network.test.ts b/packages/app/src/test-reject-network.test.ts new file mode 100644 index 0000000000..25f01dc082 --- /dev/null +++ b/packages/app/src/test-reject-network.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { registerMswTestHooks } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import axios from 'axios'; +// eslint-disable-next-line no-restricted-imports +import http from 'http'; +// eslint-disable-next-line no-restricted-imports +import https from 'https'; + +const errorMsg = 'Network requests are not allowed in tests'; + +// These test relates to the @backstage/cli Jest configuration. It makes sure +// that network requests are properly rejected in JSDom environments. + +describe('without msw', () => { + it('should reject network requests', async () => { + await expect(fetch('https://example.com')).rejects.toThrow(errorMsg); + await expect(axios('https://example.com')).rejects.toThrow(errorMsg); + expect(() => http.get('http://example.com')).toThrow(errorMsg); + expect(() => https.get('https://example.com')).toThrow(errorMsg); + await expect( + new Promise(resolve => { + const ws = new WebSocket('ws://example.com'); + ws.addEventListener('error', () => resolve('error')); + }), + ).resolves.toBe('error'); + expect(typeof EventSource).toBe('undefined'); + expect(() => new XMLHttpRequest()).toThrow(errorMsg); + }); +}); + +// This makes sure that MSW mocks still work as expected + +describe('with msw', () => { + const server = setupServer(); + registerMswTestHooks(server); + + it('should mock network requests', async () => { + server.use( + rest.get('http://example.com', (_, res, ctx) => + res(ctx.json({ ok: true })), + ), + rest.get('https://example.com', (_, res, ctx) => + res(ctx.json({ ok: true })), + ), + ); + + await expect( + fetch('https://example.com').then(res => res.json()), + ).resolves.toEqual({ ok: true }); + + await expect( + axios('https://example.com').then(res => res.data), + ).resolves.toEqual({ ok: true }); + + await expect( + new Promise(resolve => { + const req = http.get('http://example.com'); + req.on('response', res => { + res.on('data', data => { + resolve(JSON.parse(data.toString())); + }); + }); + }), + ).resolves.toEqual({ + ok: true, + }); + + await expect( + new Promise(resolve => { + const req = https.get('https://example.com'); + req.on('response', res => { + res.on('data', data => { + resolve(JSON.parse(data.toString())); + }); + }); + }), + ).resolves.toEqual({ + ok: true, + }); + + await expect( + new Promise(resolve => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://example.com'); + xhr.onload = () => { + resolve(JSON.parse(xhr.responseText)); + }; + xhr.send(); + }), + ).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 36ffd359aa..dedb1661a9 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -23,6 +23,14 @@ const paths = require('@backstage/cli-common').findPaths(process.cwd()); const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs']; +const FRONTEND_ROLES = [ + 'frontend', + 'web-library', + 'common-library', + 'frontend-plugin', + 'frontend-plugin-module', +]; + const envOptions = { oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), }; @@ -121,24 +129,13 @@ const transformIgnorePattern = [ // Provides additional config that's based on the role of the target package function getRoleConfig(role) { - switch (role) { - case 'frontend': - case 'web-library': - case 'common-library': - case 'frontend-plugin': - case 'frontend-plugin-module': - return { testEnvironment: require.resolve('jest-environment-jsdom') }; - case 'cli': - case 'backend': - case 'node-library': - case 'backend-plugin': - case 'backend-plugin-module': - default: - return { testEnvironment: require.resolve('jest-environment-node') }; + if (FRONTEND_ROLES.includes(role)) { + return { testEnvironment: require.resolve('jest-environment-jsdom') }; } + return { testEnvironment: require.resolve('jest-environment-node') }; } -async function getProjectConfig(targetPath, extraConfig) { +async function getProjectConfig(targetPath, extraConfig, extraOptions) { const configJsPath = path.resolve(targetPath, 'jest.config.js'); const configTsPath = path.resolve(targetPath, 'jest.config.ts'); // If the package has it's own jest config, we use that instead. @@ -232,6 +229,17 @@ async function getProjectConfig(targetPath, extraConfig) { options.setupFilesAfterEnv = options.setupFilesAfterEnv || []; + if ( + extraOptions.rejectFrontendNetworkRequests && + FRONTEND_ROLES.includes(pkgJson.backstage?.role) + ) { + // By adding this first we ensure that it's possible to for example override + // fetch with a mock in a custom setup file + options.setupFilesAfterEnv.unshift( + require.resolve('./jestRejectNetworkRequests.js'), + ); + } + if (options.testEnvironment === require.resolve('jest-environment-jsdom')) { // FIXME https://github.com/jsdom/jsdom/issues/1724 options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill')); @@ -276,21 +284,31 @@ async function getRootConfig() { collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'], }; + const { rejectFrontendNetworkRequests, ...rootOptions } = + rootPkgJson.jest ?? {}; + const extraRootOptions = { + rejectFrontendNetworkRequests, + }; + const workspacePatterns = rootPkgJson.workspaces && rootPkgJson.workspaces.packages; // Check if we're running within a specific monorepo package. In that case just get the single project config. if (!workspacePatterns || paths.targetRoot !== paths.targetDir) { - return getProjectConfig(paths.targetDir, { - ...baseCoverageConfig, - ...(rootPkgJson.jest ?? {}), - }); + return getProjectConfig( + paths.targetDir, + { + ...baseCoverageConfig, + ...rootOptions, + }, + extraRootOptions, + ); } const globalRootConfig = { ...baseCoverageConfig }; const globalProjectConfig = {}; - for (const [key, value] of Object.entries(rootPkgJson.jest ?? {})) { + for (const [key, value] of Object.entries(rootOptions)) { if (projectConfigKeys.includes(key)) { globalProjectConfig[key] = value; } else { @@ -321,10 +339,14 @@ async function getRootConfig() { testScript?.includes('backstage-cli test') || testScript?.includes('backstage-cli package test'); if (testScript && isSupportedTestScript) { - return await getProjectConfig(projectPath, { - ...globalProjectConfig, - displayName: packageData.name, - }); + return await getProjectConfig( + projectPath, + { + ...globalProjectConfig, + displayName: packageData.name, + }, + extraRootOptions, + ); } return undefined; diff --git a/packages/cli/config/jestRejectNetworkRequests.js b/packages/cli/config/jestRejectNetworkRequests.js new file mode 100644 index 0000000000..de6b486809 --- /dev/null +++ b/packages/cli/config/jestRejectNetworkRequests.js @@ -0,0 +1,59 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const http = require('http'); +const https = require('https'); + +const errorMessage = 'Network requests are not allowed in tests'; + +const origHttpAgent = http.globalAgent; +const origHttpsAgent = https.globalAgent; +const origFetch = global.fetch; +const origXMLHttpRequest = global.fetch; + +http.globalAgent = new http.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +https.globalAgent = new https.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +if (global.fetch) { + global.fetch = async () => { + throw new Error(errorMessage); + }; +} + +if (global.XMLHttpRequest) { + global.XMLHttpRequest = class { + constructor() { + throw new Error(errorMessage); + } + }; +} + +// Reset overrides after each suite to make sure we don't pollute the test environment +afterAll(() => { + http.globalAgent = origHttpAgent; + https.globalAgent = origHttpsAgent; + global.fetch = origFetch; + global.XMLHttpRequest = origXMLHttpRequest; +}); diff --git a/yarn.lock b/yarn.lock index 448a5bffef..82d6c50ef6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21628,7 +21628,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.7.7, axios@npm:^1.0.0, axios@npm:^1.4.0, axios@npm:^1.6.0, axios@npm:^1.7.4": +"axios@npm:1.7.7, axios@npm:^1.0.0, axios@npm:^1.4.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7": version: 1.7.7 resolution: "axios@npm:1.7.7" dependencies: @@ -26975,8 +26975,10 @@ __metadata: "@types/react-dom": "*" "@types/zen-observable": ^0.8.0 "@vitejs/plugin-react": ^4.3.1 + axios: ^1.7.7 cross-env: ^7.0.0 history: ^5.0.0 + msw: ^1.0.0 react: ^18.0.2 react-dom: ^18.0.2 react-router: ^6.3.0