diff --git a/src/browser/provider/built-in/chrome/config.js b/src/browser/provider/built-in/chrome/config.js index 02b882b15ee..276bb3321d8 100644 --- a/src/browser/provider/built-in/chrome/config.js +++ b/src/browser/provider/built-in/chrome/config.js @@ -1,100 +1,41 @@ import emulatedDevices from 'chrome-emulated-devices-list'; -import OS from 'os-family'; -import { find as findElement, pickBy as filterProperties } from 'lodash'; +import { pickBy as filterProperties } from 'lodash'; +import { + hasMatch, findMatch, isMatchTrue, getModes, splitEscaped, getPathFromParsedModes, parseConfig +} from '../../utils/argument-parsing'; const HEADLESS_DEFAULT_WIDTH = 1280; const HEADLESS_DEFAULT_HEIGHT = 800; -const CONFIG_TERMINATOR_RE = /(\s+|^)-/; +const AVAILABLE_MODES = ['userProfile', 'headless', 'emulation']; var configCache = {}; -function hasMatch (array, re) { - return !!findElement(array, el => el.match(re)); +function hasCustomProfile (userArgs) { + return !!userArgs.match(/--user-data-dir=/); } -function findMatch (array, re) { - var element = findElement(array, el => el.match(re)); +function parseModes (modesStr, userArgs) { + var parsed = splitEscaped(modesStr, ':'); + var path = getPathFromParsedModes(parsed, AVAILABLE_MODES); + var detectedModes = getModes(parsed, AVAILABLE_MODES); + var optionsString = ''; - return element ? element.match(re)[1] : ''; -} - -function isMatchTrue (array, re) { - var match = findMatch(array, re); - - return match && match !== '0' && match !== 'false'; -} - -function splitEscaped (str, splitterChar) { - var result = ['']; - - for (var i = 0; i < str.length; i++) { - if (str[i] === splitterChar) { - result.push(''); - continue; - } - - if (str[i] === '\\' && (str[i + 1] === '\\' || str [i + 1] === splitterChar)) - i++; - - result[result.length - 1] += str[i]; - } - - return result; -} - -function parseConfig (str) { - var configTerminatorMatch = str.match(CONFIG_TERMINATOR_RE); - - if (!configTerminatorMatch) - return { modesString: str, userArgs: '' }; - - return { - modesString: str.substr(0, configTerminatorMatch.index), - userArgs: str.substr(configTerminatorMatch.index + configTerminatorMatch[1].length) - }; -} - -function getPathFromParsedModes (modesList) { - if (!modesList.length) - return ''; - - if (modesList[0] === 'headless' || modesList[0] === 'emulation') - return ''; - - var path = modesList.shift(); - - if (OS.win && modesList.length && path.match(/^[A-Za-z]$/)) - path += ':' + modesList.shift(); - - return path; -} - -function parseModes (str) { - var parsed = splitEscaped(str, ':'); - var path = getPathFromParsedModes(parsed); - var nextMode = parsed.shift(); - var hasHeadless = nextMode === 'headless'; - - if (hasHeadless) - nextMode = parsed.shift(); - - var hasEmulation = nextMode === 'emulation'; - - if (hasEmulation) - nextMode = parsed.shift(); + if (parsed.length) + optionsString = parsed.shift(); while (parsed.length) - nextMode += ':' + parsed.shift(); + optionsString += ':' + parsed.shift(); var modes = { - path: path, - headless: hasHeadless, - emulation: hasEmulation || hasHeadless + path: path, + userProfile: detectedModes.userProfile || hasCustomProfile(userArgs), + headless: detectedModes.headless, + emulation: detectedModes.emulation || detectedModes.headless }; - return { modes, optionsString: nextMode || '' }; + return { modes, optionsString }; } function simplifyDeviceName (deviceName) { @@ -154,7 +95,7 @@ function parseOptions (str, modes) { width: Number(findMatch(parsed, /^width=(.*)/) || NaN), height: Number(findMatch(parsed, /^height=(.*)/) || NaN), scaleFactor: Number(findMatch(parsed, /^scaleFactor=(.*)/) || NaN), - userAgent: findMatch(parsed, /^userAgent=(.*)/), + userAgent: findMatch(parsed, /^userAgent=(.*)/) }; specifiedDeviceOptions = filterProperties(specifiedDeviceOptions, optionValue => { @@ -167,7 +108,7 @@ function parseOptions (str, modes) { function getNewConfig (configString) { var { userArgs, modesString } = parseConfig(configString); - var { modes, optionsString } = parseModes(modesString); + var { modes, optionsString } = parseModes(modesString, userArgs); var options = parseOptions(optionsString, modes); return Object.assign({ userArgs }, modes, options); diff --git a/src/browser/provider/built-in/chrome/index.js b/src/browser/provider/built-in/chrome/index.js index 736274f0d99..7807e8aeaae 100644 --- a/src/browser/provider/built-in/chrome/index.js +++ b/src/browser/provider/built-in/chrome/index.js @@ -36,7 +36,8 @@ export default { runtimeInfo.viewportSize = await this.runInitScript(browserId, GET_WINDOW_DIMENSIONS_INFO_SCRIPT); - await cdp.createClient(runtimeInfo); + if (runtimeInfo.config.headless || runtimeInfo.config.emulation) + await cdp.createClient(runtimeInfo); this.openedBrowsers[browserId] = runtimeInfo; }, diff --git a/src/browser/provider/built-in/chrome/local-chrome.js b/src/browser/provider/built-in/chrome/local-chrome.js index f91ab6f2f77..81451780836 100644 --- a/src/browser/provider/built-in/chrome/local-chrome.js +++ b/src/browser/provider/built-in/chrome/local-chrome.js @@ -4,10 +4,11 @@ import { findProcess, killProcess } from '../../../../utils/promisified-function const BROWSER_CLOSING_TIMEOUT = 5; - -function buildChromeArgs (config, cdpPort, platformArgs, userDataDir) { - return [`--remote-debugging-port=${cdpPort}`, `--user-data-dir=${userDataDir.name}`] +function buildChromeArgs (config, cdpPort, platformArgs, profileDir) { + return [] .concat( + config.headless || config.emulation ? [`--remote-debugging-port=${cdpPort}`] : [], + !config.userProfile ? [`--user-data-dir=${profileDir.name}`] : [], config.headless ? ['--headless'] : [], config.userArgs ? [config.userArgs] : [], platformArgs ? [platformArgs] : [] @@ -32,17 +33,11 @@ async function killChrome (cdpPort) { } } -export async function start (pageUrl, { browserName, config, cdpPort, tempUserDataDir }) { - var chromeInfo = null; - - if (config.path) - chromeInfo = await browserTools.getBrowserInfo(config.path); - else - chromeInfo = await browserTools.getBrowserInfo(browserName); - +export async function start (pageUrl, { browserName, config, cdpPort, tempProfileDir }) { + var chromeInfo = await browserTools.getBrowserInfo(config.path || browserName); var chromeOpenParameters = Object.assign({}, chromeInfo); - chromeOpenParameters.cmd = buildChromeArgs(config, cdpPort, chromeOpenParameters.cmd, tempUserDataDir); + chromeOpenParameters.cmd = buildChromeArgs(config, cdpPort, chromeOpenParameters.cmd, tempProfileDir); await browserTools.open(chromeOpenParameters, pageUrl); } diff --git a/src/browser/provider/built-in/chrome/runtime-info.js b/src/browser/provider/built-in/chrome/runtime-info.js index 303403a70ce..90dd9b867d6 100644 --- a/src/browser/provider/built-in/chrome/runtime-info.js +++ b/src/browser/provider/built-in/chrome/runtime-info.js @@ -3,7 +3,7 @@ import { getFreePort } from 'endpoint-utils'; import getConfig from './config'; -function createTempUserDataDir () { +function createTempProfileDir () { tmp.setGracefulCleanup(); return tmp.dirSync({ unsafeCleanup: true }); @@ -11,8 +11,8 @@ function createTempUserDataDir () { export default async function (configString) { var config = getConfig(configString); - var tempUserDataDir = createTempUserDataDir(); + var tempProfileDir = !config.userProfile ? createTempProfileDir() : null; var cdpPort = config.cdpPort || await getFreePort(); - return { config, cdpPort, tempUserDataDir }; + return { config, cdpPort, tempProfileDir }; } diff --git a/src/browser/provider/built-in/firefox/config.js b/src/browser/provider/built-in/firefox/config.js new file mode 100644 index 00000000000..38e905d9591 --- /dev/null +++ b/src/browser/provider/built-in/firefox/config.js @@ -0,0 +1,36 @@ +import { splitEscaped, parseConfig, getModes, getPathFromParsedModes } from '../../utils/argument-parsing'; + + +const AVAILABLE_MODES = ['userProfile']; + +var configCache = {}; + +function hasCustomProfile (userArgs) { + return !!(userArgs.match(/-P\s/) || userArgs.match(/-profile\s/)); +} + +function parseModes (modesStr, userArgs) { + var parsed = splitEscaped(modesStr, ':'); + var path = getPathFromParsedModes(parsed, AVAILABLE_MODES); + var detectedModes = getModes(parsed, AVAILABLE_MODES); + + return { + path: path, + userProfile: detectedModes.userProfile || hasCustomProfile(userArgs) + }; +} + + +function getNewConfig (configString) { + var { userArgs, modesString } = parseConfig(configString); + var modes = parseModes(modesString, userArgs); + + return Object.assign({ userArgs }, modes); +} + +export default function (configString) { + if (!configCache[configString]) + configCache[configString] = getNewConfig(configString); + + return configCache[configString]; +} diff --git a/src/browser/provider/built-in/firefox/index.js b/src/browser/provider/built-in/firefox/index.js new file mode 100644 index 00000000000..82610182042 --- /dev/null +++ b/src/browser/provider/built-in/firefox/index.js @@ -0,0 +1,28 @@ +import browserTools from 'testcafe-browser-tools'; +import getRuntimeInfo from './runtime-info'; +import { start as startLocalFirefox } from './local-firefox'; + + +export default { + openedBrowsers: {}, + + isMultiBrowser: false, + + async openBrowser (browserId, pageUrl, configString) { + var runtimeInfo = await getRuntimeInfo(configString); + var browserName = this.providerName.replace(':', ''); + + runtimeInfo.browserId = browserId; + runtimeInfo.browserName = browserName; + + await startLocalFirefox(pageUrl, runtimeInfo); + }, + + async closeBrowser (browserId) { + await browserTools.close(browserId); + }, + + async isLocalBrowser () { + return true; + }, +}; diff --git a/src/browser/provider/built-in/firefox/local-firefox.js b/src/browser/provider/built-in/firefox/local-firefox.js new file mode 100644 index 00000000000..386717b4def --- /dev/null +++ b/src/browser/provider/built-in/firefox/local-firefox.js @@ -0,0 +1,22 @@ +import browserTools from 'testcafe-browser-tools'; + + +function buildFirefoxArgs (config, platformArgs, profileDir) { + return [] + .concat( + !config.userProfile ? ['-no-remote', '-new-instance', `-profile "${profileDir.name}"`] : [], + config.userArgs ? [config.userArgs] : [], + platformArgs ? [platformArgs] : [] + ) + .join(' '); +} + + +export async function start (pageUrl, { browserName, config, tempProfileDir }) { + var firefoxInfo = await browserTools.getBrowserInfo(config.path || browserName); + var firefoxOpenParameters = Object.assign({}, firefoxInfo); + + firefoxOpenParameters.cmd = buildFirefoxArgs(config, firefoxOpenParameters.cmd, tempProfileDir); + + await browserTools.open(firefoxOpenParameters, pageUrl); +} diff --git a/src/browser/provider/built-in/firefox/runtime-info.js b/src/browser/provider/built-in/firefox/runtime-info.js new file mode 100644 index 00000000000..02df9e0ae93 --- /dev/null +++ b/src/browser/provider/built-in/firefox/runtime-info.js @@ -0,0 +1,16 @@ +import tmp from 'tmp'; +import getConfig from './config'; + + +function createTempProfileDir () { + tmp.setGracefulCleanup(); + + return tmp.dirSync({ unsafeCleanup: true }); +} + +export default async function (configString) { + var config = getConfig(configString); + var tempProfileDir = !config.userProfile ? createTempProfileDir() : null; + + return { config, tempProfileDir }; +} diff --git a/src/browser/provider/built-in/index.js b/src/browser/provider/built-in/index.js index 5202031e158..f21f10bd21d 100644 --- a/src/browser/provider/built-in/index.js +++ b/src/browser/provider/built-in/index.js @@ -2,18 +2,22 @@ import nodeVersion from 'node-version'; import pathBrowserProvider from './path'; import locallyInstalledBrowserProvider from './locally-installed'; import remoteBrowserProvider from './remote'; +import firefoxProvider from './firefox'; const chromeProvider = nodeVersion.major !== '0' ? require('./chrome') : null; + export default Object.assign( { 'locally-installed': locallyInstalledBrowserProvider, 'path': pathBrowserProvider, - 'remote': remoteBrowserProvider + 'remote': remoteBrowserProvider, + 'firefox': firefoxProvider }, chromeProvider && { - 'chrome:': chromeProvider, - 'chromium:': chromeProvider + 'chrome': chromeProvider, + 'chromium': chromeProvider, + 'chrome-canary': chromeProvider, } ); diff --git a/src/browser/provider/pool.js b/src/browser/provider/pool.js index 2426d8db65a..e08aec84b4a 100644 --- a/src/browser/provider/pool.js +++ b/src/browser/provider/pool.js @@ -7,7 +7,7 @@ import { GeneralError } from '../../errors/runtime'; import MESSAGE from '../../errors/runtime/message'; -const BROWSER_PROVIDER_RE = /^([^:]+)(?::(.*))?$/; +const BROWSER_PROVIDER_RE = /^([^:\s]+):?(.*)?$/; export default { providersCache: {}, diff --git a/src/browser/provider/utils/argument-parsing.js b/src/browser/provider/utils/argument-parsing.js new file mode 100644 index 00000000000..ac3cda801c1 --- /dev/null +++ b/src/browser/provider/utils/argument-parsing.js @@ -0,0 +1,88 @@ +import { find as findElement } from 'lodash'; +import OS from 'os-family'; + + +const CONFIG_TERMINATOR_RE = /(\s+|^)-/; + +export function hasMatch (array, re) { + return !!findElement(array, el => el.match(re)); +} + +export function findMatch (array, re) { + var element = findElement(array, el => el.match(re)); + + return element ? element.match(re)[1] : ''; +} + +export function isMatchTrue (array, re) { + var match = findMatch(array, re); + + return match && match !== '0' && match !== 'false'; +} + +export function splitEscaped (str, splitterChar) { + var result = ['']; + + for (var i = 0; i < str.length; i++) { + if (str[i] === splitterChar) { + result.push(''); + continue; + } + + if (str[i] === '\\' && (str[i + 1] === '\\' || str [i + 1] === splitterChar)) + i++; + + result[result.length - 1] += str[i]; + } + + return result; +} + +export function getPathFromParsedModes (modes, availableModes = []) { + if (!modes.length) + return ''; + + if (availableModes.some(mode => mode === modes[0])) + return ''; + + var path = modes.shift(); + + if (OS.win && modes.length && path.match(/^[A-Za-z]$/)) + path += ':' + modes.shift(); + + return path; +} + +export function getModes (modes, availableModes = []) { + var result = {}; + + availableModes = availableModes.slice(); + + availableModes.forEach(key => { + result[key] = false; + }); + + while (modes.length && availableModes.length) { + if (modes[0] === availableModes[0]) { + result[availableModes[0]] = true; + + modes.shift(); + } + + availableModes.shift(); + } + + return result; +} + +export function parseConfig (str) { + var configTerminatorMatch = str.match(CONFIG_TERMINATOR_RE); + + if (!configTerminatorMatch) + return { modesString: str, userArgs: '' }; + + return { + modesString: str.substr(0, configTerminatorMatch.index), + userArgs: str.substr(configTerminatorMatch.index + configTerminatorMatch[1].length) + }; +} diff --git a/test/server/chrome-provider-config-test.js b/test/server/chrome-provider-config-test.js index ca6ec67806a..54073444cf2 100644 --- a/test/server/chrome-provider-config-test.js +++ b/test/server/chrome-provider-config-test.js @@ -8,6 +8,7 @@ describe('Chrome provider config parser', function () { var config = getChromeConfig('/chrome/path/with\\::headless:emulation:device=iPhone 4;cdpPort=9222 --arg1 --arg2'); expect(config.path).to.equal('/chrome/path/with:'); + expect(config.userProfile).to.be.false; expect(config.headless).to.be.true; expect(config.emulation).to.be.true; @@ -65,6 +66,16 @@ describe('Chrome provider config parser', function () { expect(config.scaleFactor).to.equal(0); }); + it('Should support userProfile mode', function () { + var config = getChromeConfig('userProfile'); + + expect(config.userProfile).to.be.true; + + config = getChromeConfig('--user-data-dir=/dev/null'); + + expect(config.userProfile).to.be.true; + }); + if (OS.win) { it('Should allow unescaped colon as disk/path separator on Windows', function () { var config = getChromeConfig('C:\\Chrome\\chrome.exe:headless'); diff --git a/test/server/firefox-provider-config-test.js b/test/server/firefox-provider-config-test.js new file mode 100644 index 00000000000..deba89f09fe --- /dev/null +++ b/test/server/firefox-provider-config-test.js @@ -0,0 +1,38 @@ +var expect = require('chai').expect; +var OS = require('os-family'); +var getFirefoxConfig = require('../../lib/browser/provider/built-in/firefox/config.js'); + + +describe('Firefox provider config parser', function () { + it('Should parse options and arguments', function () { + var config = getFirefoxConfig('/firefox/path/with\\::headless:emulation:device=iPhone 4;cdpPort=9222 --arg1 --arg2'); + + expect(config.path).to.equal('/firefox/path/with:'); + expect(config.userProfile).to.be.false; + + expect(config.userArgs).to.equal('--arg1 --arg2'); + }); + + it('Should support userProfile mode', function () { + var config = getFirefoxConfig('userProfile'); + + expect(config.userProfile).to.be.true; + + config = getFirefoxConfig('-P user'); + + expect(config.userProfile).to.be.true; + + config = getFirefoxConfig('-profile /home/user'); + + expect(config.userProfile).to.be.true; + }); + + if (OS.win) { + it('Should allow unescaped colon as disk/path separator on Windows', function () { + var config = getFirefoxConfig('C:\\Firefox\\firefox.exe:userProfile'); + + expect(config.path).to.eql('C:\\Firefox\\firefox.exe'); + expect(config.userProfile).to.be.true; + }); + } +});