├── .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 |
--------------------------------------------------------------------------------