cli: add option to reject network requets in frontend tests

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-10-18 23:40:52 +02:00
parent 6cbe4f999c
commit a7f97e40b3
7 changed files with 238 additions and 26 deletions
+15
View File
@@ -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.
+3
View File
@@ -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"
+3 -1
View File
@@ -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
}
@@ -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 });
});
});
+46 -24
View File
@@ -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;
@@ -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;
});
+3 -1
View File
@@ -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