190 lines
6.6 KiB
TypeScript
Raw Normal View History

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type * as playwright from 'playwright/test';
interface RequestHandler {
run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>;
}
type RouteArgs = Parameters<playwright.BrowserContext['route']>;
let lastRequestId = 0;
let fetchOverrideCounter = 0;
const currentlyInterceptingInContexts = new Map<playwright.BrowserContext, number>();
const originalFetch = globalThis.fetch;
async function executeRequestHandlers(request: Request, handlers: RequestHandler[], baseUrl: string | undefined): Promise<Response | undefined> {
const requestId = String(++lastRequestId);
const resolutionContext = { baseUrl };
for (const handler of handlers) {
const result = await handler.run({ request, requestId, resolutionContext });
if (result?.response)
return result.response;
}
}
function isMswRequestPassthrough(headers: Headers): boolean {
if (headers.get('x-msw-intention') === 'bypass')
return true;
// After MSW v2.6.4
// https://github.com/mswjs/msw/commit/2fa98c327acc51189f87789d9155c4ec57be2299
if (headers.get('accept')?.includes('msw/passthrough'))
return true;
return false;
}
async function globalFetch(...args: Parameters<typeof globalThis.fetch>) {
if (args[0] && args[0] instanceof Request) {
const request = args[0];
if (isMswRequestPassthrough(request.headers)) {
const cookieHeaders = await Promise.all([...currentlyInterceptingInContexts.keys()].map(async context => {
const cookies = await context.cookies(request.url);
if (!cookies.length)
return undefined;
return cookies.map(c => `${c.name}=${c.value}`).join('; ');
}));
if (!cookieHeaders.length)
throw new Error(`Cannot call fetch(bypass()) outside of a request handler`);
if (cookieHeaders.some(h => h !== cookieHeaders[0]))
throw new Error(`Cannot call fetch(bypass()) while concurrently handling multiple requests from different browser contexts`);
const headers = new Headers(request.headers);
headers.set('cookie', cookieHeaders[0]!);
{
// pre 2.6.4
headers.delete('x-msw-intention');
// post 2.6.4
const accept = headers.get('accept')?.split(',').filter(h => !h.includes('msw/')).join(',');
if (accept)
headers.set('accept', accept);
else
headers.delete('accept');
}
args[0] = new Request(request.clone(), { headers });
}
}
return originalFetch(...args);
}
export class Router {
private _context: playwright.BrowserContext;
private _requestHandlers: RequestHandler[] = [];
private _requestHandlersRoute: (route: playwright.Route) => Promise<void>;
private _requestHandlersActive = false;
private _routes: RouteArgs[] = [];
constructor(context: playwright.BrowserContext, baseURL: string | undefined) {
this._context = context;
this._requestHandlersRoute = async route => {
if (route.request().isNavigationRequest()) {
await route.fallback();
return;
}
const request = route.request();
const headersArray = await request.headersArray();
const headers = new Headers();
for (const { name, value } of headersArray)
headers.append(name, value);
const buffer = request.postDataBuffer();
const body = buffer?.byteLength ? new Int8Array(buffer.buffer, buffer.byteOffset, buffer.length) : undefined;
const newRequest = new Request(request.url(), {
body: body,
headers: headers,
method: request.method(),
referrer: headersArray.find(h => h.name.toLowerCase() === 'referer')?.value,
});
currentlyInterceptingInContexts.set(context, 1 + (currentlyInterceptingInContexts.get(context) || 0));
const response = await executeRequestHandlers(newRequest, this._requestHandlers, baseURL).finally(() => {
const value = currentlyInterceptingInContexts.get(context)! - 1;
if (value)
currentlyInterceptingInContexts.set(context, value);
else
currentlyInterceptingInContexts.delete(context);
});
if (!response) {
await route.fallback();
return;
}
if (response.status === 302 && response.headers.get('x-msw-intention') === 'passthrough') {
await route.continue();
return;
}
if (response.type === 'error') {
await route.abort();
return;
}
const responseHeaders: Record<string, string> = {};
for (const [name, value] of response.headers.entries()) {
if (responseHeaders[name])
responseHeaders[name] = responseHeaders[name] + (name.toLowerCase() === 'set-cookie' ? '\n' : ', ') + value;
else
responseHeaders[name] = value;
}
await route.fulfill({
status: response.status,
body: Buffer.from(await response.arrayBuffer()),
headers: responseHeaders,
});
};
}
async route(...routeArgs: RouteArgs) {
this._routes.push(routeArgs);
return await this._context.route(...routeArgs);
}
async use(...handlers: RequestHandler[]) {
this._requestHandlers = handlers.concat(this._requestHandlers);
await this._updateRequestHandlersRoute();
}
async dispose() {
this._requestHandlers = [];
await this._updateRequestHandlersRoute();
for (const route of this._routes)
await this._context.unroute(route[0], route[1]);
}
private async _updateRequestHandlersRoute() {
if (this._requestHandlers.length && !this._requestHandlersActive) {
await this._context.route('**/*', this._requestHandlersRoute);
if (!fetchOverrideCounter)
globalThis.fetch = globalFetch;
++fetchOverrideCounter;
this._requestHandlersActive = true;
}
if (!this._requestHandlers.length && this._requestHandlersActive) {
await this._context.unroute('**/*', this._requestHandlersRoute);
this._requestHandlersActive = false;
--fetchOverrideCounter;
if (!fetchOverrideCounter)
globalThis.fetch = originalFetch;
}
}
}