taiko
Version:
Taiko is a Node.js library for automating Chromium based browsers
306 lines (285 loc) • 8.96 kB
JavaScript
const { isFunction, isString, isObject } = require("../helper");
const { eventHandler } = require("../eventBus");
const headersMap = new Map();
const defaultErrorReason = "Failed";
let fetch;
let userEnabledIntercept = false;
let interceptors = [];
const createdSessionListener = async (client) => {
let resolve;
eventHandler.emit(
"handlerActingOnNewSession",
new Promise((r) => {
resolve = r;
}),
);
fetch = client.Fetch;
if (userEnabledIntercept) {
await enableFetchIntercept();
}
resolve();
};
eventHandler.on("createdSession", createdSessionListener);
const enableFetchIntercept = async () => {
await fetch.enable({
patterns: [{ urlPattern: "*" }],
});
await fetch.requestPaused(handleInterceptor);
};
const extractHostName = (url) => {
let { protocol, href, host } = new URL(url);
if (protocol === "file:") {
host = href;
}
return host;
};
const addExtraHeadersToRequest = (p) => {
const options = { requestId: p.requestId };
const host = extractHostName(p.request.url);
const headers = headersMap.get(host);
for (const headerName in headers) {
if (!(headerName in p.request.headers)) {
options.headers = options.headers || {};
options.headers[headerName] = headers[headerName];
}
}
if (options.headers) {
options.headers = headerArray(options.headers);
}
return options;
};
const filterInterceptorsAndWarnIfNeeded = (requestUrl) => {
const matches = interceptors.filter((interceptor) =>
getMatchingInterceptor(interceptor, requestUrl),
);
const matchesLen = matches.length;
if (matchesLen > 1) {
const matchesURL = matches.map((e) => `"${e.requestUrl}"`).join(",");
console.warn(
`WARNING: More than one intercept [${matchesURL}] found for request "${requestUrl}".\n Applying: intercept("${
matches[matchesLen - 1].requestUrl
}", "${matches[matchesLen - 1].action}")`,
);
}
return matches.pop();
};
const warnInterceptFailed = (p) => {
console.warn(`WARNING: Could not intercept request ${p.request.url}`);
};
const handleInterceptor = (p) => {
let options = addExtraHeadersToRequest(p);
const interceptor = filterInterceptorsAndWarnIfNeeded(p.request.url);
if (!interceptor) {
fetch.continueRequest(options).catch(() => {});
return;
}
interceptor.count = interceptor.count - 1;
switch (true) {
//Blocks matching url
case !interceptor.action:
options.errorReason = defaultErrorReason;
fetch.failRequest(options);
break;
//Mocks response and/or request based on callback
case isFunction(interceptor.action):
p.continue = (override) => overrideRequest(p, override, options);
p.respond = (mock) => {
options = mockResponse(mock, options);
fetch.fulfillRequest(options).catch(() => warnInterceptFailed(p));
};
interceptor.action(p);
break;
//Redirects to given url
case isString(interceptor.action):
if (
!/^https?:\/\//i.test(interceptor.action) &&
!/^file/i.test(interceptor.action)
) {
interceptor.action = `http://${interceptor.action}`;
}
options.url = interceptor.action;
fetch.continueRequest(options).catch(() => warnInterceptFailed(p));
break;
//Mocks response with given object
case isObject(interceptor.action):
options = mockResponse(interceptor.action, options);
fetch
.fulfillRequest(options)
.then(() => {
// On Windows, Chrome sometimes skips Network.responseReceived after
// Fetch.fulfillRequest for Document navigations, causing handleNavigation
// to wait forever for responsePromise. Emit it explicitly as a fallback.
if (p.resourceType === "Document") {
// On Windows, Chrome sometimes skips Network.responseReceived and
// frameStoppedLoading after Fetch.fulfillRequest, hanging navigation.
// Emit dedicated events so handleNavigation and waitForNavigation
// can unblock without relying on networkId (which may be undefined).
eventHandler.emit("interceptedNavigationResponse", {
url: p.request.url,
status: options.responseCode,
statusText: options.responsePhrase || "",
});
eventHandler.emit("navigationFulfilledByIntercept", {
frameId: p.frameId,
});
}
})
.catch(() => warnInterceptFailed(p));
break;
//Continue default request if none of the above matches
default:
fetch.continueRequest(options).catch(() => {});
}
};
const mockResponse = (response, options) => {
const responseBodyJson = isObject(response.body)
? JSON.stringify(response.body)
: response.body;
const responseBody = Buffer.from(responseBodyJson || "");
const responseHeaders = {};
if (response.headers) {
for (const header of Object.keys(response.headers)) {
responseHeaders[header.toLowerCase()] = response.headers[header];
}
}
if (response.contentType) {
responseHeaders["content-type"] = response.contentType;
}
if (responseBody && !("content-length" in responseHeaders)) {
responseHeaders["content-length"] = Buffer.byteLength(responseBody);
}
options.body = responseBody.toString("base64");
options.responseCode = response.status || 200;
options.responsePhrase = statusTexts[response.status || 200];
options.responseHeaders = headerArray(responseHeaders);
return options;
};
const headerArray = (headers) => {
const result = [];
for (const name in headers) {
if (!Object.is(headers[name], undefined)) {
result.push({ name, value: `${headers[name]}` });
}
}
return result;
};
const overrideRequest = (p, override, options) => {
if (override) {
override.postData = override.postData
? Buffer.from(override.postData).toString("base64")
: undefined;
for (const key in override) {
options[key] = override[key];
}
}
fetch.continueRequest(options).catch(() => warnInterceptFailed(p));
};
const statusTexts = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
209: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "Switch Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a teapot",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required",
};
const getMatchingInterceptor = (interceptor, url) => {
if (interceptor.count === 0) {
return false;
}
if (url === interceptor.requestUrl) {
return true;
}
const re = new RegExp(interceptor.requestUrl);
const matches = url.match(re);
if (matches === null || matches.length <= 0) {
return decodeURI(url).match(re);
}
return matches;
};
const addInterceptor = async (requestWithAction) => {
interceptors.push(requestWithAction);
if (!userEnabledIntercept) {
userEnabledIntercept = true;
await enableFetchIntercept();
}
};
const resetInterceptor = (url) => {
const originalLength = interceptors.length;
interceptors = interceptors.filter(
(interceptor) => !getMatchingInterceptor(interceptor, url),
);
return originalLength !== interceptors.length;
};
const resetInterceptors = () => {
interceptors = [];
userEnabledIntercept = false;
};
const setHTTPHeaders = async (headers, url) => {
const host = extractHostName(url);
headersMap.set(host, headers);
if (!userEnabledIntercept) {
userEnabledIntercept = true;
await enableFetchIntercept();
}
};
module.exports = {
addInterceptor,
resetInterceptor,
resetInterceptors,
handleInterceptor,
setHTTPHeaders,
};