diff --git a/src/browserContext.ts b/src/browserContext.ts index f13bb9f053432..ec04265e3ffed 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -39,7 +39,7 @@ export interface BrowserContext extends EventEmitter { grantPermissions(permissions: string[], options?: { origin?: string }): Promise; clearPermissions(): Promise; setGeolocation(geolocation?: types.Geolocation): Promise; - setExtraHTTPHeaders(headers: types.Headers): Promise; + setExtraHTTPHeaders(headers: types.HeadersArray): Promise; setOffline(offline: boolean): Promise; setHTTPCredentials(httpCredentials?: types.Credentials): Promise; addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise; @@ -103,7 +103,7 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser abstract _doClearPermissions(): Promise; abstract setGeolocation(geolocation?: types.Geolocation): Promise; abstract _doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise; - abstract setExtraHTTPHeaders(headers: types.Headers): Promise; + abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise; abstract setOffline(offline: boolean): Promise; abstract _doAddInitScript(expression: string): Promise; abstract _doExposeBinding(binding: PageBinding): Promise; @@ -189,9 +189,11 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser const { username, password } = proxy; if (username) { this._options.httpCredentials = { username, password: password! }; - this._options.extraHTTPHeaders = this._options.extraHTTPHeaders || {}; const token = Buffer.from(`${username}:${password}`).toString('base64'); - this._options.extraHTTPHeaders['Proxy-Authorization'] = `Basic ${token}`; + this._options.extraHTTPHeaders = network.mergeHeaders([ + this._options.extraHTTPHeaders, + network.singleHeader('Proxy-Authorization', `Basic ${token}`), + ]); } } @@ -236,8 +238,6 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio if (!options.viewport && !options.noDefaultViewport) options.viewport = { width: 1280, height: 720 }; verifyGeolocation(options.geolocation); - if (options.extraHTTPHeaders) - options.extraHTTPHeaders = network.verifyHeaders(options.extraHTTPHeaders); } export function verifyGeolocation(geolocation?: types.Geolocation) { diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 9ab10ea43f1d5..f59934674d829 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -387,8 +387,8 @@ export class CRBrowserContext extends BrowserContextBase { await (page._delegate as CRPage).updateGeolocation(); } - async setExtraHTTPHeaders(headers: types.Headers): Promise { - this._options.extraHTTPHeaders = network.verifyHeaders(headers); + async setExtraHTTPHeaders(headers: types.HeadersArray): Promise { + this._options.extraHTTPHeaders = headers; for (const page of this.pages()) await (page._delegate as CRPage).updateExtraHTTPHeaders(); } diff --git a/src/chromium/crNetworkManager.ts b/src/chromium/crNetworkManager.ts index 59480b79005e8..eb91c67da9f4c 100644 --- a/src/chromium/crNetworkManager.ts +++ b/src/chromium/crNetworkManager.ts @@ -23,6 +23,7 @@ import * as network from '../network'; import * as frames from '../frames'; import * as types from '../types'; import { CRPage } from './crPage'; +import { headersObjectToArray } from '../converters'; export class CRNetworkManager { private _client: CRSession; @@ -239,7 +240,7 @@ export class CRNetworkManager { const response = await this._client.send('Network.getResponseBody', { requestId: request._requestId }); return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); }; - return new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObject(responsePayload.headers), getResponseBody); + return new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), getResponseBody); } _handleRequestRedirect(request: InterceptableRequest, responsePayload: Protocol.Network.Response) { @@ -350,7 +351,7 @@ class InterceptableRequest implements network.RouteDelegate { if (postDataEntries && postDataEntries.length && postDataEntries[0].bytes) postDataBuffer = Buffer.from(postDataEntries[0].bytes, 'base64'); - this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, url, type, method, postDataBuffer, headersObject(headers)); + this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers)); } async continue(overrides: types.NormalizedContinueOverrides) { @@ -406,11 +407,3 @@ const errorReasons: { [reason: string]: Protocol.Network.ErrorReason } = { 'timedout': 'TimedOut', 'failed': 'Failed', }; - -function headersObject(headers: Protocol.Network.Headers): types.Headers { - const result: types.Headers = {}; - for (const key of Object.keys(headers)) - result[key.toLowerCase()] = headers[key]; - return result; -} - diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index b5e26e8b6cf95..9de079e35c61a 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -37,6 +37,7 @@ import * as types from '../types'; import { ConsoleMessage } from '../console'; import * as sourceMap from '../utils/sourceMap'; import { rewriteErrorMessage } from '../utils/stackTrace'; +import { headersArrayToObject } from '../converters'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -729,7 +730,7 @@ class FrameSession { this._crPage._browserContext._options.extraHTTPHeaders, this._page._state.extraHTTPHeaders ]); - await this._client.send('Network.setExtraHTTPHeaders', { headers }); + await this._client.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(headers, false /* lowerCase */) }); } async _updateGeolocation(): Promise { diff --git a/src/converters.ts b/src/converters.ts index 0f54dc3706f9d..07d9f0994b3d9 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -19,7 +19,6 @@ import * as mime from 'mime'; import * as path from 'path'; import * as util from 'util'; import * as types from './types'; -import { helper, assert } from './helper'; export async function normalizeFilePayloads(files: string | types.FilePayload | string[] | types.FilePayload[]): Promise { let ff: string[] | types.FilePayload[]; @@ -43,65 +42,18 @@ export async function normalizeFilePayloads(files: string | types.FilePayload | return filePayloads; } -export async function normalizeFulfillParameters(params: types.FulfillResponse & { path?: string }): Promise { - let body = ''; - let isBase64 = false; - let length = 0; - if (params.path) { - const buffer = await util.promisify(fs.readFile)(params.path); - body = buffer.toString('base64'); - isBase64 = true; - length = buffer.length; - } else if (helper.isString(params.body)) { - body = params.body; - isBase64 = false; - length = Buffer.byteLength(body); - } else if (params.body) { - body = params.body.toString('base64'); - isBase64 = true; - length = params.body.length; - } - const headers: types.Headers = {}; - for (const header of Object.keys(params.headers || {})) - headers[header.toLowerCase()] = String(params.headers![header]); - if (params.contentType) - headers['content-type'] = String(params.contentType); - else if (params.path) - headers['content-type'] = mime.getType(params.path) || 'application/octet-stream'; - if (length && !('content-length' in headers)) - headers['content-length'] = String(length); - - return { - status: params.status || 200, - headers: headersObjectToArray(headers), - body, - isBase64 - }; -} - -export function normalizeContinueOverrides(overrides: types.ContinueOverrides): types.NormalizedContinueOverrides { - return { - method: overrides.method, - headers: overrides.headers ? headersObjectToArray(overrides.headers) : undefined, - postData: helper.isString(overrides.postData) ? Buffer.from(overrides.postData, 'utf8') : overrides.postData, - }; -} - -export function headersObjectToArray(headers: types.Headers): types.HeadersArray { +export function headersObjectToArray(headers: { [key: string]: string }): types.HeadersArray { const result: types.HeadersArray = []; for (const name in headers) { - if (!Object.is(headers[name], undefined)) { - const value = headers[name]; - assert(helper.isString(value), `Expected value of header "${name}" to be String, but "${typeof value}" is found.`); - result.push({ name, value }); - } + if (!Object.is(headers[name], undefined)) + result.push({ name, value: headers[name] }); } return result; } -export function headersArrayToObject(headers: types.HeadersArray): types.Headers { - const result: types.Headers = {}; +export function headersArrayToObject(headers: types.HeadersArray, lowerCase: boolean): { [key: string]: string } { + const result: { [key: string]: string } = {}; for (const { name, value } of headers) - result[name] = value; + result[lowerCase ? name.toLowerCase() : name] = value; return result; } diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts index e02b9ef8f081b..465e57954495a 100644 --- a/src/firefox/ffBrowser.ts +++ b/src/firefox/ffBrowser.ts @@ -24,7 +24,6 @@ import { Page, PageBinding } from '../page'; import { ConnectionTransport, SlowMoTransport } from '../transport'; import * as types from '../types'; import { ConnectionEvents, FFConnection } from './ffConnection'; -import { headersArray } from './ffNetworkManager'; import { FFPage } from './ffPage'; import { Protocol } from './protocol'; @@ -211,7 +210,7 @@ export class FFBrowserContext extends BrowserContextBase { if (this._options.permissions) promises.push(this.grantPermissions(this._options.permissions)); if (this._options.extraHTTPHeaders || this._options.locale) - promises.push(this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || {})); + promises.push(this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || [])); if (this._options.httpCredentials) promises.push(this.setHTTPCredentials(this._options.httpCredentials)); if (this._options.geolocation) @@ -294,12 +293,12 @@ export class FFBrowserContext extends BrowserContextBase { await this._browser._connection.send('Browser.setGeolocationOverride', { browserContextId: this._browserContextId || undefined, geolocation: geolocation || null }); } - async setExtraHTTPHeaders(headers: types.Headers): Promise { - this._options.extraHTTPHeaders = network.verifyHeaders(headers); - const allHeaders = { ...this._options.extraHTTPHeaders }; + async setExtraHTTPHeaders(headers: types.HeadersArray): Promise { + this._options.extraHTTPHeaders = headers; + let allHeaders = this._options.extraHTTPHeaders; if (this._options.locale) - allHeaders['Accept-Language'] = this._options.locale; - await this._browser._connection.send('Browser.setExtraHTTPHeaders', { browserContextId: this._browserContextId || undefined, headers: headersArray(allHeaders) }); + allHeaders = network.mergeHeaders([allHeaders, network.singleHeader('Accept-Language', this._options.locale)]); + await this._browser._connection.send('Browser.setExtraHTTPHeaders', { browserContextId: this._browserContextId || undefined, headers: allHeaders }); } async setOffline(offline: boolean): Promise { diff --git a/src/firefox/ffNetworkManager.ts b/src/firefox/ffNetworkManager.ts index bb44959795c6a..0dd62d910b7e3 100644 --- a/src/firefox/ffNetworkManager.ts +++ b/src/firefox/ffNetworkManager.ts @@ -75,10 +75,7 @@ export class FFNetworkManager { throw new Error(`Response body for ${request.request.method()} ${request.request.url()} was evicted!`); return Buffer.from(response.base64body, 'base64'); }; - const headers: types.Headers = {}; - for (const {name, value} of event.headers) - headers[name.toLowerCase()] = value; - const response = new network.Response(request.request, event.status, event.statusText, headers, getResponseBody); + const response = new network.Response(request.request, event.status, event.statusText, event.headers, getResponseBody); this._page._frameManager.requestReceivedResponse(response); } @@ -150,14 +147,11 @@ class InterceptableRequest implements network.RouteDelegate { this._id = payload.requestId; this._session = session; - const headers: types.Headers = {}; - for (const {name, value} of payload.headers) - headers[name.toLowerCase()] = value; let postDataBuffer = null; if (payload.postData) postDataBuffer = Buffer.from(payload.postData, 'base64'); this.request = new network.Request(payload.isIntercepted ? this : null, frame, redirectedFrom ? redirectedFrom.request : null, payload.navigationId, - payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, headers); + payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, payload.headers); } async continue(overrides: types.NormalizedContinueOverrides) { @@ -188,12 +182,3 @@ class InterceptableRequest implements network.RouteDelegate { }); } } - -export function headersArray(headers: types.Headers): Protocol.Network.HTTPHeader[] { - const result: Protocol.Network.HTTPHeader[] = []; - for (const name in headers) { - if (!Object.is(headers[name], undefined)) - result.push({name, value: headers[name] + ''}); - } - return result; -} diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index bccc9f38c3e09..95c714d34e2b3 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -28,7 +28,7 @@ import { FFBrowserContext } from './ffBrowser'; import { FFSession, FFSessionEvents } from './ffConnection'; import { FFExecutionContext } from './ffExecutionContext'; import { RawKeyboardImpl, RawMouseImpl } from './ffInput'; -import { FFNetworkManager, headersArray } from './ffNetworkManager'; +import { FFNetworkManager } from './ffNetworkManager'; import { Protocol } from './protocol'; import { selectors } from '../selectors'; import { rewriteErrorMessage } from '../utils/stackTrace'; @@ -272,7 +272,7 @@ export class FFPage implements PageDelegate { } async updateExtraHTTPHeaders(): Promise { - await this._session.send('Network.setExtraHTTPHeaders', { headers: headersArray(this._page._state.extraHTTPHeaders || {}) }); + await this._session.send('Network.setExtraHTTPHeaders', { headers: this._page._state.extraHTTPHeaders || [] }); } async setViewportSize(viewportSize: types.Size): Promise { diff --git a/src/frames.ts b/src/frames.ts index fdfdce4d8d9be..be842a5ef9507 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -428,8 +428,9 @@ export class Frame { return runNavigationTask(this, options, async progress => { const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); progress.log(`navigating to "${url}", waiting until "${waitUntil}"`); - const headers = (this._page._state.extraHTTPHeaders || {}); - let referer = headers['referer'] || headers['Referer']; + const headers = this._page._state.extraHTTPHeaders || []; + const refererHeader = headers.find(h => h.name === 'referer' || h.name === 'Referer'); + let referer = refererHeader ? refererHeader.value : undefined; if (options.referer !== undefined) { if (referer !== undefined && referer !== options.referer) throw new Error('"referer" is already specified as extra HTTP header'); diff --git a/src/network.ts b/src/network.ts index d5cea20ada014..c47fa5ae16a16 100644 --- a/src/network.ts +++ b/src/network.ts @@ -16,8 +16,7 @@ import * as frames from './frames'; import * as types from './types'; -import { assert, helper } from './helper'; -import { normalizeFulfillParameters, normalizeContinueOverrides } from './converters'; +import { assert } from './helper'; export function filterCookies(cookies: types.NetworkCookie[], urls: string[]): types.NetworkCookie[] { const parsedURLs = urls.map(s => new URL(s)); @@ -78,13 +77,13 @@ export class Request { private _resourceType: string; private _method: string; private _postData: Buffer | null; - private _headers: types.Headers; + private _headers: types.HeadersArray; private _frame: frames.Frame; private _waitForResponsePromise: Promise; private _waitForResponsePromiseCallback: (value: Response | null) => void = () => {}; constructor(routeDelegate: RouteDelegate | null, frame: frames.Frame, redirectedFrom: Request | null, documentId: string | undefined, - url: string, resourceType: string, method: string, postData: Buffer | null, headers: types.Headers) { + url: string, resourceType: string, method: string, postData: Buffer | null, headers: types.HeadersArray) { assert(!url.startsWith('data:'), 'Data urls should not fire requests'); assert(!(routeDelegate && redirectedFrom), 'Should not be able to intercept redirects'); this._routeDelegate = routeDelegate; @@ -123,8 +122,8 @@ export class Request { return this._postData; } - headers(): {[key: string]: string} { - return { ...this._headers }; + headers(): types.HeadersArray { + return this._headers; } response(): Promise { @@ -191,15 +190,15 @@ export class Route { await this._delegate.abort(errorCode); } - async fulfill(response: types.FulfillResponse & { path?: string }) { + async fulfill(response: types.NormalizedFulfillResponse) { assert(!this._handled, 'Route is already handled!'); this._handled = true; - await this._delegate.fulfill(await normalizeFulfillParameters(response)); + await this._delegate.fulfill(response); } - async continue(overrides: types.ContinueOverrides = {}) { + async continue(overrides: types.NormalizedContinueOverrides = {}) { assert(!this._handled, 'Route is already handled!'); - await this._delegate.continue(normalizeContinueOverrides(overrides)); + await this._delegate.continue(overrides); } } @@ -215,10 +214,10 @@ export class Response { private _status: number; private _statusText: string; private _url: string; - private _headers: types.Headers; + private _headers: types.HeadersArray; private _getResponseBodyCallback: GetResponseBodyCallback; - constructor(request: Request, status: number, statusText: string, headers: types.Headers, getResponseBodyCallback: GetResponseBodyCallback) { + constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray, getResponseBodyCallback: GetResponseBodyCallback) { this._request = request; this._status = status; this._statusText = statusText; @@ -247,8 +246,8 @@ export class Response { return this._statusText; } - headers(): types.Headers { - return { ...this._headers }; + headers(): types.HeadersArray { + return this._headers; } finished(): Promise { @@ -348,30 +347,24 @@ export const STATUS_TEXTS: { [status: string]: string } = { '511': 'Network Authentication Required', }; -export function verifyHeaders(headers: types.Headers): types.Headers { - const result: types.Headers = {}; - for (const key of Object.keys(headers)) { - const value = headers[key]; - assert(helper.isString(value), `Expected value of header "${key}" to be String, but "${typeof value}" is found.`); - result[key] = value; - } - return result; +export function singleHeader(name: string, value: string): types.HeadersArray { + return [{ name, value }]; } -export function mergeHeaders(headers: (types.Headers | undefined | null)[]): types.Headers { +export function mergeHeaders(headers: (types.HeadersArray | undefined | null)[]): types.HeadersArray { const lowerCaseToValue = new Map(); const lowerCaseToOriginalCase = new Map(); for (const h of headers) { if (!h) continue; - for (const key of Object.keys(h)) { - const lower = key.toLowerCase(); - lowerCaseToOriginalCase.set(lower, key); - lowerCaseToValue.set(lower, h[key]); + for (const { name, value } of h) { + const lower = name.toLowerCase(); + lowerCaseToOriginalCase.set(lower, name); + lowerCaseToValue.set(lower, value); } } - const result: types.Headers = {}; + const result: types.HeadersArray = []; for (const [lower, value] of lowerCaseToValue) - result[lowerCaseToOriginalCase.get(lower)!] = value; + result.push({ name: lowerCaseToOriginalCase.get(lower)!, value }); return result; } diff --git a/src/page.ts b/src/page.ts index 42ec606f02799..77119b43c5b7a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -87,7 +87,7 @@ type PageState = { viewportSize: types.Size | null; mediaType: types.MediaType | null; colorScheme: types.ColorScheme | null; - extraHTTPHeaders: types.Headers | null; + extraHTTPHeaders: types.HeadersArray | null; }; export class Page extends EventEmitter { @@ -214,8 +214,8 @@ export class Page extends EventEmitter { await this._delegate.exposeBinding(binding); } - setExtraHTTPHeaders(headers: types.Headers) { - this._state.extraHTTPHeaders = network.verifyHeaders(headers); + setExtraHTTPHeaders(headers: types.HeadersArray) { + this._state.extraHTTPHeaders = headers; return this._delegate.updateExtraHTTPHeaders(); } diff --git a/src/rpc/client/browser.ts b/src/rpc/client/browser.ts index 2596fd789dcc2..44717879488df 100644 --- a/src/rpc/client/browser.ts +++ b/src/rpc/client/browser.ts @@ -22,6 +22,7 @@ import { Events } from './events'; import { BrowserType } from './browserType'; import { headersObjectToArray } from '../../converters'; import { BrowserContextOptions } from './types'; +import { validateHeaders } from './network'; export class Browser extends ChannelOwner { readonly _contexts = new Set(); @@ -47,8 +48,9 @@ export class Browser extends ChannelOwner { async newContext(options: BrowserContextOptions = {}): Promise { const logger = options.logger; - options = { ...options, logger: undefined }; return this._wrapApiCall('browser.newContext', async () => { + if (options.extraHTTPHeaders) + validateHeaders(options.extraHTTPHeaders); const contextOptions: BrowserNewContextParams = { ...options, viewport: options.viewport === null ? undefined : options.viewport, diff --git a/src/rpc/client/browserContext.ts b/src/rpc/client/browserContext.ts index 24cd5ae0e39b4..7f6448448cd4b 100644 --- a/src/rpc/client/browserContext.ts +++ b/src/rpc/client/browserContext.ts @@ -147,6 +147,7 @@ export class BrowserContext extends ChannelOwner { return this._wrapApiCall('browserContext.setExtraHTTPHeaders', async () => { + network.validateHeaders(headers); await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); }); } diff --git a/src/rpc/client/browserType.ts b/src/rpc/client/browserType.ts index 522aa7c6f275e..0e68b2f6fc452 100644 --- a/src/rpc/client/browserType.ts +++ b/src/rpc/client/browserType.ts @@ -28,6 +28,7 @@ import { Events } from './events'; import { TimeoutSettings } from '../../timeoutSettings'; import { ChildProcess } from 'child_process'; import { envObjectToArray } from './clientHelper'; +import { validateHeaders } from './network'; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; @@ -88,6 +89,8 @@ export class BrowserType extends ChannelOwner { + if (options.extraHTTPHeaders) + validateHeaders(options.extraHTTPHeaders); const persistentOptions: BrowserTypeLaunchPersistentContextParams = { ...options, viewport: options.viewport === null ? undefined : options.viewport, diff --git a/src/rpc/client/network.ts b/src/rpc/client/network.ts index f51c1042bd74b..5af92a017ea90 100644 --- a/src/rpc/client/network.ts +++ b/src/rpc/client/network.ts @@ -18,8 +18,12 @@ import { URLSearchParams } from 'url'; import { RequestChannel, ResponseChannel, RouteChannel, RequestInitializer, ResponseInitializer, RouteInitializer } from '../channels'; import { ChannelOwner } from './channelOwner'; import { Frame } from './frame'; -import { normalizeFulfillParameters, headersArrayToObject, normalizeContinueOverrides } from '../../converters'; +import { headersArrayToObject, headersObjectToArray } from '../../converters'; import { Headers } from './types'; +import * as fs from 'fs'; +import * as mime from 'mime'; +import * as util from 'util'; +import { helper } from '../../helper'; export type NetworkCookie = { name: string, @@ -44,19 +48,6 @@ export type SetNetworkCookieParam = { sameSite?: 'Strict' | 'Lax' | 'None' }; -type FulfillResponse = { - status?: number, - headers?: Headers, - contentType?: string, - body?: string | Buffer, -}; - -type ContinueOverrides = { - method?: string, - headers?: Headers, - postData?: string | Buffer, -}; - export class Request extends ChannelOwner { private _redirectedFrom: Request | null = null; private _redirectedTo: Request | null = null; @@ -77,7 +68,7 @@ export class Request extends ChannelOwner { this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom); if (this._redirectedFrom) this._redirectedFrom._redirectedTo = this; - this._headers = headersArrayToObject(initializer.headers); + this._headers = headersArrayToObject(initializer.headers, true /* lowerCase */); this._postData = initializer.postData ? Buffer.from(initializer.postData, 'base64') : null; } @@ -175,17 +166,49 @@ export class Route extends ChannelOwner { await this._channel.abort({ errorCode }); } - async fulfill(response: FulfillResponse & { path?: string }) { - const normalized = await normalizeFulfillParameters(response); - await this._channel.fulfill(normalized); + async fulfill(response: { status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string }) { + let body = ''; + let isBase64 = false; + let length = 0; + if (response.path) { + const buffer = await util.promisify(fs.readFile)(response.path); + body = buffer.toString('base64'); + isBase64 = true; + length = buffer.length; + } else if (helper.isString(response.body)) { + body = response.body; + isBase64 = false; + length = Buffer.byteLength(body); + } else if (response.body) { + body = response.body.toString('base64'); + isBase64 = true; + length = response.body.length; + } + + const headers: Headers = {}; + for (const header of Object.keys(response.headers || {})) + headers[header.toLowerCase()] = String(response.headers![header]); + if (response.contentType) + headers['content-type'] = String(response.contentType); + else if (response.path) + headers['content-type'] = mime.getType(response.path) || 'application/octet-stream'; + if (length && !('content-length' in headers)) + headers['content-length'] = String(length); + + await this._channel.fulfill({ + status: response.status || 200, + headers: headersObjectToArray(headers), + body, + isBase64 + }); } - async continue(overrides: ContinueOverrides = {}) { - const normalized = normalizeContinueOverrides(overrides); + async continue(overrides: { method?: string, headers?: Headers, postData?: string | Buffer } = {}) { + const postDataBuffer = helper.isString(overrides.postData) ? Buffer.from(overrides.postData, 'utf8') : overrides.postData; await this._channel.continue({ - method: normalized.method, - headers: normalized.headers, - postData: normalized.postData ? normalized.postData.toString('base64') : undefined + method: overrides.method, + headers: overrides.headers ? headersObjectToArray(overrides.headers) : undefined, + postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined, }); } } @@ -205,7 +228,7 @@ export class Response extends ChannelOwner constructor(parent: ChannelOwner, type: string, guid: string, initializer: ResponseInitializer) { super(parent, type, guid, initializer); - this._headers = headersArrayToObject(initializer.headers); + this._headers = headersArrayToObject(initializer.headers, true /* lowerCase */); } url(): string { @@ -257,3 +280,11 @@ export class Response extends ChannelOwner return Request.from(this._initializer.request).frame(); } } + +export function validateHeaders(headers: Headers) { + for (const key of Object.keys(headers)) { + const value = headers[key]; + if (!Object.is(value, undefined) && !helper.isString(value)) + throw new Error(`Expected value of header "${key}" to be String, but "${typeof value}" is found.`); + } +} diff --git a/src/rpc/client/page.ts b/src/rpc/client/page.ts index 90f8177c5397b..a3ce14147a409 100644 --- a/src/rpc/client/page.ts +++ b/src/rpc/client/page.ts @@ -32,7 +32,7 @@ import { Worker } from './worker'; import { Frame, FunctionWithSource, verifyLoadState, WaitForNavigationOptions } from './frame'; import { Keyboard, Mouse } from './input'; import { assertMaxArguments, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } from './jsHandle'; -import { Request, Response, Route, RouteHandler } from './network'; +import { Request, Response, Route, RouteHandler, validateHeaders } from './network'; import { FileChooser } from './fileChooser'; import { Buffer } from 'buffer'; import { ChromiumCoverage } from './chromiumCoverage'; @@ -291,6 +291,7 @@ export class Page extends ChannelOwner { async setExtraHTTPHeaders(headers: Headers) { return this._wrapApiCall('page.setExtraHTTPHeaders', async () => { + validateHeaders(headers); await this._channel.setExtraHTTPHeaders({ headers: headersObjectToArray(headers) }); }); } diff --git a/src/rpc/server/browserContextDispatcher.ts b/src/rpc/server/browserContextDispatcher.ts index 1130214408004..5110712943a31 100644 --- a/src/rpc/server/browserContextDispatcher.ts +++ b/src/rpc/server/browserContextDispatcher.ts @@ -24,7 +24,6 @@ import { RouteDispatcher, RequestDispatcher } from './networkDispatchers'; import { CRBrowserContext } from '../../chromium/crBrowser'; import { CDPSessionDispatcher } from './cdpSessionDispatcher'; import { Events as ChromiumEvents } from '../../chromium/events'; -import { headersArrayToObject } from '../../converters'; export class BrowserContextDispatcher extends Dispatcher implements BrowserContextChannel { private _context: BrowserContextBase; @@ -96,7 +95,7 @@ export class BrowserContextDispatcher extends Dispatcher { - await this._context.setExtraHTTPHeaders(headersArrayToObject(params.headers)); + await this._context.setExtraHTTPHeaders(params.headers); } async setOffline(params: { offline: boolean }): Promise { diff --git a/src/rpc/server/browserDispatcher.ts b/src/rpc/server/browserDispatcher.ts index 26411d01e9001..c664a99b59d96 100644 --- a/src/rpc/server/browserDispatcher.ts +++ b/src/rpc/server/browserDispatcher.ts @@ -23,7 +23,6 @@ import { CDPSessionDispatcher } from './cdpSessionDispatcher'; import { Dispatcher, DispatcherScope } from './dispatcher'; import { CRBrowser } from '../../chromium/crBrowser'; import { PageDispatcher } from './pageDispatcher'; -import { headersArrayToObject } from '../../converters'; export class BrowserDispatcher extends Dispatcher implements BrowserChannel { constructor(scope: DispatcherScope, browser: BrowserBase, guid?: string) { @@ -37,11 +36,7 @@ export class BrowserDispatcher extends Dispatcher i } async newContext(params: BrowserNewContextParams): Promise<{ context: BrowserContextChannel }> { - const options = { - ...params, - extraHTTPHeaders: params.extraHTTPHeaders ? headersArrayToObject(params.extraHTTPHeaders) : undefined, - }; - return { context: new BrowserContextDispatcher(this._scope, await this._object.newContext(options) as BrowserContextBase) }; + return { context: new BrowserContextDispatcher(this._scope, await this._object.newContext(params) as BrowserContextBase) }; } async close(): Promise { diff --git a/src/rpc/server/browserTypeDispatcher.ts b/src/rpc/server/browserTypeDispatcher.ts index fcb0b74ff27f2..87b08a3d63bac 100644 --- a/src/rpc/server/browserTypeDispatcher.ts +++ b/src/rpc/server/browserTypeDispatcher.ts @@ -21,7 +21,6 @@ import { BrowserChannel, BrowserTypeChannel, BrowserContextChannel, BrowserTypeI import { Dispatcher, DispatcherScope } from './dispatcher'; import { BrowserContextBase } from '../../browserContext'; import { BrowserContextDispatcher } from './browserContextDispatcher'; -import { headersArrayToObject } from '../../converters'; export class BrowserTypeDispatcher extends Dispatcher implements BrowserTypeChannel { constructor(scope: DispatcherScope, browserType: BrowserTypeBase) { @@ -37,11 +36,7 @@ export class BrowserTypeDispatcher extends Dispatcher { - const options = { - ...params, - extraHTTPHeaders: params.extraHTTPHeaders ? headersArrayToObject(params.extraHTTPHeaders) : undefined, - }; - const browserContext = await this._object.launchPersistentContext(params.userDataDir, options); + const browserContext = await this._object.launchPersistentContext(params.userDataDir, params); return { context: new BrowserContextDispatcher(this._scope, browserContext as BrowserContextBase) }; } } diff --git a/src/rpc/server/networkDispatchers.ts b/src/rpc/server/networkDispatchers.ts index 95fbffcda6c6d..881d2c32cd671 100644 --- a/src/rpc/server/networkDispatchers.ts +++ b/src/rpc/server/networkDispatchers.ts @@ -18,7 +18,6 @@ import { Request, Response, Route } from '../../network'; import { RequestChannel, ResponseChannel, RouteChannel, ResponseInitializer, RequestInitializer, RouteInitializer, Binary } from '../channels'; import { Dispatcher, DispatcherScope, lookupNullableDispatcher, existingDispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; -import { headersObjectToArray, headersArrayToObject } from '../../converters'; import * as types from '../../types'; export class RequestDispatcher extends Dispatcher implements RequestChannel { @@ -40,7 +39,7 @@ export class RequestDispatcher extends Dispatcher i resourceType: request.resourceType(), method: request.method(), postData: postData === null ? undefined : postData.toString('base64'), - headers: headersObjectToArray(request.headers()), + headers: request.headers(), isNavigationRequest: request.isNavigationRequest(), redirectedFrom: RequestDispatcher.fromNullable(scope, request.redirectedFrom()), }); @@ -60,7 +59,7 @@ export class ResponseDispatcher extends Dispatcher impleme async continue(params: { method?: string, headers?: types.HeadersArray, postData?: string }): Promise { await this._object.continue({ method: params.method, - headers: params.headers ? headersArrayToObject(params.headers) : undefined, + headers: params.headers, postData: params.postData ? Buffer.from(params.postData, 'base64') : undefined, }); } async fulfill(params: types.NormalizedFulfillResponse): Promise { - await this._object.fulfill({ - status: params.status, - headers: params.headers ? headersArrayToObject(params.headers) : undefined, - body: params.isBase64 ? Buffer.from(params.body, 'base64') : params.body, - }); + await this._object.fulfill(params); } async abort(params: { errorCode?: string }): Promise { diff --git a/src/rpc/server/pageDispatcher.ts b/src/rpc/server/pageDispatcher.ts index 15c68d2f35ac5..56af53190353c 100644 --- a/src/rpc/server/pageDispatcher.ts +++ b/src/rpc/server/pageDispatcher.ts @@ -23,7 +23,6 @@ import * as types from '../../types'; import { BindingCallChannel, BindingCallInitializer, ElementHandleChannel, PageChannel, PageInitializer, ResponseChannel, WorkerInitializer, WorkerChannel, JSHandleChannel, Binary, SerializedArgument, PagePdfParams, SerializedError, PageAccessibilitySnapshotResult, SerializedValue, PageEmulateMediaParams, AXNode } from '../channels'; import { Dispatcher, DispatcherScope, lookupDispatcher, lookupNullableDispatcher } from './dispatcher'; import { parseError, serializeError } from '../serializers'; -import { headersArrayToObject } from '../../converters'; import { ConsoleMessageDispatcher } from './consoleMessageDispatcher'; import { DialogDispatcher } from './dialogDispatcher'; import { DownloadDispatcher } from './downloadDispatcher'; @@ -92,7 +91,7 @@ export class PageDispatcher extends Dispatcher implements } async setExtraHTTPHeaders(params: { headers: types.HeadersArray }): Promise { - await this._page.setExtraHTTPHeaders(headersArrayToObject(params.headers)); + await this._page.setExtraHTTPHeaders(params.headers); } async reload(params: types.NavigateOptions): Promise<{ response?: ResponseChannel }> { diff --git a/src/types.ts b/src/types.ts index e4c0f3a9b5241..b637843fc74fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,20 +208,12 @@ export type MouseMultiClickOptions = PointerActionOptions & { export type World = 'main' | 'utility'; -export type Headers = { [key: string]: string }; export type HeadersArray = { name: string, value: string }[]; export type GotoOptions = NavigateOptions & { referer?: string, }; -export type FulfillResponse = { - status?: number, - headers?: Headers, - contentType?: string, - body?: string | Buffer, -}; - export type NormalizedFulfillResponse = { status: number, headers: HeadersArray, @@ -229,12 +221,6 @@ export type NormalizedFulfillResponse = { isBase64: boolean, }; -export type ContinueOverrides = { - method?: string, - headers?: Headers, - postData?: string | Buffer, -}; - export type NormalizedContinueOverrides = { method?: string, headers?: HeadersArray, @@ -275,7 +261,7 @@ export type BrowserContextOptions = { timezoneId?: string, geolocation?: Geolocation, permissions?: string[], - extraHTTPHeaders?: Headers, + extraHTTPHeaders?: HeadersArray, offline?: boolean, httpCredentials?: Credentials, deviceScaleFactor?: number, diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index a6f77da9a25b9..46d1140285d3a 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -293,8 +293,8 @@ export class WKBrowserContext extends BrowserContextBase { await this._browser._browserSession.send('Playwright.setGeolocationOverride', { browserContextId: this._browserContextId, geolocation: payload }); } - async setExtraHTTPHeaders(headers: types.Headers): Promise { - this._options.extraHTTPHeaders = network.verifyHeaders(headers); + async setExtraHTTPHeaders(headers: types.HeadersArray): Promise { + this._options.extraHTTPHeaders = headers; for (const page of this.pages()) await (page._delegate as WKPage).updateExtraHTTPHeaders(); } diff --git a/src/webkit/wkInterceptableRequest.ts b/src/webkit/wkInterceptableRequest.ts index 1491ff1edb9a4..0809b66f29907 100644 --- a/src/webkit/wkInterceptableRequest.ts +++ b/src/webkit/wkInterceptableRequest.ts @@ -21,7 +21,7 @@ import * as network from '../network'; import * as types from '../types'; import { Protocol } from './protocol'; import { WKSession } from './wkConnection'; -import { headersArrayToObject } from '../converters'; +import { headersArrayToObject, headersObjectToArray } from '../converters'; const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { 'aborted': 'Cancellation', @@ -57,7 +57,7 @@ export class WKInterceptableRequest implements network.RouteDelegate { if (event.request.postData) postDataBuffer = Buffer.from(event.request.postData, 'binary'); this.request = new network.Request(allowInterception ? this : null, frame, redirectedFrom, documentId, event.request.url, - resourceType, event.request.method, postDataBuffer, headersObject(event.request.headers)); + resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); this._interceptedPromise = new Promise(f => this._interceptedCallback = f); } @@ -76,7 +76,7 @@ export class WKInterceptableRequest implements network.RouteDelegate { // In certain cases, protocol will return error if the request was already canceled // or the page was closed. We should tolerate these errors. let mimeType = response.isBase64 ? 'application/octet-stream' : 'text/plain'; - const headers = headersArrayToObject(response.headers); + const headers = headersArrayToObject(response.headers, false /* lowerCase */); const contentType = headers['content-type']; if (contentType) mimeType = contentType.split(';')[0].trim(); @@ -98,7 +98,7 @@ export class WKInterceptableRequest implements network.RouteDelegate { await this._session.sendMayFail('Network.interceptWithRequest', { requestId: this._requestId, method: overrides.method, - headers: overrides.headers ? headersArrayToObject(overrides.headers) : undefined, + headers: overrides.headers ? headersArrayToObject(overrides.headers, false /* lowerCase */) : undefined, postData: overrides.postData ? Buffer.from(overrides.postData).toString('base64') : undefined }); } @@ -108,13 +108,6 @@ export class WKInterceptableRequest implements network.RouteDelegate { const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId }); return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); }; - return new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObject(responsePayload.headers), getResponseBody); + return new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), getResponseBody); } } - -function headersObject(headers: Protocol.Network.Headers): types.Headers { - const result: types.Headers = {}; - for (const key of Object.keys(headers)) - result[key.toLowerCase()] = headers[key]; - return result; -} diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index a7202e0cc878d..29e040741f338 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -37,6 +37,7 @@ import { selectors } from '../selectors'; import * as jpeg from 'jpeg-js'; import * as png from 'pngjs'; import { JSHandle } from '../javascript'; +import { headersArrayToObject } from '../converters'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; @@ -175,7 +176,7 @@ export class WKPage implements PageDelegate { })); } promises.push(this.updateEmulateMedia()); - promises.push(session.send('Network.setExtraHTTPHeaders', { headers: this._calculateExtraHTTPHeaders() })); + promises.push(session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._calculateExtraHTTPHeaders(), false /* lowerCase */) })); if (contextOptions.offline) promises.push(session.send('Network.setEmulateOfflineState', { offline: true })); promises.push(session.send('Page.setTouchEmulationEnabled', { enabled: !!contextOptions.hasTouch })); @@ -551,17 +552,16 @@ export class WKPage implements PageDelegate { } async updateExtraHTTPHeaders(): Promise { - await this._updateState('Network.setExtraHTTPHeaders', { headers: this._calculateExtraHTTPHeaders() }); + await this._updateState('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._calculateExtraHTTPHeaders(), false /* lowerCase */) }); } - _calculateExtraHTTPHeaders(): types.Headers { + _calculateExtraHTTPHeaders(): types.HeadersArray { + const locale = this._browserContext._options.locale; const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, - this._page._state.extraHTTPHeaders + this._page._state.extraHTTPHeaders, + locale ? network.singleHeader('Accept-Language', locale) : undefined, ]); - const locale = this._browserContext._options.locale; - if (locale) - headers['Accept-Language'] = locale; return headers; } diff --git a/test/network-response.spec.ts b/test/network-response.spec.ts index 03bc26e4aface..8d442f53fd02a 100644 --- a/test/network-response.spec.ts +++ b/test/network-response.spec.ts @@ -22,10 +22,13 @@ import path from 'path'; it('should work', async({page, server}) => { server.setRoute('/empty.html', (req, res) => { res.setHeader('foo', 'bar'); + res.setHeader('BaZ', 'bAz'); res.end(); }); const response = await page.goto(server.EMPTY_PAGE); expect(response.headers()['foo']).toBe('bar'); + expect(response.headers()['baz']).toBe('bAz'); + expect(response.headers()['BaZ']).toBe(undefined); }); diff --git a/test/page-set-extra-http-headers.spec.ts b/test/page-set-extra-http-headers.spec.ts index 144d0554037cd..3c46625ec3cb9 100644 --- a/test/page-set-extra-http-headers.spec.ts +++ b/test/page-set-extra-http-headers.spec.ts @@ -18,13 +18,15 @@ import './base.fixture'; it('should work', async({page, server}) => { await page.setExtraHTTPHeaders({ - foo: 'bar' + foo: 'bar', + baz: undefined, }); const [request] = await Promise.all([ server.waitForRequest('/empty.html'), page.goto(server.EMPTY_PAGE), ]); expect(request.headers['foo']).toBe('bar'); + expect(request.headers['baz']).toBe(undefined); }); it('should work with redirects', async({page, server}) => { @@ -70,12 +72,11 @@ it('should override extra headers from browser context', async({browser, server} expect(request.headers['bar']).toBe('foO'); }); -it('should throw for non-string header values', async({page, server}) => { - let error = null; - try { - await page.setExtraHTTPHeaders({ 'foo': 1 as any }); - } catch (e) { - error = e; - } - expect(error.message).toContain('Expected value of header "foo" to be String, but "number" is found.'); +it('should throw for non-string header values', async({browser, page}) => { + const error1 = await page.setExtraHTTPHeaders({ 'foo': 1 as any }).catch(e => e); + expect(error1.message).toContain('Expected value of header "foo" to be String, but "number" is found.'); + const error2 = await page.context().setExtraHTTPHeaders({ 'foo': true as any }).catch(e => e); + expect(error2.message).toContain('Expected value of header "foo" to be String, but "boolean" is found.'); + const error3 = await browser.newContext({ extraHTTPHeaders: { 'foo': null as any } }).catch(e => e); + expect(error3.message).toContain('Expected value of header "foo" to be String, but "object" is found.'); });