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/)
