Skip to content

Instantly share code, notes, and snippets.

@ndemengel
Last active July 29, 2024 15:32
Show Gist options
  • Save ndemengel/92e885aaa9702a832c9c999c0d96ffac to your computer and use it in GitHub Desktop.
Save ndemengel/92e885aaa9702a832c9c999c0d96ffac to your computer and use it in GitHub Desktop.
Nuxt UI Tests with Playwright @malt: the mock server (as a Playwright fixture)
// noinspection JSVoidFunctionReturnValueUsed
import * as http from 'http';
import {IncomingMessage, ServerResponse} from 'http';
import isEqual from 'lodash';
import type {TestFixture} from 'playwright/test';
import {test as base} from 'playwright/test';
import type {Expectation, ExpectationMatch, MatchingExpectation, MockApiRequestFixturesOptions, MockApiRequestFn} from '../types';
const {isEqual: isDeepEqual} = isEqual;
function keepPartsToCompare(expectation: Expectation) {
return {
method: expectation.method,
url: expectation.url,
path: expectation.path,
params: expectation.params,
};
}
function expectationsMatch(e1: Expectation, e2: Expectation) {
return isDeepEqual(keepPartsToCompare(e1), keepPartsToCompare(e2));
}
function computeMatch(expectation: Expectation, request: IncomingMessage, requestUrl: URL): ExpectationMatch | null {
let exactPathMatch = true;
let score = 0;
if (expectation.method && expectation.method !== 'ANY') {
if (expectation.method === request.method) {
score++;
} else {
return null;
}
}
// else: expectation.method is considered to be 'ANY' and it matches but doesn't increase the score
if (expectation.url) {
if (expectation.url === request.url) {
score++;
} else {
return null;
}
} else if (expectation.path) {
if (expectation.path === requestUrl.pathname) {
score++;
} else {
return null;
}
} else if (expectation.pathPrefix) {
if (requestUrl.pathname.startsWith(expectation.pathPrefix)) {
score++;
exactPathMatch = false;
} else {
return null;
}
}
const expectedParams = Object.entries(expectation.params || {});
if (expectedParams.every(([k, v]) => requestUrl.searchParams.getAll(k).includes(v))) {
score += expectedParams.length;
} else {
return null;
}
return {exactPathMatch, score};
}
function findBestMatchingExpectation(expectations: Expectation[], request: IncomingMessage, requestUrl: URL) {
const matches = expectations
.map((expectation) => {
return {
expectation,
match: computeMatch(expectation, request, requestUrl),
};
})
.filter(({match}) => match !== null) as MatchingExpectation[];
const exactPathMatch = matches.filter(({match}) => match.exactPathMatch).sort((e1, e2) => e2.match.score - e1.match.score)[0];
const partialPathMatch = matches.filter(({match}) => !match.exactPathMatch).sort((e1, e2) => e2.match.score - e1.match.score)[0];
return (exactPathMatch && exactPathMatch.expectation) || (partialPathMatch && partialPathMatch.expectation);
}
function withNewExpectation(expectations: Expectation[], expectation: Expectation) {
if (process.env.DEBUG || process.env.MALT_DEBUG) {
console.debug('Registering expectation', expectation);
}
const urlMatchers = [expectation.path, expectation.pathPrefix, expectation.url].filter((p) => !!p);
if (urlMatchers.length === 0) {
throw new Error('Either `path`, `pathPrefix`, or `url` must be provided to define an expectation: ' + JSON.stringify(expectation));
}
if (urlMatchers.length > 1) {
throw new Error('Only 1 of `path`, `pathPrefix`, and `url` must be provided when defining an expectation: ' + JSON.stringify(expectation));
}
if (expectation.url && expectation.params) {
throw new Error(
'Invalid combination: `params` cannot be used together with `url` since query params are already present in an URL: ' +
JSON.stringify(expectation),
);
}
if (expectation.method) {
expectation.method = expectation.method.toUpperCase();
}
const newExpectations = expectations.filter((e) => !expectationsMatch(e, expectation));
newExpectations.push(expectation);
return newExpectations;
}
const mockApiRequestFixtureDef: TestFixture<MockApiRequestFn, MockApiRequestFixturesOptions> = async (
{portOfMockApiServer, mockBeforeEachTest, backendUrl},
use,
) => {
if (!portOfMockApiServer) {
throw new Error('Missing `portOfMockApiServer` option');
}
// setup
let expectations: Expectation[] = [];
function onRequest(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url!, `http://${req.headers.host}`);
if (process.env.DEBUG || process.env.MALT_DEBUG) {
console.log(`onRequest ${url}`, url);
}
const expectation = findBestMatchingExpectation(expectations, req, url);
if (process.env.DEBUG || process.env.MALT_DEBUG) {
// eslint-disable-next-line no-console
console.debug(`${url} (${req.method} ${url.pathname} ${url.searchParams.toString()}) ->
Matching expectation:
${expectation && JSON.stringify(expectation, null, 2)}
All expectations:
${JSON.stringify(expectations, null, 2)}
`);
} else if (!expectation) {
// eslint-disable-next-line no-console
console.warn(`No match found for: ${url} (${req.method} ${url.pathname} ${url.searchParams.toString()})`);
}
if (expectation) {
const {status, body, successCallback} = expectation;
const requestContentType = req.headers['content-type'] || 'application/json';
const responseHeaders: Record<string, string> = {};
const responseHeaderContentType =
!requestContentType || requestContentType.startsWith('multipart/form-data') ? 'application/json' : requestContentType;
responseHeaders['content-type'] = 'application/json';
res.writeHead(status || 200, responseHeaders);
const responseBody = typeof body === 'function' ? body(req, res) : body;
if (responseHeaderContentType === 'text/plain') {
res.end(responseBody && responseBody.toString());
} else if (responseHeaderContentType && responseHeaderContentType.startsWith('application/json')) {
res.end(responseBody && JSON.stringify(responseBody));
} else {
res.end(responseBody);
}
if (successCallback) {
successCallback();
}
} else {
res.writeHead(404);
res.end();
}
}
const server = http.createServer();
server.on('request', onRequest);
server.on('error', (error) => {
console.error(error);
});
server.listen(portOfMockApiServer);
if (process.env.DEBUG || process.env.MALT_DEBUG) {
console.debug(`Mock backend listening on ${portOfMockApiServer}`);
}
function mockApiRequest(expectation: Expectation) {
expectations = withNewExpectation(expectations, expectation);
}
for (const expectation of mockBeforeEachTest) {
mockApiRequest(expectation);
}
// use in test.
await use(mockApiRequest);
// teardown
server.close();
};
interface MockApiRequestFixtures {
mockApiRequest: MockApiRequestFn;
}
export const test = base.extend<MockApiRequestFixturesOptions & MockApiRequestFixtures>({
portOfMockApiServer: [undefined, {option: true}],
mockBeforeEachTest: [[], {option: true}],
backendUrl: ['', {option: true}],
mockApiRequest: [mockApiRequestFixtureDef, {auto: true}],
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment