├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── common │ ├── helper.js │ └── logger.js ├── index.js └── server │ ├── controllers │ ├── actions.js │ ├── alert.js │ ├── context.js │ ├── cookie.js │ ├── element.js │ ├── execute.js │ ├── keys.js │ ├── next.js │ ├── screenshot.js │ ├── session.js │ ├── source.js │ ├── status.js │ ├── timeouts.js │ ├── title.js │ ├── url.js │ └── window.js │ ├── index.js │ ├── middlewares.js │ ├── responseHandler.js │ └── router.js ├── package.json └── test ├── mocha.opts └── webdriver-server.test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/.* 2 | **/node_modules 3 | **/test 4 | **/coverage 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | ecmaVersion: 2020 7 | }, 8 | extends: ['egg', 'prettier'], 9 | }; 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | push: 7 | branches: 8 | - '**' 9 | 10 | jobs: 11 | Runner: 12 | timeout-minutes: 10 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ ubuntu-latest, macOS-latest ] 18 | node-version: [ 16 ] 19 | steps: 20 | - name: Checkout Git Source 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install dependencies 29 | run: | 30 | npm i npm@6 -g 31 | npm i 32 | 33 | - name: Continuous integration 34 | run: npm run ci 35 | 36 | - name: Code coverage 37 | uses: codecov/codecov-action@v3.0.0 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | logs 4 | .nyc_output 5 | *.sw* 6 | *.un~ 7 | .idea 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2017 Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webdriver-server 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![CI][CI-image]][CI-url] 5 | [![npm download][download-image]][download-url] 6 | 7 | [npm-image]: https://img.shields.io/npm/v/webdriver-server.svg 8 | [npm-url]: https://npmjs.org/package/webdriver-server 9 | [CI-image]: https://github.com/macacajs/webdriver-server/actions/workflows/ci.yml/badge.svg 10 | [CI-url]: https://github.com/macacajs/webdriver-server/actions/workflows/ci.yml 11 | [download-image]: https://img.shields.io/npm/dm/webdriver-server.svg 12 | [download-url]: https://npmjs.org/package/webdriver-server 13 | 14 | > webdriver server 15 | 16 | 17 | 18 | ## Contributors 19 | 20 | |[
xudafeng](https://github.com/xudafeng)
|[
ziczhu](https://github.com/ziczhu)
|[
paradite](https://github.com/paradite)
|[
kobe990](https://github.com/kobe990)
|[
yihuineng](https://github.com/yihuineng)
|[
qddegtya](https://github.com/qddegtya)
| 21 | | :---: | :---: | :---: | :---: | :---: | :---: | 22 | [
snapre](https://github.com/snapre)
|[
brucejcw](https://github.com/brucejcw)
|[
zivyangll](https://github.com/zivyangll)
23 | 24 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Tue Nov 08 2022 20:07:36 GMT+0800`. 25 | 26 | 27 | 28 | ## Installment 29 | 30 | ``` bash 31 | $ npm i webdriver-server --save 32 | ``` 33 | 34 | ## License 35 | 36 | [MIT](LICENSE) 37 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib'); 4 | -------------------------------------------------------------------------------- /lib/common/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const co = require('co'); 5 | const path = require('path'); 6 | const temp = require('temp'); 7 | const xutil = require('xutil'); 8 | const crypto = require('crypto'); 9 | const AdmZip = require('adm-zip'); 10 | const download = require('download'); 11 | const ProgressBar = require('progress'); 12 | const childProcess = require('child_process'); 13 | const homeDir = require('os').homedir(); 14 | const logger = require('./logger'); 15 | 16 | const _ = xutil.merge({}, xutil); 17 | 18 | _.sleep = function(ms) { 19 | return new Promise((resolve) => { 20 | setTimeout(resolve, ms); 21 | }); 22 | }; 23 | 24 | _.retry = function(func, interval, num) { 25 | return new Promise((resolve, reject) => { 26 | func().then(resolve, err => { 27 | if (num > 0 || typeof num === 'undefined') { 28 | _.sleep(interval).then(() => { 29 | resolve(_.retry(func, interval, num - 1)); 30 | }); 31 | } else { 32 | reject(err); 33 | } 34 | }); 35 | }); 36 | }; 37 | 38 | _.waitForCondition = function(func, wait/* ms*/, interval/* ms*/) { 39 | wait = wait || 5000; 40 | interval = interval || 500; 41 | const start = Date.now(); 42 | const end = start + wait; 43 | const fn = function() { 44 | return new Promise((resolve, reject) => { 45 | const continuation = (res, rej) => { 46 | const now = Date.now(); 47 | if (now < end) { 48 | console.log('start next Condition test........'); 49 | res(_.sleep(interval).then(fn)); 50 | } else { 51 | rej(`Wait For Condition timeout ${wait}`); 52 | } 53 | }; 54 | func().then(isOk => { 55 | if (isOk) { 56 | resolve(); 57 | } else { 58 | continuation(resolve, reject); 59 | } 60 | }).catch(() => { 61 | continuation(resolve, reject); 62 | }); 63 | }); 64 | }; 65 | return fn(); 66 | }; 67 | 68 | _.escapeString = str => { 69 | return str 70 | .replace(/[\\]/g, '\\\\') 71 | .replace(/[/]/g, '\\/') 72 | .replace(/[\b]/g, '\\b') 73 | .replace(/[\f]/g, '\\f') 74 | .replace(/[\n]/g, '\\n') 75 | .replace(/[\r]/g, '\\r') 76 | .replace(/[\t]/g, '\\t') 77 | .replace(/["]/g, '\\"') 78 | .replace(/\\'/g, "\\'"); 79 | }; 80 | 81 | _.exec = (cmd, opts) => { 82 | return new Promise((resolve, reject) => { 83 | childProcess.exec(cmd, _.merge({ 84 | maxBuffer: 1024 * 512, 85 | wrapArgs: false 86 | }, opts || {}), (err, stdout) => { 87 | if (err) { 88 | return reject(err); 89 | } 90 | resolve(_.trim(stdout)); 91 | }); 92 | }); 93 | }; 94 | 95 | /* 96 | * url: app url 97 | * dir: destination folder 98 | * name: filename 99 | * return file path 100 | */ 101 | function downloadWithCache(url, dir, name) { 102 | return co(function *() { 103 | const filePath = path.resolve(dir, name); 104 | const md5Name = name + '-sha1.txt'; 105 | const mdFile = path.resolve(dir, md5Name); 106 | 107 | const downloadAndWriteSha1 = function(url, toBeHashed) { 108 | return new Promise((resolve, reject) => { 109 | const downloadIndicator = new ProgressBar( 110 | 'downloading: [:bar] :percent :etas', { 111 | complete: '=', 112 | incomplete: ' ', 113 | width: 20, 114 | total: 0 115 | } 116 | ); 117 | 118 | const promisifyBuffer = download(url); 119 | logger.info(`start to download apk: ${url}`); 120 | 121 | promisifyBuffer.pipe(fs.createWriteStream(filePath)); 122 | 123 | promisifyBuffer.on('response', res => { 124 | downloadIndicator.total = res.headers['content-length']; 125 | res.on('data', data => { 126 | downloadIndicator.tick(data.length); 127 | }); 128 | }); 129 | 130 | promisifyBuffer.on('error', (error) => { 131 | logger.error(`download failed: ${error.message}`); 132 | reject(error); 133 | }); 134 | 135 | promisifyBuffer.then(() => { 136 | logger.info(`download success: ${filePath}`); 137 | 138 | fs.writeFileSync(mdFile, toBeHashed, { 139 | encoding: 'utf8', 140 | flag: 'w' 141 | }); 142 | 143 | resolve(filePath); 144 | }); 145 | }); 146 | }; 147 | 148 | if (_.isExistedFile(mdFile)) { 149 | const data = fs.readFileSync(mdFile, { 150 | encoding: 'utf8', 151 | flag: 'r' 152 | }); 153 | logger.info(`get ${filePath} from cache`); 154 | logger.info(`sha:${data.trim()}`); 155 | return filePath; 156 | } 157 | const result = crypto.createHash('md5').update(url).digest('hex'); 158 | try { 159 | return yield downloadAndWriteSha1(url, result); 160 | } catch (e) { 161 | console.log(e); 162 | } 163 | 164 | }); 165 | } 166 | 167 | _.configApp = function(app) { 168 | return co(function *() { 169 | if (!app) { 170 | throw new Error('App path should not be empty.'); 171 | } 172 | let appPath = ''; 173 | 174 | if (app.substring(0, 4).toLowerCase() === 'http') { 175 | 176 | const fileName = path.basename(app); 177 | const tempDir = path.resolve(homeDir, '.macaca-temp'); 178 | 179 | _.mkdir(tempDir); 180 | 181 | appPath = yield downloadWithCache(app, tempDir, fileName); 182 | } else { 183 | appPath = path.resolve(app); 184 | 185 | if (!_.isExistedFile(appPath) && !_.isExistedDir(appPath)) { 186 | throw new Error(`App path ${appPath} does not exist!`); 187 | } 188 | } 189 | 190 | const extension = appPath.substring(appPath.length - 4).toLowerCase(); 191 | 192 | if (extension === '.zip') { 193 | logger.debug(`Unzipping local app form ${appPath}`); 194 | const newApp = temp.openSync({ 195 | prefix: 'macaca-app', 196 | suffix: '.zip' 197 | }); 198 | const newAppPath = newApp.path; 199 | fs.writeFileSync(newAppPath, fs.readFileSync(appPath)); 200 | const zip = AdmZip(newAppPath); 201 | const zipEntries = zip.getEntries(); 202 | const appName = zipEntries[0].entryName; 203 | const appDirname = path.dirname(newAppPath); 204 | zip.extractAllTo(appDirname, true); 205 | return path.join(appDirname, appName); 206 | } 207 | logger.debug(`Using local app form ${appPath}`); 208 | return appPath; 209 | 210 | }); 211 | }; 212 | 213 | module.exports = _; 214 | -------------------------------------------------------------------------------- /lib/common/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const logger = require('xlogger'); 5 | const options = { 6 | logFileDir: path.join(__dirname, '..', '..', 'logs') 7 | }; 8 | 9 | module.exports = logger.Logger(options); 10 | module.exports.middleware = logger.middleware(options); 11 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | const _ = require('./common/helper'); 6 | const startServer = require('./server'); 7 | 8 | const defaultOpt = { 9 | port: 3456 10 | }; 11 | 12 | function Webdriver(options) { 13 | this.options = _.merge(defaultOpt, options || {}); 14 | this.init(); 15 | } 16 | 17 | Webdriver.prototype.init = function() { 18 | this.options.ip = _.ipv4; 19 | this.options.host = os.hostname(); 20 | this.options.loaded_time = _.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss'); 21 | }; 22 | 23 | Webdriver.prototype.start = function() { 24 | return startServer(this.options); 25 | }; 26 | 27 | module.exports = Webdriver; 28 | -------------------------------------------------------------------------------- /lib/server/controllers/actions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function *actions(next) { 4 | const body = this.request.body; 5 | const actions = body.actions; 6 | this.state.value = yield this.device.handleActions(actions); 7 | yield next; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/server/controllers/alert.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *acceptAlert(next) { 4 | this.state.value = yield this.device.acceptAlert(); 5 | yield next; 6 | } 7 | 8 | function *dismissAlert(next) { 9 | this.state.value = yield this.device.dismissAlert(); 10 | yield next; 11 | } 12 | 13 | function *alertText(next) { 14 | this.state.value = yield this.device.alertText(); 15 | yield next; 16 | } 17 | 18 | function *alertKeys(next) { 19 | const body = this.request.body; 20 | const text = body.text; 21 | 22 | this.state.value = yield this.device.alertKeys(text); 23 | yield next; 24 | } 25 | 26 | module.exports = { 27 | acceptAlert, 28 | dismissAlert, 29 | alertText, 30 | alertKeys 31 | }; 32 | -------------------------------------------------------------------------------- /lib/server/controllers/context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *getContext(next) { 4 | this.state.value = yield this.device.getContext(); 5 | yield next; 6 | } 7 | 8 | function *getContexts(next) { 9 | this.state.value = yield this.device.getContexts(); 10 | yield next; 11 | } 12 | 13 | function *setContext(next) { 14 | const body = this.request.body; 15 | const name = body.name; 16 | const opts = body.opts; 17 | yield this.device.setContext(name, opts); 18 | this.state.value = null; 19 | yield next; 20 | } 21 | 22 | module.exports = { 23 | getContext, 24 | getContexts, 25 | setContext 26 | }; 27 | -------------------------------------------------------------------------------- /lib/server/controllers/cookie.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *getAllCookies(next) { 4 | this.state.value = yield this.device.getAllCookies(); 5 | yield next; 6 | } 7 | 8 | function *getNamedCookie(next) { 9 | const body = this.request.body; 10 | const name = body.name; 11 | 12 | this.state.value = yield this.device.getNamedCookie(name); 13 | yield next; 14 | } 15 | 16 | function *addCookie(next) { 17 | const body = this.request.body; 18 | const cookie = body.cookie; 19 | this.state.value = yield this.device.addCookie(cookie); 20 | yield next; 21 | } 22 | 23 | function *deleteCookie(next) { 24 | const name = this.request?.body?.name || this.params.name; 25 | 26 | this.state.value = yield this.device.deleteCookie(name); 27 | yield next; 28 | } 29 | 30 | function *deleteAllCookies(next) { 31 | this.state.value = yield this.device.deleteAllCookies(); 32 | yield next; 33 | } 34 | 35 | module.exports = { 36 | getAllCookies, 37 | getNamedCookie, 38 | addCookie, 39 | deleteCookie, 40 | deleteAllCookies 41 | }; 42 | -------------------------------------------------------------------------------- /lib/server/controllers/element.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | async function click(next) { 4 | const elementId = this.params.elementId; 5 | const { 6 | clickOpts, 7 | } = this.request.body; 8 | this.state.value = await this.device.click(elementId, clickOpts); 9 | await next; 10 | } 11 | 12 | async function setValue(next) { 13 | const elementId = this.params.elementId; 14 | const body = this.request.body; 15 | const value = body.value; 16 | 17 | this.state.value = await this.device.setValue(elementId, value); 18 | await next; 19 | } 20 | 21 | async function getText(next) { 22 | const elementId = this.params.elementId; 23 | this.state.value = await this.device.getText(elementId); 24 | await next; 25 | } 26 | 27 | async function clearText(next) { 28 | const elementId = this.params.elementId; 29 | this.state.value = await this.device.clearText(elementId); 30 | await next; 31 | } 32 | 33 | async function findElement(next) { 34 | const elementId = this.params.elementId; 35 | const body = this.request.body; 36 | const strategy = body.using; 37 | const selector = body.value; 38 | 39 | this.state.value = await this.device.findElement(strategy, selector, elementId); 40 | await next; 41 | } 42 | 43 | async function findElements(next) { 44 | const elementId = this.params.elementId; 45 | const body = this.request.body; 46 | const strategy = body.using; 47 | const selector = body.value; 48 | 49 | this.state.value = await this.device.findElements(strategy, selector, elementId); 50 | await next; 51 | } 52 | 53 | async function isDisplayed(next) { 54 | const elementId = this.params.elementId; 55 | this.state.value = await this.device.isDisplayed(elementId); 56 | await next; 57 | } 58 | 59 | async function getAttribute(next) { 60 | const elementId = this.params.elementId; 61 | const name = this.params.name; 62 | 63 | this.state.value = await this.device.getAttribute(elementId, name); 64 | await next; 65 | } 66 | 67 | async function getProperty(next) { 68 | const elementId = this.params.elementId; 69 | const name = this.params.name; 70 | 71 | this.state.value = await this.device.getProperty(elementId, name); 72 | await next; 73 | } 74 | 75 | async function getComputedCss(next) { 76 | const elementId = this.params.elementId; 77 | const propertyName = this.params.propertyName; 78 | 79 | this.state.value = await this.device.getComputedCss(elementId, propertyName); 80 | await next; 81 | } 82 | 83 | async function getRect(next) { 84 | const elementId = this.params.elementId; 85 | 86 | this.state.value = await this.device.getRect(elementId); 87 | await next; 88 | } 89 | 90 | 91 | async function takeElementScreenshot(next) { 92 | const elementId = this.params.elementId; 93 | this.state.value = await this.device.takeElementScreenshot(elementId, this.request.query); 94 | await next; 95 | } 96 | 97 | module.exports = { 98 | click, 99 | getText, 100 | clearText, 101 | setValue, 102 | findElement, 103 | findElements, 104 | getAttribute, 105 | getProperty, 106 | getComputedCss, 107 | getRect, 108 | isDisplayed, 109 | takeElementScreenshot 110 | }; 111 | -------------------------------------------------------------------------------- /lib/server/controllers/execute.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function *execute(next) { 4 | const body = this.request.body; 5 | const script = body.script; 6 | const args = body.args; 7 | this.state.value = yield this.device.execute(script, args); 8 | yield next; 9 | }; 10 | -------------------------------------------------------------------------------- /lib/server/controllers/keys.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function *keys(next) { 4 | const body = this.request.body; 5 | const value = body.value; 6 | this.state.value = yield this.device.keys(value); 7 | yield next; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/server/controllers/next.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | async function universal(next) { 4 | const { body } = this.request; 5 | const { method, args } = body; 6 | this.state.value = await this.device.next(method, args); 7 | await next; 8 | } 9 | 10 | module.exports = { 11 | universal, 12 | }; 13 | -------------------------------------------------------------------------------- /lib/server/controllers/screenshot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *getScreenshot(next) { 4 | this.state.value = yield this.device.getScreenshot(this, this.request.query); 5 | yield next; 6 | } 7 | 8 | module.exports = { 9 | getScreenshot 10 | }; 11 | -------------------------------------------------------------------------------- /lib/server/controllers/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | drivers 5 | } = require('macaca-cli'); 6 | const chalk = require('chalk'); 7 | const errors = require('webdriver-dfn-error-code').errors; 8 | 9 | const _ = require('../../common/helper'); 10 | const logger = require('../../common/logger'); 11 | 12 | const detectDevice = function(desiredCapabilities) { 13 | const platformName = desiredCapabilities.platformName && desiredCapabilities.platformName.toLowerCase(); 14 | 15 | if (platformName === 'desktop') { 16 | const browserName = desiredCapabilities.browserName && desiredCapabilities.browserName.toLowerCase(); 17 | 18 | try { 19 | const Driver = require(`macaca-${browserName}`); 20 | return new Driver(); 21 | } catch (e) { 22 | if (!drivers.includes(browserName)) { 23 | logger.error(`Browser must in (${drivers.join(', ')})`); 24 | } else { 25 | logger.info(`please run: \`npm install macaca-${browserName} -g\``); 26 | logger.error(e); 27 | } 28 | } 29 | } else { 30 | try { 31 | const Driver = require(`macaca-${platformName}`); 32 | return new Driver(); 33 | } catch (e) { 34 | if (!drivers.includes(platformName)) { 35 | logger.error(`Platform must in (${drivers.join(', ')})`); 36 | } else { 37 | logger.info(`please run: \`npm install macaca-${platformName} -g\``); 38 | logger.error(e); 39 | } 40 | } 41 | } 42 | }; 43 | 44 | const createDevice = function *(caps) { 45 | const device = detectDevice(caps); 46 | 47 | if (caps.app) { 48 | caps.app = yield _.configApp(caps.app); 49 | } 50 | caps.show = this._options.window; 51 | device.proxyMode = false; 52 | yield device.startDevice(caps); 53 | return device; 54 | }; 55 | 56 | function *createSession(next) { 57 | this.sessionId = _.uuid(); 58 | logger.debug(`Creating session, sessionId: ${this.sessionId}.`); 59 | const body = this.request.body; 60 | const caps = body.desiredCapabilities; 61 | const device = yield createDevice.call(this, caps); 62 | this.device = device; 63 | this.devices.set(this.sessionId, device); 64 | this.state.value = caps; 65 | yield next; 66 | } 67 | 68 | function *getSessions(next) { 69 | this.state.value = Array.from(this.devices.entries()).map(device => { 70 | const id = device[0]; 71 | const deviceInstances = device[1]; 72 | const capabilities = deviceInstances.getCaps && deviceInstances.getCaps(); 73 | return { 74 | id, 75 | capabilities 76 | }; 77 | }); 78 | yield next; 79 | } 80 | 81 | function *delSession(next) { 82 | const sessionId = this.params.sessionId; 83 | this.sessionId = sessionId; 84 | const device = this.devices.get(sessionId); 85 | if (!device) { 86 | this.status = 200; 87 | yield next; 88 | } else { 89 | yield device.stopDevice(); 90 | this.devices.delete(sessionId); 91 | logger.debug(`Delete session, sessionId: ${sessionId}`); 92 | this.device = null; 93 | this.status = 200; 94 | yield next; 95 | } 96 | } 97 | 98 | function *sessionAvailable(sessionId, next) { 99 | if (this.devices.has(sessionId)) { 100 | this.sessionId = sessionId; 101 | this.device = this.devices.get(sessionId); 102 | 103 | const hitProxy = () => { 104 | if (this.device) { 105 | return !this.device.whiteList(this) && this.device.proxyMode; 106 | } 107 | }; 108 | 109 | if (hitProxy()) { 110 | const body = yield this.device.proxyCommand(this.url, this.method, this.request.body); 111 | this.body = body; 112 | 113 | const log = _.clone(body); 114 | 115 | if (log.value) { 116 | log.value = _.truncate(JSON.stringify(log.value), { 117 | length: 400 118 | }); 119 | } 120 | logger.debug(`${chalk.magenta('Send HTTP Respone to Client')}[${_.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss')}]: ${JSON.stringify(log)}`); 121 | } else { 122 | yield next; 123 | } 124 | } else { 125 | throw new errors.NoSuchDriver(); 126 | } 127 | } 128 | 129 | module.exports = { 130 | sessionAvailable, 131 | createSession, 132 | getSessions, 133 | delSession 134 | }; 135 | -------------------------------------------------------------------------------- /lib/server/controllers/source.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function *source(next) { 4 | this.state.value = yield this.device.getSource(); 5 | yield next; 6 | }; 7 | -------------------------------------------------------------------------------- /lib/server/controllers/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | const arch = os.arch(); 6 | const name = os.platform(); 7 | const version = ''; 8 | 9 | module.exports = function *getStatus(next) { 10 | this.state.value = { 11 | 'build': { 12 | }, 13 | 'os': { 14 | arch, 15 | name, 16 | version 17 | } 18 | }; 19 | yield next; 20 | }; 21 | -------------------------------------------------------------------------------- /lib/server/controllers/timeouts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *implicitWait(next) { 4 | const body = this.request.body; 5 | const ms = body.ms; 6 | this.device.implicitWaitMs = parseInt(ms, 10); 7 | this.state.value = null; 8 | yield next; 9 | } 10 | 11 | module.exports = { 12 | implicitWait 13 | }; 14 | -------------------------------------------------------------------------------- /lib/server/controllers/title.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function *title(next) { 4 | this.state.value = yield this.device.title(); 5 | yield next; 6 | }; 7 | -------------------------------------------------------------------------------- /lib/server/controllers/url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *url(next) { 4 | this.state.value = yield this.device.url(); 5 | yield next; 6 | } 7 | 8 | function *getUrl(next) { 9 | const body = this.request.body; 10 | const url = body.url; 11 | 12 | this.state.value = yield this.device.get(url); 13 | yield next; 14 | } 15 | 16 | function *forward(next) { 17 | this.state.value = yield this.device.forward(); 18 | yield next; 19 | } 20 | 21 | function *back(next) { 22 | this.state.value = yield this.device.back(); 23 | yield next; 24 | } 25 | 26 | function *refresh(next) { 27 | this.state.value = yield this.device.refresh(); 28 | yield next; 29 | } 30 | 31 | module.exports = { 32 | url, 33 | getUrl, 34 | forward, 35 | back, 36 | refresh 37 | }; 38 | -------------------------------------------------------------------------------- /lib/server/controllers/window.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function *getWindow(next) { 4 | this.state.value = yield this.device.getWindow(); 5 | yield next; 6 | } 7 | 8 | function *getWindows(next) { 9 | this.state.value = yield this.device.getWindows(); 10 | yield next; 11 | } 12 | 13 | function *getWindowSize(next) { 14 | const windowHandle = this.params.windowHandle; 15 | this.state.value = yield this.device.getWindowSize(windowHandle); 16 | yield next; 17 | } 18 | 19 | function *setWindowSize(next) { 20 | const body = this.request.body; 21 | const width = body.width; 22 | const height = body.height; 23 | const windowHandle = this.params.windowHandle; 24 | 25 | this.state.value = yield this.device.setWindowSize(windowHandle, width, height); 26 | yield next; 27 | } 28 | 29 | function *maximize(next) { 30 | const windowHandle = this.params.windowHandle; 31 | 32 | this.state.value = yield this.device.maximize(windowHandle); 33 | yield next; 34 | } 35 | 36 | function *setWindow(next) { 37 | const body = this.request.body; 38 | const name = body.name; 39 | 40 | this.state.value = yield this.device.setWindow(name); 41 | yield next; 42 | } 43 | 44 | function *deleteWindow(next) { 45 | this.state.value = yield this.device.deleteWindow(); 46 | yield next; 47 | } 48 | 49 | function *setFrame(next) { 50 | const body = this.request.body; 51 | const frame = body.id; 52 | 53 | this.state.value = yield this.device.setFrame(frame); 54 | yield next; 55 | } 56 | 57 | module.exports = { 58 | getWindow, 59 | getWindows, 60 | getWindowSize, 61 | setWindowSize, 62 | maximize, 63 | setWindow, 64 | deleteWindow, 65 | setFrame 66 | }; 67 | -------------------------------------------------------------------------------- /lib/server/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const koa = require('koa'); 4 | const cors = require('koa-cors'); 5 | const bodyParser = require('koa-bodyparser'); 6 | 7 | const router = require('./router'); 8 | const logger = require('../common/logger'); 9 | const middlewares = require('./middlewares'); 10 | const responseHandler = require('./responseHandler'); 11 | 12 | module.exports = (options) => { 13 | 14 | return new Promise((resolve, reject) => { 15 | logger.debug('webdriver server start with config:\n %j', options); 16 | 17 | try { 18 | const app = koa(); 19 | 20 | const devices = new Map(); 21 | 22 | app.use(cors()); 23 | 24 | app.use(function *(next) { 25 | this.devices = devices; 26 | this._options = options; 27 | yield next; 28 | }); 29 | 30 | app.use(bodyParser()); 31 | 32 | middlewares(app); 33 | 34 | app.use(responseHandler); 35 | 36 | router(app); 37 | 38 | app.listen(options.port, resolve); 39 | } catch (e) { 40 | logger.debug(`webdriver server failed to start: ${e.stack}`); 41 | reject(e); 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /lib/server/middlewares.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | const pkg = require('../../package'); 6 | const logger = require('../common/logger'); 7 | 8 | module.exports = function(app) { 9 | const string = `${pkg.name}/${pkg.version} node/${process.version}(${os.platform()})`; 10 | 11 | app.use(function *powerby(next) { 12 | yield next; 13 | this.set('X-Powered-By', string); 14 | }); 15 | 16 | app.use(logger.middleware); 17 | logger.debug('base middlewares attached'); 18 | }; 19 | -------------------------------------------------------------------------------- /lib/server/responseHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'); 4 | const codes = require('webdriver-dfn-error-code').codes; 5 | 6 | const _ = require('../common/helper'); 7 | const logger = require('../common/logger'); 8 | 9 | module.exports = function *(next) { 10 | try { 11 | logger.debug(`${chalk.green('Recieve HTTP Request from Client')}[${_.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss')}]: method: ${this.method} url: ${this.url}, jsonBody: ${JSON.stringify(this.request.body)}`); 12 | 13 | yield next; 14 | 15 | if (this.url === '/') { 16 | return; 17 | } 18 | 19 | const statusCode = this.response.status; 20 | const message = this.response.message; 21 | 22 | if (typeof this.state.value === 'undefined' && (statusCode === 404 || statusCode === 405 || statusCode === 501)) { 23 | logger.debug(`${chalk.red('Send HTTP Respone to Client')}[${_.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss')}]: ${statusCode} ${message}`); 24 | return; 25 | } 26 | 27 | const hitNoProxy = () => { 28 | if (this.device) { 29 | return this.device.whiteList(this) || !this.device.isProxy() || !this.device.proxyMode; 30 | } 31 | return true; 32 | 33 | }; 34 | 35 | if (hitNoProxy()) { 36 | const result = { 37 | sessionId: this.sessionId || '', 38 | status: 0, 39 | value: this.state.value 40 | }; 41 | this.body = result; 42 | const log = _.clone(result); 43 | 44 | if (log.value) { 45 | log.value = _.truncate(JSON.stringify(log.value), { 46 | length: 400 47 | }); 48 | } 49 | logger.debug(`${chalk.magenta('Send HTTP Respone to Client')}[${_.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss')}]: ${JSON.stringify(log)}`); 50 | } 51 | 52 | if (this.device) { 53 | this.device.proxyMode = this.device.isProxy(); 54 | } 55 | } catch (e) { 56 | logger.debug(`${chalk.red('Send Error Respone to Client: ')}${e}`); 57 | 58 | if (!(e instanceof Error)) { 59 | this.throw(500); 60 | } 61 | if (e.stack) { 62 | logger.debug(e.stack); 63 | } 64 | const errorName = e.name; 65 | const errorMsg = e.message; 66 | const errorNames = Object.keys(codes); 67 | 68 | if (_.includes(errorNames, errorName)) { 69 | const error = codes[errorName]; 70 | const errorCode = error.code; 71 | const badResult = { 72 | sessionId: this.sessionId || '', 73 | status: errorCode, 74 | value: { 75 | message: errorMsg 76 | } 77 | }; 78 | logger.debug(`${chalk.red('Send Bad HTTP Respone to Client')}[${_.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss')}]: ${JSON.stringify(badResult)}`); 79 | this.body = badResult; 80 | } else if (errorName === 'NotImplementedError') { 81 | this.throw(501, errorMsg); 82 | } else { 83 | this.throw(e); 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /lib/server/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const Boom = require('boom'); 7 | const Router = require('koa-router'); 8 | 9 | const pkg = require('../../package'); 10 | const _ = require('../common/helper'); 11 | const logger = require('../common/logger'); 12 | 13 | const rootRouter = new Router(); 14 | const sessionRouter = new Router(); 15 | 16 | const getControllers = function() { 17 | const res = {}; 18 | const controllersDir = path.join(__dirname, 'controllers'); 19 | const list = fs.readdirSync(controllersDir); 20 | list.forEach(file => { 21 | if (path.extname(file) === '.js') { 22 | res[path.basename(file, '.js')] = require(path.join(controllersDir, file)); 23 | } 24 | }); 25 | return res; 26 | }; 27 | 28 | // W3C: https://w3c.github.io/webdriver/#endpoints 29 | module.exports = function(app) { 30 | const controllers = getControllers(); 31 | // Server status 32 | rootRouter 33 | .get('/', function *(next) { 34 | const dist = [].concat(rootRouter.stack, sessionRouter.stack); 35 | const res = []; 36 | dist.forEach(router => { 37 | res.push(`${router.path}#[${router.methods.join('|')}]`); 38 | }); 39 | const temp = _.sortBy(res, string => { 40 | return string.length; 41 | }); 42 | const num = temp[temp.length - 1].length; 43 | res.forEach((router, i) => { 44 | res[i] = router.replace('#', new Array(num - router.length + 2).join(' ')); 45 | }); 46 | res.unshift([`${pkg.name}@${pkg.version}`], new Array(num + 1).join('-'), ''); 47 | this.body = res.join(os.EOL); 48 | yield next; 49 | }) 50 | .get('/wd/hub/status', controllers.status) 51 | .post('/wd/hub/session', controllers.session.createSession) 52 | .get('/wd/hub/sessions', controllers.session.getSessions) 53 | .del('/wd/hub/session/:sessionId', controllers.session.delSession); 54 | 55 | sessionRouter 56 | // session related method 57 | .prefix('/wd/hub/session/:sessionId') 58 | .param('sessionId', controllers.session.sessionAvailable) 59 | // context 60 | .get('/context', controllers.context.getContext) 61 | .post('/context', controllers.context.setContext) 62 | .get('/contexts', controllers.context.getContexts) 63 | // timeout 64 | .post('/timeouts/implicit_wait', controllers.timeouts.implicitWait) 65 | // screenshot 66 | .get('/screenshot', controllers.screenshot.getScreenshot) 67 | // source 68 | .get('/source', controllers.source) 69 | // element 70 | .post('/click', controllers.element.click) 71 | .post('/keys', controllers.keys) 72 | .post('/element', controllers.element.findElement) 73 | .post('/elements', controllers.element.findElements) 74 | .post('/element/:elementId/element', controllers.element.findElement) 75 | .post('/element/:elementId/elements', controllers.element.findElements) 76 | .post('/element/:elementId/value', controllers.element.setValue) 77 | .post('/element/:elementId/click', controllers.element.click) 78 | .get('/element/:elementId/text', controllers.element.getText) 79 | .post('/element/:elementId/clear', controllers.element.clearText) 80 | .get('/element/:elementId/displayed', controllers.element.isDisplayed) 81 | .get('/element/:elementId/attribute/:name', controllers.element.getAttribute) 82 | .get('/element/:elementId/property/:name', controllers.element.getProperty) 83 | .get('/element/:elementId/css/:propertyName', controllers.element.getComputedCss) 84 | .get('/element/:elementId/rect', controllers.element.getRect) 85 | .get('/element/:elementId/screenshot', controllers.element.takeElementScreenshot) 86 | .post('/actions', controllers.actions) 87 | // execute 88 | .post('/execute', controllers.execute) 89 | // title 90 | .get('/title', controllers.title) 91 | // alert 92 | .post('/accept_alert', controllers.alert.acceptAlert) 93 | .post('/dismiss_alert', controllers.alert.dismissAlert) 94 | .get('/alert_text', controllers.alert.alertText) 95 | .post('/alert_text', controllers.alert.alertKeys) 96 | // cookie 97 | .get('/cookie/:name', controllers.cookie.getNamedCookie) 98 | .get('/cookie', controllers.cookie.getAllCookies) 99 | .post('/cookie', controllers.cookie.addCookie) 100 | .del('/cookie/:name', controllers.cookie.deleteCookie) 101 | .del('/cookie', controllers.cookie.deleteAllCookies) 102 | // url 103 | .get('/url', controllers.url.url) 104 | .post('/url', controllers.url.getUrl) 105 | .post('/forward', controllers.url.forward) 106 | .post('/back', controllers.url.back) 107 | .post('/refresh', controllers.url.refresh) 108 | // window 109 | .get('/window_handle', controllers.window.getWindow) 110 | .get('/window_handles', controllers.window.getWindows) 111 | .post('/window', controllers.window.setWindow) 112 | .del('/window', controllers.window.deleteWindow) 113 | .get('/window/:windowHandle/size', controllers.window.getWindowSize) 114 | .post('/window/:windowHandle/size', controllers.window.setWindowSize) 115 | .post('/window/:windowHandle/maximize', controllers.window.maximize) 116 | .post('/frame', controllers.window.setFrame) 117 | // next 118 | .post('/next', controllers.next.universal); 119 | 120 | app 121 | .use(rootRouter.routes()) 122 | .use(rootRouter.allowedMethods({ 123 | notImplemented: () => new Boom.notImplemented(), 124 | methodNotAllowed: () => new Boom.methodNotAllowed() 125 | })) 126 | .use(sessionRouter.routes()) 127 | .use(sessionRouter.allowedMethods({ 128 | notImplemented: () => new Boom.notImplemented(), 129 | methodNotAllowed: () => new Boom.methodNotAllowed() 130 | })); 131 | 132 | logger.debug('router set'); 133 | }; 134 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webdriver-server", 3 | "version": "1.3.1", 4 | "description": "webdriver server", 5 | "keywords": [ 6 | "webdriver", 7 | "testing", 8 | "ui automation", 9 | "test framework" 10 | ], 11 | "main": "index.js", 12 | "files": [ 13 | "lib/**/*.js" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:macacajs/webdriver-server.git" 18 | }, 19 | "dependencies": { 20 | "adm-zip": "~0.4.7", 21 | "boom": "~3.1.2", 22 | "chalk": "~1.1.1", 23 | "co": "~4.6.0", 24 | "detect-port": "~0.1.4", 25 | "download": "~7.1.0", 26 | "koa": "~1.1.2", 27 | "koa-bodyparser": "~2.0.1", 28 | "koa-cors": "^0.0.16", 29 | "koa-router": "~5.4.0", 30 | "macaca-cli": "*", 31 | "progress": "~2.0.0", 32 | "temp": "~0.8.3", 33 | "webdriver-dfn-error-code": "~1.0.0", 34 | "xlogger": "~1.0.4", 35 | "xutil": "~1.0.0" 36 | }, 37 | "devDependencies": { 38 | "eslint": "8", 39 | "eslint-config-egg": "^7.1.0", 40 | "eslint-config-prettier": "^4.1.0", 41 | "git-contributor": "1", 42 | "husky": "^8.0.1", 43 | "mocha": "*", 44 | "nyc": "^11.7.1" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "npm run lint" 49 | } 50 | }, 51 | "scripts": { 52 | "test": "nyc --reporter=lcov --reporter=text mocha", 53 | "lint": "eslint . --fix", 54 | "ci": "npm run lint && npm run test", 55 | "contributor": "git-contributor" 56 | }, 57 | "license": "MIT" 58 | } 59 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | -------------------------------------------------------------------------------- /test/webdriver-server.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Webdriver = require('..'); 4 | const assert = require('assert'); 5 | 6 | describe('lib/index.js', function() { 7 | it('should be ok', function() { 8 | assert(Webdriver); 9 | }); 10 | }); 11 | --------------------------------------------------------------------------------