Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/client/lib/Agent.ts
1028 views
1
// eslint-disable-next-line max-classes-per-file
2
import { BlockedResourceType } from '@secret-agent/interfaces/ITabOptions';
3
import StateMachine from 'awaited-dom/base/StateMachine';
4
import inspectInstanceProperties from 'awaited-dom/base/inspectInstanceProperties';
5
import * as Util from 'util';
6
import { bindFunctions, getCallSite } from '@secret-agent/commons/utils';
7
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
8
import SuperDocument from 'awaited-dom/impl/super-klasses/SuperDocument';
9
import IDomStorage from '@secret-agent/interfaces/IDomStorage';
10
import IUserProfile from '@secret-agent/interfaces/IUserProfile';
11
import { IRequestInit } from 'awaited-dom/base/interfaces/official';
12
import Response from 'awaited-dom/impl/official-klasses/Response';
13
import { ISuperElement } from 'awaited-dom/base/interfaces/super';
14
import IWaitForResourceOptions from '@secret-agent/interfaces/IWaitForResourceOptions';
15
import IWaitForElementOptions from '@secret-agent/interfaces/IWaitForElementOptions';
16
import { ILocationTrigger } from '@secret-agent/interfaces/Location';
17
import Request from 'awaited-dom/impl/official-klasses/Request';
18
import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
19
import {
20
IElementIsolate,
21
IHTMLFrameElementIsolate,
22
IHTMLIFrameElementIsolate,
23
IHTMLObjectElementIsolate,
24
INodeIsolate,
25
} from 'awaited-dom/base/interfaces/isolate';
26
import CSSStyleDeclaration from 'awaited-dom/impl/official-klasses/CSSStyleDeclaration';
27
import IAgentMeta from '@secret-agent/interfaces/IAgentMeta';
28
import IScreenshotOptions from '@secret-agent/interfaces/IScreenshotOptions';
29
import { INodeVisibility } from '@secret-agent/interfaces/INodeVisibility';
30
import IClientPlugin, { IClientPluginClass } from '@secret-agent/interfaces/IClientPlugin';
31
import { PluginTypes } from '@secret-agent/interfaces/IPluginTypes';
32
import requirePlugins from '@secret-agent/plugin-utils/lib/utils/requirePlugins';
33
import filterPlugins from '@secret-agent/plugin-utils/lib/utils/filterPlugins';
34
import extractPlugins from '@secret-agent/plugin-utils/lib/utils/extractPlugins';
35
import { IPluginClass } from '@secret-agent/interfaces/IPlugin';
36
import WebsocketResource from './WebsocketResource';
37
import IWaitForResourceFilter from '../interfaces/IWaitForResourceFilter';
38
import Resource from './Resource';
39
import Interactor from './Interactor';
40
import IInteractions, {
41
Command,
42
IMousePosition,
43
ITypeInteraction,
44
} from '../interfaces/IInteractions';
45
import Tab, { createTab, getCoreTab } from './Tab';
46
import IAgentCreateOptions from '../interfaces/IAgentCreateOptions';
47
import ScriptInstance from './ScriptInstance';
48
import AwaitedEventTarget from './AwaitedEventTarget';
49
import IAgentDefaults from '../interfaces/IAgentDefaults';
50
import CoreSession from './CoreSession';
51
import IAgentConfigureOptions from '../interfaces/IAgentConfigureOptions';
52
import ConnectionFactory from '../connections/ConnectionFactory';
53
import ConnectionToCore from '../connections/ConnectionToCore';
54
import DisconnectedFromCoreError from '../connections/DisconnectedFromCoreError';
55
import FrameEnvironment, {
56
getCoreFrameEnvironment,
57
getCoreFrameEnvironmentForPosition,
58
} from './FrameEnvironment';
59
import FrozenTab from './FrozenTab';
60
import FileChooser from './FileChooser';
61
import Output, { createObservableOutput } from './Output';
62
import CoreFrameEnvironment from './CoreFrameEnvironment';
63
64
export const DefaultOptions = {
65
defaultBlockedResourceTypes: [BlockedResourceType.None],
66
defaultUserProfile: {},
67
};
68
const scriptInstance = new ScriptInstance();
69
70
const { getState, setState } = StateMachine<Agent, IState>();
71
72
export interface IState {
73
connection: SessionConnection;
74
isClosing: boolean;
75
options: ISessionCreateOptions & Pick<IAgentCreateOptions, 'connectionToCore' | 'showReplay'>;
76
clientPlugins: IClientPlugin[];
77
}
78
79
const propertyKeys: (keyof Agent)[] = [
80
'document',
81
'sessionId',
82
'meta',
83
'tabs',
84
'output',
85
'frameEnvironments',
86
'mainFrameEnvironment',
87
'coreHost',
88
'activeTab',
89
'sessionName',
90
'url',
91
'lastCommandId',
92
'Request',
93
];
94
95
export default class Agent extends AwaitedEventTarget<{ close: void }> {
96
protected static options: IAgentDefaults = { ...DefaultOptions };
97
98
public readonly input: { command?: string } & any;
99
100
#output: Output;
101
102
constructor(options: IAgentCreateOptions = {}) {
103
super(() => {
104
return {
105
target: getState(this).connection.getCoreSessionOrReject(),
106
};
107
});
108
bindFunctions(this);
109
110
options.blockedResourceTypes =
111
options.blockedResourceTypes || Agent.options.defaultBlockedResourceTypes;
112
options.userProfile = options.userProfile || Agent.options.defaultUserProfile;
113
this.input = options.input;
114
115
const sessionName = scriptInstance.generateSessionName(options.name);
116
delete options.name;
117
const connection = new SessionConnection(this);
118
119
setState(this, {
120
connection,
121
isClosing: false,
122
options: {
123
...options,
124
sessionName,
125
scriptInstanceMeta: scriptInstance.meta,
126
dependencyMap: {},
127
corePluginPaths: [],
128
},
129
clientPlugins: [],
130
});
131
}
132
133
public get output(): any | any[] {
134
if (!this.#output) {
135
const coreSession = getState(this)
136
.connection.getCoreSessionOrReject()
137
.catch(() => null);
138
this.#output = createObservableOutput(coreSession);
139
}
140
return this.#output;
141
}
142
143
public set output(value: any | any[]) {
144
const output = this.output;
145
for (const key of Object.keys(output)) {
146
delete output[key];
147
}
148
Object.assign(this.output, value);
149
}
150
151
public get activeTab(): Tab {
152
return getState(this).connection.activeTab;
153
}
154
155
public get document(): SuperDocument {
156
return this.activeTab.document;
157
}
158
159
public get frameEnvironments(): Promise<FrameEnvironment[]> {
160
return this.activeTab.frameEnvironments;
161
}
162
163
public get lastCommandId(): Promise<number> {
164
return this.activeTab.lastCommandId;
165
}
166
167
public get mainFrameEnvironment(): FrameEnvironment {
168
return this.activeTab.mainFrameEnvironment;
169
}
170
171
public get sessionId(): Promise<string> {
172
const coreSession = getState(this).connection.getCoreSessionOrReject();
173
return coreSession.then(x => x.sessionId);
174
}
175
176
public get sessionName(): Promise<string> {
177
return Promise.resolve(getState(this).options.sessionName);
178
}
179
180
public get meta(): Promise<IAgentMeta> {
181
const coreSession = getState(this).connection.getCoreSessionOrReject();
182
return coreSession.then(x => x.getAgentMeta());
183
}
184
185
public get storage(): Promise<IDomStorage> {
186
const coreTab = getCoreTab(this.activeTab);
187
return coreTab.then(async tab => {
188
const profile = await tab.exportUserProfile();
189
return profile.storage;
190
});
191
}
192
193
public get tabs(): Promise<Tab[]> {
194
return getState(this).connection.refreshedTabs();
195
}
196
197
public get url(): Promise<string> {
198
return this.activeTab.url;
199
}
200
201
public get coreHost(): Promise<string> {
202
return getState(this).connection.host;
203
}
204
205
public get Request(): typeof Request {
206
return this.activeTab.Request;
207
}
208
209
// METHODS
210
211
public async close(): Promise<void> {
212
const { isClosing, connection } = getState(this);
213
if (isClosing) return;
214
setState(this, { isClosing: true });
215
216
try {
217
return await connection.close();
218
} catch (error) {
219
if (error instanceof DisconnectedFromCoreError) return;
220
throw error;
221
}
222
}
223
224
public async closeTab(tab: Tab): Promise<void> {
225
await tab.close();
226
}
227
228
public async configure(configureOptions: IAgentConfigureOptions): Promise<void> {
229
const { options } = getState(this);
230
setState(this, {
231
options: {
232
...options,
233
...configureOptions,
234
},
235
});
236
237
const connection = getState(this).connection;
238
// if already setup, call configure
239
if (connection.hasConnected) {
240
if (
241
configureOptions.showReplay !== undefined ||
242
configureOptions.connectionToCore !== undefined
243
) {
244
throw new Error(
245
'This agent has already connected to a Core - it cannot be reconnected. You can use a Handler, or initialize the connection earlier in your script.',
246
);
247
}
248
} else {
249
const session = await connection.getCoreSessionOrReject();
250
await session.configure(getState(this).options);
251
}
252
}
253
254
public detach(tab: Tab, key?: string): FrozenTab {
255
const callSitePath = getCallSite(module.filename, scriptInstance.entrypoint)
256
.map(x => `${x.getFileName()}:${x.getLineNumber()}:${x.getColumnNumber()}`)
257
.join('\n');
258
259
const coreTab = getCoreTab(tab);
260
const coreSession = getState(this).connection.getCoreSessionOrReject();
261
262
const detachedTab = coreSession.then(async session =>
263
session.detachTab(await coreTab, callSitePath, key),
264
);
265
266
return new FrozenTab(this, detachedTab);
267
}
268
269
public async focusTab(tab: Tab): Promise<void> {
270
await tab.focus();
271
}
272
273
public async waitForNewTab(options?: IWaitForOptions): Promise<Tab> {
274
const coreTab = await getCoreTab(this.activeTab);
275
const newCoreTab = coreTab.waitForNewTab(options);
276
const tab = createTab(this, newCoreTab);
277
getState(this).connection.addTab(tab);
278
return tab;
279
}
280
281
// INTERACT METHODS
282
283
public async click(mousePosition: IMousePosition): Promise<void> {
284
let coreFrame = await getCoreFrameEnvironmentForPosition(mousePosition);
285
coreFrame ??= await getCoreFrameEnvironment(this.activeTab.mainFrameEnvironment);
286
await Interactor.run(coreFrame, [{ click: mousePosition }]);
287
}
288
289
public async getFrameEnvironment(
290
frameElement: IHTMLFrameElementIsolate | IHTMLIFrameElementIsolate | IHTMLObjectElementIsolate,
291
): Promise<FrameEnvironment | null> {
292
return await this.activeTab.getFrameEnvironment(frameElement);
293
}
294
295
public async interact(...interactions: IInteractions): Promise<void> {
296
if (!interactions.length) return;
297
let coreFrame = await getCoreFrameForInteractions(interactions);
298
coreFrame ??= await getCoreFrameEnvironment(this.activeTab.mainFrameEnvironment);
299
await Interactor.run(coreFrame, interactions);
300
}
301
302
public async scrollTo(mousePosition: IMousePosition): Promise<void> {
303
let coreFrame = await getCoreFrameEnvironmentForPosition(mousePosition);
304
coreFrame ??= await getCoreFrameEnvironment(this.activeTab.mainFrameEnvironment);
305
await Interactor.run(coreFrame, [{ [Command.scroll]: mousePosition }]);
306
}
307
308
public async type(...typeInteractions: ITypeInteraction[]): Promise<void> {
309
const coreFrame = await getCoreFrameEnvironment(this.activeTab.mainFrameEnvironment);
310
await Interactor.run(
311
coreFrame,
312
typeInteractions.map(t => ({ type: t })),
313
);
314
}
315
316
public async exportUserProfile(): Promise<IUserProfile> {
317
const coreTab = await getCoreTab(this.activeTab);
318
return await coreTab.exportUserProfile();
319
}
320
321
// PLUGINS
322
323
public use(PluginObject: string | IClientPluginClass | { [name: string]: IPluginClass }): Agent {
324
const { clientPlugins, options, connection } = getState(this);
325
const ClientPluginsById: { [id: string]: IClientPluginClass } = {};
326
327
if (typeof PluginObject === 'string') {
328
const Plugins = requirePlugins(PluginObject as string);
329
const CorePlugins = filterPlugins(Plugins, PluginTypes.CorePlugin);
330
const ClientPlugins = filterPlugins<IClientPluginClass>(Plugins, PluginTypes.ClientPlugin);
331
if (CorePlugins.length) {
332
options.corePluginPaths.push(PluginObject);
333
}
334
335
if (connection.hasConnected) {
336
throw new Error(
337
'You muse call .use before any Agent "await" calls (ie, before the Agent connects to Core).',
338
);
339
}
340
341
ClientPlugins.forEach(ClientPlugin => (ClientPluginsById[ClientPlugin.id] = ClientPlugin));
342
} else {
343
const ClientPlugins = extractPlugins<IClientPluginClass>(
344
PluginObject as any,
345
PluginTypes.ClientPlugin,
346
);
347
ClientPlugins.forEach(ClientPlugin => (ClientPluginsById[ClientPlugin.id] = ClientPlugin));
348
}
349
350
Object.values(ClientPluginsById).forEach(ClientPlugin => {
351
const clientPlugin = new ClientPlugin();
352
clientPlugins.push(clientPlugin);
353
if (connection.hasConnected && clientPlugin.onAgent) {
354
clientPlugin.onAgent(this, connection.sendToActiveTab);
355
}
356
options.dependencyMap[ClientPlugin.id] = ClientPlugin.coreDependencyIds || [];
357
});
358
359
return this;
360
}
361
362
/////// METHODS THAT DELEGATE TO ACTIVE TAB //////////////////////////////////////////////////////////////////////////
363
364
public goto(href: string, timeoutMs?: number): Promise<Resource> {
365
return this.activeTab.goto(href, timeoutMs);
366
}
367
368
public goBack(timeoutMs?: number): Promise<string> {
369
return this.activeTab.goBack(timeoutMs);
370
}
371
372
public goForward(timeoutMs?: number): Promise<string> {
373
return this.activeTab.goForward(timeoutMs);
374
}
375
376
public reload(timeoutMs?: number): Promise<Resource> {
377
return this.activeTab.reload(timeoutMs);
378
}
379
380
public fetch(request: Request | string, init?: IRequestInit): Promise<Response> {
381
return this.activeTab.fetch(request, init);
382
}
383
384
public getComputedStyle(element: IElementIsolate, pseudoElement?: string): CSSStyleDeclaration {
385
return this.activeTab.getComputedStyle(element, pseudoElement);
386
}
387
388
public getComputedVisibility(node: INodeIsolate): Promise<INodeVisibility> {
389
return this.activeTab.getComputedVisibility(node);
390
}
391
392
public getJsValue<T>(path: string): Promise<T> {
393
return this.activeTab.getJsValue<T>(path);
394
}
395
396
// @deprecated 2021-04-30: Replaced with getComputedVisibility
397
public async isElementVisible(element: IElementIsolate): Promise<boolean> {
398
return await this.getComputedVisibility(element as any).then(x => x.isVisible);
399
}
400
401
public takeScreenshot(options?: IScreenshotOptions): Promise<Buffer> {
402
return this.activeTab.takeScreenshot(options);
403
}
404
405
public waitForPaintingStable(options?: IWaitForOptions): Promise<void> {
406
return this.activeTab.waitForPaintingStable(options);
407
}
408
409
public waitForResource(
410
filter: IWaitForResourceFilter,
411
options?: IWaitForResourceOptions,
412
): Promise<(Resource | WebsocketResource)[]> {
413
return this.activeTab.waitForResource(filter, options);
414
}
415
416
public waitForElement(element: ISuperElement, options?: IWaitForElementOptions): Promise<void> {
417
return this.activeTab.waitForElement(element, options);
418
}
419
420
public waitForFileChooser(options?: IWaitForOptions): Promise<FileChooser> {
421
return this.activeTab.waitForFileChooser(options);
422
}
423
424
public waitForLocation(trigger: ILocationTrigger, options?: IWaitForOptions): Promise<Resource> {
425
return this.activeTab.waitForLocation(trigger, options);
426
}
427
428
public waitForMillis(millis: number): Promise<void> {
429
return this.activeTab.waitForMillis(millis);
430
}
431
432
/////// THENABLE ///////////////////////////////////////////////////////////////////////////////////////////////////
433
434
public async then<TResult1 = Agent, TResult2 = never>(
435
onfulfilled?:
436
| ((value: Omit<Agent, 'then'>) => PromiseLike<TResult1> | TResult1)
437
| undefined
438
| null,
439
onrejected?: ((reason: any) => PromiseLike<TResult2> | TResult2) | undefined | null,
440
): Promise<TResult1 | TResult2> {
441
try {
442
this.then = null;
443
await getState(this).connection.getCoreSessionOrReject();
444
return onfulfilled(this);
445
} catch (err) {
446
if (onrejected) return onrejected(err);
447
throw err;
448
}
449
}
450
451
public toJSON(): any {
452
// return empty so we can avoid infinite "stringifying" in jest
453
return {
454
type: this.constructor.name,
455
};
456
}
457
458
public [Util.inspect.custom](): any {
459
return inspectInstanceProperties(this, propertyKeys as any);
460
}
461
}
462
463
async function getCoreFrameForInteractions(
464
interactions: IInteractions,
465
): Promise<CoreFrameEnvironment> {
466
for (const interaction of interactions) {
467
if (typeof interaction !== 'object') continue;
468
for (const element of Object.values(interaction)) {
469
const coreFrame = await getCoreFrameEnvironmentForPosition(element);
470
if (coreFrame) return coreFrame;
471
}
472
}
473
}
474
475
// This class will lazily connect to core on first access of the tab properties
476
class SessionConnection {
477
public hasConnected = false;
478
479
public get activeTab(): Tab {
480
this.getCoreSessionOrReject().catch(() => null);
481
return this._activeTab;
482
}
483
484
public set activeTab(value: Tab) {
485
this._activeTab = value;
486
}
487
488
public get host(): Promise<string> {
489
return this._connection?.hostOrError.then(x => {
490
if (x instanceof Error) throw x;
491
return x;
492
});
493
}
494
495
public onConnected?: (session: CoreSession) => void;
496
497
private _connection: ConnectionToCore;
498
private _coreSession: Promise<CoreSession | Error>;
499
private _activeTab: Tab;
500
private _tabs: Tab[] = [];
501
502
constructor(private agent: Agent) {
503
this.sendToActiveTab = this.sendToActiveTab.bind(this);
504
}
505
506
public async refreshedTabs(): Promise<Tab[]> {
507
const session = await this.getCoreSessionOrReject();
508
const coreTabs = await session.getTabs();
509
const tabIds = await Promise.all(this._tabs.map(x => x.tabId));
510
for (const coreTab of coreTabs) {
511
const hasTab = tabIds.includes(coreTab.tabId);
512
if (!hasTab) {
513
const tab = createTab(this.agent, Promise.resolve(coreTab));
514
this._tabs.push(tab);
515
}
516
}
517
return this._tabs;
518
}
519
520
public async close(): Promise<void> {
521
if (!this.hasConnected) return;
522
const sessionOrError = await this._coreSession;
523
if (sessionOrError instanceof CoreSession) {
524
await sessionOrError.close();
525
}
526
}
527
528
public closeTab(tab: Tab): void {
529
const tabIdx = this._tabs.indexOf(tab);
530
this._tabs.splice(tabIdx, 1);
531
if (this._tabs.length) {
532
this._activeTab = this._tabs[0];
533
}
534
}
535
536
public addTab(tab: Tab): void {
537
this._tabs.push(tab);
538
}
539
540
// NOTE: needs to be synchronous so _activeTab can resolve
541
public getCoreSessionOrReject(): Promise<CoreSession> {
542
if (this.hasConnected) {
543
return this._coreSession.then(x => {
544
if (x instanceof CoreSession) return x;
545
throw x;
546
});
547
}
548
this.hasConnected = true;
549
550
const { clientPlugins } = getState(this.agent);
551
const { showReplay, connectionToCore, ...options } = getState(this.agent)
552
.options as IAgentCreateOptions;
553
554
const connection = ConnectionFactory.createConnection(
555
connectionToCore ?? { isPersistent: false },
556
);
557
this._connection = connection;
558
559
this._coreSession = connection.createSession(options).catch(err => err);
560
561
const defaultShowReplay = Boolean(JSON.parse(process.env.SA_SHOW_REPLAY ?? 'true'));
562
563
if (showReplay ?? defaultShowReplay) {
564
this._coreSession = this._coreSession.then(async x => {
565
if (x instanceof CoreSession) await scriptInstance.launchReplay(x);
566
return x;
567
});
568
}
569
570
const coreSession = this._coreSession.then(value => {
571
if (value instanceof CoreSession) {
572
if (this.onConnected) this.onConnected(value);
573
return value;
574
}
575
throw value;
576
});
577
578
const coreTab = coreSession.then(x => x.firstTab).catch(err => err);
579
this._activeTab = createTab(this.agent, coreTab);
580
this._tabs = [this._activeTab];
581
582
for (const clientPlugin of clientPlugins) {
583
if (clientPlugin.onAgent) clientPlugin.onAgent(this.agent, this.sendToActiveTab);
584
}
585
return coreSession;
586
}
587
588
public async sendToActiveTab(toPluginId: string, ...args: any[]): Promise<any> {
589
const coreSession = (await this._coreSession) as CoreSession;
590
const coreTab = coreSession.tabsById.get(await this._activeTab.tabId);
591
return coreTab.commandQueue.run('Tab.runPluginCommand', toPluginId, args);
592
}
593
}
594
595