From 355a58e616746dcfbe58b50abb182af8a013a5ae Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 14 Dec 2020 16:03:52 -0800 Subject: [PATCH] feat(storage): accept path in save/load storage apis (#4714) Also make Firefox accept `expires: -1` cookies. --- docs-src/api-body.md | 5 ++++ docs-src/api-params.md | 4 +-- docs/api.md | 12 +++++---- src/client/android.ts | 4 +-- src/client/browser.ts | 4 +-- src/client/browserContext.ts | 19 +++++++++++--- src/client/browserType.ts | 4 +-- src/client/types.ts | 3 ++- src/server/firefox/ffBrowser.ts | 6 ++++- test/browsercontext-add-cookies.spec.ts | 18 +++++++++++++ test/browsercontext-storage-state.spec.ts | 32 ++++++++++++++++++++++- 11 files changed, 91 insertions(+), 20 deletions(-) diff --git a/docs-src/api-body.md b/docs-src/api-body.md index 8cb67cfeef..ea6fbcd94e 100644 --- a/docs-src/api-body.md +++ b/docs-src/api-body.md @@ -545,6 +545,11 @@ Whether to emulate network being offline for the browser context. Returns storage state for this browser context, contains current cookies and local storage snapshot. +### option: BrowserContext.storageState.path +- `path` <[string]> + +The file path to save the storage state to. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, storage state is still returned, but won't be saved to the disk. + ## async method: BrowserContext.unroute Removes a route created with [browserContext.route()](). When `handler` is not specified, removes all routes for the diff --git a/docs-src/api-params.md b/docs-src/api-params.md index 4b0ddbb78a..4136f05248 100644 --- a/docs-src/api-params.md +++ b/docs-src/api-params.md @@ -115,7 +115,7 @@ Defaults to `'visible'`. Can be either: ## context-option-storage-state -- `storageState` <[Object]> +- `storageState` <[string]|[Object]> - `cookies` <[Array]<[Object]>> Optional cookies to set for context - `name` <[string]> **required** - `value` <[string]> **required** @@ -133,7 +133,7 @@ Defaults to `'visible'`. Can be either: - `value` <[string]> Populates context with given storage state. This method can be used to initialize context with logged-in information -obtained via [browserContext.storageState()](). +obtained via [browserContext.storageState()](). Either a path to the file with saved storage, or an object with the following fields: ## context-option-acceptdownloads diff --git a/docs/api.md b/docs/api.md index d7a9e4cfc3..6c82ba9a8f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -246,7 +246,7 @@ Indicates that the browser is connected. - `bypass` <[string]> Optional coma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - `username` <[string]> Optional username to use if HTTP proxy requires authentication. - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - - `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). + - `storageState` <[string]|[Object]> Populates context with given storage state. This method can be used to initialize context with logged-in information obtained via [browserContext.storageState([options])](#browsercontextstoragestateoptions). Either a path to the file with saved storage, or an object with the following fields: - `cookies` <[Array]<[Object]>> Optional cookies to set for context - `name` <[string]> **required** - `value` <[string]> **required** @@ -321,7 +321,7 @@ Creates a new browser context. It won't share cookies/cache with other browser c - `bypass` <[string]> Optional coma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - `username` <[string]> Optional username to use if HTTP proxy requires authentication. - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - - `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). + - `storageState` <[string]|[Object]> Populates context with given storage state. This method can be used to initialize context with logged-in information obtained via [browserContext.storageState([options])](#browsercontextstoragestateoptions). Either a path to the file with saved storage, or an object with the following fields: - `cookies` <[Array]<[Object]>> Optional cookies to set for context - `name` <[string]> **required** - `value` <[string]> **required** @@ -393,7 +393,7 @@ await context.close(); - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) -- [browserContext.storageState()](#browsercontextstoragestate) +- [browserContext.storageState([options])](#browsercontextstoragestateoptions) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) @@ -745,7 +745,9 @@ instead. - `offline` <[boolean]> Whether to emulate network being offline for the browser context. - returns: <[Promise]> -#### browserContext.storageState() +#### browserContext.storageState([options]) +- `options` <[Object]> + - `path` <[string]> The file path to save the storage state to. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, storage state is still returned, but won't be saved to the disk. - returns: <[Promise]<[Object]>> - `cookies` <[Array]<[Object]>> - `name` <[string]> @@ -5185,7 +5187,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage'); - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) -- [browserContext.storageState()](#browsercontextstoragestate) +- [browserContext.storageState([options])](#browsercontextstoragestateoptions) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) diff --git a/src/client/android.ts b/src/client/android.ts index 7dcb9501e6..874367d36c 100644 --- a/src/client/android.ts +++ b/src/client/android.ts @@ -19,7 +19,7 @@ import * as util from 'util'; import { isString } from '../utils/utils'; import * as channels from '../protocol/channels'; import { Events } from './events'; -import { BrowserContext, validateBrowserContextOptions } from './browserContext'; +import { BrowserContext, prepareBrowserContextOptions } from './browserContext'; import { ChannelOwner } from './channelOwner'; import * as apiInternal from '../../android-types-internal'; import * as types from './types'; @@ -235,7 +235,7 @@ export class AndroidDevice extends ChannelOwner { return this._wrapApiCall('androidDevice.launchBrowser', async () => { - const contextOptions = validateBrowserContextOptions(options); + const contextOptions = await prepareBrowserContextOptions(options); const { context } = await this._channel.launchBrowser(contextOptions); return BrowserContext.from(context); }); diff --git a/src/client/browser.ts b/src/client/browser.ts index bd1ba31b9d..1af06ba248 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -15,7 +15,7 @@ */ import * as channels from '../protocol/channels'; -import { BrowserContext, validateBrowserContextOptions } from './browserContext'; +import { BrowserContext, prepareBrowserContextOptions } from './browserContext'; import { Page } from './page'; import { ChannelOwner } from './channelOwner'; import { Events } from './events'; @@ -46,7 +46,7 @@ export class Browser extends ChannelOwner { if (this._isRemote && options._tracePath) throw new Error(`"_tracePath" is not supported in connected browser`); - const contextOptions = validateBrowserContextOptions(options); + const contextOptions = await prepareBrowserContextOptions(options); const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context); context._options = contextOptions; this._contexts.add(context); diff --git a/src/client/browserContext.ts b/src/client/browserContext.ts index f11088b097..fd1a493b2e 100644 --- a/src/client/browserContext.ts +++ b/src/client/browserContext.ts @@ -18,6 +18,8 @@ import { Page, BindingCall, FunctionWithSource } from './page'; import * as network from './network'; import * as channels from '../protocol/channels'; +import * as util from 'util'; +import * as fs from 'fs'; import { ChannelOwner } from './channelOwner'; import { deprecate, evaluationScript, urlMatches } from './clientHelper'; import { Browser } from './browser'; @@ -25,9 +27,12 @@ import { Events } from './events'; import { TimeoutSettings } from '../utils/timeoutSettings'; import { Waiter } from './waiter'; import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState } from './types'; -import { isUnderTest, headersObjectToArray } from '../utils/utils'; +import { isUnderTest, headersObjectToArray, mkdirIfNeeded } from '../utils/utils'; import { isSafeCloseError } from '../utils/errors'; +const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); +const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); + export class BrowserContext extends ChannelOwner { _pages = new Set(); private _routes: { url: URLMatch, handler: network.RouteHandler }[] = []; @@ -219,9 +224,14 @@ export class BrowserContext extends ChannelOwner { + async storageState(options: { path?: string } = {}): Promise { return await this._wrapApiCall('browserContext.storageState', async () => { - return await this._channel.storageState(); + const state = await this._channel.storageState(); + if (options.path) { + await mkdirIfNeeded(options.path); + await fsWriteFileAsync(options.path, JSON.stringify(state), 'utf8'); + } + return state; }); } @@ -245,7 +255,7 @@ export class BrowserContext extends ChannelOwner { if (options.videoSize && !options.videosPath) throw new Error(`"videoSize" option requires "videosPath" to be specified`); if (options.extraHTTPHeaders) @@ -255,6 +265,7 @@ export function validateBrowserContextOptions(options: BrowserContextOptions): c viewport: options.viewport === null ? undefined : options.viewport, noDefaultViewport: options.viewport === null, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, + storageState: typeof options.storageState === 'string' ? JSON.parse(await fsReadFileAsync(options.storageState, 'utf8')) : options.storageState, }; if (!contextOptions.recordVideo && options.videosPath) { contextOptions.recordVideo = { diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 257a17520d..fb79d7a091 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -16,7 +16,7 @@ import * as channels from '../protocol/channels'; import { Browser } from './browser'; -import { BrowserContext, validateBrowserContextOptions } from './browserContext'; +import { BrowserContext, prepareBrowserContextOptions } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types'; import * as WebSocket from 'ws'; @@ -92,7 +92,7 @@ export class BrowserType extends ChannelOwner { return this._wrapApiCall('browserType.launchPersistentContext', async () => { assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); - const contextOptions = validateBrowserContextOptions(options); + const contextOptions = await prepareBrowserContextOptions(options); const persistentOptions: channels.BrowserTypeLaunchPersistentContextParams = { ...contextOptions, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, diff --git a/src/client/types.ts b/src/client/types.ts index c88f947b71..99594a0ef8 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -49,12 +49,13 @@ export type SetStorageState = { export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle'; export const kLifecycleEvents: Set = new Set(['load', 'domcontentloaded', 'networkidle']); -export type BrowserContextOptions = Omit & { +export type BrowserContextOptions = Omit & { viewport?: Size | null, extraHTTPHeaders?: Headers, logger?: Logger, videosPath?: string, videoSize?: Size, + storageState?: string | channels.BrowserNewContextOptions['storageState'], }; type LaunchOverrides = { diff --git a/src/server/firefox/ffBrowser.ts b/src/server/firefox/ffBrowser.ts index e46b87835b..40dd62cb08 100644 --- a/src/server/firefox/ffBrowser.ts +++ b/src/server/firefox/ffBrowser.ts @@ -242,7 +242,11 @@ export class FFBrowserContext extends BrowserContext { } async addCookies(cookies: types.SetNetworkCookieParam[]) { - await this._browser._connection.send('Browser.setCookies', { browserContextId: this._browserContextId, cookies: network.rewriteCookies(cookies) }); + const cc = network.rewriteCookies(cookies).map(c => ({ + ...c, + expires: c.expires && c.expires !== -1 ? c.expires : undefined, + })); + await this._browser._connection.send('Browser.setCookies', { browserContextId: this._browserContextId, cookies: cc }); } async clearCookies() { diff --git a/test/browsercontext-add-cookies.spec.ts b/test/browsercontext-add-cookies.spec.ts index 10f8f1a605..d937bfea79 100644 --- a/test/browsercontext-add-cookies.spec.ts +++ b/test/browsercontext-add-cookies.spec.ts @@ -27,6 +27,24 @@ it('should work', async ({context, page, server}) => { expect(await page.evaluate(() => document.cookie)).toEqual('password=123456'); }); +it('should work with expires=-1', async ({context, page}) => { + await context.addCookies([{ + name: 'username', + value: 'John Doe', + domain: 'www.example.com', + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: 'None', + }]); + await page.route('**/*', route => { + route.fulfill({ body: '' }).catch(() => {}); + }); + await page.goto('https://www.example.com'); + expect(await page.evaluate(() => document.cookie)).toEqual('username=John Doe'); +}); + it('should roundtrip cookie', async ({context, page, server}) => { await page.goto(server.EMPTY_PAGE); // @see https://en.wikipedia.org/wiki/Year_2038_problem diff --git a/test/browsercontext-storage-state.spec.ts b/test/browsercontext-storage-state.spec.ts index 978872ee0f..1d862184f1 100644 --- a/test/browsercontext-storage-state.spec.ts +++ b/test/browsercontext-storage-state.spec.ts @@ -16,6 +16,7 @@ */ import { it, expect } from './fixtures'; +import * as fs from 'fs'; it('should capture local storage', async ({ context }) => { const page1 = await context.newPage(); @@ -60,7 +61,6 @@ it('should set local storage', async ({ browser }) => { ] } }); - // await new Promise(f => setTimeout(f, 1000)); const page = await context.newPage(); await page.route('**/*', route => { route.fulfill({ body: '' }).catch(() => {}); @@ -70,3 +70,33 @@ it('should set local storage', async ({ browser }) => { expect(localStorage).toEqual({ name1: 'value1' }); await context.close(); }); + +it('should round-trip through the file', async ({ browser, context, testInfo }) => { + 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'; + document.cookie = 'username=John Doe'; + return document.cookie; + }); + + const path = testInfo.outputPath('storage-state.json'); + const state = await context.storageState({ path }); + const written = await fs.promises.readFile(path, 'utf8'); + expect(JSON.stringify(state)).toBe(written); + + const context2 = await browser.newContext({ storageState: path }); + const page2 = await context2.newPage(); + await page2.route('**/*', route => { + route.fulfill({ body: '' }).catch(() => {}); + }); + await page2.goto('https://www.example.com'); + const localStorage = await page2.evaluate('window.localStorage'); + expect(localStorage).toEqual({ name1: 'value1' }); + const cookie = await page2.evaluate('document.cookie'); + expect(cookie).toEqual('username=John Doe'); + await context2.close(); +});