Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/lib/Interactor.ts
1029 views
1
import {
2
IInteractionGroup,
3
IInteractionGroups,
4
IInteractionStep,
5
IMousePosition,
6
IMousePositionXY,
7
InteractionCommand,
8
} from '@secret-agent/interfaces/IInteractions';
9
import { assert } from '@secret-agent/commons/utils';
10
import {
11
getKeyboardKey,
12
IKeyboardKey,
13
KeyboardKeys,
14
} from '@secret-agent/interfaces/IKeyboardLayoutUS';
15
import IInteractionsHelper from '@secret-agent/interfaces/IInteractionsHelper';
16
import IRect from '@secret-agent/interfaces/IRect';
17
import IWindowOffset from '@secret-agent/interfaces/IWindowOffset';
18
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
19
import Log from '@secret-agent/commons/Logger';
20
import { IBoundLog } from '@secret-agent/interfaces/ILog';
21
import { INodePointer } from '@secret-agent/interfaces/AwaitedDom';
22
import IPoint from '@secret-agent/interfaces/IPoint';
23
import IMouseUpResult from '@secret-agent/interfaces/IMouseUpResult';
24
import IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';
25
import { IPuppetKeyboard, IPuppetMouse } from '@secret-agent/interfaces/IPuppetInput';
26
import ICorePlugins from '@secret-agent/interfaces/ICorePlugins';
27
import IViewport from '@secret-agent/interfaces/IViewport';
28
import IElementRect from '@secret-agent/interfaces/IElementRect';
29
import { INodeVisibility } from '@secret-agent/interfaces/INodeVisibility';
30
import { getClientRectFnName, getNodeIdFnName } from '@secret-agent/interfaces/jsPathFnNames';
31
import Tab from './Tab';
32
import FrameEnvironment from './FrameEnvironment';
33
import { JsPath } from './JsPath';
34
import MouseupListener from './MouseupListener';
35
import MouseoverListener from './MouseoverListener';
36
import { formatJsPath } from './CommandFormatter';
37
38
const { log } = Log(module);
39
40
const commandsNeedingScroll = [
41
InteractionCommand.click,
42
InteractionCommand.doubleclick,
43
InteractionCommand.move,
44
];
45
46
export default class Interactor implements IInteractionsHelper {
47
public get mousePosition(): IPoint {
48
return { ...this.mouse.position };
49
}
50
51
public get scrollOffset(): Promise<IPoint> {
52
return this.jsPath.getWindowOffset().then(offset => {
53
return {
54
x: offset.scrollX,
55
y: offset.scrollY,
56
};
57
});
58
}
59
60
public get viewport(): IViewport {
61
return this.frameEnvironment.session.viewport;
62
}
63
64
public logger: IBoundLog;
65
66
private preInteractionPaintStableStatus: { isStable: boolean; timeUntilReadyMs?: number };
67
68
private readonly frameEnvironment: FrameEnvironment;
69
70
private get tab(): Tab {
71
return this.frameEnvironment.tab;
72
}
73
74
private get jsPath(): JsPath {
75
return this.frameEnvironment.jsPath;
76
}
77
78
private get mouse(): IPuppetMouse {
79
return this.tab.puppetPage.mouse;
80
}
81
82
private get keyboard(): IPuppetKeyboard {
83
return this.tab.puppetPage.keyboard;
84
}
85
86
private get plugins(): ICorePlugins {
87
return this.tab.session.plugins;
88
}
89
90
constructor(frameEnvironment: FrameEnvironment) {
91
this.frameEnvironment = frameEnvironment;
92
this.logger = log.createChild(module, {
93
sessionId: frameEnvironment.session.id,
94
frameId: frameEnvironment.id,
95
});
96
}
97
98
public async initialize(): Promise<void> {
99
const startingMousePosition = await this.plugins.getStartingMousePoint(this);
100
this.mouse.position = startingMousePosition || this.mouse.position;
101
}
102
103
public play(interactions: IInteractionGroups, resolvablePromise: IResolvablePromise<any>): void {
104
const finalInteractions = Interactor.injectScrollToPositions(interactions);
105
106
this.preInteractionPaintStableStatus = this.frameEnvironment.navigationsObserver.getPaintStableStatus();
107
this.plugins
108
.playInteractions(finalInteractions, this.playInteraction.bind(this, resolvablePromise), this)
109
.then(resolvablePromise.resolve)
110
.catch(resolvablePromise.reject);
111
}
112
113
public async lookupBoundingRect(
114
mousePosition: IMousePosition,
115
throwIfNotPresent = false,
116
includeNodeVisibility = false,
117
): Promise<
118
IRect & {
119
elementTag?: string;
120
nodeId?: number;
121
nodeVisibility?: INodeVisibility;
122
}
123
> {
124
if (isMousePositionCoordinate(mousePosition)) {
125
return {
126
x: mousePosition[0] as number,
127
y: mousePosition[1] as number,
128
width: 1,
129
height: 1,
130
};
131
}
132
if (mousePosition === null) {
133
throw new Error('Null mouse position provided to agent.interact');
134
}
135
const jsPath = this.jsPath;
136
const containerOffset = await this.frameEnvironment.getContainerOffset();
137
const rectResult = await jsPath.exec<IElementRect>(
138
[...mousePosition, [getClientRectFnName, includeNodeVisibility]],
139
containerOffset,
140
);
141
const rect = rectResult.value;
142
const nodePointer = rectResult.nodePointer as INodePointer;
143
144
if (!nodePointer?.id && throwIfNotPresent)
145
throw new Error(
146
`The provided interaction->mousePosition did not match any nodes (${formatJsPath(
147
mousePosition,
148
)})`,
149
);
150
151
return {
152
x: rect.x,
153
y: rect.y,
154
height: rect.height,
155
width: rect.width,
156
elementTag: rect.tag,
157
nodeId: nodePointer?.id,
158
nodeVisibility: rect.nodeVisibility,
159
};
160
}
161
162
public async createMouseupTrigger(
163
nodeId: number,
164
): Promise<{
165
didTrigger: (mousePosition: IMousePosition, throwOnFail?: boolean) => Promise<IMouseUpResult>;
166
}> {
167
assert(nodeId, 'nodeId should not be null');
168
const mouseListener = new MouseupListener(this.frameEnvironment, nodeId);
169
await mouseListener.register();
170
return {
171
didTrigger: async (mousePosition, throwOnFail = true) => {
172
const result = await mouseListener.didTriggerMouseEvent();
173
if (!result.didClickLocation && throwOnFail) {
174
this.throwMouseUpTriggerFailed(nodeId, result, mousePosition);
175
}
176
return result;
177
},
178
};
179
}
180
181
public async createMouseoverTrigger(
182
nodeId: number,
183
): Promise<{ didTrigger: () => Promise<boolean> }> {
184
assert(nodeId, 'nodeId should not be null');
185
const mouseListener = new MouseoverListener(this.frameEnvironment, nodeId);
186
await mouseListener.register();
187
188
return {
189
didTrigger: () => mouseListener.didTriggerMouseEvent(),
190
};
191
}
192
193
private async playInteraction(
194
resolvable: IResolvablePromise<any>,
195
interaction: IInteractionStep,
196
): Promise<void> {
197
if (resolvable.isResolved) return;
198
if (this.tab.isClosing) {
199
throw new CanceledPromiseError('Canceling interaction - tab closing');
200
}
201
202
switch (interaction.command) {
203
case InteractionCommand.move: {
204
const { x, y } = await this.getPositionXY(interaction.mousePosition);
205
await this.mouse.move(x, y);
206
break;
207
}
208
case InteractionCommand.scroll: {
209
const windowBounds = await this.jsPath.getWindowOffset();
210
const scroll = await this.getScrollOffset(interaction.mousePosition, windowBounds);
211
212
if (scroll) {
213
const { deltaY, deltaX } = scroll;
214
await this.mouse.wheel(scroll);
215
// need to check for offset since wheel event doesn't wait for scroll
216
await this.jsPath.waitForScrollOffset(
217
Math.max(0, deltaX + windowBounds.scrollX),
218
Math.max(0, deltaY + windowBounds.scrollY),
219
);
220
}
221
break;
222
}
223
224
case InteractionCommand.click:
225
case InteractionCommand.doubleclick: {
226
const { delayMillis, mouseButton, command, mousePosition } = interaction;
227
if (!mousePosition) {
228
throw new Error(
229
`Null element provided to interact.click. Please double-check your selector`,
230
);
231
}
232
const button = mouseButton || 'left';
233
const clickCount = command === InteractionCommand.doubleclick ? 2 : 1;
234
const isCoordinates = isMousePositionCoordinate(mousePosition);
235
236
if (isCoordinates) {
237
const [x, y] = mousePosition as number[];
238
const clickOptions = { button, clickCount };
239
await this.mouse.move(x, y);
240
await this.mouse.down(clickOptions);
241
if (delayMillis) await waitFor(delayMillis, resolvable);
242
243
await this.mouse.up(clickOptions);
244
return;
245
}
246
247
let nodePointerId: number;
248
if (isMousePositionNodeId(mousePosition)) {
249
nodePointerId = mousePosition[0] as number;
250
} else {
251
const nodeLookup = await this.jsPath.exec<number>(
252
[...mousePosition, [getNodeIdFnName]],
253
null,
254
);
255
if (nodeLookup.value) {
256
nodePointerId = nodeLookup.value;
257
}
258
}
259
260
const result = await this.moveMouseOverTarget(nodePointerId, interaction, resolvable);
261
262
if (result.simulateOptionClick) {
263
await this.jsPath.simulateOptionClick([nodePointerId]);
264
return;
265
}
266
267
await this.mouse.down({ button, clickCount });
268
if (delayMillis) await waitFor(delayMillis, resolvable);
269
270
const mouseupTrigger = await this.createMouseupTrigger(nodePointerId);
271
await this.mouse.up({ button, clickCount });
272
await mouseupTrigger.didTrigger(mousePosition);
273
break;
274
}
275
case InteractionCommand.clickUp: {
276
const button = interaction.mouseButton || 'left';
277
await this.mouse.up({ button });
278
break;
279
}
280
case InteractionCommand.clickDown: {
281
const button = interaction.mouseButton || 'left';
282
await this.mouse.down({ button });
283
break;
284
}
285
286
case InteractionCommand.type: {
287
let counter = 0;
288
for (const keyboardCommand of interaction.keyboardCommands) {
289
const delay = interaction.keyboardDelayBetween;
290
const keyupDelay = interaction.keyboardKeyupDelay;
291
if (counter > 0 && delay) {
292
await waitFor(delay, resolvable);
293
}
294
295
if ('keyCode' in keyboardCommand) {
296
const key = getKeyboardKey(keyboardCommand.keyCode);
297
await this.keyboard.press(key, keyupDelay);
298
} else if ('up' in keyboardCommand) {
299
const key = getKeyboardKey(keyboardCommand.up);
300
await this.keyboard.up(key);
301
} else if ('down' in keyboardCommand) {
302
const key = getKeyboardKey(keyboardCommand.down);
303
await this.keyboard.down(key);
304
} else if ('string' in keyboardCommand) {
305
const text = keyboardCommand.string;
306
for (const char of text) {
307
if (char in KeyboardKeys) {
308
await this.keyboard.press(char as IKeyboardKey, keyupDelay);
309
} else {
310
await this.keyboard.sendCharacter(char);
311
}
312
if (delay) await waitFor(delay, resolvable);
313
}
314
}
315
counter += 1;
316
}
317
break;
318
}
319
320
case InteractionCommand.waitForNode: {
321
await this.frameEnvironment.waitForDom(interaction.delayNode);
322
break;
323
}
324
case InteractionCommand.waitForElementVisible: {
325
await this.frameEnvironment.waitForDom(interaction.delayElement, { waitForVisible: true });
326
break;
327
}
328
case InteractionCommand.waitForMillis: {
329
await waitFor(interaction.delayMillis, resolvable);
330
break;
331
}
332
}
333
}
334
335
private async getScrollOffset(
336
targetPosition: IMousePosition,
337
windowBounds: IWindowOffset,
338
): Promise<{ deltaX: number; deltaY: number }> {
339
assert(targetPosition, 'targetPosition should not be null');
340
341
if (isMousePositionCoordinate(targetPosition)) {
342
const [x, y] = targetPosition as IMousePositionXY;
343
const deltaX = x - windowBounds.scrollX;
344
const deltaY = y - windowBounds.scrollY;
345
return { deltaX, deltaY };
346
}
347
348
const rect = await this.lookupBoundingRect(targetPosition);
349
350
const deltaY = deltaToFullyVisible(rect.y, rect.height, windowBounds.innerHeight);
351
const deltaX = deltaToFullyVisible(rect.x, rect.width, windowBounds.innerWidth);
352
353
if (deltaY === 0 && deltaX === 0) return null;
354
return { deltaX, deltaY };
355
}
356
357
private async getPositionXY(
358
mousePosition: IMousePosition,
359
): Promise<IPoint & { nodeId?: number }> {
360
assert(mousePosition, 'mousePosition should not be null');
361
if (isMousePositionCoordinate(mousePosition)) {
362
const [x, y] = mousePosition as number[];
363
return { x: round(x), y: round(y) };
364
}
365
const containerOffset = await this.frameEnvironment.getContainerOffset();
366
const clientRectResult = await this.jsPath.exec<IElementRect>(
367
[...mousePosition, [getClientRectFnName]],
368
containerOffset,
369
);
370
const nodePointer = clientRectResult.nodePointer as INodePointer;
371
372
const point = this.createPointInRect(clientRectResult.value);
373
return { ...point, nodeId: nodePointer?.id };
374
}
375
376
private createPointInRect(rect: IElementRect): IPoint {
377
if (rect.y === 0 && rect.height === 0 && rect.width === 0 && rect.x === 0) {
378
return { x: 0, y: 0 };
379
}
380
// Default is to find exact middle. An emulator should replace an entry with a coordinate to avoid this functionality
381
let x = round(rect.x + rect.width / 2);
382
let y = round(rect.y + rect.height / 2);
383
// if coordinates go out of screen, bring back
384
if (x > this.viewport.width) x = this.viewport.width - 1;
385
if (y > this.viewport.height) y = this.viewport.height - 1;
386
387
return { x, y };
388
}
389
390
private async moveMouseOverTarget(
391
nodeId: number,
392
interaction: IInteractionStep,
393
resolvable: IResolvablePromise,
394
): Promise<{ domCoordinates: IPoint; simulateOptionClick?: boolean }> {
395
let targetPoint: IPoint;
396
let nodeVisibility: INodeVisibility;
397
// try 2x to hover over the expected target
398
for (let retryNumber = 0; retryNumber < 2; retryNumber += 1) {
399
const rect = await this.lookupBoundingRect([nodeId], false, true);
400
401
if (rect.elementTag === 'option') {
402
return { simulateOptionClick: true, domCoordinates: null };
403
}
404
nodeVisibility = rect.nodeVisibility;
405
targetPoint = this.createPointInRect({
406
tag: rect.elementTag,
407
...rect,
408
});
409
410
const needsMouseoverTest = !isPointInRect(this.mouse.position, rect);
411
412
// wait for mouse to be over target
413
const waitForTarget = needsMouseoverTest
414
? await this.createMouseoverTrigger(nodeId)
415
: { didTrigger: () => Promise.resolve(true) };
416
await this.mouse.move(targetPoint.x, targetPoint.y);
417
418
const isOverTarget = await waitForTarget.didTrigger();
419
if (isOverTarget === true) {
420
return { domCoordinates: targetPoint };
421
}
422
423
this.logger.info(
424
'Interaction.click - moving over target before click did not hover over expected "Interaction.mousePosition" element.',
425
{
426
mousePosition: interaction.mousePosition,
427
expectedNodeId: nodeId,
428
isNodeHidden: Object.values(rect.nodeVisibility ?? {}).some(Boolean),
429
domCoordinates: targetPoint,
430
retryNumber,
431
},
432
);
433
434
// give the page time to sort out
435
await waitFor(500, resolvable);
436
// make sure element is on screen
437
await this.playInteraction(resolvable, {
438
command: 'scroll',
439
mousePosition: [nodeId],
440
});
441
}
442
443
this.logger.error(
444
'Interaction.click - moving over target before click did not hover over expected "Interaction.mousePosition" element.',
445
{
446
'Interaction.mousePosition': interaction.mousePosition,
447
target: {
448
nodeId,
449
nodeVisibility,
450
domCoordinates: { x: targetPoint.x, y: targetPoint.y },
451
},
452
},
453
);
454
455
throw new Error(
456
'Interaction.click - could not move mouse over target provided by "Interaction.mousePosition".',
457
);
458
}
459
460
private throwMouseUpTriggerFailed(
461
nodeId: number,
462
mouseUpResult: IMouseUpResult,
463
mousePosition: IMousePosition,
464
) {
465
let extras = '';
466
const isNodeHidden = mouseUpResult.expectedNodeVisibility.isVisible === false;
467
if (isNodeHidden && nodeId) {
468
extras = `\n\nNOTE: The target node is not visible in the dom.`;
469
}
470
if (this.preInteractionPaintStableStatus?.isStable === false) {
471
if (!extras) extras += '\n\nNOTE:';
472
extras += ` You might have more predictable results by waiting for the page to stabilize before triggering this click -- agent.waitForPaintingStable()`;
473
}
474
this.logger.error(
475
`Interaction.click did not trigger mouseup on expected "Interaction.mousePosition" path.${extras}`,
476
{
477
'Interaction.mousePosition': mousePosition,
478
expected: {
479
nodeId,
480
element: mouseUpResult.expectedNodePreview,
481
visibility: mouseUpResult.expectedNodeVisibility,
482
},
483
clicked: {
484
nodeId: mouseUpResult.targetNodeId,
485
element: mouseUpResult.targetNodePreview,
486
coordinates: {
487
x: mouseUpResult.pageX,
488
y: mouseUpResult.pageY,
489
},
490
},
491
},
492
);
493
throw new Error(
494
`Interaction.click did not trigger mouseup on expected "Interaction.mousePosition" path.${extras}`,
495
);
496
}
497
498
private static injectScrollToPositions(interactions: IInteractionGroups): IInteractionGroups {
499
const finalInteractions: IInteractionGroups = [];
500
for (const group of interactions) {
501
const groupCommands: IInteractionGroup = [];
502
finalInteractions.push(groupCommands);
503
for (const step of group) {
504
if (
505
commandsNeedingScroll.includes(InteractionCommand[step.command]) &&
506
step.mousePosition
507
) {
508
groupCommands.push({
509
command: InteractionCommand.scroll,
510
mousePosition: step.mousePosition,
511
});
512
}
513
groupCommands.push(step);
514
}
515
}
516
return finalInteractions;
517
}
518
}
519
520
function isMousePositionNodeId(mousePosition: IMousePosition): boolean {
521
return mousePosition.length === 1 && typeof mousePosition[0] === 'number';
522
}
523
524
export function isPointInRect(point: IPoint, rect: IRect): boolean {
525
if (point.x < rect.x || point.x > rect.x + rect.width) return false;
526
if (point.y < rect.y || point.y > rect.y + rect.height) return false;
527
528
return true;
529
}
530
531
export function isMousePositionCoordinate(value: IMousePosition): boolean {
532
return (
533
Array.isArray(value) &&
534
value.length === 2 &&
535
typeof value[0] === 'number' &&
536
typeof value[1] === 'number'
537
);
538
}
539
540
export function deltaToFullyVisible(
541
coordinate: number,
542
length: number,
543
boundaryLength: number,
544
): number {
545
if (coordinate >= 0) {
546
if (length > boundaryLength) {
547
length = boundaryLength / 2;
548
}
549
const bottom = Math.round(coordinate + length);
550
// end passes boundary
551
if (bottom > boundaryLength) {
552
return -Math.round(boundaryLength - bottom);
553
}
554
} else {
555
const top = Math.round(coordinate);
556
if (top < 0) {
557
return top;
558
}
559
}
560
return 0;
561
}
562
563
async function waitFor(millis: number, resolvable: IResolvablePromise): Promise<void> {
564
if (millis === undefined || millis === null) return;
565
566
await Promise.race([
567
resolvable.promise,
568
new Promise(resolve => setTimeout(resolve, millis).unref()),
569
]);
570
}
571
572
function round(num: number): number {
573
return Math.round(10 * num) / 10;
574
}
575
576