| 1 | // Copyright 2015 Selenium committers | 
| 2 | // Copyright 2015 Software Freedom Conservancy | 
| 3 | // | 
| 4 | // Licensed under the Apache License, Version 2.0 (the "License"); | 
| 5 | // you may not use this file except in compliance with the License. | 
| 6 | //     You may obtain a copy of the License at | 
| 7 | // | 
| 8 | // http://www.apache.org/licenses/LICENSE-2.0 | 
| 9 | // | 
| 10 | // Unless required by applicable law or agreed to in writing, software | 
| 11 | // distributed under the License is distributed on an "AS IS" BASIS, | 
| 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
| 13 | // See the License for the specific language governing permissions and | 
| 14 | // limitations under the License. | 
| 15 |  | 
| 16 | /** | 
| 17 |  * @fileoverview Defines a WebDriver client for Safari. Before using this | 
| 18 |  * module, you must install the | 
| 19 |  * [latest version](http://selenium-release.storage.googleapis.com/index.html) | 
| 20 |  * of the SafariDriver browser extension; using Safari for normal browsign is | 
| 21 |  * not recommended once the extension has been installed. You can, and should, | 
| 22 |  * disable the extension when the browser is not being used with WebDriver. | 
| 23 |  */ | 
| 24 |  | 
| 25 | 'use strict'; | 
| 26 |  | 
| 27 | var events = require('events'); | 
| 28 | var fs = require('fs'); | 
| 29 | var http = require('http'); | 
| 30 | var path = require('path'); | 
| 31 | var url = require('url'); | 
| 32 | var util = require('util'); | 
| 33 | var ws = require('ws'); | 
| 34 |  | 
| 35 | var webdriver = require('./'); | 
| 36 | var promise = webdriver.promise; | 
| 37 | var _base = require('./_base'); | 
| 38 | var io = require('./io'); | 
| 39 | var exec = require('./io/exec'); | 
| 40 | var portprober = require('./net/portprober'); | 
| 41 |  | 
| 42 |  | 
| 43 | /** @const */ | 
| 44 | var CLIENT_PATH = _base.isDevMode() | 
| 45 |     ? path.join(__dirname, | 
| 46 |         '../../../build/javascript/safari-driver/client.js') | 
| 47 |     : path.join(__dirname, 'lib/safari/client.js'); | 
| 48 |  | 
| 49 |  | 
| 50 | /** @const */ | 
| 51 | var LIBRARY_DIR = process.platform === 'darwin' | 
| 52 |     ? path.join('/Users', process.env['USER'], 'Library/Safari') | 
| 53 |     : path.join(process.env['APPDATA'], 'Apple Computer', 'Safari'); | 
| 54 |  | 
| 55 |  | 
| 56 | /** @const */ | 
| 57 | var SESSION_DATA_FILES = (function() { | 
| 58 |   if (process.platform === 'darwin') { | 
| 59 |     var libraryDir = path.join('/Users', process.env['USER'], 'Library'); | 
| 60 |     return [ | 
| 61 |       path.join(libraryDir, 'Caches/com.apple.Safari/Cache.db'), | 
| 62 |       path.join(libraryDir, 'Cookies/Cookies.binarycookies'), | 
| 63 |       path.join(libraryDir, 'Cookies/Cookies.plist'), | 
| 64 |       path.join(libraryDir, 'Safari/History.plist'), | 
| 65 |       path.join(libraryDir, 'Safari/LastSession.plist'), | 
| 66 |       path.join(libraryDir, 'Safari/LocalStorage'), | 
| 67 |       path.join(libraryDir, 'Safari/Databases') | 
| 68 |     ]; | 
| 69 |   } else if (process.platform === 'win32') { | 
| 70 |     var appDataDir = path.join(process.env['APPDATA'], | 
| 71 |         'Apple Computer', 'Safari'); | 
| 72 |     var localDataDir = path.join(process.env['LOCALAPPDATA'], | 
| 73 |         'Apple Computer', 'Safari'); | 
| 74 |     return [ | 
| 75 |       path.join(appDataDir, 'History.plist'), | 
| 76 |       path.join(appDataDir, 'LastSession.plist'), | 
| 77 |       path.join(appDataDir, 'Cookies/Cookies.plist'), | 
| 78 |       path.join(appDataDir, 'Cookies/Cookies.binarycookies'), | 
| 79 |       path.join(localDataDir, 'Cache.db'), | 
| 80 |       path.join(localDataDir, 'Databases'), | 
| 81 |       path.join(localDataDir, 'LocalStorage') | 
| 82 |     ]; | 
| 83 |   } else { | 
| 84 |     return []; | 
| 85 |   } | 
| 86 | })(); | 
| 87 |  | 
| 88 |  | 
| 89 | /** @typedef {{port: number, address: string, family: string}} */ | 
| 90 | var Host; | 
| 91 |  | 
| 92 |  | 
| 93 | /** | 
| 94 |  * A basic HTTP/WebSocket server used to communicate with the SafariDriver | 
| 95 |  * browser extension. | 
| 96 |  * @constructor | 
| 97 |  * @extends {events.EventEmitter} | 
| 98 |  */ | 
| 99 | var Server = function() { | 
| 100 |   events.EventEmitter.call(this); | 
| 101 |  | 
| 102 |   var server = http.createServer(function(req, res) { | 
| 103 |     if (req.url === '/favicon.ico') { | 
| 104 |       res.writeHead(204); | 
| 105 |       res.end(); | 
| 106 |       return; | 
| 107 |     } | 
| 108 |  | 
| 109 |     var query = url.parse(req.url).query || ''; | 
| 110 |     if (query.indexOf('url=') == -1) { | 
| 111 |       var address = server.address() | 
| 112 |       var host = address.address + ':' + address.port; | 
| 113 |       res.writeHead(302, {'Location': 'http://' + host + '?url=ws://' + host}); | 
| 114 |       res.end(); | 
| 115 |     } | 
| 116 |  | 
| 117 |     fs.readFile(CLIENT_PATH, 'utf8', function(err, data) { | 
| 118 |       if (err) { | 
| 119 |         res.writeHead(500, {'Content-Type': 'text/plain'}); | 
| 120 |         res.end(err.stack); | 
| 121 |         return; | 
| 122 |       } | 
| 123 |       var content = '<!DOCTYPE html><body><script>' + data + '</script>'; | 
| 124 |       res.writeHead(200, { | 
| 125 |         'Content-Type': 'text/html; charset=utf-8', | 
| 126 |         'Content-Length': Buffer.byteLength(content, 'utf8'), | 
| 127 |       }); | 
| 128 |       res.end(content); | 
| 129 |     }); | 
| 130 |   }); | 
| 131 |  | 
| 132 |   var wss = new ws.Server({server: server}); | 
| 133 |   wss.on('connection', this.emit.bind(this, 'connection')); | 
| 134 |  | 
| 135 |   /** | 
| 136 |    * Starts the server on a random port. | 
| 137 |    * @return {!webdriver.promise.Promise<Host>} A promise that will resolve | 
| 138 |    *     with the server host when it has fully started. | 
| 139 |    */ | 
| 140 |   this.start = function() { | 
| 141 |     if (server.address()) { | 
| 142 |       return promise.fulfilled(server.address()); | 
| 143 |     } | 
| 144 |     return portprober.findFreePort('localhost').then(function(port) { | 
| 145 |       return promise.checkedNodeCall( | 
| 146 |           server.listen.bind(server, port, 'localhost')); | 
| 147 |     }).then(function() { | 
| 148 |       return server.address(); | 
| 149 |     }); | 
| 150 |   }; | 
| 151 |  | 
| 152 |   /** | 
| 153 |    * Stops the server. | 
| 154 |    * @return {!webdriver.promise.Promise} A promise that will resolve when the | 
| 155 |    *     server has closed all connections. | 
| 156 |    */ | 
| 157 |   this.stop = function() { | 
| 158 |     return new promise.Promise(function(fulfill) { | 
| 159 |       server.close(fulfill); | 
| 160 |     }); | 
| 161 |   }; | 
| 162 |  | 
| 163 |   /** | 
| 164 |    * @return {Host} This server's host info. | 
| 165 |    * @throws {Error} If the server is not running. | 
| 166 |    */ | 
| 167 |   this.address = function() { | 
| 168 |     var addr = server.address(); | 
| 169 |     if (!addr) { | 
| 170 |       throw Error('There server is not running!'); | 
| 171 |     } | 
| 172 |     return addr; | 
| 173 |   }; | 
| 174 | }; | 
| 175 | util.inherits(Server, events.EventEmitter); | 
| 176 |  | 
| 177 |  | 
| 178 | /** | 
| 179 |  * @return {!promise.Promise<string>} A promise that will resolve with the path | 
| 180 |  *     to Safari on the current system. | 
| 181 |  */ | 
| 182 | function findSafariExecutable() { | 
| 183 |   switch (process.platform) { | 
| 184 |     case 'darwin': | 
| 185 |       return promise.fulfilled( | 
| 186 |           '/Applications/Safari.app/Contents/MacOS/Safari'); | 
| 187 |  | 
| 188 |     case 'win32': | 
| 189 |       var files = [ | 
| 190 |         process.env['PROGRAMFILES'] || '\\Program Files', | 
| 191 |         process.env['PROGRAMFILES(X86)'] || '\\Program Files (x86)' | 
| 192 |       ].map(function(prefix) { | 
| 193 |         return path.join(prefix, 'Safari\\Safari.exe'); | 
| 194 |       }); | 
| 195 |       return io.exists(files[0]).then(function(exists) { | 
| 196 |         return exists ? files[0] : io.exists(files[1]).then(function(exists) { | 
| 197 |           if (exists) { | 
| 198 |             return files[1]; | 
| 199 |           } | 
| 200 |           throw Error('Unable to find Safari on the current system'); | 
| 201 |         }); | 
| 202 |       }); | 
| 203 |  | 
| 204 |     default: | 
| 205 |       return promise.rejected( | 
| 206 |           Error('Safari is not supported on the current platform: ' + | 
| 207 |               process.platform)); | 
| 208 |   } | 
| 209 | } | 
| 210 |  | 
| 211 |  | 
| 212 | /** | 
| 213 |  * @param {string} url The URL to connect to. | 
| 214 |  * @return {!promise.Promise<string>} A promise for the path to a file that | 
| 215 |  *     Safari can open on start-up to trigger a new connection to the WebSocket | 
| 216 |  *     server. | 
| 217 |  */ | 
| 218 | function createConnectFile(url) { | 
| 219 |   return io.tmpFile({postfix: '.html'}).then(function(f) { | 
| 220 |     var writeFile =  promise.checkedNodeCall(fs.writeFile, | 
| 221 |         f, | 
| 222 |         '<!DOCTYPE html><script>window.location = "' + url + '";</script>', | 
| 223 |         {encoding: 'utf8'}); | 
| 224 |     return writeFile.then(function() { | 
| 225 |       return f; | 
| 226 |     }); | 
| 227 |   }); | 
| 228 | } | 
| 229 |  | 
| 230 |  | 
| 231 | /** | 
| 232 |  * Deletes all session data files if so desired. | 
| 233 |  * @param {!Object} desiredCapabilities . | 
| 234 |  * @return {!Array<promise.Promise>} A list of promises for the deleted files. | 
| 235 |  */ | 
| 236 | function cleanSession(desiredCapabilities) { | 
| 237 |   if (!desiredCapabilities) { | 
| 238 |     return []; | 
| 239 |   } | 
| 240 |   var options = desiredCapabilities[OPTIONS_CAPABILITY_KEY]; | 
| 241 |   if (!options) { | 
| 242 |     return []; | 
| 243 |   } | 
| 244 |   if (!options['cleanSession']) { | 
| 245 |     return []; | 
| 246 |   } | 
| 247 |   return SESSION_DATA_FILES.map(function(file) { | 
| 248 |     return io.unlink(file); | 
| 249 |   }); | 
| 250 | } | 
| 251 |  | 
| 252 |  | 
| 253 | /** | 
| 254 |  * @constructor | 
| 255 |  * @implements {webdriver.CommandExecutor} | 
| 256 |  */ | 
| 257 | var CommandExecutor = function() { | 
| 258 |   /** @private {Server} */ | 
| 259 |   this.server_ = null; | 
| 260 |  | 
| 261 |   /** @private {ws.WebSocket} */ | 
| 262 |   this.socket_ = null; | 
| 263 |  | 
| 264 |   /** @private {promise.Promise.<!exec.Command>} */ | 
| 265 |   this.safari_ = null; | 
| 266 | }; | 
| 267 |  | 
| 268 |  | 
| 269 | /** @override */ | 
| 270 | CommandExecutor.prototype.execute = function(command, callback) { | 
| 271 |   var safariCommand = JSON.stringify({ | 
| 272 |     'origin': 'webdriver', | 
| 273 |     'type': 'command', | 
| 274 |     'command': { | 
| 275 |       'id': _base.require('goog.string').getRandomString(), | 
| 276 |       'name': command.getName(), | 
| 277 |       'parameters': command.getParameters() | 
| 278 |     } | 
| 279 |   }); | 
| 280 |   var self = this; | 
| 281 |  | 
| 282 |   switch (command.getName()) { | 
| 283 |     case webdriver.CommandName.NEW_SESSION: | 
| 284 |       this.startSafari_(command).then(sendCommand, callback); | 
| 285 |       break; | 
| 286 |  | 
| 287 |     case webdriver.CommandName.QUIT: | 
| 288 |       this.destroySession_().then(function() { | 
| 289 |         callback(null, _base.require('bot.response').createResponse(null)); | 
| 290 |       }, callback); | 
| 291 |       break; | 
| 292 |  | 
| 293 |     default: | 
| 294 |       sendCommand(); | 
| 295 |       break; | 
| 296 |   } | 
| 297 |  | 
| 298 |   function sendCommand() { | 
| 299 |     new promise.Promise(function(fulfill, reject) { | 
| 300 |       // TODO: support reconnecting with the extension. | 
| 301 |       if (!self.socket_) { | 
| 302 |         self.destroySession_().thenFinally(function() { | 
| 303 |           reject(Error('The connection to the SafariDriver was closed')); | 
| 304 |         }); | 
| 305 |         return; | 
| 306 |       } | 
| 307 |  | 
| 308 |       self.socket_.send(safariCommand, function(err) { | 
| 309 |         if (err) { | 
| 310 |           reject(err); | 
| 311 |           return; | 
| 312 |         } | 
| 313 |       }); | 
| 314 |  | 
| 315 |       self.socket_.once('message', function(data) { | 
| 316 |         try { | 
| 317 |           data = JSON.parse(data); | 
| 318 |         } catch (ex) { | 
| 319 |           reject(Error('Failed to parse driver message: ' + data)); | 
| 320 |           return; | 
| 321 |         } | 
| 322 |         fulfill(data['response']); | 
| 323 |       }); | 
| 324 |  | 
| 325 |     }).then(function(value) { | 
| 326 |       callback(null, value); | 
| 327 |     }, callback); | 
| 328 |   } | 
| 329 | }; | 
| 330 |  | 
| 331 |  | 
| 332 | /** | 
| 333 |  * @param {!webdriver.Command} command . | 
| 334 |  * @private | 
| 335 |  */ | 
| 336 | CommandExecutor.prototype.startSafari_ = function(command) { | 
| 337 |   this.server_ = new Server(); | 
| 338 |  | 
| 339 |   this.safari_ = this.server_.start().then(function(address) { | 
| 340 |     var tasks = cleanSession(command.getParameters()['desiredCapabilities']); | 
| 341 |     tasks.push( | 
| 342 |       findSafariExecutable(), | 
| 343 |       createConnectFile( | 
| 344 |           'http://' + address.address + ':' + address.port)); | 
| 345 |     return promise.all(tasks).then(function(tasks) { | 
| 346 |       var exe = tasks[tasks.length - 2]; | 
| 347 |       var html = tasks[tasks.length - 1]; | 
| 348 |       return exec(exe, {args: [html]}); | 
| 349 |     }); | 
| 350 |   }); | 
| 351 |  | 
| 352 |   var connected = promise.defer(); | 
| 353 |   var self = this; | 
| 354 |   var start = Date.now(); | 
| 355 |   var timer = setTimeout(function() { | 
| 356 |     connected.reject(Error( | 
| 357 |       'Failed to connect to the SafariDriver after ' + (Date.now() - start) + | 
| 358 |       ' ms; Have you installed the latest extension from ' + | 
| 359 |       'http://selenium-release.storage.googleapis.com/index.html?')); | 
| 360 |   }, 10 * 1000); | 
| 361 |   this.server_.once('connection', function(socket) { | 
| 362 |     clearTimeout(timer); | 
| 363 |     self.socket_ = socket; | 
| 364 |     socket.once('close', function() { | 
| 365 |       self.socket_ = null; | 
| 366 |     }); | 
| 367 |     connected.fulfill(); | 
| 368 |   }); | 
| 369 |   return connected.promise; | 
| 370 | }; | 
| 371 |  | 
| 372 |  | 
| 373 | /** | 
| 374 |  * Destroys the active session by stopping the WebSocket server and killing the | 
| 375 |  * Safari subprocess. | 
| 376 |  * @private | 
| 377 |  */ | 
| 378 | CommandExecutor.prototype.destroySession_ = function() { | 
| 379 |   var tasks = []; | 
| 380 |   if (this.server_) { | 
| 381 |     tasks.push(this.server_.stop()); | 
| 382 |   } | 
| 383 |   if (this.safari_) { | 
| 384 |     tasks.push(this.safari_.then(function(safari) { | 
| 385 |       safari.kill(); | 
| 386 |       return safari.result(); | 
| 387 |     })); | 
| 388 |   } | 
| 389 |   var self = this; | 
| 390 |   return promise.all(tasks).thenFinally(function() { | 
| 391 |     self.server_ = null; | 
| 392 |     self.socket_ = null; | 
| 393 |     self.safari_ = null; | 
| 394 |   }); | 
| 395 | }; | 
| 396 |  | 
| 397 |  | 
| 398 | /** @const */ | 
| 399 | var OPTIONS_CAPABILITY_KEY = 'safari.options'; | 
| 400 |  | 
| 401 |  | 
| 402 |  | 
| 403 | /** | 
| 404 |  * Configuration options specific to the {@link Driver SafariDriver}. | 
| 405 |  * @constructor | 
| 406 |  * @extends {webdriver.Serializable} | 
| 407 |  */ | 
| 408 | var Options = function() { | 
| 409 |   webdriver.Serializable.call(this); | 
| 410 |  | 
| 411 |   /** @private {Object<string, *>} */ | 
| 412 |   this.options_ = null; | 
| 413 |  | 
| 414 |   /** @private {webdriver.logging.Preferences} */ | 
| 415 |   this.logPrefs_ = null; | 
| 416 | }; | 
| 417 | util.inherits(Options, webdriver.Serializable); | 
| 418 |  | 
| 419 |  | 
| 420 | /** | 
| 421 |  * Extracts the SafariDriver specific options from the given capabilities | 
| 422 |  * object. | 
| 423 |  * @param {!webdriver.Capabilities} capabilities The capabilities object. | 
| 424 |  * @return {!Options} The ChromeDriver options. | 
| 425 |  */ | 
| 426 | Options.fromCapabilities = function(capabilities) { | 
| 427 |   var options = new Options(); | 
| 428 |  | 
| 429 |   var o = capabilities.get(OPTIONS_CAPABILITY_KEY); | 
| 430 |   if (o instanceof Options) { | 
| 431 |     options = o; | 
| 432 |   } else if (o) { | 
| 433 |     options.setCleanSession(o.cleanSession); | 
| 434 |   } | 
| 435 |  | 
| 436 |   if (capabilities.has(webdriver.Capability.LOGGING_PREFS)) { | 
| 437 |     options.setLoggingPrefs( | 
| 438 |         capabilities.get(webdriver.Capability.LOGGING_PREFS)); | 
| 439 |   } | 
| 440 |  | 
| 441 |   return options; | 
| 442 | }; | 
| 443 |  | 
| 444 |  | 
| 445 | /** | 
| 446 |  * Sets whether to force Safari to start with a clean session. Enabling this | 
| 447 |  * option will cause all global browser data to be deleted. | 
| 448 |  * @param {boolean} clean Whether to make sure the session has no cookies, | 
| 449 |  *     cache entries, local storage, or databases. | 
| 450 |  * @return {!Options} A self reference. | 
| 451 |  */ | 
| 452 | Options.prototype.setCleanSession = function(clean) { | 
| 453 |   if (!this.options_) { | 
| 454 |     this.options_ = {}; | 
| 455 |   } | 
| 456 |   this.options_['cleanSession'] = clean; | 
| 457 |   return this; | 
| 458 | }; | 
| 459 |  | 
| 460 |  | 
| 461 | /** | 
| 462 |  * Sets the logging preferences for the new session. | 
| 463 |  * @param {!webdriver.logging.Preferences} prefs The logging preferences. | 
| 464 |  * @return {!Options} A self reference. | 
| 465 |  */ | 
| 466 | Options.prototype.setLoggingPrefs = function(prefs) { | 
| 467 |   this.logPrefs_ = prefs; | 
| 468 |   return this; | 
| 469 | }; | 
| 470 |  | 
| 471 |  | 
| 472 | /** | 
| 473 |  * Converts this options instance to a {@link webdriver.Capabilities} object. | 
| 474 |  * @param {webdriver.Capabilities=} opt_capabilities The capabilities to merge | 
| 475 |  *     these options into, if any. | 
| 476 |  * @return {!webdriver.Capabilities} The capabilities. | 
| 477 |  */ | 
| 478 | Options.prototype.toCapabilities = function(opt_capabilities) { | 
| 479 |   var capabilities = opt_capabilities || webdriver.Capabilities.safari(); | 
| 480 |   if (this.logPrefs_) { | 
| 481 |     capabilities.set(webdriver.Capability.LOGGING_PREFS, this.logPrefs_); | 
| 482 |   } | 
| 483 |   if (this.options_) { | 
| 484 |     capabilities.set(OPTIONS_CAPABILITY_KEY, this); | 
| 485 |   } | 
| 486 |   return capabilities; | 
| 487 | }; | 
| 488 |  | 
| 489 |  | 
| 490 | /** | 
| 491 |  * Converts this instance to its JSON wire protocol representation. Note this | 
| 492 |  * function is an implementation detail not intended for general use. | 
| 493 |  * @return {!Object<string, *>} The JSON wire protocol representation of this | 
| 494 |  *     instance. | 
| 495 |  * @override | 
| 496 |  */ | 
| 497 | Options.prototype.serialize = function() { | 
| 498 |   return this.options_ || {}; | 
| 499 | }; | 
| 500 |  | 
| 501 |  | 
| 502 |  | 
| 503 | /** | 
| 504 |  * A WebDriver client for Safari. This class should never be instantiated | 
| 505 |  * directly; instead, use the {@link selenium-webdriver.Builder}: | 
| 506 |  * | 
| 507 |  *     var driver = new Builder() | 
| 508 |  *         .forBrowser('safari') | 
| 509 |  *         .build(); | 
| 510 |  * | 
| 511 |  * @param {(Options|webdriver.Capabilities)=} opt_config The configuration | 
| 512 |  *     options for the new session. | 
| 513 |  * @param {webdriver.promise.ControlFlow=} opt_flow The control flow to create | 
| 514 |  *     the driver under. | 
| 515 |  * @constructor | 
| 516 |  * @extends {webdriver.WebDriver} | 
| 517 |  */ | 
| 518 | var Driver = function(opt_config, opt_flow) { | 
| 519 |   var executor = new CommandExecutor(); | 
| 520 |   var capabilities = | 
| 521 |       opt_config instanceof Options ? opt_config.toCapabilities() : | 
| 522 |       (opt_config || webdriver.Capabilities.safari()); | 
| 523 |  | 
| 524 |   var driver = webdriver.WebDriver.createSession( | 
| 525 |       executor, capabilities, opt_flow); | 
| 526 |   webdriver.WebDriver.call( | 
| 527 |       this, driver.getSession(), executor, driver.controlFlow()); | 
| 528 | }; | 
| 529 | util.inherits(Driver, webdriver.WebDriver); | 
| 530 |  | 
| 531 |  | 
| 532 | // Public API | 
| 533 |  | 
| 534 |  | 
| 535 | exports.Driver = Driver; | 
| 536 | exports.Options = Options; |