From d20e56e197dd5e64d8d4503db8c83e76cdb78930 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Nov 2020 14:24:53 -0800 Subject: [PATCH] feat(state): allow getting / setting context state (#4412) --- docs/api.md | 53 +++++++ package-lock.json | 2 +- src/client/browserContext.ts | 8 +- src/client/types.ts | 10 +- src/dispatchers/browserContextDispatcher.ts | 4 + src/dispatchers/browserDispatcher.ts | 5 +- src/dispatchers/pageDispatcher.ts | 4 +- src/protocol/channels.ts | 151 ++++++++---------- src/protocol/protocol.yml | 160 ++++++++++---------- src/protocol/validator.ts | 91 ++++++----- src/server/browserContext.ts | 51 +++++++ src/server/frames.ts | 4 +- src/server/page.ts | 30 +++- src/server/types.ts | 20 +++ test/browsercontext-storage-state.spec.ts | 76 ++++++++++ 15 files changed, 445 insertions(+), 224 deletions(-) create mode 100644 test/browsercontext-storage-state.spec.ts diff --git a/docs/api.md b/docs/api.md index a0af7c6fff..474263dbf0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -240,6 +240,22 @@ Indicates that the browser is connected. - `size` <[Object]> Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of each page will be scaled down if necessary to fit the specified size. - `width` <[number]> Video frame width. - `height` <[number]> Video frame height. + - `storageState` <[Object]> Populates context with given storage state. This method can be used to initialize context with logged-in information obtained via [browserContext.storageState()](#browsercontextstoragestate). + - `cookies` <[Array]<[Object]>> Optional cookies to set for context + - `name` <[string]> **required** + - `value` <[string]> **required** + - `url` <[string]> Optional either url or domain / path are required + - `domain` <[string]> Optional either url or domain / path are required + - `path` <[string]> Optional either url or domain / path are required + - `expires` <[number]> Optional Unix time in seconds. + - `httpOnly` <[boolean]> Optional httpOnly flag + - `secure` <[boolean]> Optional secure flag + - `sameSite` <"Strict"|"Lax"|"None"> Optional sameSite flag + - `origins` <[Array]<[Object]>> Optional localStorage to set for context + - `origin` <[string]> + - `localStorage` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> - returns: <[Promise]<[BrowserContext]>> Creates a new browser context. It won't share cookies/cache with other browser contexts. @@ -299,6 +315,22 @@ Creates a new browser context. It won't share cookies/cache with other browser c - `size` <[Object]> Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of each page will be scaled down if necessary to fit the specified size. - `width` <[number]> Video frame width. - `height` <[number]> Video frame height. + - `storageState` <[Object]> Populates context with given storage state. This method can be used to initialize context with logged-in information obtained via [browserContext.storageState()](#browsercontextstoragestate). + - `cookies` <[Array]<[Object]>> Optional cookies to set for context + - `name` <[string]> **required** + - `value` <[string]> **required** + - `url` <[string]> Optional either url or domain / path are required + - `domain` <[string]> Optional either url or domain / path are required + - `path` <[string]> Optional either url or domain / path are required + - `expires` <[number]> Optional Unix time in seconds. + - `httpOnly` <[boolean]> Optional httpOnly flag + - `secure` <[boolean]> Optional secure flag + - `sameSite` <"Strict"|"Lax"|"None"> Optional sameSite flag + - `origins` <[Array]<[Object]>> Optional localStorage to set for context + - `origin` <[string]> + - `localStorage` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> - returns: <[Promise]<[Page]>> Creates a new page in a new browser context. Closing this page will close the context as well. @@ -355,6 +387,7 @@ await context.close(); - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) +- [browserContext.storageState()](#browsercontextstoragestate) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) @@ -680,6 +713,25 @@ Provide credentials for [HTTP authentication](https://developer.mozilla.org/en-U - `offline` <[boolean]> Whether to emulate network being offline for the browser context. - returns: <[Promise]> +#### browserContext.storageState() +- returns: <[Promise]<[Object]>> + - `cookies` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> + - `domain` <[string]> + - `path` <[string]> + - `expires` <[number]> Unix time in seconds. + - `httpOnly` <[boolean]> + - `secure` <[boolean]> + - `sameSite` <"Strict"|"Lax"|"None"> + - `origins` <[Array]<[Object]>> + - `origin` <[string]> + - `localStorage` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> + +Returns storage state for this browser context, contains current cookies and local storage snapshot. + #### browserContext.unroute(url[, handler]) - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern or predicate receiving [URL] used to register a routing with [browserContext.route(url, handler)](#browsercontextrouteurl-handler). - `handler` <[function]\([Route], [Request]\)> Handler function used to register a routing with [browserContext.route(url, handler)](#browsercontextrouteurl-handler). @@ -4686,6 +4738,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage'); - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) +- [browserContext.storageState()](#browsercontextstoragestate) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) diff --git a/package-lock.json b/package-lock.json index 6035098720..a894d2e0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "playwright-internal", - "version": "1.6.0-next", + "version": "1.7.0-next", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/client/browserContext.ts b/src/client/browserContext.ts index a6ddd15e57..f11088b097 100644 --- a/src/client/browserContext.ts +++ b/src/client/browserContext.ts @@ -24,7 +24,7 @@ import { Browser } from './browser'; import { Events } from './events'; import { TimeoutSettings } from '../utils/timeoutSettings'; import { Waiter } from './waiter'; -import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions } from './types'; +import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState } from './types'; import { isUnderTest, headersObjectToArray } from '../utils/utils'; import { isSafeCloseError } from '../utils/errors'; @@ -219,6 +219,12 @@ export class BrowserContext extends ChannelOwner { + return await this._wrapApiCall('browserContext.storageState', async () => { + return await this._channel.storageState(); + }); + } + async _onClose() { if (this._browser) this._browser._contexts.delete(this); diff --git a/src/client/types.ts b/src/client/types.ts index 49d0b0e970..252621e860 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -36,6 +36,14 @@ export type WaitForFunctionOptions = { timeout?: number, polling?: 'raf' | numbe export type SelectOption = { value?: string, label?: string, index?: number }; export type SelectOptionOptions = { timeout?: number, noWaitAfter?: boolean }; export type FilePayload = { name: string, mimeType: string, buffer: Buffer }; +export type StorageState = { + cookies: channels.NetworkCookie[], + origins: channels.OriginStorage[] +}; +export type SetStorageState = { + cookies?: channels.SetNetworkCookie[], + origins?: channels.OriginStorage[] +}; export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle'; export const kLifecycleEvents: Set = new Set(['load', 'domcontentloaded', 'networkidle']); @@ -58,7 +66,7 @@ type FirefoxUserPrefs = { }; type LaunchOptionsBase = Omit & LaunchOverrides; export type LaunchOptions = LaunchOptionsBase & FirefoxUserPrefs; -export type LaunchPersistentContextOptions = LaunchOptionsBase & BrowserContextOptions; +export type LaunchPersistentContextOptions = Omit; export type ConnectOptions = { wsEndpoint: string, diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index a2d99c24d6..31b53971d4 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -117,6 +117,10 @@ export class BrowserContextDispatcher extends Dispatcher { + return await this._context.storageState(); + } + async close(): Promise { await this._context.close(); } diff --git a/src/dispatchers/browserDispatcher.ts b/src/dispatchers/browserDispatcher.ts index f035ea9655..5dc669f3cd 100644 --- a/src/dispatchers/browserDispatcher.ts +++ b/src/dispatchers/browserDispatcher.ts @@ -34,7 +34,10 @@ export class BrowserDispatcher extends Dispatcher { - return { context: new BrowserContextDispatcher(this._scope, await this._object.newContext(params)) }; + const context = await this._object.newContext(params); + if (params.storageState) + await context.setStorageState(params.storageState); + return { context: new BrowserContextDispatcher(this._scope, context) }; } async close(): Promise { diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index 1bd863ba5c..b2857ef898 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -138,10 +138,10 @@ export class PageDispatcher extends Dispatcher i async setNetworkInterceptionEnabled(params: channels.PageSetNetworkInterceptionEnabledParams): Promise { if (!params.enabled) { - await this._page._setRequestInterceptor(undefined); + await this._page._setClientRequestInterceptor(undefined); return; } - this._page._setRequestInterceptor((route, request) => { + this._page._setClientRequestInterceptor((route, request) => { this._dispatchEvent('route', { route: new RouteDispatcher(this._scope, route), request: RequestDispatcher.from(this._scope, request) }); }); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 082b659db4..48690803ea 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -80,6 +80,39 @@ export type AXNode = { children?: AXNode[], }; +export type SetNetworkCookie = { + name: string, + value: string, + url?: string, + domain?: string, + path?: string, + expires?: number, + httpOnly?: boolean, + secure?: boolean, + sameSite?: 'Strict' | 'Lax' | 'None', +}; + +export type NetworkCookie = { + name: string, + value: string, + domain: string, + path: string, + expires: number, + httpOnly: boolean, + secure: boolean, + sameSite: 'Strict' | 'Lax' | 'None', +}; + +export type NameValue = { + name: string, + value: string, +}; + +export type OriginStorage = { + origin: string, + localStorage: NameValue[], +}; + export type SerializedError = { error?: { message: string, @@ -162,10 +195,7 @@ export type BrowserTypeLaunchParams = { handleSIGTERM?: boolean, handleSIGHUP?: boolean, timeout?: number, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], headless?: boolean, devtools?: boolean, proxy?: { @@ -188,10 +218,7 @@ export type BrowserTypeLaunchOptions = { handleSIGTERM?: boolean, handleSIGHUP?: boolean, timeout?: number, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], headless?: boolean, devtools?: boolean, proxy?: { @@ -218,10 +245,7 @@ export type BrowserTypeLaunchPersistentContextParams = { handleSIGTERM?: boolean, handleSIGHUP?: boolean, timeout?: number, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], headless?: boolean, devtools?: boolean, proxy?: { @@ -250,10 +274,7 @@ export type BrowserTypeLaunchPersistentContextParams = { accuracy?: number, }, permissions?: string[], - extraHTTPHeaders?: { - name: string, - value: string, - }[], + extraHTTPHeaders?: NameValue[], offline?: boolean, httpCredentials?: { username: string, @@ -287,10 +308,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { handleSIGTERM?: boolean, handleSIGHUP?: boolean, timeout?: number, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], headless?: boolean, devtools?: boolean, proxy?: { @@ -319,10 +337,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { accuracy?: number, }, permissions?: string[], - extraHTTPHeaders?: { - name: string, - value: string, - }[], + extraHTTPHeaders?: NameValue[], offline?: boolean, httpCredentials?: { username: string, @@ -386,10 +401,7 @@ export type BrowserNewContextParams = { accuracy?: number, }, permissions?: string[], - extraHTTPHeaders?: { - name: string, - value: string, - }[], + extraHTTPHeaders?: NameValue[], offline?: boolean, httpCredentials?: { username: string, @@ -419,6 +431,10 @@ export type BrowserNewContextParams = { username?: string, password?: string, }, + storageState?: { + cookies?: SetNetworkCookie[], + origins?: OriginStorage[], + }, }; export type BrowserNewContextOptions = { noDefaultViewport?: boolean, @@ -438,10 +454,7 @@ export type BrowserNewContextOptions = { accuracy?: number, }, permissions?: string[], - extraHTTPHeaders?: { - name: string, - value: string, - }[], + extraHTTPHeaders?: NameValue[], offline?: boolean, httpCredentials?: { username: string, @@ -471,6 +484,10 @@ export type BrowserNewContextOptions = { username?: string, password?: string, }, + storageState?: { + cookies?: SetNetworkCookie[], + origins?: OriginStorage[], + }, }; export type BrowserNewContextResult = { context: BrowserContextChannel, @@ -526,6 +543,7 @@ export interface BrowserContextChannel extends Channel { setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: Metadata): Promise; setNetworkInterceptionEnabled(params: BrowserContextSetNetworkInterceptionEnabledParams, metadata?: Metadata): Promise; setOffline(params: BrowserContextSetOfflineParams, metadata?: Metadata): Promise; + storageState(params?: BrowserContextStorageStateParams, metadata?: Metadata): Promise; crNewCDPSession(params: BrowserContextCrNewCDPSessionParams, metadata?: Metadata): Promise; } export type BrowserContextBindingCallEvent = { @@ -546,17 +564,7 @@ export type BrowserContextCrServiceWorkerEvent = { worker: WorkerChannel, }; export type BrowserContextAddCookiesParams = { - cookies: { - name: string, - value: string, - url?: string, - domain?: string, - path?: string, - expires?: number, - httpOnly?: boolean, - secure?: boolean, - sameSite?: 'Strict' | 'Lax' | 'None', - }[], + cookies: SetNetworkCookie[], }; export type BrowserContextAddCookiesOptions = { @@ -585,16 +593,7 @@ export type BrowserContextCookiesOptions = { }; export type BrowserContextCookiesResult = { - cookies: { - name: string, - value: string, - domain: string, - path: string, - expires: number, - httpOnly: boolean, - secure: boolean, - sameSite: 'Strict' | 'Lax' | 'None', - }[], + cookies: NetworkCookie[], }; export type BrowserContextExposeBindingParams = { name: string, @@ -632,10 +631,7 @@ export type BrowserContextSetDefaultTimeoutNoReplyOptions = { }; export type BrowserContextSetDefaultTimeoutNoReplyResult = void; export type BrowserContextSetExtraHTTPHeadersParams = { - headers: { - name: string, - value: string, - }[], + headers: NameValue[], }; export type BrowserContextSetExtraHTTPHeadersOptions = { @@ -683,6 +679,12 @@ export type BrowserContextSetOfflineOptions = { }; export type BrowserContextSetOfflineResult = void; +export type BrowserContextStorageStateParams = {}; +export type BrowserContextStorageStateOptions = {}; +export type BrowserContextStorageStateResult = { + cookies: NetworkCookie[], + origins: OriginStorage[], +}; export type BrowserContextCrNewCDPSessionParams = { page: PageChannel, }; @@ -938,10 +940,7 @@ export type PageScreenshotResult = { binary: Binary, }; export type PageSetExtraHTTPHeadersParams = { - headers: { - name: string, - value: string, - }[], + headers: NameValue[], }; export type PageSetExtraHTTPHeadersOptions = { @@ -2141,36 +2140,24 @@ export type RouteAbortOptions = { export type RouteAbortResult = void; export type RouteContinueParams = { method?: string, - headers?: { - name: string, - value: string, - }[], + headers?: NameValue[], postData?: Binary, }; export type RouteContinueOptions = { method?: string, - headers?: { - name: string, - value: string, - }[], + headers?: NameValue[], postData?: Binary, }; export type RouteContinueResult = void; export type RouteFulfillParams = { status?: number, - headers?: { - name: string, - value: string, - }[], + headers?: NameValue[], body?: string, isBase64?: boolean, }; export type RouteFulfillOptions = { status?: number, - headers?: { - name: string, - value: string, - }[], + headers?: NameValue[], body?: string, isBase64?: boolean, }; @@ -2401,10 +2388,7 @@ export type ElectronLaunchParams = { executablePath: string, args?: string[], cwd?: string, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], handleSIGINT?: boolean, handleSIGTERM?: boolean, handleSIGHUP?: boolean, @@ -2413,10 +2397,7 @@ export type ElectronLaunchParams = { export type ElectronLaunchOptions = { args?: string[], cwd?: string, - env?: { - name: string, - value: string, - }[], + env?: NameValue[], handleSIGINT?: boolean, handleSIGTERM?: boolean, handleSIGHUP?: boolean, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 64fcc4d3d9..812f77b5ec 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -113,6 +113,59 @@ AXNode: items: AXNode +SetNetworkCookie: + type: object + properties: + name: string + value: string + url: string? + domain: string? + path: string? + expires: number? + httpOnly: boolean? + secure: boolean? + sameSite: + type: enum? + literals: + - Strict + - Lax + - None + + +NetworkCookie: + type: object + properties: + name: string + value: string + domain: string + path: string + expires: number + httpOnly: boolean + secure: boolean + sameSite: + type: enum + literals: + - Strict + - Lax + - None + + +NameValue: + type: object + properties: + name: string + value: string + + +OriginStorage: + type: object + properties: + origin: string + localStorage: + type: array + items: NameValue + + SerializedError: type: object properties: @@ -216,11 +269,7 @@ BrowserType: timeout: number? env: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue headless: boolean? devtools: boolean? proxy: @@ -254,11 +303,7 @@ BrowserType: timeout: number? env: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue headless: boolean? devtools: boolean? proxy: @@ -294,11 +339,7 @@ BrowserType: items: string extraHTTPHeaders: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue offline: boolean? httpCredentials: type: object? @@ -371,11 +412,7 @@ Browser: items: string extraHTTPHeaders: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue offline: boolean? httpCredentials: type: object? @@ -415,6 +452,15 @@ Browser: bypass: string? username: string? password: string? + storageState: + type: object? + properties: + cookies: + type: array? + items: SetNetworkCookie + origins: + type: array? + items: OriginStorage returns: context: BrowserContext @@ -453,23 +499,7 @@ BrowserContext: parameters: cookies: type: array - items: - type: object - properties: - name: string - value: string - url: string? - domain: string? - path: string? - expires: number? - httpOnly: boolean? - secure: boolean? - sameSite: - type: enum? - literals: - - Strict - - Lax - - None + items: SetNetworkCookie addInitScript: parameters: @@ -489,22 +519,7 @@ BrowserContext: returns: cookies: type: array - items: - type: object - properties: - name: string - value: string - domain: string - path: string - expires: number - httpOnly: boolean - secure: boolean - sameSite: - type: enum - literals: - - Strict - - Lax - - None + items: NetworkCookie exposeBinding: parameters: @@ -534,11 +549,7 @@ BrowserContext: parameters: headers: type: array - items: - type: object - properties: - name: string - value: string + items: NameValue setGeolocation: parameters: @@ -565,6 +576,15 @@ BrowserContext: parameters: offline: boolean + storageState: + returns: + cookies: + type: array + items: NetworkCookie + origins: + type: array + items: OriginStorage + crNewCDPSession: parameters: page: Page @@ -721,11 +741,7 @@ Page: parameters: headers: type: array - items: - type: object - properties: - name: string - value: string + items: NameValue setNetworkInterceptionEnabled: parameters: @@ -1797,11 +1813,7 @@ Route: method: string? headers: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue postData: binary? fulfill: @@ -1810,11 +1822,7 @@ Route: status: number? headers: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue body: string? isBase64: boolean? @@ -2038,11 +2046,7 @@ Electron: cwd: string? env: type: array? - items: - type: object - properties: - name: string - value: string + items: NameValue handleSIGINT: boolean? handleSIGTERM: boolean? handleSIGHUP: boolean? diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index a6c46450ef..a9ced7bbc0 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -86,6 +86,35 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { orientation: tOptional(tString), children: tOptional(tArray(tType('AXNode'))), }); + scheme.SetNetworkCookie = tObject({ + name: tString, + value: tString, + url: tOptional(tString), + domain: tOptional(tString), + path: tOptional(tString), + expires: tOptional(tNumber), + httpOnly: tOptional(tBoolean), + secure: tOptional(tBoolean), + sameSite: tOptional(tEnum(['Strict', 'Lax', 'None'])), + }); + scheme.NetworkCookie = tObject({ + name: tString, + value: tString, + domain: tString, + path: tString, + expires: tNumber, + httpOnly: tBoolean, + secure: tBoolean, + sameSite: tEnum(['Strict', 'Lax', 'None']), + }); + scheme.NameValue = tObject({ + name: tString, + value: tString, + }); + scheme.OriginStorage = tObject({ + origin: tString, + localStorage: tArray(tType('NameValue')), + }); scheme.SerializedError = tObject({ error: tOptional(tObject({ message: tString, @@ -108,10 +137,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { handleSIGTERM: tOptional(tBoolean), handleSIGHUP: tOptional(tBoolean), timeout: tOptional(tNumber), - env: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + env: tOptional(tArray(tType('NameValue'))), headless: tOptional(tBoolean), devtools: tOptional(tBoolean), proxy: tOptional(tObject({ @@ -135,10 +161,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { handleSIGTERM: tOptional(tBoolean), handleSIGHUP: tOptional(tBoolean), timeout: tOptional(tNumber), - env: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + env: tOptional(tArray(tType('NameValue'))), headless: tOptional(tBoolean), devtools: tOptional(tBoolean), proxy: tOptional(tObject({ @@ -167,10 +190,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { accuracy: tOptional(tNumber), })), permissions: tOptional(tArray(tString)), - extraHTTPHeaders: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, @@ -214,10 +234,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { accuracy: tOptional(tNumber), })), permissions: tOptional(tArray(tString)), - extraHTTPHeaders: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, @@ -247,6 +264,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { username: tOptional(tString), password: tOptional(tString), })), + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('SetNetworkCookie'))), + origins: tOptional(tArray(tType('OriginStorage'))), + })), }); scheme.BrowserCrNewBrowserCDPSessionParams = tOptional(tObject({})); scheme.BrowserCrStartTracingParams = tObject({ @@ -257,17 +278,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { }); scheme.BrowserCrStopTracingParams = tOptional(tObject({})); scheme.BrowserContextAddCookiesParams = tObject({ - cookies: tArray(tObject({ - name: tString, - value: tString, - url: tOptional(tString), - domain: tOptional(tString), - path: tOptional(tString), - expires: tOptional(tNumber), - httpOnly: tOptional(tBoolean), - secure: tOptional(tBoolean), - sameSite: tOptional(tEnum(['Strict', 'Lax', 'None'])), - })), + cookies: tArray(tType('SetNetworkCookie')), }); scheme.BrowserContextAddInitScriptParams = tObject({ source: tString, @@ -294,10 +305,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { timeout: tNumber, }); scheme.BrowserContextSetExtraHTTPHeadersParams = tObject({ - headers: tArray(tObject({ - name: tString, - value: tString, - })), + headers: tArray(tType('NameValue')), }); scheme.BrowserContextSetGeolocationParams = tObject({ geolocation: tOptional(tObject({ @@ -318,6 +326,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.BrowserContextSetOfflineParams = tObject({ offline: tBoolean, }); + scheme.BrowserContextStorageStateParams = tOptional(tObject({})); scheme.BrowserContextCrNewCDPSessionParams = tObject({ page: tChannel('Page'), }); @@ -371,10 +380,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { })), }); scheme.PageSetExtraHTTPHeadersParams = tObject({ - headers: tArray(tObject({ - name: tString, - value: tString, - })), + headers: tArray(tType('NameValue')), }); scheme.PageSetNetworkInterceptionEnabledParams = tObject({ enabled: tBoolean, @@ -840,18 +846,12 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { }); scheme.RouteContinueParams = tObject({ method: tOptional(tString), - headers: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + headers: tOptional(tArray(tType('NameValue'))), postData: tOptional(tBinary), }); scheme.RouteFulfillParams = tObject({ status: tOptional(tNumber), - headers: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + headers: tOptional(tArray(tType('NameValue'))), body: tOptional(tString), isBase64: tOptional(tBoolean), }); @@ -898,10 +898,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { executablePath: tString, args: tOptional(tArray(tString)), cwd: tOptional(tString), - env: tOptional(tArray(tObject({ - name: tString, - value: tString, - }))), + env: tOptional(tArray(tType('NameValue'))), handleSIGINT: tOptional(tBoolean), handleSIGTERM: tOptional(tBoolean), handleSIGHUP: tOptional(tBoolean), diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 59218b3d6c..02062b92b8 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -109,6 +109,7 @@ export abstract class BrowserContext extends EventEmitter { readonly _browserContextId: string | undefined; private _selectors?: Selectors; readonly _actionListeners = new Set(); + private _origins = new Set(); constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(); @@ -321,6 +322,56 @@ export abstract class BrowserContext extends EventEmitter { } throw pageOrError; } + + addVisitedOrigin(origin: string) { + this._origins.add(origin); + } + + async storageState(): Promise { + const result: types.StorageState = { + cookies: (await this.cookies()).filter(c => c.value !== ''), + origins: [] + }; + if (this._origins.size) { + const page = await this.newPage(); + await page._setServerRequestInterceptor(handler => { + handler.fulfill({ body: '' }).catch(() => {}); + }); + for (const origin of this._origins) { + const originStorage: types.OriginStorage = { origin, localStorage: [] }; + result.origins.push(originStorage); + const frame = page.mainFrame(); + await frame.goto(new ProgressController(), origin); + const storage = await frame._evaluateExpression(`({ + localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })), + })`, false, undefined, 'utility'); + originStorage.localStorage = storage.localStorage; + } + await page.close(); + } + return result; + } + + async setStorageState(state: types.SetStorageState) { + if (state.cookies) + await this.addCookies(state.cookies); + if (state.origins && state.origins.length) { + const page = await this.newPage(); + await page._setServerRequestInterceptor(handler => { + handler.fulfill({ body: '' }).catch(() => {}); + }); + for (const originState of state.origins) { + const frame = page.mainFrame(); + await frame.goto(new ProgressController(), originState.origin); + await frame._evaluateExpression(` + originState => { + for (const { name, value } of (originState.localStorage || [])) + localStorage.setItem(name, value); + }`, true, originState, 'utility'); + } + await page.close(); + } + } } export function assertBrowserContextIsNotOwned(context: BrowserContext) { diff --git a/src/server/frames.ts b/src/server/frames.ts index 69c430688f..043df215e6 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -199,7 +199,7 @@ export class FrameManager { frame.emit(Frame.Events.Navigation, navigationEvent); if (!initial) { debugLogger.log('api', ` navigated to "${url}"`); - this._page.emit(Page.Events.FrameNavigated, frame); + this._page.frameNavigated(frame); } // Restore pending if any - see comments above about keepPending. frame._pendingDocument = keepPending; @@ -213,7 +213,7 @@ export class FrameManager { const navigationEvent: NavigationEvent = { url, name: frame._name }; frame.emit(Frame.Events.Navigation, navigationEvent); debugLogger.log('api', ` navigated to "${url}"`); - this._page.emit(Page.Events.FrameNavigated, frame); + this._page.frameNavigated(frame); } frameAbortedNavigation(frameId: string, errorText: string, documentId?: string) { diff --git a/src/server/page.ts b/src/server/page.ts index 629ff99123..672e08509b 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -143,7 +143,8 @@ export class Page extends EventEmitter { private _workers = new Map(); readonly pdf: ((options?: types.PDFOptions) => Promise) | undefined; readonly coverage: any; - private _requestInterceptor?: network.RouteHandler; + private _clientRequestInterceptor: network.RouteHandler | undefined; + private _serverRequestInterceptor: network.RouteHandler | undefined; _ownedContext: BrowserContext | undefined; readonly selectors: Selectors; _video: Video | null = null; @@ -362,11 +363,16 @@ export class Page extends EventEmitter { } _needsRequestInterception(): boolean { - return !!this._requestInterceptor || !!this._browserContext._requestInterceptor; + return !!this._clientRequestInterceptor || !!this._serverRequestInterceptor || !!this._browserContext._requestInterceptor; } - async _setRequestInterceptor(handler: network.RouteHandler | undefined): Promise { - this._requestInterceptor = handler; + async _setClientRequestInterceptor(handler: network.RouteHandler | undefined): Promise { + this._clientRequestInterceptor = handler; + await this._delegate.updateRequestInterception(); + } + + async _setServerRequestInterceptor(handler: network.RouteHandler | undefined): Promise { + this._serverRequestInterceptor = handler; await this._delegate.updateRequestInterception(); } @@ -375,8 +381,12 @@ export class Page extends EventEmitter { const route = request._route(); if (!route) return; - if (this._requestInterceptor) { - this._requestInterceptor(route, request); + if (this._serverRequestInterceptor) { + this._serverRequestInterceptor(route, request); + return; + } + if (this._clientRequestInterceptor) { + this._clientRequestInterceptor(route, request); return; } if (this._browserContext._requestInterceptor) { @@ -444,6 +454,14 @@ export class Page extends EventEmitter { this._video = video; this.emit(Page.Events.VideoStarted, video); } + + frameNavigated(frame: frames.Frame) { + this.emit(Page.Events.FrameNavigated, frame); + const url = frame.url(); + if (!url.startsWith('http')) + return; + this._browserContext.addVisitedOrigin(new URL(url).origin); + } } export class Worker extends EventEmitter { diff --git a/src/server/types.ts b/src/server/types.ts index cfed13d178..aa00662eac 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -328,3 +328,23 @@ export type Error = { export type UIOptions = { slowMo?: number; }; + +export type NameValueList = { + name: string; + value: string; +}[]; + +export type OriginStorage = { + origin: string; + localStorage: NameValueList; +}; + +export type StorageState = { + cookies: NetworkCookie[], + origins: OriginStorage[] +} + +export type SetStorageState = { + cookies?: SetNetworkCookieParam[], + origins?: OriginStorage[] +} diff --git a/test/browsercontext-storage-state.spec.ts b/test/browsercontext-storage-state.spec.ts new file mode 100644 index 0000000000..cc2d03b269 --- /dev/null +++ b/test/browsercontext-storage-state.spec.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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. + */ + +import { it, expect } from './fixtures'; + +it('should capture local storage', (test, { browserName, platform }) => { + test.fixme(browserName === 'webkit' && platform === 'win32'); +}, async ({ context }) => { + const page1 = await context.newPage(); + await page1.route('**/*', route => { + route.fulfill({ body: '' }).catch(() => {}); + }); + await page1.goto('https://www.example.com'); + await page1.evaluate(() => { + localStorage['name1'] = 'value1'; + }); + await page1.goto('https://www.domain.com'); + await page1.evaluate(() => { + localStorage['name2'] = 'value2'; + }); + const { origins } = await context.storageState(); + expect(origins).toEqual([{ + origin: 'https://www.example.com', + localStorage: [{ + name: 'name1', + value: 'value1' + }], + }, { + origin: 'https://www.domain.com', + localStorage: [{ + name: 'name2', + value: 'value2' + }], + }]); +}); + +it('should set local storage', (test, { browserName, platform }) => { + test.fixme(browserName === 'webkit' && platform === 'win32'); +}, async ({ browser }) => { + const context = await browser.newContext({ + storageState: { + origins: [ + { + origin: 'https://www.example.com', + localStorage: [{ + name: 'name1', + value: 'value1' + }] + }, + ] + } + }); + // await new Promise(f => setTimeout(f, 1000)); + const page = await context.newPage(); + await page.route('**/*', route => { + route.fulfill({ body: '' }).catch(() => {}); + }); + await page.goto('https://www.example.com'); + const localStorage = await page.evaluate('window.localStorage'); + expect(localStorage).toEqual({ name1: 'value1' }); + await context.close(); +});