From 3fbdcb73ae4a5fde0c7110edc37721639105ade1 Mon Sep 17 00:00:00 2001 From: cibernox Date: Mon, 9 Nov 2015 17:28:27 +0000 Subject: [PATCH] [FEATURE canary] Fire native events instead of jquery events Goal: To fire events in relatively accurate and cross-browser way. Simulate real events in pure javascript accurately is nearly impossible, but this is a best effort. This fires 3 different kind of events: - MouseEvents: click, dbclick, mousedown, mouseup, mouseenter, mouseleave, ... - KeyEvents: keydown, keypress and keyup - Events: Anything else. This should probably cover 99% of use cases. --- FEATURES.md | 5 + features.json | 1 + packages/ember-testing/lib/helpers.js | 180 +++++++++++++++---- packages/ember-testing/tests/helpers_test.js | 53 +++++- 4 files changed, 200 insertions(+), 39 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index c262888c3f5..6a5c39ea5ba 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -59,3 +59,8 @@ for a detailed explanation. When the proper API's are implemented by the resolver in use this feature allows `{{x-foo}}` in a given routes template (say the `post` route) to lookup a component nested under `post`. + +* `ember-test-helpers-fire-native-events` + + Makes ember test helpers (`fillIn`, `click`, `triggerEvent` ...) fire native javascript events instead + of `jQuery.Event`s, maching more closely app's real usage. \ No newline at end of file diff --git a/features.json b/features.json index a07b5837dc6..26a6576d9e9 100644 --- a/features.json +++ b/features.json @@ -3,6 +3,7 @@ "features-stripped-test": null, "ember-htmlbars-component-generation": false, "ember-application-visit": true, + "ember-test-helpers-fire-native-events": true, "ember-routing-route-configured-query-params": null, "ember-libraries-isregistered": null, "ember-debug-handlers": true, diff --git a/packages/ember-testing/lib/helpers.js b/packages/ember-testing/lib/helpers.js index 8f09185426f..a1b605f7dc0 100644 --- a/packages/ember-testing/lib/helpers.js +++ b/packages/ember-testing/lib/helpers.js @@ -4,6 +4,7 @@ import run from 'ember-metal/run_loop'; import jQuery from 'ember-views/system/jquery'; import Test from 'ember-testing/test'; import RSVP from 'ember-runtime/ext/rsvp'; +import isEnabled from 'ember-metal/features'; /** @module ember @@ -13,6 +14,141 @@ import RSVP from 'ember-runtime/ext/rsvp'; var helper = Test.registerHelper; var asyncHelper = Test.registerAsyncHelper; +var keyboardEventTypes, mouseEventTypes, buildKeyboardEvent, buildMouseEvent, buildBasicEvent, fireEvent, focus; + +if (isEnabled('ember-test-helpers-fire-native-events')) { + let defaultEventOptions = { canBubble: true, cancelable: true }; + keyboardEventTypes = ['keydown', 'keypress', 'keyup']; + mouseEventTypes = ['click', 'mousedown', 'mouseup', 'dblclick', 'mousenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover']; + + + buildKeyboardEvent = function buildKeyboardEvent(type, options = {}) { + let event; + try { + event = document.createEvent('KeyEvents'); + let eventOpts = jQuery.extend({}, defaultEventOptions, options); + event.initKeyEvent( + type, + eventOpts.canBubble, + eventOpts.cancelable, + window, + eventOpts.ctrlKey, + eventOpts.altKey, + eventOpts.shiftKey, + eventOpts.metaKey, + eventOpts.keyCode, + eventOpts.charCode + ); + } catch (e) { + event = buildBasicEvent(type, options); + } + return event; + }; + + buildMouseEvent = function buildMouseEvent(type, options = {}) { + let event; + try { + event = document.createEvent('MouseEvents'); + let eventOpts = jQuery.extend({}, defaultEventOptions, options); + event.initMouseEvent( + type, + eventOpts.canBubble, + eventOpts.cancelable, + window, + eventOpts.detail, + eventOpts.screenX, + eventOpts.screenY, + eventOpts.clientX, + eventOpts.clientY, + eventOpts.ctrlKey, + eventOpts.altKey, + eventOpts.shiftKey, + eventOpts.metaKey, + eventOpts.button, + eventOpts.relatedTarget); + } catch (e) { + event = buildBasicEvent(type, options); + } + return event; + }; + + buildBasicEvent = function buildBasicEvent(type, options = {}) { + let event = document.createEvent('Events'); + event.initEvent(type, true, true); + jQuery.extend(event, options); + return event; + }; + + fireEvent = function fireEvent(element, type, options = {}) { + if (!element) { + return; + } + let event; + if (keyboardEventTypes.indexOf(type) > -1) { + event = buildKeyboardEvent(type, options); + } else if (mouseEventTypes.indexOf(type) > -1) { + let rect = element.getBoundingClientRect(); + let x = rect.left + 1; + let y = rect.top + 1; + let simulatedCoordinates = { + screenX: x + 5, + screenY: y + 95, + clientX: x, + clientY: y + }; + event = buildMouseEvent(type, jQuery.extend(simulatedCoordinates, options)); + } else { + event = buildBasicEvent(type, options); + } + element.dispatchEvent(event); + }; + + focus = function focus(el) { + if (!el) { return; } + let $el = jQuery(el); + if ($el.is(':input, [contenteditable=true]')) { + let type = $el.prop('type'); + if (type !== 'checkbox' && type !== 'radio' && type !== 'hidden') { + run(null, function() { + // Firefox does not trigger the `focusin` event if the window + // does not have focus. If the document doesn't have focus just + // use trigger('focusin') instead. + + if (!document.hasFocus || document.hasFocus()) { + el.focus(); + } else { + $el.trigger('focusin'); + } + }); + } + } + }; +} else { + focus = function focus(el) { + if (el && el.is(':input, [contenteditable=true]')) { + var type = el.prop('type'); + if (type !== 'checkbox' && type !== 'radio' && type !== 'hidden') { + run(el, function() { + // Firefox does not trigger the `focusin` event if the window + // does not have focus. If the document doesn't have focus just + // use trigger('focusin') instead. + if (!document.hasFocus || document.hasFocus()) { + this.focus(); + } else { + this.trigger('focusin'); + } + }); + } + } + }; + + fireEvent = function fireEvent(element, type, options) { + var event = jQuery.Event(type, options); + jQuery(element).trigger(event); + }; +} + + function currentRouteName(app) { var routingService = app.__container__.lookup('service:-routing'); @@ -36,24 +172,6 @@ function pauseTest() { return new RSVP.Promise(function() { }, 'TestAdapter paused promise'); } -function focus(el) { - if (el && el.is(':input, [contenteditable=true]')) { - var type = el.prop('type'); - if (type !== 'checkbox' && type !== 'radio' && type !== 'hidden') { - run(el, function() { - // Firefox does not trigger the `focusin` event if the window - // does not have focus. If the document doesn't have focus just - // use trigger('focusin') instead. - if (!document.hasFocus || document.hasFocus()) { - this.focus(); - } else { - this.trigger('focusin'); - } - }); - } - } -} - function visit(app, url) { var router = app.__container__.lookup('router:main'); var shouldHandleURL = false; @@ -78,13 +196,15 @@ function visit(app, url) { } function click(app, selector, context) { - var $el = app.testHelpers.findWithAssert(selector, context); - run($el, 'mousedown'); + let $el = app.testHelpers.findWithAssert(selector, context); + let el = $el[0]; - focus($el); + run(null, fireEvent, el, 'mousedown'); - run($el, 'mouseup'); - run($el, 'click'); + focus(el); + + run(null, fireEvent, el, 'mouseup'); + run(null, fireEvent, el, 'click'); return app.testHelpers.wait(); } @@ -119,10 +239,9 @@ function triggerEvent(app, selector, contextOrType, typeOrOptions, possibleOptio } var $el = app.testHelpers.findWithAssert(selector, context); + var el = $el[0]; - var event = jQuery.Event(type, options); - - run($el, 'trigger', event); + run(null, fireEvent, el, type, options); return app.testHelpers.wait(); } @@ -143,18 +262,19 @@ function keyEvent(app, selector, contextOrType, typeOrKeyCode, keyCode) { } function fillIn(app, selector, contextOrText, text) { - var $el, context; + var $el, el, context; if (typeof text === 'undefined') { text = contextOrText; } else { context = contextOrText; } $el = app.testHelpers.findWithAssert(selector, context); - focus($el); + el = $el[0]; + focus(el); run(function() { $el.val(text); - $el.trigger('input'); - $el.change(); + fireEvent(el, 'input'); + fireEvent(el, 'change'); }); return app.testHelpers.wait(); } diff --git a/packages/ember-testing/tests/helpers_test.js b/packages/ember-testing/tests/helpers_test.js index c3efeb9e469..f044a091616 100644 --- a/packages/ember-testing/tests/helpers_test.js +++ b/packages/ember-testing/tests/helpers_test.js @@ -395,8 +395,6 @@ QUnit.test('`click` triggers appropriate events in order', function() { ['mousedown', 'focusin', 'mouseup', 'click'], 'fires focus events on contenteditable'); }).then(function() { - // In IE (< 8), the change event only fires when the value changes before element focused. - jQuery('.index-view input[type=checkbox]').focus(); events = []; return click('.index-view input[type=checkbox]'); }).then(function() { @@ -407,6 +405,44 @@ QUnit.test('`click` triggers appropriate events in order', function() { }); }); +QUnit.test('`click` triggers native events with simulated X/Y coordinates', function() { + expect(15); + + var click, wait, events; + + App.IndexView = EmberView.extend({ + classNames: 'index-view', + + didInsertElement() { + let pushEvent = e => events.push(e); + this.element.addEventListener('mousedown', pushEvent); + this.element.addEventListener('mouseup', pushEvent); + this.element.addEventListener('click', pushEvent); + } + }); + + + Ember.TEMPLATES.index = compile('some text'); + + run(App, App.advanceReadiness); + + click = App.testHelpers.click; + wait = App.testHelpers.wait; + + return wait().then(function() { + events = []; + return click('.index-view'); + }).then(function() { + events.forEach(e => { + ok(e instanceof window.Event, 'The event is an instance of MouseEvent'); + ok(typeof e.screenX === 'number' && e.screenX > 0, 'screenX is correct'); + ok(typeof e.screenY === 'number' && e.screenY > 0, 'screenY is correct'); + ok(typeof e.clientX === 'number' && e.clientX > 0, 'clientX is correct'); + ok(typeof e.clientY === 'number' && e.clientY > 0, 'clientY is correct'); + }); + }); +}); + QUnit.test('`wait` waits for outstanding timers', function() { expect(1); @@ -423,7 +459,6 @@ QUnit.test('`wait` waits for outstanding timers', function() { }); }); - QUnit.test('`wait` respects registerWaiters with optional context', function() { expect(3); @@ -470,7 +505,7 @@ QUnit.test('`triggerEvent accepts an optional options hash without context', fun template: compile('{{input type="text" id="scope" class="input"}}'), didInsertElement() { - this.$('.input').on('blur change', function(e) { + this.$('.input').on('keydown change', function(e) { event = e; }); } @@ -482,10 +517,10 @@ QUnit.test('`triggerEvent accepts an optional options hash without context', fun wait = App.testHelpers.wait; return wait().then(function() { - return triggerEvent('.input', 'blur', { keyCode: 13 }); + return triggerEvent('.input', 'keydown', { keyCode: 13 }); }).then(function() { equal(event.keyCode, 13, 'options were passed'); - equal(event.type, 'blur', 'correct event was triggered'); + equal(event.type, 'keydown', 'correct event was triggered'); equal(event.target.getAttribute('id'), 'scope', 'triggered on the correct element'); }); }); @@ -650,7 +685,7 @@ QUnit.test('`triggerEvent accepts an optional options hash and context', functio template: compile('{{input type="text" id="outside-scope" class="input"}}
{{input type="text" id="inside-scope" class="input"}}
'), didInsertElement() { - this.$('.input').on('blur change', function(e) { + this.$('.input').on('keydown change', function(e) { event = e; }); } @@ -663,11 +698,11 @@ QUnit.test('`triggerEvent accepts an optional options hash and context', functio return wait() .then(function() { - return triggerEvent('.input', '#limited', 'blur', { keyCode: 13 }); + return triggerEvent('.input', '#limited', 'keydown', { keyCode: 13 }); }) .then(function() { equal(event.keyCode, 13, 'options were passed'); - equal(event.type, 'blur', 'correct event was triggered'); + equal(event.type, 'keydown', 'correct event was triggered'); equal(event.target.getAttribute('id'), 'inside-scope', 'triggered on the correct element'); }); });