feat(route): chain routes (#14771)

This commit is contained in:
Pavel Feldman 2022-06-10 08:06:39 -08:00 committed by GitHub
parent a98394033c
commit 7a568a2952
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 240 additions and 50 deletions

View File

@ -143,30 +143,32 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
response._finishedPromise.resolve();
}
_onRoute(route: network.Route, request: network.Request) {
for (const routeHandler of this._routes) {
if (!routeHandler.matches(request.url()))
continue;
// Immediately deactivate based on |times|.
async _onRoute(route: network.Route, request: network.Request) {
const routes = this._routes.filter(r => r.matches(request.url()));
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await route._finalContinue();
return;
}
if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1);
(async () => {
try {
// Let async callback work prior to disabling interception.
await routeHandler.handle(route, request);
} finally {
if (!this._routes.length)
await this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
}
})();
await new Promise<void>(f => {
routeHandler.handle(route, request, async done => {
if (!done)
await nextRoute();
f();
});
});
};
// There is no chaining, first handler wins.
return;
}
await nextRoute();
// it can race with BrowserContext.close() which then throws since its closed
route._internalContinue();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
}
async _onBinding(bindingCall: BindingCall) {

View File

@ -216,7 +216,17 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
}
}
type OverridesForContinue = {
url?: string;
method?: string;
headers?: Headers;
postData?: string | Buffer;
};
export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route {
private _pendingContinueOverrides: OverridesForContinue | undefined;
private _routeChain: ((done: boolean) => Promise<void>) | null = null;
static from(route: channels.RouteChannel): Route {
return (route as any)._object;
}
@ -240,11 +250,21 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
]);
}
_startHandling(routeChain: (done: boolean) => Promise<void>) {
this._routeChain = routeChain;
}
async abort(errorCode?: string) {
await this._raceWithPageClose(this._channel.abort({ errorCode }));
await this._followChain(true);
}
async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: string } = {}) {
await this._innerFulfill(options);
await this._followChain(true);
}
private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: string } = {}) {
let fetchResponseUid;
let { status: statusOption, headers: headersOption, body } = options;
@ -314,15 +334,21 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
}));
}
async continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer } = {}) {
await this._continue(options);
async continue(options: OverridesForContinue = {}) {
if (!this._routeChain)
throw new Error('Route is already handled!');
this._pendingContinueOverrides = { ...this._pendingContinueOverrides, ...options };
await this._followChain(false);
}
async _internalContinue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer } = {}) {
await this._continue(options, true).catch(() => {});
async _followChain(done: boolean) {
const chain = this._routeChain!;
this._routeChain = null;
await chain(done);
}
private async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, isInternal?: boolean) {
async _finalContinue() {
const options = this._pendingContinueOverrides || {};
return await this._wrapApiCall(async () => {
const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData;
await this._raceWithPageClose(this._channel.continue({
@ -331,11 +357,11 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
headers: options.headers ? headersObjectToArray(options.headers) : undefined,
postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined,
}));
}, isInternal);
}, !this._pendingContinueOverrides);
}
}
export type RouteHandlerCallback = (route: Route, request: Request) => void | Promise<void>;
export type RouteHandlerCallback = (route: Route, request: Request) => void;
export type ResourceTiming = {
startTime: number;
@ -548,9 +574,10 @@ export class RouteHandler {
return urlMatches(this._baseURL, requestURL, this.url);
}
public handle(route: Route, request: Request): Promise<void> | void {
public handle(route: Route, request: Request, routeChain: (done: boolean) => Promise<void>) {
++this.handledCount;
return this.handler(route, request);
route._startHandling(routeChain);
this.handler(route, request);
}
public willExpire(): boolean {

View File

@ -179,28 +179,31 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this.emit(Events.Page.FrameDetached, frame);
}
private _onRoute(route: Route, request: Request) {
for (const routeHandler of this._routes) {
if (!routeHandler.matches(request.url()))
continue;
// Immediately deactivate based on |times|.
private async _onRoute(route: Route, request: Request) {
const routes = this._routes.filter(r => r.matches(request.url()));
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await this._browserContext._onRoute(route, request);
return;
}
if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1);
(async () => {
try {
// Let async callback work prior to disabling interception.
await routeHandler.handle(route, request);
} finally {
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
}
})();
await new Promise<void>(f => {
routeHandler.handle(route, request, async done => {
if (!done)
await nextRoute();
f();
});
});
};
// There is no chaining, first handler wins.
return;
}
this._browserContext._onRoute(route, request);
await nextRoute();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
}
async _onBinding(bindingCall: BindingCall) {

View File

@ -63,12 +63,12 @@ it('should unroute', async ({ browser, server }) => {
};
await context.route('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([4]);
expect(intercepted).toEqual([4, 3, 2, 1]);
intercepted = [];
await context.unroute('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3]);
expect(intercepted).toEqual([3, 2, 1]);
intercepted = [];
await context.unroute('**/empty.html');
@ -249,3 +249,84 @@ it('should overwrite post body with empty string', async ({ context, server, pag
const body = (await req.postBody).toString();
expect(body).toBe('');
});
it('should chain continue', async ({ context, page, server }) => {
const intercepted = [];
await context.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
});
await context.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
});
await context.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
});
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3, 2, 1]);
});
it('should not chain fulfill', async ({ context, page, server }) => {
let failed = false;
await context.route('**/empty.html', route => {
failed = true;
});
await context.route('**/empty.html', route => {
route.fulfill({ status: 200, body: 'fulfilled' });
});
await context.route('**/empty.html', route => {
route.continue();
});
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();
expect(body.toString()).toEqual('fulfilled');
expect(failed).toBeFalsy();
});
it('should not chain abort', async ({ context, page, server }) => {
let failed = false;
await context.route('**/empty.html', route => {
failed = true;
});
await context.route('**/empty.html', route => {
route.abort();
});
await context.route('**/empty.html', route => {
route.continue();
});
const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy();
expect(failed).toBeFalsy();
});
it('should chain continue into page', async ({ context, page, server }) => {
const intercepted = [];
await context.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
});
await context.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
});
await context.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
});
await page.route('**/empty.html', route => {
intercepted.push(4);
route.continue();
});
await page.route('**/empty.html', route => {
intercepted.push(5);
route.continue();
});
await page.route('**/empty.html', route => {
intercepted.push(6);
route.continue();
});
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([6, 5, 4, 3, 2, 1]);
});

View File

@ -59,12 +59,12 @@ it('should unroute', async ({ page, server }) => {
};
await page.route('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([4]);
expect(intercepted).toEqual([4, 3, 2, 1]);
intercepted = [];
await page.unroute('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3]);
expect(intercepted).toEqual([3, 2, 1]);
intercepted = [];
await page.unroute('**/empty.html');
@ -837,3 +837,80 @@ for (const method of ['fulfill', 'continue', 'abort'] as const) {
expect(e.message).toContain('Route is already handled!');
});
}
it('should chain continue', async ({ page, server }) => {
const intercepted = [];
await page.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
});
await page.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
});
await page.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
});
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3, 2, 1]);
});
it('should not chain fulfill', async ({ page, server }) => {
let failed = false;
await page.route('**/empty.html', route => {
failed = true;
});
await page.route('**/empty.html', route => {
route.fulfill({ status: 200, body: 'fulfilled' });
});
await page.route('**/empty.html', route => {
route.continue();
});
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();
expect(body.toString()).toEqual('fulfilled');
expect(failed).toBeFalsy();
});
it('should not chain abort', async ({ page, server }) => {
let failed = false;
await page.route('**/empty.html', route => {
failed = true;
});
await page.route('**/empty.html', route => {
route.abort();
});
await page.route('**/empty.html', route => {
route.continue();
});
const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy();
expect(failed).toBeFalsy();
});
it('should continue after exception', async ({ page, server }) => {
await page.route('**/empty.html', route => {
route.continue();
});
await page.route('**/empty.html', async route => {
try {
await route.fulfill({ har: 'file', response: {} as any });
} catch (e) {
route.continue();
}
});
await page.goto(server.EMPTY_PAGE);
});
it('should chain once', async ({ page, server }) => {
await page.route('**/empty.html', route => {
route.fulfill({ status: 200, body: 'fulfilled one' });
}, { times: 1 });
await page.route('**/empty.html', route => {
route.continue();
}, { times: 1 });
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();
expect(body.toString()).toEqual('fulfilled one');
});