diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index 3ed25b7106..e72c881288 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -508,9 +508,9 @@ File path to respond with. The content type will be inferred from file extension is resolved relative to the current working directory. ### option: Route.fulfill.response -- `response` <[APIResponse]> +- `response` <[APIResponse]|[HARResponse]> -[APIResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden using fulfill options. +[APIResponse] or [HARResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden using fulfill options. ## method: Route.request - returns: <[Request]> diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index cc5bddb7ff..69f7bd86a2 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -38,6 +38,7 @@ "./lib/server": "./lib/server/index.js", "./lib/utilsBundle": "./lib/utilsBundle.js", "./lib/zipBundle": "./lib/zipBundle.js", + "./types/har": "./types/har.d.ts", "./types/protocol": "./types/protocol.d.ts", "./types/structs": "./types/structs.d.ts" }, diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index b1589acd13..af72376f46 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -31,6 +31,7 @@ import type { HeadersArray, URLMatch } from '../common/types'; import { urlMatches } from '../common/netUtils'; import { MultiMap } from '../utils/multimap'; import { APIResponse } from './fetch'; +import type { HARResponse } from '../../types/har'; export type NetworkCookie = { name: string, @@ -292,7 +293,7 @@ export class Route extends ChannelOwner implements api.Ro this._reportHandled(true); } - async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}) { + async fulfill(options: { response?: api.APIResponse | HARResponse, 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); @@ -304,9 +305,9 @@ export class Route extends ChannelOwner implements api.Ro }); } - private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}): Promise<'abort' | 'continue' | 'done'> { + private async _innerFulfill(options: { response?: api.APIResponse | HARResponse, 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; + let { status: statusOption, headers: headersOption, body, contentType } = options; if (options.har && options.response) throw new Error(`At most one of "har" and "response" options should be present`); @@ -335,15 +336,25 @@ export class Route extends ChannelOwner implements api.Ro body = Buffer.from(entry.body, 'base64'); } - if (options.response) { + if (options.response instanceof APIResponse) { statusOption ??= options.response.status(); headersOption ??= options.response.headers(); - if (body === undefined && options.path === undefined && options.response instanceof APIResponse) { + if (body === undefined && options.path === undefined) { if (options.response._request._connection === this._connection) fetchResponseUid = (options.response as APIResponse)._fetchUid(); else body = await options.response.body(); } + } else if (options.response) { + const harResponse = options.response as HARResponse; + statusOption ??= harResponse.status; + headersOption ??= headersArrayToObject(harResponse.headers, false); + if (body === undefined && options.path === undefined) { + body = harResponse.content.text; + contentType ??= harResponse.content.mimeType; + if (body !== undefined && harResponse.content.encoding === 'base64') + body = Buffer.from(body, 'base64'); + } } let isBase64 = false; @@ -365,8 +376,8 @@ export class Route extends ChannelOwner implements api.Ro const headers: Headers = {}; for (const header of Object.keys(headersOption || {})) headers[header.toLowerCase()] = String(headersOption![header]); - if (options.contentType) - headers['content-type'] = String(options.contentType); + if (contentType) + headers['content-type'] = String(contentType); else if (options.path) headers['content-type'] = mime.getType(options.path) || 'application/octet-stream'; if (length && !('content-length' in headers)) diff --git a/packages/playwright-core/src/server/har/har.ts b/packages/playwright-core/src/server/har/har.ts index 82389c19fb..0cc8bf8c18 100644 --- a/packages/playwright-core/src/server/har/har.ts +++ b/packages/playwright-core/src/server/har/har.ts @@ -15,6 +15,10 @@ */ // see http://www.softwareishard.com/blog/har-12-spec/ +export type HARFile = { + log: Log; +}; + export type Log = { version: string; creator: Creator; diff --git a/packages/playwright-core/types/har.d.ts b/packages/playwright-core/types/har.d.ts new file mode 100644 index 0000000000..96276660da --- /dev/null +++ b/packages/playwright-core/types/har.d.ts @@ -0,0 +1,167 @@ +/** + * 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. + */ + +// see http://www.softwareishard.com/blog/har-12-spec/ +export type HARFile = { + log: HARLog; +} + +export type HARLog = { + version: string; + creator: HARCreator; + browser?: HARBrowser; + pages?: HARPage[]; + entries: HAREntry[]; + comment?: string; +}; + +export type HARCreator = { + name: string; + version: string; + comment?: string; +}; + +export type HARBrowser = { + name: string; + version: string; + comment?: string; +}; + +export type HARPage = { + startedDateTime: string; + id: string; + title: string; + pageTimings: HARPageTimings; + comment?: string; +}; + +export type HARPageTimings = { + onContentLoad?: number; + onLoad?: number; + comment?: string; +}; + +export type HAREntry = { + pageref?: string; + startedDateTime: string; + time: number; + request: HARRequest; + response: HARResponse; + cache: HARCache; + timings: HARTimings; + serverIPAddress?: string; + connection?: string; + comment?: string; +}; + +export type HARRequest = { + method: string; + url: string; + httpVersion: string; + cookies: HARCookie[]; + headers: HARHeader[]; + queryString: HARQueryParameter[]; + postData?: HARPostData; + headersSize: number; + bodySize: number; + comment?: string; +}; + +export type HARResponse = { + status: number; + statusText: string; + httpVersion: string; + cookies: HARCookie[]; + headers: HARHeader[]; + content: HARContent; + redirectURL: string; + headersSize: number; + bodySize: number; + comment?: string; +}; + +export type HARCookie = { + name: string; + value: string; + path?: string; + domain?: string; + expires?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: string; + comment?: string; +}; + +export type HARHeader = { + name: string; + value: string; + comment?: string; +}; + +export type HARQueryParameter = { + name: string; + value: string; + comment?: string; +}; + +export type HARPostData = { + mimeType: string; + params: HARParam[]; + text: string; + comment?: string; +}; + +export type HARParam = { + name: string; + value?: string; + fileName?: string; + contentType?: string; + comment?: string; +}; + +export type HARContent = { + size: number; + compression?: number; + mimeType: string; + text?: string; + encoding?: string; + comment?: string; +}; + +export type HARCache = { + beforeRequest?: HARCacheState; + afterRequest?: HARCacheState; + comment?: string; +}; + +export type HARCacheState = { + expires?: string; + lastAccess: string; + eTag: string; + hitCount: number; + comment?: string; +}; + +export type HARTimings = { + blocked?: number; + dns?: number; + connect?: number; + send: number; + wait: number; + receive: number; + ssl?: number; + comment?: string; +}; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index dc61b639b6..b165e3928e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -17,10 +17,13 @@ import { Protocol } from 'playwright-core/types/protocol'; import { ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; +import { HARResponse } from 'playwright-core/types/har'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from 'playwright-core/types/structs'; +export * from 'playwright-core/types/har'; + type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { state?: 'visible'|'attached'; }; @@ -14980,10 +14983,10 @@ export interface Route { path?: string; /** - * [APIResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden - * using fulfill options. + * [APIResponse] or [HARResponse] to fulfill route's request with. Individual fields of the response (such as headers) can + * be overridden using fulfill options. */ - response?: APIResponse; + response?: APIResponse|HARResponse; /** * Response status code, defaults to `200`. diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 05c7124f39..bf322c1ee8 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -17,6 +17,7 @@ import { test as base, expect } from './pageTest'; import fs from 'fs'; +import type { HARFile } from '@playwright/test'; const it = base.extend<{ // We access test servers at 10.0.2.2 from inside the browser on Android, @@ -418,3 +419,46 @@ it('should override status when fulfilling from har', async ({ page, isAndroid, // 404 should fail the CSS and styles should not apply. await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); }); + +it('should fulfill with har response', async ({ page, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + const har = JSON.parse(await fs.promises.readFile(harPath, 'utf-8')) as HARFile; + await page.route('**/*', async route => { + const response = findResponse(har, route.request().url()); + await route.fulfill({ response }); + }); + await page.goto('http://no.playwright/'); + // HAR contains a redirect for the script. + expect(await page.evaluate('window.value')).toBe('foo'); + // HAR contains a POST for the css file but we match ignoring the method, so the file should be served. + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(0, 255, 255)'); +}); + +it('should override status when fulfill with response from har', async ({ page, isAndroid, asset }) => { + it.fixme(isAndroid); + + const harPath = asset('har-fulfill.har'); + const har = JSON.parse(await fs.promises.readFile(harPath, 'utf-8')) as HARFile; + await page.route('**/*', async route => { + const response = findResponse(har, route.request().url()); + await route.fulfill({ response, status: route.request().url().endsWith('.css') ? 404 : undefined }); + }); + 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)'); +}); + +function findResponse(har: HARFile, url: string) { + let entry; + const originalUrl = url; + while (url.trim()) { + entry = har.log.entries.find(entry => entry.request.url === url); + url = entry?.response.redirectURL; + } + expect(entry, originalUrl).toBeTruthy(); + return entry?.response; +} diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index ddf7d3528b..348432d986 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -16,10 +16,13 @@ import { Protocol } from 'playwright-core/types/protocol'; import { ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; +import { HARResponse } from 'playwright-core/types/har'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from 'playwright-core/types/structs'; +export * from 'playwright-core/types/har'; + type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { state?: 'visible'|'attached'; };