diff --git a/docs/api.md b/docs/api.md index 8faa0f7f94c21..150c142e2d4c0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -199,6 +199,7 @@ Indicates that the browser is connected. - `accuracy` <[number]> Optional non-negative accuracy value. - `locale` Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, `Accept-Language` request header value as well as number and date formatting rules. - `permissions` <[Object]> A map from origin keys to permissions values. See [browserContext.setPermissions](#browsercontextsetpermissionsorigin-permissions) for more details. + - `extraHTTPHeaders` <[Object]> An object containing additional HTTP headers to be sent with every request. All header values must be strings. - returns: <[Promise]<[BrowserContext]>> Creates a new browser context. It won't share cookies/cache with other browser contexts. @@ -232,6 +233,7 @@ Creates a new browser context. It won't share cookies/cache with other browser c - `accuracy` <[number]> Optional non-negative accuracy value. - `locale` Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, `Accept-Language` request header value as well as number and date formatting rules. - `permissions` <[Object]> A map from origin keys to permissions values. See [browserContext.setPermissions](#browsercontextsetpermissionsorigin-permissions) for more details. + - `extraHTTPHeaders` <[Object]> An object containing additional HTTP headers to be sent with every request. All header values must be strings. - returns: <[Promise]<[Page]>> Creates a new page in a new browser context. Closing this page will close the context as well. @@ -271,6 +273,7 @@ await context.close(); - [browserContext.setCookies(cookies)](#browsercontextsetcookiescookies) - [browserContext.setDefaultNavigationTimeout(timeout)](#browsercontextsetdefaultnavigationtimeouttimeout) - [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) +- [browserContext.setExtraHTTPHeaders(headers)](#browsercontextsetextrahttpheadersheaders) - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setPermissions(origin, permissions[])](#browsercontextsetpermissionsorigin-permissions) @@ -374,6 +377,14 @@ This setting will change the default maximum time for all the methods accepting > **NOTE** [`page.setDefaultNavigationTimeout`](#pagesetdefaultnavigationtimeouttimeout), [`page.setDefaultTimeout`](#pagesetdefaulttimeouttimeout) and [`browserContext.setDefaultNavigationTimeout`](#browsercontextsetdefaultnavigationtimeouttimeout) take priority over [`browserContext.setDefaultTimeout`](#browserContextsetdefaulttimeouttimeout). +#### browserContext.setExtraHTTPHeaders(headers) +- `headers` <[Object]> An object containing additional HTTP headers to be sent with every request. All header values must be strings. +- returns: <[Promise]> + +The extra HTTP headers will be sent with every request initiated by any page in the context. These headers are merged with page-specific extra HTTP headers set with [page.setExtraHTTPHeaders()](#pagesetextrahttpheadersheaders). If page overrides a particular header, page-specific header value will be used instead of the browser context header value. + +> **NOTE** `browserContext.setExtraHTTPHeaders` does not guarantee the order of headers in the outgoing requests. + #### browserContext.setGeolocation(geolocation) - `geolocation` <[Object]> - `latitude` <[number]> Latitude between -90 and 90. @@ -3582,6 +3593,7 @@ const backgroundPage = await backroundPageTarget.page(); - [browserContext.setCookies(cookies)](#browsercontextsetcookiescookies) - [browserContext.setDefaultNavigationTimeout(timeout)](#browsercontextsetdefaultnavigationtimeouttimeout) - [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) +- [browserContext.setExtraHTTPHeaders(headers)](#browsercontextsetextrahttpheadersheaders) - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setPermissions(origin, permissions[])](#browsercontextsetpermissionsorigin-permissions) diff --git a/package.json b/package.json index c6152a4d82d8d..b4c1da986e011 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "main": "index.js", "playwright": { "chromium_revision": "744254", - "firefox_revision": "1029", + "firefox_revision": "1031", "webkit_revision": "1155" }, "scripts": { diff --git a/src/browserContext.ts b/src/browserContext.ts index e0744e2f2a2d6..e7a735eaeec21 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -30,7 +30,8 @@ export type BrowserContextOptions = { locale?: string, timezoneId?: string, geolocation?: types.Geolocation, - permissions?: { [key: string]: string[] }; + permissions?: { [key: string]: string[] }, + extraHTTPHeaders?: network.Headers, }; export interface BrowserContext { @@ -44,6 +45,7 @@ export interface BrowserContext { setPermissions(origin: string, permissions: string[]): Promise; clearPermissions(): Promise; setGeolocation(geolocation: types.Geolocation | null): Promise; + setExtraHTTPHeaders(headers: network.Headers): Promise; close(): Promise; _existingPages(): Page[]; @@ -67,6 +69,8 @@ export function validateBrowserContextOptions(options: BrowserContextOptions): B result.viewport = { ...result.viewport }; if (result.geolocation) result.geolocation = verifyGeolocation(result.geolocation); + if (result.extraHTTPHeaders) + result.extraHTTPHeaders = network.verifyHeaders(result.extraHTTPHeaders); return result; } diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 37e85f163b2e5..13d6ad14c4193 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -294,6 +294,12 @@ export class CRBrowserContext extends platform.EventEmitter implements BrowserCo await (page._delegate as CRPage)._client.send('Emulation.setGeolocationOverride', geolocation || {}); } + async setExtraHTTPHeaders(headers: network.Headers): Promise { + this._options.extraHTTPHeaders = network.verifyHeaders(headers); + for (const page of this._existingPages()) + await (page._delegate as CRPage).updateExtraHTTPHeaders(); + } + async close() { if (this._closed) return; diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index b15e4a61564d7..8d0aaddf03925 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -118,6 +118,7 @@ export class CRPage implements PageDelegate { promises.push(emulateTimezone(this._client, options.timezoneId)); if (options.geolocation) promises.push(this._client.send('Emulation.setGeolocationOverride', options.geolocation)); + promises.push(this.updateExtraHTTPHeaders()); await Promise.all(promises); } @@ -316,7 +317,11 @@ export class CRPage implements PageDelegate { this._page._onFileChooserOpened(handle); } - async setExtraHTTPHeaders(headers: network.Headers): Promise { + async updateExtraHTTPHeaders(): Promise { + const headers = network.mergeHeaders([ + this._page.context()._options.extraHTTPHeaders, + this._page._state.extraHTTPHeaders + ]); await this._client.send('Network.setExtraHTTPHeaders', { headers }); } diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts index 8af9ff804cd4c..1e21c139196ab 100644 --- a/src/firefox/ffBrowser.ts +++ b/src/firefox/ffBrowser.ts @@ -28,6 +28,7 @@ import * as platform from '../platform'; import { Protocol } from './protocol'; import { ConnectionTransport, SlowMoTransport } from '../transport'; import { TimeoutSettings } from '../timeoutSettings'; +import { headersArray } from './ffNetworkManager'; export class FFBrowser extends platform.EventEmitter implements Browser { _connection: FFConnection; @@ -274,6 +275,8 @@ export class FFBrowserContext extends platform.EventEmitter implements BrowserCo await Promise.all(entries.map(entry => this.setPermissions(entry[0], entry[1]))); if (this._options.geolocation) await this.setGeolocation(this._options.geolocation); + if (this._options.extraHTTPHeaders) + await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders); } _existingPages(): Page[] { @@ -349,6 +352,11 @@ export class FFBrowserContext extends platform.EventEmitter implements BrowserCo throw new Error('Geolocation emulation is not supported in Firefox'); } + async setExtraHTTPHeaders(headers: network.Headers): Promise { + this._options.extraHTTPHeaders = network.verifyHeaders(headers); + await this._browser._connection.send('Browser.setExtraHTTPHeaders', { browserContextId: this._browserContextId || undefined, headers: headersArray(this._options.extraHTTPHeaders) }); + } + async close() { if (this._closed) return; diff --git a/src/firefox/ffNetworkManager.ts b/src/firefox/ffNetworkManager.ts index c513622c29043..b117492cad02a 100644 --- a/src/firefox/ffNetworkManager.ts +++ b/src/firefox/ffNetworkManager.ts @@ -208,7 +208,7 @@ class InterceptableRequest implements network.RequestDelegate { } } -function headersArray(headers: network.Headers): Protocol.Network.HTTPHeader[] { +export function headersArray(headers: network.Headers): Protocol.Network.HTTPHeader[] { const result: Protocol.Network.HTTPHeader[] = []; for (const name in headers) { if (!Object.is(headers[name], undefined)) diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index a819c48170c6f..dcbfc71a3f232 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -21,14 +21,13 @@ import * as dom from '../dom'; import { FFSession } from './ffConnection'; import { FFExecutionContext } from './ffExecutionContext'; import { Page, PageDelegate, Worker } from '../page'; -import { FFNetworkManager } from './ffNetworkManager'; +import { FFNetworkManager, headersArray } from './ffNetworkManager'; import { Events } from '../events'; import * as dialog from '../dialog'; import { Protocol } from './protocol'; import { RawMouseImpl, RawKeyboardImpl } from './ffInput'; import { BrowserContext } from '../browserContext'; import { getAccessibilityTree } from './ffAccessibility'; -import * as network from '../network'; import * as types from '../types'; import * as platform from '../platform'; import { kScreenshotDuringNavigationError } from '../screenshotter'; @@ -251,11 +250,8 @@ export class FFPage implements PageDelegate { return { newDocumentId: response.navigationId || undefined }; } - async setExtraHTTPHeaders(headers: network.Headers): Promise { - const array = []; - for (const [name, value] of Object.entries(headers)) - array.push({ name, value }); - await this._session.send('Network.setExtraHTTPHeaders', { headers: array }); + async updateExtraHTTPHeaders(): Promise { + await this._session.send('Network.setExtraHTTPHeaders', { headers: headersArray(this._page._state.extraHTTPHeaders || {}) }); } async setViewportSize(viewportSize: types.Size): Promise { diff --git a/src/network.ts b/src/network.ts index 1aa5518e04bd9..e81314658062f 100644 --- a/src/network.ts +++ b/src/network.ts @@ -15,7 +15,7 @@ */ import * as frames from './frames'; -import { assert } from './helper'; +import { assert, helper } from './helper'; import * as platform from './platform'; export type NetworkCookie = { @@ -373,3 +373,31 @@ export const STATUS_TEXTS: { [status: string]: string } = { '510': 'Not Extended', '511': 'Network Authentication Required', }; + +export function verifyHeaders(headers: Headers): Headers { + const result: 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 mergeHeaders(headers: (Headers | undefined | null)[]): Headers { + 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]); + } + } + const result: Headers = {}; + for (const [lower, value] of lowerCaseToValue) + result[lowerCaseToOriginalCase.get(lower)!] = value; + return result; +} diff --git a/src/page.ts b/src/page.ts index 033ad57487d6b..64fe314a0f67d 100644 --- a/src/page.ts +++ b/src/page.ts @@ -45,7 +45,7 @@ export interface PageDelegate { navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise; - setExtraHTTPHeaders(extraHTTPHeaders: network.Headers): Promise; + updateExtraHTTPHeaders(): Promise; setViewportSize(viewportSize: types.Size): Promise; setEmulateMedia(mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null): Promise; setCacheEnabled(enabled: boolean): Promise; @@ -268,13 +268,8 @@ export class Page extends platform.EventEmitter { } setExtraHTTPHeaders(headers: network.Headers) { - this._state.extraHTTPHeaders = {}; - 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.`); - this._state.extraHTTPHeaders[key] = value; - } - return this._delegate.setExtraHTTPHeaders(headers); + this._state.extraHTTPHeaders = network.verifyHeaders(headers); + return this._delegate.updateExtraHTTPHeaders(); } async _onBindingCalled(payload: string, context: js.ExecutionContext) { diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index 2a62a6f3256ce..3ccd842d4a250 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -28,6 +28,7 @@ import { WKConnection, WKSession, kPageProxyMessageReceived, PageProxyMessageRec import { WKPageProxy } from './wkPageProxy'; import * as platform from '../platform'; import { TimeoutSettings } from '../timeoutSettings'; +import { WKPage } from './wkPage'; const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Safari/605.1.15'; @@ -269,6 +270,12 @@ export class WKBrowserContext extends platform.EventEmitter implements BrowserCo await this._browser._browserSession.send('Browser.setGeolocationOverride', { browserContextId: this._browserContextId, geolocation: payload }); } + async setExtraHTTPHeaders(headers: network.Headers): Promise { + this._options.extraHTTPHeaders = network.verifyHeaders(headers); + for (const page of this._existingPages()) + await (page._delegate as WKPage).updateExtraHTTPHeaders(); + } + async close() { if (this._closed) return; diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 88b8c2699b479..02a1693e7c08a 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -142,12 +142,7 @@ export class WKPage implements PageDelegate { } if (contextOptions.bypassCSP) promises.push(session.send('Page.setBypassCSP', { enabled: true })); - if (this._page._state.extraHTTPHeaders || contextOptions.locale) { - const headers = this._page._state.extraHTTPHeaders || {}; - if (contextOptions.locale) - headers['Accept-Language'] = contextOptions.locale; - promises.push(session.send('Network.setExtraHTTPHeaders', { headers })); - } + promises.push(session.send('Network.setExtraHTTPHeaders', { headers: this._calculateExtraHTTPHeaders() })); if (this._page._state.hasTouch) promises.push(session.send('Page.setTouchEmulationEnabled', { enabled: true })); if (contextOptions.timezoneId) { @@ -378,12 +373,19 @@ export class WKPage implements PageDelegate { await Promise.all(promises); } - async setExtraHTTPHeaders(headers: network.Headers): Promise { - const copy = { ...headers }; + async updateExtraHTTPHeaders(): Promise { + await this._updateState('Network.setExtraHTTPHeaders', { headers: this._calculateExtraHTTPHeaders() }); + } + + _calculateExtraHTTPHeaders(): network.Headers { + const headers = network.mergeHeaders([ + this._page.context()._options.extraHTTPHeaders, + this._page._state.extraHTTPHeaders + ]); const locale = this._page.context()._options.locale; if (locale) - copy['Accept-Language'] = locale; - await this._updateState('Network.setExtraHTTPHeaders', { headers: copy }); + headers['Accept-Language'] = locale; + return headers; } async setEmulateMedia(mediaType: types.MediaType | null, colorScheme: types.ColorScheme | null): Promise { diff --git a/test/network.spec.js b/test/network.spec.js index 93ec7d7b57520..17a339e2c38c1 100644 --- a/test/network.spec.js +++ b/test/network.spec.js @@ -331,6 +331,35 @@ module.exports.describe = function({testRunner, expect, MAC, WIN, FFOX, CHROMIUM ]); expect(request.headers['foo']).toBe('bar'); }); + it('should work with extra headers from browser context', async({browser, server}) => { + const context = await browser.newContext(); + await context.setExtraHTTPHeaders({ + 'foo': 'bar', + }); + const page = await context.newPage(); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + await context.close(); + expect(request.headers['foo']).toBe('bar'); + }); + it('should override extra headers from browser context', async({browser, server}) => { + const context = await browser.newContext({ + extraHTTPHeaders: { 'fOo': 'bAr', 'baR': 'foO' }, + }); + const page = await context.newPage(); + await page.setExtraHTTPHeaders({ + 'Foo': 'Bar' + }); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + await context.close(); + expect(request.headers['foo']).toBe('Bar'); + expect(request.headers['bar']).toBe('foO'); + }); it('should throw for non-string header values', async({page, server}) => { let error = null; try { diff --git a/test/popup.spec.js b/test/popup.spec.js index a19d956d65f40..cc251d61e50bb 100644 --- a/test/popup.spec.js +++ b/test/popup.spec.js @@ -36,6 +36,18 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE expect(userAgent).toBe('hey'); expect(request.headers['user-agent']).toBe('hey'); }); + it.skip(CHROMIUM)('should inherit extra headers from browser context', async function({browser, server}) { + const context = await browser.newContext({ + extraHTTPHeaders: { 'foo': 'bar' }, + }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const requestPromise = server.waitForRequest('/dummy.html'); + await page.evaluate(url => window._popup = window.open(url), server.PREFIX + '/dummy.html'); + const request = await requestPromise; + await context.close(); + expect(request.headers['foo']).toBe('bar'); + }); it.skip(CHROMIUM)('should inherit touch support from browser context', async function({browser, server}) { const context = await browser.newContext({ viewport: { width: 400, height: 500, isMobile: true }