├── .eslintrc ├── .gitignore ├── README.md ├── index.js ├── package.json ├── plumbing.js └── utils.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es6": true, 5 | "node": true, 6 | "commonjs": true, 7 | "browser": true 8 | }, 9 | "parserOptions": { 10 | "ecmaVersion": 6 11 | }, 12 | "rules": { 13 | "arrow-spacing": "error", 14 | "indent": [ "error", 2, { "SwitchCase": 1 }], 15 | "linebreak-style": [ "error", "unix" ], 16 | "no-mixed-spaces-and-tabs": "error", 17 | "no-class-assign": "error", 18 | "no-cond-assign": "off", 19 | "no-const-assign": "error", 20 | "no-constant-condition": [ "error", { "checkLoops": false } ], 21 | "no-this-before-super": "error", 22 | "no-var": "error", 23 | "object-shorthand": [ "error", "always" ], 24 | "one-var": [ "error", { "initialized": "never" } ], 25 | "prefer-arrow-callback": "error", 26 | "prefer-const": "error", 27 | "quotes": [ "error", "single", { "avoidEscape": true, "allowTemplateLiterals": true } ], 28 | "quote-props": [ "error", "as-needed" ], 29 | "semi": [ "error", "always" ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test.js 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automatonic 2 | 3 | automatonic is a library that, for now, is meant to be used within an [Electron](http://electron.atom.io) app for browser automation. Electron provides pretty good APIs for doing automation, but they're not particularly convenient for things like automated testing. There _are_ things like [Nightmare.js](http://www.nightmarejs.org) the provide an abstraction on top of Electron, and this is largely inspired by those. 4 | 5 | ## Why not just use Nightmare.js? 6 | 7 | Reasons. Here are a few: 8 | 9 | * The page I was trying to test would cause Nightmare.js to freeze, but the same test when run manually in Electron or PhantomJS worked fine. 10 | * You can't use Nightmare.js from within an Electron app. 11 | * You can't test multi-session interaction (multiple browsers, like chat) because you only get access to one BrowserWindow. 12 | 13 | ## API 14 | 15 | All of the API methods return a Promise, and all of them use an internal queue to make sure actions run in the correct order. As this is still in a proof-of-concept phase, the API is fairly limited. At some point, there _may_ be an API to use directly from node that spins up a child process with Electron and proxies back and forth like Nightmare.js. 16 | 17 | ### Browser 18 | 19 | * __constructor([options object])__ or `Browser.new([options object])` 20 | > The options object is passed straight through to Electron's `BrowserWindow`. If not specified, `webPreferences.nodeIntegration` is set to `false` because it can interfere with module loaders and has the tiny risk of having a third party script completely own your machine. 21 | 22 | > The automatonic specific options are: 23 | * __pollInterval__: number of milliseconds between element checks when waiting for an element to appear. Default is 200. 24 | * __typingInterval__: number of milliseconds between characters when typing into an input. Default is 50. 25 | * exposeElectronAs: `string` variable name to expose the electron module to the page e.g. `window.${exposeElectronAs} = require('electron')`. This is implemented with a preload script, so it works even if `nodeIntegration` is disabled (the default). 26 | * preloadScript: `string` of extra script that gets added to any generated preload script to be handed to electron. 27 | 28 | > Any generated preload scripts are created as temporary files that are cleaned up when the main process exits. 29 | 30 | #### Properties 31 | 32 | * __browser__ 33 | > The `BrowserWindow` instance belonging to this `Browser`. 34 | 35 | #### Methods 36 | 37 | * __goto(url[, options object])__ 38 | > Navigate to the given `url`. Any options are passed directly to `BrowserWindow.loadURL`, and the returned Promise resolves when the page load is complete. 39 | 40 | * __execute(function[, ...args])__ 41 | > Execute the given function in the browser by `toString()`ing it, `JSON.stringify`ing the arguments, shipping them to the render instance, wrapping everything up in a Promise, and returning the result. 42 | 43 | * __click(selector[, options object])__ 44 | > Find an element with the given selector and trigger `mouseover`, `mousedown`, `click`, and `mouseup` events. This will wait up to 1s (default, change with the `timeout` option) for the element to appear. 45 | 46 | * __type(selector, string[, options object])__ 47 | > Find an element with the given selector, focus it, and then pass each character from the string into the target element. Each character will trigger `keydown`, `keypress`, update the value, `input`, and `keyup`. Once all of the characters are added, a `change` event will be triggered. This will wait up to 1s (default, change with the `timeout` option) for the element to appear. Specifying `append: true` will not empty the target input before sending characters. 48 | 49 | * __waitFor(selector, timeout = 5000)__ 50 | > Wait up to `timeout` milliseconds for an element matching `selector` to appear on the page. 51 | 52 | * __waitFor(timeout = 1000)__ 53 | > Wait for `timeout` milliseconds before continuing. 54 | 55 | * __checkFor(selector)__ 56 | > Immediately check to see if an element matching `selector` exists. 57 | 58 | * __checkForText(string)__ 59 | > Immediately check to see if `string` exists in the page HTML. If `string` is a RegExp, then its `test` method will be used to determine whether or not there is a match. 60 | 61 | * __checkpoint()__ 62 | > Sets a checkpoint in the queue. If any step before the checkpoint fails, everything between the checkpoint and the failure will be removed from the queue. The Promise returned will resolve when all of the steps before the checkpoint have resolved. 63 | 64 | * __close()__ 65 | > Closes and disposes of the Browser. 66 | 67 | ### Utility methods 68 | 69 | * __run(generator)__ 70 | > This is basically a copy of [co](https://github.com/tj/co) that only allows `yield`ing Promises. This is particularly useful for allowing easy branching within an automation. This returns a Promise that resolves when the generator has nothing left to `yield`. 71 | 72 | * __sleep(milliseconds)__ 73 | > Returns a Promise that resolves after `milliseconds`ms have elapsed. 74 | 75 | ## Usage 76 | ```js 77 | const { Browser, run, sleep } = require('automatonic'); 78 | run(function*() { 79 | const I = new Browser(); 80 | I.goto('https://google.com'); 81 | 82 | // let's give 'em a second to settle 83 | yield sleep(1000); 84 | 85 | // do a search 86 | I.type('#lst-ib', 'automatonic\n'); 87 | I.click('button[name=btnG]'); 88 | 89 | // wait for a result and grab its title 90 | I.waitFor('h3.r a'); 91 | const first = yield I.execute(function() { 92 | return document.querySelector('h3.r a').innerText; 93 | }); 94 | 95 | if (~first.toLowerCase().indexOf('wikipedia')) { 96 | console.log("hey look, it's a Wikipedia link"); 97 | } else { 98 | console.log("it's not a Wikipedia link, let's click it"); 99 | I.click('h3.r a'); 100 | } 101 | 102 | yield sleep(20000); 103 | I.close(); 104 | }).then(null, err => { 105 | console.error('OH NOES!', err); 106 | }); 107 | ``` 108 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | if (!process.versions.electron) { 2 | console.log('TODO: support launching electron'); // eslint-disable-line no-console 3 | process.exit(1); 4 | } 5 | 6 | const { BrowserWindow } = require('electron'); 7 | const { whenReady, digest, queue, execute, waitFor, waitForQueue } = require('./plumbing'); 8 | const { noop, run, delay, tmpFileSync } = require('./utils'); 9 | 10 | 11 | const browsers = []; 12 | class Browser { 13 | constructor (options = {}) { 14 | this._queue = []; 15 | this.pollInterval = options.pollInterval || 200; 16 | this.typingInterval = options.typingInterval || 50; 17 | browsers.push(this); 18 | this._queue.push(null); 19 | 20 | if (!options.webPreferences) options.webPreferences = { nodeIntegration: false }; 21 | else if (!('nodeIntegration' in options.webPreferences)) options.webPreferences.nodeIntegration = false; 22 | 23 | let script = ''; 24 | if (typeof options.exposeElectronAs === 'string') script = `window[${JSON.stringify(options.exposeElectronAs)}] = require('electron');\n`; 25 | if (typeof options.preloadScript === 'string') script += options.preloadScript; 26 | if (script) { 27 | options.webPreferences.preload = tmpFileSync(script); 28 | } 29 | 30 | whenReady(() => { 31 | this.browser = new BrowserWindow(options); 32 | this.browser.on('closed', () => { 33 | browsers.splice(browsers.indexOf(this), 1); 34 | this.halt('Browser closed'); 35 | }); 36 | this._queue.shift(); 37 | digest(this); 38 | }); 39 | } 40 | 41 | execute(fn, ...args) { 42 | return queue(this, () => { 43 | return execute(this, fn, ...args); 44 | }); 45 | } 46 | 47 | close() { 48 | return queue(this, () => { 49 | this.browser.close(); 50 | }); 51 | } 52 | 53 | kill() { 54 | return queue(this, () => { 55 | this.browser.destroy(); 56 | }); 57 | } 58 | 59 | goto(url, options) { 60 | return queue(this, (done, err) => { 61 | let sent = false; 62 | this.browser.webContents.once('did-finish-load', () => { 63 | if (!sent) { 64 | sent = true; 65 | done(); 66 | } 67 | }); 68 | this.browser.webContents.once('did-fail-load', (event, errorCode, desc, url) => { 69 | if (!sent) { 70 | sent = true; 71 | err(`Failed to navigate to '${url}' (${desc})`); 72 | } 73 | }); 74 | this.browser.loadURL(url, options); 75 | }); 76 | } 77 | 78 | halt(reason) { 79 | const err = new Error('Queued function failure; draining queue'); 80 | if (reason) err.stack += `\n----------\nCaused by:\n${reason.stack ? reason.stack : reason}`; 81 | let step; 82 | while (step = this._queue.shift()) { 83 | if (step[3]) { 84 | step[2](err); 85 | break; 86 | } else { 87 | step[2](err); 88 | } 89 | } 90 | } 91 | 92 | checkpoint() { 93 | return new Promise((ok, fail) => { 94 | this._queue.push([noop, ok, fail, true]); 95 | }); 96 | } 97 | 98 | waitFor(selector, timeout) { 99 | return queue(this, () => { 100 | if (typeof selector === 'number') return delay(selector); 101 | else return waitFor(this, selector, timeout); 102 | }); 103 | } 104 | 105 | title() { 106 | return queue(this, () => { 107 | return Promise.resolve(this.browser.webContents.getTitle()); 108 | }); 109 | } 110 | 111 | click(selector, options = {}) { 112 | return waitForQueue(this, selector, () => { 113 | return execute(this, selector => { 114 | const el = document.querySelector(selector); 115 | if (!el) throw new Error(`click: No element matches '${selector}'`); 116 | function fire(name) { 117 | const ev = new MouseEvent(name, { cancellable: true, bubbles: true }); 118 | el.dispatchEvent(ev); 119 | } 120 | fire('mouseover'); 121 | fire('mousedown'); 122 | fire('click'); 123 | fire('mouseup'); 124 | }, selector); 125 | }, options.timeout); 126 | } 127 | 128 | type(selector, str, options = {}) { 129 | return waitForQueue(this, selector, () => { 130 | return execute(this, (selector, str, options) => { 131 | const el = document.querySelector(selector); 132 | if (!el) throw new Error(`type: No element matches '${selector}'`); 133 | el.focus(); 134 | if (!options.append) el.value = ''; 135 | function letter(a) { 136 | const keyCode = a.charCodeAt(0); 137 | el.dispatchEvent(new KeyboardEvent('keydown', { keyCode })); 138 | el.dispatchEvent(new KeyboardEvent('keypress', { keyCode })); 139 | el.value += a; 140 | el.dispatchEvent(new Event('input')); 141 | el.dispatchEvent(new KeyboardEvent('keyup', { keyCode })); 142 | } 143 | return new Promise(ok => { 144 | const array = str.split(''); 145 | function step() { 146 | const a = array.shift(); 147 | if (a) { 148 | letter(a); 149 | setTimeout(step, options.typingInterval); 150 | } else { 151 | el.dispatchEvent(new Event('change')); 152 | ok(); 153 | } 154 | } 155 | step(); 156 | }); 157 | }, selector, str, options); 158 | }, options.timeout); 159 | } 160 | 161 | checkFor(selector) { 162 | return this.execute(selector => { 163 | return !!document.querySelector(selector); 164 | }, selector); 165 | } 166 | 167 | checkForText(str) { 168 | return this.execute(() => { 169 | return document.body.innerHTML; 170 | }).then(text => { 171 | if (typeof str === 'string') return true; 172 | else if (typeof str.test === 'function') return str.test(text); 173 | }); 174 | } 175 | } 176 | 177 | Browser.new = function(...args) { return new Browser(...args); }; 178 | 179 | module.exports.Browser = Browser; 180 | module.exports.run = run; 181 | module.exports.sleep = delay; 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automatonic", 3 | "version": "0.1.1", 4 | "license": "MIT", 5 | "main": "index.js", 6 | "description": "Browser automation as an electron library", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/evs-chris/automatonic.git" 10 | }, 11 | "engines": { 12 | "electron": "^1.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /plumbing.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | const { noop, delay } = require('./utils'); 3 | 4 | let whenReady = (function() { 5 | if (app.isReady()) return function(fn) { return fn(); }; 6 | const queue = []; 7 | let ready = false; 8 | 9 | const proxy = function(fn) { 10 | if (fn) queue.push(fn); 11 | 12 | if (ready) { 13 | ready = false; 14 | let next; 15 | while (next = queue.shift()) { 16 | try { 17 | next(); 18 | } catch (e) { 19 | console.error('Error in queued function:', e); // eslint-disable-line no-console 20 | } 21 | } 22 | ready = true; 23 | whenReady = function(fn) { return fn(); }; 24 | } 25 | }; 26 | 27 | app.on('ready', () => { 28 | ready = true; 29 | proxy(); 30 | }); 31 | 32 | return proxy; 33 | })(); 34 | 35 | function redigest(browser) { 36 | browser._queue.running = false; 37 | delay(0).then(() => digest(browser)); 38 | } 39 | function digest(browser) { 40 | whenReady(() => { 41 | const next = browser._queue.shift(); 42 | if (!next) return; 43 | 44 | browser._queue.running = true; 45 | const [fn, ok, fail] = next; 46 | if (fn.length) { 47 | fn(v => { 48 | ok(v); 49 | redigest(browser); 50 | }, e => { 51 | fail(e); 52 | browser.halt(e); 53 | redigest(browser); 54 | }); 55 | } else { 56 | const res = fn(); 57 | if (typeof res === 'object' && typeof res.then === 'function') { 58 | res.then(v => { 59 | ok(v); 60 | redigest(browser); 61 | }, e => { 62 | fail(e); 63 | browser.halt(e); 64 | redigest(browser); 65 | }); 66 | } else { 67 | ok(res); 68 | redigest(browser); 69 | } 70 | } 71 | }); 72 | } 73 | 74 | function queue(browser, fn) { 75 | const promise = new Promise((ok, fail) => { 76 | browser._queue.push([fn, ok, fail]); 77 | if (browser._queue.length === 1 && !browser._queue.running) { 78 | digest(browser); 79 | } 80 | }); 81 | promise.and = browser; 82 | return promise; 83 | } 84 | 85 | function execute(browser, fn, ...args) { 86 | const script = `new Promise(ok => ok((${fn.toString()})(${args.map(JSON.stringify).join(',')}))).then(null, err => { return Promise.reject({ message: err.message, stack: err.stack }); })`; 87 | return browser.browser.webContents.executeJavaScript(script, noop); 88 | } 89 | 90 | 91 | function waitFor(browser, selector, timeout = 5000) { 92 | return new Promise((done, err) => { 93 | const start = Date.now(); 94 | const check = () => { 95 | execute(browser, selector => { return !!document.querySelector(selector); }, selector).then(found => { 96 | if (!found) { 97 | if (Date.now() - start < timeout) setTimeout(check, browser.pollInterval); 98 | else err(`Could not find element '${selector}' in allotted time (${timeout}ms)`); 99 | } else done(true); 100 | }, err); 101 | }; 102 | check(); 103 | }); 104 | } 105 | 106 | function waitForQueue(browser, selector, fn, timeout = 5000) { 107 | return queue(browser, () => { 108 | return waitFor(browser, selector, timeout).then(() => fn.call(browser)); 109 | }); 110 | } 111 | 112 | module.exports.whenReady = whenReady; 113 | module.exports.digest = digest; 114 | module.exports.queue = queue; 115 | module.exports.execute = execute; 116 | module.exports.waitFor = waitFor; 117 | module.exports.waitForQueue = waitForQueue; 118 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | function noop() {} 6 | 7 | function delay(time) { 8 | return new Promise(ok => { setTimeout(ok, time); }); 9 | } 10 | 11 | // shamelessly partially copied from co 12 | function run(generator, ...args) { 13 | let gen = generator; 14 | return new Promise((ok, fail) => { 15 | if (typeof gen === 'function') gen = gen.apply(this, args); 16 | if (!gen || typeof gen.next !== 'function') return ok(gen); 17 | 18 | fulfilled(); 19 | 20 | function fulfilled(res) { 21 | let ret; 22 | try { 23 | ret = gen.next(res); 24 | } catch (e) { 25 | return fail(e); 26 | } 27 | next(ret); 28 | return null; 29 | } 30 | 31 | function rejected(err) { 32 | let ret; 33 | try { 34 | ret = gen.throw(err); 35 | } catch (e) { 36 | return fail(e); 37 | } 38 | next(ret); 39 | } 40 | 41 | function next(ret) { 42 | const value = ret.value; 43 | if (ret.done) return ok(value); 44 | if (value && typeof value.then === 'function') return value.then(fulfilled, rejected); 45 | fail(new TypeError('You may only yield promises')); 46 | } 47 | }); 48 | } 49 | 50 | const tmpFileSync = (function() { 51 | const files = []; 52 | let count = 0; 53 | 54 | function tmpFileSync(data, opts) { 55 | const name = path.join(os.tmpdir(), `automatonic_${process.pid}_${count++}`); 56 | files.push(name); 57 | fs.writeFileSync(name, data, opts); 58 | return name; 59 | } 60 | 61 | process.on('exit', () => { 62 | files.forEach(f => { 63 | try { 64 | fs.unlinkSync(f); 65 | } catch (e) { 66 | // oh well? 67 | } 68 | }); 69 | }); 70 | 71 | return tmpFileSync; 72 | })(); 73 | 74 | module.exports.noop = noop; 75 | module.exports.delay = delay; 76 | module.exports.run = run; 77 | module.exports.tmpFileSync = tmpFileSync; 78 | --------------------------------------------------------------------------------