feat(api): wait for popups and downloads when performing actions (#1744)

This commit is contained in:
Dmitry Gozman 2020-04-16 13:09:24 -07:00 коммит произвёл GitHub
Родитель 67cd5698a7
Коммит f5942295d4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 185 добавлений и 47 удалений

Просмотреть файл

@ -134,6 +134,10 @@ export class CRBrowser extends BrowserBase {
const opener = targetInfo.openerId ? this._crPages.get(targetInfo.openerId) || null : null;
const crPage = new CRPage(session, targetInfo.targetId, context, opener);
this._crPages.set(targetInfo.targetId, crPage);
if (opener && opener._initializedPage) {
for (const signalBarrier of opener._initializedPage._frameManager._signalBarriers)
signalBarrier.addPopup(crPage.pageOrError());
}
crPage.pageOrError().then(() => {
this._firstPageCallback();
context.emit(CommonEvents.BrowserContext.Page, crPage._page);

Просмотреть файл

@ -315,7 +315,8 @@ class FrameSession {
private _eventListeners: RegisteredListener[] = [];
readonly _targetId: string;
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
private _firstNonInitialNavigationCommittedCallback = () => {};
private _firstNonInitialNavigationCommittedFulfill = () => {};
private _firstNonInitialNavigationCommittedReject = (e: Error) => {};
constructor(crPage: CRPage, client: CRSession, targetId: string) {
this._client = client;
@ -323,7 +324,13 @@ class FrameSession {
this._page = crPage._page;
this._targetId = targetId;
this._networkManager = new CRNetworkManager(client, this._page);
this._firstNonInitialNavigationCommittedPromise = new Promise(f => this._firstNonInitialNavigationCommittedCallback = f);
this._firstNonInitialNavigationCommittedPromise = new Promise((f, r) => {
this._firstNonInitialNavigationCommittedFulfill = f;
this._firstNonInitialNavigationCommittedReject = r;
});
client.once(CRSessionEvents.Disconnected, () => {
this._firstNonInitialNavigationCommittedReject(new Error('Page closed'));
});
}
private _isMainFrame(): boolean {
@ -386,7 +393,7 @@ class FrameSession {
this._eventListeners.push(helper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)));
});
} else {
this._firstNonInitialNavigationCommittedCallback();
this._firstNonInitialNavigationCommittedFulfill();
this._eventListeners.push(helper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)));
}
}),
@ -479,7 +486,7 @@ class FrameSession {
_onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) {
this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url, framePayload.name || '', framePayload.loaderId, initial);
if (!initial)
this._firstNonInitialNavigationCommittedCallback();
this._firstNonInitialNavigationCommittedFulfill();
}
_onFrameRequestedNavigation(payload: Protocol.Page.frameRequestedNavigationPayload) {

Просмотреть файл

@ -55,7 +55,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
}
async _doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
return await this.frame._page._frameManager.waitForNavigationsCreatedBy(async () => {
return await this.frame._page._frameManager.waitForSignalsCreatedBy(async () => {
return this._delegate.evaluate(this, returnByValue, pageFunction, ...args);
}, Number.MAX_SAFE_INTEGER, waitForNavigations ? undefined : { waitUntil: 'nowait' });
}
@ -227,7 +227,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (!force)
await this._waitForHitTargetAt(point, deadline);
await this._page._frameManager.waitForNavigationsCreatedBy(async () => {
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
let restoreModifiers: input.Modifier[] | undefined;
if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
@ -269,7 +269,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (option.index !== undefined)
assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"');
}
return await this._page._frameManager.waitForNavigationsCreatedBy<string[]>(async () => {
return await this._page._frameManager.waitForSignalsCreatedBy<string[]>(async () => {
return this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions);
}, deadline, options);
}
@ -277,7 +277,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async fill(value: string, options?: types.NavigatingActionWaitOptions): Promise<void> {
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForNavigationsCreatedBy(async () => {
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
const errorOrNeedsInput = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
if (typeof errorOrNeedsInput === 'string')
throw new Error(errorOrNeedsInput);
@ -323,7 +323,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
filePayloads.push(item);
}
}
await this._page._frameManager.waitForNavigationsCreatedBy(async () => {
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, filePayloads);
}, deadline, options);
}
@ -341,7 +341,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async type(text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForNavigationsCreatedBy(async () => {
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
await this.focus();
await this._page.keyboard.type(text, options);
}, deadline, options, true);
@ -349,7 +349,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async press(key: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForNavigationsCreatedBy(async () => {
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
await this.focus();
await this._page.keyboard.press(key, options);
}, deadline, options, true);

Просмотреть файл

@ -39,6 +39,8 @@ export class Download {
this._url = url;
this._finishedCallback = () => {};
this._finishedPromise = new Promise(f => this._finishedCallback = f);
for (const barrier of this._page._frameManager._signalBarriers)
barrier.addDownload();
this._page.emit(Events.Page.Download, this);
page._browserContext._downloads.add(this);
this._acceptDownloads = !!this._page._browserContext._options.acceptDownloads;

Просмотреть файл

@ -129,6 +129,10 @@ export class FFBrowser extends BrowserBase {
const ffPage = new FFPage(session, context, opener);
this._ffPages.set(targetId, ffPage);
if (opener && opener._initializedPage) {
for (const signalBarrier of opener._initializedPage._frameManager._signalBarriers)
signalBarrier.addPopup(ffPage.pageOrError());
}
ffPage.pageOrError().then(async () => {
this._firstPageCallback();
const page = ffPage._page;
@ -200,7 +204,7 @@ export class FFBrowserContext extends BrowserContextBase {
}
pages(): Page[] {
return this._ffPages().map(ffPage => ffPage._initializedPage()).filter(pageOrNull => !!pageOrNull) as Page[];
return this._ffPages().map(ffPage => ffPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
}
async newPage(): Promise<Page> {

Просмотреть файл

@ -43,7 +43,7 @@ export class FFPage implements PageDelegate {
readonly _browserContext: FFBrowserContext;
private _pagePromise: Promise<Page | Error>;
private _pageCallback: (pageOrError: Page | Error) => void = () => {};
private _initialized = false;
_initializedPage: Page | null = null;
private readonly _opener: FFPage | null;
private readonly _contextIdToContext: Map<string, dom.FrameExecutionContext>;
private _eventListeners: RegisteredListener[];
@ -70,6 +70,7 @@ export class FFPage implements PageDelegate {
helper.addEventListener(this._session, 'Runtime.executionContextCreated', this._onExecutionContextCreated.bind(this)),
helper.addEventListener(this._session, 'Runtime.executionContextDestroyed', this._onExecutionContextDestroyed.bind(this)),
helper.addEventListener(this._session, 'Page.linkClicked', event => this._onLinkClicked(event.phase)),
helper.addEventListener(this._session, 'Page.willOpenNewWindowAsynchronously', this._onWillOpenNewWindowAsynchronously.bind(this)),
helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)),
helper.addEventListener(this._session, 'Runtime.console', this._onConsole.bind(this)),
helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)),
@ -84,17 +85,13 @@ export class FFPage implements PageDelegate {
session.once(FFSessionEvents.Disconnected, () => this._page._didDisconnect());
this._session.once('Page.ready', () => {
this._pageCallback(this._page);
this._initialized = true;
this._initializedPage = this._page;
});
// 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._session.send('Page.addScriptToEvaluateOnNewDocument', { script: '', worldName: UTILITY_WORLD_NAME }).catch(this._pageCallback);
}
_initializedPage(): Page | null {
return this._initialized ? this._page : null;
}
async pageOrError(): Promise<Page | Error> {
return this._pagePromise;
}
@ -136,12 +133,21 @@ export class FFPage implements PageDelegate {
this._page._frameManager.frameDidPotentiallyRequestNavigation();
}
_onWillOpenNewWindowAsynchronously() {
for (const barrier of this._page._frameManager._signalBarriers)
barrier.expectPopup();
}
_onNavigationStarted(params: Protocol.Page.navigationStartedPayload) {
this._page._frameManager.frameRequestedNavigation(params.frameId, params.navigationId);
}
_onNavigationAborted(params: Protocol.Page.navigationAbortedPayload) {
const frame = this._page._frameManager.frame(params.frameId)!;
if (params.errorText === 'Will download to file') {
for (const barrier of this._page._frameManager._signalBarriers)
barrier.expectDownload();
}
for (const task of frame._frameTasks)
task.onNewDocument(params.navigationId, new Error(params.errorText));
}

Просмотреть файл

@ -51,7 +51,7 @@ export class FrameManager {
private _frames = new Map<string, Frame>();
private _mainFrame: Frame;
readonly _consoleMessageTags = new Map<string, ConsoleTagHandler>();
private _pendingNavigationBarriers = new Set<PendingNavigationBarrier>();
readonly _signalBarriers = new Set<SignalBarrier>();
constructor(page: Page) {
this._page = page;
@ -100,11 +100,11 @@ export class FrameManager {
}
}
async waitForNavigationsCreatedBy<T>(action: () => Promise<T>, deadline: number, options: types.NavigatingActionWaitOptions = {}, input?: boolean): Promise<T> {
async waitForSignalsCreatedBy<T>(action: () => Promise<T>, deadline: number, options: types.NavigatingActionWaitOptions = {}, input?: boolean): Promise<T> {
if (options.waitUntil === 'nowait')
return action();
const barrier = new PendingNavigationBarrier({ waitUntil: 'domcontentloaded', ...options }, deadline);
this._pendingNavigationBarriers.add(barrier);
const barrier = new SignalBarrier({ waitUntil: 'domcontentloaded', ...options }, deadline);
this._signalBarriers.add(barrier);
try {
const result = await action();
if (input)
@ -114,17 +114,17 @@ export class FrameManager {
await new Promise(helper.makeWaitForNextTask());
return result;
} finally {
this._pendingNavigationBarriers.delete(barrier);
this._signalBarriers.delete(barrier);
}
}
frameWillPotentiallyRequestNavigation() {
for (const barrier of this._pendingNavigationBarriers)
for (const barrier of this._signalBarriers)
barrier.retain();
}
frameDidPotentiallyRequestNavigation() {
for (const barrier of this._pendingNavigationBarriers)
for (const barrier of this._signalBarriers)
barrier.release();
}
@ -132,8 +132,8 @@ export class FrameManager {
const frame = this._frames.get(frameId);
if (!frame)
return;
for (const barrier of this._pendingNavigationBarriers)
barrier.addFrame(frame);
for (const barrier of this._signalBarriers)
barrier.addFrameNavigation(frame);
frame._pendingDocumentId = documentId;
}
@ -939,10 +939,12 @@ function selectorToString(selector: string, waitFor: 'attached' | 'detached' | '
return `${label}${selector}`;
}
class PendingNavigationBarrier {
export class SignalBarrier {
private _frameIds = new Map<string, number>();
private _options: types.NavigatingActionWaitOptions;
private _protectCount = 0;
private _expectedPopups = 0;
private _expectedDownloads = 0;
private _promise: Promise<void>;
private _promiseCallback = () => {};
private _deadline: number;
@ -959,7 +961,7 @@ class PendingNavigationBarrier {
return this._promise;
}
async addFrame(frame: Frame) {
async addFrameNavigation(frame: Frame) {
this.retain();
const timeout = helper.timeUntilDeadline(this._deadline);
const options = { ...this._options, timeout } as types.NavigateOptions;
@ -967,6 +969,33 @@ class PendingNavigationBarrier {
this.release();
}
async expectPopup() {
++this._expectedPopups;
}
async unexpectPopup() {
--this._expectedPopups;
this._maybeResolve();
}
async addPopup(pageOrError: Promise<Page | Error>) {
if (this._expectedPopups)
--this._expectedPopups;
this.retain();
await pageOrError;
this.release();
}
async expectDownload() {
++this._expectedDownloads;
}
async addDownload() {
if (this._expectedDownloads)
--this._expectedDownloads;
this._maybeResolve();
}
retain() {
++this._protectCount;
}
@ -977,7 +1006,7 @@ class PendingNavigationBarrier {
}
private async _maybeResolve() {
if (!this._protectCount && !this._frameIds.size)
if (!this._protectCount && !this._expectedPopups && !this._expectedDownloads && !this._frameIds.size)
this._promiseCallback();
}
}

Просмотреть файл

@ -128,6 +128,10 @@ export class WKBrowser extends BrowserBase {
const wkPage = new WKPage(context, pageProxySession, opener || null);
this._wkPages.set(pageProxyId, wkPage);
if (opener && opener._initializedPage) {
for (const signalBarrier of opener._initializedPage._frameManager._signalBarriers)
signalBarrier.addPopup(wkPage.pageOrError());
}
wkPage.pageOrError().then(async () => {
this._firstPageCallback();
const page = wkPage._page;
@ -216,7 +220,7 @@ export class WKBrowserContext extends BrowserContextBase {
}
pages(): Page[] {
return this._wkPages().map(wkPage => wkPage._initializedPage()).filter(pageOrNull => !!pageOrNull) as Page[];
return this._wkPages().map(wkPage => wkPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
}
async newPage(): Promise<Page> {

Просмотреть файл

@ -58,9 +58,10 @@ export class WKPage implements PageDelegate {
private _eventListeners: RegisteredListener[];
private readonly _evaluateOnNewDocumentSources: string[] = [];
readonly _browserContext: WKBrowserContext;
private _initialized = false;
_initializedPage: Page | null = null;
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
private _firstNonInitialNavigationCommittedCallback = () => {};
private _firstNonInitialNavigationCommittedFulfill = () => {};
private _firstNonInitialNavigationCommittedReject = (e: Error) => {};
constructor(browserContext: WKBrowserContext, pageProxySession: WKSession, opener: WKPage | null) {
this._pageProxySession = pageProxySession;
@ -80,11 +81,10 @@ export class WKPage implements PageDelegate {
helper.addEventListener(this._pageProxySession, 'Target.didCommitProvisionalTarget', this._onDidCommitProvisionalTarget.bind(this)),
];
this._pagePromise = new Promise(f => this._pagePromiseCallback = f);
this._firstNonInitialNavigationCommittedPromise = new Promise(f => this._firstNonInitialNavigationCommittedCallback = f);
}
_initializedPage(): Page | null {
return this._initialized ? this._page : null;
this._firstNonInitialNavigationCommittedPromise = new Promise((f, r) => {
this._firstNonInitialNavigationCommittedFulfill = f;
this._firstNonInitialNavigationCommittedReject = r;
});
}
private async _initializePageProxySession() {
@ -217,6 +217,7 @@ export class WKPage implements PageDelegate {
this._provisionalPage = null;
}
this._page._didDisconnect();
this._firstNonInitialNavigationCommittedReject(new Error('Page closed'));
}
dispatchMessageToSession(message: any) {
@ -224,7 +225,11 @@ export class WKPage implements PageDelegate {
}
handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) {
if (!this._initialized || !this._provisionalPage)
if (!this._initializedPage) {
this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed'));
return;
}
if (!this._provisionalPage)
return;
let errorText = event.error;
if (errorText.includes('cancelled'))
@ -247,7 +252,7 @@ export class WKPage implements PageDelegate {
});
assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type);
if (!this._initialized) {
if (!this._initializedPage) {
assert(!targetInfo.isProvisional);
let pageOrError: Page | Error;
try {
@ -263,12 +268,19 @@ export class WKPage implements PageDelegate {
if (targetInfo.isPaused)
this._pageProxySession.send('Target.resume', { targetId: targetInfo.targetId }).catch(debugError);
if ((pageOrError instanceof Page) && this._page.mainFrame().url() === '') {
// Initial empty page has an empty url. We should wait until the first real url has been loaded,
// even if that url is about:blank. This is especially important for popups, where we need the
// actual url before interacting with it.
await this._firstNonInitialNavigationCommittedPromise;
try {
// Initial empty page has an empty url. We should wait until the first real url has been loaded,
// even if that url is about:blank. This is especially important for popups, where we need the
// actual url before interacting with it.
await this._firstNonInitialNavigationCommittedPromise;
} catch (e) {
pageOrError = e;
}
} else {
// Avoid rejection on disconnect.
this._firstNonInitialNavigationCommittedPromise.catch(() => {});
}
this._initialized = true;
this._initializedPage = pageOrError instanceof Page ? pageOrError : null;
this._pagePromiseCallback(pageOrError);
} else {
assert(targetInfo.isProvisional);
@ -302,6 +314,8 @@ export class WKPage implements PageDelegate {
helper.addEventListener(this._session, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)),
helper.addEventListener(this._session, 'Page.loadEventFired', event => this._onLifecycleEvent(event.frameId, 'load')),
helper.addEventListener(this._session, 'Page.domContentEventFired', event => this._onLifecycleEvent(event.frameId, 'domcontentloaded')),
helper.addEventListener(this._session, 'Page.willRequestOpenWindow', event => this._onWillRequestOpenWindow()),
helper.addEventListener(this._session, 'Page.didRequestOpenWindow', event => this._onDidRequestOpenWindow(event)),
helper.addEventListener(this._session, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)),
helper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)),
helper.addEventListener(this._pageProxySession, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)),
@ -344,6 +358,18 @@ export class WKPage implements PageDelegate {
this._page._frameManager.frameLifecycleEvent(frameId, event);
}
private _onWillRequestOpenWindow() {
for (const barrier of this._page._frameManager._signalBarriers)
barrier.expectPopup();
}
private _onDidRequestOpenWindow(event: Protocol.Page.didRequestOpenWindowPayload) {
if (!event.opened) {
for (const barrier of this._page._frameManager._signalBarriers)
barrier.unexpectPopup();
}
}
private _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) {
this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null);
this._onFrameNavigated(frameTree.frame, true);
@ -368,7 +394,7 @@ export class WKPage implements PageDelegate {
this._workers.clear();
this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url, framePayload.name || '', framePayload.loaderId, initial);
if (!initial)
this._firstNonInitialNavigationCommittedCallback();
this._firstNonInitialNavigationCommittedFulfill();
}
private _onFrameNavigatedWithinDocument(frameId: string, url: string) {

Просмотреть файл

@ -34,6 +34,40 @@ describe('Auto waiting', () => {
]);
expect(messages.join('|')).toBe('route|domcontentloaded|click');
});
it('should await popup when clicking anchor', async function({page, server}) {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<a target=_blank rel=opener href="/empty.html">link</a>');
const messages = [];
await Promise.all([
page.waitForEvent('popup').then(() => messages.push('popup')),
page.click('a').then(() => messages.push('click')),
]);
expect(messages.join('|')).toBe('popup|click');
});
it('should await popup when clicking anchor with noopener', async function({page, server}) {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<a target=_blank rel=noopener href="/empty.html">link</a>');
const messages = [];
await Promise.all([
page.waitForEvent('popup').then(() => messages.push('popup')),
page.click('a').then(() => messages.push('click')),
]);
expect(messages.join('|')).toBe('popup|click');
});
it('should await download when clicking anchor', async function({page, server}) {
server.setRoute('/download', (req, res) => {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment');
res.end(`Hello world`);
});
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const messages = [];
await Promise.all([
page.waitForEvent('download').then(() => messages.push('download')),
page.click('a').then(() => messages.push('click')),
]);
expect(messages.join('|')).toBe('download|click');
});
it('should await cross-process navigation when clicking anchor', async({page, server}) => {
const messages = [];
server.setRoute('/empty.html', async (req, res) => {
@ -130,6 +164,15 @@ describe('Auto waiting', () => {
]);
expect(messages.join('|')).toBe('route|domcontentloaded|evaluate');
});
it('should await new popup when evaluating', async function({page, server}) {
await page.goto(server.EMPTY_PAGE);
const messages = [];
await Promise.all([
page.waitForEvent('popup').then(() => messages.push('popup')),
page.evaluate(() => window._popup = window.open(window.location.href)).then(() => messages.push('evaluate')),
]);
expect(messages.join('|')).toBe('popup|evaluate');
});
it('should await navigating specified target', async({page, server}) => {
const messages = [];
server.setRoute('/empty.html', async (req, res) => {

Просмотреть файл

@ -630,7 +630,6 @@ describe('Events.BrowserContext.Page', function() {
context.waitForEvent('page'),
page.goto(server.PREFIX + '/popup/window-open.html')
]);
// The url is still about:blank in FF when 'page' event is fired.
expect(popup.url()).toBe(server.PREFIX + '/popup/popup.html');
expect(await popup.opener()).toBe(page);
expect(await page.opener()).toBe(null);

Просмотреть файл

@ -217,6 +217,20 @@ describe('Page.Events.Popup', function() {
expect(popup).toBeTruthy();
await context.close();
});
it('should emit for immediately closed popups', async({browser, server}) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.evaluate(() => {
const win = window.open(window.location.href);
win.close();
}),
]);
expect(popup).toBeTruthy();
await context.close();
});
it('should be able to capture alert', async({browser}) => {
const context = await browser.newContext();
const page = await context.newPage();