UNPKG

jwebdriver

Version:

A webdriver client for node.js

764 lines (638 loc) 31.1 kB
jWebDriver ================ ![jWebDriver logo](https://raw.github.com/yaniswang/jWebDriver/master/logo.png) 一個NodeJs平台下的WebDriver客戶端 [![Build Status](https://img.shields.io/travis/yaniswang/jWebDriver.svg)](https://travis-ci.org/yaniswang/jWebDriver) [![NPM version](https://img.shields.io/npm/v/jwebdriver.svg?style=flat)](https://www.npmjs.com/package/jwebdriver) [![License](https://img.shields.io/npm/l/jwebdriver.svg?style=flat)](https://www.npmjs.com/package/jwebdriver) [![NPM count](https://img.shields.io/npm/dm/jwebdriver.svg?style=flat)](https://www.npmjs.com/package/jwebdriver) [![NPM count](https://img.shields.io/npm/dt/jwebdriver.svg?style=flat)](https://www.npmjs.com/package/jwebdriver) 1. 官方網站: [http://jwebdriver.com/](http://jwebdriver.com/) 2. 語言切換: [English](https://github.com/yaniswang/jWebDriver/blob/master/README.md), [簡體中文](https://github.com/yaniswang/jWebDriver/blob/master/README_zh-cn.md), [繁體中文](https://github.com/yaniswang/jWebDriver/blob/master/README_zh-tw.md) 3. 更新日誌: [CHANGE](https://github.com/yaniswang/jWebDriver/blob/master/CHANGE.md) 4. API文檔: [http://jwebdriver.com/api/](http://jwebdriver.com/api/) 5. 覆蓋率: [http://jwebdriver.com/coverage/](http://jwebdriver.com/coverage/) (81.26%) 6. 釘釘交流群:11779932(加入驗證:jWebDriver),下載釘釘:[https://www.dingtalk.com/](https://www.dingtalk.com/) 功能 ================ 1. 支持所有WebDriver協議API: [https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol](https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol) 2. 支持無線Native和Webview,基於macaca實現: ([https://macacajs.com/](https://macacajs.com/)) 3. 簡單易用,支持混合式Promise 4. 支持鏈式Promise & generator & es7 await 5. jQuery風格的測試代碼, 對前端開發人員簡單易懂 6. 全單元測試覆蓋 7. 支持hosts模式, 不同測試任務使用不同的hosts綁定 8. 支持遠程文件上傳,以支持grid方式執行機集群 9. 支持鏈式chai斷言 快速開始 ================ 1. 安裝 Selenium server 和驅動. > npm i selenium-standalone -g > selenium-standalone install --drivers.firefox.baseURL=http://npm.taobao.org/mirrors/geckodriver --baseURL=http://npm.taobao.org/mirrors/selenium --drivers.chrome.baseURL=http://npm.taobao.org/mirrors/chromedriver --drivers.ie.baseURL=http://npm.taobao.org/mirrors/selenium > selenium-standalone start 2. 安裝 jWebDriver > npm install jwebdriver 3. 運行測試腳本 > node baidu.js var JWebDriver = require('jwebdriver'); var driver = new JWebDriver(); driver.session("chrome") .url('https://www.baidu.com/') .find('#kw') .val('mp3') .submit() .title() .then(function(title){ console.log(title); }) .close(); > mocha mocha-promise.js var JWebDriver = require('jwebdriver'); var chai = require("chai"); chai.should(); chai.use(JWebDriver.chaiSupportChainPromise); describe('jWebDriver test', function(){ this.timeout(30000); var browser; before(function(){ var driver = new JWebDriver(); return (browser = driver.session('chrome')); }); it('should search baidu', function(){ return browser.url('https://www.baidu.com/') .find('#kw') .should.have.length(1) .val('mp3').submit() .url() .should.contain('wd=mp3'); }); after(function(){ return browser.close(); }); }); > mocha mocha-generators.js var JWebDriver = require('jwebdriver'); var chai = require("chai"); chai.should(); chai.use(JWebDriver.chaiSupportChainPromise); require('mocha-generators').install(); describe('jWebDriver test', function(){ var browser; before(function*(){ var driver = new JWebDriver(); browser = yield driver.session('chrome'); }); it('should search baidu', function*(){ yield browser.url('https://www.baidu.com/'); var kw = yield browser.find('#kw').should.have.length(1); yield kw.val('mp3').submit(); yield browser.url().should.contain('wd=mp3'); }); after(function*(){ yield browser.close(); }); }); > node macaca.js (for mobile native & webview) var path = require('path'); var JWebDriver = require('jwebdriver'); var driver = new JWebDriver({ port: 3456 }); var appPath = '../test/resource/android.zip'; driver.session({ 'platformName': 'android', 'app': path.resolve(appPath) }) .wait('//*[@resource-id="com.github.android_app_bootstrap:id/mobileNoEditText"]') .sendElementKeys('中文+Test+12345678') .wait('//*[@resource-id="com.github.android_app_bootstrap:id/codeEditText"]') .sendElementKeys('22222\n') .wait('name', 'Login') .click() .wait('name', 'list') .prop('text') .then(function(text){ console.log(text) }) .rect() .then(function(rect){ console.log(rect) }) .click() .sendActions('drag', { fromX: 200, fromY: 400, toX: 200, toY: 100, duration: 0.5 }) .sendActions('drag', { fromX: 100, fromY: 100, toX: 100, toY: 400, duration: 0.5 }) .wait('name', 'Gesture') .click() .back() .back() .wait('name', 'Baidu') .click() .webview() .wait('#index-kw') .sendKeys('mp3') .wait('#index-bn') .click() .url() .then(function(url){ console.log(url); }) .title() .then(function(title){ console.log(title); }) .native() .wait('name', 'PERSONAL') .click(); 更多示例 ================ 1. [Baidu test](https://github.com/yaniswang/jWebDriver/blob/master/example/baidu.js) 2. [Gooogle test](https://github.com/yaniswang/jWebDriver/blob/master/example/google.js) 3. [Mocha Promise](https://github.com/yaniswang/jWebDriver/blob/master/example/mocha-promise.js) 4. [Mocha Generators](https://github.com/yaniswang/jWebDriver/blob/master/example/mocha-generators.js) 5. [Mobile test (Native&webview)](https://github.com/yaniswang/jWebDriver/blob/master/example/macaca.js) 6. [Upload test](https://github.com/yaniswang/jWebDriver/blob/master/example/upload.js) 7. [Drag Drop test](https://github.com/yaniswang/jWebDriver/blob/master/example/dragdrop.js) 8. [Co test](https://github.com/yaniswang/jWebDriver/blob/master/example/co.js) 9. [ES7 async](https://github.com/yaniswang/jWebDriver/blob/master/example/es7async.js) 10. [Plugin](https://github.com/yaniswang/jWebDriver/blob/master/example/plugin.js) API手冊 ================ jWebDriver 有3個類: Driver, Broswer, Elements 所有API均支持 鏈式Promise 和 generator,以及es7 async語法: ------------------------------------------ browser.find('#kw').then(function(elements){ return elements.val('test') .submit(); }) .title() .then(function(title){ console.log(title); }); 並且,你也可以基於Driver類使用混合鏈式Promise, 所有API方法都會從Browser和Elements類複製到Driver類的實例上: ------------------------------------------ var driver = new JWebDriver(); driver.session("chrome") .url('https://www.baidu.com/') .find('#kw') .val('mp3') .submit() .title() .then(function(title){ console.log(title); }) .close(); 你可以下面搜索所有的API,已經包括了所有API的所有使用方式: ------------------------------------------ var co = require('co'); co(function*(){ // ========================== driver api ========================== var JWebDriver = require('jwebdriver'); var driver = new JWebDriver(); // connect to http://127.0.0.1:4444 var driver = new JWebDriver('127.0.0.1', '4444'); // connect to http://127.0.0.1:4444 var driver = new JWebDriver({ 'host': '127.0.0.1', 'port': 4444, 'logLevel': 0, // 0: no log, 1: warning & error, 2: all log 'nocolor': false, 'speed': 100 // default: 0 ms }); var wdInfo = yield driver.info(); // get webdriver server info // ========================== session api ========================== var arrSessions = yield driver.sessions(); // get all sessions for(var i=0;i<arrSessions.length;i++){ yield arrSessions[i].close(); } // new session var browser = yield driver.session('browser', '40.0', 'windows'); var browser = yield driver.session({ 'browserName':'browser', 'version': 'ANY', 'platform': 'ANY' }); // attach session var browser = yield driver.session({ sessionId: 'xxxxxxxxxx' }); // set manual proxy var browser = yield driver.session({ 'browserName':'browser', 'proxy': { 'proxyType': 'manual', 'httpProxy': '192.168.1.1:1080', 'sslProxy': '192.168.1.1:1080' } }); // set pac proxy var browser = yield driver.session({ 'browserName':'browser', 'proxy': { 'proxyType': 'pac', 'proxyAutoconfigUrl': 'http://x.x.x.x/test.pac' } }); // set hosts var browser = yield driver.session({ 'browserName':'browser', 'hosts': '192.168.1.1 www.alibaba.com\r\n192.168.1.1 www.google.com' }); // attach session var browser = yield driver.session('xxxxxxxxxx'); // session id // get session info var capabilities = yield browser.info(); // get capabilities var isSupported = yield browser.support('javascript'); // get capability supported: javascript, cssselector, screenshot, storage, alert, database, rotatable yield browser.config({ pageloadTimeout: 5000, // page onload timeout scriptTimeout: 1000, // sync script timeout asyncScriptTimeout: 1000, // async script timeout implicitTimeout: 1000 // implicit timeout }); yield browser.close(); // close session yield browser.sleep(1000); // sleep // get webdriver log var logTypes = yield browser.logTypes(); var logs = yield browser.logs('browser'); // ========================== window ========================== var curWindowHandle = yield browser.windowHandle(); // get current window handle var arrWindowHandles = yield browser.windowHandles(); // get all windows yield browser.switchWindow('handleid'); // focus to window yield browser.switchWindow(0); // focus to first window yield browser.switchWindow(1); // focus to second window var newWindowHandle = yield browser.newWindow('http://www.alibaba.com/', 'testwindow', 'width=200,height=200'); // open new window and return windowHandle yield browser.closeWindow(); // close current window // ========================== frame ========================== var elements = yield browser.frames(); // get all frames yield browser.switchFrame(0); // focus to frame 0 yield browser.switchFrame(1); // focus to frame 1 yield browser.switchFrame('#iframe_id'); // focus to frame #iframe_id yield browser.switchFrame(null); // focus to main page yield browser.switchFrameParent(); // focus to parent context // ========================== position & size & maximize & screenshot ========================== var position = yield browser.position(); // return {x: 1, y: 1} yield browser.position(10, 10); // set position yield browser.position({ x: 10, y: 10 }); var info = yield browser.size(); // return {width: 100, height: 100} yield browser.size(100, 100); // set size yield browser.size({ width: 100, height: 100 }); yield browser.maximize(); var png_base64 = yield browser.getScreenshot();// get the screen shot, base64 type var png_base64 = yield browser.getScreenshot('d:/test.png');// get the screen shot, and save to file var png_base64 = yield browser.getScreenshot({ elem: '#id' }); // get the element shot, (require install gm) var png_base64 = yield browser.getScreenshot({ elem: '#id', filename: 'test.png' }); // get the element shot, and save to file // ========================== url & title & source ========================== yield browser.url('http://www.alibaba.com/'); // goto url var url = yield browser.url(); // get url var title = yield browser.title(); // get title var source = yield browser.source(); // get source code var html = yield browser.html(); // get html code, nick name of source // ========================== navigator ========================== yield browser.refresh(); // refresh page yield browser.back(); // back to previous page yield browser.forward(); // forward to next page yield browser.scrollTo('#id'); // scroll to element (first element) yield browser.scrollTo('#id', 10, 10); // scroll to element (first element) yield browser.scrollTo('#id', { // scroll to element (first element) x: 10, y: 10 }); yield browser.scrollTo(10, 10); yield browser.scrollTo({ x: 10, y: 10 }); var elements = yield browser.find('#divtest'); elements.scrollTo(0, 100); // scroll all elements to x, y // ========================== cookie ========================== var value = yield browser.cookie('test'); // get cookie yield browser.cookie('test', '123'); // set cookie yield browser.cookie('test', '123', { // https://code.google.com/p/selenium/wiki/JsonWireProtocol#Cookie_JSON_Object path: '', domain: '', secure: '', httpOnly: '', expiry: '' }); yield browser.cookie('test',123, { expiry: '7 day' // second|minute|hour|day|month|year }); yield browser.removeCookie('test'); // delete cookie var mapCookies = yield browser.cookies(); // get all cookie yield browser.clearCookies(); // delete all cookies // ========================== local storage && session storage ========================== var arrKeys = yield browser.localStorageKeys(); // get all local storage keys var value = yield browser.localStorage('test'); // get local storage value yield browser.localStorage('test', '1'); // set local storage value yield browser.removeLocalStorage('test'); // delete local storage yield browser.clearLocalStorages(); // clear all local sotrage var arrKeys = yield browser.sessionStorageKeys(); // get all session storage keys var value = yield browser.sessionStorage('test'); // get session storage value yield browser.sessionStorage('test', '1'); // set session storage value yield browser.removeSessionStorage('test'); // delete session storage yield browser.clearSessionStorages(); // clear all session sotrage // ========================== alert, confirm, prompt ========================== var msg = yield browser.getAlert();// get alert text if(msg !== null){ yield browser.setAlert('test');// set msg to prompt yield browser.acceptAlert(); // accept alert yield browser.dismissAlert(); // dismiss alert } // ========================== mouse ========================== var MouseButtons = browser.MouseButtons; yield browser.mouseMove(element); // move to center of element yield browser.mouseMove('#id'); // move to center of element yield browser.mouseMove('#id', 10, 10); // move to offset of the element yield browser.mouseMove('#id', {x: 10, y: 10}); // move to offset of the element yield browser.mouseDown(); // left mouse button down yield browser.mouseDown(MouseButtons.RIGHT); // right mouse button down yield browser.mouseDown('RIGHT'); // right mouse button down yield browser.mouseUp(); // left mouse button up yield browser.mouseUp(MouseButtons.RIGHT); // right mouse button up yield browser.mouseUp('RIGHT'); // right mouse button up yield browser.click(); yield browser.click(MouseButtons.RIGHT); yield browser.click('RIGHT'); yield browser.dblClick(); yield browser.dragDrop('#a', '#b'); // drag a drop to b yield browser.dragDrop({ selector: '#a', x: 1, y: 1 }, { selector: '#b', x: 2, y: 2 }); // ========================== keyboard ========================== yield browser.sendKeys('abc'); var Keys = browser.Keys; yield browser.keyDown(Keys.CTRL); yield browser.keyDown('CTRL'); yield browser.sendKeys('a'+Keys.LEFT); yield browser.keyUp(Keys.CTRL); yield browser.keyUp('CTRL'); yield browser.sendKeys('{CTRL}a{CTRL}'); // ========================== eval ========================== // sync eval var title = yield browser.eval(function(){ return document.title; }); var value = yield browser.eval(function(arg1, arg2){ return arg1; }, 1, 2); var value = yield browser.eval(function(arg1, arg2){ return arg1; }, [1, 2]); // async eval var value = yield browser.eval(function(arg1, arg2, done){ setTimeout(function(){ done(arg2); }, 2000); }, 1, 2); // pass element to eval var tagName = yield browser.eval(function(elements){ return elements[0].tagName; }, yield browser.find('#id')); // ========================== element ========================== var elements = yield browser.wait('#id');// wait for element if(elements.length === 1){ console.log('#id displayed'); } yield elements.sleep(300); // sleep ms var elements = yield browser.wait('#id', 5000);// wait for element, 5000 ms timeout var elements = yield browser.wait('#id', { timeout: 10000, // set timeout, default: 10000 displayed: true, // wait for element displayed, default: true removed: false // wait for element removed, default: false }); var elements = yield browser.wait('name', 'aaa', 5000); // support type: class name|css selector|id|name|link text|partial link text|tag name|xpath var elements = yield browser.find('#id'); // find element var elements = yield browser.findVisible('span'); // find visible element var elements = yield browser.find('active');// get active element var elements = yield browser.find('#id');// get element by css selector var elements = yield browser.find('//html/body');// get element by xpath var elements = yield browser.find('name', 'aaa'); // support type: class name|css selector|id|name|link text|partial link text|tag name|xpath var elements = yield elements.find('.class'); // find all child element var isEqual = yield elements.equal('#bbb a'); // test if two elements refer to the same DOM element. // elements filter with sync mode var elements = yield elements.get(0); // get element by index var elements = yield elements.first(); // get first element var elements = yield elements.last(); // get last element var elements = yield elements.slice(1, 2); // get element from start to end // elements filter for chain promise elements.get(0, true).click(); // get element by index elements.first(0, true).click(); // get first element elements.last(0, true).click(); // get last element elements.slice(1, 2, true).click(); // get element from start to end // traversal the elements for(var i=0;i<elements.length;i++){ var element = yield elements.get(i); console.log(yield element.text()); } var tagName = yield element.tagName(); // get tagname (first element) var value = element.val(); // equal to element.attr('value'); yield element.val('mp3'); // equal to: element.clear().sendKeys('mp3'); var value = yield element.attr('id'); // get attribute value (first element) var value = yield element.prop('id'); // get property value (first element) var info = yield element.rect(); // get rect info (first element) var value = yield element.css('border'); // get css value (first element) yield element.clear(); // clear input & textarea value var text = yield element.text(); // get displayed text (first element) var offset = yield element.offset(); // get offset from left top corner of the page (first element) var offset = yield element.offset(true); // get offset from left top corner of the screen (first element) var size = yield element.size(); // return {width: 100, height: 100} (first element) var isDisplayed = yield element.displayed(); // determine if an element is currently displayed (first element) var isEnabled = yield element.enabled(); //is element enabled (first element) var isSelected = yield element.selected(); // is element selected (first element) // select option yield element.select(0); // select index yield element.select('book'); // select value yield element.select({ type: 'value', // index | value | text value: 'book' }); yield element.sendKeys('abc'); // send keys to element var Keys = browser.Keys; yield element.sendKeys('a'+Keys.LEFT); yield element.sendKeys('{CTRL}a{CTRL}'); yield element.click(); // click element yield element.dblClick(); // dblClick element yield element.dragDropTo('#id'); // dragDrop to element (first element) yield element.dragDropTo('#id', 10, 10); // dragDrop to element (first element) yield element.dragDropTo({ selector: '#id', x: 10, y: 10 }); // dragDrop to element (first element) var fileElement = browser.wait('#file'); yield fileElement.uploadFile('c:/test.jpg');// upload file to browser machine and set temp path to <input type="file"> yield element.submit();// submit form // ========================== mobile api ========================== // touch down, touch move, touch up yield browser.touchDown(10, 10); yield browser.touchDown({ x: 10, // X coordinate on the screen. y: 10 // Y coordinate on the screen. }); yield browser.touchMove(10, 10); yield browser.touchMove({ x: 10, // X coordinate on the screen. y: 10 // Y coordinate on the screen. }); yield browser.touchUp(10, 10); yield browser.touchUp({ x: 10, // X coordinate on the screen. y: 10 // Y coordinate on the screen. }); // scroll yield browser.touchScroll(10, 10); // Use this command if you don't care where the scroll starts on the screen yield browser.touchScroll({ x: 10, // The x offset in pixels to scrollby. y: 10 // The y offset in pixels to scrollby. }); // flick yield browser.touchFlick({ // Use this flick command if you don't care where the flick starts on the screen. xspeed: 5, // The x speed in pixels per second. yspeed: 0 // The y speed in pixels per second. }); // element api var element = yield browser.find('#id'); yield element.touchClick(); yield element.touchDblClick(); yield element.touchLongClick(); yield element.touchScroll(10, 10); yield element.touchScroll({ x: 10, // The x offset in pixels to scroll by. y: 10 // The y offset in pixels to scroll by. }); yield element.touchFlick(10, 10, 5); // flick to x: 10, y: 10 with speed 5 yield element.touchFlick({ x: 10, // The x offset in pixels to flick by. y: 10, // The y offset in pixels to flick by. speed: 5 // The speed in pixels per seconds }); var orientation = yield browser.orientation(); // return LANDSCAPE|PORTRAIT yield browser.orientation('LANDSCAPE'); // set orientation: LANDSCAPE|PORTRAIT // ========================== geo location ========================== var loc = yield browser.geolocation(); // return {latitude: number, longitude: number, altitude: number} yield browser.geolocation(1, 1, 1); // set location yield browser.geolocation({ latitude: 1, longitude: 1, altitude: 1 }); // ========================== macaca api ========================== var arrContexts = yield browser.contexts(); // get all contexts var contextId = yield browser.context(); // get context id yield browser.context('NATIVE_APP'); // set context id yield browser.native(); // set context to native yield browser.webview(); // set context to webview // tap yield browser.sendActions('tap', { x: 100, y: 100}); yield element.sendActions('tap'); // doubleTap yield browser.sendActions('doubleTap', { x: 100, y: 100}); yield element.sendActions('doubleTap'); // press yield browser.sendActions('press', { x: 100, y: 100}); yield element.sendActions('press', { duration: 2 }); // pinch yield element.sendActions('pinch', { scale: 2 }); // ios yield element.sendActions('pinch', { direction: "in", percent: 50 }); // android // rotate yield element.sendActions('rotate', { rotation: 6, velocity: 1 }); // drag yield driver.sendActions('drag', { fromX: 100, fromY: 100, toX: 200, toY: 200 }); yield element.sendActions('drag', { toX: 200, toY: 200 }) }).then(function(){ console.log('All done!') }).catch(function(error){ console.log(error); }); 如何獲取元素的截圖? ------------------------------------------ 1. 安裝gm組件 > `brew install graphicsmagick` (Mac) > `sudo apt-get install graphicsmagick` (Linux) > [http://www.graphicsmagick.org/download.html](http://www.graphicsmagick.org/download.html) (Windows) 2. 獲取截圖 var png_base64 = yield browser.getScreenshot({ elem: '#id', filename: 'test.png' }); 如何擴展自定義方法? ------------------------------------------ var JWebDriver = require('jwebdriver'); var driver = new JWebDriver(); JWebDriver.addMethod('searchMp3', function(){ return this.find('#kw').val('mp3').submit(); }); driver.session("chrome") .url('https://www.baidu.com/') .searchMp3() .title() .then(function(title){ console.log(title); }) .close(); 協議 ================ jWebDriver 基於 MIT 協議發佈: > The MIT License > > Copyright (c) 2014-2017 Yanis Wang <yanis.wang@gmail.com> > > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal > in the Software without restriction, including without limitation the rights > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell > copies of the Software, and to permit persons to whom the Software is > furnished to do so, subject to the following conditions: > > The above copyright notice and this permission notice shall be included in > all copies or substantial portions of the Software. > > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN > THE SOFTWARE. 感謝 ================ * Selenium: [http://code.google.com/p/selenium/](http://code.google.com/p/selenium/) * xtend: [https://npmjs.org/package/xtend](https://npmjs.org/package/xtend) * mocha: [https://npmjs.org/package/mocha](https://npmjs.org/package/mocha) * chai: [https://github.com/chaijs/chai](https://github.com/chaijs/chai) * istanbul: [https://github.com/gotwarlost/istanbul](https://github.com/gotwarlost/istanbul) * Grunt: [http://gruntjs.com/](http://gruntjs.com/) * JSHint: [https://github.com/jshint/jshint](https://github.com/jshint/jshint) * node-zip: [https://github.com/daraosn/node-zip](https://github.com/daraosn/node-zip) * Express: [https://github.com/strongloop/express](https://github.com/strongloop/express) * request: [https://github.com/request/request](https://github.com/request/request) * co: [https://github.com/tj/co](https://github.com/tj/co) * PhantomJs: [https://github.com/Medium/phantomjs](https://github.com/Medium/phantomjs) * GraphicsMagick: [http://www.graphicsmagick.org/](http://www.graphicsmagick.org/) * GitHub: [https://github.com/](https://github.com/)