UNPKG

20.7 kBJavaScriptView Raw
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 * @fileoverview Defines an environment agnostic {@linkplain cmd.Executor
20 * command executor} that communicates with a remote end using JSON over HTTP.
21 *
22 * Clients should implement the {@link Client} interface, which is used by
23 * the {@link Executor} to send commands to the remote end.
24 */
25
26'use strict';
27
28const fs = require('fs');
29const path = require('path');
30
31const cmd = require('./command');
32const devmode = require('./devmode');
33const error = require('./error');
34const logging = require('./logging');
35const promise = require('./promise');
36const Session = require('./session').Session;
37const WebElement = require('./webdriver').WebElement;
38
39
40/**
41 * Converts a headers map to a HTTP header block string.
42 * @param {!Map<string, string>} headers The map to convert.
43 * @return {string} The headers as a string.
44 */
45function headersToString(headers) {
46 let ret = [];
47 headers.forEach(function(value, name) {
48 ret.push(`${name.toLowerCase()}: ${value}`);
49 });
50 return ret.join('\n');
51}
52
53
54/**
55 * Represents a HTTP request message. This class is a "partial" request and only
56 * defines the path on the server to send a request to. It is each client's
57 * responsibility to build the full URL for the final request.
58 * @final
59 */
60class Request {
61 /**
62 * @param {string} method The HTTP method to use for the request.
63 * @param {string} path The path on the server to send the request to.
64 * @param {Object=} opt_data This request's non-serialized JSON payload data.
65 */
66 constructor(method, path, opt_data) {
67 this.method = /** string */method;
68 this.path = /** string */path;
69 this.data = /** Object */opt_data;
70 this.headers = /** !Map<string, string> */new Map(
71 [['Accept', 'application/json; charset=utf-8']]);
72 }
73
74 /** @override */
75 toString() {
76 let ret = `${this.method} ${this.path} HTTP/1.1\n`;
77 ret += headersToString(this.headers) + '\n\n';
78 if (this.data) {
79 ret += JSON.stringify(this.data);
80 }
81 return ret;
82 }
83}
84
85
86/**
87 * Represents a HTTP response message.
88 * @final
89 */
90class Response {
91 /**
92 * @param {number} status The response code.
93 * @param {!Object<string>} headers The response headers. All header names
94 * will be converted to lowercase strings for consistent lookups.
95 * @param {string} body The response body.
96 */
97 constructor(status, headers, body) {
98 this.status = /** number */status;
99 this.body = /** string */body;
100 this.headers = /** !Map<string, string>*/new Map;
101 for (let header in headers) {
102 this.headers.set(header.toLowerCase(), headers[header]);
103 }
104 }
105
106 /** @override */
107 toString() {
108 let ret = `HTTP/1.1 ${this.status}\n${headersToString(this.headers)}\n\n`;
109 if (this.body) {
110 ret += this.body;
111 }
112 return ret;
113 }
114}
115
116
117const DEV_ROOT = '../../../../buck-out/gen/javascript/';
118
119/** @enum {string} */
120const Atom = {
121 GET_ATTRIBUTE: devmode
122 ? path.join(__dirname, DEV_ROOT, 'webdriver/atoms/getAttribute.js')
123 : path.join(__dirname, 'atoms/getAttribute.js'),
124 IS_DISPLAYED: devmode
125 ? path.join(__dirname, DEV_ROOT, 'atoms/fragments/is-displayed.js')
126 : path.join(__dirname, 'atoms/isDisplayed.js'),
127};
128
129
130const ATOMS = /** !Map<string, !Promise<string>> */new Map();
131const LOG = logging.getLogger('webdriver.http');
132
133/**
134 * @param {Atom} file The atom file to load.
135 * @return {!Promise<string>} A promise that will resolve to the contents of the
136 * file.
137 */
138function loadAtom(file) {
139 if (ATOMS.has(file)) {
140 return ATOMS.get(file);
141 }
142 let contents = /** !Promise<string> */new Promise((resolve, reject) => {
143 LOG.finest(() => `Loading atom ${file}`);
144 fs.readFile(file, 'utf8', function(err, data) {
145 if (err) {
146 reject(err);
147 } else {
148 resolve(data);
149 }
150 });
151 });
152 ATOMS.set(file, contents);
153 return contents;
154}
155
156
157function post(path) { return resource('POST', path); }
158function del(path) { return resource('DELETE', path); }
159function get(path) { return resource('GET', path); }
160function resource(method, path) { return {method: method, path: path}; }
161
162
163/** @typedef {{method: string, path: string}} */
164var CommandSpec;
165
166
167/** @typedef {function(!cmd.Command): !Promise<!cmd.Command>} */
168var CommandTransformer;
169
170
171/**
172 * @param {!cmd.Command} command The initial command.
173 * @param {Atom} atom The name of the atom to execute.
174 * @return {!Promise<!cmd.Command>} The transformed command to execute.
175 */
176function toExecuteAtomCommand(command, atom, ...params) {
177 return loadAtom(atom).then(atom => {
178 return new cmd.Command(cmd.Name.EXECUTE_SCRIPT)
179 .setParameter('sessionId', command.getParameter('sessionId'))
180 .setParameter('script', `return (${atom}).apply(null, arguments)`)
181 .setParameter('args', params.map(param => command.getParameter(param)));
182 });
183}
184
185
186
187/** @const {!Map<string, CommandSpec>} */
188const COMMAND_MAP = new Map([
189 [cmd.Name.GET_SERVER_STATUS, get('/status')],
190 [cmd.Name.NEW_SESSION, post('/session')],
191 [cmd.Name.GET_SESSIONS, get('/sessions')],
192 [cmd.Name.DESCRIBE_SESSION, get('/session/:sessionId')],
193 [cmd.Name.QUIT, del('/session/:sessionId')],
194 [cmd.Name.CLOSE, del('/session/:sessionId/window')],
195 [cmd.Name.GET_CURRENT_WINDOW_HANDLE, get('/session/:sessionId/window_handle')],
196 [cmd.Name.GET_WINDOW_HANDLES, get('/session/:sessionId/window_handles')],
197 [cmd.Name.GET_CURRENT_URL, get('/session/:sessionId/url')],
198 [cmd.Name.GET, post('/session/:sessionId/url')],
199 [cmd.Name.GO_BACK, post('/session/:sessionId/back')],
200 [cmd.Name.GO_FORWARD, post('/session/:sessionId/forward')],
201 [cmd.Name.REFRESH, post('/session/:sessionId/refresh')],
202 [cmd.Name.ADD_COOKIE, post('/session/:sessionId/cookie')],
203 [cmd.Name.GET_ALL_COOKIES, get('/session/:sessionId/cookie')],
204 [cmd.Name.DELETE_ALL_COOKIES, del('/session/:sessionId/cookie')],
205 [cmd.Name.DELETE_COOKIE, del('/session/:sessionId/cookie/:name')],
206 [cmd.Name.FIND_ELEMENT, post('/session/:sessionId/element')],
207 [cmd.Name.FIND_ELEMENTS, post('/session/:sessionId/elements')],
208 [cmd.Name.GET_ACTIVE_ELEMENT, post('/session/:sessionId/element/active')],
209 [cmd.Name.FIND_CHILD_ELEMENT, post('/session/:sessionId/element/:id/element')],
210 [cmd.Name.FIND_CHILD_ELEMENTS, post('/session/:sessionId/element/:id/elements')],
211 [cmd.Name.CLEAR_ELEMENT, post('/session/:sessionId/element/:id/clear')],
212 [cmd.Name.CLICK_ELEMENT, post('/session/:sessionId/element/:id/click')],
213 [cmd.Name.SEND_KEYS_TO_ELEMENT, post('/session/:sessionId/element/:id/value')],
214 [cmd.Name.SUBMIT_ELEMENT, post('/session/:sessionId/element/:id/submit')],
215 [cmd.Name.GET_ELEMENT_TEXT, get('/session/:sessionId/element/:id/text')],
216 [cmd.Name.GET_ELEMENT_TAG_NAME, get('/session/:sessionId/element/:id/name')],
217 [cmd.Name.IS_ELEMENT_SELECTED, get('/session/:sessionId/element/:id/selected')],
218 [cmd.Name.IS_ELEMENT_ENABLED, get('/session/:sessionId/element/:id/enabled')],
219 [cmd.Name.IS_ELEMENT_DISPLAYED, get('/session/:sessionId/element/:id/displayed')],
220 [cmd.Name.GET_ELEMENT_LOCATION, get('/session/:sessionId/element/:id/location')],
221 [cmd.Name.GET_ELEMENT_SIZE, get('/session/:sessionId/element/:id/size')],
222 [cmd.Name.GET_ELEMENT_ATTRIBUTE, get('/session/:sessionId/element/:id/attribute/:name')],
223 [cmd.Name.GET_ELEMENT_VALUE_OF_CSS_PROPERTY, get('/session/:sessionId/element/:id/css/:propertyName')],
224 [cmd.Name.ELEMENT_EQUALS, get('/session/:sessionId/element/:id/equals/:other')],
225 [cmd.Name.TAKE_ELEMENT_SCREENSHOT, get('/session/:sessionId/element/:id/screenshot')],
226 [cmd.Name.SWITCH_TO_WINDOW, post('/session/:sessionId/window')],
227 [cmd.Name.MAXIMIZE_WINDOW, post('/session/:sessionId/window/current/maximize')],
228 [cmd.Name.GET_WINDOW_POSITION, get('/session/:sessionId/window/current/position')],
229 [cmd.Name.SET_WINDOW_POSITION, post('/session/:sessionId/window/current/position')],
230 [cmd.Name.GET_WINDOW_SIZE, get('/session/:sessionId/window/current/size')],
231 [cmd.Name.SET_WINDOW_SIZE, post('/session/:sessionId/window/current/size')],
232 [cmd.Name.SWITCH_TO_FRAME, post('/session/:sessionId/frame')],
233 [cmd.Name.GET_PAGE_SOURCE, get('/session/:sessionId/source')],
234 [cmd.Name.GET_TITLE, get('/session/:sessionId/title')],
235 [cmd.Name.EXECUTE_SCRIPT, post('/session/:sessionId/execute')],
236 [cmd.Name.EXECUTE_ASYNC_SCRIPT, post('/session/:sessionId/execute_async')],
237 [cmd.Name.SCREENSHOT, get('/session/:sessionId/screenshot')],
238 [cmd.Name.SET_TIMEOUT, post('/session/:sessionId/timeouts')],
239 [cmd.Name.MOVE_TO, post('/session/:sessionId/moveto')],
240 [cmd.Name.CLICK, post('/session/:sessionId/click')],
241 [cmd.Name.DOUBLE_CLICK, post('/session/:sessionId/doubleclick')],
242 [cmd.Name.MOUSE_DOWN, post('/session/:sessionId/buttondown')],
243 [cmd.Name.MOUSE_UP, post('/session/:sessionId/buttonup')],
244 [cmd.Name.MOVE_TO, post('/session/:sessionId/moveto')],
245 [cmd.Name.SEND_KEYS_TO_ACTIVE_ELEMENT, post('/session/:sessionId/keys')],
246 [cmd.Name.TOUCH_SINGLE_TAP, post('/session/:sessionId/touch/click')],
247 [cmd.Name.TOUCH_DOUBLE_TAP, post('/session/:sessionId/touch/doubleclick')],
248 [cmd.Name.TOUCH_DOWN, post('/session/:sessionId/touch/down')],
249 [cmd.Name.TOUCH_UP, post('/session/:sessionId/touch/up')],
250 [cmd.Name.TOUCH_MOVE, post('/session/:sessionId/touch/move')],
251 [cmd.Name.TOUCH_SCROLL, post('/session/:sessionId/touch/scroll')],
252 [cmd.Name.TOUCH_LONG_PRESS, post('/session/:sessionId/touch/longclick')],
253 [cmd.Name.TOUCH_FLICK, post('/session/:sessionId/touch/flick')],
254 [cmd.Name.ACCEPT_ALERT, post('/session/:sessionId/accept_alert')],
255 [cmd.Name.DISMISS_ALERT, post('/session/:sessionId/dismiss_alert')],
256 [cmd.Name.GET_ALERT_TEXT, get('/session/:sessionId/alert_text')],
257 [cmd.Name.SET_ALERT_TEXT, post('/session/:sessionId/alert_text')],
258 [cmd.Name.SET_ALERT_CREDENTIALS, post('/session/:sessionId/alert/credentials')],
259 [cmd.Name.GET_LOG, post('/session/:sessionId/log')],
260 [cmd.Name.GET_AVAILABLE_LOG_TYPES, get('/session/:sessionId/log/types')],
261 [cmd.Name.GET_SESSION_LOGS, post('/logs')],
262 [cmd.Name.UPLOAD_FILE, post('/session/:sessionId/file')],
263]);
264
265
266/** @const {!Map<string, (CommandSpec|CommandTransformer)>} */
267const W3C_COMMAND_MAP = new Map([
268 [cmd.Name.GET_ACTIVE_ELEMENT, get('/session/:sessionId/element/active')],
269 [cmd.Name.GET_ELEMENT_ATTRIBUTE, (cmd) => {
270 return toExecuteAtomCommand(cmd, Atom.GET_ATTRIBUTE, 'id', 'name');
271 }],
272 [cmd.Name.IS_ELEMENT_DISPLAYED, (cmd) => {
273 return toExecuteAtomCommand(cmd, Atom.IS_DISPLAYED, 'id');
274 }],
275 [cmd.Name.MAXIMIZE_WINDOW, post('/session/:sessionId/window/maximize')],
276 [cmd.Name.GET_WINDOW_POSITION, get('/session/:sessionId/window/position')],
277 [cmd.Name.SET_WINDOW_POSITION, post('/session/:sessionId/window/position')],
278 [cmd.Name.GET_WINDOW_SIZE, get('/session/:sessionId/window/size')],
279 [cmd.Name.SET_WINDOW_SIZE, post('/session/:sessionId/window/size')],
280]);
281
282
283/**
284 * Handles sending HTTP messages to a remote end.
285 *
286 * @interface
287 */
288class Client {
289
290 /**
291 * Sends a request to the server. The client will automatically follow any
292 * redirects returned by the server, fulfilling the returned promise with the
293 * final response.
294 *
295 * @param {!Request} httpRequest The request to send.
296 * @return {!Promise<Response>} A promise that will be fulfilled with the
297 * server's response.
298 */
299 send(httpRequest) {}
300}
301
302
303const CLIENTS =
304 /** !WeakMap<!Executor, !(Client|IThenable<!Client>)> */new WeakMap;
305
306
307/**
308 * Sends a request using the given executor.
309 * @param {!Executor} executor
310 * @param {!Request} request
311 * @return {!Promise<Response>}
312 */
313function doSend(executor, request) {
314 const client = CLIENTS.get(executor);
315 if (promise.isPromise(client)) {
316 return client.then(client => {
317 CLIENTS.set(executor, client);
318 return client.send(request);
319 });
320 } else {
321 return client.send(request);
322 }
323}
324
325
326/**
327 * @param {Map<string, CommandSpec>} customCommands
328 * A map of custom command definitions.
329 * @param {boolean} w3c Whether to use W3C command mappings.
330 * @param {!cmd.Command} command The command to resolve.
331 * @return {!Promise<!Request>} A promise that will resolve with the
332 * command to execute.
333 */
334function buildRequest(customCommands, w3c, command) {
335 LOG.finest(() => `Translating command: ${command.getName()}`);
336 let spec = customCommands && customCommands.get(command.getName());
337 if (spec) {
338 return toHttpRequest(spec);
339 }
340
341 if (w3c) {
342 spec = W3C_COMMAND_MAP.get(command.getName());
343 if (typeof spec === 'function') {
344 LOG.finest(() => `Transforming command for W3C: ${command.getName()}`);
345 return spec(command)
346 .then(newCommand => buildRequest(customCommands, w3c, newCommand));
347 } else if (spec) {
348 return toHttpRequest(spec);
349 }
350 }
351
352 spec = COMMAND_MAP.get(command.getName());
353 if (spec) {
354 return toHttpRequest(spec);
355 }
356 return Promise.reject(
357 new error.UnknownCommandError(
358 'Unrecognized command: ' + command.getName()));
359
360 /**
361 * @param {CommandSpec} resource
362 * @return {!Promise<!Request>}
363 */
364 function toHttpRequest(resource) {
365 LOG.finest(() => `Building HTTP request: ${JSON.stringify(resource)}`);
366 let parameters = command.getParameters();
367 let path = buildPath(resource.path, parameters);
368 return Promise.resolve(new Request(resource.method, path, parameters));
369 }
370}
371
372
373/**
374 * A command executor that communicates with the server using JSON over HTTP.
375 *
376 * By default, each instance of this class will use the legacy wire protocol
377 * from [Selenium project][json]. The executor will automatically switch to the
378 * [W3C wire protocol][w3c] if the remote end returns a compliant response to
379 * a new session command.
380 *
381 * [json]: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
382 * [w3c]: https://w3c.github.io/webdriver/webdriver-spec.html
383 *
384 * @implements {cmd.Executor}
385 */
386class Executor {
387 /**
388 * @param {!(Client|IThenable<!Client>)} client The client to use for sending
389 * requests to the server, or a promise-like object that will resolve to
390 * to the client.
391 */
392 constructor(client) {
393 CLIENTS.set(this, client);
394
395 /**
396 * Whether this executor should use the W3C wire protocol. The executor
397 * will automatically switch if the remote end sends a compliant response
398 * to a new session command, however, this property may be directly set to
399 * `true` to force the executor into W3C mode.
400 * @type {boolean}
401 */
402 this.w3c = false;
403
404 /** @private {Map<string, CommandSpec>} */
405 this.customCommands_ = null;
406
407 /** @private {!logging.Logger} */
408 this.log_ = logging.getLogger('webdriver.http.Executor');
409 }
410
411 /**
412 * Defines a new command for use with this executor. When a command is sent,
413 * the {@code path} will be preprocessed using the command's parameters; any
414 * path segments prefixed with ":" will be replaced by the parameter of the
415 * same name. For example, given "/person/:name" and the parameters
416 * "{name: 'Bob'}", the final command path will be "/person/Bob".
417 *
418 * @param {string} name The command name.
419 * @param {string} method The HTTP method to use when sending this command.
420 * @param {string} path The path to send the command to, relative to
421 * the WebDriver server's command root and of the form
422 * "/path/:variable/segment".
423 */
424 defineCommand(name, method, path) {
425 if (!this.customCommands_) {
426 this.customCommands_ = new Map;
427 }
428 this.customCommands_.set(name, {method, path});
429 }
430
431 /** @override */
432 execute(command) {
433 let request = buildRequest(this.customCommands_, this.w3c, command);
434 return request.then(request => {
435 this.log_.finer(() => `>>> ${request.method} ${request.path}`);
436 return doSend(this, request).then(response => {
437 this.log_.finer(() => `>>>\n${request}\n<<<\n${response}`);
438
439 let parsed =
440 parseHttpResponse(/** @type {!Response} */ (response), this.w3c);
441
442 if (command.getName() === cmd.Name.NEW_SESSION
443 || command.getName() === cmd.Name.DESCRIBE_SESSION) {
444 if (!parsed || !parsed['sessionId']) {
445 throw new error.WebDriverError(
446 'Unable to parse new session response: ' + response.body);
447 }
448
449 // The remote end is a W3C compliant server if there is no `status`
450 // field in the response. This is not appliable for the DESCRIBE_SESSION
451 // command, which is not defined in the W3C spec.
452 if (command.getName() === cmd.Name.NEW_SESSION) {
453 this.w3c = this.w3c || !('status' in parsed);
454 }
455
456 return new Session(parsed['sessionId'], parsed['value']);
457 }
458
459 if (parsed
460 && typeof parsed === 'object'
461 && 'value' in parsed) {
462 let value = parsed['value'];
463 return typeof value === 'undefined' ? null : value;
464 }
465 return parsed;
466 });
467 });
468 }
469}
470
471
472/**
473 * @param {string} str .
474 * @return {?} .
475 */
476function tryParse(str) {
477 try {
478 return JSON.parse(str);
479 } catch (ignored) {
480 // Do nothing.
481 }
482}
483
484
485/**
486 * Callback used to parse {@link Response} objects from a
487 * {@link HttpClient}.
488 * @param {!Response} httpResponse The HTTP response to parse.
489 * @param {boolean} w3c Whether the response should be processed using the
490 * W3C wire protocol.
491 * @return {?} The parsed response.
492 * @throws {WebDriverError} If the HTTP response is an error.
493 */
494function parseHttpResponse(httpResponse, w3c) {
495 let parsed = tryParse(httpResponse.body);
496 if (parsed !== undefined) {
497 if (w3c) {
498 if (httpResponse.status > 399) {
499 error.throwDecodedError(parsed);
500 }
501
502 if (httpResponse.status < 200) {
503 // This should never happen, but throw the raw response so
504 // users report it.
505 throw new error.WebDriverError(
506 `Unexpected HTTP response:\n${httpResponse}`);
507 }
508 } else {
509 error.checkLegacyResponse(parsed);
510 }
511 return parsed;
512 }
513
514 let value = httpResponse.body.replace(/\r\n/g, '\n');
515
516 // 404 represents an unknown command; anything else > 399 is a generic unknown
517 // error.
518 if (httpResponse.status == 404) {
519 throw new error.UnsupportedOperationError(value);
520 } else if (httpResponse.status >= 400) {
521 throw new error.WebDriverError(value);
522 }
523
524 return value || null;
525}
526
527
528/**
529 * Builds a fully qualified path using the given set of command parameters. Each
530 * path segment prefixed with ':' will be replaced by the value of the
531 * corresponding parameter. All parameters spliced into the path will be
532 * removed from the parameter map.
533 * @param {string} path The original resource path.
534 * @param {!Object<*>} parameters The parameters object to splice into the path.
535 * @return {string} The modified path.
536 */
537function buildPath(path, parameters) {
538 let pathParameters = path.match(/\/:(\w+)\b/g);
539 if (pathParameters) {
540 for (let i = 0; i < pathParameters.length; ++i) {
541 let key = pathParameters[i].substring(2); // Trim the /:
542 if (key in parameters) {
543 let value = parameters[key];
544 if (WebElement.isId(value)) {
545 // When inserting a WebElement into the URL, only use its ID value,
546 // not the full JSON.
547 value = WebElement.extractId(value);
548 }
549 path = path.replace(pathParameters[i], '/' + value);
550 delete parameters[key];
551 } else {
552 throw new error.InvalidArgumentError(
553 'Missing required parameter: ' + key);
554 }
555 }
556 }
557 return path;
558}
559
560
561// PUBLIC API
562
563exports.Executor = Executor;
564exports.Client = Client;
565exports.Request = Request;
566exports.Response = Response;
567exports.buildPath = buildPath; // Exported for testing.