chore: use a single binding for all Playwright needs (#32039)

This makes it easier to manage bindings, being just init scripts.
Fixes the BFCache binding problem.
Makes bindings removable in Firefox.

Fixes #31515.
This commit is contained in:
Dmitry Gozman 2024-08-07 06:20:12 -07:00 committed by GitHub
parent fd9276f2ac
commit ea747afcdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 122 additions and 144 deletions

View File

@ -86,7 +86,7 @@ export abstract class BrowserContext extends SdkObject {
private _customCloseHandler?: () => Promise<any>;
readonly _tempDirs: string[] = [];
private _settingStorageState = false;
readonly initScripts: InitScript[] = [];
initScripts: InitScript[] = [];
private _routesInFlight = new Set<network.Route>();
private _debugger!: Debugger;
_closeReason: string | undefined;
@ -271,9 +271,7 @@ export abstract class BrowserContext extends SdkObject {
protected abstract doClearPermissions(): Promise<void>;
protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void>;
protected abstract doAddInitScript(initScript: InitScript): Promise<void>;
protected abstract doRemoveInitScripts(): Promise<void>;
protected abstract doExposeBinding(binding: PageBinding): Promise<void>;
protected abstract doRemoveExposedBindings(): Promise<void>;
protected abstract doRemoveNonInternalInitScripts(): Promise<void>;
protected abstract doUpdateRequestInterception(): Promise<void>;
protected abstract doClose(reason: string | undefined): Promise<void>;
protected abstract onClosePersistent(): void;
@ -320,15 +318,16 @@ export abstract class BrowserContext extends SdkObject {
}
const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding);
await this.doExposeBinding(binding);
await this.doAddInitScript(binding.initScript);
const frames = this.pages().map(page => page.frames()).flat();
await Promise.all(frames.map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {})));
}
async _removeExposedBindings() {
for (const key of this._pageBindings.keys()) {
if (!key.startsWith('__pw'))
for (const [key, binding] of this._pageBindings) {
if (!binding.internal)
this._pageBindings.delete(key);
}
await this.doRemoveExposedBindings();
}
async grantPermissions(permissions: string[], origin?: string) {
@ -414,8 +413,8 @@ export abstract class BrowserContext extends SdkObject {
}
async _removeInitScripts(): Promise<void> {
this.initScripts.splice(0, this.initScripts.length);
await this.doRemoveInitScripts();
this.initScripts = this.initScripts.filter(script => script.internal);
await this.doRemoveNonInternalInitScripts();
}
async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise<void> {

View File

@ -21,7 +21,7 @@ import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import { assert, createGuid } from '../../utils';
import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate, Worker } from '../page';
import type { InitScript, PageDelegate, Worker } from '../page';
import { Page } from '../page';
import { Frame } from '../frames';
import type { Dialog } from '../dialog';
@ -491,19 +491,9 @@ export class CRBrowserContext extends BrowserContext {
await (page._delegate as CRPage).addInitScript(initScript);
}
async doRemoveInitScripts() {
async doRemoveNonInternalInitScripts() {
for (const page of this.pages())
await (page._delegate as CRPage).removeInitScripts();
}
async doExposeBinding(binding: PageBinding) {
for (const page of this.pages())
await (page._delegate as CRPage).exposeBinding(binding);
}
async doRemoveExposedBindings() {
for (const page of this.pages())
await (page._delegate as CRPage).removeExposedBindings();
await (page._delegate as CRPage).removeNonInternalInitScripts();
}
async doUpdateRequestInterception(): Promise<void> {

View File

@ -26,7 +26,7 @@ import * as dom from '../dom';
import * as frames from '../frames';
import { helper } from '../helper';
import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate } from '../page';
import { type InitScript, PageBinding, type PageDelegate } from '../page';
import { Page, Worker } from '../page';
import type { Progress } from '../progress';
import type * as types from '../types';
@ -182,15 +182,6 @@ export class CRPage implements PageDelegate {
return this._sessionForFrame(frame)._navigate(frame, url, referrer);
}
async exposeBinding(binding: PageBinding) {
await this._forAllFrameSessions(frame => frame._initBinding(binding));
await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {})));
}
async removeExposedBindings() {
await this._forAllFrameSessions(frame => frame._removeExposedBindings());
}
async updateExtraHTTPHeaders(): Promise<void> {
const headers = network.mergeHeaders([
this._browserContext._options.extraHTTPHeaders,
@ -260,7 +251,7 @@ export class CRPage implements PageDelegate {
await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world));
}
async removeInitScripts() {
async removeNonInternalInitScripts() {
await this._forAllFrameSessions(frame => frame._removeEvaluatesOnNewDocument());
}
@ -420,7 +411,6 @@ class FrameSession {
private _screencastId: string | null = null;
private _screencastClients = new Set<any>();
private _evaluateOnNewDocumentIdentifiers: string[] = [];
private _exposedBindingNames: string[] = [];
private _metricsOverride: Protocol.Emulation.setDeviceMetricsOverrideParameters | undefined;
private _workerSessions = new Map<string, CRSession>();
@ -519,9 +509,7 @@ class FrameSession {
grantUniveralAccess: true,
worldName: UTILITY_WORLD_NAME,
});
for (const binding of this._crPage._browserContext._pageBindings.values())
frame.evaluateExpression(binding.source).catch(e => {});
for (const initScript of this._crPage._browserContext.initScripts)
for (const initScript of this._crPage._page.allInitScripts())
frame.evaluateExpression(initScript.source).catch(e => {});
}
@ -541,6 +529,7 @@ class FrameSession {
this._client.send('Log.enable', {}),
lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}),
this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }),
this._client.send('Page.addScriptToEvaluateOnNewDocument', {
source: '',
worldName: UTILITY_WORLD_NAME,
@ -573,11 +562,7 @@ class FrameSession {
promises.push(this._updateGeolocation(true));
promises.push(this._updateEmulateMedia());
promises.push(this._updateFileChooserInterception(true));
for (const binding of this._crPage._page.allBindings())
promises.push(this._initBinding(binding));
for (const initScript of this._crPage._browserContext.initScripts)
promises.push(this._evaluateOnNewDocument(initScript, 'main'));
for (const initScript of this._crPage._page.initScripts)
for (const initScript of this._crPage._page.allInitScripts())
promises.push(this._evaluateOnNewDocument(initScript, 'main'));
if (screencastOptions)
promises.push(this._startVideoRecording(screencastOptions));
@ -834,25 +819,6 @@ class FrameSession {
this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace));
}
async _initBinding(binding: PageBinding) {
const [, response] = await Promise.all([
this._client.send('Runtime.addBinding', { name: binding.name }),
this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: binding.source })
]);
this._exposedBindingNames.push(binding.name);
if (!binding.name.startsWith('__pw'))
this._evaluateOnNewDocumentIdentifiers.push(response.identifier);
}
async _removeExposedBindings() {
const toRetain: string[] = [];
const toRemove: string[] = [];
for (const name of this._exposedBindingNames)
(name.startsWith('__pw_') ? toRetain : toRemove).push(name);
this._exposedBindingNames = toRetain;
await Promise.all(toRemove.map(name => this._client.send('Runtime.removeBinding', { name })));
}
async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
const pageOrError = await this._crPage.pageOrError();
if (!(pageOrError instanceof Error)) {
@ -1102,7 +1068,8 @@ class FrameSession {
async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> {
const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined;
const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName });
this._evaluateOnNewDocumentIdentifiers.push(identifier);
if (!initScript.internal)
this._evaluateOnNewDocumentIdentifiers.push(identifier);
}
async _removeEvaluatesOnNewDocument(): Promise<void> {

View File

@ -21,7 +21,8 @@ import type { BrowserOptions } from '../browser';
import { Browser } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext';
import * as network from '../network';
import type { InitScript, Page, PageBinding, PageDelegate } from '../page';
import type { InitScript, Page, PageDelegate } from '../page';
import { PageBinding } from '../page';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';
import type * as channels from '@protocol/channels';
@ -178,7 +179,10 @@ export class FFBrowserContext extends BrowserContext {
override async _initialize() {
assert(!this._ffPages().length);
const browserContextId = this._browserContextId;
const promises: Promise<any>[] = [super._initialize()];
const promises: Promise<any>[] = [
super._initialize(),
this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: PageBinding.kPlaywrightBinding, script: '' }),
];
if (this._options.acceptDownloads !== 'internal-browser-default') {
promises.push(this._browser.session.send('Browser.setDownloadOptions', {
browserContextId,
@ -353,21 +357,17 @@ export class FFBrowserContext extends BrowserContext {
}
async doAddInitScript(initScript: InitScript) {
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script: script.source })) });
await this._updateInitScripts();
}
async doRemoveInitScripts() {
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [] });
async doRemoveNonInternalInitScripts() {
await this._updateInitScripts();
}
async doExposeBinding(binding: PageBinding) {
await this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: binding.name, script: binding.source });
}
async doRemoveExposedBindings() {
// TODO: implement me.
// This is not a critical problem, what ends up happening is
// an old binding will be restored upon page reload and will point nowhere.
private async _updateInitScripts() {
const bindingScripts = [...this._pageBindings.values()].map(binding => binding.initScript.source);
const initScripts = this.initScripts.map(script => script.source);
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [...bindingScripts, ...initScripts].map(script => ({ script })) });
}
async doUpdateRequestInterception(): Promise<void> {

View File

@ -20,7 +20,7 @@ import * as dom from '../dom';
import type * as frames from '../frames';
import type { RegisteredListener } from '../../utils/eventsHelper';
import { eventsHelper } from '../../utils/eventsHelper';
import type { PageBinding, PageDelegate } from '../page';
import type { PageDelegate } from '../page';
import { InitScript } from '../page';
import { Page, Worker } from '../page';
import type * as types from '../types';
@ -114,7 +114,7 @@ export class FFPage implements PageDelegate {
});
// Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy.
// Therefore, we can end up with an initialized page without utility world, although very unlikely.
this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e));
this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e));
}
potentiallyUninitializedPage(): Page {
@ -336,14 +336,6 @@ export class FFPage implements PageDelegate {
this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError());
}
async exposeBinding(binding: PageBinding) {
await this._session.send('Page.addBinding', { name: binding.name, script: binding.source });
}
async removeExposedBindings() {
// TODO: implement me.
}
didClose() {
this._markAsError(new TargetClosedError());
this._session.dispose();
@ -412,9 +404,9 @@ export class FFPage implements PageDelegate {
await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });
}
async removeInitScripts() {
this._initScripts = [];
await this._session.send('Page.setInitScripts', { scripts: [] });
async removeNonInternalInitScripts() {
this._initScripts = this._initScripts.filter(s => s.initScript.internal);
await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) });
}
async closePage(runBeforeUnload: boolean): Promise<void> {

View File

@ -54,10 +54,8 @@ export interface PageDelegate {
reload(): Promise<void>;
goBack(): Promise<boolean>;
goForward(): Promise<boolean>;
exposeBinding(binding: PageBinding): Promise<void>;
removeExposedBindings(): Promise<void>;
addInitScript(initScript: InitScript): Promise<void>;
removeInitScripts(): Promise<void>;
removeNonInternalInitScripts(): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>;
potentiallyUninitializedPage(): Page;
pageOrError(): Promise<Page | Error>;
@ -154,7 +152,7 @@ export class Page extends SdkObject {
private _emulatedMedia: Partial<EmulatedMedia> = {};
private _interceptFileChooser = false;
private readonly _pageBindings = new Map<string, PageBinding>();
readonly initScripts: InitScript[] = [];
initScripts: InitScript[] = [];
readonly _screenshotter: Screenshotter;
readonly _frameManager: frames.FrameManager;
readonly accessibility: accessibility.Accessibility;
@ -342,15 +340,15 @@ export class Page extends SdkObject {
throw new Error(`Function "${name}" has been already registered in the browser context`);
const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding);
await this._delegate.exposeBinding(binding);
await this._delegate.addInitScript(binding.initScript);
await Promise.all(this.frames().map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {})));
}
async _removeExposedBindings() {
for (const key of this._pageBindings.keys()) {
if (!key.startsWith('__pw'))
for (const [key, binding] of this._pageBindings) {
if (!binding.internal)
this._pageBindings.delete(key);
}
await this._delegate.removeExposedBindings();
}
setExtraHTTPHeaders(headers: types.HeadersArray) {
@ -533,8 +531,8 @@ export class Page extends SdkObject {
}
async _removeInitScripts() {
this.initScripts.splice(0, this.initScripts.length);
await this._delegate.removeInitScripts();
this.initScripts = this.initScripts.filter(script => script.internal);
await this._delegate.removeNonInternalInitScripts();
}
needsRequestInterception(): boolean {
@ -727,8 +725,9 @@ export class Page extends SdkObject {
this._browserContext.addVisitedOrigin(origin);
}
allBindings() {
return [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()];
allInitScripts() {
const bindings = [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()];
return [...bindings.map(binding => binding.initScript), ...this._browserContext.initScripts, ...this.initScripts];
}
getBinding(name: string) {
@ -819,23 +818,29 @@ type BindingPayload = {
};
export class PageBinding {
static kPlaywrightBinding = '__playwright__binding__';
readonly name: string;
readonly playwrightFunction: frames.FunctionWithSource;
readonly source: string;
readonly initScript: InitScript;
readonly needsHandle: boolean;
readonly internal: boolean;
constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
this.name = name;
this.playwrightFunction = playwrightFunction;
this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle}, (${source})())`;
this.initScript = new InitScript(`(${addPageBinding.toString()})(${JSON.stringify(PageBinding.kPlaywrightBinding)}, ${JSON.stringify(name)}, ${needsHandle}, (${source})())`, true /* internal */);
this.needsHandle = needsHandle;
this.internal = name.startsWith('__pw');
}
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload;
try {
assert(context.world);
const binding = page.getBinding(name)!;
const binding = page.getBinding(name);
if (!binding)
throw new Error(`Function "${name}" is not exposed`);
let result: any;
if (binding.needsHandle) {
const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null);
@ -877,10 +882,8 @@ export class PageBinding {
}
}
function addPageBinding(bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType<typeof source>) {
const binding = (globalThis as any)[bindingName];
if (binding.__installed)
return;
function addPageBinding(playwrightBinding: string, bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType<typeof source>) {
const binding = (globalThis as any)[playwrightBinding];
(globalThis as any)[bindingName] = (...args: any[]) => {
const me = (globalThis as any)[bindingName];
if (needsHandle && args.slice(1).some(arg => arg !== undefined))
@ -919,8 +922,9 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript
export class InitScript {
readonly source: string;
readonly internal: boolean;
constructor(source: string) {
constructor(source: string, internal?: boolean) {
const guid = createGuid();
this.source = `(() => {
globalThis.__pwInitScripts = globalThis.__pwInitScripts || {};
@ -930,6 +934,7 @@ export class InitScript {
globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true;
${source}
})();`;
this.internal = !!internal;
}
}

View File

@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper';
import { assert } from '../../utils';
import { eventsHelper } from '../../utils/eventsHelper';
import * as network from '../network';
import type { InitScript, Page, PageBinding, PageDelegate } from '../page';
import type { InitScript, Page, PageDelegate } from '../page';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';
import type * as channels from '@protocol/channels';
@ -320,21 +320,11 @@ export class WKBrowserContext extends BrowserContext {
await (page._delegate as WKPage)._updateBootstrapScript();
}
async doRemoveInitScripts() {
async doRemoveNonInternalInitScripts() {
for (const page of this.pages())
await (page._delegate as WKPage)._updateBootstrapScript();
}
async doExposeBinding(binding: PageBinding) {
for (const page of this.pages())
await (page._delegate as WKPage).exposeBinding(binding);
}
async doRemoveExposedBindings() {
for (const page of this.pages())
await (page._delegate as WKPage).removeExposedBindings();
}
async doUpdateRequestInterception(): Promise<void> {
for (const page of this.pages())
await (page._delegate as WKPage).updateRequestInterception();

View File

@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper';
import { helper } from '../helper';
import type { JSHandle } from '../javascript';
import * as network from '../network';
import type { InitScript, PageBinding, PageDelegate } from '../page';
import { type InitScript, PageBinding, type PageDelegate } from '../page';
import { Page } from '../page';
import type { Progress } from '../progress';
import type * as types from '../types';
@ -179,6 +179,7 @@ export class WKPage implements PageDelegate {
const promises: Promise<any>[] = [
// Resource tree should be received before first execution context.
session.send('Runtime.enable'),
session.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }),
session.send('Page.createUserWorld', { name: UTILITY_WORLD_NAME }).catch(_ => {}), // Worlds are per-process
session.send('Console.enable'),
session.send('Network.enable'),
@ -200,8 +201,6 @@ export class WKPage implements PageDelegate {
const emulatedMedia = this._page.emulatedMedia();
if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors)
promises.push(WKPage._setEmulateMedia(session, emulatedMedia.media, emulatedMedia.colorScheme, emulatedMedia.reducedMotion, emulatedMedia.forcedColors));
for (const binding of this._page.allBindings())
promises.push(session.send('Runtime.addBinding', { name: binding.name }));
const bootstrapScript = this._calculateBootstrapScript();
if (bootstrapScript.length)
promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript }));
@ -768,21 +767,11 @@ export class WKPage implements PageDelegate {
});
}
async exposeBinding(binding: PageBinding): Promise<void> {
this._session.send('Runtime.addBinding', { name: binding.name });
await this._updateBootstrapScript();
await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {})));
}
async removeExposedBindings(): Promise<void> {
await this._updateBootstrapScript();
}
async addInitScript(initScript: InitScript): Promise<void> {
await this._updateBootstrapScript();
}
async removeInitScripts() {
async removeNonInternalInitScripts() {
await this._updateBootstrapScript();
}
@ -795,11 +784,7 @@ export class WKPage implements PageDelegate {
}
scripts.push('if (!window.safari) window.safari = { pushNotification: { toString() { return "[object SafariRemoteNotification]"; } } };');
scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};');
for (const binding of this._page.allBindings())
scripts.push(binding.source);
scripts.push(...this._browserContext.initScripts.map(s => s.source));
scripts.push(...this._page.initScripts.map(s => s.source));
scripts.push(...this._page.allInitScripts().map(script => script.source));
return scripts.join(';\n');
}

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<link rel='stylesheet' href='./one-style.css'>
<div>BFCached</div>
<script>
window.didShow = new Promise(f => window.addEventListener('pageshow', event => {
console.log(event);
window._persisted = !!event.persisted;
window._event = event;
f({ persisted: !!event.persisted });
}));
</script>

View File

@ -0,0 +1,37 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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 { contextTest as test, expect } from '../../config/browserTest';
test.use({
launchOptions: async ({ launchOptions }, use) => {
await use({ ...launchOptions, ignoreDefaultArgs: ['--disable-back-forward-cache'] });
}
});
test('bindings should work after restoring from bfcache', async ({ page, server }) => {
await page.exposeFunction('add', (a, b) => a + b);
await page.goto(server.PREFIX + '/cached/bfcached.html');
expect(await page.evaluate('window.add(1, 2)')).toBe(3);
await page.setContent(`<a href='about:blank'}>click me</a>`);
await page.click('a');
await page.goBack({ waitUntil: 'commit' });
await page.evaluate('window.didShow');
expect(await page.evaluate('window.add(2, 3)')).toBe(5);
});

View File

@ -92,15 +92,17 @@ it('page.goBack should work for file urls', async ({ page, server, asset, browse
});
it('goBack/goForward should work with bfcache-able pages', async ({ page, server }) => {
await page.goto(server.PREFIX + '/cached/one-style.html');
await page.setContent(`<a href=${JSON.stringify(server.PREFIX + '/cached/one-style.html?foo')}>click me</a>`);
await page.goto(server.PREFIX + '/cached/bfcached.html');
await page.setContent(`<a href=${JSON.stringify(server.PREFIX + '/cached/bfcached.html?foo')}>click me</a>`);
await page.click('a');
let response = await page.goBack();
expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html');
expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html');
// BFCache should be disabled.
expect(await page.evaluate('window.didShow')).toEqual({ persisted: false });
response = await page.goForward();
expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html?foo');
expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html?foo');
});
it('page.reload should work', async ({ page, server }) => {