chore: support Tab targets (#10148)

This commit is contained in:
Alex Rudenko 2023-08-28 08:20:57 +02:00 committed by GitHub
parent c4a4412920
commit c4bad4a6da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 436 additions and 47 deletions

View File

@ -31,6 +31,10 @@ export enum TargetType {
BROWSER = 'browser',
WEBVIEW = 'webview',
OTHER = 'other',
/**
* @internal
*/
TAB = 'tab',
}
/**

View File

@ -141,6 +141,13 @@ export class Accessibility {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
/**
* Captures the current state of the accessibility tree.
* The returned object represents the root accessible node of the page.

View File

@ -442,7 +442,9 @@ export class CDPBrowser extends BrowserBase {
url: 'about:blank',
browserContextId: contextId || undefined,
});
const target = this.#targetManager.getAvailableTargets().get(targetId);
const target = (await this.waitForTarget(t => {
return (t as CDPTarget)._targetId === targetId;
})) as CDPTarget;
if (!target) {
throw new Error(`Missing target for page (id = ${targetId})`);
}

View File

@ -17,10 +17,11 @@
import {Protocol} from 'devtools-protocol';
import {TargetFilterCallback} from '../api/Browser.js';
import {TargetType} from '../api/Target.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {CDPSession, Connection} from './Connection.js';
import {CDPSession, CDPSessionEmittedEvents, Connection} from './Connection.js';
import {EventEmitter} from './EventEmitter.js';
import {InitializationStatus, CDPTarget} from './Target.js';
import {
@ -31,6 +32,17 @@ import {
} from './TargetManager.js';
import {debugError} from './util.js';
function isTargetExposed(target: CDPTarget): boolean {
return target.type() !== TargetType.TAB && !target._subtype();
}
function isPageTargetBecomingPrimary(
target: CDPTarget,
newTargetInfo: Protocol.Target.TargetInfo
): boolean {
return Boolean(target._subtype()) && !newTargetInfo.subtype;
}
/**
* ChromeTargetManager uses the CDP's auto-attach mechanism to intercept
* new targets and allow the rest of Puppeteer to configure listeners while
@ -86,6 +98,10 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
#targetsIdsForInit = new Set<string>();
#waitForInitiallyDiscoveredTargets = true;
// TODO: remove the flag once the testing/rollout is done.
#tabMode = false;
#discoveryFilter = this.#tabMode ? [{}] : [{type: 'tab', exclude: true}, {}];
constructor(
connection: Connection,
targetFactory: TargetFactory,
@ -107,7 +123,7 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
this.#connection
.send('Target.setDiscoverTargets', {
discover: true,
filter: [{type: 'tab', exclude: true}, {}],
filter: this.#discoveryFilter,
})
.then(this.#storeExistingTargetsForInit)
.catch(debugError);
@ -143,6 +159,15 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
waitForDebuggerOnStart: true,
flatten: true,
autoAttach: true,
filter: this.#tabMode
? [
{
type: 'page',
exclude: true,
},
...this.#discoveryFilter,
]
: this.#discoveryFilter,
});
this.#finishInitializationIfReady();
await this.#initializeDeferred.valueOrThrow();
@ -158,7 +183,13 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
}
getAvailableTargets(): Map<string, CDPTarget> {
return this.#attachedTargetsByTargetId;
const result = new Map<string, CDPTarget>();
for (const [id, target] of this.#attachedTargetsByTargetId.entries()) {
if (isTargetExposed(target)) {
result.set(id, target);
}
}
return result;
}
addTargetInterceptor(
@ -285,6 +316,18 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
const wasInitialized =
target._initializedDeferred.value() === InitializationStatus.SUCCESS;
if (isPageTargetBecomingPrimary(target, event.targetInfo)) {
const target = this.#attachedTargetsByTargetId.get(
event.targetInfo.targetId
);
const session = target?._session();
assert(
session,
'Target that is being activated is missing a CDPSession.'
);
session.parentSession()?.emit(CDPSessionEmittedEvents.Swapped, session);
}
target._targetInfoChanged(event.targetInfo);
if (wasInitialized && previousURL !== target.url()) {
@ -350,7 +393,11 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
const target = existingTarget
? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
: this.#targetFactory(targetInfo, session);
: this.#targetFactory(
targetInfo,
session,
parentSession instanceof CDPSession ? parentSession : undefined
);
if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
this.#ignoredTargets.add(targetInfo.targetId);
@ -391,7 +438,7 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
}
this.#targetsIdsForInit.delete(target._targetId);
if (!existingTarget) {
if (!existingTarget && isTargetExposed(target)) {
this.emit(TargetManagerEmittedEvents.TargetAvailable, target);
}
this.#finishInitializationIfReady();
@ -403,6 +450,7 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
waitForDebuggerOnStart: true,
flatten: true,
autoAttach: true,
filter: this.#discoveryFilter,
}),
session.send('Runtime.runIfWaitingForDebugger'),
]).catch(debugError);
@ -428,6 +476,8 @@ export class ChromeTargetManager extends EventEmitter implements TargetManager {
}
this.#attachedTargetsByTargetId.delete(target._targetId);
this.emit(TargetManagerEmittedEvents.TargetGone, target);
if (isTargetExposed(target)) {
this.emit(TargetManagerEmittedEvents.TargetGone, target);
}
};
}

View File

@ -24,6 +24,7 @@ import {ConnectionTransport} from './ConnectionTransport.js';
import {debug} from './Debug.js';
import {TargetCloseError, ProtocolError} from './Errors.js';
import {EventEmitter} from './EventEmitter.js';
import {CDPTarget} from './Target.js';
import {debugError} from './util.js';
const debugProtocolSend = debug('puppeteer:protocol:SEND ►');
@ -429,6 +430,7 @@ export interface CDPSessionOnMessageObject {
*/
export const CDPSessionEmittedEvents = {
Disconnected: Symbol('CDPSession.Disconnected'),
Swapped: Symbol('CDPSession.Swapped'),
} as const;
/**
@ -515,6 +517,7 @@ export class CDPSessionImpl extends CDPSession {
#callbacks = new CallbackRegistry();
#connection?: Connection;
#parentSessionId?: string;
#target?: CDPTarget;
/**
* @internal
@ -532,6 +535,25 @@ export class CDPSessionImpl extends CDPSession {
this.#parentSessionId = parentSessionId;
}
/**
* Sets the CDPTarget associated with the session instance.
*
* @internal
*/
_setTarget(target: CDPTarget): void {
this.#target = target;
}
/**
* Gets the CDPTarget associated with the session instance.
*
* @internal
*/
_target(): CDPTarget {
assert(this.#target, 'Target must exist');
return this.#target;
}
override connection(): Connection | undefined {
return this.#connection;
}

View File

@ -144,6 +144,14 @@ export class Coverage {
this.#cssCoverage = new CSSCoverage(client);
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#jsCoverage.updateClient(client);
this.#cssCoverage.updateClient(client);
}
/**
* @param options - Set of configurable options for coverage defaults to
* `resetOnNavigation : true, reportAnonymousScripts : false,`
@ -212,6 +220,13 @@ export class JSCoverage {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
async start(
options: {
resetOnNavigation?: boolean;
@ -342,6 +357,13 @@ export class CSSCoverage {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> {
assert(!this.#enabled, 'CSSCoverage is already enabled');
const {resetOnNavigation = true} = options;

View File

@ -35,6 +35,10 @@ export class EmulationManager {
this.#client = client;
}
updateClient(client: CDPSession): void {
this.#client = client;
}
get javascriptEnabled(): boolean {
return this.#javascriptEnabled;
}

View File

@ -49,6 +49,7 @@ export const FrameEmittedEvents = {
LifecycleEvent: Symbol('Frame.LifecycleEvent'),
FrameNavigatedWithinDocument: Symbol('Frame.FrameNavigatedWithinDocument'),
FrameDetached: Symbol('Frame.FrameDetached'),
FrameSwappedByActivation: Symbol('Frame.FrameSwappedByActivation'),
};
/**
@ -82,14 +83,33 @@ export class Frame extends BaseFrame {
this._loaderId = '';
this.updateClient(client);
this.on(FrameEmittedEvents.FrameSwappedByActivation, () => {
// Emulate loading process for swapped frames.
this._onLoadingStarted();
this._onLoadingStopped();
});
}
updateClient(client: CDPSession): void {
/**
* Updates the frame ID with the new ID. This happens when the main frame is
* replaced by a different frame.
*/
updateId(id: string): void {
this._id = id;
}
updateClient(client: CDPSession, keepWorlds = false): void {
this.#client = client;
this.worlds = {
[MAIN_WORLD]: new IsolatedWorld(this),
[PUPPETEER_WORLD]: new IsolatedWorld(this),
};
if (!keepWorlds) {
this.worlds = {
[MAIN_WORLD]: new IsolatedWorld(this),
[PUPPETEER_WORLD]: new IsolatedWorld(this),
};
} else {
this.worlds[MAIN_WORLD].frameUpdated();
this.worlds[PUPPETEER_WORLD].frameUpdated();
}
}
override page(): Page {

View File

@ -18,11 +18,13 @@ import {Protocol} from 'devtools-protocol';
import {Page} from '../api/Page.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {isErrorLike} from '../util/ErrorLike.js';
import {
CDPSession,
CDPSessionEmittedEvents,
CDPSessionImpl,
isTargetClosedError,
} from './Connection.js';
import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js';
@ -59,6 +61,8 @@ export const FrameManagerEmittedEvents = {
),
};
const TIME_FOR_WAITING_FOR_SWAP = 100; // ms.
/**
* A frame manager manages the frames for a given {@link Page | page}.
*
@ -113,13 +117,71 @@ export class FrameManager extends EventEmitter {
this.#timeoutSettings = timeoutSettings;
this.setupEventListeners(this.#client);
client.once(CDPSessionEmittedEvents.Disconnected, () => {
const mainFrame = this._frameTree.getMainFrame();
if (mainFrame) {
this.#removeFramesRecursively(mainFrame);
}
this.#onClientDisconnect().catch(debugError);
});
}
/**
* Called when the frame's client is disconnected. We don't know if the
* disconnect means that the frame is removed or if it will be replaced by a
* new frame. Therefore, we wait for a swap event.
*/
async #onClientDisconnect() {
const mainFrame = this._frameTree.getMainFrame();
if (!mainFrame) {
return;
}
for (const child of mainFrame.childFrames()) {
this.#removeFramesRecursively(child);
}
const swapped = Deferred.create<void>({
timeout: TIME_FOR_WAITING_FOR_SWAP,
message: 'Frame was not swapped',
});
mainFrame.once(FrameEmittedEvents.FrameSwappedByActivation, () => {
swapped.resolve();
});
try {
await swapped.valueOrThrow();
} catch (err) {
this.#removeFramesRecursively(mainFrame);
}
}
/**
* When the main frame is replaced by another main frame,
* we maintain the main frame object identity while updating
* its frame tree and ID.
*/
async swapFrameTree(client: CDPSession): Promise<void> {
this.#onExecutionContextsCleared(this.#client);
this.#client = client;
assert(
this.#client instanceof CDPSessionImpl,
'CDPSession is not an instance of CDPSessionImpl.'
);
const frame = this._frameTree.getMainFrame();
if (frame) {
this.#frameNavigatedReceived.add(this.#client._target()._targetId);
this._frameTree.removeFrame(frame);
frame.updateId(this.#client._target()._targetId);
frame.mainRealm().clearContext();
frame.isolatedRealm().clearContext();
this._frameTree.addFrame(frame);
frame.updateClient(client, true);
}
this.setupEventListeners(client);
client.once(CDPSessionEmittedEvents.Disconnected, () => {
this.#onClientDisconnect().catch(debugError);
});
await this.initialize(client);
await this.#networkManager.updateClient(client);
if (frame) {
frame.emit(FrameEmittedEvents.FrameSwappedByActivation);
}
}
private setupEventListeners(session: CDPSession) {
session.on('Page.frameAttached', event => {
this.#onFrameAttached(session, event.frameId, event.parentFrameId);

View File

@ -59,6 +59,13 @@ export class CDPKeyboard extends Keyboard {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
override async down(
key: KeyInput,
options: Readonly<KeyDownOptions> = {
@ -290,6 +297,13 @@ export class CDPMouse extends Mouse {
this.#keyboard = keyboard;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
#_state: Readonly<MouseState> = {
position: {x: 0, y: 0},
buttons: MouseButtonFlag.None,
@ -571,6 +585,13 @@ export class CDPTouchscreen extends Touchscreen {
this.#keyboard = keyboard;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
override async tap(x: number, y: number): Promise<void> {
await this.touchStart(x, y);
await this.touchEnd();

View File

@ -124,9 +124,11 @@ export class IsolatedWorld implements Realm {
}
constructor(frame: Frame) {
// Keep own reference to client because it might differ from the FrameManager's
// client for OOP iframes.
this.#frame = frame;
this.frameUpdated();
}
frameUpdated(): void {
this.#client.on('Runtime.bindingCalled', this.#onBindingCalled);
}

View File

@ -117,6 +117,11 @@ export class LifecycleWatcher {
FrameEmittedEvents.FrameSwapped,
this.#frameSwapped.bind(this)
),
addEventListener(
frame,
FrameEmittedEvents.FrameSwappedByActivation,
this.#frameSwapped.bind(this)
),
addEventListener(
frame,
FrameEmittedEvents.FrameDetached,

View File

@ -21,7 +21,7 @@ import {createDebuggableDeferred} from '../util/DebuggableDeferred.js';
import {Deferred} from '../util/Deferred.js';
import {CDPSession} from './Connection.js';
import {EventEmitter} from './EventEmitter.js';
import {EventEmitter, Handler} from './EventEmitter.js';
import {FrameManager} from './FrameManager.js';
import {HTTPRequest} from './HTTPRequest.js';
import {HTTPResponse} from './HTTPResponse.js';
@ -90,6 +90,23 @@ export class NetworkManager extends EventEmitter {
};
#deferredInit?: Deferred<void>;
#handlers = new Map<string, Handler<any>>([
['Fetch.requestPaused', this.#onRequestPaused.bind(this)],
['Fetch.authRequired', this.#onAuthRequired.bind(this)],
['Network.requestWillBeSent', this.#onRequestWillBeSent.bind(this)],
[
'Network.requestServedFromCache',
this.#onRequestServedFromCache.bind(this),
],
['Network.responseReceived', this.#onResponseReceived.bind(this)],
['Network.loadingFinished', this.#onLoadingFinished.bind(this)],
['Network.loadingFailed', this.#onLoadingFailed.bind(this)],
[
'Network.responseReceivedExtraInfo',
this.#onResponseReceivedExtraInfo.bind(this),
],
]);
constructor(
client: CDPSession,
ignoreHTTPSErrors: boolean,
@ -100,29 +117,18 @@ export class NetworkManager extends EventEmitter {
this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#frameManager = frameManager;
this.#client.on('Fetch.requestPaused', this.#onRequestPaused.bind(this));
this.#client.on('Fetch.authRequired', this.#onAuthRequired.bind(this));
this.#client.on(
'Network.requestWillBeSent',
this.#onRequestWillBeSent.bind(this)
);
this.#client.on(
'Network.requestServedFromCache',
this.#onRequestServedFromCache.bind(this)
);
this.#client.on(
'Network.responseReceived',
this.#onResponseReceived.bind(this)
);
this.#client.on(
'Network.loadingFinished',
this.#onLoadingFinished.bind(this)
);
this.#client.on('Network.loadingFailed', this.#onLoadingFailed.bind(this));
this.#client.on(
'Network.responseReceivedExtraInfo',
this.#onResponseReceivedExtraInfo.bind(this)
);
for (const [event, handler] of this.#handlers) {
this.#client.on(event, handler);
}
}
async updateClient(client: CDPSession): Promise<void> {
this.#client = client;
for (const [event, handler] of this.#handlers) {
this.#client.on(event, handler);
}
this.#deferredInit = undefined;
await this.initialize();
}
/**

View File

@ -46,6 +46,7 @@ import {Binding} from './Binding.js';
import {
CDPSession,
CDPSessionEmittedEvents,
CDPSessionImpl,
isTargetClosedError,
} from './Connection.js';
import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js';
@ -127,6 +128,7 @@ export class CDPPage extends Page {
#closed = false;
#client: CDPSession;
#tabSession: CDPSession | undefined;
#target: CDPTarget;
#keyboard: CDPKeyboard;
#mouse: CDPMouse;
@ -289,6 +291,7 @@ export class CDPPage extends Page {
) {
super();
this.#client = client;
this.#tabSession = client.parentSession();
this.#target = target;
this.#keyboard = new CDPKeyboard(client);
this.#mouse = new CDPMouse(client, this.#keyboard);
@ -307,6 +310,25 @@ export class CDPPage extends Page {
this.#viewport = null;
this.#setupEventListeners();
this.#tabSession?.on(CDPSessionEmittedEvents.Swapped, async newSession => {
this.#client = newSession;
assert(
this.#client instanceof CDPSessionImpl,
'CDPSession is not instance of CDPSessionImpl'
);
this.#target = this.#client._target();
assert(this.#target, 'Missing target on swap');
this.#keyboard.updateClient(newSession);
this.#mouse.updateClient(newSession);
this.#touchscreen.updateClient(newSession);
this.#accessibility.updateClient(newSession);
this.#emulationManager.updateClient(newSession);
this.#tracing.updateClient(newSession);
this.#coverage.updateClient(newSession);
await this.#frameManager.swapFrameTree(newSession);
this.#setupEventListeners();
});
}
#setupEventListeners() {

View File

@ -22,7 +22,7 @@ import {Page, PageEmittedEvents} from '../api/Page.js';
import {Target, TargetType} from '../api/Target.js';
import {Deferred} from '../util/Deferred.js';
import {CDPSession} from './Connection.js';
import {CDPSession, CDPSessionImpl} from './Connection.js';
import {CDPPage} from './Page.js';
import {Viewport} from './PuppeteerViewport.js';
import {TargetManager} from './TargetManager.js';
@ -84,6 +84,16 @@ export class CDPTarget extends Target {
this.#browserContext = browserContext;
this._targetId = targetInfo.targetId;
this.#sessionFactory = sessionFactory;
if (this.#session && this.#session instanceof CDPSessionImpl) {
this.#session._setTarget(this);
}
}
/**
* @internal
*/
_subtype(): string | undefined {
return this.#targetInfo.subtype;
}
/**
@ -109,7 +119,10 @@ export class CDPTarget extends Target {
if (!this.#sessionFactory) {
throw new Error('sessionFactory is not initialized');
}
return this.#sessionFactory(false);
return this.#sessionFactory(false).then(session => {
(session as CDPSessionImpl)._setTarget(this);
return session;
});
}
override url(): string {
@ -131,6 +144,8 @@ export class CDPTarget extends Target {
return TargetType.BROWSER;
case 'webview':
return TargetType.WEBVIEW;
case 'tab':
return TargetType.TAB;
default:
return TargetType.OTHER;
}

View File

@ -25,7 +25,8 @@ import {CDPTarget} from './Target.js';
*/
export type TargetFactory = (
targetInfo: Protocol.Target.TargetInfo,
session?: CDPSession
session?: CDPSession,
parentSession?: CDPSession
) => CDPTarget;
/**

View File

@ -57,6 +57,13 @@ export class Tracing {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
/**
* Starts a trace for the current page.
* @remarks

View File

@ -178,7 +178,7 @@ export class ChromeLauncher extends ProductLauncher {
'--disable-dev-shm-usage',
'--disable-extensions',
// AcceptCHFrame disabled because of crbug.com/1348106.
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints',
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,Prerender2',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-popup-blocking',

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<head>
<script>
function addRules() {
const script = document.createElement('script');
script.type = 'speculationrules';
script.innerText = `
{
"prerender": [
{"source": "list", "urls": ["target.html"]}
]
}
`;
document.head.append(script);
}
</script>
</head>
<body>
<button onclick="addRules()">add rules</button>
<a href="target.html">test</a>
</body>

View File

@ -0,0 +1,4 @@
<!DOCTYPE html>
<head></head>
</head>
<body>target</body>

View File

@ -82,7 +82,7 @@ describe('Launcher specs', function () {
});
remote.disconnect();
const error = await watchdog;
expect(error.message).toContain('frame got detached');
expect(error.message).toContain('Session closed.');
} finally {
await close();
}

View File

@ -0,0 +1,92 @@
/**
* Copyright 2023 Google Inc. 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 expect from 'expect';
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
describe('Prerender', function () {
setupTestBrowserHooks();
it('can navigate to a prerendered page via input', async () => {
const {page, server} = await getTestState();
await page.goto(server.PREFIX + '/prerender/index.html');
const button = await page.waitForSelector('button');
await button?.click();
const link = await page.waitForSelector('a');
await Promise.all([page.waitForNavigation(), link?.click()]);
expect(
await page.evaluate(() => {
return document.body.innerText;
})
).toBe('target');
});
it('can navigate to a prerendered page via Puppeteer', async () => {
const {page, server} = await getTestState();
await page.goto(server.PREFIX + '/prerender/index.html');
const button = await page.waitForSelector('button');
await button?.click();
await page.goto(server.PREFIX + '/prerender/target.html');
expect(
await page.evaluate(() => {
return document.body.innerText;
})
).toBe('target');
});
describe('via frame', () => {
it('can navigate to a prerendered page via input', async () => {
const {page, server} = await getTestState();
await page.goto(server.PREFIX + '/prerender/index.html');
const button = await page.waitForSelector('button');
await button?.click();
const mainFrame = page.mainFrame();
const link = await mainFrame.waitForSelector('a');
await Promise.all([mainFrame.waitForNavigation(), link?.click()]);
expect(mainFrame).toBe(page.mainFrame());
expect(
await mainFrame.evaluate(() => {
return document.body.innerText;
})
).toBe('target');
expect(mainFrame).toBe(page.mainFrame());
});
it('can navigate to a prerendered page via Puppeteer', async () => {
const {page, server} = await getTestState();
await page.goto(server.PREFIX + '/prerender/index.html');
const button = await page.waitForSelector('button');
await button?.click();
const mainFrame = page.mainFrame();
await mainFrame.goto(server.PREFIX + '/prerender/target.html');
expect(
await mainFrame.evaluate(() => {
return document.body.innerText;
})
).toBe('target');
expect(mainFrame).toBe(page.mainFrame());
});
});
});