safari.js

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
27var events = require('events');
28var fs = require('fs');
29var http = require('http');
30var path = require('path');
31var url = require('url');
32var util = require('util');
33var ws = require('ws');
34
35var webdriver = require('./');
36var promise = webdriver.promise;
37var _base = require('./_base');
38var io = require('./io');
39var exec = require('./io/exec');
40var portprober = require('./net/portprober');
41
42
43/** @const */
44var 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 */
51var 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 */
57var 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}} */
90var 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 */
99var 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};
175util.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 */
182function 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 */
218function 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 */
236function 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 */
257var 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 */
270CommandExecutor.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 */
336CommandExecutor.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 */
378CommandExecutor.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 */
399var 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 */
408var 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};
417util.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 */
426Options.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 */
452Options.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 */
466Options.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 */
478Options.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 */
497Options.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 */
518var 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};
529util.inherits(Driver, webdriver.WebDriver);
530
531
532// Public API
533
534
535exports.Driver = Driver;
536exports.Options = Options;