1 | // Licensed to the Software Freedom Conservancy (SFC) under one
|
2 | // or more contributor license agreements. See the NOTICE file
|
3 | // distributed with this work for additional information
|
4 | // regarding copyright ownership. The SFC licenses this file
|
5 | // to you under the Apache License, Version 2.0 (the
|
6 | // "License"); you may not use this file except in compliance
|
7 | // with the License. You may obtain a copy of the License at
|
8 | //
|
9 | // http://www.apache.org/licenses/LICENSE-2.0
|
10 | //
|
11 | // Unless required by applicable law or agreed to in writing,
|
12 | // software distributed under the License is distributed on an
|
13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14 | // KIND, either express or implied. See the License for the
|
15 | // specific language governing permissions and limitations
|
16 | // under the License.
|
17 |
|
18 | ;
|
19 |
|
20 | const AdmZip = require('adm-zip');
|
21 | const fs = require('fs');
|
22 | const path = require('path');
|
23 | const url = require('url');
|
24 | const util = require('util');
|
25 |
|
26 | const httpUtil = require('../http/util');
|
27 | const io = require('../io');
|
28 | const exec = require('../io/exec');
|
29 | const cmd = require('../lib/command');
|
30 | const input = require('../lib/input');
|
31 | const promise = require('../lib/promise');
|
32 | const webdriver = require('../lib/webdriver');
|
33 | const net = require('../net');
|
34 | const portprober = require('../net/portprober');
|
35 |
|
36 |
|
37 | /**
|
38 | * @typedef {(string|!Array<string|number|!stream.Stream|null|undefined>)}
|
39 | */
|
40 | var StdIoOptions;
|
41 |
|
42 |
|
43 | /**
|
44 | * @typedef {(string|!IThenable<string>)}
|
45 | */
|
46 | var CommandLineFlag;
|
47 |
|
48 |
|
49 | /**
|
50 | * A record object that defines the configuration options for a DriverService
|
51 | * instance.
|
52 | *
|
53 | * @record
|
54 | */
|
55 | function ServiceOptions() {}
|
56 |
|
57 | /**
|
58 | * Whether the service should only be accessed on this host's loopback address.
|
59 | *
|
60 | * @type {(boolean|undefined)}
|
61 | */
|
62 | ServiceOptions.prototype.loopback;
|
63 |
|
64 | /**
|
65 | * The host name to access the server on. If this option is specified, the
|
66 | * {@link #loopback} option will be ignored.
|
67 | *
|
68 | * @type {(string|undefined)}
|
69 | */
|
70 | ServiceOptions.prototype.hostname;
|
71 |
|
72 | /**
|
73 | * The port to start the server on (must be > 0). If the port is provided as a
|
74 | * promise, the service will wait for the promise to resolve before starting.
|
75 | *
|
76 | * @type {(number|!IThenable<number>)}
|
77 | */
|
78 | ServiceOptions.prototype.port;
|
79 |
|
80 | /**
|
81 | * The arguments to pass to the service. If a promise is provided, the service
|
82 | * will wait for it to resolve before starting.
|
83 | *
|
84 | * @type {!(Array<CommandLineFlag>|IThenable<!Array<CommandLineFlag>>)}
|
85 | */
|
86 | ServiceOptions.prototype.args;
|
87 |
|
88 | /**
|
89 | * The base path on the server for the WebDriver wire protocol (e.g. '/wd/hub').
|
90 | * Defaults to '/'.
|
91 | *
|
92 | * @type {(string|undefined|null)}
|
93 | */
|
94 | ServiceOptions.prototype.path;
|
95 |
|
96 | /**
|
97 | * The environment variables that should be visible to the server process.
|
98 | * Defaults to inheriting the current process's environment.
|
99 | *
|
100 | * @type {(Object<string, string>|undefined)}
|
101 | */
|
102 | ServiceOptions.prototype.env;
|
103 |
|
104 | /**
|
105 | * IO configuration for the spawned server process. For more information, refer
|
106 | * to the documentation of `child_process.spawn`.
|
107 | *
|
108 | * @type {(StdIoOptions|undefined)}
|
109 | * @see https://nodejs.org/dist/latest-v4.x/docs/api/child_process.html#child_process_options_stdio
|
110 | */
|
111 | ServiceOptions.prototype.stdio;
|
112 |
|
113 |
|
114 | /**
|
115 | * Manages the life and death of a native executable WebDriver server.
|
116 | *
|
117 | * It is expected that the driver server implements the
|
118 | * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol.
|
119 | * Furthermore, the managed server should support multiple concurrent sessions,
|
120 | * so that this class may be reused for multiple clients.
|
121 | */
|
122 | class DriverService {
|
123 | /**
|
124 | * @param {string} executable Path to the executable to run.
|
125 | * @param {!ServiceOptions} options Configuration options for the service.
|
126 | */
|
127 | constructor(executable, options) {
|
128 | /** @private {string} */
|
129 | this.executable_ = executable;
|
130 |
|
131 | /** @private {boolean} */
|
132 | this.loopbackOnly_ = !!options.loopback;
|
133 |
|
134 | /** @private {(string|undefined)} */
|
135 | this.hostname_ = options.hostname;
|
136 |
|
137 | /** @private {(number|!IThenable<number>)} */
|
138 | this.port_ = options.port;
|
139 |
|
140 | /**
|
141 | * @private {!(Array<CommandLineFlag>|
|
142 | * IThenable<!Array<CommandLineFlag>>)}
|
143 | */
|
144 | this.args_ = options.args;
|
145 |
|
146 | /** @private {string} */
|
147 | this.path_ = options.path || '/';
|
148 |
|
149 | /** @private {!Object<string, string>} */
|
150 | this.env_ = options.env || process.env;
|
151 |
|
152 | /**
|
153 | * @private {(string|!Array<string|number|!stream.Stream|null|undefined>)}
|
154 | */
|
155 | this.stdio_ = options.stdio || 'ignore';
|
156 |
|
157 | /**
|
158 | * A promise for the managed subprocess, or null if the server has not been
|
159 | * started yet. This promise will never be rejected.
|
160 | * @private {Promise<!exec.Command>}
|
161 | */
|
162 | this.command_ = null;
|
163 |
|
164 | /**
|
165 | * Promise that resolves to the server's address or null if the server has
|
166 | * not been started. This promise will be rejected if the server terminates
|
167 | * before it starts accepting WebDriver requests.
|
168 | * @private {Promise<string>}
|
169 | */
|
170 | this.address_ = null;
|
171 | }
|
172 |
|
173 | /**
|
174 | * @return {!Promise<string>} A promise that resolves to the server's address.
|
175 | * @throws {Error} If the server has not been started.
|
176 | */
|
177 | address() {
|
178 | if (this.address_) {
|
179 | return this.address_;
|
180 | }
|
181 | throw Error('Server has not been started.');
|
182 | }
|
183 |
|
184 | /**
|
185 | * Returns whether the underlying process is still running. This does not take
|
186 | * into account whether the process is in the process of shutting down.
|
187 | * @return {boolean} Whether the underlying service process is running.
|
188 | */
|
189 | isRunning() {
|
190 | return !!this.address_;
|
191 | }
|
192 |
|
193 | /**
|
194 | * Starts the server if it is not already running.
|
195 | * @param {number=} opt_timeoutMs How long to wait, in milliseconds, for the
|
196 | * server to start accepting requests. Defaults to 30 seconds.
|
197 | * @return {!Promise<string>} A promise that will resolve to the server's base
|
198 | * URL when it has started accepting requests. If the timeout expires
|
199 | * before the server has started, the promise will be rejected.
|
200 | */
|
201 | start(opt_timeoutMs) {
|
202 | if (this.address_) {
|
203 | return this.address_;
|
204 | }
|
205 |
|
206 | var timeout = opt_timeoutMs || DriverService.DEFAULT_START_TIMEOUT_MS;
|
207 | var self = this;
|
208 |
|
209 | let resolveCommand;
|
210 | this.command_ = new Promise(resolve => resolveCommand = resolve);
|
211 |
|
212 | this.address_ = new Promise((resolveAddress, rejectAddress) => {
|
213 | resolveAddress(Promise.resolve(this.port_).then(port => {
|
214 | if (port <= 0) {
|
215 | throw Error('Port must be > 0: ' + port);
|
216 | }
|
217 |
|
218 | return resolveCommandLineFlags(this.args_).then(args => {
|
219 | var command = exec(self.executable_, {
|
220 | args: args,
|
221 | env: self.env_,
|
222 | stdio: self.stdio_
|
223 | });
|
224 |
|
225 | resolveCommand(command);
|
226 |
|
227 | var earlyTermination = command.result().then(function(result) {
|
228 | var error = result.code == null ?
|
229 | Error('Server was killed with ' + result.signal) :
|
230 | Error('Server terminated early with status ' + result.code);
|
231 | rejectAddress(error);
|
232 | self.address_ = null;
|
233 | self.command_ = null;
|
234 | throw error;
|
235 | });
|
236 |
|
237 | var hostname = self.hostname_;
|
238 | if (!hostname) {
|
239 | hostname = !self.loopbackOnly_ && net.getAddress()
|
240 | || net.getLoopbackAddress();
|
241 | }
|
242 |
|
243 | var serverUrl = url.format({
|
244 | protocol: 'http',
|
245 | hostname: hostname,
|
246 | port: port + '',
|
247 | pathname: self.path_
|
248 | });
|
249 |
|
250 | return new Promise((fulfill, reject) => {
|
251 | let cancelToken =
|
252 | earlyTermination.catch(e => reject(Error(e.message)));
|
253 |
|
254 | httpUtil.waitForServer(serverUrl, timeout, cancelToken)
|
255 | .then(_ => fulfill(serverUrl), err => {
|
256 | if (err instanceof promise.CancellationError) {
|
257 | fulfill(serverUrl);
|
258 | } else {
|
259 | reject(err);
|
260 | }
|
261 | });
|
262 | });
|
263 | });
|
264 | }));
|
265 | });
|
266 |
|
267 | return this.address_;
|
268 | }
|
269 |
|
270 | /**
|
271 | * Stops the service if it is not currently running. This function will kill
|
272 | * the server immediately. To synchronize with the active control flow, use
|
273 | * {@link #stop()}.
|
274 | * @return {!Promise} A promise that will be resolved when the server has been
|
275 | * stopped.
|
276 | */
|
277 | kill() {
|
278 | if (!this.address_ || !this.command_) {
|
279 | return Promise.resolve(); // Not currently running.
|
280 | }
|
281 | return this.command_.then(function(command) {
|
282 | command.kill('SIGTERM');
|
283 | });
|
284 | }
|
285 |
|
286 | /**
|
287 | * Schedules a task in the current control flow to stop the server if it is
|
288 | * currently running.
|
289 | * @return {!promise.Thenable} A promise that will be resolved when
|
290 | * the server has been stopped.
|
291 | */
|
292 | stop() {
|
293 | return promise.controlFlow().execute(this.kill.bind(this));
|
294 | }
|
295 | }
|
296 |
|
297 |
|
298 | /**
|
299 | * @param {!(Array<CommandLineFlag>|IThenable<!Array<CommandLineFlag>>)} args
|
300 | * @return {!Promise<!Array<string>>}
|
301 | */
|
302 | function resolveCommandLineFlags(args) {
|
303 | // Resolve the outer array, then the individual flags.
|
304 | return Promise.resolve(args)
|
305 | .then(/** !Array<CommandLineFlag> */args => Promise.all(args));
|
306 | }
|
307 |
|
308 |
|
309 | /**
|
310 | * The default amount of time, in milliseconds, to wait for the server to
|
311 | * start.
|
312 | * @const {number}
|
313 | */
|
314 | DriverService.DEFAULT_START_TIMEOUT_MS = 30 * 1000;
|
315 |
|
316 |
|
317 | /**
|
318 | * Creates {@link DriverService} objects that manage a WebDriver server in a
|
319 | * child process.
|
320 | */
|
321 | DriverService.Builder = class {
|
322 | /**
|
323 | * @param {string} exe Path to the executable to use. This executable must
|
324 | * accept the `--port` flag for defining the port to start the server on.
|
325 | * @throws {Error} If the provided executable path does not exist.
|
326 | */
|
327 | constructor(exe) {
|
328 | if (!fs.existsSync(exe)) {
|
329 | throw Error(`The specified executable path does not exist: ${exe}`);
|
330 | }
|
331 |
|
332 | /** @private @const {string} */
|
333 | this.exe_ = exe;
|
334 |
|
335 | /** @private {!ServiceOptions} */
|
336 | this.options_ = {
|
337 | args: [],
|
338 | port: 0,
|
339 | env: null,
|
340 | stdio: 'ignore'
|
341 | };
|
342 | }
|
343 |
|
344 | /**
|
345 | * Define additional command line arguments to use when starting the server.
|
346 | *
|
347 | * @param {...CommandLineFlag} var_args The arguments to include.
|
348 | * @return {!THIS} A self reference.
|
349 | * @this {THIS}
|
350 | * @template THIS
|
351 | */
|
352 | addArguments(var_args) {
|
353 | let args = Array.prototype.slice.call(arguments, 0);
|
354 | this.options_.args = this.options_.args.concat(args);
|
355 | return this;
|
356 | }
|
357 |
|
358 | /**
|
359 | * Sets the host name to access the server on. If specified, the
|
360 | * {@linkplain #setLoopback() loopback} setting will be ignored.
|
361 | *
|
362 | * @param {string} hostname
|
363 | * @return {!DriverService.Builder} A self reference.
|
364 | */
|
365 | setHostname(hostname) {
|
366 | this.options_.hostname = hostname;
|
367 | return this;
|
368 | }
|
369 |
|
370 | /**
|
371 | * Sets whether the service should be accessed at this host's loopback
|
372 | * address.
|
373 | *
|
374 | * @param {boolean} loopback
|
375 | * @return {!DriverService.Builder} A self reference.
|
376 | */
|
377 | setLoopback(loopback) {
|
378 | this.options_.loopback = loopback;
|
379 | return this;
|
380 | }
|
381 |
|
382 | /**
|
383 | * Sets the base path for WebDriver REST commands (e.g. "/wd/hub").
|
384 | * By default, the driver will accept commands relative to "/".
|
385 | *
|
386 | * @param {?string} basePath The base path to use, or `null` to use the
|
387 | * default.
|
388 | * @return {!DriverService.Builder} A self reference.
|
389 | */
|
390 | setPath(basePath) {
|
391 | this.options_.path = basePath;
|
392 | return this;
|
393 | }
|
394 |
|
395 | /**
|
396 | * Sets the port to start the server on.
|
397 | *
|
398 | * @param {number} port The port to use, or 0 for any free port.
|
399 | * @return {!DriverService.Builder} A self reference.
|
400 | * @throws {Error} If an invalid port is specified.
|
401 | */
|
402 | setPort(port) {
|
403 | if (port < 0) {
|
404 | throw Error(`port must be >= 0: ${port}`);
|
405 | }
|
406 | this.options_.port = port;
|
407 | return this;
|
408 | }
|
409 |
|
410 | /**
|
411 | * Defines the environment to start the server under. This setting will be
|
412 | * inherited by every browser session started by the server. By default, the
|
413 | * server will inherit the enviroment of the current process.
|
414 | *
|
415 | * @param {(Map<string, string>|Object<string, string>|null)} env The desired
|
416 | * environment to use, or `null` if the server should inherit the
|
417 | * current environment.
|
418 | * @return {!DriverService.Builder} A self reference.
|
419 | */
|
420 | setEnvironment(env) {
|
421 | if (env instanceof Map) {
|
422 | let tmp = {};
|
423 | env.forEach((value, key) => tmp[key] = value);
|
424 | env = tmp;
|
425 | }
|
426 | this.options_.env = env;
|
427 | return this;
|
428 | }
|
429 |
|
430 | /**
|
431 | * IO configuration for the spawned server process. For more information,
|
432 | * refer to the documentation of `child_process.spawn`.
|
433 | *
|
434 | * @param {StdIoOptions} config The desired IO configuration.
|
435 | * @return {!DriverService.Builder} A self reference.
|
436 | * @see https://nodejs.org/dist/latest-v4.x/docs/api/child_process.html#child_process_options_stdio
|
437 | */
|
438 | setStdio(config) {
|
439 | this.options_.stdio = config;
|
440 | return this;
|
441 | }
|
442 |
|
443 | /**
|
444 | * Creates a new DriverService using this instance's current configuration.
|
445 | *
|
446 | * @return {!DriverService} A new driver service.
|
447 | */
|
448 | build() {
|
449 | let port = this.options_.port || portprober.findFreePort();
|
450 | let args = Promise.resolve(port).then(port => {
|
451 | return this.options_.args.concat('--port=' + port);
|
452 | });
|
453 |
|
454 | let options =
|
455 | /** @type {!ServiceOptions} */
|
456 | (Object.assign({}, this.options_, {args, port}));
|
457 | return new DriverService(this.exe_, options);
|
458 | }
|
459 | };
|
460 |
|
461 |
|
462 | /**
|
463 | * Manages the life and death of the
|
464 | * <a href="http://selenium-release.storage.googleapis.com/index.html">
|
465 | * standalone Selenium server</a>.
|
466 | */
|
467 | class SeleniumServer extends DriverService {
|
468 | /**
|
469 | * @param {string} jar Path to the Selenium server jar.
|
470 | * @param {SeleniumServer.Options=} opt_options Configuration options for the
|
471 | * server.
|
472 | * @throws {Error} If the path to the Selenium jar is not specified or if an
|
473 | * invalid port is specified.
|
474 | */
|
475 | constructor(jar, opt_options) {
|
476 | if (!jar) {
|
477 | throw Error('Path to the Selenium jar not specified');
|
478 | }
|
479 |
|
480 | var options = opt_options || {};
|
481 |
|
482 | if (options.port < 0) {
|
483 | throw Error('Port must be >= 0: ' + options.port);
|
484 | }
|
485 |
|
486 | let port = options.port || portprober.findFreePort();
|
487 | let args = Promise.all([port, options.jvmArgs || [], options.args || []])
|
488 | .then(resolved => {
|
489 | let port = resolved[0];
|
490 | let jvmArgs = resolved[1];
|
491 | let args = resolved[2];
|
492 | return jvmArgs.concat('-jar', jar, '-port', port).concat(args);
|
493 | });
|
494 |
|
495 | super('java', {
|
496 | loopback: options.loopback,
|
497 | port: port,
|
498 | args: args,
|
499 | path: '/wd/hub',
|
500 | env: options.env,
|
501 | stdio: options.stdio
|
502 | });
|
503 | }
|
504 | }
|
505 |
|
506 |
|
507 | /**
|
508 | * Options for the Selenium server:
|
509 | *
|
510 | * - `loopback` - Whether the server should only be accessed on this host's
|
511 | * loopback address.
|
512 | * - `port` - The port to start the server on (must be > 0). If the port is
|
513 | * provided as a promise, the service will wait for the promise to resolve
|
514 | * before starting.
|
515 | * - `args` - The arguments to pass to the service. If a promise is provided,
|
516 | * the service will wait for it to resolve before starting.
|
517 | * - `jvmArgs` - The arguments to pass to the JVM. If a promise is provided,
|
518 | * the service will wait for it to resolve before starting.
|
519 | * - `env` - The environment variables that should be visible to the server
|
520 | * process. Defaults to inheriting the current process's environment.
|
521 | * - `stdio` - IO configuration for the spawned server process. For more
|
522 | * information, refer to the documentation of `child_process.spawn`.
|
523 | *
|
524 | * @typedef {{
|
525 | * loopback: (boolean|undefined),
|
526 | * port: (number|!promise.Promise<number>),
|
527 | * args: !(Array<string>|promise.Promise<!Array<string>>),
|
528 | * jvmArgs: (!Array<string>|
|
529 | * !promise.Promise<!Array<string>>|
|
530 | * undefined),
|
531 | * env: (!Object<string, string>|undefined),
|
532 | * stdio: (string|!Array<string|number|!stream.Stream|null|undefined>|
|
533 | * undefined)
|
534 | * }}
|
535 | */
|
536 | SeleniumServer.Options;
|
537 |
|
538 |
|
539 |
|
540 | /**
|
541 | * A {@link webdriver.FileDetector} that may be used when running
|
542 | * against a remote
|
543 | * [Selenium server](http://selenium-release.storage.googleapis.com/index.html).
|
544 | *
|
545 | * When a file path on the local machine running this script is entered with
|
546 | * {@link webdriver.WebElement#sendKeys WebElement#sendKeys}, this file detector
|
547 | * will transfer the specified file to the Selenium server's host; the sendKeys
|
548 | * command will be updated to use the transfered file's path.
|
549 | *
|
550 | * __Note:__ This class depends on a non-standard command supported on the
|
551 | * Java Selenium server. The file detector will fail if used with a server that
|
552 | * only supports standard WebDriver commands (such as the ChromeDriver).
|
553 | *
|
554 | * @final
|
555 | */
|
556 | class FileDetector extends input.FileDetector {
|
557 | /**
|
558 | * Prepares a `file` for use with the remote browser. If the provided path
|
559 | * does not reference a normal file (i.e. it does not exist or is a
|
560 | * directory), then the promise returned by this method will be resolved with
|
561 | * the original file path. Otherwise, this method will upload the file to the
|
562 | * remote server, which will return the file's path on the remote system so
|
563 | * it may be referenced in subsequent commands.
|
564 | *
|
565 | * @override
|
566 | */
|
567 | handleFile(driver, file) {
|
568 | return io.stat(file).then(function(stats) {
|
569 | if (stats.isDirectory()) {
|
570 | return file; // Not a valid file, return original input.
|
571 | }
|
572 |
|
573 | var zip = new AdmZip();
|
574 | zip.addLocalFile(file);
|
575 | // Stored compression, see https://en.wikipedia.org/wiki/Zip_(file_format)
|
576 | zip.getEntries()[0].header.method = 0;
|
577 |
|
578 | var command = new cmd.Command(cmd.Name.UPLOAD_FILE)
|
579 | .setParameter('file', zip.toBuffer().toString('base64'));
|
580 | return driver.schedule(command,
|
581 | 'remote.FileDetector.handleFile(' + file + ')');
|
582 | }, function(err) {
|
583 | if (err.code === 'ENOENT') {
|
584 | return file; // Not a file; return original input.
|
585 | }
|
586 | throw err;
|
587 | });
|
588 | }
|
589 | }
|
590 |
|
591 |
|
592 | // PUBLIC API
|
593 |
|
594 | exports.DriverService = DriverService;
|
595 | exports.FileDetector = FileDetector;
|
596 | exports.SeleniumServer = SeleniumServer;
|
597 | exports.ServiceOptions = ServiceOptions; // Exported for API docs.
|