# 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 |
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;