Skip to content
Permalink
Newer
Older
100644 1213 lines (1068 sloc) 43.5 KB
May 31, 2017
1
/* global XMLHttpRequest */
2
'use strict'
May 19, 2017
3
4
const debug = require('debug')('HeadlessChrome:actions')
6
const Promise = require('bluebird')
7
const fs = require('mz/fs')
8
const _ = require('lodash')
May 19, 2017
9
10
const {
11
browserIsInitialized,
12
sleep,
13
fixSelector,
14
interleaveArrayToObject,
15
promiseTimeout,
16
objectToEncodedUri
17
} = require('./util')
May 19, 2017
18
20
* Injects JavaScript in the page
21
*
22
* Modules available: jQuery, jquery, jQuery.slim and jquery.slim
23
* @example inject('jquery')
24
*
25
* You can use jsdelivr to inject any npm or github package in the page
26
* @example inject('https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js')
27
* @example inject('https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js')
28
*
29
* You can inject a local Javascript file
30
* @example inject('./custom-file.js')
31
* @example inject(__dirname + '/path/to/file.js')
32
*
33
* Note: the path will be resolved with `require.resolve()` so you can include
34
* files that are in `node_modules` simply by installing them with NPM
35
* @example inject('jquery/dist/jquery.min')
36
* @example inject('lodash/dist/lodash.min')
37
*
38
* @param {string} moduleOrScript - Javascript code, file, url or name of the
39
* module to inject.
41
exports.inject = async function inject (moduleOrScript) {
42
debug(`:: inject => Inject "${moduleOrScript}" on the root document...`)
43
browserIsInitialized.call(this)
44
45
if (moduleOrScript.slice(0, 8) === 'https://' || moduleOrScript.slice(0, 7) === 'http://') {
46
return this.injectRemoteScript(moduleOrScript)
47
} else {
48
switch (moduleOrScript) {
49
// TODO: Inject modules from local file
50
case 'jquery':
51
case 'jQuery':
52
return this.evaluateAsync(function () {
53
return new Promise((resolve, reject) => {
54
if (typeof window.jQuery === 'undefined') {
55
const script = document.createElement('script')
56
script.src = 'https://code.jquery.com/jquery-3.2.1.min.js'
57
script.onload = resolve
58
script.onerror = reject
59
document.head.appendChild(script)
60
} else {
61
resolve()
62
}
63
})
66
case 'jquery.slim':
67
case 'jQuery.slim':
68
return this.evaluateAsync(function () {
69
return new Promise((resolve, reject) => {
70
if (typeof window.jQuery === 'undefined') {
71
const script = document.createElement('script')
72
script.src = 'https://code.jquery.com/jquery-3.2.1.slim.min.js'
73
script.onload = resolve
74
script.onerror = reject
75
document.head.appendChild(script)
76
} else {
77
resolve()
78
}
79
})
80
})
81
82
default:
83
const file = require.resolve(moduleOrScript)
84
const buff = await fs.readFile(file)
85
return this.injectScript(buff.toString())
86
}
90
/**
91
* Injects a remote script in the page
92
* @example injectRemoteScript(https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js)
93
* @param {string} src - Url to remote JavaScript file
94
*/
95
exports.injectRemoteScript = async function injectRemoteScript (src) {
96
debug(`:: injectRemoteScript => Inject "${src}" on the root document...`)
97
browserIsInitialized.call(this)
98
const result = await this.evaluateAsync(function (src) {
99
return new Promise((resolve, reject) => {
100
const script = document.createElement('script')
101
script.src = src
102
script.onload = resolve
103
script.onerror = reject
104
document.head.appendChild(script)
105
})
106
}, src)
107
debug(`:: injectRemoteScript => script "${src}" was injected successfully!`)
108
109
return result
110
}
111
112
/**
113
* Injects code in the DOM as script tag
114
* @param {string} script - Code to be injected and evaluated in the DOM
115
*/
116
exports.injectScript = async function injectScript (script) {
117
debug(`:: injectScript => Injecting "${script.slice(0, 10)}..." on the root document...`)
118
browserIsInitialized.call(this)
119
const result = await this.evaluate(function (text) {
120
const script = document.createElement('script')
121
script.text = text
122
document.head.appendChild(script)
123
}, script)
124
debug(`:: injectScript => script "${script.slice(0, 10)}..." was injected successfully!`)
125
126
return result
127
}
128
May 19, 2017
129
/**
130
* Evaluates a fn in the context of the browser
131
* @param fn {function} - The function to evaluate in the browser
132
* @param args {*} - The arguments to pass to the function
133
*/
134
exports.evaluate = async function evaluate (fn, ...args) {
135
debug(`:: evaluate => ${fn}`, ...args)
136
browserIsInitialized.call(this)
137
const exp = args && args.length > 0 ? `(${String(fn)}).apply(null, ${JSON.stringify(args)})` : `(${String(fn)}).apply(null)`
138
const result = await this.client.Runtime.evaluate({
139
expression: exp,
140
returnByValue: true
141
})
142
debug(`:: evaluate => Function ${fn} evaluated successfully!`, result)
143
144
return result
May 31, 2017
146
147
/**
148
* Evaluates an async fn in the context of the browser
149
* @param fn {function} - The function to evaluate in the browser
150
* @param args {*} - The arguments to pass to the function
151
*/
152
exports.evaluateAsync = async function evaluate (fn, ...args) {
153
debug(`:: evaluate => ${fn}`, ...args)
154
browserIsInitialized.call(this)
155
const exp = args && args.length > 0 ? `(${String(fn)}).apply(null, ${JSON.stringify(args)})` : `(${String(fn)}).apply(null)`
156
return this.client.Runtime.evaluate({
157
expression: exp,
158
returnByValue: true,
159
awaitPromise: true
160
})
161
}
May 19, 2017
162
163
/**
164
* Evaluates a fn in the context of a passed node
165
* @param {NodeObject} node - The Node Object used to get the context
166
* @param fn {function} - The function to evaluate in the browser
167
* @param args {*} - The arguments to pass to the function
168
*/
169
exports.evaluateOnNode = async function evaluateOnNode (node, fn, ...args) {
170
debug(`:: evaluateOnNode => Evaluate on node "${node.nodeId}", fn: ${fn}`, ...args)
171
browserIsInitialized.call(this)
172
const exp = args && args.length > 0 ? `function() {(${String(fn)}).apply(null, ${JSON.stringify(args)})}` : `function() {(${String(fn)}).apply(null)}`
174
const resolvedNode = await this.client.DOM.resolveNode(node)
176
return this.client.Runtime.callFunctionOn({
177
objectId: resolvedNode.object.objectId,
178
functionDeclaration: exp
179
})
180
}
May 19, 2017
182
/**
183
* Navigates to a URL
184
* @param {string} url - The URL to navigate to
185
* @param {object} options - The options object.
186
* options:
187
* @property {number} timeout - Time in ms that this method has to wait until the
188
* "pageLoaded" event is triggered. If the value is 0 or false, it means that it doesn't
189
* have to wait after calling the "Page.navigate" method
May 19, 2017
190
*/
191
exports.goTo = async function (url, opt = {}) {
192
debug(`:: goTo => URL "${url}" | Options: ${JSON.stringify(opt, null, 2)}`)
193
browserIsInitialized.call(this)
195
const options = Object.assign({
196
timeout: this.options.browser.loadPageTimeout
197
}, opt)
199
if (options.bypassCertificate) {
200
this.client.Security.certificateError(({eventId}) => {
201
this.client.Security.handleCertificateError({
202
eventId,
203
action: 'continue'
207
this.client.Security.setOverrideCertificateErrors({override: true})
210
await this.client.Page.navigate({ url })
211
212
if (options.timeout && typeof options.timeout) {
213
await this.waitForPageToLoad(options.timeout)
214
}
215
debug(`:: goTo => URL "${url}" navigated!`)
May 19, 2017
217
218
/**
219
* Get the value of an Node.
220
* @param {NodeObject} node - The Node Object
221
* @return {object} - Object containing type and value of the element
222
*/
223
exports.getNodeValue = async function (node) {
224
debug(`:: getNodeValue => Get value from Node "${node.nodeId}"`)
225
browserIsInitialized.call(this)
227
const nodeAttrs = interleaveArrayToObject((await this.client.DOM.getAttributes(node)).attributes)
228
const value = nodeAttrs.value
229
debug(`:: getNodeValue => Value from Node "${node.nodeId}":`, value)
230
return value
231
}
May 19, 2017
233
/**
234
* Get the value of an element.
235
* @param {string} selector - The target selector
236
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
237
* @return {object} - Object containing type and value of the element
238
*/
239
exports.getValue = async function (selector, frameId) {
240
debug(`:: getValue => Get value from element matching "${selector}"`)
241
browserIsInitialized.call(this)
242
selector = fixSelector(selector)
243
244
const node = await this.querySelector(selector, frameId)
245
const { className: htmlObjectType } = (await this.client.DOM.resolveNode(node)).object
246
247
let value
248
// If the node object type is HTMLTextAreaElement, then get the value from the console.
249
/**
250
* TODO: Take the value from the DOM Node. For some reason, there're some pages where is not possible
251
* to get the textarea value, as its nodeId refreshes all the time
252
*/
253
if (htmlObjectType === 'HTMLTextAreaElement') {
254
// Escape selector for onConsole use
255
selector = selector.replace(/\\/g, '\\')
256
const textareaEvaluate = await this.evaluate(function (selector) {
257
return document.querySelector(selector).value
258
}, selector)
259
value = textareaEvaluate.result.value
260
} else {
261
value = await this.getNodeValue(node)
262
}
263
debug(`:: getValue => Value from element matching "${selector}":`, value)
264
return value
265
}
266
267
/**
268
* Set the value of an element.
269
* @param {NodeObject} node - The Node Object
270
* @param {string} value - The value to set the node to (it may be an array of values when the node is a multiple "HTMLSelectElement")
272
exports.setNodeValue = async function (node, value) {
273
debug(`:: setNodeValue => Set node value "${value}" to node "${node.nodeId}"`)
274
browserIsInitialized.call(this)
275
276
const { className: htmlObjectType } = (await this.client.DOM.resolveNode(node)).object
277
278
if (htmlObjectType === 'HTMLSelectElement') {
279
const selectOptions = await this.client.DOM.querySelectorAll({
280
nodeId: node.nodeId,
281
selector: 'option'
282
})
283
284
// Ensure the selectedValuesArray is an array
285
let selectedValuesArray = value
286
if (!Array.isArray(value)) {
287
selectedValuesArray = [value]
288
}
Jul 16, 2017
289
290
// Iterate over all the options of the select
291
for (let option of selectOptions.nodeIds) {
292
const optionValue = await this.getNodeValue({nodeId: option})
293
if (selectedValuesArray.indexOf(optionValue) > -1) {
294
// If the option value is in the list of selectedValues, then set the "selected" property
295
await this.client.DOM.setAttributeValue({
296
nodeId: option,
297
name: 'selected',
298
value: 'true'
299
})
300
} else {
301
// Otherwise, remove the "selected" property
302
await this.client.DOM.removeAttribute({
303
nodeId: option,
304
name: 'selected'
305
})
306
}
307
}
308
} else {
309
await this.client.DOM.setAttributeValue({
310
nodeId: node.nodeId,
311
name: 'value',
312
value: value
313
})
314
}
315
}
May 19, 2017
316
317
/**
318
* Set the value of an element.
319
* @param {string} selector - The selector to set the value of.
320
* @param {string} [value] - The value to set the selector to
321
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
322
*/
323
exports.setValue = async function (selector, value, frameId) {
324
debug(`:: setValue => Set value "${value}" to element matching selector "${selector}" in frame "${frameId || 'root'}"`)
325
browserIsInitialized.call(this)
326
selector = fixSelector(selector)
327
328
const node = await this.querySelector(selector, frameId)
329
const { className: htmlObjectType } = (await this.client.DOM.resolveNode(node)).object
330
331
// If the node object type is HTMLTextAreaElement, then get the value from the console.
332
/**
333
* TODO: Take the value from the DOM Node. For some reason, there're some pages where is not possible
334
* to get the textarea value, as its nodeId refreshes all the time
335
*/
336
if (htmlObjectType === 'HTMLTextAreaElement') {
337
// Escape selector for onConsole use
338
selector = selector.replace(/\\/g, '\\')
339
await this.evaluate(function (selector, value) {
340
document.querySelector(selector).value = value
341
}, selector, value)
342
} else {
343
await this.setNodeValue(node, value)
344
}
345
}
May 19, 2017
346
347
/**
348
* Fills a selector of an input or textarea element with the passed value
349
* @param {string} selector - The selector
350
* @param {string} value - The value to fill the element matched in the selector
351
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
352
*/
353
exports.fill = async function (selector, value, frameId) {
354
debug(`:: fill => Fill selector "${selector}" with value "${value}" in frame "${frameId || 'root'}"`)
355
browserIsInitialized.call(this)
356
selector = fixSelector(selector)
357
358
const node = await this.querySelector(selector, frameId)
359
360
const nodeAttrs = interleaveArrayToObject((await this.client.DOM.getAttributes(node)).attributes)
361
const { className: htmlObjectType } = (await this.client.DOM.resolveNode(node)).object
May 19, 2017
362
363
const type = nodeAttrs.type
364
const supportedTypes = ['color', 'date', 'datetime', 'datetime-local', 'email', 'hidden', 'month', 'number', 'password', 'range', 'search', 'tel', 'text', 'time', 'url', 'week']
365
// Check https://developer.mozilla.org/en-US/docs/Web/API for more info about HTML Objects
366
const isValidInput = htmlObjectType === 'HTMLInputElement' && (typeof type === 'undefined' || supportedTypes.indexOf(type) !== -1)
367
const isTextArea = htmlObjectType === 'HTMLTextAreaElement'
368
369
if (isTextArea || isValidInput) {
370
await this.setNodeValue(node, value)
371
} else {
372
throw new Error(`DOM element ${selector} can not be filled`)
373
}
374
}
May 19, 2017
375
376
/**
377
* Clear an input field.
378
* @param {string} selector - The selector to clear.
379
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
380
*/
381
exports.clear = async function (selector, frameId) {
382
debug(`:: clear => Clear the field of the selector "${selector}"`)
383
browserIsInitialized.call(this)
384
selector = fixSelector(selector)
385
const node = await this.querySelector(selector, frameId)
386
await this.setNodeValue(node, '')
387
}
May 19, 2017
388
389
/**
390
* Returns the node associated to the passed selector
391
* @param {string} selector - The selector to find
392
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
393
* @return {NodeId Object} - NodeId Object (+info: https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-NodeId)
394
*/
395
exports.querySelector = async function (selector, frameId) {
396
debug(`:: querySelector => Get element from selector "${selector}" in frame "${frameId || 'root'}"`)
397
browserIsInitialized.call(this)
398
selector = fixSelector(selector)
399
400
const DOM = this.client.DOM
May 19, 2017
401
402
try {
403
const document = (frameId) ? await DOM.getFlattenedDocument() : await DOM.getDocument()
404
let node = (frameId) ? _.find(document.nodes, { frameId: frameId }) : document.root
405
406
// If the node is an iFrame, the #document node is in the contentDocument attribute of the node
407
if (node.nodeName === 'IFRAME' && node.contentDocument) {
408
node = node.contentDocument
409
}
410
411
return await DOM.querySelector({
412
nodeId: node.nodeId,
413
selector
414
})
415
} catch (err) {
416
err.name = `Can not query selector ${selector} in frame "${frameId || 'root'}": ${err.name}`
417
throw err
418
}
419
}
May 19, 2017
420
421
/**
422
* Focus on an element matching the selector
423
* @param {string} selector - The selector to find the element
424
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
425
*/
426
exports.focus = async function (selector, frameId) {
427
debug(`:: focus => Focusing on selector "${selector}" in frame "${frameId || 'root'}"`)
428
browserIsInitialized.call(this)
429
selector = fixSelector(selector)
May 19, 2017
430
431
try {
432
const node = await this.querySelector(selector, frameId)
433
await this.client.DOM.focus({ nodeId: node.nodeId })
434
} catch (err) {
435
throw new Error(`Can not focus to ${selector}. Error: ${err}`)
436
}
437
}
May 19, 2017
438
439
/**
440
* Simulate a keypress on a selector
441
* @param {string} selector - The selector to type into.
May 19, 2017
442
* @param {string} text - The text to type.
443
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
444
* @param {object} options - Lets you send keys like control & shift
445
*/
446
exports.type = async function (selector, text, frameId, opts) {
447
const options = Object.assign({
448
reset: false, // Clear the field first
449
eventType: 'char' // Enum: [ 'keyDown', 'keyUp', 'rawKeyDown', 'char' ]
450
}, opts)
451
452
debug(`:: type => Type text "${text}" in selector "${selector}" | Options: ${JSON.stringify(options, null, 2)}`)
453
selector = fixSelector(selector)
454
function computeModifier (modifierString) {
455
const modifiers = {
456
'ctrl': 0x04000000,
457
'shift': 0x02000000,
458
'alt': 0x08000000,
459
'meta': 0x10000000,
460
'keypad': 0x20000000
461
}
462
let modifier = 0
463
const checkKey = function (key) {
464
if (key in modifiers) {
465
return
May 19, 2017
466
}
467
debug(key + 'is not a supported key modifier')
468
}
469
if (!modifierString) {
470
return modifier
471
}
May 19, 2017
472
473
const keys = modifierString.split('+')
474
keys.forEach(checkKey)
475
return keys.reduce(function (acc, key) {
476
return acc | modifiers[key]
477
}, modifier)
478
}
479
480
const modifiers = computeModifier(options && options.modifiers)
481
482
// Clear the field in the selector, if needed
483
if (options.reset) {
484
await this.clear(selector, frameId)
485
}
486
487
// Focus on the selector
488
await this.focus(selector, frameId)
489
490
function isSpecialChar (key) {
491
if (key === '\r') {
492
return true
493
}
494
}
495
496
// Type on the selector
497
for (let key of text.split('')) {
498
if (isSpecialChar(key)) {
499
await this.keyboardEvent('rawKeyDown', '', modifiers, 13)
500
await this.keyboardEvent(options.eventType, '\r', modifiers)
501
await this.keyboardEvent('keyUp', '', modifiers, 13)
502
} else {
503
await this.keyboardEvent(options.eventType, key, modifiers)
504
}
505
}
506
}
507
/**
508
* Types text (doesn't matter where it is)
509
* @param {string} text - The text to type.
510
* @param {object} options - Lets you send keys like control & shift
511
*/
512
exports.typeText = async function (text, opts) {
513
const options = Object.assign({
514
reset: false, // Clear the field first
515
eventType: 'char' // Enum: [ 'keyDown', 'keyUp', 'rawKeyDown', 'char' ]
516
}, opts)
517
518
debug(`:: typeText => Type text "${text}" | Options: ${JSON.stringify(options, null, 2)}`)
519
function computeModifier (modifierString) {
520
const modifiers = {
521
'ctrl': 0x04000000,
522
'shift': 0x02000000,
523
'alt': 0x08000000,
524
'meta': 0x10000000,
525
'keypad': 0x20000000
526
}
527
let modifier = 0
528
const checkKey = function (key) {
529
if (key in modifiers) {
530
return
531
}
532
debug(key + 'is not a supported key modifier')
533
}
534
if (!modifierString) {
535
return modifier
536
}
537
538
const keys = modifierString.split('+')
539
keys.forEach(checkKey)
540
return keys.reduce(function (acc, key) {
541
return acc | modifiers[key]
542
}, modifier)
543
}
544
545
const modifiers = computeModifier(options && options.modifiers)
546
547
function isSpecialChar (key) {
548
if (key === '\r') {
549
return true
550
}
551
}
552
553
// Type on the selector
554
for (let key of text.split('')) {
555
if (isSpecialChar(key)) {
556
await this.keyboardEvent('rawKeyDown', '', modifiers, 13)
557
await this.keyboardEvent(options.eventType, '\r', modifiers)
558
await this.keyboardEvent('keyUp', '', modifiers, 13)
559
} else {
560
await this.keyboardEvent(options.eventType, key, modifiers)
561
}
May 19, 2017
564
565
/**
566
* Select a value in an html select element.
567
* @param {string} selector - The identifier for the select element.
568
* @param {string} value - The value to select.
569
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
570
*/
571
exports.select = async function (selector, value, frameId) {
572
debug(`:: select => ${(typeof value !== 'undefined') ? 'Set select value to "' + value + '"' : 'Get select value'} in the select matching selector "${selector}" for frame "${frameId || 'root'}"`)
573
browserIsInitialized.call(this)
574
selector = fixSelector(selector)
575
576
const node = await this.querySelector(selector, frameId)
578
if (typeof value !== 'undefined') {
579
return this.setNodeValue(node, value)
580
} else {
581
return this.getNodeValue(node)
582
}
583
}
May 19, 2017
584
585
/**
586
* Fire a key event.
587
* @param {string} [type=keypress] - The type of key event.
588
* @param {string} [key=null] - The key to use for the event.
589
* @param {number} [modifier=0] - The keyboard modifier to use.
590
* @see {@link https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent}
591
*/
592
exports.keyboardEvent = async function (type, key, modifier, windowsVirtualKeyCode = 0) {
593
debug(`:: keyboardEvent => Event type "${type}" | Key "${key}" | Modifier: "${modifier}"`)
594
browserIsInitialized.call(this)
May 19, 2017
595
596
type = (typeof type === 'undefined') ? 'char' : type
597
key = (typeof key === 'undefined') ? null : key
598
modifier = modifier || 0
599
600
const parameters = {
601
type: type,
602
modifiers: modifier,
603
text: key
604
}
605
if (windowsVirtualKeyCode) {
606
parameters.windowsVirtualKeyCode = windowsVirtualKeyCode
607
parameters.nativeVirtualKeyCode = windowsVirtualKeyCode
608
}
609
await this.client.Input.dispatchKeyEvent(parameters)
May 19, 2017
611
612
/**
613
* Waits certain amount of ms
614
* @param {number} time - Ammount of ms to wait
615
*/
616
exports.wait = async function (time) {
617
debug(`:: wait => Waiting ${time} ms...`)
618
browserIsInitialized.call(this)
619
620
return sleep(time)
621
}
May 19, 2017
622
623
/**
624
* Binding callback to handle console messages
625
*
626
* @param listener is a callback for handling console message
627
*
628
*/
629
exports.onConsole = async function (listener) {
630
debug(`:: onConsole => Binding new listener to console`)
631
browserIsInitialized.call(this)
633
this.on('onConsoleMessage', listener)
636
/**
637
* Waits for a page to finish loading. Throws error after timeout
638
* @param {number} timeout - The timeout in ms. (Default: "loadPageTimeout" property in the browser instance options)
639
*/
640
exports.waitForPageToLoad = async function (timeout) {
641
debug(`:: waitForPageToLoad => Waiting page load...`)
642
browserIsInitialized.call(this)
644
return promiseTimeout(new Promise((resolve, reject) => {
645
const listener = () => {
646
debug(`:: waitForPageToLoad => Page loaded!`)
647
this.removeListener('pageLoaded', listener)
648
resolve(true)
649
}
650
this.on('pageLoaded', listener)
651
}), timeout || this.options.browser.loadPageTimeout)
654
/**
655
* Waits for all the frames in the page to finish loading. Returns the list of frames after that
656
* @param {regexp|string} url - The URL that must be waited for load
657
* @return {object} - List of frames, with childFrames
658
*/
659
exports.waitForFrameToLoad = async function (url, timeout) {
660
if (!url) {
661
throw new Error(`"url" parameter must be passed `)
662
}
663
debug(`:: waitForFramesToLoad => Waiting page frame ${url} load...`)
664
browserIsInitialized.call(this)
665
666
const frames = await this.getFrames()
667
let frame = _.find(frames, function (f) {
668
if ((typeof url === 'string' && f.url === url) || (typeof url !== 'string' && f.url.match(url))) {
669
return f
670
}
671
})
672
673
if (!frame) {
674
frame = await promiseTimeout(new Promise((resolve, reject) => {
675
const listener = (data) => {
676
debug(`:: waitForPageToLoad => Page loaded!`)
677
this.removeListener('frameNavigated', listener)
678
if ((typeof url === 'string' && data.frame.url === url) || (typeof url !== 'string' && data.frame.url.match(url))) {
679
resolve(data.frame)
682
this.on('frameNavigated', listener)
683
}), timeout || this.options.browser.loadPageTimeout)
686
return frame
687
}
689
/**
690
* Waits for a selector to finish loading. Throws error after timeout
691
* @param {string} selector - The identifier for the select element.
692
* @param {number} interval - The interval in ms. (Default: "loadPageTimeout" property in the browser instance options)
693
* @param {number} timeout - The timeout in ms. (Default: "loadPageTimeout" property in the browser instance options)
694
*/
695
exports.waitForSelectorToLoad = async function (selector, interval, timeout) {
696
debug(`:: waitForSelectorToLoad => Waiting selector "${selector}" load...`)
697
browserIsInitialized.call(this)
698
699
const startDate = (new Date()).getTime()
700
while (true) {
701
const exists = await this.exist(selector)
703
if (exists === true) {
704
debug(`:: waitForSelectorToLoad => Selector "${selector}" loaded!`)
705
return
706
}
Jul 16, 2017
707
708
debug(`:: waitForSelectorToLoad => Selector "${selector}" hasn't loaded yet. Waiting ${interval || this.options.browser.loadSelectorInterval}ms...`)
709
await sleep(interval || this.options.browser.loadSelectorInterval)
Jul 16, 2017
710
711
const diff = ((new Date()).getTime()) - startDate
712
if (diff > (timeout || this.options.browser.loadSelectorTimeout)) {
713
debug(`:: waitForSelectorToLoad => Timeout while trying to wait for selector "${selector}".`)
714
throw new Error(`Timeout while trying to wait for selector "${selector}"`)
715
}
716
}
717
}
May 19, 2017
719
/**
720
* Fire a mouse event.
721
* @param {string} [type=mousePressed] - Type of the mouse event. Allowed values: mousePressed, mouseReleased, mouseMoved.
722
* @param {number} [x=0] - X coordinate of the event relative to the main frame's viewport.
723
* @param {number} [y=0] - Y coordinate of the event relative to the main frame's viewport. 0 refers to the top of the viewport and Y increases as it proceeds towards the bottom of the viewport.
May 19, 2017
724
* @param {number} [modifier=0] - Bit field representing pressed modifier keys. Alt=1, Ctrl=2, Meta/Command=4, Shift=8 (default: 0).
725
* @param {string} [button=left] - Mouse button (default: "none"). Allowed values: none, left, middle, right.
726
* @see {@link https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent}
727
*/
728
exports.mouseEvent = async function ({ type = 'mousePressed', x = 0, y = 0, modifiers = 0, button = 'left', clickCount = 1 }) {
729
debug(`:: mouseEvent => Type "${type}" | (${x}; ${y}) | Modifiers: "${modifiers}" | Button: ${button}`)
730
browserIsInitialized.call(this)
May 19, 2017
731
732
await this.client.Input.dispatchMouseEvent({ type, x, y, button, modifiers, clickCount })
733
}
May 19, 2017
734
735
/**
736
* Click on a selector by firing a 'click event' directly in the element of the selector
737
* @param {string} selector - Selector of the element to click
738
* @param {string} frameId - The FrameID where the selector should be searched
May 19, 2017
739
*/
740
exports.click = async function (selector, frameId) {
741
debug(`:: click => Click event on selector "${selector}"`)
742
browserIsInitialized.call(this)
743
selector = fixSelector(selector)
745
const node = await this.querySelector(selector, frameId)
746
await this.evaluateOnNode(node, function clickElement (selector) {
747
document.querySelector(selector).click()
748
}, selector)
749
}
750
751
/**
752
* Clicks left button hover the centroid of the element matching the passed selector
753
* @param {string} [selector]
754
* @param {string} frameId - The FrameID where the selector should be searched
755
*/
756
exports.clickOnSelector = async function (selector, frameId) {
757
debug(`:: clickOnSelector => Mouse click in centroid of element with selector "${selector}" for frame "${frameId || 'root'}"`)
758
browserIsInitialized.call(this)
759
selector = fixSelector(selector)
760
761
const node = await this.querySelector(selector, frameId)
762
const nodeCentroid = await this.getNodeCentroid(node.nodeId)
763
await this.mouseEvent({
764
type: 'mousePressed',
765
x: nodeCentroid.x,
766
y: nodeCentroid.y
767
})
768
await this.mouseEvent({
769
type: 'mouseReleased',
770
x: nodeCentroid.x,
771
y: nodeCentroid.y
772
})
773
}
774
775
/**
776
* Calculates the centroid of a node by using the boxModel data of the element
777
* @param {string} nodeId - The Node Id
778
* @return {object} - { x, y } object with the coordinates
779
*/
780
exports.getNodeCentroid = async function (nodeId) {
781
debug(`:: getNodeCentroid => Get centroid coordinates for node "${nodeId}"`)
782
browserIsInitialized.call(this)
783
const boxModel = await this.client.DOM.getBoxModel({ nodeId: nodeId })
785
return {
786
x: parseInt((boxModel.model.content[0] + boxModel.model.content[2] + boxModel.model.content[4] + boxModel.model.content[6]) / 4),
787
y: parseInt((boxModel.model.content[1] + boxModel.model.content[3] + boxModel.model.content[5] + boxModel.model.content[7]) / 4)
788
}
789
}
791
/**
792
* Get the browser cookies
793
* @return {object} - Object with all the cookies
794
*/
795
exports.getCookies = async function () {
796
debug(`:: getCookies => Return the browser cookies`)
797
browserIsInitialized.call(this)
798
799
return this.client.Network.getCookies()
800
}
802
/**
803
* Set the browser cookies
804
* @param {string} name - The name of the cookie.
805
* @param {string} value - The value of the cookie.
806
* @param {string} url - The request-URI to associate with the setting of the cookie.
807
* @param {{
808
* @property {object} options - Options object
809
* @property {string} [domain] - If omitted, the cookie becomes a host-only cookie
810
* @property {string} [path] - Defaults to the path portion of the url parameter
811
* @property {boolean} [secure] - Defaults to false.
812
* @property {boolean} [httpOnly] - Defaults to false.
813
* @property {string} [sameSite] - Represents the cookie's 'SameSite' status: https://tools.ietf.org/html/draft-west-first-party-cookies
814
* @property {number} [expirationDate] - If omitted, cookie becomes a session cookie
815
* }} options - additional options for setting the cookie (more info here: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setCookie)
816
* @return {boolean} - True if successfully set cookie
817
*/
818
exports.setCookie = async function (name, value, options = {}) {
819
debug(`:: setCookie => Setting browser cookie "${name}" with value "${value}". Additional options: ${options}`)
820
browserIsInitialized.call(this)
822
const setCookieOptions = Object.assign({ name, value }, options)
823
824
return this.client.Network.setCookie(setCookieOptions)
825
}
826
827
/**
828
* Clear the browser cookies
829
*/
830
exports.clearBrowserCookies = async function () {
831
debug(`:: clearBrowserCookies => Clearing browser cookies`)
832
browserIsInitialized.call(this)
833
834
return this.client.Network.clearBrowserCookies()
837
/**
838
* Checks if an element matches the selector
839
* @param {string} selector - The selector string
840
* @param {string} frameId - The FrameID where the selector should be searched
841
* @return {boolean} - Boolean indicating if element of selector exists or not
843
// TODO: make use of the "frameId" parameter in this fn, and avoid using this.evaluate()
844
exports.exist = async function (selector, frameId) {
845
debug(`:: exist => Exist element with selector "${selector}" for frame "${frameId || 'root'}"?`)
846
browserIsInitialized.call(this)
847
selector = fixSelector(selector)
849
const exist = await this.evaluate(selector => !!document.querySelector(selector), selector)
851
debug(`:: exist => Exist: "${exist.result.value}"`)
853
return exist.result.value
854
}
855
856
/**
857
* Checks if an element matching a selector is visible
858
* @param {string} selector - The selector string
859
* @param {string} frameId - The FrameID where the selector should be searched
860
* @return {boolean} - Boolean indicating if element of selector is visible or not
862
// TODO: make use of the "frameId" parameter in this fn, and avoid using this.evaluate()
863
exports.visible = async function (selector, frameId) {
864
debug(`:: visible => Visible element with selector "${selector}" for frame "${frameId || 'root'}"?`)
865
browserIsInitialized.call(this)
866
867
const visible = await this.evaluate(selector => {
868
const el = document.querySelector(selector)
869
let style
870
try {
871
style = window.getComputedStyle(el, null)
872
} catch (e) {
873
return false
874
}
875
if (style.visibility === 'hidden' || style.display === 'none') {
876
return false
877
}
878
if (style.display === 'inline' || style.display === 'inline-block') {
879
return true
880
}
881
return el.clientHeight > 0 && el.clientWidth > 0
882
}, selector)
883
884
debug(`:: visible => Visible: "${visible.result.value}"`)
886
return visible.result.value
887
}
888
889
/**
890
* Takes a screenshot of the page and returns it as a string
891
* @param {object} captureOptions - Options object
892
* Options properties:
893
* @property {string} [format] - Image compression format (defaults to png). Allowed values: jpeg, png.
894
* @property {integer} [quality] - Compression quality from range [0..100] (jpeg only).
895
* @property {ViewPort} [clip] - Capture the screenshot of a given viewport/region only (https://chromedevtools.github.io/devtools-protocol/tot/Page/#type-Viewport)
896
* @property {boolean} [fromSurface] - Capture the screenshot from the surface, rather than the view. Defaults to false. EXPERIMENTAL
897
* @property {string} [selector] - The selector to be captured. If empty, will capture the page
898
* @property {boolean} [fullPage] - If true, captures the full page height
899
* @param {boolean} returnBinary - If true, returns as binary. Otherwise, returns a base64 string
900
* @return {string} - Binary or Base64 string with the image data
901
*/
902
exports.getScreenshot = async function ({
903
format = 'png',
904
quality,
905
clip = {
906
x: 0,
907
y: 0,
908
width: this.options.deviceMetrics.width,
909
height: this.options.deviceMetrics.height,
910
scale: this.options.deviceMetrics.deviceScaleFactor
911
},
912
fromSurface,
913
selector,
914
fullPage
915
}, returnBinary = false) {
916
debug(`:: getScreenshot => Taking a screenshot of the ${selector ? 'selector "' + selector + '"' : 'page'}. Returning as ${returnBinary ? 'binary' : 'base64'} string`)
917
browserIsInitialized.call(this)
918
919
format = format.toLowerCase()
920
if (format === 'jpg') {
921
format = 'jpeg'
922
}
924
// Validate screenshot format
925
if (format !== 'jpeg' && format !== 'png') {
926
throw new Error(`Invalid format "${format}" for the screenshot. Allowed values: "jpeg" and "png".`)
927
}
928
929
const screenshotSettings = {
930
format,
931
quality,
932
fromSurface: true
933
}
934
935
// If fullPage parameter is on, then resize the screen to full size
936
if (fullPage === true) {
937
screenshotSettings.clip = await this.resizeFullScreen()
938
}
939
940
// If there's no clip, setup a default clip
941
if (selector) {
942
screenshotSettings.clip = await this.getSelectorViewport(selector)
943
}
944
945
let screenshot = (await this.client.Page.captureScreenshot(screenshotSettings)).data
946
947
if (returnBinary) {
948
screenshot = Buffer.from(screenshot, 'base64')
949
}
950
debug(`:: getScreenshot => Screenshot took!`)
951
952
return screenshot
953
}
954
955
/**
956
* Saves a screenshot of the page
Aug 28, 2017
957
* @param {string} fileName - Path and Name of the file (without the extension)
958
* @param {object} captureOptions - Options object
959
* Options properties:
960
* @property {string} format - Image compression format (defaults to png). Allowed values: jpeg, png.
961
* @property {integer} quality - Compression quality from range [0..100] (jpeg only).
962
* @property {ViewPort} clip - Capture the screenshot of a given region only (https://chromedevtools.github.io/devtools-protocol/tot/Page/#type-Viewport)
963
* @property {boolean} fromSurface - Capture the screenshot from the surface, rather than the view. Defaults to false. EXPERIMENTAL
964
* @property {string} [selector] - The selector to be captured. If empty, will capture the page
965
* @property {boolean} [fullPage] - If true, captures the full page height
966
* @return {string} - Binary or Base64 string with the image data
967
*/
968
exports.saveScreenshot = async function (fileName = `screenshot-${Date.now()}`, { format = 'png', quality, clip, fromSurface, selector, fullPage } = {}) {
969
debug(`:: saveScreenshot => Saving a screenshot of the ${selector ? 'selector "' + selector + '"' : 'page'}...`)
970
browserIsInitialized.call(this)
971
972
format = format.toLowerCase()
973
if (format === 'jpg') {
974
format = 'jpeg'
975
}
976
977
// Validate screenshot format
978
if (format !== 'jpeg' && format !== 'png') {
979
throw new Error(`Invalid format "${format}" for the screenshot. Allowed values: "jpeg" and "png".`)
980
}
Sep 10, 2017
981
982
const screenshot = await this.getScreenshot({
983
format,
984
quality,
985
clip,
986
fromSurface,
987
selector,
988
fullPage
Sep 10, 2017
989
}, true)
990
991
await fs.writeFile(`${fileName}.${format}`, screenshot)
992
debug(`:: saveScreenshot => Screenshot saved!`)
993
return `${fileName}.${format}`
994
}
995
Aug 28, 2017
996
/**
997
* Prints the page to PDF
998
* @param {{
999
* @property {boolean} landscape - Paper orientation. Defaults to false.
1000
* @property {boolean} displayHeaderFooter - Display header and footer. Defaults to false.
1001
* @property {boolean} printBackground - Print background graphics. Defaults to false.
1002
* @property {number} scale - Scale of the webpage rendering. Defaults to 1.
1003
* @property {number} paperWidth - Paper width in inches. Defaults to 8.5 inches.
1004
* @property {number} paperHeight - Paper height in inches. Defaults to 11 inches.
1005
* @property {number} marginTop - Top margin in inches. Defaults to 1cm (~0.4 inches).
1006
* @property {number} marginBottom - Bottom margin in inches. Defaults to 1cm (~0.4 inches).
1007
* @property {number} marginLeft - Left margin in inches. Defaults to 1cm (~0.4 inches).
1008
* @property {number} marginRight - Right margin in inches. Defaults to 1cm (~0.4 inches).
1009
* @property {string} pageRanges - Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means print all pages.
1010
* }} options - Options object
1011
* @param {boolean} returnBinary - If true, returns as binary. Otherwise, returns a base64 string
1012
* @return {string} - Binary or Base64 string with the PDF data
1013
*/
1014
exports.printToPDF = async function (options = {}, returnBinary = false) {
1015
debug(`:: printToPDF => Printing page to PDF with options: ${JSON.stringify(options, null, 2)}. Returning as ${returnBinary ? 'binary' : 'base64'} string`)
1016
browserIsInitialized.call(this)
1017
let pdf = (await this.client.Page.printToPDF(options)).data
1018
1019
if (returnBinary) {
1020
pdf = Buffer.from(pdf, 'base64')
1021
}
1022
1023
return pdf
1024
}
1025
1026
/**
Aug 28, 2017
1027
* Saves a PDF file of the page
1028
* @param {string} fileName - Path and Name of the file
Aug 28, 2017
1029
* @param {{
1030
* @property {boolean} landscape - Paper orientation. Defaults to false.
1031
* @property {boolean} displayHeaderFooter - Display header and footer. Defaults to false.
1032
* @property {boolean} printBackground - Print background graphics. Defaults to false.
1033
* @property {number} scale - Scale of the webpage rendering. Defaults to 1.
1034
* @property {number} paperWidth - Paper width in inches. Defaults to 8.5 inches.
1035
* @property {number} paperHeight - Paper height in inches. Defaults to 11 inches.
1036
* @property {number} marginTop - Top margin in inches. Defaults to 1cm (~0.4 inches).
1037
* @property {number} marginBottom - Bottom margin in inches. Defaults to 1cm (~0.4 inches).
1038
* @property {number} marginLeft - Left margin in inches. Defaults to 1cm (~0.4 inches).
1039
* @property {number} marginRight - Right margin in inches. Defaults to 1cm (~0.4 inches).
1040
* @property {string} pageRanges - Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means print all pages.
1041
* }} options - PDF options
1042
*/
1043
exports.savePdf = async function (fileName = `pdf-${Date.now()}`, options = {}) {
1044
debug(`:: savePdf => Saving a PDF of the page...`)
1045
browserIsInitialized.call(this)
1046
1047
await fs.writeFile(`${fileName}.pdf`, await this.printToPDF(options, true))
1048
debug(`:: savePdf => PDF saved!`)
1049
return `${fileName}.pdf`
1050
}
1051
1052
/**
1053
* Get the Viewport of the element matching a selector
1054
* @param {string} selector - The selector string
1055
* @param {string} frameId - The FrameID where the selector should be searched
1056
* @return {Viewport} - Object with the viewport properties (https://chromedevtools.github.io/devtools-protocol/tot/Page/#type-Viewport)
1057
*/
1058
exports.getSelectorViewport = async function (selector, frameId) {
1059
debug(`:: getSelectorViewport => Getting viewport for element with selector "${selector}" for frame "${frameId || 'root'}"?`)
1060
browserIsInitialized.call(this)
1061
1062
selector = fixSelector(selector)
1063
1064
const node = await this.querySelector(selector, frameId)
1065
1066
const boxModel = await this.client.DOM.getBoxModel({ nodeId: node.nodeId })
1067
1068
const viewport = {
1069
x: boxModel.model.margin[0],
1070
y: boxModel.model.margin[1],
1071
width: boxModel.model.width,
1072
height: boxModel.model.height,
1073
scale: this.options.deviceMetrics.deviceScaleFactor
1074
}
1075
Sep 10, 2017
1076
debug(`:: getSelectorViewport => Viewport: "${JSON.stringify(viewport)}"`)
1080
1081
/**
1082
* Get the list of frames in the loaded page
1083
* @return {object} - List of frames, with childFrames
1084
*/
1085
exports.getFrames = async function () {
1086
debug(`:: getFrames => Getting frames list`)
1087
browserIsInitialized.call(this)
1088
const frames = []
1089
const resourceTree = await this.client.Page.getResourceTree()
1090
frames.push(resourceTree.frameTree.frame)
1091
_.each(resourceTree.frameTree.childFrames, (frameObj) => {
1092
frames.push(frameObj.frame)
1093
})
1094
return frames
1095
}
May 31, 2017
1096
1097
/**
1098
* Resize viewports of the page to full screen size
1099
*/
1100
exports.resizeFullScreen = async function () {
1101
debug(`:: resizeFullScreen => Resizing viewport to full screen size`)
1102
const {DOM, Emulation} = this.client
1104
const {root: {nodeId: documentNodeId}} = await DOM.getDocument()
1105
const {nodeId: bodyNodeId} = await DOM.querySelector({
1106
selector: 'body',
1107
nodeId: documentNodeId
1109
const deviceMetrics = await this.options.deviceMetrics
1110
const { model: { height } } = await DOM.getBoxModel({ nodeId: bodyNodeId })
1111
1112
await Emulation.setDeviceMetricsOverride({
1113
width: deviceMetrics.width,
1114
height: height,
1115
deviceScaleFactor: deviceMetrics.deviceScaleFactor,
Sep 10, 2017
1116
mobile: deviceMetrics.mobile,
1117
fitWindow: deviceMetrics.fitWindow
1118
})
1119
await this.client.Emulation.setVisibleSize({
1120
width: deviceMetrics.width,
1121
height: height
1122
})
1123
1124
await Emulation.setPageScaleFactor({ pageScaleFactor: deviceMetrics.deviceScaleFactor })
1125
1126
const fullPageViewport = {
1127
x: 0,
1128
y: 0,
1129
width: deviceMetrics.width,
1130
height: height,
1131
scale: deviceMetrics.deviceScaleFactor
1132
}
1133
1134
return fullPageViewport
May 31, 2017
1137
/**
1138
* Accepts or dismisses a JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload)
1139
* @param {boolean} [accept=true] - Whether to accept or dismiss the dialog
1140
* @param {string} [promptText] - The text to enter into the dialog prompt before accepting. Used only if this is a prompt dialog.
1141
*/
1142
exports.handleDialog = async function (accept = true, promptText = '') {
1143
debug(`:: handleDialog => Handling dialog on the page...`)
1144
browserIsInitialized.call(this)
1145
await this.client.Page.handleJavaScriptDialog({
1146
accept,
1147
promptText
1148
})
1149
debug(`:: handleDialog => Dialog handled!`)
1150
}
May 31, 2017
1151
1152
/**
1153
* Post data from the browser context
1154
* @param {string} url - The URL or path to POST to
1155
* @param {object} [data] - The data object to be posted
1156
* @param {object} [options] - Options of the request
1157
* @return {object} - Request status and data
1158
*/
1159
exports.post = async function (url, data = {}, options = {}) {
1160
debug(`:: post => Posting on URL "${url}" data: ${JSON.stringify(data, null, 2)} with options ${options}...`)
1161
browserIsInitialized.call(this)
1162
1163
if (!url) {
1164
throw new Error(`An URL must be supplied in order to make the POST request`)
1165
}
1166
1167
let requestData = data
1168
if (typeof data === 'object') {
1169
requestData = objectToEncodedUri(data)
1170
}
1171
1172
const requestOptions = Object.assign({
1173
contentType: 'application/x-www-form-urlencoded',
1174
timeout: this.options.browser.loadPageTimeout
1175
}, options)
1176
1177
return this.evaluateAsync(function (url, data, options) {
1178
return new Promise((resolve, reject) => {
1179
const xhr = new XMLHttpRequest()
1180
xhr.open('POST', url)
1181
xhr.setRequestHeader('Content-Type', options.contentType)
1182
xhr.onload = function (e) {
1183
resolve({
1184
responseText: xhr.responseText,
1185
status: xhr.status,
1186
response: xhr.response,
1187
responseType: xhr.responseType,
1188
responseURL: xhr.responseURL,
1189
statusText: xhr.statusText,
1190
responseXML: xhr.responseXML,
1191
readyState: xhr.readyState
1192
})
1193
}
1194
xhr.onerror = function (e) {
1195
const error = new Error(`Error while making request POST to URL "${url}"`)
1196
error.requestData = data
1197
error.options = options
1198
error.response = {
1199
responseText: xhr.responseText,
1200
status: xhr.status,
1201
response: xhr.response,
1202
responseType: xhr.responseType,
1203
responseURL: xhr.responseURL,
1204
statusText: xhr.statusText,
1205
responseXML: xhr.responseXML,
1206
readyState: xhr.readyState
1208
reject(error)
May 31, 2017
1209
}
1210
data ? xhr.send(data) : xhr.send()
1211
})
1212
}, url, requestData, requestOptions)
1213
}