// Licensed to the Software Freedom Conservancy (SFC) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The SFC licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. /** * @fileoverview Defines a WebDriver client for Safari. * * * __Testing Older Versions of Safari__ * * To test versions of Safari prior to Safari 10.0, you must install the * [latest version](http://selenium-release.storage.googleapis.com/index.html) * of the SafariDriver browser extension; using Safari for normal browsing is * not recommended once the extension has been installed. You can, and should, * disable the extension when the browser is not being used with WebDriver. * * You must also enable the use of legacy driver using the {@link Options} class. * * let options = new safari.Options() * .useLegacyDriver(true); * * let driver = new (require('selenium-webdriver')).Builder() * .forBrowser('safari') * .setSafariOptions(options) * .build(); */ 'use strict'; const events = require('events'); const fs = require('fs'); const http = require('http'); const path = require('path'); const url = require('url'); const util = require('util'); const ws = require('ws'); const io = require('./io'); const exec = require('./io/exec'); const isDevMode = require('./lib/devmode'); const Capabilities = require('./lib/capabilities').Capabilities; const Capability = require('./lib/capabilities').Capability; const command = require('./lib/command'); const error = require('./lib/error'); const logging = require('./lib/logging'); const promise = require('./lib/promise'); const Session = require('./lib/session').Session; const Symbols = require('./lib/symbols'); const webdriver = require('./lib/webdriver'); const portprober = require('./net/portprober'); const remote = require('./remote'); const http_ = require('./http'); /** @const */ const CLIENT_PATH = isDevMode ? path.join(__dirname, '../../../buck-out/gen/javascript/safari-driver/client.js') : path.join(__dirname, 'lib/safari/client.js'); /** @const */ const LIBRARY_DIR = (function() { if (process.platform === 'darwin') { return path.join('/Users', process.env['USER'], 'Library/Safari'); } else if (process.platform === 'win32') { return path.join(process.env['APPDATA'], 'Apple Computer', 'Safari'); } else { return '/dev/null'; } })(); /** @const */ const SESSION_DATA_FILES = (function() { if (process.platform === 'darwin') { var libraryDir = path.join('/Users', process.env['USER'], 'Library'); return [ path.join(libraryDir, 'Caches/com.apple.Safari/Cache.db'), path.join(libraryDir, 'Cookies/Cookies.binarycookies'), path.join(libraryDir, 'Cookies/Cookies.plist'), path.join(libraryDir, 'Safari/History.plist'), path.join(libraryDir, 'Safari/LastSession.plist'), path.join(libraryDir, 'Safari/LocalStorage'), path.join(libraryDir, 'Safari/Databases') ]; } else if (process.platform === 'win32') { var appDataDir = path.join(process.env['APPDATA'], 'Apple Computer', 'Safari'); var localDataDir = path.join(process.env['LOCALAPPDATA'], 'Apple Computer', 'Safari'); return [ path.join(appDataDir, 'History.plist'), path.join(appDataDir, 'LastSession.plist'), path.join(appDataDir, 'Cookies/Cookies.plist'), path.join(appDataDir, 'Cookies/Cookies.binarycookies'), path.join(localDataDir, 'Cache.db'), path.join(localDataDir, 'Databases'), path.join(localDataDir, 'LocalStorage') ]; } else { return []; } })(); /** @typedef {{port: number, address: string, family: string}} */ var Host; /** * A basic HTTP/WebSocket server used to communicate with the legacy SafariDriver * browser extension. */ class Server extends events.EventEmitter { constructor() { super(); var server = http.createServer(function(req, res) { if (req.url === '/favicon.ico') { res.writeHead(204); res.end(); return; } var query = url.parse(/** @type {string} */(req.url)).query || ''; if (query.indexOf('url=') == -1) { var address = server.address() var host = address.address + ':' + address.port; res.writeHead( 302, {'Location': 'http://' + host + '?url=ws://' + host}); res.end(); } fs.readFile(CLIENT_PATH, 'utf8', function(err, data) { if (err) { res.writeHead(500, {'Content-Type': 'text/plain'}); res.end(err.stack); return; } var content = ''; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(content, 'utf8'), }); res.end(content); }); }); var wss = new ws.Server({server: server}); wss.on('connection', this.emit.bind(this, 'connection')); /** * Starts the server on a random port. * @return {!Promise} A promise that will resolve with the server host * when it has fully started. */ this.start = function() { if (server.address()) { return Promise.resolve(server.address()); } return portprober.findFreePort('localhost').then(function(port) { return promise.checkedNodeCall( server.listen.bind(server, port, 'localhost')); }).then(function() { return server.address(); }); }; /** * Stops the server. * @return {!Promise} A promise that will resolve when the server has closed * all connections. */ this.stop = function() { return new Promise(fulfill => server.close(fulfill)); }; /** * @return {Host} This server's host info. * @throws {Error} If the server is not running. */ this.address = function() { var addr = server.address(); if (!addr) { throw Error('There server is not running!'); } return addr; }; } } /** * @return {!Promise} A promise that will resolve with the path * to Safari on the current system. */ function findSafariExecutable() { switch (process.platform) { case 'darwin': return Promise.resolve('/Applications/Safari.app/Contents/MacOS/Safari'); case 'win32': var files = [ process.env['PROGRAMFILES'] || '\\Program Files', process.env['PROGRAMFILES(X86)'] || '\\Program Files (x86)' ].map(function(prefix) { return path.join(prefix, 'Safari\\Safari.exe'); }); return io.exists(files[0]).then(function(exists) { return exists ? files[0] : io.exists(files[1]).then(function(exists) { if (exists) { return files[1]; } throw Error('Unable to find Safari on the current system'); }); }); default: return Promise.reject( Error('Safari is not supported on the current platform: ' + process.platform)); } } /** * @param {string} serverUrl The URL to connect to. * @return {!Promise} A promise for the path to a file that Safari can * open on start-up to trigger a new connection to the WebSocket server. */ function createConnectFile(serverUrl) { return io.tmpFile({postfix: '.html'}).then(function(f) { let contents = ``; return io.write(f, contents).then(() => f); }); } /** * Deletes all session data files if so desired. * @param {!Object} desiredCapabilities . * @return {!Array} A list of promises for the deleted files. */ function cleanSession(desiredCapabilities) { if (!desiredCapabilities) { return []; } var options = desiredCapabilities[OPTIONS_CAPABILITY_KEY]; if (!options) { return []; } if (!options['cleanSession']) { return []; } return SESSION_DATA_FILES.map(function(file) { return io.unlink(file); }); } /** @return {string} . */ function getRandomString() { let seed = Date.now(); return Math.floor(Math.random() * seed).toString(36) + Math.abs(Math.floor(Math.random() * seed) ^ Date.now()).toString(36); } /** * @implements {command.Executor} */ class CommandExecutor { constructor() { this.server_ = null; /** @private {ws.WebSocket} */ this.socket_ = null; /** @private {?string} 8*/ this.sessionId_ = null; /** @private {Promise} */ this.safari_ = null; /** @private {!logging.Logger} */ this.log_ = logging.getLogger('webdriver.safari'); } /** @override */ execute(cmd) { var self = this; return new promise.Promise(function(fulfill, reject) { var safariCommand = JSON.stringify({ 'origin': 'webdriver', 'type': 'command', 'command': { 'id': getRandomString(), 'name': cmd.getName(), 'parameters': cmd.getParameters() } }); switch (cmd.getName()) { case command.Name.NEW_SESSION: self.startSafari_(cmd) .then(() => self.sendCommand_(safariCommand)) .then(caps => new Session(self.sessionId(), caps)) .then(fulfill, reject); break; case command.Name.DESCRIBE_SESSION: self.sendCommand_(safariCommand) .then(caps => new Session(self.sessionId(), caps)) .then(fulfill, reject); break; case command.Name.QUIT: self.destroySession_().then(() => fulfill(null), reject); break; default: self.sendCommand_(safariCommand).then(fulfill, reject); break; } }); } /** * @return {string} The static session ID for this executor's current * connection. */ sessionId() { if (!this.sessionId_) { throw Error('not currently connected') } return this.sessionId_; } /** * @param {string} data . * @return {!promise.Promise} . * @private */ sendCommand_(data) { let self = this; return new promise.Promise(function(fulfill, reject) { // TODO: support reconnecting with the extension. if (!self.socket_) { self.destroySession_().finally(function() { reject(Error('The connection to the SafariDriver was closed')); }); return; } self.log_.fine(() => '>>> ' + data); self.socket_.send(data, function(err) { if (err) { reject(err); return; } }); self.socket_.once('message', function(data) { try { self.log_.fine(() => '<<< ' + data); data = JSON.parse(data); } catch (ex) { reject(Error('Failed to parse driver message: ' + data)); return; } try { error.checkLegacyResponse(data['response']); fulfill(data['response']['value']); } catch (ex) { reject(ex); } }); }); } /** * @param {!command.Command} command . * @private */ startSafari_(command) { this.server_ = new Server(); this.safari_ = this.server_.start().then(function(address) { var tasks = cleanSession( /** @type {!Object} */( command.getParameters()['desiredCapabilities'])); tasks.push( findSafariExecutable(), createConnectFile( 'http://' + address.address + ':' + address.port)); return Promise.all(tasks).then(function(/** !Array */tasks) { var exe = tasks[tasks.length - 2]; var html = tasks[tasks.length - 1]; return exec(exe, {args: [html]}); }); }); return new Promise((resolve, reject) => { let start = Date.now(); let timer = setTimeout(function() { let elapsed = Date.now() - start; reject(Error( 'Failed to connect to the SafariDriver after ' + elapsed + ' ms; Have you installed the latest extension from ' + 'http://selenium-release.storage.googleapis.com/index.html?')); }, 10 * 1000); this.server_.once('connection', socket => { clearTimeout(timer); this.socket_ = socket; this.sessionId_ = getRandomString(); socket.once('close', () => { this.socket_ = null; this.sessionId_ = null; }); resolve(); }); }); } /** * Destroys the active session by stopping the WebSocket server and killing the * Safari subprocess. * @private */ destroySession_() { var tasks = []; if (this.server_) { tasks.push(this.server_.stop()); } if (this.safari_) { tasks.push(this.safari_.then(function(safari) { safari.kill(); return safari.result(); })); } var self = this; return promise.all(tasks).finally(function() { self.server_ = null; self.socket_ = null; self.safari_ = null; }); } } /** * @return {string} . * @throws {Error} */ function findSafariDriver() { let exe = io.findInPath('safaridriver', true); if (!exe) { throw Error( `The safaridriver executable could not be found on the current PATH. Please ensure you are using Safari 10.0 or above.`); } return exe; } /** * Creates {@link selenium-webdriver/remote.DriverService} instances that manage * a [safaridriver] server in a child process. * * [safaridriver]: https://developer.apple.com/library/prerelease/content/releasenotes/General/WhatsNewInSafari/Articles/Safari_10_0.html#//apple_ref/doc/uid/TP40014305-CH11-DontLinkElementID_28 */ class ServiceBuilder extends remote.DriverService.Builder { /** * @param {string=} opt_exe Path to the server executable to use. If omitted, * the builder will attempt to locate the safaridriver on the system PATH. */ constructor(opt_exe) { super(opt_exe || findSafariDriver()); this.setLoopback(true); // Required. } } /** @const */ const OPTIONS_CAPABILITY_KEY = 'safari.options'; const LEGACY_DRIVER_CAPABILITY_KEY = 'legacyDriver' /** * Configuration options specific to the {@link Driver SafariDriver}. */ class Options { constructor() { /** @private {Object} */ this.options_ = null; /** @private {./lib/logging.Preferences} */ this.logPrefs_ = null; /** @private {?./lib/capabilities.ProxyConfig} */ this.proxy_ = null; /** @private {boolean} */ this.legacyDriver_ = false; } /** * Extracts the SafariDriver specific options from the given capabilities * object. * @param {!Capabilities} capabilities The capabilities object. * @return {!Options} The ChromeDriver options. */ static fromCapabilities(capabilities) { var options = new Options(); var o = capabilities.get(OPTIONS_CAPABILITY_KEY); if (o instanceof Options) { options = o; } else if (o) { options.setCleanSession(o.cleanSession); } if (capabilities.has(Capability.PROXY)) { options.setProxy(capabilities.get(Capability.PROXY)); } if (capabilities.has(Capability.LOGGING_PREFS)) { options.setLoggingPrefs(capabilities.get(Capability.LOGGING_PREFS)); } if (capabilities.has(LEGACY_DRIVER_CAPABILITY_KEY)) { options.useLegacyDriver(capabilities.get(LEGACY_DRIVER_CAPABILITY_KEY)); } return options; } /** * Sets whether to force Safari to start with a clean session. Enabling this * option will cause all global browser data to be deleted. * @param {boolean} clean Whether to make sure the session has no cookies, * cache entries, local storage, or databases. * @return {!Options} A self reference. */ setCleanSession(clean) { if (!this.options_) { this.options_ = {}; } this.options_['cleanSession'] = clean; return this; } /** * Sets whether to use the legacy driver from the Selenium project. This option * is disabled by default. * * @param {boolean} enable Whether to enable the legacy driver. * @return {!Options} A self reference. */ useLegacyDriver(enable) { this.legacyDriver_ = enable; return this; } /** * Sets the logging preferences for the new session. * @param {!./lib/logging.Preferences} prefs The logging preferences. * @return {!Options} A self reference. */ setLoggingPrefs(prefs) { this.logPrefs_ = prefs; return this; } /** * Sets the proxy to use. * * @param {./lib/capabilities.ProxyConfig} proxy The proxy configuration to use. * @return {!Options} A self reference. */ setProxy(proxy) { this.proxy_ = proxy; return this; } /** * Converts this options instance to a {@link Capabilities} object. * @param {Capabilities=} opt_capabilities The capabilities to * merge these options into, if any. * @return {!Capabilities} The capabilities. */ toCapabilities(opt_capabilities) { var caps = opt_capabilities || Capabilities.safari(); if (this.logPrefs_) { caps.set(Capability.LOGGING_PREFS, this.logPrefs_); } if (this.proxy_) { caps.set(Capability.PROXY, this.proxy_); } if (this.options_) { caps.set(OPTIONS_CAPABILITY_KEY, this); } caps.set(LEGACY_DRIVER_CAPABILITY_KEY, this.legacyDriver_); return caps; } /** * Converts this instance to its JSON wire protocol representation. Note this * function is an implementation detail not intended for general use. * @return {!Object} The JSON wire protocol representation of this * instance. */ [Symbols.serialize]() { return this.options_ || {}; } } /** * A WebDriver client for Safari. This class should never be instantiated * directly; instead, use the {@linkplain ./builder.Builder Builder}: * * var driver = new Builder() * .forBrowser('safari') * .build(); * */ class Driver extends webdriver.WebDriver { /** * @param {(Options|Capabilities)=} opt_config The configuration * options for the new session. * @param {promise.ControlFlow=} opt_flow The control flow to create * the driver under. */ constructor(opt_config, opt_flow) { let caps, executor, useLegacyDriver = false, onQuit = () => {}; if (opt_config instanceof Options) { caps = opt_config.toCapabilities(); } else { caps = opt_config || Capabilities.safari() } if (caps.has(LEGACY_DRIVER_CAPABILITY_KEY)) { useLegacyDriver = caps.get(LEGACY_DRIVER_CAPABILITY_KEY); caps.delete(LEGACY_DRIVER_CAPABILITY_KEY); } if (useLegacyDriver) { executor = new CommandExecutor(); } else { let service = new ServiceBuilder().build(); executor = new http_.Executor( service.start() .then(url => new http_.HttpClient(url)) ); onQuit = () => service.kill(); } let driver = webdriver.WebDriver.createSession(executor, caps, opt_flow); super(driver.getSession(), executor, driver.controlFlow()); /** @override */ this.quit = () => { return super.quit().finally(onQuit); }; } } // Public API exports.Driver = Driver; exports.Options = Options; exports.ServiceBuilder = ServiceBuilder;