From dcdd3c3cdb217a86f762a3f34b7acae5b5b039a2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 13 Jun 2022 11:30:51 -0800 Subject: [PATCH] feat(route): explicitly fall back to the next handler (#14834) --- docs/src/api/class-route.md | 114 ++++++++++++++++++ .../src/client/browserContext.ts | 32 ++--- .../playwright-core/src/client/network.ts | 52 +++++--- packages/playwright-core/src/client/page.ts | 31 ++--- packages/playwright-core/types/types.d.ts | 29 +++++ tests/library/browsercontext-route.spec.ts | 34 +++--- tests/page/page-route.spec.ts | 32 ++--- 7 files changed, 227 insertions(+), 97 deletions(-) diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index a1d188f328..bebadc7b83 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -114,6 +114,120 @@ If set changes the post data of request If set changes the request HTTP headers. Header values will be converted to a string. +## async method: Route.fallback + +Proceeds to the next registered route in the route chain. If no more routes are +registered, continues the request as is. This allows registering multiple routes +with the same mask and falling back from one to another. + +```js +// Handle GET requests. +await page.route('**/*', route => { + if (route.request().method() !== 'GET') { + route.fallback(); + return; + } + // Handling GET only. + // ... +}); + +// Handle POST requests. +await page.route('**/*', route => { + if (route.request().method() !== 'POST') { + route.fallback(); + return; + } + // Handling POST only. + // ... +}); +``` + +```java +// Handle GET requests. +page.route("**/*", route -> { + if (!route.request().method().equals("GET")) { + route.fallback(); + return; + } + // Handling GET only. + // ... +}); + +// Handle POST requests. +page.route("**/*", route -> { + if (!route.request().method().equals("POST")) { + route.fallback(); + return; + } + // Handling POST only. + // ... +}); +``` + +```python async +# Handle GET requests. +def handle_post(route): + if route.request.method != "GET": + route.fallback() + return + # Handling GET only. + # ... + +# Handle POST requests. +def handle_post(route): + if route.request.method != "POST": + route.fallback() + return + # Handling POST only. + # ... + +await page.route("**/*", handle_get) +await page.route("**/*", handle_post) +``` + +```python sync +# Handle GET requests. +def handle_post(route): + if route.request.method != "GET": + route.fallback() + return + # Handling GET only. + # ... + +# Handle POST requests. +def handle_post(route): + if route.request.method != "POST": + route.fallback() + return + # Handling POST only. + # ... + +page.route("**/*", handle_get) +page.route("**/*", handle_post) +``` + +```csharp +// Handle GET requests. +await page.RouteAsync("**/*", route => { + if (route.Request.Method != "GET") { + await route.FallbackAsync(); + return; + } + // Handling GET only. + // ... +}); + +// Handle POST requests. +await page.RouteAsync("**/*", route => { + if (route.Request.Method != "POST") { + await route.FallbackAsync(); + return; + } + // Handling POST only. + // ... +}); +``` + ## async method: Route.fulfill Fulfills route's request with given response. diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index ea02b4cb15..ab8429aed1 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -144,31 +144,17 @@ export class BrowserContext extends ChannelOwner } 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; - } - + const routeHandlers = this._routes.filter(r => r.matches(request.url())); + for (const routeHandler of routeHandlers) { if (routeHandler.willExpire()) this._routes.splice(this._routes.indexOf(routeHandler), 1); - - await new Promise(f => { - routeHandler.handle(route, request, async done => { - if (!done) - await nextRoute(); - f(); - }); - }); - }; - - await nextRoute(); - - if (!this._routes.length) - this._wrapApiCall(() => this._disableInterception(), true).catch(() => {}); + const handled = await routeHandler.handle(route, request); + if (!this._routes.length) + this._wrapApiCall(() => this._disableInterception(), true).catch(() => {}); + if (handled) + return; + } + await route._innerContinue({}, true); } async _onBinding(bindingCall: BindingCall) { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 7b8014cd84..13247364a9 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -229,8 +229,7 @@ type OverridesForContinue = { }; export class Route extends ChannelOwner implements api.Route { - private _pendingContinueOverrides: OverridesForContinue | undefined; - private _routeChain: ((done: boolean) => Promise) | null = null; + private _handlingPromise: ManualPromise | null = null; static from(route: channels.RouteChannel): Route { return (route as any)._object; @@ -255,22 +254,30 @@ export class Route extends ChannelOwner implements api.Ro ]); } - _startHandling(routeChain: (done: boolean) => Promise) { - this._routeChain = routeChain; + _startHandling(): Promise { + this._handlingPromise = new ManualPromise(); + return this._handlingPromise; + } + + async fallback() { + this._checkNotHandled(); + this._reportHandled(false); } async abort(errorCode?: string) { + this._checkNotHandled(); await this._raceWithPageClose(this._channel.abort({ errorCode })); - await this._followChain(true); + this._reportHandled(true); } async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}) { + this._checkNotHandled(); await this._wrapApiCall(async () => { const fallback = await this._innerFulfill(options); switch (fallback) { case 'abort': await this.abort(); break; case 'continue': await this.continue(); break; - case 'done': await this._followChain(true); break; + case 'done': this._reportHandled(true); break; } }); } @@ -354,20 +361,23 @@ export class Route extends ChannelOwner implements api.Ro } async continue(options: OverridesForContinue = {}) { - if (!this._routeChain) + this._checkNotHandled(); + await this._innerContinue(options); + this._reportHandled(true); + } + + _checkNotHandled() { + if (!this._handlingPromise) throw new Error('Route is already handled!'); - this._pendingContinueOverrides = { ...this._pendingContinueOverrides, ...options }; - await this._followChain(false); } - async _followChain(done: boolean) { - const chain = this._routeChain!; - this._routeChain = null; - await chain(done); + _reportHandled(done: boolean) { + const chain = this._handlingPromise!; + this._handlingPromise = null; + chain.resolve(done); } - async _finalContinue() { - const options = this._pendingContinueOverrides || {}; + async _innerContinue(options: OverridesForContinue = {}, internal = false) { return await this._wrapApiCall(async () => { const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; await this._raceWithPageClose(this._channel.continue({ @@ -376,7 +386,7 @@ export class Route extends ChannelOwner implements api.Ro headers: options.headers ? headersObjectToArray(options.headers) : undefined, postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined, })); - }, !this._pendingContinueOverrides); + }, !!internal); } } @@ -593,12 +603,16 @@ export class RouteHandler { return urlMatches(this._baseURL, requestURL, this.url); } - public handle(route: Route, request: Request, routeChain: (done: boolean) => Promise) { + public async handle(route: Route, request: Request): Promise { ++this.handledCount; - route._startHandling(routeChain); + const handledPromise = route._startHandling(); // Extract handler into a variable to avoid [RouteHandler.handler] in the stack. const handler = this.handler; - handler(route, request); + const [handled] = await Promise.all([ + handledPromise, + handler(route, request), + ]); + return handled; } public willExpire(): boolean { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 5dfe511f59..88dadc58f5 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -180,30 +180,17 @@ export class Page extends ChannelOwner implements api.Page } 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; - } - + const routeHandlers = this._routes.filter(r => r.matches(request.url())); + for (const routeHandler of routeHandlers) { if (routeHandler.willExpire()) this._routes.splice(this._routes.indexOf(routeHandler), 1); - - await new Promise(f => { - routeHandler.handle(route, request, async done => { - if (!done) - await nextRoute(); - f(); - }); - }); - }; - - await nextRoute(); - if (!this._routes.length) - this._wrapApiCall(() => this._disableInterception(), true).catch(() => {}); + const handled = await routeHandler.handle(route, request); + if (!this._routes.length) + this._wrapApiCall(() => this._disableInterception(), true).catch(() => {}); + if (handled) + return; + } + await this._browserContext._onRoute(route, request); } async _onBinding(bindingCall: BindingCall) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 8a5b8a9993..93c4ab91f5 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14833,6 +14833,35 @@ export interface Route { url?: string; }): Promise; + /** + * Proceeds to the next registered route in the route chain. If no more routes are registered, continues the request as is. + * This allows registering multiple routes with the same mask and falling back from one to another. + * + * ```js + * // Handle GET requests. + * await page.route('**\/*', route => { + * if (route.request().method() !== 'GET') { + * route.fallback(); + * return; + * } + * // Handling GET only. + * // ... + * }); + * + * // Handle POST requests. + * await page.route('**\/*', route => { + * if (route.request().method() !== 'POST') { + * route.fallback(); + * return; + * } + * // Handling POST only. + * // ... + * }); + * ``` + * + */ + fallback(): Promise; + /** * Fulfills route's request with given response. * diff --git a/tests/library/browsercontext-route.spec.ts b/tests/library/browsercontext-route.spec.ts index e53694d973..8b7ae35a4e 100644 --- a/tests/library/browsercontext-route.spec.ts +++ b/tests/library/browsercontext-route.spec.ts @@ -47,19 +47,19 @@ it('should unroute', async ({ browser, server }) => { let intercepted = []; await context.route('**/*', route => { intercepted.push(1); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(2); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(3); - route.continue(); + route.fallback(); }); const handler4 = route => { intercepted.push(4); - route.continue(); + route.fallback(); }; await context.route('**/empty.html', handler4); await page.goto(server.EMPTY_PAGE); @@ -250,19 +250,19 @@ it('should overwrite post body with empty string', async ({ context, server, pag expect(body).toBe(''); }); -it('should chain continue', async ({ context, page, server }) => { +it('should chain fallback', async ({ context, page, server }) => { const intercepted = []; await context.route('**/empty.html', route => { intercepted.push(1); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(2); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(3); - route.continue(); + route.fallback(); }); await page.goto(server.EMPTY_PAGE); expect(intercepted).toEqual([3, 2, 1]); @@ -277,7 +277,7 @@ it('should not chain fulfill', async ({ context, page, server }) => { route.fulfill({ status: 200, body: 'fulfilled' }); }); await context.route('**/empty.html', route => { - route.continue(); + route.fallback(); }); const response = await page.goto(server.EMPTY_PAGE); const body = await response.body(); @@ -294,38 +294,38 @@ it('should not chain abort', async ({ context, page, server }) => { route.abort(); }); await context.route('**/empty.html', route => { - route.continue(); + route.fallback(); }); 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 }) => { +it('should chain fallback into page', async ({ context, page, server }) => { const intercepted = []; await context.route('**/empty.html', route => { intercepted.push(1); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(2); - route.continue(); + route.fallback(); }); await context.route('**/empty.html', route => { intercepted.push(3); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(4); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(5); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(6); - route.continue(); + route.fallback(); }); 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 d72abe110d..ac9b5ceba6 100644 --- a/tests/page/page-route.spec.ts +++ b/tests/page/page-route.spec.ts @@ -43,19 +43,19 @@ it('should unroute', async ({ page, server }) => { let intercepted = []; await page.route('**/*', route => { intercepted.push(1); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(2); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(3); - route.continue(); + route.fallback(); }); const handler4 = route => { intercepted.push(4); - route.continue(); + route.fallback(); }; await page.route('**/empty.html', handler4); await page.goto(server.EMPTY_PAGE); @@ -825,10 +825,10 @@ it('should contain raw response header after fulfill', async ({ page, server }) expect(headers['content-type']).toBeTruthy(); }); -for (const method of ['fulfill', 'continue', 'abort'] as const) { +for (const method of ['fulfill', 'continue', 'fallback', 'abort'] as const) { it(`route.${method} should throw if called twice`, async ({ page, server }) => { - const routePromise = new Promise(async resove => { - await page.route('**/*', resove); + const routePromise = new Promise(async resolve => { + await page.route('**/*', resolve); }); page.goto(server.PREFIX + '/empty.html').catch(() => {}); const route = await routePromise; @@ -838,19 +838,19 @@ for (const method of ['fulfill', 'continue', 'abort'] as const) { }); } -it('should chain continue', async ({ page, server }) => { +it('should fall back', async ({ page, server }) => { const intercepted = []; await page.route('**/empty.html', route => { intercepted.push(1); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(2); - route.continue(); + route.fallback(); }); await page.route('**/empty.html', route => { intercepted.push(3); - route.continue(); + route.fallback(); }); await page.goto(server.EMPTY_PAGE); expect(intercepted).toEqual([3, 2, 1]); @@ -865,7 +865,7 @@ it('should not chain fulfill', async ({ page, server }) => { route.fulfill({ status: 200, body: 'fulfilled' }); }); await page.route('**/empty.html', route => { - route.continue(); + route.fallback(); }); const response = await page.goto(server.EMPTY_PAGE); const body = await response.body(); @@ -882,14 +882,14 @@ it('should not chain abort', async ({ page, server }) => { route.abort(); }); await page.route('**/empty.html', route => { - route.continue(); + route.fallback(); }); 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 }) => { +it('should fall back after exception', async ({ page, server }) => { await page.route('**/empty.html', route => { route.continue(); }); @@ -897,7 +897,7 @@ it('should continue after exception', async ({ page, server }) => { try { await route.fulfill({ har: { path: 'file' }, response: {} as any }); } catch (e) { - route.continue(); + route.fallback(); } }); await page.goto(server.EMPTY_PAGE); @@ -908,7 +908,7 @@ it('should chain once', async ({ page, server }) => { route.fulfill({ status: 200, body: 'fulfilled one' }); }, { times: 1 }); await page.route('**/empty.html', route => { - route.continue(); + route.fallback(); }, { times: 1 }); const response = await page.goto(server.EMPTY_PAGE); const body = await response.body();