diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index e0f3a8a7f1..0fc5a9e775 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -217,10 +217,12 @@ Optional response body as text. Optional response body as raw bytes. ### option: Route.fulfill.har -- `har` <[path]> +- `har` <[Object]> + - `path` <[string]> Path to the HAR file. + - `fallback` ?<[RouteHARFallback]<"abort"|"continue"|"throw">> Behavior in the case where matching entry was not found in the HAR. Either [`method: Route.abort`] the request, [`method: Route.continue`] it, or throw an error. Defaults to "abort". -HAR file to extract the response from. If HAR file contains an entry with the matching the url, its headers, status and body will be used. Individual fields such as headers can be overridden using fulfill options. If matching entry is not found, this method will throw. -If `har` is a relative path, then it is resolved relative to the current working directory. +HAR file to extract the response from. If HAR file contains an entry with the matching url and HTTP method, then the entry's headers, status and body will be used to fulfill. An entry resulting in a redirect will be followed automatically. Individual fields such as headers can be overridden using fulfill options. +If `path` is a relative path, then it is resolved relative to the current working directory. ### option: Route.fulfill.path - `path` <[path]> diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 54eb35c0b5..7b8014cd84 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -55,6 +55,11 @@ export type SetNetworkCookieParam = { sameSite?: 'Strict' | 'Lax' | 'None' }; +type RouteHAR = { + fallback?: 'abort' | 'continue' | 'throw'; + path: string; +}; + export class Request extends ChannelOwner implements api.Request { private _redirectedFrom: Request | null = null; private _redirectedTo: Request | null = null; @@ -259,12 +264,18 @@ export class Route extends ChannelOwner implements api.Ro 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); + async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}) { + 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; + } + }); } - private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: string } = {}) { + private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}): Promise<'abort' | 'continue' | 'done'> { let fetchResponseUid; let { status: statusOption, headers: headersOption, body } = options; @@ -272,14 +283,21 @@ export class Route extends ChannelOwner implements api.Ro throw new Error(`At most one of "har" and "response" options should be present`); if (options.har) { + const fallback = options.har.fallback ?? 'abort'; + if (!['abort', 'continue', 'throw'].includes(fallback)) + throw new Error(`har.fallback: expected one of "abort", "continue" or "throw", received "${fallback}"`); const entry = await this._connection.localUtils()._channel.harFindEntry({ cacheKey: this.request()._context()._guid, - harFile: options.har, + harFile: options.har.path, url: this.request().url(), + method: this.request().method(), needBody: body === undefined, }); - if (entry.error) - throw new Error(entry.error); + if (entry.error) { + if (fallback === 'throw') + throw new Error(entry.error); + return fallback; + } if (statusOption === undefined) statusOption = entry.status; if (headersOption === undefined && entry.headers) @@ -332,6 +350,7 @@ export class Route extends ChannelOwner implements api.Ro isBase64, fetchResponseUid })); + return 'done'; } async continue(options: OverridesForContinue = {}) { @@ -577,7 +596,9 @@ export class RouteHandler { public handle(route: Route, request: Request, routeChain: (done: boolean) => Promise) { ++this.handledCount; route._startHandling(routeChain); - this.handler(route, request); + // Extract handler into a variable to avoid [RouteHandler.handler] in the stack. + const handler = this.handler; + handler(route, request); } public willExpire(): boolean { diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index b5cb874d3e..d45c5603c9 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -393,6 +393,7 @@ export type LocalUtilsHarFindEntryParams = { cacheKey: string, harFile: string, url: string, + method: string, needBody: boolean, }; export type LocalUtilsHarFindEntryOptions = { diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 16a375130f..01a20d221a 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -479,6 +479,7 @@ LocalUtils: cacheKey: string harFile: string url: string + method: string needBody: boolean returns: error: string? diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2778a104e0..a515c2fc4b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -209,6 +209,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { cacheKey: tString, harFile: tString, url: tString, + method: tString, needBody: tBoolean, }); scheme.LocalUtilsHarClearCacheParams = tObject({ diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 2d84c31665..fb827bed53 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -23,7 +23,7 @@ import { assert, createGuid } from '../../utils'; import type { DispatcherScope } from './dispatcher'; import { Dispatcher } from './dispatcher'; import { yazl, yauzl } from '../../zipBundle'; -import type { Log } from '../har/har'; +import type { Log, Entry } from '../har/har'; export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel> implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; @@ -104,17 +104,38 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. cache.set(params.harFile, harLog); } - const entry = harLog.entries.find(entry => entry.request.url === params.url); - if (!entry) - throw new Error(`No entry matching ${params.url}`); - let base64body: string | undefined; - if (params.needBody && entry.response.content && entry.response.content.text !== undefined) { - if (entry.response.content.encoding === 'base64') - base64body = entry.response.content.text; - else - base64body = Buffer.from(entry.response.content.text, 'utf8').toString('base64'); + const visited = new Set(); + let url = params.url; + let method = params.method; + while (true) { + const entry = harLog.entries.find(entry => entry.request.url === url && entry.request.method === method); + if (!entry) + throw new Error(`No entry matching ${params.url}`); + if (visited.has(entry)) + throw new Error(`Found redirect cycle for ${params.url}`); + visited.add(entry); + + const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location'); + if (redirectStatus.includes(entry.response.status) && locationHeader) { + const locationURL = new URL(locationHeader.value, url); + url = locationURL.toString(); + if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' || + entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) { + // HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch) + method = 'GET'; + } + continue; + } + + let base64body: string | undefined; + if (params.needBody && entry.response.content && entry.response.content.text !== undefined) { + if (entry.response.content.encoding === 'base64') + base64body = entry.response.content.text; + else + base64body = Buffer.from(entry.response.content.text, 'utf8').toString('base64'); + } + return { status: entry.response.status, headers: entry.response.headers, body: base64body }; } - return { status: entry.response.status, headers: entry.response.headers, body: base64body }; } catch (e) { return { error: `Error reading HAR file ${params.harFile}: ` + e.message }; } @@ -124,3 +145,5 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. this._harCache.delete(params.cacheKey); } } + +const redirectStatus = [301, 302, 303, 307, 308]; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 760bf8b482..dcdc410c87 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14868,12 +14868,25 @@ export interface Route { contentType?: string; /** - * HAR file to extract the response from. If HAR file contains an entry with the matching the url, its headers, status and - * body will be used. Individual fields such as headers can be overridden using fulfill options. If matching entry is not - * found, this method will throw. If `har` is a relative path, then it is resolved relative to the current working - * directory. + * HAR file to extract the response from. If HAR file contains an entry with the matching url and HTTP method, then the + * entry's headers, status and body will be used to fulfill. An entry resulting in a redirect will be followed + * automatically. Individual fields such as headers can be overridden using fulfill options. If `path` is a relative path, + * then it is resolved relative to the current working directory. */ - har?: string; + har?: { + /** + * Path to the HAR file. + */ + path: string; + + /** + * Behavior in the case where matching entry was not found in the HAR. Either + * [route.abort([errorCode])](https://playwright.dev/docs/api/class-route#route-abort) the request, + * [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) it, or throw an error. Defaults + * to "abort". + */ + fallback?: "abort"|"continue"|"throw"; + }; /** * Response headers. Header values will be converted to a string. diff --git a/tests/assets/har-fulfill.har b/tests/assets/har-fulfill.har new file mode 100644 index 0000000000..fcc222d426 --- /dev/null +++ b/tests/assets/har-fulfill.har @@ -0,0 +1,371 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Playwright", + "version": "1.23.0-next" + }, + "browser": { + "name": "chromium", + "version": "103.0.5060.33" + }, + "pages": [ + { + "startedDateTime": "2022-06-10T04:27:32.125Z", + "id": "page@b17b177f1c2e66459db3dcbe44636ffd", + "title": "Hey", + "pageTimings": { + "onContentLoad": 70, + "onLoad": 70 + } + } + ], + "entries": [ + { + "_requestref": "request@ee2a0dc164935fcd4d9432d37b245f3c", + "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", + "_monotonicTime": 270572145.898, + "startedDateTime": "2022-06-10T04:27:32.146Z", + "time": 8.286, + "request": { + "method": "GET", + "url": "http://no.playwright/", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Accept", + "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" + }, + { + "name": "Upgrade-Insecure-Requests", + "value": "1" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" + } + ], + "queryString": [], + "headersSize": 326, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-length", + "value": "111" + }, + { + "name": "content-type", + "value": "text/html" + } + ], + "content": { + "size": 111, + "mimeType": "text/html", + "compression": 0, + "text": "Hey
hello
" + }, + "headersSize": 65, + "bodySize": 170, + "redirectURL": "", + "_transferSize": 170 + }, + "cache": { + "beforeRequest": null, + "afterRequest": null + }, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 8.286, + "receive": -1 + }, + "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", + "_securityDetails": {} + }, + { + "_requestref": "request@f2ff0fd79321ff90d0bc1b5d6fc13bad", + "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", + "_monotonicTime": 270572174.683, + "startedDateTime": "2022-06-10T04:27:32.172Z", + "time": 7.132, + "request": { + "method": "POST", + "url": "http://no.playwright/style.css", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Accept", + "value": "text/css,*/*;q=0.1" + }, + { + "name": "Referer", + "value": "http://no.playwright/" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" + } + ], + "queryString": [], + "headersSize": 220, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-length", + "value": "24" + }, + { + "name": "content-type", + "value": "text/css" + } + ], + "content": { + "size": 24, + "mimeType": "text/css", + "compression": 0, + "text": "body { background:cyan }" + }, + "headersSize": 63, + "bodySize": 81, + "redirectURL": "", + "_transferSize": 81 + }, + "cache": { + "beforeRequest": null, + "afterRequest": null + }, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 8.132, + "receive": -1 + }, + "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", + "_securityDetails": {} + }, + { + "_requestref": "request@f2ff0fd79321ff90d0bc1b5d6fc13bac", + "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", + "_monotonicTime": 270572174.683, + "startedDateTime": "2022-06-10T04:27:32.174Z", + "time": 8.132, + "request": { + "method": "GET", + "url": "http://no.playwright/style.css", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Accept", + "value": "text/css,*/*;q=0.1" + }, + { + "name": "Referer", + "value": "http://no.playwright/" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" + } + ], + "queryString": [], + "headersSize": 220, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-length", + "value": "24" + }, + { + "name": "content-type", + "value": "text/css" + } + ], + "content": { + "size": 24, + "mimeType": "text/css", + "compression": 0, + "text": "body { background: red }" + }, + "headersSize": 63, + "bodySize": 81, + "redirectURL": "", + "_transferSize": 81 + }, + "cache": { + "beforeRequest": null, + "afterRequest": null + }, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 8.132, + "receive": -1 + }, + "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", + "_securityDetails": {} + }, + { + "_requestref": "request@9626f59acb1f4a95f25112d32e9f7f60", + "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", + "_monotonicTime": 270572175.042, + "startedDateTime": "2022-06-10T04:27:32.175Z", + "time": 15.997, + "request": { + "method": "GET", + "url": "http://no.playwright/script.js", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Referer", + "value": "http://no.playwright/" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" + } + ], + "queryString": [], + "headersSize": 205, + "bodySize": 0 + }, + "response": { + "status": 301, + "statusText": "Moved Permanently", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "location", + "value": "http://no.playwright/script2.js" + } + ], + "content": { + "size": -1, + "mimeType": "x-unknown", + "compression": 0 + }, + "headersSize": 77, + "bodySize": 0, + "redirectURL": "http://no.playwright/script2.js", + "_transferSize": 77 + }, + "cache": { + "beforeRequest": null, + "afterRequest": null + }, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 7.673, + "receive": 8.324 + }, + "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", + "_securityDetails": {} + }, + { + "_requestref": "request@d7ee53396148a663b819c348c53b03fb", + "_frameref": "frame@c7467fc0f1f86f09fc3b0d727a3862ea", + "_monotonicTime": 270572181.822, + "startedDateTime": "2022-06-10T04:27:32.182Z", + "time": 6.735, + "request": { + "method": "GET", + "url": "http://no.playwright/script2.js", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Referer", + "value": "http://no.playwright/" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.33 Safari/537.36" + } + ], + "queryString": [], + "headersSize": 206, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-length", + "value": "18" + }, + { + "name": "content-type", + "value": "text/javascript" + } + ], + "content": { + "size": 18, + "mimeType": "text/javascript", + "compression": 0, + "text": "window.value='foo'" + }, + "headersSize": 70, + "bodySize": 82, + "redirectURL": "", + "_transferSize": 82 + }, + "cache": { + "beforeRequest": null, + "afterRequest": null + }, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 6.735, + "receive": -1 + }, + "pageref": "page@b17b177f1c2e66459db3dcbe44636ffd", + "_securityDetails": {} + } + ] + } +} \ No newline at end of file diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 7fcf433591..073e179d4d 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -292,47 +292,6 @@ it('should filter by regexp', async ({ contextFactory, server }, testInfo) => { expect(log.entries[0].request.url.endsWith('har.html')).toBe(true); }); -it('should fulfill route from har', async ({ contextFactory, server }, testInfo) => { - const kCustomCSS = 'body { background-color: rgb(50, 100, 150); }'; - - const harPath = testInfo.outputPath('test.har'); - const harContext = await contextFactory({ baseURL: server.PREFIX, recordHar: { path: harPath, urlFilter: '/*.css' }, ignoreHTTPSErrors: true }); - const harPage = await harContext.newPage(); - await harPage.route('**/one-style.css', async route => { - // Make sure har content is not what the server returns. - await route.fulfill({ body: kCustomCSS }); - }); - await harPage.goto('/har.html'); - await harContext.close(); - - const context = await contextFactory(); - const page1 = await context.newPage(); - await page1.route('**/*.css', async route => { - // Fulfulling from har should give expected CSS. - await route.fulfill({ har: harPath }); - }); - const [response1] = await Promise.all([ - page1.waitForResponse('**/one-style.css'), - page1.goto(server.PREFIX + '/one-style.html'), - ]); - expect(await response1.text()).toBe(kCustomCSS); - await expect(page1.locator('body')).toHaveCSS('background-color', 'rgb(50, 100, 150)'); - await page1.close(); - - const page2 = await context.newPage(); - await page2.route('**/*.css', async route => { - // Overriding status should make CSS not apply. - await route.fulfill({ har: harPath, status: 404 }); - }); - const [response2] = await Promise.all([ - page2.waitForResponse('**/one-style.css'), - page2.goto(server.PREFIX + '/one-style.html'), - ]); - expect(response2.status()).toBe(404); - await expect(page2.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - await page2.close(); -}); - it('should include sizes', async ({ contextFactory, server, asset }, testInfo) => { const { page, getLog } = await pageWithHar(contextFactory, testInfo); await page.goto(server.PREFIX + '/har.html'); diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 35e71b369f..cf98a7635a 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -322,43 +322,98 @@ it('headerValue should return set-cookie from intercepted response', async ({ pa expect(await response.headerValue('Set-Cookie')).toBe('a=b'); }); -it('should complain about bad har', async ({ page, server, isElectron, isAndroid }, testInfo) => { - it.fixme(isElectron, 'error: Browser context management is not supported.'); +it('should complain about har + response options', async ({ page, server, isAndroid }) => { it.fixme(isAndroid); + + let error; + await page.route('**/*.css', async route => { + const response = await page.request.fetch(route.request()); + error = await route.fulfill({ har: { path: 'har' }, response }).catch(e => e); + await route.continue(); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(error.message).toBe(`route.fulfill: At most one of "har" and "response" options should be present`); +}); + +it('should complain about bad har.fallback', async ({ page, server, isAndroid }) => { + it.fixme(isAndroid); + + let error; + await page.route('**/*.css', async route => { + error = await route.fulfill({ har: { path: 'har', fallback: 'foo' } as any }).catch(e => e); + await route.continue(); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(error.message).toBe(`route.fulfill: har.fallback: expected one of "abort", "continue" or "throw", received "foo"`); +}); + +it('should fulfill from har, matching the method and following redirects', async ({ page, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + await page.route('**/*', route => route.fulfill({ har: { path: harPath } })); + await page.goto('http://no.playwright/'); + // HAR contains a redirect for the script that should be followed automatically. + expect(await page.evaluate('window.value')).toBe('foo'); + // HAR contains a POST for the css file that should not be used. + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); +}); + +it('should fallback to abort when not found in har', async ({ page, server, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + await page.route('**/*', route => route.fulfill({ har: { path: harPath } })); + const error = await page.goto(server.EMPTY_PAGE).catch(e => e); + expect(error instanceof Error).toBe(true); +}); + +it('should support fallback:continue when not found in har', async ({ page, server, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + await page.route('**/*', route => route.fulfill({ har: { path: harPath, fallback: 'continue' } })); + await page.goto(server.PREFIX + '/one-style.html'); + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(255, 192, 203)'); +}); + +it('should support fallback:throw when not found in har', async ({ page, server, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + let error; + await page.route('**/*.css', async route => { + error = await route.fulfill({ har: { path: harPath, fallback: 'throw' } }).catch(e => e); + await route.continue(); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(error.message).toBe(`route.fulfill: Error reading HAR file ${harPath}: No entry matching ${server.PREFIX + '/one-style.css'}`); +}); + +it('should complain about bad har with fallback:throw', async ({ page, server, isAndroid }, testInfo) => { + it.fixme(isAndroid); + const harPath = testInfo.outputPath('test.har'); fs.writeFileSync(harPath, JSON.stringify({ log: {} }), 'utf-8'); let error; await page.route('**/*.css', async route => { - error = await route.fulfill({ har: harPath }).catch(e => e); + error = await route.fulfill({ har: { path: harPath, fallback: 'throw' } }).catch(e => e); await route.continue(); }); await page.goto(server.PREFIX + '/one-style.html'); - expect(error.message).toContain(`Error reading HAR file ${harPath}: Cannot read`); + expect(error.message).toContain(`route.fulfill: Error reading HAR file ${harPath}:`); }); -it('should complain about no entry found in har', async ({ page, server, isElectron, isAndroid }, testInfo) => { - it.fixme(isElectron, 'error: Browser context management is not supported.'); +it('should override status when fulfilling from har', async ({ page, isAndroid, asset }) => { it.fixme(isAndroid); - const harPath = testInfo.outputPath('test.har'); - fs.writeFileSync(harPath, JSON.stringify({ log: { entries: [] } }), 'utf-8'); - let error; - await page.route('**/*.css', async route => { - error = await route.fulfill({ har: harPath }).catch(e => e); - await route.continue(); - }); - await page.goto(server.PREFIX + '/one-style.html'); - expect(error.message).toBe(`Error reading HAR file ${harPath}: No entry matching ${server.PREFIX + '/one-style.css'}`); -}); -it('should complain about har + response options', async ({ page, server, isElectron, isAndroid }) => { - it.fixme(isElectron, 'error: Browser context management is not supported.'); - it.fixme(isAndroid); - let error; - await page.route('**/*.css', async route => { - const response = await page.request.fetch(route.request()); - error = await route.fulfill({ har: 'har', response }).catch(e => e); - await route.continue(); + const harPath = asset('har-fulfill.har'); + await page.route('**/*', async route => { + await route.fulfill({ har: { path: harPath }, status: route.request().url().endsWith('.css') ? 404 : undefined }); }); - await page.goto(server.PREFIX + '/one-style.html'); - expect(error.message).toBe(`At most one of "har" and "response" options should be present`); + await page.goto('http://no.playwright/'); + // Script should work. + expect(await page.evaluate('window.value')).toBe('foo'); + // 404 should fail the CSS and styles should not apply. + await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); }); diff --git a/tests/page/page-route.spec.ts b/tests/page/page-route.spec.ts index 99d726ee4a..d72abe110d 100644 --- a/tests/page/page-route.spec.ts +++ b/tests/page/page-route.spec.ts @@ -895,7 +895,7 @@ it('should continue after exception', async ({ page, server }) => { }); await page.route('**/empty.html', async route => { try { - await route.fulfill({ har: 'file', response: {} as any }); + await route.fulfill({ har: { path: 'file' }, response: {} as any }); } catch (e) { route.continue(); }