safari.js

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