1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447/**
* Copyright (c) Microsoft Corporation.
*
* 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 fs from 'fs';
import path from 'path';
import { BrowserContext } from '../browserContext';
import { ArtifactDispatcher } from './artifactDispatcher';
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
import { DialogDispatcher } from './dialogDispatcher';
import { Dispatcher } from './dispatcher';
import { FrameDispatcher } from './frameDispatcher';
import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import { BindingCallDispatcher, PageDispatcher, WorkerDispatcher } from './pageDispatcher';
import { CRBrowser, CRBrowserContext } from '../chromium/crBrowser';
import { serializeError } from '../errors';
import { TracingDispatcher } from './tracingDispatcher';
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
import { WritableStreamDispatcher } from './writableStreamDispatcher';
import { createGuid } from '../utils/crypto';
import { urlMatches } from '../../utils/isomorphic/urlMatch';
import { Recorder } from '../recorder';
import { RecorderApp } from '../recorder/recorderApp';
import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { JSHandleDispatcher } from './jsHandleDispatcher';
import type { Artifact } from '../artifact';
import type { ConsoleMessage } from '../console';
import type { Dialog } from '../dialog';
import type { Request, Response, RouteHandler } from '../network';
import type { InitScript, Page, PageBinding } from '../page';
import type { DispatcherScope } from './dispatcher';
import type * as channels from '@protocol/channels';
import type { Progress } from '@protocol/progress';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
_type_EventTarget = true;
_type_BrowserContext = true;
private _context: BrowserContext;
private _subscriptions = new Set<channels.BrowserContextUpdateSubscriptionParams['event']>();
_webSocketInterceptionPatterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = [];
private _bindings: PageBinding[] = [];
private _initScripts: InitScript[] = [];
private _dialogHandler: (dialog: Dialog) => boolean;
private _clockPaused = false;
private _requestInterceptor: RouteHandler;
private _interceptionUrlMatchers: (string | RegExp)[] = [];
private _routeWebSocketInitScript: InitScript | undefined;
static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher {
const result = parentScope.connection.existingDispatcher<BrowserContextDispatcher>(context);
return result || new BrowserContextDispatcher(parentScope, context);
}
private constructor(parentScope: DispatcherScope, context: BrowserContext) {
// We will reparent these to the context below.
const requestContext = APIRequestContextDispatcher.from(parentScope as BrowserContextDispatcher, context.fetchRequest);
const tracing = TracingDispatcher.from(parentScope as BrowserContextDispatcher, context.tracing);
super(parentScope, context, 'BrowserContext', {
isChromium: context._browser.options.isChromium,
requestContext,
tracing,
options: context._options,
});
this.adopt(requestContext);
this.adopt(tracing);
this._requestInterceptor = (route, request) => {
const matchesSome = this._interceptionUrlMatchers.some(urlMatch => urlMatches(this._context._options.baseURL, request.url(), urlMatch));
// If there is already a dispatcher, that means we've already routed this request through page.
// Client expects a single `route` event, either on the page or on the context, so we can just fallback here.
const routeDispatcher = this.connection.existingDispatcher<RouteDispatcher>(route);
if (!matchesSome || routeDispatcher) {
route.continue({ isFallback: true }).catch(() => {});
return;
}
this._dispatchEvent('route', { route: new RouteDispatcher(RequestDispatcher.from(this, request), route) });
};
this._context = context;
// Note: when launching persistent context, or connecting to an existing browser,
// dispatcher is created very late, so we can already have pages, videos and everything else.
const onVideo = (artifact: Artifact) => {
// Note: Video must outlive Page and BrowserContext, so that client can saveAs it
// after closing the context. We use |scope| for it.
const artifactDispatcher = ArtifactDispatcher.from(parentScope, artifact);
this._dispatchEvent('video', { artifact: artifactDispatcher });
};
this.addObjectListener(BrowserContext.Events.VideoStarted, onVideo);
for (const video of context._browser._idToVideo.values()) {
if (video.context === context)
onVideo(video.artifact);
}
for (const page of context.pages())
this._dispatchEvent('page', { page: PageDispatcher.from(this, page) });
this.addObjectListener(BrowserContext.Events.Page, page => {
this._dispatchEvent('page', { page: PageDispatcher.from(this, page) });
});
this.addObjectListener(BrowserContext.Events.Close, () => {
this._dispatchEvent('close');
this._dispose();
});
this.addObjectListener(BrowserContext.Events.PageError, (error: Error, page: Page) => {
this._dispatchEvent('pageError', { error: serializeError(error), page: PageDispatcher.from(this, page) });
});
this.addObjectListener(BrowserContext.Events.Console, (message: ConsoleMessage) => {
const pageDispatcher = PageDispatcher.fromNullable(this, message.page());
const workerDispatcher = WorkerDispatcher.fromNullable(this, message.worker());
if (this._shouldDispatchEvent(message.page(), 'console') || workerDispatcher?._subscriptions.has('console')) {
this._dispatchEvent('console', {
page: pageDispatcher,
worker: workerDispatcher,
...this.serializeConsoleMessage(message, workerDispatcher || pageDispatcher!),
});
}
});
this._dialogHandler = dialog => {
if (!this._shouldDispatchEvent(dialog.page(), 'dialog'))
return false;
this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this, dialog) });
return true;
};
context.dialogManager.addDialogHandler(this._dialogHandler);
if (context._browser.options.name === 'chromium' && this._object._browser instanceof CRBrowser) {
for (const serviceWorker of (context as CRBrowserContext).serviceWorkers())
this._dispatchEvent('serviceWorker', { worker: new WorkerDispatcher(this, serviceWorker) });
this.addObjectListener(CRBrowserContext.CREvents.ServiceWorker, serviceWorker => this._dispatchEvent('serviceWorker', { worker: new WorkerDispatcher(this, serviceWorker) }));
}
this.addObjectListener(BrowserContext.Events.Request, (request: Request) => {
// Create dispatcher, if:
// - There are listeners to the requests.
// - We are redirected from a reported request so that redirectedTo was updated on client.
// - We are a navigation request and dispatcher will be reported as a part of the goto return value and newDocument param anyways.
// By the time requestFinished is triggered to update the request, we should have a request on the client already.
const redirectFromDispatcher = request.redirectedFrom() && this.connection.existingDispatcher(request.redirectedFrom());
if (!redirectFromDispatcher && !this._shouldDispatchNetworkEvent(request, 'request') && !request.isNavigationRequest())
return;
const requestDispatcher = RequestDispatcher.from(this, request);
this._dispatchEvent('request', {
request: requestDispatcher,
page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined())
});
});
this.addObjectListener(BrowserContext.Events.Response, (response: Response) => {
const requestDispatcher = this.connection.existingDispatcher<RequestDispatcher>(response.request());
if (!requestDispatcher && !this._shouldDispatchNetworkEvent(response.request(), 'response'))
return;
this._dispatchEvent('response', {
response: ResponseDispatcher.from(this, response),
page: PageDispatcher.fromNullable(this, response.frame()?._page.initializedOrUndefined())
});
});
this.addObjectListener(BrowserContext.Events.RequestFailed, (request: Request) => {
const requestDispatcher = this.connection.existingDispatcher<RequestDispatcher>(request);
if (!requestDispatcher && !this._shouldDispatchNetworkEvent(request, 'requestFailed'))
return;
this._dispatchEvent('requestFailed', {
request: RequestDispatcher.from(this, request),
failureText: request._failureText || undefined,
responseEndTiming: request._responseEndTiming,
page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined())
});
});
this.addObjectListener(BrowserContext.Events.RequestFinished, ({ request, response }: { request: Request, response: Response | null }) => {
const requestDispatcher = this.connection.existingDispatcher<RequestDispatcher>(request);
if (!requestDispatcher && !this._shouldDispatchNetworkEvent(request, 'requestFinished'))
return;
this._dispatchEvent('requestFinished', {
request: RequestDispatcher.from(this, request),
response: ResponseDispatcher.fromNullable(this, response),
responseEndTiming: request._responseEndTiming,
page: PageDispatcher.fromNullable(this, request.frame()?._page.initializedOrUndefined()),
});
});
this.addObjectListener(BrowserContext.Events.RecorderEvent, ({ event, data, page, code }: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }) => {
this._dispatchEvent('recorderEvent', { event, data, code, page: PageDispatcher.from(this, page) });
});
}
private _shouldDispatchNetworkEvent(request: Request, event: channels.BrowserContextUpdateSubscriptionParams['event'] & channels.PageUpdateSubscriptionParams['event']): boolean {
return this._shouldDispatchEvent(request.frame()?._page?.initializedOrUndefined(), event);
}
private _shouldDispatchEvent(page: Page | null | undefined, event: channels.BrowserContextUpdateSubscriptionParams['event'] & channels.PageUpdateSubscriptionParams['event']): boolean {
if (this._subscriptions.has(event))
return true;
const pageDispatcher = page ? this.connection.existingDispatcher<PageDispatcher>(page) : undefined;
if (pageDispatcher?._subscriptions.has(event))
return true;
return false;
}
serializeConsoleMessage(message: ConsoleMessage, jsScope: PageDispatcher | WorkerDispatcher) {
return {
type: message.type(),
text: message.text(),
args: message.args().map(a => {
const elementHandle = a.asElement();
if (elementHandle)
return ElementHandleDispatcher.from(FrameDispatcher.from(this, elementHandle._frame), elementHandle);
return JSHandleDispatcher.fromJSHandle(jsScope, a);
}),
location: message.location(),
};
}
async createTempFiles(params: channels.BrowserContextCreateTempFilesParams, progress: Progress): Promise<channels.BrowserContextCreateTempFilesResult> {
const dir = this._context._browser.options.artifactsDir;
const tmpDir = path.join(dir, 'upload-' + createGuid());
const tempDirWithRootName = params.rootDirName ? path.join(tmpDir, path.basename(params.rootDirName)) : tmpDir;
await progress.race(fs.promises.mkdir(tempDirWithRootName, { recursive: true }));
this._context._tempDirs.push(tmpDir);
return {
rootDir: params.rootDirName ? new WritableStreamDispatcher(this, tempDirWithRootName) : undefined,
writableStreams: await Promise.all(params.items.map(async item => {
await progress.race(fs.promises.mkdir(path.dirname(path.join(tempDirWithRootName, item.name)), { recursive: true }));
const file = fs.createWriteStream(path.join(tempDirWithRootName, item.name));
return new WritableStreamDispatcher(this, file, item.lastModifiedMs);
}))
};
}
async exposeBinding(params: channels.BrowserContextExposeBindingParams, progress: Progress): Promise<void> {
const binding = await this._context.exposeBinding(progress, params.name, !!params.needsHandle, (source, ...args) => {
// When reusing the context, we might have some bindings called late enough,
// after context and page dispatchers have been disposed.
if (this._disposed)
return;
const pageDispatcher = PageDispatcher.from(this, source.page);
const binding = new BindingCallDispatcher(pageDispatcher, params.name, !!params.needsHandle, source, args);
this._dispatchEvent('bindingCall', { binding });
return binding.promise();
});
this._bindings.push(binding);
}
async newPage(params: channels.BrowserContextNewPageParams, progress: Progress): Promise<channels.BrowserContextNewPageResult> {
return { page: PageDispatcher.from(this, await this._context.newPage(progress)) };
}
async cookies(params: channels.BrowserContextCookiesParams, progress: Progress): Promise<channels.BrowserContextCookiesResult> {
return { cookies: await progress.race(this._context.cookies(params.urls)) };
}
async addCookies(params: channels.BrowserContextAddCookiesParams, progress: Progress): Promise<void> {
// Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway.
await this._context.addCookies(params.cookies);
}
async clearCookies(params: channels.BrowserContextClearCookiesParams, progress: Progress): Promise<void> {
// Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway.
const nameRe = params.nameRegexSource !== undefined && params.nameRegexFlags !== undefined ? new RegExp(params.nameRegexSource, params.nameRegexFlags) : undefined;
const domainRe = params.domainRegexSource !== undefined && params.domainRegexFlags !== undefined ? new RegExp(params.domainRegexSource, params.domainRegexFlags) : undefined;
const pathRe = params.pathRegexSource !== undefined && params.pathRegexFlags !== undefined ? new RegExp(params.pathRegexSource, params.pathRegexFlags) : undefined;
await this._context.clearCookies({
name: nameRe || params.name,
domain: domainRe || params.domain,
path: pathRe || params.path,
});
}
async grantPermissions(params: channels.BrowserContextGrantPermissionsParams, progress: Progress): Promise<void> {
// Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway.
await this._context.grantPermissions(params.permissions, params.origin);
}
async clearPermissions(params: channels.BrowserContextClearPermissionsParams, progress: Progress): Promise<void> {
// Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway.
await this._context.clearPermissions();
}
async setGeolocation(params: channels.BrowserContextSetGeolocationParams, progress: Progress): Promise<void> {
// Note: progress is ignored because this operation is not cancellable and should not block in the browser anyway.
await this._context.setGeolocation(params.geolocation);
}
async setExtraHTTPHeaders(params: channels.BrowserContextSetExtraHTTPHeadersParams, progress: Progress): Promise<void> {
await this._context.setExtraHTTPHeaders(progress, params.headers);
}
async setOffline(params: channels.BrowserContextSetOfflineParams, progress: Progress): Promise<void> {
await this._context.setOffline(progress, params.offline);
}
async setHTTPCredentials(params: channels.BrowserContextSetHTTPCredentialsParams, progress: Progress): Promise<void> {
// Note: this operation is deprecated, so we do not properly cleanup.
await progress.race(this._context.setHTTPCredentials(params.httpCredentials));
}
async addInitScript(params: channels.BrowserContextAddInitScriptParams, progress: Progress): Promise<void> {
this._initScripts.push(await this._context.addInitScript(progress, params.source));
}
async setNetworkInterceptionPatterns(params: channels.BrowserContextSetNetworkInterceptionPatternsParams, progress: Progress): Promise<void> {
const hadMatchers = this._interceptionUrlMatchers.length > 0;
if (!params.patterns.length) {
// Note: it is important to remove the interceptor when there are no patterns,
// because that disables the slow-path interception in the browser itself.
if (hadMatchers)
await this._context.removeRequestInterceptor(this._requestInterceptor);
this._interceptionUrlMatchers = [];
} else {
this._interceptionUrlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
if (!hadMatchers)
await this._context.addRequestInterceptor(progress, this._requestInterceptor);
}
}
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, progress: Progress): Promise<void> {
this._webSocketInterceptionPatterns = params.patterns;
if (params.patterns.length && !this._routeWebSocketInitScript)
this._routeWebSocketInitScript = await WebSocketRouteDispatcher.install(progress, this.connection, this._context);
}
async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise<channels.BrowserContextStorageStateResult> {
return await progress.race(this._context.storageState(progress, params.indexedDB));
}
async close(params: channels.BrowserContextCloseParams, progress: Progress): Promise<void> {
progress.metadata.potentiallyClosesScope = true;
await this._context.close(params);
}
async enableRecorder(params: channels.BrowserContextEnableRecorderParams, progress: Progress): Promise<void> {
await RecorderApp.show(this._context, params);
}
async disableRecorder(params: channels.BrowserContextDisableRecorderParams, progress: Progress): Promise<void> {
const recorder = await Recorder.existingForContext(this._context);
if (recorder)
recorder.setMode('none');
}
async exposeConsoleApi(params: channels.BrowserContextExposeConsoleApiParams, progress: Progress): Promise<void> {
await this._context.exposeConsoleApi();
}
async pause(params: channels.BrowserContextPauseParams, progress: Progress) {
// Debugger will take care of this.
}
async newCDPSession(params: channels.BrowserContextNewCDPSessionParams, progress: Progress): Promise<channels.BrowserContextNewCDPSessionResult> {
if (!this._object._browser.options.isChromium)
throw new Error(`CDP session is only available in Chromium`);
if (!params.page && !params.frame || params.page && params.frame)
throw new Error(`CDP session must be initiated with either Page or Frame, not none or both`);
const crBrowserContext = this._object as CRBrowserContext;
return { session: new CDPSessionDispatcher(this, await progress.race(crBrowserContext.newCDPSession((params.page ? params.page as PageDispatcher : params.frame as FrameDispatcher)._object))) };
}
async harStart(params: channels.BrowserContextHarStartParams, progress: Progress): Promise<channels.BrowserContextHarStartResult> {
const harId = this._context.harStart(params.page ? (params.page as PageDispatcher)._object : null, params.options);
return { harId };
}
async harExport(params: channels.BrowserContextHarExportParams, progress: Progress): Promise<channels.BrowserContextHarExportResult> {
const artifact = await progress.race(this._context.harExport(params.harId));
if (!artifact)
throw new Error('No HAR artifact. Ensure record.harPath is set.');
return { artifact: ArtifactDispatcher.from(this, artifact) };
}
async clockFastForward(params: channels.BrowserContextClockFastForwardParams, progress: Progress): Promise<channels.BrowserContextClockFastForwardResult> {
await this._context.clock.fastForward(progress, params.ticksString ?? params.ticksNumber ?? 0);
}
async clockInstall(params: channels.BrowserContextClockInstallParams, progress: Progress): Promise<channels.BrowserContextClockInstallResult> {
await this._context.clock.install(progress, params.timeString ?? params.timeNumber ?? undefined);
}
async clockPauseAt(params: channels.BrowserContextClockPauseAtParams, progress: Progress): Promise<channels.BrowserContextClockPauseAtResult> {
await this._context.clock.pauseAt(progress, params.timeString ?? params.timeNumber ?? 0);
this._clockPaused = true;
}
async clockResume(params: channels.BrowserContextClockResumeParams, progress: Progress): Promise<channels.BrowserContextClockResumeResult> {
await this._context.clock.resume(progress);
this._clockPaused = false;
}
async clockRunFor(params: channels.BrowserContextClockRunForParams, progress: Progress): Promise<channels.BrowserContextClockRunForResult> {
await this._context.clock.runFor(progress, params.ticksString ?? params.ticksNumber ?? 0);
}
async clockSetFixedTime(params: channels.BrowserContextClockSetFixedTimeParams, progress: Progress): Promise<channels.BrowserContextClockSetFixedTimeResult> {
await this._context.clock.setFixedTime(progress, params.timeString ?? params.timeNumber ?? 0);
}
async clockSetSystemTime(params: channels.BrowserContextClockSetSystemTimeParams, progress: Progress): Promise<channels.BrowserContextClockSetSystemTimeResult> {
await this._context.clock.setSystemTime(progress, params.timeString ?? params.timeNumber ?? 0);
}
async updateSubscription(params: channels.BrowserContextUpdateSubscriptionParams, progress: Progress): Promise<void> {
if (params.enabled)
this._subscriptions.add(params.event);
else
this._subscriptions.delete(params.event);
}
async registerSelectorEngine(params: channels.BrowserContextRegisterSelectorEngineParams, progress: Progress): Promise<void> {
this._object.selectors().register(params.selectorEngine);
}
async setTestIdAttributeName(params: channels.BrowserContextSetTestIdAttributeNameParams, progress: Progress): Promise<void> {
this._object.selectors().setTestIdAttributeName(params.testIdAttributeName);
}
override _onDispose() {
// Avoid protocol calls for the closed context.
if (this._context.isClosingOrClosed())
return;
// Cleanup properly and leave the page in a good state. Other clients may still connect and use it.
this._context.dialogManager.removeDialogHandler(this._dialogHandler);
this._interceptionUrlMatchers = [];
this._context.removeRequestInterceptor(this._requestInterceptor).catch(() => {});
this._context.removeExposedBindings(this._bindings).catch(() => {});
this._bindings = [];
this._context.removeInitScripts(this._initScripts).catch(() => {});
this._initScripts = [];
if (this._routeWebSocketInitScript)
WebSocketRouteDispatcher.uninstall(this.connection, this._context, this._routeWebSocketInitScript).catch(() => {});
this._routeWebSocketInitScript = undefined;
if (this._clockPaused)
this._context.clock.resumeNoReply();
this._clockPaused = false;
}
}