renovate/test/http-mock.ts

267 lines
6.2 KiB
TypeScript

import type { Url } from 'node:url';
import { afterAll, afterEach, beforeAll } from '@jest/globals';
import { codeBlock } from 'common-tags';
// eslint-disable-next-line no-restricted-imports
import nock from 'nock';
import { makeGraphqlSnapshot } from './graphql-snapshot';
// eslint-disable-next-line no-restricted-imports
export type { Scope, ReplyHeaders, Body } from 'nock';
interface RequestLog {
headers: Record<string, string>;
method: string;
url: string;
status: number;
body?: any;
graphql?: any;
}
interface MissingRequestLog {
method: string;
url: string;
}
type BasePath = string | RegExp | Url;
let requestsDone: RequestLog[] = [];
let requestsMissing: MissingRequestLog[] = [];
type TestRequest = {
method: string;
href: string;
};
function onMissing(req: TestRequest, opts?: TestRequest): void {
if (opts) {
requestsMissing.push({ method: opts.method, url: opts.href });
} else {
requestsMissing.push({ method: req.method, url: req.href });
}
}
export function allUsed(): boolean {
return nock.isDone();
}
function getPending(): string[] {
return nock.pendingMocks().map((req) => `- ${req.replace(':443/', '/')}`);
}
/**
* Clear nock state. Will be called in `afterEach`
*
* @argument check Use `false` to clear mocks without checking for the missing/unused ones.
* Disabling such checks is discouraged.
*/
export function clear(check = true): void {
const isDone = nock.isDone();
const pending = getPending();
nock.abortPendingRequests();
nock.cleanAll();
const done = requestsDone;
requestsDone = [];
const missing = requestsMissing;
requestsMissing = [];
if (!check) {
return;
}
if (missing.length) {
const err = new Error(missingHttpMockMessage(done, missing));
massageHttpMockStacktrace(err);
throw err;
}
if (!isDone) {
const err = new Error(unusedHttpMockMessage(done, pending));
massageHttpMockStacktrace(err);
throw err;
}
}
export function scope(basePath: BasePath, options?: nock.Options): nock.Scope {
return nock(basePath, options).on('replied', (req) => {
const { headers, method } = req;
const url = req.options?.href;
const status = req.response?.statusCode;
const result: RequestLog = { headers, method, url, status };
const requestBody = req.requestBodyBuffers?.[0]?.toString();
if (requestBody && headers['content-type'] === 'application/json') {
try {
const body = JSON.parse(requestBody);
const graphql = makeGraphqlSnapshot(body);
if (graphql) {
result.graphql = graphql;
} else {
result.body = body;
}
} catch {
result.body = requestBody;
}
}
requestsDone.push(result);
});
}
export function getTrace(): RequestLog[] {
return requestsDone;
}
function massageHttpMockStacktrace(err: Error): void {
if (!err.stack) {
return;
}
const state = expect.getState();
if (!state.currentTestName || !state.testPath) {
return;
}
const fs: typeof import('fs-extra') = jest.requireActual('fs-extra');
const content = fs.readFileSync(state.testPath, { encoding: 'utf8' });
// Shrink the `testName` until we could locate it in the source file
let testName = state.currentTestName.replace(/^[^\s]*\s/, '');
let idx = content.indexOf(testName);
while (testName.length) {
if (idx !== -1) {
break;
}
const prevName = testName;
testName = testName.replace(/^[^\s]*\s/, '');
if (prevName === testName) {
break;
}
idx = content.indexOf(testName);
}
if (idx === -1) {
return;
}
const lines = content.slice(0, idx).split('\n');
const lineNum = lines.length;
const linePos = lines[lines.length - 1].length + 1;
const stackLine = ` at <test> (${state.testPath}:${lineNum}:${linePos})`;
err.stack = err.stack.replace(/\+\+\+.*$/s, stackLine);
}
function missingHttpMockMessage(
done: RequestLog[],
missing: MissingRequestLog[],
): string {
const blocks: string[] = [];
const title = codeBlock`
*** Missing HTTP mocks ***
`;
const explanation = codeBlock`
---
Renovate testing strategy requires that every HTTP request
has a corresponding mock.
This error occurs when some of the request aren't mocked.
Let's suppose your code performs two HTTP calls:
GET https://example.com/foo/bar/fail 404 <without body>
POST https://example.com/foo/bar/success 200 { "ok": true }
The unit test should have this mock:
httpMock.scope('https://example.com/foo/bar')
.get('/fail')
.reply(404)
.post('/success')
.reply(200, { ok: true });
Note: \`httpMock.scope(...)\` is the Renovate-specific construct.
The scope object itself is provided by the \`nock\` library.
Details: https://github.com/nock/nock#usage
+++
`;
blocks.push(title);
blocks.push(codeBlock`
${missing.map(({ method, url }) => `- ${method} ${url}`).join('\n')}
`);
if (done.length) {
blocks.push(codeBlock`
Requests done:
${done.map(({ method, url, status }) => `- ${method} ${url} [${status}]`).join('\n')}
`);
}
blocks.push(explanation);
return blocks.join('\n\n');
}
function unusedHttpMockMessage(done: RequestLog[], pending: string[]): string {
const blocks: string[] = [];
const title = codeBlock`
*** Unused HTTP mocks ***
`;
const explanation = codeBlock`
---
Renovate testing strategy requires that every HTTP request
has a corresponding mock.
This error occurs because some of the created mocks are unused.
In most cases, you simply need to remove them.
+++
`;
blocks.push(title);
blocks.push(pending.join('\n'));
if (done.length) {
blocks.push(codeBlock`
Requests done:
${done.map(({ method, url, status }) => `- ${method} ${url} [${status}]`).join('\n')}
`);
}
blocks.push(explanation);
return blocks.join('\n\n');
}
// init nock
beforeAll(() => {
nock.emitter.on('no match', onMissing);
nock.disableNetConnect();
});
// clean nock to clear memory leack from http module patching
afterAll(() => {
nock.emitter.removeListener('no match', onMissing);
nock.restore();
});
// clear nock state
afterEach(() => {
clear();
});