From a57069f7219305c4f135544a3d66980d178c4c78 Mon Sep 17 00:00:00 2001 From: Sergey Tatarintsev Date: Thu, 14 May 2015 18:01:52 +0300 Subject: [PATCH] Allow to use Sizzle for browsers without CSS3 Browsers without CSS3 selectors support will use Sizzle selectors library. Ability to use CSS3 selectors is determined during calibration. If calibration is disabled, it is assumed that browser has CSS3 selectors. findElement method of browser will be also implemented with Sizzle if CSS3-selectors are not available, so it will be possible to use such selector as both actions and capture/ignore targets. ClientBridge class added which encapsulates all inject/eval client script logic previously found in browser. --- lib/browser/client-bridge.js | 82 +++++++++++++ .../client-scripts/gemini.calibrate.js | 38 ++++-- lib/browser/client-scripts/gemini.coverage.js | 7 +- lib/browser/client-scripts/gemini.js | 7 +- lib/browser/client-scripts/query.native.js | 9 ++ lib/browser/client-scripts/query.sizzle.js | 12 ++ lib/browser/index.js | 92 ++++++++------ lib/calibrator.js | 17 +-- package.json | 2 + test/browser.test.js | 114 +++++++++++------- test/calibrator.test.js | 11 +- test/client-bridge.test.js | 109 +++++++++++++++++ test/suite-util.test.js | 11 +- test/util.js | 10 ++ 14 files changed, 406 insertions(+), 115 deletions(-) create mode 100644 lib/browser/client-bridge.js create mode 100644 lib/browser/client-scripts/query.native.js create mode 100644 lib/browser/client-scripts/query.sizzle.js create mode 100644 test/client-bridge.test.js create mode 100644 test/util.js diff --git a/lib/browser/client-bridge.js b/lib/browser/client-bridge.js new file mode 100644 index 000000000..679e5406b --- /dev/null +++ b/lib/browser/client-bridge.js @@ -0,0 +1,82 @@ +'use strict'; + +var util = require('util'), + q = require('q'), + + StateError = require('../errors/state-error'), + + NO_CLIENT_FUNC = 'ERRNOFUNC'; + +/** + * @param {Browser} browser + * @param {String} script + * @constructor + */ +function ClientBridge(browser, script) { + this._browser = browser; + this._script = script; +} + +/** + * @param {String} name + * @param {Array} [args] + */ +ClientBridge.prototype.call = function(name, args) { + args = args || []; + return this._callCommand(this._clientMethodCommand(name, args), true); +}; + +/** + * @param {String} command + * @param {Boolean} shouldInject + */ +ClientBridge.prototype._callCommand = function(command, injectAllowed) { + var _this = this; + return this._browser.evalScript(command) + .then(function(result) { + if (!result || !result.error) { + return q.resolve(result); + } + + if (result.error !== NO_CLIENT_FUNC) { + return q.reject(new StateError(result.message)); + } + + if (injectAllowed) { + return _this._inject() + .then(function() { + return _this._callCommand(command, false); + }); + } + return q.reject(new StateError('Unable to inject gemini client script')); + }); +}; + +/** + * @param {String} name + * @param {Array} args + */ +ClientBridge.prototype._clientMethodCommand = function(name, args) { + var call = util.format('__gemini.%s(%s)', + name, + args.map(JSON.stringify).join(', ') + ); + return this._guardClientCall(call); +}; + +/** + * @param {String} call + */ +ClientBridge.prototype._guardClientCall = function(call) { + return util.format( + 'typeof __gemini !== "undefined"? %s : {error: "%s"})', + call, + NO_CLIENT_FUNC + ); +}; + +ClientBridge.prototype._inject = function() { + return this._browser.evalScript(this._script); +}; + +module.exports = ClientBridge; diff --git a/lib/browser/client-scripts/gemini.calibrate.js b/lib/browser/client-scripts/gemini.calibrate.js index b5c5d6c18..548068c32 100644 --- a/lib/browser/client-scripts/gemini.calibrate.js +++ b/lib/browser/client-scripts/gemini.calibrate.js @@ -23,16 +23,34 @@ document.body.appendChild(div); } - var bodyStyle = document.body.style; - bodyStyle.margin = 0; - bodyStyle.padding = 0; + function createPattern() { + var bodyStyle = document.body.style; + bodyStyle.margin = 0; + bodyStyle.padding = 0; - if (needsResetBorder()) { - bodyStyle.border = 0; + if (needsResetBorder()) { + bodyStyle.border = 0; + } + bodyStyle.backgroundColor = '#00ff00'; + bodyStyle.width = '100%'; + bodyStyle.height = '100%'; + createRedStripe('left'); + createRedStripe('right'); } - bodyStyle.backgroundColor = '#00ff00'; - bodyStyle.width = '100%'; - bodyStyle.height = '100%'; - createRedStripe('left'); - createRedStripe('right'); + + function getBrowserFeatures() { + var features = { + hasCSS3Selectors: true + }; + try { + document.querySelector('body:nth-child(1)'); + } catch (e) { + features.hasCSS3Selectors = false; + } + + return features; + } + + createPattern(); + return getBrowserFeatures(); }(window)); diff --git a/lib/browser/client-scripts/gemini.coverage.js b/lib/browser/client-scripts/gemini.coverage.js index 7fff2054b..59a4e2efe 100644 --- a/lib/browser/client-scripts/gemini.coverage.js +++ b/lib/browser/client-scripts/gemini.coverage.js @@ -1,7 +1,8 @@ 'use strict'; var util = require('./util'), - rect = require('./rect'); + rect = require('./rect'), + query = require('./query'); exports.collectCoverage = function collectCoverage(rect) { var coverage = {}, @@ -66,14 +67,14 @@ function coverageForRule(rule, area, ctx) { util.each(rule.selectorText.split(','), function(selector) { var within, - matches = document.querySelectorAll(selector); + matches = query.all(selector); selector = selector.trim(); var re = /:{1,2}(?:after|before|first-letter|first-line|selection)(:{1,2}\w+)?$/; // if selector contains pseudo-elements cut it off and try to find element without it if (matches.length === 0 && re.test(selector)) { - matches = document.querySelectorAll(selector.replace(re, '$1')); + matches = query.all(selector.replace(re, '$1')); } if (matches.length > 0) { diff --git a/lib/browser/client-scripts/gemini.js b/lib/browser/client-scripts/gemini.js index 86b5f40c0..7db2a4846 100644 --- a/lib/browser/client-scripts/gemini.js +++ b/lib/browser/client-scripts/gemini.js @@ -3,10 +3,13 @@ var util = require('./util'), rect = require('./rect'), + query = require('./query'), Rect = rect.Rect; global.__gemini = exports; +exports.query = query; + // Terminology // - clientRect - the result of calling getBoundingClientRect of the element // - extRect - clientRect + outline + box shadow @@ -69,7 +72,7 @@ function prepareScreenshotUnsafe(selectors, opts) { function getCaptureRect(selectors) { var element, elementRect, rect; for (var i = 0; i < selectors.length; i++) { - element = document.querySelector(selectors[i]); + element = query.first(selectors[i]); if (!element) { return { error: 'NOTFOUND', @@ -89,7 +92,7 @@ function getCaptureRect(selectors) { function findIgnoreAreas(selectors) { var result = []; util.each(selectors, function(selector) { - var element = document.querySelector(selector); + var element = query.first(selector); if (element) { result.push(getElementCaptureRect(element).round().serialize()); } diff --git a/lib/browser/client-scripts/query.native.js b/lib/browser/client-scripts/query.native.js new file mode 100644 index 000000000..f41e8afdf --- /dev/null +++ b/lib/browser/client-scripts/query.native.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.first = function(selector) { + return document.querySelector(selector); +}; + +exports.all = function(selector) { + return document.querySelectorAll(selector); +}; diff --git a/lib/browser/client-scripts/query.sizzle.js b/lib/browser/client-scripts/query.sizzle.js new file mode 100644 index 000000000..9898976d8 --- /dev/null +++ b/lib/browser/client-scripts/query.sizzle.js @@ -0,0 +1,12 @@ +'use strict'; +/*jshint newcap:false*/ +var Sizzle = require('sizzle'); + +exports.first = function(selector) { + var elems = Sizzle(selector + ':first'); + return elems.length > 0? elems[0] : null; +}; + +exports.all = function(selector) { + return Sizzle(selector); +}; diff --git a/lib/browser/index.js b/lib/browser/index.js index fd8408656..2879796ef 100644 --- a/lib/browser/index.js +++ b/lib/browser/index.js @@ -12,10 +12,11 @@ var path = require('path'), Image = require('../image'), Actions = require('./actions'), - GeminiError = require('../errors/gemini-error'), - StateError = require('../errors/state-error'); + ClientBridge = require('./client-bridge'), -module.exports = inherit({ + GeminiError = require('../errors/gemini-error'); + +var Browser = inherit({ __constructor: function(config, id) { this.config = config; this._capabilities = config.browsers[id]; @@ -81,6 +82,9 @@ module.exports = inherit({ .then(function() { return _this.buildScripts(); }) + .then(function() { + return _this.chooseLocator(); + }) .fail(function(e) { if (e.code === 'ECONNREFUSED') { return q.reject(new GeminiError( @@ -132,12 +136,9 @@ module.exports = inherit({ return this._browser.get(url); }, - injectScripts: function() { - return this.inject(this._scripts); - }, - - inject: function(script) { - return this._browser.execute(script); + evalScript: function(script) { + /*jshint evil:true*/ + return this._browser.eval(script); }, buildScripts: function() { @@ -151,16 +152,32 @@ module.exports = inherit({ } script.transform({sourcemap: false, global: true}, 'uglifyify'); + var queryLib = this._needsSizzle? './query.sizzle.js' : './query.native.js'; + script.transform({ + aliases: { + './query': {relative: queryLib} + }, + verbose: false + }, 'aliasify'); var _this = this; return q.nfcall(script.bundle.bind(script)) .then(function(buf) { - _this._scripts = _this._polyfill + '\n' + buf.toString(); - return _this._scripts; + var scripts = _this._polyfill + '\n' + buf.toString(); + _this._clientBridge = new ClientBridge(_this, scripts); + return scripts; }); }, + get _needsSizzle() { + return this._calibration && !this._calibration.hasCSS3Selectors; + }, + + chooseLocator: function() { + this.findElement = this._needsSizzle? this._findElementScript : this._findElementWd; + }, + reset: function() { var _this = this; return this.findElement('body') @@ -193,49 +210,40 @@ module.exports = inherit({ }); }, - _findElements: function(selectorsList) { - var _this = this; - return q.all(selectorsList.map(function(selector) { - return _this.findElement(selector, true); - })); + findElement: function(selector) { + throw new Error('findElement is called before appropriate locator is chosen'); }, - findElement: function(selector) { + _findElementWd: function(selector) { return this._browser.elementByCssSelector(selector) - .then(function(wdElement) { - return wdElement; - }) .fail(function(error) { - if (error.status === 7) { + if (error.status === Browser.ELEMENT_NOT_FOUND) { error.selector = selector; } return q.reject(error); }); }, - prepareScreenshot: function(selectors, opts) { - /*jshint evil:true*/ - var _this = this; - opts = opts || {}; - return this._browser.eval(this._prepareScreenshotCommand(selectors, opts)) - .then(function(data) { - if (data.error) { - if (data.error !== 'ERRNOFUNC') { - return q.reject(new StateError(data.message)); - } - - return _this.injectScripts() - .then(function() { - return _this.prepareScreenshot(selectors, opts); - }); + _findElementScript: function(selector) { + return this._clientBridge.call('query.first', [selector]) + .then(function(element) { + if (element) { + return element; } - return q.resolve(data); + + var error = new Error('Unable to find element'); + error.status = Browser.ELEMENT_NOT_FOUND; + error.selector = selector; + return q.reject(error); }); }, - _prepareScreenshotCommand: function(selectors, opts) { - return 'typeof(__gemini) !== "undefined"? __gemini.prepareScreenshot(' + JSON.stringify(selectors) + ', ' + - JSON.stringify(opts) + ') : {error: "ERRNOFUNC"}'; + prepareScreenshot: function(selectors, opts) { + opts = opts || {}; + return this._clientBridge.call('prepareScreenshot', [ + selectors, + opts + ]); }, captureFullscreenImage: function() { @@ -264,4 +272,8 @@ module.exports = inherit({ return new Actions(this); } +}, { + ELEMENT_NOT_FOUND: 7 }); + +module.exports = Browser; diff --git a/lib/calibrator.js b/lib/calibrator.js index 6e530629d..d34752963 100644 --- a/lib/calibrator.js +++ b/lib/calibrator.js @@ -2,6 +2,7 @@ var q = require('q'), fs = require('fs'), path = require('path'), + _ = require('lodash'), Image = require('./image'), @@ -26,12 +27,12 @@ Calibrator.prototype.calibrate = function(browser) { } return browser.open('about:blank') .then(function() { - return browser.inject(clientScriptCalibrate); + return browser.evalScript(clientScriptCalibrate); }) - .then(function() { - return browser.captureFullscreenImage(); + .then(function(features) { + return [features, browser.captureFullscreenImage()]; }) - .then(function(image) { + .spread(function(features, image) { var find = ['#00ff00', '#00ff00', '#00ff00', '#ff0000']; /** @@ -70,13 +71,13 @@ Calibrator.prototype.calibrate = function(browser) { )); } - var calibration = { + _.extend(features, { top: start.pos.y, left: start.pos.x - }; + }); - _this._cache[browser.id] = calibration; - return calibration; + _this._cache[browser.id] = features; + return features; }); }; diff --git a/package.json b/package.json index 784cb5fbe..879c9f6fe 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "main": "lib/public-api.js", "dependencies": { + "aliasify": "^1.7.2", "browserify": "^9.0.4", "chalk": "^0.5.1", "coa": "~0.4.0", @@ -24,6 +25,7 @@ "q-io": "~1.11.0", "qemitter": "^1.0.0", "resolve": "^1.1.0", + "sizzle": "^2.2.0", "source-map": "^0.2.0", "temp": "~0.8.0", "uglifyify": "^3.0.1", diff --git a/test/browser.test.js b/test/browser.test.js index 560390c5e..28d222b79 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -1,18 +1,14 @@ 'use strict'; -var Browser = require('../lib/browser'), - Calibrator = require('../lib/calibrator'), +var Calibrator = require('../lib/calibrator'), + Browser = require('../lib/browser'), + ClientBridge = require('../lib/browser/client-bridge'), assert = require('chai').assert, q = require('q'), wd = require('wd'), fs = require('fs'), path = require('path'), - sinon = require('sinon'); - -function makeBrowser(capabilities, config) { - config = config || {}; - config.browsers = {id: capabilities}; - return new Browser(config, 'id'); -} + sinon = require('sinon'), + makeBrowser = require('./util').makeBrowser; describe('browser', function() { beforeEach(function() { @@ -149,8 +145,6 @@ describe('browser', function() { assert.calledWith(_this.wd.get, 'http://www.example.com'); }); }); - - it('should execute client script'); }); describe('reset', function() { @@ -163,6 +157,7 @@ describe('browser', function() { this.sinon.stub(wd, 'promiseRemote').returns(this.wd); this.browser = makeBrowser({browserName: 'browser', version: '1.0'}); + this.browser.chooseLocator(); }); it('should reset mouse position', function() { @@ -176,36 +171,6 @@ describe('browser', function() { }); }); - describe('prepareScreenshot', function() { - beforeEach(function() { - this.wd = { - eval: sinon.stub().returns(q({})) - }; - this.sinon.stub(wd, 'promiseRemote').returns(this.wd); - - this.browser = makeBrowser({browserName: 'browser', version: '1.0'}); - }); - - it('should execute client side method', function() { - var _this = this; - return this.browser.prepareScreenshot(['.selector1', '.selector2'], {}).then(function() { - /*jshint evil:true*/ - assert.called(_this.wd.eval); - }); - }); - - it('should reject promise if client-side method returned error', function() { - /*jshint evil:true*/ - this.wd.eval.returns(q({ - error: 'err', - message: 'message' - })); - - var result = this.browser.prepareScreenshot(['.selector']); - return assert.isRejected(result, /^message$/); - }); - }); - describe('captureFullscreenImage', function() { it('should call to the driver', function() { var img = path.join(__dirname, 'functional', 'data', 'image', 'image1.png'), @@ -269,4 +234,71 @@ describe('browser', function() { return assert.eventually.notInclude(scripts, 'exports.collectCoverage'); }); }); + + describe('findElement', function() { + beforeEach(function() { + this.wd = { + configureHttp: sinon.stub().returns(q()), + init: sinon.stub().returns(q({})), + get: sinon.stub().returns(q({})), + eval: sinon.stub().returns(q('')), + elementByCssSelector: sinon.stub().returns(q()) + }; + this.sinon.stub(wd, 'promiseRemote').returns(this.wd); + this.browser = makeBrowser(); + }); + + describe('when browser supports CSS3 selectors', function() { + beforeEach(function() { + var calibrator = sinon.createStubInstance(Calibrator); + calibrator.calibrate.returns(q({ + hasCSS3Selectors: true + })); + return this.browser.launch(calibrator); + }); + + it('should return what wd.elementByCssSelector returns', function() { + var element = {element: 'elem'}; + this.wd.elementByCssSelector.withArgs('.class').returns(q(element)); + return assert.eventually.equal(this.browser.findElement('.class'), element); + }); + + it('should add a selector property if element is not found', function() { + var error = new Error('Element not found'); + error.status = Browser.ELEMENT_NOT_FOUND; + this.wd.elementByCssSelector.returns(q.reject(error)); + + return assert.isRejected(this.browser.findElement('.class')) + .then(function(error) { + assert.equal(error.selector, '.class'); + }); + }); + }); + + describe('when browser does not support CSS3 selectors', function() { + beforeEach(function() { + this.sinon.stub(ClientBridge.prototype, 'call').returns(q({})); + var calibrator = sinon.createStubInstance(Calibrator); + calibrator.calibrate.returns(q({ + hasCSS3Selectors: false + })); + return this.browser.launch(calibrator); + }); + + it('should return what client method returns', function() { + var element = {element: 'elem'}; + ClientBridge.prototype.call.withArgs('query.first', ['.class']).returns(q(element)); + return assert.eventually.equal(this.browser.findElement('.class'), element); + }); + + it('should reject with element not found error if client method returns null', function() { + ClientBridge.prototype.call.returns(q(null)); + return assert.isRejected(this.browser.findElement('.class')) + .then(function(error) { + assert.equal(error.status, Browser.ELEMENT_NOT_FOUND); + assert.equal(error.selector, '.class'); + }); + }); + }); + }); }); diff --git a/test/calibrator.test.js b/test/calibrator.test.js index 1186ecabb..fe27c1513 100644 --- a/test/calibrator.test.js +++ b/test/calibrator.test.js @@ -29,7 +29,7 @@ describe('calibrator', function() { }; browser = new Browser(config, 'id'); sinon.stub(browser); - browser.inject.returns(q()); + browser.evalScript.returns(q({})); browser.open.returns(q()); calibrator = new Calibrator(); }); @@ -40,6 +40,13 @@ describe('calibrator', function() { return assert.eventually.deepEqual(result, {top: 24, left: 6}); }); + it('should return also features detected by script', function() { + setScreenshot('calibrate.png'); + browser.evalScript.returns(q({feature: 'value'})); + var result = calibrator.calibrate(browser); + return assert.eventually.propertyVal(result, 'feature', 'value'); + }); + it('should not perform the calibration process two times', function() { setScreenshot('calibrate.png'); return calibrator.calibrate(browser) @@ -48,7 +55,7 @@ describe('calibrator', function() { }) .then(function() { assert.calledOnce(browser.open); - assert.calledOnce(browser.inject); + assert.calledOnce(browser.evalScript); assert.calledOnce(browser.captureFullscreenImage); }); }); diff --git a/test/client-bridge.test.js b/test/client-bridge.test.js new file mode 100644 index 000000000..d5633a386 --- /dev/null +++ b/test/client-bridge.test.js @@ -0,0 +1,109 @@ +'use strict'; +var q = require('q'), + ClientBridge = require('../lib/browser/client-bridge'), + StateError = require('../lib/errors/state-error'), + assert = require('chai').assert, + sinon = require('sinon'), + + makeBrowser = require('./util').makeBrowser, + CALL_SCRIPT = 'typeof __gemini !== "undefined"? __gemini.example(1, "two") : {error: "ERRNOFUNC"})'; + +describe('ClientBridge', function() { + beforeEach(function() { + this.browser = sinon.stub(makeBrowser()); + this.browser.evalScript.returns(q({})); + this.script = 'exampleScript()'; + this.bridge = new ClientBridge(this.browser, this.script); + }); + + describe('call', function() { + it('should try to call a method on __gemini namespace', function() { + var _this = this; + return this.bridge.call('example', [1, 'two']) + .then(function() { + assert.calledWith( + _this.browser.evalScript, + CALL_SCRIPT + ); + }); + }); + + it('should allow to not specify the arguments', function() { + var _this = this; + return this.bridge.call('example') + .then(function() { + assert.calledWith( + _this.browser.evalScript, + sinon.match('__gemini.example()') + ); + }); + }); + + it('should return what evalScript returns if succeeded', function() { + this.browser.evalScript.returns(q('result')); + return assert.becomes(this.bridge.call('example'), 'result'); + }); + + it('should reject if evalScript returns unexpected error', function() { + var message = 'Something happened'; + this.browser.evalScript.returns(q({error: message})); + var result = this.bridge.call('fail'); + return assert.isRejected(result, StateError, message); + }); + + it('should not attempt to call eval second if it return with unexpected error', function() { + var _this = this; + this.browser.evalScript.returns(q({error: 'unexpected'})); + return assert.isRejected(this.bridge.call('fail')) + .then(function() { + assert.calledOnce(_this.browser.evalScript); + }); + }); + + describe('if scripts were not injected', function() { + beforeEach(function() { + this.setupAsNonInjected = function(finalResult) { + this.browser.evalScript + .onFirstCall().returns(q({error: 'ERRNOFUNC'})) + .onThirdCall().returns(q(finalResult)); + + this.browser.evalScript + .withArgs(this.script) + .returns(q()); + }; + + this.performCall = function() { + return this.bridge.call('example'); + }; + }); + + it('should try to inject scripts', function() { + this.setupAsNonInjected(); + var _this = this; + return this.performCall() + .then(function() { + assert.calledWith(_this.browser.evalScript, _this.script); + }); + }); + + it('should try to eval again after inject', function() { + this.setupAsNonInjected(); + var _this = this; + return this.bridge.call('example', [1, 'two']) + .then(function() { + assert.deepEqual(_this.browser.evalScript.thirdCall.args, [CALL_SCRIPT]); + }); + }); + + it('should return result of the succesfull call', function() { + this.setupAsNonInjected('success'); + return assert.becomes(this.performCall(), 'success'); + }); + + it('should fail if scripts failed to inject', function() { + this.setupAsNonInjected({error: 'ERRNOFUNC'}); + return assert.isRejected(this.performCall(), StateError); + }); + }); + }); +}); diff --git a/test/suite-util.test.js b/test/suite-util.test.js index b649d7e0b..acffa0df4 100644 --- a/test/suite-util.test.js +++ b/test/suite-util.test.js @@ -2,21 +2,14 @@ var _ = require('lodash'), assert = require('chai').assert, - Browser = require('../lib/browser'), suiteUtil = require('../lib/suite-util'), shouldSkip = suiteUtil.shouldSkip, - flattenSuites = suiteUtil.flattenSuites; + flattenSuites = suiteUtil.flattenSuites, + makeBrowser = require('./util').makeBrowser; describe('suite-util', function() { describe('shouldSkip()', function() { - function makeBrowser(capabilities) { - var config = { - browsers: {id: capabilities} - }; - return new Browser(config, 'id'); - } - it('should not skip any browser if skipped=false', function() { assert.isFalse(shouldSkip(false, makeBrowser({browserName: 'browser'}))); }); diff --git a/test/util.js b/test/util.js new file mode 100644 index 000000000..d5b577b8d --- /dev/null +++ b/test/util.js @@ -0,0 +1,10 @@ +'use strict'; +var Browser = require('../lib/browser'); + +function makeBrowser(capabilities, config) { + config = config || {}; + config.browsers = {id: capabilities || {}}; + return new Browser(config, 'id'); +} + +exports.makeBrowser = makeBrowser;