diff --git a/src/cli/argument-parser.js b/src/cli/argument-parser.js index 45773d76a6b..91e5931ab7b 100644 --- a/src/cli/argument-parser.js +++ b/src/cli/argument-parser.js @@ -107,6 +107,7 @@ export default class CLIArgumentParser { .option('--ports ', 'specify custom port numbers') .option('--hostname ', 'specify the hostname') .option('--proxy ', 'specify the host of the proxy server') + .option('--proxy-bypass ', 'specify a comma-separated list of rules that define URLs accessed bypassing the proxy server') .option('--qr-code', 'outputs QR-code that repeats URLs used to connect the remote browsers') // NOTE: these options will be handled by chalk internally diff --git a/src/cli/cli.js b/src/cli/cli.js index f46d1823538..b0a0d73f8f4 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -68,6 +68,7 @@ async function runTests (argParser) { var port1 = opts.ports && opts.ports[0]; var port2 = opts.ports && opts.ports[1]; var externalProxyHost = opts.proxy; + var proxyBypass = opts.proxyBypass; log.showSpinner(); @@ -87,7 +88,7 @@ async function runTests (argParser) { reporters.forEach(r => runner.reporter(r.name, r.outStream)); runner - .useProxy(externalProxyHost) + .useProxy(externalProxyHost, proxyBypass) .src(argParser.src) .browsers(browsers) .concurrency(concurrency) diff --git a/src/runner/index.js b/src/runner/index.js index 0783cf1d200..c332abcb4c8 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -9,6 +9,7 @@ import Reporter from '../reporter'; import Task from './task'; import { GeneralError } from '../errors/runtime'; import MESSAGE from '../errors/runtime/message'; +import { assertType, is } from '../errors/runtime/type-assertions'; const DEFAULT_SELECTOR_TIMEOUT = 10000; @@ -26,6 +27,7 @@ export default class Runner extends EventEmitter { this.opts = { externalProxyHost: null, + proxyBypass: null, screenshotPath: null, takeScreenshotsOnFails: false, skipJsErrors: false, @@ -118,6 +120,21 @@ export default class Runner extends EventEmitter { assets.forEach(asset => this.proxy.GET(asset.path, asset.info)); } + _validateRunOptions () { + const concurrency = this.bootstrapper.concurrency; + const speed = this.opts.speed; + const proxyBypass = this.opts.proxyBypass; + + if (typeof speed !== 'number' || isNaN(speed) || speed < 0.01 || speed > 1) + throw new GeneralError(MESSAGE.invalidSpeedValue); + + if (typeof concurrency !== 'number' || isNaN(concurrency) || concurrency < 1) + throw new GeneralError(MESSAGE.invalidConcurrencyFactor); + + if (proxyBypass) + assertType(is.string, null, '"proxyBypass" argument', proxyBypass); + } + // API embeddingOptions (opts) { @@ -142,9 +159,6 @@ export default class Runner extends EventEmitter { } concurrency (concurrency) { - if (typeof concurrency !== 'number' || isNaN(concurrency) || concurrency < 1) - throw new GeneralError(MESSAGE.invalidConcurrencyFactor); - this.bootstrapper.concurrency = concurrency; return this; @@ -165,8 +179,9 @@ export default class Runner extends EventEmitter { return this; } - useProxy (externalProxyHost) { + useProxy (externalProxyHost, proxyBypass) { this.opts.externalProxyHost = externalProxyHost; + this.opts.proxyBypass = proxyBypass; return this; } @@ -194,12 +209,13 @@ export default class Runner extends EventEmitter { this.opts.assertionTimeout = assertionTimeout === void 0 ? DEFAULT_ASSERTION_TIMEOUT : assertionTimeout; this.opts.pageLoadTimeout = pageLoadTimeout === void 0 ? DEFAULT_PAGE_LOAD_TIMEOUT : pageLoadTimeout; - if (typeof speed !== 'number' || isNaN(speed) || speed < 0.01 || speed > 1) - throw new GeneralError(MESSAGE.invalidSpeedValue); - this.opts.speed = speed; - var runTaskPromise = this.bootstrapper.createRunnableConfiguration() + var runTaskPromise = Promise.resolve() + .then(() => { + this._validateRunOptions(); + return this.bootstrapper.createRunnableConfiguration(); + }) .then(({ reporterPlugins, browserSet, tests, testedApp }) => { this.emit('done-bootstrapping'); diff --git a/src/runner/test-run-controller.js b/src/runner/test-run-controller.js index 33ea4634ca2..f5fa7cbf688 100644 --- a/src/runner/test-run-controller.js +++ b/src/runner/test-run-controller.js @@ -1,5 +1,6 @@ import EventEmitter from 'events'; import { TestRun as LegacyTestRun } from 'testcafe-legacy-api'; +import checkUrl from '../utils/check-url'; import TestRun from '../test-run'; @@ -126,6 +127,10 @@ export default class TestRunController extends EventEmitter { testRun.start(); - return this.proxy.openSession(testRun.test.pageUrl, testRun, this.opts.externalProxyHost); + const pageUrl = testRun.test.pageUrl; + const needBypassHost = this.opts.proxyBypass && checkUrl(pageUrl, this.opts.proxyBypass.split(',')); + const externalProxyHost = needBypassHost ? null : this.opts.externalProxyHost; + + return this.proxy.openSession(pageUrl, testRun, externalProxyHost); } } diff --git a/src/utils/check-url.js b/src/utils/check-url.js new file mode 100644 index 00000000000..36ebc2fd9a9 --- /dev/null +++ b/src/utils/check-url.js @@ -0,0 +1,68 @@ +import { escapeRegExp as escapeRe } from 'lodash'; + +const startsWithWildcardRegExp = /^\*\./; +const endsWithWildcardRegExp = /\.\*$/; +const trailingSlashesRegExp = /\/.*$/; +const portRegExp = /:(\d+)$/; +const protocolRegExp = /^(\w+):\/\//; +const wildcardRegExp = /\\\.\\\*/g; + +function parseUrl (url) { + if (!url || typeof url !== 'string') + return null; + + let protocol = url.match(protocolRegExp); + + protocol = protocol ? protocol[1] : null; + url = url.replace(protocolRegExp, ''); + url = url.replace(trailingSlashesRegExp, ''); + + let port = url.match(portRegExp); + + port = port ? parseInt(port[1], 10) : null; + url = url.replace(portRegExp, ''); + + return { protocol, url, port }; +} + +function prepareRule (url) { + const rule = parseUrl(url); + + if (rule) { + rule.url = rule.url.replace(startsWithWildcardRegExp, '.'); + rule.url = rule.url.replace(endsWithWildcardRegExp, '.'); + } + + return rule; +} + +function urlMatchRule (sourceUrl, rule) { + if (!sourceUrl || !rule) + return false; + + const matchByProtocols = !rule.protocol || !sourceUrl.protocol || rule.protocol === sourceUrl.protocol; + const matchByPorts = !rule.port || sourceUrl.port === rule.port; + const domainRequiredBeforeRule = rule.url.startsWith('.'); + const domainRequiredAfterRule = rule.url.endsWith('.'); + + let regExStr = '^'; + + if (domainRequiredBeforeRule) + regExStr += '.+'; + + regExStr += escapeRe(rule.url).replace(wildcardRegExp, '\\..*'); + + if (domainRequiredAfterRule) + regExStr += '.+'; + + regExStr += '$'; + + return new RegExp(regExStr).test(sourceUrl.url) && matchByProtocols && matchByPorts; +} + +export default function (url, rules) { + if (!Array.isArray(rules)) + rules = [rules]; + + return rules.some(rule => urlMatchRule(parseUrl(url), prepareRule(rule))); +} diff --git a/test/functional/fixtures/proxy/test.js b/test/functional/fixtures/proxy/test.js index b373be33b39..0ffa3c2869d 100644 --- a/test/functional/fixtures/proxy/test.js +++ b/test/functional/fixtures/proxy/test.js @@ -2,6 +2,7 @@ var os = require('os'); const TRUSTED_PROXY_URL = os.hostname() + ':3004'; const TRANSPARENT_PROXY_URL = os.hostname() + ':3005'; +const ERROR_PROXY_URL = 'ERROR'; describe('Using external proxy server', function () { it('Should open page via proxy server', function () { @@ -12,3 +13,14 @@ describe('Using external proxy server', function () { return runTests('testcafe-fixtures/restricted-page.test.js', null, { useProxy: TRUSTED_PROXY_URL }); }); }); + +describe('Using proxy-bypass', function () { + it('Should bypass using proxy by one rule', function () { + return runTests('testcafe-fixtures/index.test.js', null, { useProxy: ERROR_PROXY_URL, proxyBypass: 'localhost:3000' }); + }); + + it('Should bypass using proxy by set of rules', function () { + return runTests('testcafe-fixtures/index.test.js', null, { useProxy: ERROR_PROXY_URL, proxyBypass: 'dummy,localhost:3000' }); + }); +}); + diff --git a/test/functional/setup.js b/test/functional/setup.js index e3ca032b0cb..2d0f2b9997b 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -165,6 +165,7 @@ before(function () { var appCommand = opts && opts.appCommand; var appInitDelay = opts && opts.appInitDelay; var externalProxyHost = opts && opts.useProxy; + var proxyBypass = opts && opts.proxyBypass; var customReporters = opts && opts.reporters; var actualBrowsers = browsersInfo.filter(function (browserInfo) { @@ -208,7 +209,7 @@ before(function () { } return runner - .useProxy(externalProxyHost) + .useProxy(externalProxyHost, proxyBypass) .browsers(connections) .filter(function (test) { return testName ? test === testName : true; diff --git a/test/server/cli-argument-parser-test.js b/test/server/cli-argument-parser-test.js index 2e6ecdbddc0..a912dc03908 100644 --- a/test/server/cli-argument-parser-test.js +++ b/test/server/cli-argument-parser-test.js @@ -333,7 +333,7 @@ describe('CLI argument parser', function () { }); it('Should parse command line arguments', function () { - return parse('-r list -S -q -e --hostname myhost --proxy localhost:1234 --qr-code --app run-app --speed 0.5 --debug-on-fail ie test/server/data/file-list/file-1.js') + return parse('-r list -S -q -e --hostname myhost --proxy localhost:1234 --proxy-bypass localhost:5678 --qr-code --app run-app --speed 0.5 --debug-on-fail ie test/server/data/file-list/file-1.js') .then(function (parser) { expect(parser.browsers).eql(['ie']); expect(parser.src).eql([path.resolve(process.cwd(), 'test/server/data/file-list/file-1.js')]); @@ -347,6 +347,7 @@ describe('CLI argument parser', function () { expect(parser.opts.speed).eql(0.5); expect(parser.opts.qrCode).to.be.ok; expect(parser.opts.proxy).to.be.ok; + expect(parser.opts.proxyBypass).to.be.ok; expect(parser.opts.debugOnFail).to.be.ok; }); }); @@ -377,6 +378,7 @@ describe('CLI argument parser', function () { { long: '--ports' }, { long: '--hostname' }, { long: '--proxy' }, + { long: '--proxy-bypass' }, { long: '--qr-code' }, { long: '--color' }, { long: '--no-color' } diff --git a/test/server/match-url-test.js b/test/server/match-url-test.js new file mode 100644 index 00000000000..68402858608 --- /dev/null +++ b/test/server/match-url-test.js @@ -0,0 +1,169 @@ +var expect = require('chai').expect; +var matchUrl = require('../../lib/utils/check-url'); + +it('Should check does url match rule', function () { + var rule = 'google.com'; + + expect(matchUrl('google.com.uk', rule)).to.be.false; + expect(matchUrl('docs.google.com', rule)).to.be.false; + expect(matchUrl('http://docs.google.com', rule)).to.be.false; + expect(matchUrl('https://docs.google.com', rule)).to.be.false; + expect(matchUrl('www.docs.google.com', rule)).to.be.false; + expect(matchUrl('http://docs.ggoogle.com', rule)).to.be.false; + expect(matchUrl('http://goooogle.com', rule)).to.be.false; + expect(matchUrl('gogoogle.com', rule)).to.be.false; + expect(matchUrl('http://google.com', rule)).to.be.true; + expect(matchUrl('https://google.com', rule)).to.be.true; + expect(matchUrl('https://google.com/', rule)).to.be.true; + expect(matchUrl('google.com/', rule)).to.be.true; + + rule = 'http://google.com'; + + expect(matchUrl('https://google.com', rule)).to.be.false; + expect(matchUrl('https://google.com/', rule)).to.be.false; + expect(matchUrl('docs.google.com', rule)).to.be.false; + expect(matchUrl('http://docs.google.com', rule)).to.be.false; + expect(matchUrl('https://docs.google.com', rule)).to.be.false; + expect(matchUrl('www.docs.google.com', rule)).to.be.false; + expect(matchUrl('http://docs.ggoogle.com', rule)).to.be.false; + expect(matchUrl('http://google.com', rule)).to.be.true; + expect(matchUrl('google.com/', rule)).to.be.true; + + rule = 'https://google.com'; + + expect(matchUrl('http://google.com', rule)).to.be.false; + expect(matchUrl('https://google.com', rule)).to.be.true; + + ['.google.com', '*.google.com'].forEach(r => { + expect(matchUrl('http://google.com', r)).to.be.false; + expect(matchUrl('https://google.com', r)).to.be.false; + expect(matchUrl('https://google.com/', r)).to.be.false; + expect(matchUrl('google.com/', r)).to.be.false; + expect(matchUrl('http://docs.ggoogle.com', r)).to.be.false; + expect(matchUrl('docs.google.com', r)).to.be.true; + expect(matchUrl('http://docs.google.com', r)).to.be.true; + expect(matchUrl('https://docs.google.com', r)).to.be.true; + expect(matchUrl('www.docs.google.com', r)).to.be.true; + }); + + ['.com', '*.com'].forEach(r => { + expect(matchUrl('http://google.com.uk', r)).to.be.false; + expect(matchUrl('http://google.com', r)).to.be.true; + expect(matchUrl('google.com/', r)).to.be.true; + expect(matchUrl('docs.google.com', r)).to.be.true; + expect(matchUrl('http://docs.google.com', r)).to.be.true; + expect(matchUrl('www.docs.google.com', r)).to.be.true; + }); + + ['google.', 'google.*'].forEach(r => { + expect(matchUrl('docs.google.com', r)).to.be.false; + expect(matchUrl('https://docs.google.co.uk', r)).to.be.false; + expect(matchUrl('https://docs.google.uk', r)).to.be.false; + expect(matchUrl('http://google.com', r)).to.be.true; + expect(matchUrl('https://google.co.uk', r)).to.be.true; + expect(matchUrl('google.ru/', r)).to.be.true; + }); + + ['docs.google.', 'docs.google.*'].forEach(r => { + expect(matchUrl('http://google.com', r)).to.be.false; + expect(matchUrl('docs.google', r)).to.be.false; + expect(matchUrl('https://docs.googlee.com', r)).to.be.false; + expect(matchUrl('www.docs.google.co.uk', r)).to.be.false; + expect(matchUrl('http://docs.ggoogle.com', r)).to.be.false; + expect(matchUrl('http://___docs.google.com', r)).to.be.false; + expect(matchUrl('docs.google.en', r)).to.be.true; + expect(matchUrl('http://docs.google.co.uk', r)).to.be.true; + }); + + ['.google.', '*.google.*'].forEach(r => { + expect(matchUrl('http://google.com', r)).to.be.false; + expect(matchUrl('docs.google.com', r)).to.be.true; + expect(matchUrl('http://docs.google.com', r)).to.be.true; + expect(matchUrl('www.docs.google.com', r)).to.be.true; + expect(matchUrl('www.my.docs.google.com', r)).to.be.true; + + }); + + ['.docs.google.', '*.docs.google.*'].forEach(r => { + expect(matchUrl('http://google.com', r)).to.be.false; + expect(matchUrl('docs.google.com', r)).to.be.false; + expect(matchUrl('http://docs.google.com', r)).to.be.false; + expect(matchUrl('www.docs.google.com', r)).to.be.true; + expect(matchUrl('www.my.docs.google.com.eu', r)).to.be.true; + }); + + rule = 'docs.*.com'; + + expect(matchUrl('docs.google.com', rule)).to.be.true; + expect(matchUrl('docs.google.eu.com', rule)).to.be.true; + expect(matchUrl('docs.google.ru', rule)).to.be.false; + expect(matchUrl('docs.google.co.uk', rule)).to.be.false; + + rule = 'docs.*.*.com'; + + expect(matchUrl('docs.google.com', rule)).to.be.false; + expect(matchUrl('docs.google.ru', rule)).to.be.false; + expect(matchUrl('my.docs.google.ro.eu.com', rule)).to.be.false; + expect(matchUrl('docs.google.co.uk', rule)).to.be.false; + expect(matchUrl('docs.google.eu.com', rule)).to.be.true; + expect(matchUrl('docs.google.ro.eu.com', rule)).to.be.true; + + rule = '.docs.*.*.com.'; + + expect(matchUrl('my.docs.google.eu.com.ru', rule)).to.be.true; + + rule = 'docs.g*e.com'; + + expect(matchUrl('docs.google.com', rule)).to.be.false; + + rule = 'localhost'; + + expect(matchUrl('localhost', rule)).to.be.true; + expect(matchUrl('http://localhost', rule)).to.be.true; + expect(matchUrl('my-localhost', rule)).to.be.false; + expect(matchUrl('localhost-my', rule)).to.be.false; + + rule = '127.0.0.1'; + + expect(matchUrl('127.0.0.1', rule)).to.be.true; + expect(matchUrl('http://127.0.0.1', rule)).to.be.true; + expect(matchUrl('https://127.0.0.1', rule)).to.be.true; + + rule = '127.0.0.'; + + expect(matchUrl('127.127.0.0', rule)).to.be.false; + expect(matchUrl('127.0.0.2', rule)).to.be.true; + + rule = '.0.0.'; + + expect(matchUrl('127.0.1.2', rule)).to.be.false; + expect(matchUrl('127.0.0.2', rule)).to.be.true; + + rule = '127.*.*.0'; + + expect(matchUrl('128.120.120.0', rule)).to.be.false; + expect(matchUrl('127.0.0.0', rule)).to.be.true; + expect(matchUrl('127.120.120.0', rule)).to.be.true; + + rule = 'google.com:81'; + + expect(matchUrl('google.com', rule)).to.be.false; + expect(matchUrl('google.com:80', rule)).to.be.false; + expect(matchUrl('google.com:81', rule)).to.be.true; + + rule = 'google.:81'; + + expect(matchUrl('google.com', rule)).to.be.false; + expect(matchUrl('google.com:80', rule)).to.be.false; + expect(matchUrl('google.com:81', rule)).to.be.true; + + rule = 'localhost:3000'; + + expect(matchUrl('localhost:3000/features/functional/local', rule)).to.be.true; + expect(matchUrl('http://localhost:3000/features/functional/local', rule)).to.be.true; + expect(matchUrl(null, rule)).to.be.false; + + rule = 1; + + expect(matchUrl('google', rule)).to.be.false; +}); diff --git a/test/server/runner-test.js b/test/server/runner-test.js index 1d423d300a0..a2bdcae0b7d 100644 --- a/test/server/runner-test.js +++ b/test/server/runner-test.js @@ -497,58 +497,64 @@ describe('Runner', function () { }); it('Should raise an error if speed option has wrong value', function () { - var incorrectSpeedErrorMessage = 'Speed should be a number between 0.01 and 1.'; + var exceptionCount = 0; + + var incorrectSpeedError = function (speed) { + return runner + .run({ speed }) + .catch(function (err) { + exceptionCount++; + expect(err.message).eql('Speed should be a number between 0.01 and 1.'); + }); + }; - return testCafe - .createBrowserConnection() - .then(function (browserConnection) { - return runner - .browsers(browserConnection) - .run({ speed: 'yo' }); - }) - .catch(function (err) { - expect(err.message).eql(incorrectSpeedErrorMessage); - }) - .then(function () { - return runner.run({ speed: -0.01 }); - }).catch(function (err) { - expect(err.message).eql(incorrectSpeedErrorMessage); - }) - .then(function () { - return runner.run({ speed: 1.01 }); - }).catch(function (err) { - expect(err.message).eql(incorrectSpeedErrorMessage); - }); + return Promise.resolve() + .then(() => incorrectSpeedError('yo')) + .then(() => incorrectSpeedError(-0.01)) + .then(() => incorrectSpeedError(0)) + .then(() => incorrectSpeedError(1.01)) + .then(() => expect(exceptionCount).to.be.eql(4)); }); it('Should raise an error if concurrency option has wrong value', function () { - var incorrectConcurrencyFactorErrorMessage = 'The concurrency factor should be an integer greater or equal to 1.'; + var exceptionCount = 0; + + var incorrectConcurrencyFactorError = function (concurrency) { + return runner + .concurrency(concurrency) + .run() + .catch(function (err) { + exceptionCount++; + expect(err.message).eql('The concurrency factor should be an integer greater or equal to 1.'); + }); + }; - return testCafe - .createBrowserConnection() - .then(function (browserConnection) { - return runner - .browsers(browserConnection) - .concurrency('yo'); - }) - .catch(function (err) { - expect(err.message).eql(incorrectConcurrencyFactorErrorMessage); - }) - .then(function () { - return runner.concurrency(-1); - }).catch(function (err) { - expect(err.message).eql(incorrectConcurrencyFactorErrorMessage); - }) - .then(function () { - return runner.concurrency(0.1); - }).catch(function (err) { - expect(err.message).eql(incorrectConcurrencyFactorErrorMessage); - }) - .then(function () { - return runner.concurrency(0); - }).catch(function (err) { - expect(err.message).eql(incorrectConcurrencyFactorErrorMessage); - }); + return Promise.resolve() + .then(() => incorrectConcurrencyFactorError('yo')) + .then(() => incorrectConcurrencyFactorError(-1)) + .then(() => incorrectConcurrencyFactorError(0.1)) + .then(() => incorrectConcurrencyFactorError(0)) + .then(() => expect(exceptionCount).to.be.eql(4)); + }); + + it('Should raise an error if proxyBypass option has wrong type', function () { + var exceptionCount = 0; + + var expectProxyBypassError = function (proxyBypass, type) { + runner.opts.proxyBypass = proxyBypass; + + return runner + .run() + .catch(function (err) { + exceptionCount++; + expect(err.message).contains('"proxyBypass" argument is expected to be a string, but it was ' + type); + }); + }; + + return expectProxyBypassError(1, 'number') + .then(() => expectProxyBypassError({}, 'object')) + .then(() => expectProxyBypassError(true, 'bool')) + .then(() => expect(exceptionCount).to.be.eql(3)); }); });