diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 8ba716ebae..ea02b4cb15 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -143,30 +143,32 @@ export class BrowserContext extends ChannelOwner 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(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) { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 8c9ebc4b3b..54eb35c0b5 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -216,7 +216,17 @@ export class Request extends ChannelOwner implements ap } } +type OverridesForContinue = { + url?: string; + method?: string; + headers?: Headers; + postData?: string | Buffer; +}; + export class Route extends ChannelOwner implements api.Route { + private _pendingContinueOverrides: OverridesForContinue | undefined; + private _routeChain: ((done: boolean) => Promise) | null = null; + static from(route: channels.RouteChannel): Route { return (route as any)._object; } @@ -240,11 +250,21 @@ export class Route extends ChannelOwner implements api.Ro ]); } + _startHandling(routeChain: (done: boolean) => Promise) { + 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 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 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; +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 { + public handle(route: Route, request: Request, routeChain: (done: boolean) => Promise) { ++this.handledCount; - return this.handler(route, request); + route._startHandling(routeChain); + this.handler(route, request); } public willExpire(): boolean { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index ca841c2e83..5dfe511f59 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -179,28 +179,31 @@ export class Page extends ChannelOwner 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(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) { diff --git a/tests/library/browsercontext-route.spec.ts b/tests/library/browsercontext-route.spec.ts index f851f9f48c..e53694d973 100644 --- a/tests/library/browsercontext-route.spec.ts +++ b/tests/library/browsercontext-route.spec.ts @@ -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]); +}); diff --git a/tests/page/page-route.spec.ts b/tests/page/page-route.spec.ts index 8924e3b0c8..99d726ee4a 100644 --- a/tests/page/page-route.spec.ts +++ b/tests/page/page-route.spec.ts @@ -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'); +});