Files
Sergei Zharinov ac2fc77641 refactor(http-mock): Remove request tracing (#44482)
* refactor(http-mock): remove request tracing

* test: replace remaining HTTP trace assertions
2026-09-23 09:01:01 +00:00

260 lines
5.8 KiB
TypeScript

import fs from 'node:fs';
import type { Url } from 'node:url';
import is from '@sindresorhus/is';
import { codeBlock } from 'common-tags';
// oxlint-disable-next-line no-restricted-imports
import nock from 'nock';
// oxlint-disable-next-line no-restricted-imports
export type { Body, ReplyHeaders, Scope } from 'nock';
interface RequestLog {
method: string;
url: string;
status: number;
}
interface MissingRequestLog {
method: string;
url: string;
}
type BasePath = string | RegExp | Url;
let requestsDone: RequestLog[] = [];
let requestsMissing: MissingRequestLog[] = [];
interface 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, pending));
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 { method } = req;
const url = req.options?.href;
const status = req.response?.statusCode;
requestsDone.push({ method, url, status });
});
}
function massageHttpMockStacktrace(err: Error): void {
if (!err.stack) {
return;
}
const state = expect.getState();
if (!state.currentTestName || !state.testPath) {
return;
}
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.at(-1)!.length + 1;
const stackLine = ` at <test> (${state.testPath}:${lineNum}:${linePos})`;
err.stack = err.stack.replace(/\+\+\+.*$/s, stackLine);
}
function missingHttpMockMessage(
done: RequestLog[],
missing: MissingRequestLog[],
pending: string[],
): 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')}
`);
}
if (pending.length) {
blocks.push(codeBlock`
Pending mocks:
${pending.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();
});
export function error(props: string | Record<string, unknown> = {}): Error {
if (is.string(props)) {
return new Error(props);
}
return Object.assign(new Error(), props);
}