Skip to content

Commit aa4c893

Browse files
authored
feat(rpc): implement waitForNavigation on the client (#2949)
Drive-by: fix electron issues, exposed by the test using waitForNavigation. Drive-by: mark some tests skip(CHANNEL) that were mistakenly marked skip(USES_HOOKS).
1 parent 824f649 commit aa4c893

19 files changed

+144
-64
lines changed

src/frames.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,16 @@ type ConsoleTagHandler = () => void;
5353
export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any;
5454

5555
export const kNavigationEvent = Symbol('navigation');
56-
type NavigationEvent = {
57-
documentInfo?: DocumentInfo, // Undefined for same-document navigations.
56+
export type NavigationEvent = {
57+
// New frame url after navigation.
58+
url: string,
59+
// New frame name after navigation.
60+
name: string,
61+
// Information about the new document for cross-document navigations.
62+
// Undefined for same-document navigations.
63+
newDocument?: DocumentInfo,
64+
// Error for cross-document navigations if any. When error is present,
65+
// the navigation did not commit.
5866
error?: Error,
5967
};
6068
export const kAddLifecycleEvent = Symbol('addLifecycle');
@@ -172,9 +180,9 @@ export class FrameManager {
172180
else
173181
frame._currentDocument = { documentId, request: undefined };
174182
frame._pendingDocument = undefined;
175-
const navigationEvent: NavigationEvent = { documentInfo: frame._currentDocument };
176-
frame._eventEmitter.emit(kNavigationEvent, navigationEvent);
177183
frame._onClearLifecycle();
184+
const navigationEvent: NavigationEvent = { url, name, newDocument: frame._currentDocument };
185+
frame._eventEmitter.emit(kNavigationEvent, navigationEvent);
178186
if (!initial) {
179187
this._page._logger.info(` navigated to "${url}"`);
180188
this._page.emit(Events.Page.FrameNavigated, frame);
@@ -186,7 +194,7 @@ export class FrameManager {
186194
if (!frame)
187195
return;
188196
frame._url = url;
189-
const navigationEvent: NavigationEvent = {};
197+
const navigationEvent: NavigationEvent = { url, name: frame._name };
190198
frame._eventEmitter.emit(kNavigationEvent, navigationEvent);
191199
this._page._logger.info(` navigated to "${url}"`);
192200
this._page.emit(Events.Page.FrameNavigated, frame);
@@ -198,7 +206,12 @@ export class FrameManager {
198206
return;
199207
if (documentId !== undefined && frame._pendingDocument.documentId !== documentId)
200208
return;
201-
const navigationEvent: NavigationEvent = { documentInfo: frame._pendingDocument, error: new Error(errorText) };
209+
const navigationEvent: NavigationEvent = {
210+
url: frame._url,
211+
name: frame._name,
212+
newDocument: frame._pendingDocument,
213+
error: new Error(errorText),
214+
};
202215
frame._pendingDocument = undefined;
203216
frame._eventEmitter.emit(kNavigationEvent, navigationEvent);
204217
}
@@ -410,7 +423,7 @@ export class Frame {
410423
}
411424
url = helper.completeUserURL(url);
412425

413-
const sameDocument = helper.waitForEvent(progress, this._eventEmitter, kNavigationEvent, (e: NavigationEvent) => !e.documentInfo);
426+
const sameDocument = helper.waitForEvent(progress, this._eventEmitter, kNavigationEvent, (e: NavigationEvent) => !e.newDocument);
414427
const navigateResult = await this._page._delegate.navigateFrame(this, url, referer);
415428

416429
let event: NavigationEvent;
@@ -419,10 +432,10 @@ export class Frame {
419432
event = await helper.waitForEvent(progress, this._eventEmitter, kNavigationEvent, (event: NavigationEvent) => {
420433
// We are interested either in this specific document, or any other document that
421434
// did commit and replaced the expected document.
422-
return event.documentInfo && (event.documentInfo.documentId === navigateResult.newDocumentId || !event.error);
435+
return event.newDocument && (event.newDocument.documentId === navigateResult.newDocumentId || !event.error);
423436
}).promise;
424437

425-
if (event.documentInfo!.documentId !== navigateResult.newDocumentId) {
438+
if (event.newDocument!.documentId !== navigateResult.newDocumentId) {
426439
// This is just a sanity check. In practice, new navigation should
427440
// cancel the previous one and report "request cancelled"-like error.
428441
throw new Error('Navigation interrupted by another one');
@@ -436,7 +449,7 @@ export class Frame {
436449
if (!this._subtreeLifecycleEvents.has(waitUntil))
437450
await helper.waitForEvent(progress, this._eventEmitter, kAddLifecycleEvent, (e: types.LifecycleEvent) => e === waitUntil).promise;
438451

439-
const request = event.documentInfo ? event.documentInfo.request : undefined;
452+
const request = event.newDocument ? event.newDocument.request : undefined;
440453
return request ? request._finalRequest().response() : null;
441454
});
442455
}
@@ -460,7 +473,7 @@ export class Frame {
460473
if (!this._subtreeLifecycleEvents.has(waitUntil))
461474
await helper.waitForEvent(progress, this._eventEmitter, kAddLifecycleEvent, (e: types.LifecycleEvent) => e === waitUntil).promise;
462475

463-
const request = navigationEvent.documentInfo ? navigationEvent.documentInfo.request : undefined;
476+
const request = navigationEvent.newDocument ? navigationEvent.newDocument.request : undefined;
464477
return request ? request._finalRequest().response() : null;
465478
});
466479
}

src/rpc/channels.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ export interface PageChannel extends Channel {
119119
on(event: 'fileChooser', callback: (params: { element: ElementHandleChannel, isMultiple: boolean }) => void): this;
120120
on(event: 'frameAttached', callback: (params: { frame: FrameChannel }) => void): this;
121121
on(event: 'frameDetached', callback: (params: { frame: FrameChannel }) => void): this;
122-
on(event: 'frameNavigated', callback: (params: { frame: FrameChannel, url: string, name: string }) => void): this;
123122
on(event: 'load', callback: () => void): this;
124123
on(event: 'pageError', callback: (params: { error: types.Error }) => void): this;
125124
on(event: 'popup', callback: (params: { page: PageChannel }) => void): this;
@@ -173,8 +172,11 @@ export type PageInitializer = {
173172
isClosed: boolean
174173
};
175174

175+
export type FrameNavigatedEvent = { url: string, name: string, newDocument?: { request?: RequestChannel }, error?: string };
176+
176177
export interface FrameChannel extends Channel {
177178
on(event: 'loadstate', callback: (params: { add?: types.LifecycleEvent, remove?: types.LifecycleEvent }) => void): this;
179+
on(event: 'navigated', callback: (params: FrameNavigatedEvent) => void): this;
178180

179181
evalOnSelector(params: { selector: string; expression: string, isFunction: boolean, arg: any}): Promise<{ value: any }>;
180182
evalOnSelectorAll(params: { selector: string; expression: string, isFunction: boolean, arg: any}): Promise<{ value: any }>;
@@ -206,7 +208,6 @@ export interface FrameChannel extends Channel {
206208
type(params: { selector: string, text: string, delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions): Promise<void>;
207209
uncheck(params: { selector: string, force?: boolean, noWaitAfter?: boolean } & types.TimeoutOptions): Promise<void>;
208210
waitForFunction(params: { expression: string, isFunction: boolean, arg: any } & types.WaitForFunctionOptions): Promise<{ handle: JSHandleChannel }>;
209-
waitForNavigation(params: types.WaitForNavigationOptions): Promise<{ response: ResponseChannel | null }>;
210211
waitForSelector(params: { selector: string } & types.WaitForElementOptions): Promise<{ element: ElementHandleChannel | null }>;
211212
}
212213
export type FrameInitializer = {
@@ -403,7 +404,7 @@ export type ElectronInitializer = {};
403404

404405
export interface ElectronApplicationChannel extends Channel {
405406
on(event: 'close', callback: () => void): this;
406-
on(event: 'window', callback: (params: { page: PageChannel }) => void): this;
407+
on(event: 'window', callback: (params: { page: PageChannel, browserWindow: JSHandleChannel }) => void): this;
407408

408409
newBrowserWindow(params: { arg: any }): Promise<{ page: PageChannel }>;
409410
evaluateExpression(params: { expression: string, isFunction: boolean, arg: any }): Promise<{ value: any }>;

src/rpc/client/electron.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ElectronEvents } from '../../server/electron';
2424
import { TimeoutSettings } from '../../timeoutSettings';
2525
import { Waiter } from './waiter';
2626
import { TimeoutError } from '../../errors';
27+
import { Events } from '../../events';
2728

2829
export class Electron extends ChannelOwner<ElectronChannel, ElectronInitializer> {
2930
static from(electron: ElectronChannel): Electron {
@@ -53,10 +54,12 @@ export class ElectronApplication extends ChannelOwner<ElectronApplicationChannel
5354
constructor(parent: ChannelOwner, type: string, guid: string, initializer: ElectronApplicationInitializer) {
5455
super(parent, type, guid, initializer);
5556
this._context = BrowserContext.from(initializer.context);
56-
this._channel.on('window', ({page}) => {
57+
this._channel.on('window', ({ page, browserWindow }) => {
5758
const window = Page.from(page);
59+
(window as any).browserWindow = JSHandle.from(browserWindow);
5860
this._windows.add(window);
5961
this.emit(ElectronEvents.ElectronApplication.Window, window);
62+
window.once(Events.Page.Close, () => this._windows.delete(window));
6063
});
6164
this._channel.on('close', () => {
6265
this.emit(ElectronEvents.ElectronApplication.Close);

src/rpc/client/frame.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { assertMaxArguments } from '../../helper';
18+
import { assertMaxArguments, helper } from '../../helper';
1919
import * as types from '../../types';
20-
import { FrameChannel, FrameInitializer } from '../channels';
20+
import { FrameChannel, FrameInitializer, FrameNavigatedEvent } from '../channels';
2121
import { BrowserContext } from './browserContext';
2222
import { ChannelOwner } from './channelOwner';
2323
import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle';
@@ -75,6 +75,13 @@ export class Frame extends ChannelOwner<FrameChannel, FrameInitializer> {
7575
if (event.remove)
7676
this._loadStates.delete(event.remove);
7777
});
78+
this._channel.on('navigated', event => {
79+
this._url = event.url;
80+
this._name = event.name;
81+
this._eventEmitter.emit('navigated', event);
82+
if (!event.error && this._page)
83+
this._page.emit(Events.Page.FrameNavigated, this);
84+
});
7885
}
7986

8087
private _apiName(method: string) {
@@ -87,9 +94,48 @@ export class Frame extends ChannelOwner<FrameChannel, FrameInitializer> {
8794
});
8895
}
8996

97+
private _setupNavigationWaiter(): Waiter {
98+
const waiter = new Waiter();
99+
waiter.rejectOnEvent(this._page!, Events.Page.Close, new Error('Navigation failed because page was closed!'));
100+
waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!'));
101+
waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this);
102+
return waiter;
103+
}
104+
90105
async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise<network.Response | null> {
91106
return this._wrapApiCall(this._apiName('waitForNavigation'), async () => {
92-
return network.Response.fromNullable((await this._channel.waitForNavigation({ ...options })).response);
107+
const waitUntil = verifyLoadState(options.waitUntil === undefined ? 'load' : options.waitUntil);
108+
const timeout = this._page!._timeoutSettings.navigationTimeout(options);
109+
const waiter = this._setupNavigationWaiter();
110+
waiter.rejectOnTimeout(timeout, new TimeoutError(`Timeout ${timeout}ms exceeded.`));
111+
112+
const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : '';
113+
waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`);
114+
115+
const navigatedEvent = await waiter.waitForEvent<FrameNavigatedEvent>(this._eventEmitter, 'navigated', event => {
116+
// Any failed navigation results in a rejection.
117+
if (event.error)
118+
return true;
119+
waiter.log(` navigated to "${event.url}"`);
120+
return helper.urlMatches(event.url, options.url);
121+
});
122+
if (navigatedEvent.error) {
123+
const e = new Error(navigatedEvent.error);
124+
e.stack = '';
125+
await waiter.waitForPromise(Promise.reject(e));
126+
}
127+
128+
if (!this._loadStates.has(waitUntil)) {
129+
await waiter.waitForEvent<types.LifecycleEvent>(this._eventEmitter, 'loadstate', s => {
130+
waiter.log(` "${s}" event fired`);
131+
return s === waitUntil;
132+
});
133+
}
134+
135+
const request = navigatedEvent.newDocument ? network.Request.fromNullable(navigatedEvent.newDocument.request || null) : null;
136+
const response = request ? await waiter.waitForPromise(request._finalRequest().response()) : null;
137+
waiter.dispose();
138+
return response;
93139
});
94140
}
95141

@@ -99,12 +145,12 @@ export class Frame extends ChannelOwner<FrameChannel, FrameInitializer> {
99145
return;
100146
return this._wrapApiCall(this._apiName('waitForLoadState'), async () => {
101147
const timeout = this._page!._timeoutSettings.navigationTimeout(options);
102-
const waiter = new Waiter();
103-
waiter.rejectOnEvent(this._page!, Events.Page.Close, new Error('Navigation failed because page was closed!'));
104-
waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!'));
105-
waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this);
148+
const waiter = this._setupNavigationWaiter();
106149
waiter.rejectOnTimeout(timeout, new TimeoutError(`Timeout ${timeout}ms exceeded.`));
107-
await waiter.waitForEvent<types.LifecycleEvent>(this._eventEmitter, 'loadstate', s => s === state);
150+
await waiter.waitForEvent<types.LifecycleEvent>(this._eventEmitter, 'loadstate', s => {
151+
waiter.log(` "${s}" event fired`);
152+
return s === state;
153+
});
108154
waiter.dispose();
109155
});
110156
}

src/rpc/client/network.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export class Request extends ChannelOwner<RequestChannel, RequestInitializer> {
132132
errorText: this._failureText
133133
};
134134
}
135+
136+
_finalRequest(): Request {
137+
return this._redirectedTo ? this._redirectedTo._finalRequest() : this;
138+
}
135139
}
136140

137141
export class Route extends ChannelOwner<RouteChannel, RouteInitializer> {

src/rpc/client/page.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class Page extends ChannelOwner<PageChannel, PageInitializer> {
7575

7676
constructor(parent: ChannelOwner, type: string, guid: string, initializer: PageInitializer) {
7777
super(parent, type, guid, initializer);
78+
this.setMaxListeners(0);
7879
this._browserContext = parent as BrowserContext;
7980
this._timeoutSettings = new TimeoutSettings(this._browserContext._timeoutSettings);
8081

@@ -98,7 +99,6 @@ export class Page extends ChannelOwner<PageChannel, PageInitializer> {
9899
this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple)));
99100
this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame)));
100101
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame)));
101-
this._channel.on('frameNavigated', ({ frame, url, name }) => this._onFrameNavigated(Frame.from(frame), url, name));
102102
this._channel.on('load', () => this.emit(Events.Page.Load));
103103
this._channel.on('pageError', ({ error }) => this.emit(Events.Page.PageError, parseError(error)));
104104
this._channel.on('popup', ({ page }) => this.emit(Events.Page.Popup, Page.from(page)));
@@ -136,12 +136,6 @@ export class Page extends ChannelOwner<PageChannel, PageInitializer> {
136136
this.emit(Events.Page.FrameDetached, frame);
137137
}
138138

139-
private _onFrameNavigated(frame: Frame, url: string, name: string) {
140-
frame._url = url;
141-
frame._name = name;
142-
this.emit(Events.Page.FrameNavigated, frame);
143-
}
144-
145139
private _onRoute(route: Route, request: Request) {
146140
for (const {url, handler} of this._routes) {
147141
if (helper.urlMatches(request.url(), url)) {

src/rpc/client/waiter.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@
1515
*/
1616

1717
import { EventEmitter } from 'events';
18+
import { rewriteErrorMessage } from '../../utils/stackTrace';
1819

1920
export class Waiter {
2021
private _dispose: (() => void)[] = [];
2122
private _failures: Promise<any>[] = [];
23+
// TODO: can/should we move these logs into wrapApiCall?
24+
private _logs: string[] = [];
2225

2326
async waitForEvent<T = void>(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean): Promise<T> {
2427
const { promise, dispose } = waitForEvent(emitter, event, predicate);
25-
return this._wait(promise, dispose);
28+
return this.waitForPromise(promise, dispose);
2629
}
2730

2831
rejectOnEvent<T = void>(emitter: EventEmitter, event: string, error: Error, predicate?: (arg: T) => boolean) {
@@ -42,7 +45,7 @@ export class Waiter {
4245
dispose();
4346
}
4447

45-
private async _wait<T>(promise: Promise<T>, dispose?: () => void): Promise<T> {
48+
async waitForPromise<T>(promise: Promise<T>, dispose?: () => void): Promise<T> {
4649
try {
4750
const result = await Promise.race([promise, ...this._failures]);
4851
if (dispose)
@@ -52,10 +55,15 @@ export class Waiter {
5255
if (dispose)
5356
dispose();
5457
this.dispose();
58+
rewriteErrorMessage(e, e.message + formatLogRecording(this._logs) + kLoggingNote);
5559
throw e;
5660
}
5761
}
5862

63+
log(s: string) {
64+
this._logs.push(s);
65+
}
66+
5967
private _rejectOn(promise: Promise<any>, dispose?: () => void) {
6068
this._failures.push(promise);
6169
if (dispose)
@@ -89,3 +97,15 @@ function waitForTimeout(timeout: number): { promise: Promise<void>, dispose: ()
8997
const dispose = () => clearTimeout(timeoutId);
9098
return { promise, dispose };
9199
}
100+
101+
const kLoggingNote = `\nNote: use DEBUG=pw:api environment variable and rerun to capture Playwright logs.`;
102+
103+
function formatLogRecording(log: string[]): string {
104+
if (!log.length)
105+
return '';
106+
const header = ` logs `;
107+
const headerLength = 60;
108+
const leftLength = (headerLength - header.length) / 2;
109+
const rightLength = headerLength - header.length - leftLength;
110+
return `\n${'='.repeat(leftLength)}${header}${'='.repeat(rightLength)}\n${log.join('\n')}\n${'='.repeat(headerLength)}`;
111+
}

src/rpc/server/electronDispatcher.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@
1515
*/
1616

1717
import { Dispatcher, DispatcherScope, lookupDispatcher } from './dispatcher';
18-
import { Electron, ElectronApplication, ElectronEvents } from '../../server/electron';
18+
import { Electron, ElectronApplication, ElectronEvents, ElectronPage } from '../../server/electron';
1919
import { ElectronApplicationChannel, ElectronApplicationInitializer, PageChannel, JSHandleChannel, ElectronInitializer, ElectronChannel, ElectronLaunchOptions } from '../channels';
2020
import { BrowserContextDispatcher } from './browserContextDispatcher';
2121
import { BrowserContextBase } from '../../browserContext';
22-
import { Page } from '../../page';
2322
import { PageDispatcher } from './pageDispatcher';
2423
import { parseArgument } from './jsHandleDispatcher';
2524
import { createHandle } from './elementHandlerDispatcher';
@@ -42,8 +41,11 @@ export class ElectronApplicationDispatcher extends Dispatcher<ElectronApplicatio
4241
});
4342

4443
electronApplication.on(ElectronEvents.ElectronApplication.Close, () => this._dispatchEvent('close'));
45-
electronApplication.on(ElectronEvents.ElectronApplication.Window, (page: Page) => {
46-
this._dispatchEvent('window', { page: lookupDispatcher<PageDispatcher>(page) });
44+
electronApplication.on(ElectronEvents.ElectronApplication.Window, (page: ElectronPage) => {
45+
this._dispatchEvent('window', {
46+
page: lookupDispatcher<PageDispatcher>(page),
47+
browserWindow: createHandle(this._scope, page.browserWindow),
48+
});
4749
});
4850
}
4951

0 commit comments

Comments
 (0)