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