Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/FrameEnvironment.ts
1029 views
1
import Log from '@secret-agent/commons/Logger';
2
import { ILocationTrigger, IPipelineStatus } from '@secret-agent/interfaces/Location';
3
import { IJsPath } from 'awaited-dom/base/AwaitedPath';
4
import { ICookie } from '@secret-agent/interfaces/ICookie';
5
import { IInteractionGroups } from '@secret-agent/interfaces/IInteractions';
6
import { URL } from 'url';
7
import * as Fs from 'fs';
8
import Timer from '@secret-agent/commons/Timer';
9
import { createPromise } from '@secret-agent/commons/utils';
10
import IWaitForElementOptions from '@secret-agent/interfaces/IWaitForElementOptions';
11
import IExecJsPathResult from '@secret-agent/interfaces/IExecJsPathResult';
12
import { IRequestInit } from 'awaited-dom/base/interfaces/official';
13
import { IPuppetFrame, IPuppetFrameEvents } from '@secret-agent/interfaces/IPuppetFrame';
14
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
15
import ISetCookieOptions from '@secret-agent/interfaces/ISetCookieOptions';
16
import { IBoundLog } from '@secret-agent/interfaces/ILog';
17
import INodePointer from 'awaited-dom/base/INodePointer';
18
import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
19
import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
20
import { LoadStatus } from '@secret-agent/interfaces/INavigation';
21
import { getNodeIdFnName } from '@secret-agent/interfaces/jsPathFnNames';
22
import IJsPathResult from '@secret-agent/interfaces/IJsPathResult';
23
import TypeSerializer from '@secret-agent/commons/TypeSerializer';
24
import * as Os from 'os';
25
import ICommandMeta from '@secret-agent/interfaces/ICommandMeta';
26
import IPoint from '@secret-agent/interfaces/IPoint';
27
import IResourceMeta from '@secret-agent/interfaces/IResourceMeta';
28
import SessionState from './SessionState';
29
import TabNavigationObserver from './FrameNavigationsObserver';
30
import Session from './Session';
31
import Tab from './Tab';
32
import Interactor from './Interactor';
33
import CommandRecorder from './CommandRecorder';
34
import FrameNavigations from './FrameNavigations';
35
import { Serializable } from '../interfaces/ISerializable';
36
import InjectedScriptError from './InjectedScriptError';
37
import { IJsPathHistory, JsPath } from './JsPath';
38
import InjectedScripts from './InjectedScripts';
39
import { PageRecorderResultSet } from '../injected-scripts/pageEventsRecorder';
40
41
const { log } = Log(module);
42
43
export default class FrameEnvironment {
44
public get session(): Session {
45
return this.tab.session;
46
}
47
48
public get devtoolsFrameId(): string {
49
return this.puppetFrame.id;
50
}
51
52
public get parentId(): number {
53
return this.parentFrame?.id;
54
}
55
56
public get parentFrame(): FrameEnvironment | null {
57
if (this.puppetFrame.parentId) {
58
return this.tab.frameEnvironmentsByPuppetId.get(this.puppetFrame.parentId);
59
}
60
return null;
61
}
62
63
public get isAttached(): boolean {
64
return this.puppetFrame.isAttached;
65
}
66
67
public get securityOrigin(): string {
68
return this.puppetFrame.securityOrigin;
69
}
70
71
public get childFrameEnvironments(): FrameEnvironment[] {
72
return [...this.tab.frameEnvironmentsById.values()].filter(
73
x => x.puppetFrame.parentId === this.devtoolsFrameId && this.isAttached,
74
);
75
}
76
77
public get isMainFrame(): boolean {
78
return !this.puppetFrame.parentId;
79
}
80
81
public readonly navigationsObserver: TabNavigationObserver;
82
83
public readonly navigations: FrameNavigations;
84
85
public readonly id: number;
86
public readonly tab: Tab;
87
public readonly jsPath: JsPath;
88
public readonly createdTime: Date;
89
public readonly createdAtCommandId: number;
90
public puppetFrame: IPuppetFrame;
91
public isReady: Promise<Error | void>;
92
public domNodeId: number;
93
public readonly interactor: Interactor;
94
protected readonly logger: IBoundLog;
95
96
private puppetNodeIdsBySaNodeId: Record<number, string> = {};
97
private prefetchedJsPaths: IJsPathResult[];
98
private readonly isDetached: boolean;
99
private isClosing = false;
100
private waitTimeouts: { timeout: NodeJS.Timeout; reject: (reason?: any) => void }[] = [];
101
private readonly commandRecorder: CommandRecorder;
102
private readonly cleanPaths: string[] = [];
103
104
public get url(): string {
105
return this.navigations.currentUrl;
106
}
107
108
private get sessionState(): SessionState {
109
return this.session.sessionState;
110
}
111
112
constructor(tab: Tab, frame: IPuppetFrame) {
113
this.puppetFrame = frame;
114
this.tab = tab;
115
this.createdTime = new Date();
116
this.id = tab.session.nextFrameId();
117
this.logger = log.createChild(module, {
118
tabId: tab.id,
119
sessionId: tab.session.id,
120
frameId: this.id,
121
});
122
this.jsPath = new JsPath(this, tab.isDetached);
123
this.isDetached = tab.isDetached;
124
this.createdAtCommandId = this.sessionState.lastCommand?.id;
125
this.navigations = new FrameNavigations(this.id, tab.sessionState);
126
this.navigationsObserver = new TabNavigationObserver(this.navigations);
127
this.interactor = new Interactor(this);
128
129
// give tab time to setup
130
process.nextTick(() => this.listen());
131
this.commandRecorder = new CommandRecorder(this, tab.session, tab.id, this.id, [
132
this.createRequest,
133
this.execJsPath,
134
this.fetch,
135
this.getChildFrameEnvironment,
136
this.getCookies,
137
this.getJsValue,
138
this.getLocationHref,
139
this.interact,
140
this.removeCookie,
141
this.runPluginCommand,
142
this.setCookie,
143
this.setFileInputFiles,
144
this.waitForElement,
145
this.waitForLoad,
146
this.waitForLocation,
147
// DO NOT ADD waitForReady
148
]);
149
// don't let this explode
150
this.isReady = this.install().catch(err => err);
151
}
152
153
public isAllowedCommand(method: string): boolean {
154
return (
155
this.commandRecorder.fnNames.has(method) ||
156
method === 'close' ||
157
method === 'recordDetachedJsPath'
158
);
159
}
160
161
public close(): void {
162
if (this.isClosing) return;
163
this.isClosing = true;
164
const parentLogId = this.logger.stats('FrameEnvironment.Closing');
165
166
try {
167
const cancelMessage = 'Terminated command because session closing';
168
Timer.expireAll(this.waitTimeouts, new CanceledPromiseError(cancelMessage));
169
this.navigationsObserver.cancelWaiting(cancelMessage);
170
this.logger.stats('FrameEnvironment.Closed', { parentLogId });
171
for (const path of this.cleanPaths) {
172
Fs.promises.unlink(path).catch(() => null);
173
}
174
this.commandRecorder.clear();
175
} catch (error) {
176
if (!error.message.includes('Target closed') && !(error instanceof CanceledPromiseError)) {
177
this.logger.error('FrameEnvironment.ClosingError', { error, parentLogId });
178
}
179
}
180
}
181
182
/////// COMMANDS /////////////////////////////////////////////////////////////////////////////////////////////////////
183
184
public async interact(...interactionGroups: IInteractionGroups): Promise<void> {
185
if (this.isDetached) {
186
throw new Error("Sorry, you can't interact with a detached frame");
187
}
188
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
189
const interactionResolvable = createPromise<void>(120e3);
190
this.waitTimeouts.push({
191
timeout: interactionResolvable.timeout,
192
reject: interactionResolvable.reject,
193
});
194
195
const cancelForNavigation = new CanceledPromiseError('Frame navigated');
196
const cancelOnNavigate = () => {
197
interactionResolvable.reject(cancelForNavigation);
198
};
199
try {
200
this.interactor.play(interactionGroups, interactionResolvable);
201
this.puppetFrame.once('frame-navigated', cancelOnNavigate);
202
await interactionResolvable.promise;
203
} catch (error) {
204
if (error === cancelForNavigation) return;
205
if (error instanceof CanceledPromiseError && this.isClosing) return;
206
throw error;
207
} finally {
208
this.puppetFrame.off('frame-navigated', cancelOnNavigate);
209
}
210
}
211
212
public async getJsValue<T>(expression: string): Promise<T> {
213
return await this.puppetFrame.evaluate<T>(expression, false);
214
}
215
216
public meta(): IFrameMeta {
217
return this.toJSON();
218
}
219
220
public async execJsPath<T>(jsPath: IJsPath): Promise<IExecJsPathResult<T>> {
221
// if nothing loaded yet, return immediately
222
if (!this.navigations.top) return null;
223
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
224
const containerOffset = await this.getContainerOffset();
225
return await this.jsPath.exec(jsPath, containerOffset);
226
}
227
228
public async prefetchExecJsPaths(jsPaths: IJsPathHistory[]): Promise<IJsPathResult[]> {
229
const containerOffset = await this.getContainerOffset();
230
this.prefetchedJsPaths = await this.jsPath.runJsPaths(jsPaths, containerOffset);
231
return this.prefetchedJsPaths;
232
}
233
234
public recordDetachedJsPath(index: number, runStartDate: number, endDate: number): void {
235
const entry = this.prefetchedJsPaths[index];
236
237
const commandMeta = <ICommandMeta>{
238
name: 'execJsPath',
239
args: TypeSerializer.stringify([entry.jsPath]),
240
id: this.sessionState.commands.length + 1,
241
wasPrefetched: true,
242
tabId: this.tab.id,
243
frameId: this.id,
244
result: entry.result,
245
runStartDate,
246
endDate,
247
};
248
if (this.sessionState.nextCommandMeta) {
249
const { commandId, sendDate, startDate: clientStartDate } = this.sessionState.nextCommandMeta;
250
this.sessionState.nextCommandMeta = null;
251
commandMeta.id = commandId;
252
commandMeta.clientSendDate = sendDate?.getTime();
253
commandMeta.clientStartDate = clientStartDate?.getTime();
254
}
255
256
// only need to record start
257
this.sessionState.recordCommandStart(commandMeta);
258
}
259
260
public async createRequest(input: string | number, init?: IRequestInit): Promise<INodePointer> {
261
if (!this.navigations.top && !this.url) {
262
throw new Error(
263
'You need to use a "goto" before attempting to fetch. The in-browser fetch needs an origin to function properly.',
264
);
265
}
266
await this.navigationsObserver.waitForReady();
267
return this.runIsolatedFn(
268
`${InjectedScripts.Fetcher}.createRequest`,
269
input,
270
// @ts-ignore
271
init,
272
);
273
}
274
275
public async fetch(input: string | number, init?: IRequestInit): Promise<INodePointer> {
276
if (!this.navigations.top && !this.url) {
277
throw new Error(
278
'You need to use a "goto" before attempting to fetch. The in-browser fetch needs an origin to function properly.',
279
);
280
}
281
await this.navigationsObserver.waitForReady();
282
return this.runIsolatedFn(
283
`${InjectedScripts.Fetcher}.fetch`,
284
input,
285
// @ts-ignore
286
init,
287
);
288
}
289
290
public getLocationHref(): Promise<string> {
291
return Promise.resolve(this.navigations.currentUrl || this.puppetFrame.url);
292
}
293
294
public async getCookies(): Promise<ICookie[]> {
295
await this.navigationsObserver.waitForReady();
296
return await this.session.browserContext.getCookies(
297
new URL(this.puppetFrame.securityOrigin ?? this.puppetFrame.url),
298
);
299
}
300
301
public async setCookie(
302
name: string,
303
value: string,
304
options?: ISetCookieOptions,
305
): Promise<boolean> {
306
if (!this.navigations.top && this.puppetFrame.url === 'about:blank') {
307
throw new Error(`Chrome won't allow you to set cookies on a blank tab.
308
309
SecretAgent supports two options to set cookies:
310
a) Goto a url first and then set cookies on the activeTab
311
b) Use the UserProfile feature to set cookies for 1 or more domains before they're loaded (https://secretagent.dev/docs/advanced/user-profile)
312
`);
313
}
314
315
await this.navigationsObserver.waitForReady();
316
const url = this.navigations.currentUrl;
317
await this.session.browserContext.addCookies([
318
{
319
name,
320
value,
321
url,
322
...options,
323
},
324
]);
325
return true;
326
}
327
328
public async removeCookie(name: string): Promise<boolean> {
329
await this.session.browserContext.addCookies([
330
{
331
name,
332
value: '',
333
expires: 0,
334
url: this.puppetFrame.url,
335
},
336
]);
337
return true;
338
}
339
340
public async getChildFrameEnvironment(jsPath: IJsPath): Promise<IFrameMeta> {
341
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
342
const nodeIdResult = await this.jsPath.exec<number>([...jsPath, [getNodeIdFnName]], null);
343
if (!nodeIdResult.value) return null;
344
345
const domId = nodeIdResult.value;
346
347
for (const frame of this.childFrameEnvironments) {
348
if (!frame.isAttached) continue;
349
350
await frame.isReady;
351
if (frame.domNodeId === domId) {
352
return frame.toJSON();
353
}
354
}
355
}
356
357
public async runPluginCommand(toPluginId: string, args: any[]): Promise<any> {
358
const commandMeta = {
359
puppetPage: this.tab.puppetPage,
360
puppetFrame: this.puppetFrame,
361
};
362
return await this.session.plugins.onPluginCommand(toPluginId, commandMeta, args);
363
}
364
365
public waitForElement(jsPath: IJsPath, options?: IWaitForElementOptions): Promise<boolean> {
366
return this.waitForDom(jsPath, options);
367
}
368
369
public async waitForLoad(status: IPipelineStatus, options?: IWaitForOptions): Promise<void> {
370
await this.isReady;
371
return this.navigationsObserver.waitForLoad(status, options);
372
}
373
374
public async waitForLocation(
375
trigger: ILocationTrigger,
376
options?: IWaitForOptions,
377
): Promise<IResourceMeta> {
378
const timer = new Timer(options?.timeoutMs ?? 60e3, this.waitTimeouts);
379
await timer.waitForPromise(
380
this.navigationsObserver.waitForLocation(trigger, options),
381
`Timeout waiting for location ${trigger}`,
382
);
383
384
const resource = await timer.waitForPromise(
385
this.navigationsObserver.waitForNavigationResourceId(),
386
`Timeout waiting for location ${trigger}`,
387
);
388
return this.sessionState.getResourceMeta(resource);
389
}
390
391
// NOTE: don't add this function to commands. It will record extra commands when called from interactor, which
392
// can break waitForLocation
393
public async waitForDom(jsPath: IJsPath, options?: IWaitForElementOptions): Promise<boolean> {
394
const waitForVisible = options?.waitForVisible ?? false;
395
const timeoutMs = options?.timeoutMs ?? 30e3;
396
const timeoutPerTry = timeoutMs < 1e3 ? timeoutMs : 1e3;
397
const timeoutMessage = `Timeout waiting for element to be visible`;
398
399
const timer = new Timer(timeoutMs, this.waitTimeouts);
400
await timer.waitForPromise(
401
this.navigationsObserver.waitForReady(),
402
'Timeout waiting for DomContentLoaded',
403
);
404
405
try {
406
while (!timer.isResolved()) {
407
try {
408
const containerOffset = await this.getContainerOffset();
409
const promise = this.jsPath.waitForElement(
410
jsPath,
411
containerOffset,
412
waitForVisible,
413
timeoutPerTry,
414
);
415
416
const isNodeVisible = await timer.waitForPromise(promise, timeoutMessage);
417
let isValid = isNodeVisible.value?.isVisible;
418
if (!waitForVisible) isValid = isNodeVisible.value?.nodeExists;
419
if (isValid) return true;
420
} catch (err) {
421
if (String(err).includes('not a valid selector')) throw err;
422
// don't log during loop
423
}
424
425
timer.throwIfExpired(timeoutMessage);
426
}
427
} finally {
428
timer.clear();
429
}
430
return false;
431
}
432
433
public moveMouseToStartLocation(): Promise<void> {
434
if (this.isDetached) return;
435
return this.interactor.initialize();
436
}
437
438
public async flushPageEventsRecorder(): Promise<boolean> {
439
try {
440
// don't wait for env to be available
441
if (!this.puppetFrame.canEvaluate(true)) return false;
442
443
const results = await this.puppetFrame.evaluate<PageRecorderResultSet>(
444
`window.flushPageRecorder()`,
445
true,
446
);
447
return this.onPageRecorderEvents(results);
448
} catch (error) {
449
// no op if it fails
450
}
451
return false;
452
}
453
454
public onPageRecorderEvents(results: PageRecorderResultSet): boolean {
455
const [domChanges, mouseEvents, focusEvents, scrollEvents, loadEvents] = results;
456
const hasRecords = results.some(x => x.length > 0);
457
if (!hasRecords) return false;
458
this.logger.stats('FrameEnvironment.onPageEvents', {
459
tabId: this.id,
460
dom: domChanges.length,
461
mouse: mouseEvents.length,
462
focusEvents: focusEvents.length,
463
scrollEvents: scrollEvents.length,
464
loadEvents,
465
});
466
467
for (const [event, url, timestamp] of loadEvents) {
468
const incomingStatus = pageStateToLoadStatus[event];
469
// only record the content paint
470
if (incomingStatus === LoadStatus.ContentPaint) {
471
this.navigations.onLoadStateChanged(incomingStatus, url, null, new Date(timestamp));
472
}
473
}
474
475
this.sessionState.captureDomEvents(
476
this.tab.id,
477
this.id,
478
domChanges,
479
mouseEvents,
480
focusEvents,
481
scrollEvents,
482
);
483
return true;
484
}
485
486
/////// UTILITIES ////////////////////////////////////////////////////////////////////////////////////////////////////
487
488
public toJSON(): IFrameMeta {
489
return {
490
id: this.id,
491
parentFrameId: this.parentId,
492
name: this.puppetFrame.name,
493
tabId: this.tab.id,
494
puppetId: this.devtoolsFrameId,
495
url: this.navigations.currentUrl,
496
securityOrigin: this.securityOrigin,
497
sessionId: this.session.id,
498
createdAtCommandId: this.createdAtCommandId,
499
} as IFrameMeta;
500
}
501
502
public runIsolatedFn<T>(fnName: string, ...args: Serializable[]): Promise<T> {
503
const callFn = `${fnName}(${args
504
.map(x => {
505
if (!x) return 'undefined';
506
return JSON.stringify(x);
507
})
508
.join(', ')})`;
509
return this.runFn<T>(fnName, callFn);
510
}
511
512
public async getDomNodeId(puppetNodeId: string): Promise<number> {
513
const nodeId = await this.puppetFrame.evaluateOnNode<number>(
514
puppetNodeId,
515
'NodeTracker.watchNode(this)',
516
);
517
this.puppetNodeIdsBySaNodeId[nodeId] = puppetNodeId;
518
return nodeId;
519
}
520
521
public async getContainerOffset(): Promise<IPoint> {
522
if (!this.parentId) return { x: 0, y: 0 };
523
const parentOffset = await this.parentFrame.getContainerOffset();
524
const frameElementNodeId = await this.puppetFrame.getFrameElementNodeId();
525
const thisOffset = await this.puppetFrame.evaluateOnNode<IPoint>(
526
frameElementNodeId,
527
`(() => {
528
const rect = this.getBoundingClientRect().toJSON();
529
return { x:rect.x, y:rect.y};
530
})()`,
531
);
532
return {
533
x: thisOffset.x + parentOffset.x,
534
y: thisOffset.y + parentOffset.y,
535
};
536
}
537
538
public async setFileInputFiles(
539
jsPath: IJsPath,
540
files: { name: string; data: Buffer }[],
541
): Promise<void> {
542
const puppetNodeId = this.puppetNodeIdsBySaNodeId[jsPath[0] as number];
543
const tmpDir = await Fs.promises.mkdtemp(`${Os.tmpdir()}/sa-upload`);
544
const filepaths: string[] = [];
545
for (const file of files) {
546
const fileName = `${tmpDir}/${file.name}`;
547
filepaths.push(fileName);
548
await Fs.promises.writeFile(fileName, file.data);
549
}
550
await this.puppetFrame.setFileInputFiles(puppetNodeId, filepaths);
551
this.cleanPaths.push(tmpDir);
552
}
553
554
protected async runFn<T>(fnName: string, serializedFn: string): Promise<T> {
555
const result = await this.puppetFrame.evaluate<T>(serializedFn, true);
556
557
if ((result as any)?.error) {
558
this.logger.error(fnName, { result });
559
throw new InjectedScriptError((result as any).error as string);
560
} else {
561
return result as T;
562
}
563
}
564
565
protected async install(): Promise<void> {
566
try {
567
if (this.isMainFrame) {
568
// only install interactor on the main frame
569
await this.interactor?.initialize();
570
} else {
571
const frameElementNodeId = await this.puppetFrame.getFrameElementNodeId();
572
// retrieve the domNode containing this frame (note: valid id only in the containing frame)
573
this.domNodeId = await this.getDomNodeId(frameElementNodeId);
574
}
575
} catch (error) {
576
// This can happen if the node goes away. Still want to record frame
577
this.logger.warn('FrameCreated.getDomNodeIdError', {
578
error,
579
frameId: this.id,
580
});
581
}
582
this.sessionState.captureFrameDetails(this);
583
}
584
585
private listen(): void {
586
const frame = this.puppetFrame;
587
frame.on('frame-navigated', this.onFrameNavigated.bind(this), true);
588
frame.on('frame-requested-navigation', this.onFrameRequestedNavigation.bind(this), true);
589
frame.on('frame-lifecycle', this.onFrameLifecycle.bind(this), true);
590
}
591
592
private onFrameLifecycle(event: IPuppetFrameEvents['frame-lifecycle']): void {
593
const lowerEventName = event.name.toLowerCase();
594
let status: LoadStatus.Load | LoadStatus.DomContentLoaded;
595
596
if (lowerEventName === 'load') status = LoadStatus.Load;
597
else if (lowerEventName === 'domcontentloaded') status = LoadStatus.DomContentLoaded;
598
599
if (status) {
600
this.navigations.onLoadStateChanged(
601
status,
602
event.loader.url ?? event.frame.url,
603
event.loader.id,
604
);
605
}
606
}
607
608
private onFrameNavigated(event: IPuppetFrameEvents['frame-navigated']): void {
609
const { navigatedInDocument, frame } = event;
610
if (navigatedInDocument) {
611
this.logger.info('Page.navigatedWithinDocument', event);
612
// set load state back to all loaded
613
this.navigations.onNavigationRequested(
614
'inPage',
615
frame.url,
616
this.tab.lastCommandId,
617
event.loaderId,
618
);
619
}
620
this.sessionState.captureFrameDetails(this);
621
}
622
623
// client-side frame navigations (form posts/gets, redirects/ page reloads)
624
private onFrameRequestedNavigation(
625
event: IPuppetFrameEvents['frame-requested-navigation'],
626
): void {
627
this.logger.info('Page.frameRequestedNavigation', event);
628
// disposition options: currentTab, newTab, newWindow, download
629
const { url, reason } = event;
630
this.navigations.updateNavigationReason(url, reason);
631
}
632
}
633
634
const pageStateToLoadStatus = {
635
LargestContentfulPaint: LoadStatus.ContentPaint,
636
DOMContentLoaded: LoadStatus.DomContentLoaded,
637
load: LoadStatus.Load,
638
};
639
640