| 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 Profile management module. This module is considered internal; |
| 20 | * users should use {@link selenium-webdriver/firefox}. |
| 21 | */ |
| 22 | |
| 23 | 'use strict'; |
| 24 | |
| 25 | var AdmZip = require('adm-zip'), |
| 26 | AdmConstants = require('adm-zip/util/constants'), |
| 27 | fs = require('fs'), |
| 28 | path = require('path'), |
| 29 | util = require('util'), |
| 30 | vm = require('vm'); |
| 31 | |
| 32 | var Serializable = require('..').Serializable, |
| 33 | promise = require('..').promise, |
| 34 | _base = require('../_base'), |
| 35 | io = require('../io'), |
| 36 | extension = require('./extension'); |
| 37 | |
| 38 | |
| 39 | /** @const */ |
| 40 | var WEBDRIVER_PREFERENCES_PATH = _base.isDevMode() |
| 41 | ? path.join(__dirname, '../../../firefox-driver/webdriver.json') |
| 42 | : path.join(__dirname, '../lib/firefox/webdriver.json'); |
| 43 | |
| 44 | /** @const */ |
| 45 | var WEBDRIVER_EXTENSION_PATH = _base.isDevMode() |
| 46 | ? path.join(__dirname, |
| 47 | '../../../../build/javascript/firefox-driver/webdriver.xpi') |
| 48 | : path.join(__dirname, '../lib/firefox/webdriver.xpi'); |
| 49 | |
| 50 | /** @const */ |
| 51 | var WEBDRIVER_EXTENSION_NAME = 'fxdriver@googlecode.com'; |
| 52 | |
| 53 | |
| 54 | |
| 55 | /** @type {Object} */ |
| 56 | var defaultPreferences = null; |
| 57 | |
| 58 | /** |
| 59 | * Synchronously loads the default preferences used for the FirefoxDriver. |
| 60 | * @return {!Object} The default preferences JSON object. |
| 61 | */ |
| 62 | function getDefaultPreferences() { |
| 63 | if (!defaultPreferences) { |
| 64 | var contents = fs.readFileSync(WEBDRIVER_PREFERENCES_PATH, 'utf8'); |
| 65 | defaultPreferences = JSON.parse(contents); |
| 66 | } |
| 67 | return defaultPreferences; |
| 68 | } |
| 69 | |
| 70 | |
| 71 | /** |
| 72 | * Parses a user.js file in a Firefox profile directory. |
| 73 | * @param {string} f Path to the file to parse. |
| 74 | * @return {!promise.Promise.<!Object>} A promise for the parsed preferences as |
| 75 | * a JSON object. If the file does not exist, an empty object will be |
| 76 | * returned. |
| 77 | */ |
| 78 | function loadUserPrefs(f) { |
| 79 | var done = promise.defer(); |
| 80 | fs.readFile(f, function(err, contents) { |
| 81 | if (err && err.code === 'ENOENT') { |
| 82 | done.fulfill({}); |
| 83 | return; |
| 84 | } |
| 85 | |
| 86 | if (err) { |
| 87 | done.reject(err); |
| 88 | return; |
| 89 | } |
| 90 | |
| 91 | var prefs = {}; |
| 92 | var context = vm.createContext({ |
| 93 | 'user_pref': function(key, value) { |
| 94 | prefs[key] = value; |
| 95 | } |
| 96 | }); |
| 97 | |
| 98 | vm.runInContext(contents, context, f); |
| 99 | done.fulfill(prefs); |
| 100 | }); |
| 101 | return done.promise; |
| 102 | } |
| 103 | |
| 104 | |
| 105 | /** |
| 106 | * Copies the properties of one object into another. |
| 107 | * @param {!Object} a The destination object. |
| 108 | * @param {!Object} b The source object to apply as a mixin. |
| 109 | */ |
| 110 | function mixin(a, b) { |
| 111 | Object.keys(b).forEach(function(key) { |
| 112 | a[key] = b[key]; |
| 113 | }); |
| 114 | } |
| 115 | |
| 116 | |
| 117 | /** |
| 118 | * @param {!Object} defaults The default preferences to write. Will be |
| 119 | * overridden by user.js preferences in the template directory and the |
| 120 | * frozen preferences required by WebDriver. |
| 121 | * @param {string} dir Path to the directory write the file to. |
| 122 | * @return {!promise.Promise.<string>} A promise for the profile directory, |
| 123 | * to be fulfilled when user preferences have been written. |
| 124 | */ |
| 125 | function writeUserPrefs(prefs, dir) { |
| 126 | var userPrefs = path.join(dir, 'user.js'); |
| 127 | return loadUserPrefs(userPrefs).then(function(overrides) { |
| 128 | mixin(prefs, overrides); |
| 129 | mixin(prefs, getDefaultPreferences()['frozen']); |
| 130 | |
| 131 | var contents = Object.keys(prefs).map(function(key) { |
| 132 | return 'user_pref(' + JSON.stringify(key) + ', ' + |
| 133 | JSON.stringify(prefs[key]) + ');'; |
| 134 | }).join('\n'); |
| 135 | |
| 136 | var done = promise.defer(); |
| 137 | fs.writeFile(userPrefs, contents, function(err) { |
| 138 | err && done.reject(err) || done.fulfill(dir); |
| 139 | }); |
| 140 | return done.promise; |
| 141 | }); |
| 142 | }; |
| 143 | |
| 144 | |
| 145 | /** |
| 146 | * Installs a group of extensions in the given profile directory. If the |
| 147 | * WebDriver extension is not included in this set, the default version |
| 148 | * bundled with this package will be installed. |
| 149 | * @param {!Array.<string>} extensions The extensions to install, as a |
| 150 | * path to an unpacked extension directory or a path to a xpi file. |
| 151 | * @param {string} dir The profile directory to install to. |
| 152 | * @param {boolean=} opt_excludeWebDriverExt Whether to skip installation of |
| 153 | * the default WebDriver extension. |
| 154 | * @return {!promise.Promise.<string>} A promise for the main profile directory |
| 155 | * once all extensions have been installed. |
| 156 | */ |
| 157 | function installExtensions(extensions, dir, opt_excludeWebDriverExt) { |
| 158 | var hasWebDriver = !!opt_excludeWebDriverExt; |
| 159 | var next = 0; |
| 160 | var extensionDir = path.join(dir, 'extensions'); |
| 161 | var done = promise.defer(); |
| 162 | |
| 163 | return io.exists(extensionDir).then(function(exists) { |
| 164 | if (!exists) { |
| 165 | return promise.checkedNodeCall(fs.mkdir, extensionDir); |
| 166 | } |
| 167 | }).then(function() { |
| 168 | installNext(); |
| 169 | return done.promise; |
| 170 | }); |
| 171 | |
| 172 | function installNext() { |
| 173 | if (!done.isPending()) { |
| 174 | return; |
| 175 | } |
| 176 | |
| 177 | if (next >= extensions.length) { |
| 178 | if (hasWebDriver) { |
| 179 | done.fulfill(dir); |
| 180 | } else { |
| 181 | install(WEBDRIVER_EXTENSION_PATH); |
| 182 | } |
| 183 | } else { |
| 184 | install(extensions[next++]); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | function install(ext) { |
| 189 | extension.install(ext, extensionDir).then(function(id) { |
| 190 | hasWebDriver = hasWebDriver || (id === WEBDRIVER_EXTENSION_NAME); |
| 191 | installNext(); |
| 192 | }, done.reject); |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | |
| 197 | /** |
| 198 | * Decodes a base64 encoded profile. |
| 199 | * @param {string} data The base64 encoded string. |
| 200 | * @return {!promise.Promise.<string>} A promise for the path to the decoded |
| 201 | * profile directory. |
| 202 | */ |
| 203 | function decode(data) { |
| 204 | return io.tmpFile().then(function(file) { |
| 205 | var buf = new Buffer(data, 'base64'); |
| 206 | return promise.checkedNodeCall(fs.writeFile, file, buf).then(function() { |
| 207 | return io.tmpDir(); |
| 208 | }).then(function(dir) { |
| 209 | var zip = new AdmZip(file); |
| 210 | zip.extractAllTo(dir); // Sync only? Why?? :-( |
| 211 | return dir; |
| 212 | }); |
| 213 | }); |
| 214 | } |
| 215 | |
| 216 | |
| 217 | |
| 218 | /** |
| 219 | * Models a Firefox proifle directory for use with the FirefoxDriver. The |
| 220 | * {@code Proifle} directory uses an in-memory model until {@link #writeToDisk} |
| 221 | * is called. |
| 222 | * @param {string=} opt_dir Path to an existing Firefox profile directory to |
| 223 | * use a template for this profile. If not specified, a blank profile will |
| 224 | * be used. |
| 225 | * @constructor |
| 226 | * @extends {Serializable.<string>} |
| 227 | */ |
| 228 | var Profile = function(opt_dir) { |
| 229 | Serializable.call(this); |
| 230 | |
| 231 | /** @private {!Object} */ |
| 232 | this.preferences_ = {}; |
| 233 | |
| 234 | mixin(this.preferences_, getDefaultPreferences()['mutable']); |
| 235 | mixin(this.preferences_, getDefaultPreferences()['frozen']); |
| 236 | |
| 237 | /** @private {boolean} */ |
| 238 | this.nativeEventsEnabled_ = true; |
| 239 | |
| 240 | /** @private {(string|undefined)} */ |
| 241 | this.template_ = opt_dir; |
| 242 | |
| 243 | /** @private {number} */ |
| 244 | this.port_ = 0; |
| 245 | |
| 246 | /** @private {!Array.<string>} */ |
| 247 | this.extensions_ = []; |
| 248 | }; |
| 249 | util.inherits(Profile, Serializable); |
| 250 | |
| 251 | |
| 252 | /** |
| 253 | * Registers an extension to be included with this profile. |
| 254 | * @param {string} extension Path to the extension to include, as either an |
| 255 | * unpacked extension directory or the path to a xpi file. |
| 256 | */ |
| 257 | Profile.prototype.addExtension = function(extension) { |
| 258 | this.extensions_.push(extension); |
| 259 | }; |
| 260 | |
| 261 | |
| 262 | /** |
| 263 | * Sets a desired preference for this profile. |
| 264 | * @param {string} key The preference key. |
| 265 | * @param {(string|number|boolean)} value The preference value. |
| 266 | * @throws {Error} If attempting to set a frozen preference. |
| 267 | */ |
| 268 | Profile.prototype.setPreference = function(key, value) { |
| 269 | var frozen = getDefaultPreferences()['frozen']; |
| 270 | if (frozen.hasOwnProperty(key) && frozen[key] !== value) { |
| 271 | throw Error('You may not set ' + key + '=' + JSON.stringify(value) |
| 272 | + '; value is frozen for proper WebDriver functionality (' |
| 273 | + key + '=' + JSON.stringify(frozen[key]) + ')'); |
| 274 | } |
| 275 | this.preferences_[key] = value; |
| 276 | }; |
| 277 | |
| 278 | |
| 279 | /** |
| 280 | * Returns the currently configured value of a profile preference. This does |
| 281 | * not include any defaults defined in the profile's template directory user.js |
| 282 | * file (if a template were specified on construction). |
| 283 | * @param {string} key The desired preference. |
| 284 | * @return {(string|number|boolean|undefined)} The current value of the |
| 285 | * requested preference. |
| 286 | */ |
| 287 | Profile.prototype.getPreference = function(key) { |
| 288 | return this.preferences_[key]; |
| 289 | }; |
| 290 | |
| 291 | |
| 292 | /** |
| 293 | * @return {number} The port this profile is currently configured to use, or |
| 294 | * 0 if the port will be selected at random when the profile is written |
| 295 | * to disk. |
| 296 | */ |
| 297 | Profile.prototype.getPort = function() { |
| 298 | return this.port_; |
| 299 | }; |
| 300 | |
| 301 | |
| 302 | /** |
| 303 | * Sets the port to use for the WebDriver extension loaded by this profile. |
| 304 | * @param {number} port The desired port, or 0 to use any free port. |
| 305 | */ |
| 306 | Profile.prototype.setPort = function(port) { |
| 307 | this.port_ = port; |
| 308 | }; |
| 309 | |
| 310 | |
| 311 | /** |
| 312 | * @return {boolean} Whether the FirefoxDriver is configured to automatically |
| 313 | * accept untrusted SSL certificates. |
| 314 | */ |
| 315 | Profile.prototype.acceptUntrustedCerts = function() { |
| 316 | return !!this.preferences_['webdriver_accept_untrusted_certs']; |
| 317 | }; |
| 318 | |
| 319 | |
| 320 | /** |
| 321 | * Sets whether the FirefoxDriver should automatically accept untrusted SSL |
| 322 | * certificates. |
| 323 | * @param {boolean} value . |
| 324 | */ |
| 325 | Profile.prototype.setAcceptUntrustedCerts = function(value) { |
| 326 | this.preferences_['webdriver_accept_untrusted_certs'] = !!value; |
| 327 | }; |
| 328 | |
| 329 | |
| 330 | /** |
| 331 | * Sets whether to assume untrusted certificates come from untrusted issuers. |
| 332 | * @param {boolean} value . |
| 333 | */ |
| 334 | Profile.prototype.setAssumeUntrustedCertIssuer = function(value) { |
| 335 | this.preferences_['webdriver_assume_untrusted_issuer'] = !!value; |
| 336 | }; |
| 337 | |
| 338 | |
| 339 | /** |
| 340 | * @return {boolean} Whether to assume untrusted certs come from untrusted |
| 341 | * issuers. |
| 342 | */ |
| 343 | Profile.prototype.assumeUntrustedCertIssuer = function() { |
| 344 | return !!this.preferences_['webdriver_assume_untrusted_issuer']; |
| 345 | }; |
| 346 | |
| 347 | |
| 348 | /** |
| 349 | * Sets whether to use native events with this profile. |
| 350 | * @param {boolean} enabled . |
| 351 | */ |
| 352 | Profile.prototype.setNativeEventsEnabled = function(enabled) { |
| 353 | this.nativeEventsEnabled_ = enabled; |
| 354 | }; |
| 355 | |
| 356 | |
| 357 | /** |
| 358 | * Returns whether native events are enabled in this profile. |
| 359 | * @return {boolean} . |
| 360 | */ |
| 361 | Profile.prototype.nativeEventsEnabled = function() { |
| 362 | return this.nativeEventsEnabled_; |
| 363 | }; |
| 364 | |
| 365 | |
| 366 | /** |
| 367 | * Writes this profile to disk. |
| 368 | * @param {boolean=} opt_excludeWebDriverExt Whether to exclude the WebDriver |
| 369 | * extension from the generated profile. Used to reduce the size of an |
| 370 | * {@link #encode() encoded profile} since the server will always install |
| 371 | * the extension itself. |
| 372 | * @return {!promise.Promise.<string>} A promise for the path to the new |
| 373 | * profile directory. |
| 374 | */ |
| 375 | Profile.prototype.writeToDisk = function(opt_excludeWebDriverExt) { |
| 376 | var profileDir = io.tmpDir(); |
| 377 | if (this.template_) { |
| 378 | profileDir = profileDir.then(function(dir) { |
| 379 | return io.copyDir( |
| 380 | this.template_, dir, /(parent\.lock|lock|\.parentlock)/); |
| 381 | }.bind(this)); |
| 382 | } |
| 383 | |
| 384 | // Freeze preferences for async operations. |
| 385 | var prefs = {}; |
| 386 | mixin(prefs, this.preferences_); |
| 387 | |
| 388 | // Freeze extensions for async operations. |
| 389 | var extensions = this.extensions_.concat(); |
| 390 | |
| 391 | return profileDir.then(function(dir) { |
| 392 | return writeUserPrefs(prefs, dir); |
| 393 | }).then(function(dir) { |
| 394 | return installExtensions(extensions, dir, !!opt_excludeWebDriverExt); |
| 395 | }); |
| 396 | }; |
| 397 | |
| 398 | |
| 399 | /** |
| 400 | * Encodes this profile as a zipped, base64 encoded directory. |
| 401 | * @return {!promise.Promise.<string>} A promise for the encoded profile. |
| 402 | */ |
| 403 | Profile.prototype.encode = function() { |
| 404 | return this.writeToDisk(true).then(function(dir) { |
| 405 | var zip = new AdmZip(); |
| 406 | zip.addLocalFolder(dir, ''); |
| 407 | zip.getEntries()[0].header.method = AdmConstants.STORED; |
| 408 | return io.tmpFile().then(function(file) { |
| 409 | zip.writeZip(file); // Sync! Why oh why :-( |
| 410 | return promise.checkedNodeCall(fs.readFile, file); |
| 411 | }); |
| 412 | }).then(function(data) { |
| 413 | return new Buffer(data).toString('base64'); |
| 414 | }); |
| 415 | }; |
| 416 | |
| 417 | |
| 418 | /** |
| 419 | * Encodes this profile as a zipped, base64 encoded directory. |
| 420 | * @return {!promise.Promise.<string>} A promise for the encoded profile. |
| 421 | * @override |
| 422 | */ |
| 423 | Profile.prototype.serialize = function() { |
| 424 | return this.encode(); |
| 425 | }; |
| 426 | |
| 427 | |
| 428 | // PUBLIC API |
| 429 | |
| 430 | |
| 431 | exports.Profile = Profile; |
| 432 | exports.decode = decode; |
| 433 | exports.loadUserPrefs = loadUserPrefs; |