Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/Tab.ts
1029 views
1
import Log from '@secret-agent/commons/Logger';
2
import { IBlockedResourceType } from '@secret-agent/interfaces/ITabOptions';
3
import * as Url from 'url';
4
import IWaitForResourceOptions from '@secret-agent/interfaces/IWaitForResourceOptions';
5
import Timer from '@secret-agent/commons/Timer';
6
import IResourceMeta from '@secret-agent/interfaces/IResourceMeta';
7
import { createPromise } from '@secret-agent/commons/utils';
8
import TimeoutError from '@secret-agent/commons/interfaces/TimeoutError';
9
import { IPuppetPage, IPuppetPageEvents } from '@secret-agent/interfaces/IPuppetPage';
10
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
11
import { TypedEventEmitter } from '@secret-agent/commons/eventUtils';
12
import { IBoundLog } from '@secret-agent/interfaces/ILog';
13
import IWebsocketResourceMessage from '@secret-agent/interfaces/IWebsocketResourceMessage';
14
import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
15
import IScreenshotOptions from '@secret-agent/interfaces/IScreenshotOptions';
16
import MitmRequestContext from '@secret-agent/mitm/lib/MitmRequestContext';
17
import { IJsPath } from 'awaited-dom/base/AwaitedPath';
18
import { IInteractionGroups, InteractionCommand } from '@secret-agent/interfaces/IInteractions';
19
import IExecJsPathResult from '@secret-agent/interfaces/IExecJsPathResult';
20
import IWaitForElementOptions from '@secret-agent/interfaces/IWaitForElementOptions';
21
import { ILocationTrigger, IPipelineStatus } from '@secret-agent/interfaces/Location';
22
import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
23
import { LoadStatus } from '@secret-agent/interfaces/INavigation';
24
import IPuppetDialog from '@secret-agent/interfaces/IPuppetDialog';
25
import IFileChooserPrompt from '@secret-agent/interfaces/IFileChooserPrompt';
26
import FrameNavigations from './FrameNavigations';
27
import CommandRecorder from './CommandRecorder';
28
import FrameEnvironment from './FrameEnvironment';
29
import IResourceFilterProperties from '../interfaces/IResourceFilterProperties';
30
import InjectedScripts from './InjectedScripts';
31
import Session from './Session';
32
import SessionState from './SessionState';
33
import FrameNavigationsObserver from './FrameNavigationsObserver';
34
import DomChangesTable, {
35
IDomChangeRecord,
36
IFrontendDomChangeEvent,
37
} from '../models/DomChangesTable';
38
import DetachedTabState from './DetachedTabState';
39
40
const { log } = Log(module);
41
42
export default class Tab extends TypedEventEmitter<ITabEventParams> {
43
public readonly id: number;
44
public readonly parentTabId?: number;
45
public readonly session: Session;
46
public readonly frameEnvironmentsById = new Map<number, FrameEnvironment>();
47
public readonly frameEnvironmentsByPuppetId = new Map<string, FrameEnvironment>();
48
public puppetPage: IPuppetPage;
49
public isClosing = false;
50
public isReady: Promise<void>;
51
public isDetached = false;
52
53
protected readonly logger: IBoundLog;
54
55
private readonly commandRecorder: CommandRecorder;
56
private readonly createdAtCommandId: number;
57
private waitTimeouts: { timeout: NodeJS.Timeout; reject: (reason?: any) => void }[] = [];
58
private lastFileChooserEvent: {
59
event: IPuppetPageEvents['filechooser'];
60
atCommandId: number;
61
};
62
63
private onFrameCreatedResourceEventsByFrameId: {
64
[frameId: string]: {
65
type: keyof IPuppetPageEvents;
66
event: IPuppetPageEvents[keyof IPuppetPageEvents];
67
}[];
68
} = {};
69
70
public get navigations(): FrameNavigations {
71
return this.mainFrameEnvironment.navigations;
72
}
73
74
public get navigationsObserver(): FrameNavigationsObserver {
75
return this.mainFrameEnvironment.navigationsObserver;
76
}
77
78
public get url(): string {
79
return this.navigations.currentUrl;
80
}
81
82
public get sessionState(): SessionState {
83
return this.session.sessionState;
84
}
85
86
public get lastCommandId(): number | undefined {
87
return this.sessionState.lastCommand?.id;
88
}
89
90
public get sessionId(): string {
91
return this.session.id;
92
}
93
94
public get mainFrameId(): number {
95
return this.mainFrameEnvironment.id;
96
}
97
98
public get mainFrameEnvironment(): FrameEnvironment {
99
return this.frameEnvironmentsByPuppetId.get(this.puppetPage.mainFrame.id);
100
}
101
102
// eslint-disable-next-line @typescript-eslint/member-ordering
103
private constructor(
104
session: Session,
105
puppetPage: IPuppetPage,
106
isDetached: boolean,
107
parentTabId?: number,
108
windowOpenParams?: { url: string; windowName: string; loaderId: string },
109
) {
110
super();
111
this.setEventsToLog(['child-tab-created', 'close']);
112
this.id = session.nextTabId();
113
this.logger = log.createChild(module, {
114
tabId: this.id,
115
sessionId: session.id,
116
});
117
this.session = session;
118
this.parentTabId = parentTabId;
119
this.createdAtCommandId = session.sessionState.lastCommand?.id;
120
this.puppetPage = puppetPage;
121
this.isDetached = isDetached;
122
123
for (const puppetFrame of puppetPage.frames) {
124
const frame = new FrameEnvironment(this, puppetFrame);
125
this.frameEnvironmentsByPuppetId.set(frame.devtoolsFrameId, frame);
126
this.frameEnvironmentsById.set(frame.id, frame);
127
}
128
129
if (windowOpenParams) {
130
this.navigations.onNavigationRequested(
131
'newFrame',
132
windowOpenParams.url,
133
this.lastCommandId,
134
windowOpenParams.loaderId,
135
);
136
}
137
this.listen();
138
this.isReady = this.waitForReady();
139
this.commandRecorder = new CommandRecorder(this, this.session, this.id, this.mainFrameId, [
140
this.focus,
141
this.dismissDialog,
142
this.getFrameEnvironments,
143
this.goto,
144
this.goBack,
145
this.goForward,
146
this.reload,
147
this.takeScreenshot,
148
this.waitForFileChooser,
149
this.waitForMillis,
150
this.waitForNewTab,
151
this.waitForResource,
152
this.runPluginCommand,
153
// DO NOT ADD waitForReady
154
]);
155
}
156
157
public isAllowedCommand(method: string): boolean {
158
return (
159
this.commandRecorder.fnNames.has(method) ||
160
method === 'close' ||
161
method === 'getResourceProperty'
162
);
163
}
164
165
public checkForResolvedNavigation(
166
browserRequestId: string,
167
resource: IResourceMeta,
168
error?: Error,
169
): boolean {
170
if (resource.type !== 'Document') return;
171
172
const frame = this.frameWithPendingNavigation(
173
browserRequestId,
174
resource.request?.url,
175
resource.response?.url,
176
);
177
if (frame && !resource.isRedirect) {
178
frame.navigations.onResourceLoaded(resource.id, resource.response?.statusCode, error);
179
return true;
180
}
181
return false;
182
}
183
184
public frameWithPendingNavigation(
185
browserRequestId: string,
186
requestedUrl: string,
187
finalUrl: string,
188
): FrameEnvironment {
189
for (const frame of this.frameEnvironmentsById.values()) {
190
const isMatch = frame.navigations.doesMatchPending(browserRequestId, requestedUrl, finalUrl);
191
if (isMatch) return frame;
192
}
193
}
194
195
public async setBlockedResourceTypes(
196
blockedResourceTypes: IBlockedResourceType[],
197
): Promise<void> {
198
const mitmSession = this.session.mitmRequestSession;
199
const blockedResources = mitmSession.blockedResources.types;
200
let enableJs = true;
201
202
if (blockedResourceTypes.includes('None')) {
203
blockedResources.length = 0;
204
} else if (blockedResourceTypes.includes('All')) {
205
blockedResources.push('Image', 'Stylesheet', 'Script', 'Font', 'Ico', 'Media');
206
enableJs = false;
207
} else if (blockedResourceTypes.includes('BlockAssets')) {
208
blockedResources.push('Image', 'Stylesheet', 'Script');
209
} else {
210
if (blockedResourceTypes.includes('BlockImages')) {
211
blockedResources.push('Image');
212
}
213
if (blockedResourceTypes.includes('BlockCssResources')) {
214
blockedResources.push('Stylesheet');
215
}
216
if (blockedResourceTypes.includes('BlockJsResources')) {
217
blockedResources.push('Script');
218
}
219
if (blockedResourceTypes.includes('JsRuntime')) {
220
enableJs = false;
221
}
222
}
223
await this.puppetPage.setJavaScriptEnabled(enableJs);
224
mitmSession.blockedResources.urls = [];
225
}
226
227
public async close(): Promise<void> {
228
if (this.isClosing) return;
229
this.isClosing = true;
230
const parentLogId = this.logger.stats('Tab.Closing');
231
const errors: Error[] = [];
232
233
try {
234
const cancelMessage = 'Terminated command because session closing';
235
Timer.expireAll(this.waitTimeouts, new CanceledPromiseError(cancelMessage));
236
for (const frame of this.frameEnvironmentsById.values()) {
237
frame.close();
238
}
239
this.cancelPendingEvents(cancelMessage);
240
} catch (error) {
241
if (!error.message.includes('Target closed') && !(error instanceof CanceledPromiseError)) {
242
errors.push(error);
243
}
244
}
245
246
try {
247
this.puppetPage.off('close', this.close);
248
// run this one individually
249
await this.puppetPage.close();
250
} catch (error) {
251
if (!error.message.includes('Target closed') && !(error instanceof CanceledPromiseError)) {
252
errors.push(error);
253
}
254
}
255
this.commandRecorder.clear();
256
this.emit('close');
257
this.logger.stats('Tab.Closed', { parentLogId, errors });
258
}
259
260
public async setOrigin(origin: string): Promise<void> {
261
const mitmSession = this.session.mitmRequestSession;
262
const originalBlocker = mitmSession.blockedResources;
263
mitmSession.blockedResources = {
264
types: [],
265
urls: [origin],
266
handlerFn(request, response) {
267
response.end(`<html lang="en"><body>Empty</body></html>`);
268
return true;
269
},
270
};
271
try {
272
await this.puppetPage.navigate(origin);
273
} finally {
274
// restore originals
275
mitmSession.blockedResources = originalBlocker;
276
}
277
}
278
279
public async getResourceProperty<T = string | number | Buffer>(
280
resourceId: number,
281
propertyPath: string,
282
): Promise<T> {
283
let finalResourceId = resourceId;
284
// if no resource id, this is a request for the default resource (page)
285
if (!resourceId) {
286
finalResourceId = await this.navigationsObserver.waitForNavigationResourceId();
287
}
288
289
if (propertyPath === 'data' || propertyPath === 'response.data') {
290
return (await this.sessionState.getResourceData(finalResourceId, true)) as any;
291
}
292
293
const resource = this.sessionState.getResourceMeta(finalResourceId);
294
295
const pathParts = propertyPath.split('.');
296
297
let propertyParent: any = resource;
298
if (pathParts.length > 1) {
299
const parentProp = pathParts.shift();
300
if (parentProp === 'request' || parentProp === 'response') {
301
propertyParent = propertyParent[parentProp];
302
}
303
}
304
const property = pathParts.shift();
305
return propertyParent[property];
306
}
307
308
/////// DELEGATED FNS ////////////////////////////////////////////////////////////////////////////////////////////////
309
310
public interact(...interactionGroups: IInteractionGroups): Promise<void> {
311
return this.mainFrameEnvironment.interact(...interactionGroups);
312
}
313
314
public getJsValue<T>(path: string): Promise<{ value: T; type: string }> {
315
return this.mainFrameEnvironment.getJsValue(path);
316
}
317
318
public execJsPath<T>(jsPath: IJsPath): Promise<IExecJsPathResult<T>> {
319
return this.mainFrameEnvironment.execJsPath<T>(jsPath);
320
}
321
322
public getLocationHref(): Promise<string> {
323
return this.mainFrameEnvironment.getLocationHref();
324
}
325
326
public waitForElement(jsPath: IJsPath, options?: IWaitForElementOptions): Promise<boolean> {
327
return this.mainFrameEnvironment.waitForElement(jsPath, options);
328
}
329
330
public waitForLoad(status: IPipelineStatus, options?: IWaitForOptions): Promise<void> {
331
return this.mainFrameEnvironment.waitForLoad(status, options);
332
}
333
334
public waitForLocation(
335
trigger: ILocationTrigger,
336
options?: IWaitForOptions,
337
): Promise<IResourceMeta> {
338
return this.mainFrameEnvironment.waitForLocation(trigger, options);
339
}
340
341
/////// COMMANDS /////////////////////////////////////////////////////////////////////////////////////////////////////
342
343
public getFrameEnvironments(): Promise<IFrameMeta[]> {
344
return Promise.resolve(
345
[...this.frameEnvironmentsById.values()].filter(x => x.isAttached).map(x => x.toJSON()),
346
);
347
}
348
349
public async goto(url: string, timeoutMs = 30e3): Promise<IResourceMeta> {
350
const formattedUrl = Url.format(url);
351
352
const navigation = this.navigations.onNavigationRequested(
353
'goto',
354
formattedUrl,
355
this.lastCommandId,
356
null,
357
);
358
359
const timeoutMessage = `Timeout waiting for "tab.goto(${url})"`;
360
361
const timer = new Timer(timeoutMs, this.waitTimeouts);
362
const loader = await timer.waitForPromise(
363
this.puppetPage.navigate(formattedUrl),
364
timeoutMessage,
365
);
366
this.navigations.assignLoaderId(navigation, loader.loaderId);
367
368
const resource = await timer.waitForPromise(
369
this.navigationsObserver.waitForNavigationResourceId(),
370
timeoutMessage,
371
);
372
return this.sessionState.getResourceMeta(resource);
373
}
374
375
public async goBack(timeoutMs?: number): Promise<string> {
376
const navigation = this.navigations.onNavigationRequested(
377
'goBack',
378
null,
379
this.lastCommandId,
380
null,
381
);
382
const backUrl = await this.puppetPage.goBack();
383
this.navigations.assignLoaderId(
384
navigation,
385
this.puppetPage.mainFrame.activeLoader?.id,
386
backUrl,
387
);
388
389
await this.navigationsObserver.waitForLoad('PaintingStable', { timeoutMs });
390
return this.url;
391
}
392
393
public async goForward(timeoutMs?: number): Promise<string> {
394
const navigation = this.navigations.onNavigationRequested(
395
'goForward',
396
null,
397
this.lastCommandId,
398
null,
399
);
400
const url = await this.puppetPage.goForward();
401
this.navigations.assignLoaderId(navigation, this.puppetPage.mainFrame.activeLoader?.id, url);
402
await this.navigationsObserver.waitForLoad('PaintingStable', { timeoutMs });
403
return this.url;
404
}
405
406
public async reload(timeoutMs?: number): Promise<IResourceMeta> {
407
const navigation = this.navigations.onNavigationRequested(
408
'reload',
409
this.url,
410
this.lastCommandId,
411
null,
412
);
413
414
const timer = new Timer(timeoutMs, this.waitTimeouts);
415
const timeoutMessage = `Timeout waiting for "tab.reload()"`;
416
417
let loaderId = this.puppetPage.mainFrame.activeLoader.id;
418
await timer.waitForPromise(this.puppetPage.reload(), timeoutMessage);
419
if (this.puppetPage.mainFrame.activeLoader.id === loaderId) {
420
const frameNavigated = await timer.waitForPromise(
421
this.puppetPage.mainFrame.waitOn('frame-navigated', null, timeoutMs),
422
timeoutMessage,
423
);
424
loaderId = frameNavigated.loaderId;
425
}
426
this.navigations.assignLoaderId(
427
navigation,
428
loaderId ?? this.puppetPage.mainFrame.activeLoader?.id,
429
);
430
431
const resource = await timer.waitForPromise(
432
this.navigationsObserver.waitForNavigationResourceId(),
433
timeoutMessage,
434
);
435
return this.sessionState.getResourceMeta(resource);
436
}
437
438
public async focus(): Promise<void> {
439
await this.puppetPage.bringToFront();
440
}
441
442
public takeScreenshot(options: IScreenshotOptions = {}): Promise<Buffer> {
443
if (options.rectangle) options.rectangle.scale ??= 1;
444
return this.puppetPage.screenshot(options.format, options.rectangle, options.jpegQuality);
445
}
446
447
public async dismissDialog(accept: boolean, promptText?: string): Promise<void> {
448
const resolvable = createPromise();
449
this.mainFrameEnvironment.interactor.play(
450
[[{ command: InteractionCommand.willDismissDialog }]],
451
resolvable,
452
);
453
await resolvable.promise;
454
return this.puppetPage.dismissDialog(accept, promptText);
455
}
456
457
public async waitForNewTab(options: IWaitForOptions = {}): Promise<Tab> {
458
// last command is the one running right now
459
const startCommandId = Number.isInteger(options.sinceCommandId)
460
? options.sinceCommandId
461
: this.lastCommandId - 1;
462
let newTab: Tab;
463
const startTime = new Date();
464
if (startCommandId >= 0) {
465
for (const tab of this.session.tabsById.values()) {
466
if (tab.parentTabId === this.id && tab.createdAtCommandId >= startCommandId) {
467
newTab = tab;
468
break;
469
}
470
}
471
}
472
if (!newTab) newTab = await this.waitOn('child-tab-created', undefined, options?.timeoutMs);
473
474
// wait for a real url to be requested
475
if (newTab.url === 'about:blank' || !newTab.url) {
476
let timeoutMs = options?.timeoutMs ?? 10e3;
477
const millis = Date.now() - startTime.getTime();
478
timeoutMs -= millis;
479
await newTab.navigations.waitOn('navigation-requested', null, timeoutMs).catch(() => null);
480
}
481
482
await newTab.navigationsObserver.waitForNavigationResourceId();
483
return newTab;
484
}
485
486
public async waitForResource(
487
filter: IResourceFilterProperties,
488
opts?: IWaitForResourceOptions,
489
): Promise<IResourceMeta[]> {
490
const timer = new Timer(opts?.timeoutMs ?? 60e3, this.waitTimeouts);
491
const resourceMetas: IResourceMeta[] = [];
492
const promise = createPromise();
493
494
const onResource = (resourceMeta: IResourceMeta) => {
495
if (resourceMeta.tabId !== this.id) return;
496
if (resourceMeta.seenAtCommandId === undefined) {
497
resourceMeta.seenAtCommandId = this.lastCommandId;
498
// need to set directly since passed in object is a copy
499
this.sessionState.getResourceMeta(resourceMeta.id).seenAtCommandId = this.lastCommandId;
500
}
501
if (resourceMeta.seenAtCommandId <= opts?.sinceCommandId ?? -1) return;
502
if (filter.type && resourceMeta.type !== filter.type) return;
503
if (filter.url) {
504
if (typeof filter.url === 'string') {
505
// don't let query string url
506
if (filter.url.match(/[\w.:/_\-@;$]\?[-+;%@.\w_]+=.+/) && !filter.url.includes('\\?')) {
507
filter.url = filter.url.replace('?', '\\?');
508
}
509
}
510
if (!resourceMeta.url.match(filter.url)) return;
511
}
512
// if already included, skip
513
if (resourceMetas.some(x => x.id === resourceMeta.id)) return;
514
515
resourceMetas.push(resourceMeta);
516
// resolve if any match
517
promise.resolve();
518
};
519
520
try {
521
this.on('resource', onResource);
522
for (const resource of this.sessionState.getResources(this.id)) {
523
onResource(resource);
524
}
525
await timer.waitForPromise(promise.promise, 'Timeout waiting for DomContentLoaded');
526
} catch (err) {
527
const isTimeout = err instanceof TimeoutError;
528
if (isTimeout && opts?.throwIfTimeout === false) {
529
return resourceMetas;
530
}
531
throw err;
532
} finally {
533
this.off('resource', onResource);
534
timer.clear();
535
}
536
537
return resourceMetas;
538
}
539
540
public async waitForFileChooser(options?: IWaitForOptions): Promise<IFileChooserPrompt> {
541
let startCommandId =
542
options?.sinceCommandId && Number.isInteger(options.sinceCommandId)
543
? options.sinceCommandId
544
: null;
545
546
if (!startCommandId && this.sessionState.commands.length >= 2) {
547
startCommandId = this.sessionState.commands[this.sessionState.commands.length - 2]?.id;
548
}
549
550
let event: IPuppetPageEvents['filechooser'];
551
if (this.lastFileChooserEvent) {
552
const { atCommandId } = this.lastFileChooserEvent;
553
if (atCommandId >= startCommandId) {
554
event = this.lastFileChooserEvent.event;
555
}
556
}
557
558
if (!event) {
559
event = await this.puppetPage.waitOn('filechooser', null, options?.timeoutMs ?? 30e3);
560
}
561
562
const frameEnvironment = this.frameEnvironmentsByPuppetId.get(event.frameId);
563
const nodeId = await frameEnvironment.getDomNodeId(event.objectId);
564
return {
565
jsPath: [nodeId],
566
frameId: frameEnvironment.id,
567
selectMultiple: event.selectMultiple,
568
};
569
}
570
571
public waitForMillis(millis: number): Promise<void> {
572
return new Timer(millis, this.waitTimeouts).waitForTimeout();
573
}
574
575
public async runPluginCommand(toPluginId, args: any[]): Promise<any> {
576
const commandMeta = {
577
puppetPage: this.puppetPage,
578
puppetFrame: this.mainFrameEnvironment?.puppetFrame,
579
};
580
return await this.session.plugins.onPluginCommand(toPluginId, commandMeta, args);
581
}
582
583
public async getMainFrameDomChanges(sinceCommandId?: number): Promise<IFrontendDomChangeEvent[]> {
584
const frameDomNodePaths = this.sessionState.db.frames.frameDomNodePathsById;
585
586
return (await this.getFrameDomChanges(this.mainFrameId, sinceCommandId)).map(x =>
587
DomChangesTable.toFrontendRecord(x, frameDomNodePaths),
588
);
589
}
590
591
public async getFrameDomChanges(
592
frameId: number,
593
sinceCommandId: number,
594
): Promise<IDomChangeRecord[]> {
595
await this.mainFrameEnvironment.flushPageEventsRecorder();
596
this.sessionState.db.flush();
597
598
return this.sessionState.db.domChanges.getFrameChanges(this.mainFrameId, sinceCommandId);
599
}
600
601
public async createDetachedState(): Promise<DetachedTabState> {
602
// need the dom to be loaded on the page
603
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
604
// find last page load
605
const lastLoadedNavigation = this.navigations.getLastLoadedNavigation();
606
const domChanges = await this.getFrameDomChanges(
607
this.mainFrameId,
608
lastLoadedNavigation.startCommandId - 1,
609
);
610
const resources = this.sessionState.getResourceLookupMap(this.id);
611
this.logger.info('DetachingTab', {
612
url: lastLoadedNavigation.finalUrl,
613
domChangeIndices:
614
domChanges.length > 0
615
? [domChanges[0].eventIndex, domChanges[domChanges.length - 1].eventIndex]
616
: [],
617
domChanges: domChanges.length,
618
resources: Object.keys(resources).length,
619
});
620
return new DetachedTabState(this.session, lastLoadedNavigation, domChanges, resources);
621
}
622
623
/////// UTILITIES ////////////////////////////////////////////////////////////////////////////////////////////////////
624
625
public toJSON() {
626
return {
627
id: this.id,
628
parentTabId: this.parentTabId,
629
sessionId: this.sessionId,
630
url: this.url,
631
createdAtCommandId: this.createdAtCommandId,
632
isDetached: this.isDetached,
633
};
634
}
635
636
private async waitForReady(): Promise<void> {
637
await this.mainFrameEnvironment.isReady;
638
if (!this.isDetached && this.session.options?.blockedResourceTypes) {
639
await this.setBlockedResourceTypes(this.session.options.blockedResourceTypes);
640
}
641
}
642
643
private listen(): void {
644
const page = this.puppetPage;
645
646
this.close = this.close.bind(this);
647
page.on('close', this.close);
648
page.on('page-error', this.onPageError.bind(this), true);
649
page.on('crashed', this.onTargetCrashed.bind(this));
650
page.on('console', this.onConsole.bind(this), true);
651
page.on('frame-created', this.onFrameCreated.bind(this), true);
652
page.on('page-callback-triggered', this.onPageCallback.bind(this));
653
page.on('dialog-opening', this.onDialogOpening.bind(this));
654
page.on('filechooser', this.onFileChooser.bind(this));
655
656
// resource requested should registered before navigations so we can grab nav on new tab anchor clicks
657
page.on('resource-will-be-requested', this.onResourceWillBeRequested.bind(this), true);
658
page.on('resource-was-requested', this.onResourceWasRequested.bind(this), true);
659
page.on('resource-loaded', this.onResourceLoaded.bind(this), true);
660
page.on('resource-failed', this.onResourceFailed.bind(this), true);
661
page.on('navigation-response', this.onNavigationResourceResponse.bind(this), true);
662
663
// websockets
664
page.on('websocket-handshake', ev => {
665
this.session.mitmRequestSession?.registerWebsocketHeaders(this.id, ev);
666
});
667
page.on('websocket-frame', this.onWebsocketFrame.bind(this));
668
}
669
670
private onPageCallback(event: IPuppetPageEvents['page-callback-triggered']): void {
671
if (event.name === InjectedScripts.PageEventsCallbackName) {
672
const { frameId, payload } = event;
673
if (!frameId || !this.frameEnvironmentsByPuppetId.has(frameId)) {
674
log.warn('DomRecorder.bindingCalledBeforeExecutionTracked', {
675
sessionId: this.sessionId,
676
payload,
677
});
678
return;
679
}
680
681
this.frameEnvironmentsByPuppetId.get(frameId).onPageRecorderEvents(JSON.parse(payload));
682
}
683
}
684
685
/////// REQUESTS EVENT HANDLERS /////////////////////////////////////////////////////////////////
686
687
private onResourceWillBeRequested(event: IPuppetPageEvents['resource-will-be-requested']): void {
688
const { session, lastCommandId } = this;
689
const { resource, isDocumentNavigation, frameId, redirectedFromUrl } = event;
690
const { browserRequestId } = resource;
691
const url = resource.url.href;
692
693
let navigations = this.frameEnvironmentsByPuppetId.get(frameId)?.navigations;
694
// if no frame id provided, use default
695
if (!frameId && !navigations) {
696
navigations = this.navigations;
697
}
698
699
if (!navigations && frameId) {
700
this.onFrameCreatedResourceEventsByFrameId[frameId] ??= [];
701
const events = this.onFrameCreatedResourceEventsByFrameId[frameId];
702
if (!events.some(x => x.event === event)) {
703
events.push({ event, type: 'resource-will-be-requested' });
704
}
705
return;
706
}
707
708
if (isDocumentNavigation && !navigations.top) {
709
navigations.onNavigationRequested(
710
'newFrame',
711
url,
712
lastCommandId,
713
browserRequestId,
714
event.loaderId,
715
);
716
}
717
resource.hasUserGesture ||= navigations.didGotoUrl(url);
718
719
session.mitmRequestSession.browserRequestMatcher.onBrowserRequestedResource(resource, this.id);
720
721
if (isDocumentNavigation && !event.resource.browserCanceled) {
722
navigations.onHttpRequested(
723
url,
724
lastCommandId,
725
redirectedFromUrl,
726
browserRequestId,
727
event.loaderId,
728
);
729
}
730
}
731
732
private onResourceWasRequested(event: IPuppetPageEvents['resource-was-requested']): void {
733
this.session.mitmRequestSession.browserRequestMatcher.onBrowserRequestedResourceExtraDetails(
734
event.resource,
735
this.id,
736
);
737
}
738
739
private onResourceLoaded(event: IPuppetPageEvents['resource-loaded']): void {
740
const { resource } = event;
741
this.session.mitmRequestSession.browserRequestMatcher.onBrowserRequestedResourceExtraDetails(
742
resource,
743
this.id,
744
);
745
746
let frame = this.frameEnvironmentsByPuppetId.get(event.frameId);
747
// if no frame id provided, use default
748
if (!frame && !event.frameId) frame = this.mainFrameEnvironment;
749
750
if (!frame && event.frameId) {
751
this.onFrameCreatedResourceEventsByFrameId[event.frameId] ??= [];
752
const events = this.onFrameCreatedResourceEventsByFrameId[event.frameId];
753
if (!events.some(x => x.event === event)) {
754
events.push({ event, type: 'resource-loaded' });
755
}
756
return;
757
}
758
759
const resourcesWithBrowserRequestId = this.sessionState.getBrowserRequestResources(
760
resource.browserRequestId,
761
);
762
763
const isPending = frame.navigations.doesMatchPending(
764
resource.browserRequestId,
765
resource.url?.href,
766
resource.responseUrl,
767
);
768
if (isPending) {
769
if (event.resource.browserServedFromCache) {
770
frame.navigations.onHttpResponded(
771
event.resource.browserRequestId,
772
event.resource.responseUrl ?? event.resource.url?.href,
773
event.loaderId,
774
);
775
}
776
if (resourcesWithBrowserRequestId?.length) {
777
const { resourceId } = resourcesWithBrowserRequestId[
778
resourcesWithBrowserRequestId.length - 1
779
];
780
frame.navigations.onResourceLoaded(resourceId, event.resource.status);
781
}
782
}
783
784
if (!resourcesWithBrowserRequestId?.length) {
785
// first check if this is a mitm error
786
const errorsMatchingUrl = this.session.mitmErrorsByUrl.get(event.resource.url.href);
787
788
// if this resource error-ed out,
789
for (let i = 0; i < errorsMatchingUrl?.length ?? 0; i += 1) {
790
const error = errorsMatchingUrl[i];
791
const request = error.event?.request?.request;
792
if (!request) continue;
793
if (
794
request.method === event.resource.method &&
795
Math.abs(request.timestamp - event.resource.requestTime.getTime()) < 500
796
) {
797
errorsMatchingUrl.splice(i, 1);
798
this.sessionState.captureResourceRequestId(
799
error.resourceId,
800
event.resource.browserRequestId,
801
this.id,
802
);
803
return;
804
}
805
}
806
807
setImmediate(r => this.checkForResourceCapture(r), event);
808
}
809
}
810
811
private async checkForResourceCapture(
812
event: IPuppetPageEvents['resource-loaded'],
813
): Promise<void> {
814
const resourcesWithBrowserRequestId = this.sessionState.getBrowserRequestResources(
815
event.resource.browserRequestId,
816
);
817
818
if (resourcesWithBrowserRequestId?.length) return;
819
820
const ctx = MitmRequestContext.createFromPuppetResourceRequest(event.resource);
821
const resourceDetails = MitmRequestContext.toEmittedResource(ctx);
822
if (!event.resource.browserServedFromCache) {
823
resourceDetails.body = await event.body();
824
if (resourceDetails.body) {
825
delete resourceDetails.response.headers['content-encoding'];
826
delete resourceDetails.response.headers['Content-Encoding'];
827
}
828
}
829
const resource = this.sessionState.captureResource(this.id, resourceDetails, true);
830
this.checkForResolvedNavigation(event.resource.browserRequestId, resource);
831
}
832
833
private onResourceFailed(event: IPuppetPageEvents['resource-failed']): void {
834
const { resource } = event;
835
836
let loadError: Error;
837
if (resource.browserLoadFailure) {
838
loadError = new Error(resource.browserLoadFailure);
839
} else if (resource.browserBlockedReason) {
840
loadError = new Error(`Resource blocked: "${resource.browserBlockedReason}"`);
841
} else if (resource.browserCanceled) {
842
loadError = new Error('Load canceled');
843
} else {
844
loadError = new Error(
845
'Resource failed to load, but the reason was not provided by devtools.',
846
);
847
}
848
849
const browserRequestId = event.resource.browserRequestId;
850
851
let resourceId = this.session.mitmRequestSession.browserRequestMatcher.onBrowserRequestFailed({
852
resource,
853
tabId: this.id,
854
loadError,
855
});
856
857
if (!resourceId) {
858
const resources = this.sessionState.getBrowserRequestResources(browserRequestId);
859
if (resources?.length) {
860
resourceId = resources[resources.length - 1].resourceId;
861
}
862
}
863
864
// this function will resolve any pending resourceId for a navigation
865
const resourceMeta = this.sessionState.captureResourceFailed(
866
this.id,
867
MitmRequestContext.toEmittedResource({ id: resourceId, ...resource } as any),
868
loadError,
869
);
870
if (resourceMeta) this.checkForResolvedNavigation(browserRequestId, resourceMeta, loadError);
871
}
872
873
private onNavigationResourceResponse(event: IPuppetPageEvents['navigation-response']): void {
874
let frame = this.frameEnvironmentsByPuppetId.get(event.frameId);
875
876
// if no frame id provided, use default
877
if (!frame && !event.frameId) {
878
frame = this.mainFrameEnvironment;
879
}
880
881
if (event.frameId && !frame) {
882
this.onFrameCreatedResourceEventsByFrameId[event.frameId] ??= [];
883
const events = this.onFrameCreatedResourceEventsByFrameId[event.frameId];
884
if (!events.some(x => x.event === event)) {
885
events.push({ event, type: 'navigation-response' });
886
}
887
return;
888
}
889
890
frame.navigations.onHttpResponded(event.browserRequestId, event.url, event.loaderId);
891
this.session.mitmRequestSession.recordDocumentUserActivity(event.url);
892
}
893
894
private onWebsocketFrame(event: IPuppetPageEvents['websocket-frame']): void {
895
const wsResource = this.sessionState.captureWebsocketMessage(event);
896
this.emit('websocket-message', wsResource);
897
}
898
899
private onFrameCreated(event: IPuppetPageEvents['frame-created']): void {
900
if (this.frameEnvironmentsByPuppetId.has(event.frame.id)) return;
901
const frame = new FrameEnvironment(this, event.frame);
902
this.frameEnvironmentsByPuppetId.set(frame.devtoolsFrameId, frame);
903
this.frameEnvironmentsById.set(frame.id, frame);
904
const resourceEvents = this.onFrameCreatedResourceEventsByFrameId[frame.devtoolsFrameId];
905
if (resourceEvents) {
906
for (const { event: resourceEvent, type } of resourceEvents) {
907
if (type === 'resource-will-be-requested')
908
this.onResourceWillBeRequested(resourceEvent as any);
909
else if (type === 'navigation-response')
910
this.onNavigationResourceResponse(resourceEvent as any);
911
else if (type === 'resource-loaded') this.onResourceLoaded(resourceEvent as any);
912
}
913
}
914
delete this.onFrameCreatedResourceEventsByFrameId[frame.devtoolsFrameId];
915
}
916
917
/////// LOGGING EVENTS ///////////////////////////////////////////////////////////////////////////
918
919
private onPageError(event: IPuppetPageEvents['page-error']): void {
920
const { error, frameId } = event;
921
this.logger.info('Window.pageError', { error, frameId });
922
const translatedFrameId = this.frameEnvironmentsByPuppetId.get(frameId)?.id;
923
this.sessionState.captureError(this.id, translatedFrameId, `events.page-error`, error);
924
}
925
926
private onConsole(event: IPuppetPageEvents['console']): void {
927
const { frameId, type, message, location } = event;
928
const translatedFrameId = this.frameEnvironmentsByPuppetId.get(frameId)?.id;
929
this.sessionState.captureLog(this.id, translatedFrameId, type, message, location);
930
}
931
932
private onTargetCrashed(event: IPuppetPageEvents['crashed']): void {
933
const error = event.error;
934
935
const errorLevel = event.fatal ? 'error' : 'info';
936
this.logger[errorLevel]('BrowserEngine.Tab.crashed', { error });
937
938
this.sessionState.captureError(this.id, this.mainFrameId, `events.error`, error);
939
}
940
941
/////// DIALOGS //////////////////////////////////////////////////////////////////////////////////
942
943
private onDialogOpening(event: IPuppetPageEvents['dialog-opening']): void {
944
this.emit('dialog', event.dialog);
945
}
946
947
private onFileChooser(event: IPuppetPageEvents['filechooser']): void {
948
this.lastFileChooserEvent = { event, atCommandId: this.lastCommandId };
949
}
950
951
// CREATE
952
953
public static create(
954
session: Session,
955
puppetPage: IPuppetPage,
956
isDetached?: boolean,
957
parentTab?: Tab,
958
openParams?: { url: string; windowName: string; loaderId: string },
959
): Tab {
960
const tab = new Tab(session, puppetPage, isDetached, parentTab?.id, openParams);
961
tab.logger.info('Tab.created', {
962
parentTab: parentTab?.id,
963
openParams,
964
});
965
return tab;
966
}
967
}
968
969
interface ITabEventParams {
970
close: null;
971
'resource-requested': IResourceMeta;
972
resource: IResourceMeta;
973
dialog: IPuppetDialog;
974
'websocket-message': IWebsocketResourceMessage;
975
'child-tab-created': Tab;
976
}
977
978