feat(storage): accept path in save/load storage apis (#4714)

Also make Firefox accept `expires: -1` cookies.
This commit is contained in:
Dmitry Gozman 2020-12-14 16:03:52 -08:00 committed by GitHub
parent 5f6ccee742
commit 355a58e616
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 91 additions and 20 deletions

View file

@ -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. 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 ## async method: BrowserContext.unroute
Removes a route created with [browserContext.route()](). When `handler` is not specified, removes all routes for the Removes a route created with [browserContext.route()](). When `handler` is not specified, removes all routes for the

View file

@ -115,7 +115,7 @@ Defaults to `'visible'`. Can be either:
## context-option-storage-state ## context-option-storage-state
- `storageState` <[Object]> - `storageState` <[string]|[Object]>
- `cookies` <[Array]<[Object]>> Optional cookies to set for context - `cookies` <[Array]<[Object]>> Optional cookies to set for context
- `name` <[string]> **required** - `name` <[string]> **required**
- `value` <[string]> **required** - `value` <[string]> **required**
@ -133,7 +133,7 @@ Defaults to `'visible'`. Can be either:
- `value` <[string]> - `value` <[string]>
Populates context with given storage state. This method can be used to initialize context with logged-in information 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 ## context-option-acceptdownloads

View file

@ -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"`. - `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. - `username` <[string]> Optional username to use if HTTP proxy requires authentication.
- `password` <[string]> Optional password 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 - `cookies` <[Array]<[Object]>> Optional cookies to set for context
- `name` <[string]> **required** - `name` <[string]> **required**
- `value` <[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"`. - `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. - `username` <[string]> Optional username to use if HTTP proxy requires authentication.
- `password` <[string]> Optional password 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 - `cookies` <[Array]<[Object]>> Optional cookies to set for context
- `name` <[string]> **required** - `name` <[string]> **required**
- `value` <[string]> **required** - `value` <[string]> **required**
@ -393,7 +393,7 @@ await context.close();
- [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation)
- [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials)
- [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline)
- [browserContext.storageState()](#browsercontextstoragestate) - [browserContext.storageState([options])](#browsercontextstoragestateoptions)
- [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler)
- [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate)
<!-- GEN:stop --> <!-- GEN:stop -->
@ -745,7 +745,9 @@ instead.
- `offline` <[boolean]> Whether to emulate network being offline for the browser context. - `offline` <[boolean]> Whether to emulate network being offline for the browser context.
- returns: <[Promise]> - 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]>> - returns: <[Promise]<[Object]>>
- `cookies` <[Array]<[Object]>> - `cookies` <[Array]<[Object]>>
- `name` <[string]> - `name` <[string]>
@ -5185,7 +5187,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage');
- [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation) - [browserContext.setGeolocation(geolocation)](#browsercontextsetgeolocationgeolocation)
- [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials) - [browserContext.setHTTPCredentials(httpCredentials)](#browsercontextsethttpcredentialshttpcredentials)
- [browserContext.setOffline(offline)](#browsercontextsetofflineoffline) - [browserContext.setOffline(offline)](#browsercontextsetofflineoffline)
- [browserContext.storageState()](#browsercontextstoragestate) - [browserContext.storageState([options])](#browsercontextstoragestateoptions)
- [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler) - [browserContext.unroute(url[, handler])](#browsercontextunrouteurl-handler)
- [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate) - [browserContext.waitForEvent(event[, optionsOrPredicate])](#browsercontextwaitforeventevent-optionsorpredicate)
<!-- GEN:stop --> <!-- GEN:stop -->

View file

@ -19,7 +19,7 @@ import * as util from 'util';
import { isString } from '../utils/utils'; import { isString } from '../utils/utils';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { Events } from './events'; import { Events } from './events';
import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, prepareBrowserContextOptions } from './browserContext';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import * as apiInternal from '../../android-types-internal'; import * as apiInternal from '../../android-types-internal';
import * as types from './types'; import * as types from './types';
@ -235,7 +235,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
async launchBrowser(options: types.BrowserContextOptions & { packageName?: string } = {}): Promise<BrowserContext> { async launchBrowser(options: types.BrowserContextOptions & { packageName?: string } = {}): Promise<BrowserContext> {
return this._wrapApiCall('androidDevice.launchBrowser', async () => { return this._wrapApiCall('androidDevice.launchBrowser', async () => {
const contextOptions = validateBrowserContextOptions(options); const contextOptions = await prepareBrowserContextOptions(options);
const { context } = await this._channel.launchBrowser(contextOptions); const { context } = await this._channel.launchBrowser(contextOptions);
return BrowserContext.from(context); return BrowserContext.from(context);
}); });

View file

@ -15,7 +15,7 @@
*/ */
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, prepareBrowserContextOptions } from './browserContext';
import { Page } from './page'; import { Page } from './page';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { Events } from './events'; import { Events } from './events';
@ -46,7 +46,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
return this._wrapApiCall('browser.newContext', async () => { return this._wrapApiCall('browser.newContext', async () => {
if (this._isRemote && options._tracePath) if (this._isRemote && options._tracePath)
throw new Error(`"_tracePath" is not supported in connected browser`); 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); const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context);
context._options = contextOptions; context._options = contextOptions;
this._contexts.add(context); this._contexts.add(context);

View file

@ -18,6 +18,8 @@
import { Page, BindingCall, FunctionWithSource } from './page'; import { Page, BindingCall, FunctionWithSource } from './page';
import * as network from './network'; import * as network from './network';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import * as util from 'util';
import * as fs from 'fs';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { deprecate, evaluationScript, urlMatches } from './clientHelper'; import { deprecate, evaluationScript, urlMatches } from './clientHelper';
import { Browser } from './browser'; import { Browser } from './browser';
@ -25,9 +27,12 @@ import { Events } from './events';
import { TimeoutSettings } from '../utils/timeoutSettings'; import { TimeoutSettings } from '../utils/timeoutSettings';
import { Waiter } from './waiter'; import { Waiter } from './waiter';
import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState } from './types'; 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'; 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<channels.BrowserContextChannel, channels.BrowserContextInitializer> { export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel, channels.BrowserContextInitializer> {
_pages = new Set<Page>(); _pages = new Set<Page>();
private _routes: { url: URLMatch, handler: network.RouteHandler }[] = []; private _routes: { url: URLMatch, handler: network.RouteHandler }[] = [];
@ -219,9 +224,14 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
return result; return result;
} }
async storageState(): Promise<StorageState> { async storageState(options: { path?: string } = {}): Promise<StorageState> {
return await this._wrapApiCall('browserContext.storageState', async () => { 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<channels.BrowserContextChannel,
} }
} }
export function validateBrowserContextOptions(options: BrowserContextOptions): channels.BrowserNewContextOptions { export async function prepareBrowserContextOptions(options: BrowserContextOptions): Promise<channels.BrowserNewContextOptions> {
if (options.videoSize && !options.videosPath) if (options.videoSize && !options.videosPath)
throw new Error(`"videoSize" option requires "videosPath" to be specified`); throw new Error(`"videoSize" option requires "videosPath" to be specified`);
if (options.extraHTTPHeaders) if (options.extraHTTPHeaders)
@ -255,6 +265,7 @@ export function validateBrowserContextOptions(options: BrowserContextOptions): c
viewport: options.viewport === null ? undefined : options.viewport, viewport: options.viewport === null ? undefined : options.viewport,
noDefaultViewport: options.viewport === null, noDefaultViewport: options.viewport === null,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, 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) { if (!contextOptions.recordVideo && options.videosPath) {
contextOptions.recordVideo = { contextOptions.recordVideo = {

View file

@ -16,7 +16,7 @@
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { Browser } from './browser'; import { Browser } from './browser';
import { BrowserContext, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, prepareBrowserContextOptions } from './browserContext';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types'; import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
@ -92,7 +92,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> { async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> {
return this._wrapApiCall('browserType.launchPersistentContext', async () => { return this._wrapApiCall('browserType.launchPersistentContext', async () => {
assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); 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 = { const persistentOptions: channels.BrowserTypeLaunchPersistentContextParams = {
...contextOptions, ...contextOptions,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,

View file

@ -49,12 +49,13 @@ export type SetStorageState = {
export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle'; export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle';
export const kLifecycleEvents: Set<LifecycleEvent> = new Set(['load', 'domcontentloaded', 'networkidle']); export const kLifecycleEvents: Set<LifecycleEvent> = new Set(['load', 'domcontentloaded', 'networkidle']);
export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders'> & { export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders' | 'storageState'> & {
viewport?: Size | null, viewport?: Size | null,
extraHTTPHeaders?: Headers, extraHTTPHeaders?: Headers,
logger?: Logger, logger?: Logger,
videosPath?: string, videosPath?: string,
videoSize?: Size, videoSize?: Size,
storageState?: string | channels.BrowserNewContextOptions['storageState'],
}; };
type LaunchOverrides = { type LaunchOverrides = {

View file

@ -242,7 +242,11 @@ export class FFBrowserContext extends BrowserContext {
} }
async addCookies(cookies: types.SetNetworkCookieParam[]) { 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() { async clearCookies() {

View file

@ -27,6 +27,24 @@ it('should work', async ({context, page, server}) => {
expect(await page.evaluate(() => document.cookie)).toEqual('password=123456'); 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: '<html></html>' }).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}) => { it('should roundtrip cookie', async ({context, page, server}) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
// @see https://en.wikipedia.org/wiki/Year_2038_problem // @see https://en.wikipedia.org/wiki/Year_2038_problem

View file

@ -16,6 +16,7 @@
*/ */
import { it, expect } from './fixtures'; import { it, expect } from './fixtures';
import * as fs from 'fs';
it('should capture local storage', async ({ context }) => { it('should capture local storage', async ({ context }) => {
const page1 = await context.newPage(); 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(); const page = await context.newPage();
await page.route('**/*', route => { await page.route('**/*', route => {
route.fulfill({ body: '<html></html>' }).catch(() => {}); route.fulfill({ body: '<html></html>' }).catch(() => {});
@ -70,3 +70,33 @@ it('should set local storage', async ({ browser }) => {
expect(localStorage).toEqual({ name1: 'value1' }); expect(localStorage).toEqual({ name1: 'value1' });
await context.close(); 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: '<html></html>' }).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: '<html></html>' }).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();
});