Last active
July 29, 2024 15:32
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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