diff --git a/addon-test-support/@ember/test-helpers/-utils.js b/addon-test-support/@ember/test-helpers/-utils.js new file mode 100644 index 000000000..110b7eb8f --- /dev/null +++ b/addon-test-support/@ember/test-helpers/-utils.js @@ -0,0 +1,9 @@ +import { Promise } from 'rsvp'; + +export const nextTick = setTimeout; + +export function nextTickPromise() { + return new Promise(resolve => { + nextTick(resolve); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/-get-element.js b/addon-test-support/@ember/test-helpers/dom/-get-element.js new file mode 100644 index 000000000..10eb30cf2 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/-get-element.js @@ -0,0 +1,21 @@ +import { getContext } from '../setup-context'; + +export default function getElement(selectorOrElement) { + if ( + selectorOrElement instanceof Window || + selectorOrElement instanceof Document || + selectorOrElement instanceof Element + ) { + return selectorOrElement; + } else if (typeof selectorOrElement === 'string') { + let context = getContext(); + let rootElement = context && context.element; + if (!rootElement) { + throw new Error(`Must setup rendering context before attempting to interact with elements.`); + } + + return rootElement.querySelector(selectorOrElement); + } else { + throw new Error('Must use an element or a selector string'); + } +} diff --git a/addon-test-support/@ember/test-helpers/dom/-is-focusable.js b/addon-test-support/@ember/test-helpers/dom/-is-focusable.js new file mode 100644 index 000000000..a00abd843 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/-is-focusable.js @@ -0,0 +1,14 @@ +import isFormControl from './-is-form-control'; + +const FOCUSABLE_TAGS = ['A']; +export default function isFocusable(element) { + if ( + isFormControl(element) || + element.isContentEditable || + FOCUSABLE_TAGS.indexOf(element.tagName) > -1 + ) { + return true; + } + + return element.hasAttribute('tabindex'); +} diff --git a/addon-test-support/@ember/test-helpers/dom/-is-form-control.js b/addon-test-support/@ember/test-helpers/dom/-is-form-control.js new file mode 100644 index 000000000..79f42cb0d --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/-is-form-control.js @@ -0,0 +1,11 @@ +const FORM_CONTROL_TAGS = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA']; + +export default function isFormControl(el) { + let { tagName, type } = el; + + if (type === 'hidden') { + return false; + } + + return FORM_CONTROL_TAGS.indexOf(tagName) > -1; +} diff --git a/addon-test-support/@ember/test-helpers/dom/blur.js b/addon-test-support/@ember/test-helpers/dom/blur.js new file mode 100644 index 000000000..4e734c175 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/blur.js @@ -0,0 +1,49 @@ +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import settled from '../settled'; +import isFocusable from './-is-focusable'; +import { nextTickPromise } from '../-utils'; + +/** + @private + @method __blur__ + @param {Element} element +*/ +export function __blur__(element) { + let browserIsNotFocused = document.hasFocus && !document.hasFocus(); + + // makes `document.activeElement` be `body`. + // If the browser is focused, it also fires a blur event + element.blur(); + + // Chrome/Firefox does not trigger the `blur` event if the window + // does not have focus. If the document does not have focus then + // fire `blur` event via native event. + if (browserIsNotFocused) { + fireEvent(element, 'blur', { bubbles: false }); + fireEvent(element, 'focusout'); + } +} + +/** + @method blur + @param {String|Element} [target=document.activeElement] the element to blur + @return {Promise} + @public +*/ +export default function blur(target = document.activeElement) { + return nextTickPromise().then(() => { + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`blur('${target}')\`.`); + } + + if (!isFocusable(element)) { + throw new Error(`${target} is not focusable`); + } + + __blur__(element); + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/click.js b/addon-test-support/@ember/test-helpers/dom/click.js new file mode 100644 index 000000000..eab9c0e60 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/click.js @@ -0,0 +1,44 @@ +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import { __focus__ } from './focus'; +import settled from '../settled'; +import isFocusable from './-is-focusable'; +import { nextTickPromise } from '../-utils'; + +/** + @private + @method __click__ + @param {Element} element +*/ +export function __click__(element) { + fireEvent(element, 'mousedown'); + + if (isFocusable(element)) { + __focus__(element); + } + + fireEvent(element, 'mouseup'); + fireEvent(element, 'click'); +} + +/** + @method click + @param {String|Element} target + @return {Promise} + @public +*/ +export default function click(target) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `click`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`click('${target}')\`.`); + } + + __click__(element); + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/fill-in.js b/addon-test-support/@ember/test-helpers/dom/fill-in.js new file mode 100644 index 000000000..45ddedbeb --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/fill-in.js @@ -0,0 +1,47 @@ +import getElement from './-get-element'; +import isFormControl from './-is-form-control'; +import { __focus__ } from './focus'; +import settled from '../settled'; +import fireEvent from './fire-event'; +import { nextTickPromise } from '../-utils'; + +/* + @method fillIn + @param {String|Element} target + @param {String} text + @return {Promise} + @public +*/ +export default function fillIn(target, text) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `fillIn`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`fillIn('${target}')\`.`); + } + + if (!isFormControl(element) && !element.isContentEditable) { + throw new Error('`fillIn` is only usable on form controls or contenteditable elements.'); + } + + if (!text) { + throw new Error('Must provide `text` when calling `fillIn`.'); + } + + __focus__(element); + + if (element.isContentEditable) { + element.innerHTML = text; + } else { + element.value = text; + } + + fireEvent(element, 'input'); + fireEvent(element, 'change'); + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/fire-event.js b/addon-test-support/@ember/test-helpers/dom/fire-event.js new file mode 100644 index 000000000..1679e4c10 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/fire-event.js @@ -0,0 +1,227 @@ +import { merge } from '@ember/polyfills'; + +const DEFAULT_EVENT_OPTIONS = { bubbles: true, cancelable: true }; +export const KEYBOARD_EVENT_TYPES = Object.freeze(['keydown', 'keypress', 'keyup']); +const MOUSE_EVENT_TYPES = [ + 'click', + 'mousedown', + 'mouseup', + 'dblclick', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', +]; +const FILE_SELECTION_EVENT_TYPES = ['change']; + +/** + @method fireEvent + @param {Element} element + @param {String} type + @param {Object} [options] + @returns {Event} + + @private +*/ +export default function fireEvent(element, type, options = {}) { + if (!element) { + throw new Error('Must pass an element to `fireEvent`'); + } + + let event; + if (KEYBOARD_EVENT_TYPES.indexOf(type) > -1) { + event = buildKeyboardEvent(type, options); + } else if (MOUSE_EVENT_TYPES.indexOf(type) > -1) { + let rect; + if (element instanceof Window) { + rect = element.document.documentElement.getBoundingClientRect(); + } else if (element instanceof Document) { + rect = element.documentElement.getBoundingClientRect(); + } else if (element instanceof Element) { + rect = element.getBoundingClientRect(); + } else { + return; + } + + let x = rect.left + 1; + let y = rect.top + 1; + let simulatedCoordinates = { + screenX: x + 5, // Those numbers don't really mean anything. + screenY: y + 95, // They're just to make the screenX/Y be different of clientX/Y.. + clientX: x, + clientY: y, + }; + + event = buildMouseEvent(type, merge(simulatedCoordinates, options)); + } else if (FILE_SELECTION_EVENT_TYPES.indexOf(type) > -1 && element.files) { + event = buildFileEvent(type, element, options); + } else { + event = buildBasicEvent(type, options); + } + + element.dispatchEvent(event); + return event; +} + +/** + @method buildBasicEvent + @param {String} type + @param {Object} [options] + @return {Event} + @private +*/ +function buildBasicEvent(type, options = {}) { + let event = document.createEvent('Events'); + + let bubbles = options.bubbles !== undefined ? options.bubbles : true; + let cancelable = options.cancelable !== undefined ? options.cancelable : true; + + delete options.bubbles; + delete options.cancelable; + + // bubbles and cancelable are readonly, so they can be + // set when initializing event + event.initEvent(type, bubbles, cancelable); + merge(event, options); + return event; +} + +/** + @method buildMouseEvent + @param {String} type + @param {Object} [options] + @return {Event} + @private +*/ +function buildMouseEvent(type, options = {}) { + let event; + try { + event = document.createEvent('MouseEvents'); + let eventOpts = merge(merge({}, DEFAULT_EVENT_OPTIONS), options); + event.initMouseEvent( + type, + eventOpts.bubbles, + 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; +} + +/** + @method buildKeyboardEvent + @param {String} type + @param {Object} (optional) options + @return {Event} + @private +*/ +function buildKeyboardEvent(type, options = {}) { + let eventOpts = merge(merge({}, DEFAULT_EVENT_OPTIONS), options); + let event, eventMethodName; + + try { + event = new KeyboardEvent(type, eventOpts); + + // Property definitions are required for B/C for keyboard event usage + // If this properties are not defined, when listening for key events + // keyCode/which will be 0. Also, keyCode and which now are string + // and if app compare it with === with integer key definitions, + // there will be a fail. + // + // https://w3c.github.io/uievents/#interface-keyboardevent + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent + Object.defineProperty(event, 'keyCode', { + get() { + return parseInt(this.key); + }, + }); + + Object.defineProperty(event, 'which', { + get() { + return parseInt(this.key); + }, + }); + + return event; + } catch (e) { + // left intentionally blank + } + + try { + event = document.createEvent('KeyboardEvents'); + eventMethodName = 'initKeyboardEvent'; + } catch (e) { + // left intentionally blank + } + + if (!event) { + try { + event = document.createEvent('KeyEvents'); + eventMethodName = 'initKeyEvent'; + } catch (e) { + // left intentionally blank + } + } + + if (event) { + event[eventMethodName]( + type, + eventOpts.bubbles, + eventOpts.cancelable, + window, + eventOpts.ctrlKey, + eventOpts.altKey, + eventOpts.shiftKey, + eventOpts.metaKey, + eventOpts.keyCode, + eventOpts.charCode + ); + } else { + event = buildBasicEvent(type, options); + } + + return event; +} + +/** + @method buildFileEvent + @param {String} type + @param {Element} element + @param {Array} [files] array of files + @return {Event} + @private +*/ +function buildFileEvent(type, element, files = []) { + let event = buildBasicEvent(type); + + if (files.length > 0) { + Object.defineProperty(files, 'item', { + value(index) { + return typeof index === 'number' ? this[index] : null; + }, + }); + Object.defineProperty(element, 'files', { + value: files, + }); + } + + Object.defineProperty(event, 'target', { + value: element, + }); + + return event; +} diff --git a/addon-test-support/@ember/test-helpers/dom/focus.js b/addon-test-support/@ember/test-helpers/dom/focus.js new file mode 100644 index 000000000..b6ec24cc9 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/focus.js @@ -0,0 +1,56 @@ +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import settled from '../settled'; +import isFocusable from './-is-focusable'; +import { nextTickPromise } from '../-utils'; + +/** + @private + @method __focus__ + @param {Element} element +*/ +export function __focus__(element) { + let browserIsNotFocused = document.hasFocus && !document.hasFocus(); + + // makes `document.activeElement` be `element`. If the browser is focused, it also fires a focus event + element.focus(); + + // Firefox does not trigger the `focusin` event if the window + // does not have focus. If the document does not have focus then + // fire `focusin` event as well. + if (browserIsNotFocused) { + // if the browser is not focused the previous `el.focus()` didn't fire an event, so we simulate it + fireEvent(element, 'focus', { + bubbles: false, + }); + + fireEvent(element, 'focusin'); + } +} + +/** + @method focus + @param {String|Element} target + @return {Promise} + @public +*/ +export default function focus(target) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `focus`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`focus('${target}')\`.`); + } + + if (!isFocusable(element)) { + throw new Error(`${target} is not focusable`); + } + + __focus__(element); + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/tap.js b/addon-test-support/@ember/test-helpers/dom/tap.js new file mode 100644 index 000000000..57e1d430d --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/tap.js @@ -0,0 +1,34 @@ +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import { __click__ } from './click'; +import settled from '../settled'; +import { nextTickPromise } from '../-utils'; + +/* + @method tap + @param {String|Element} target + @param {Object} options + @return {Promise} + @public +*/ +export default function tap(target, options = {}) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `tap`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`tap('${target}')\`.`); + } + + let touchstartEv = fireEvent(element, 'touchstart', options); + let touchendEv = fireEvent(element, 'touchend', options); + + if (!touchstartEv.defaultPrevented && !touchendEv.defaultPrevented) { + __click__(element); + } + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/trigger-event.js b/addon-test-support/@ember/test-helpers/dom/trigger-event.js new file mode 100644 index 000000000..dce53075c --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/trigger-event.js @@ -0,0 +1,33 @@ +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import settled from '../settled'; +import { nextTickPromise } from '../-utils'; + +/** + @method triggerEvent + @param {String|Element} target + @param {String} eventType + @param {Object} options + @return {Promise} + @public +*/ +export default function triggerEvent(target, type, options) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `triggerEvent`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`triggerEvent('${target}', ...)\`.`); + } + + if (!type) { + throw new Error(`Must provide an \`eventType\` to \`triggerEvent\``); + } + + fireEvent(element, type, options); + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/trigger-key-event.js b/addon-test-support/@ember/test-helpers/dom/trigger-key-event.js new file mode 100644 index 000000000..dbfd0db38 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/trigger-key-event.js @@ -0,0 +1,59 @@ +import { merge } from '@ember/polyfills'; +import getElement from './-get-element'; +import fireEvent from './fire-event'; +import settled from '../settled'; +import { KEYBOARD_EVENT_TYPES } from './fire-event'; +import { nextTickPromise } from '../-utils'; + +const DEFAULT_MODIFIERS = Object.freeze({ + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, +}); + +/** + @public + @param {String|Element} target + @param {'keydown' | 'keyup' | 'keypress'} eventType + @param {String} keyCode + @param {Object} [modifiers] + @param {Boolean} [modifiers.ctrlKey=false] + @param {Boolean} [modifiers.altKey=false] + @param {Boolean} [modifiers.shiftKey=false] + @param {Boolean} [modifiers.metaKey=false] + @return {Promise} +*/ +export default function triggerKeyEvent(target, eventType, keyCode, modifiers = DEFAULT_MODIFIERS) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `triggerKeyEvent`.'); + } + + let element = getElement(target); + if (!element) { + throw new Error(`Element not found when calling \`triggerKeyEvent('${target}', ...)\`.`); + } + + if (!eventType) { + throw new Error(`Must provide an \`eventType\` to \`triggerKeyEvent\``); + } + + if (KEYBOARD_EVENT_TYPES.indexOf(eventType) === -1) { + let validEventTypes = KEYBOARD_EVENT_TYPES.join(', '); + throw new Error( + `Must provide an \`eventType\` of ${validEventTypes} to \`triggerKeyEvent\` but you passed \`${eventType}\`.` + ); + } + + if (!keyCode) { + throw new Error(`Must provide a \`keyCode\` to \`triggerKeyEvent\``); + } + + let options = merge({ keyCode, which: keyCode, key: keyCode }, modifiers); + + fireEvent(element, eventType, options); + + return settled(); + }); +} diff --git a/addon-test-support/@ember/test-helpers/dom/wait-for.js b/addon-test-support/@ember/test-helpers/dom/wait-for.js new file mode 100644 index 000000000..1364596c1 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/wait-for.js @@ -0,0 +1,44 @@ +import waitUntil from '../wait-until'; +import { getContext } from '../setup-context'; +import getElement from './-get-element'; +import { nextTickPromise } from '../-utils'; + +function toArray(nodelist) { + let array = new Array(nodelist.length); + for (let i = 0; i < nodelist.length; i++) { + array[i] = nodelist[i]; + } + + return array; +} + +/** + @method waitFor + @param {string|Element} target + @param {Object} [options] + @param {number} [options.timeout=1000] + @param {number} [options.count=1] + @returns {Element|Array} +*/ +export default function waitFor(target, { timeout = 1000, count = null } = {}) { + return nextTickPromise().then(() => { + if (!target) { + throw new Error('Must pass an element or selector to `waitFor`.'); + } + + let callback; + if (count !== null) { + callback = () => { + let context = getContext(); + let rootElement = context && context.element; + let elements = rootElement.querySelectorAll(target); + if (elements.length === count) { + return toArray(elements); + } + }; + } else { + callback = () => getElement(target); + } + return waitUntil(callback, { timeout }); + }); +} diff --git a/addon-test-support/@ember/test-helpers/index.js b/addon-test-support/@ember/test-helpers/index.js index f543a20aa..36f840124 100644 --- a/addon-test-support/@ember/test-helpers/index.js +++ b/addon-test-support/@ember/test-helpers/index.js @@ -12,4 +12,15 @@ export { default as teardownContext } from './teardown-context'; export { default as setupRenderingContext, render, clearRender } from './setup-rendering-context'; export { default as teardownRenderingContext } from './teardown-rendering-context'; export { default as settled, isSettled, getState as getSettledState } from './settled'; +export { default as waitUntil } from './wait-until'; export { default as validateErrorHandler } from './validate-error-handler'; + +// DOM Helpers +export { default as click } from './dom/click'; +export { default as tap } from './dom/tap'; +export { default as focus } from './dom/focus'; +export { default as blur } from './dom/blur'; +export { default as triggerEvent } from './dom/trigger-event'; +export { default as triggerKeyEvent } from './dom/trigger-key-event'; +export { default as fillIn } from './dom/fill-in'; +export { default as waitFor } from './dom/wait-for'; diff --git a/addon-test-support/@ember/test-helpers/wait-until.js b/addon-test-support/@ember/test-helpers/wait-until.js new file mode 100644 index 000000000..58485cb72 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/wait-until.js @@ -0,0 +1,38 @@ +import { Promise } from 'rsvp'; + +import { nextTick } from './-utils'; + +export default function(callback, options = {}) { + let timeout = 'timeout' in options ? options.timeout : 1000; + + // creating this error eagerly so it has the proper invocation stack + let waitUntilTimedOut = new Error('waitUntil timed out'); + + return new Promise(function(resolve, reject) { + // starting at -10 because the first invocation happens on 0 + // but still increments the time... + let time = -10; + function tick() { + time += 10; + + let value; + try { + value = callback(); + } catch (error) { + reject(error); + } + + if (value) { + resolve(value); + } else if (time < timeout) { + // using `setTimeout` directly to allow fake timers + // to intercept + nextTick(tick, 10); + } else { + reject(waitUntilTimedOut); + } + } + + nextTick(tick); + }); +} diff --git a/tests/helpers/events.js b/tests/helpers/events.js index 2bb19e2ee..68fb9a4b1 100644 --- a/tests/helpers/events.js +++ b/tests/helpers/events.js @@ -1,179 +1,184 @@ -import { run } from '@ember/runloop'; -import { merge } from '@ember/polyfills'; - -const DEFAULT_EVENT_OPTIONS = { canBubble: true, cancelable: true }; -const KEYBOARD_EVENT_TYPES = ['keydown', 'keypress', 'keyup']; -const MOUSE_EVENT_TYPES = [ +// from https://mdn.mozilla.org/en-US/docs/Web/Events +export const KNOWN_EVENTS = Object.freeze([ + 'abort', + 'afterprint', + 'animationend', + 'animationiteration', + 'animationstart', + 'appinstalled', + 'audioprocess', + 'audioend ', + 'audiostart ', + 'beforeprint', + 'beforeunload', + 'beginEvent', + 'blocked ', + 'blur', + 'boundary ', + 'cached', + 'canplay', + 'canplaythrough', + 'change', + 'chargingchange', + 'chargingtimechange', + 'checking', 'click', - 'mousedown', - 'mouseup', + 'close', + 'complete', + 'compositionend', + 'compositionstart', + 'compositionupdate', + 'contextmenu', + 'copy', + 'cut', 'dblclick', + 'devicechange', + 'devicelight', + 'devicemotion', + 'deviceorientation', + 'deviceproximity', + 'dischargingtimechange', + 'downloading', + 'drag', + 'dragend', + 'dragenter', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'durationchange', + 'emptied', + 'end', + 'ended', + 'endEvent', + 'error', + 'focus', + 'focusin', + 'focusout', + 'fullscreenchange', + 'fullscreenerror', + 'gamepadconnected', + 'gamepaddisconnected', + 'gotpointercapture', + 'hashchange', + 'lostpointercapture', + 'input', + 'invalid', + 'keydown', + 'keypress', + 'keyup', + 'languagechange ', + 'levelchange', + 'load', + 'loadeddata', + 'loadedmetadata', + 'loadend', + 'loadstart', + 'mark ', + 'message', + 'messageerror', + 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', -]; - -export const elMatches = - typeof Element !== 'undefined' && - (Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector); - -export function matches(el, selector) { - return elMatches.call(el, selector); -} - -function isFocusable(el) { - let focusableTags = ['INPUT', 'BUTTON', 'LINK', 'SELECT', 'A', 'TEXTAREA']; - let { tagName, type } = el; - - if (type === 'hidden') { - return false; - } - - return focusableTags.indexOf(tagName) > -1 || el.contentEditable === 'true'; -} - -export function click(el, options = {}) { - run(() => fireEvent(el, 'mousedown', options)); - focus(el); - run(() => fireEvent(el, 'mouseup', options)); - run(() => fireEvent(el, 'click', options)); -} - -export function focus(el) { - if (!el) { - return; - } - if (isFocusable(el)) { - run(null, function() { - let browserIsNotFocused = document.hasFocus && !document.hasFocus(); - - // 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 (browserIsNotFocused) { - fireEvent(el, 'focusin'); - } - - // makes `document.activeElement` be `el`. If the browser is focused, it also fires a focus event - el.focus(); - - // if the browser is not focused the previous `el.focus()` didn't fire an event, so we simulate it - if (browserIsNotFocused) { - fireEvent(el, 'focus'); - } + 'mouseup', + 'nomatch', + 'notificationclick', + 'noupdate', + 'obsolete', + 'offline', + 'online', + 'open', + 'orientationchange', + 'pagehide', + 'pageshow', + 'paste', + 'pause', + 'pointercancel', + 'pointerdown', + 'pointerenter', + 'pointerleave', + 'pointerlockchange', + 'pointerlockerror', + 'pointermove', + 'pointerout', + 'pointerover', + 'pointerup', + 'play', + 'playing', + 'popstate', + 'progress', + 'push', + 'pushsubscriptionchange', + 'ratechange', + 'readystatechange', + 'repeatEvent', + 'reset', + 'resize', + 'resourcetimingbufferfull', + 'result ', + 'resume ', + 'scroll', + 'seeked', + 'seeking', + 'select', + 'selectstart', + 'selectionchange', + 'show', + 'soundend ', + 'soundstart ', + 'speechend', + 'speechstart', + 'stalled', + 'start', + 'storage', + 'submit', + 'success', + 'suspend', + 'SVGAbort', + 'SVGError', + 'SVGLoad', + 'SVGResize', + 'SVGScroll', + 'SVGUnload', + 'SVGZoom', + 'timeout', + 'timeupdate', + 'touchcancel', + 'touchend', + 'touchmove', + 'touchstart', + 'transitionend', + 'unload', + 'updateready', + 'upgradeneeded ', + 'userproximity', + 'voiceschanged', + 'versionchange', + 'visibilitychange', + 'volumechange', + 'waiting', + 'wheel', +]); + +let uuid = 0; +export function buildInstrumentedElement(elementType) { + let assert = QUnit.config.current.assert; + + let element = document.createElement(elementType); + element.setAttribute('id', `fixture-${uuid++}`); + + KNOWN_EVENTS.forEach(type => { + element.addEventListener(type, e => { + assert.step(type); + assert.ok(e instanceof Event, `${type} listener should receive a native event`); }); - } -} - -export function blur(el) { - if (isFocusable(el)) { - run(null, function() { - let browserIsNotFocused = document.hasFocus && !document.hasFocus(); + }); - fireEvent(el, 'focusout'); - - // makes `document.activeElement` be `body`. - // If the browser is focused, it also fires a blur event - el.blur(); - - // Chrome/Firefox does not trigger the `blur` event if the window - // does not have focus. If the document does not have focus then - // fire `blur` event via native event. - if (browserIsNotFocused) { - fireEvent(el, 'blur'); - } - }); - } -} - -export function fireEvent(element, type, options = {}) { - if (!element) { - return; - } - let event; - if (KEYBOARD_EVENT_TYPES.indexOf(type) > -1) { - event = buildKeyboardEvent(type, options); - } else if (MOUSE_EVENT_TYPES.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, merge(simulatedCoordinates, options)); - } else { - event = buildBasicEvent(type, options); - } - element.dispatchEvent(event); -} - -function buildBasicEvent(type, options = {}) { - let event = document.createEvent('Events'); - event.initEvent(type, true, true); - merge(event, options); - return event; -} - -function buildMouseEvent(type, options = {}) { - let event; - try { - event = document.createEvent('MouseEvents'); - let eventOpts = merge({}, DEFAULT_EVENT_OPTIONS); - merge(eventOpts, 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; -} + let fixture = document.querySelector('#qunit-fixture'); + fixture.appendChild(element); -function buildKeyboardEvent(type, options = {}) { - let event; - try { - event = document.createEvent('KeyEvents'); - let eventOpts = merge({}, DEFAULT_EVENT_OPTIONS); - merge(eventOpts, 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; + return element; } diff --git a/tests/integration/module-for-acceptance-interop-test.js b/tests/integration/module-for-acceptance-interop-test.js index cdfa6616d..805b046ae 100644 --- a/tests/integration/module-for-acceptance-interop-test.js +++ b/tests/integration/module-for-acceptance-interop-test.js @@ -2,12 +2,11 @@ import { test } from 'qunit'; import { run } from '@ember/runloop'; import EmberRouter from '@ember/routing/router'; import Component from '@ember/component'; -import { settled } from '@ember/test-helpers'; +import { click } from '@ember/test-helpers'; import hasEmberVersion from '@ember/test-helpers/has-ember-version'; import hbs from 'htmlbars-inline-precompile'; import ajax from '../helpers/ajax'; -import { fireEvent } from '../helpers/events'; import Pretender from 'pretender'; import moduleForAcceptance from '../helpers/module-for-acceptance'; @@ -63,8 +62,10 @@ if (hasEmberVersion(2, 8)) { return this.application.testHelpers .visit('/ajax-request') .then(() => { - fireEvent(document.querySelector('.special-thing'), 'click'); - return settled(); + // returning `click` here is going to trigger the `click` event + // then `return settled()` (which is how we are testing the underlying + // settled interop). + return click(document.querySelector('.special-thing')); }) .then(() => { let testingElement = document.getElementById('ember-testing'); diff --git a/tests/integration/settled-test.js b/tests/integration/settled-test.js index c214d7e4f..70c5e6ec1 100644 --- a/tests/integration/settled-test.js +++ b/tests/integration/settled-test.js @@ -9,11 +9,10 @@ import { teardownRenderingContext, } from 'ember-test-helpers'; import hasEmberVersion from 'ember-test-helpers/has-ember-version'; -import { module, test, skip } from 'qunit'; +import { module, test } from 'qunit'; import hbs from 'htmlbars-inline-precompile'; import Pretender from 'pretender'; -import { fireEvent } from '../helpers/events'; -import hasjQuery from '../helpers/has-jquery'; +import { click } from '@ember/test-helpers'; import ajax from '../helpers/ajax'; const TestComponent1 = Component.extend({ @@ -162,9 +161,7 @@ module('settled real-world scenarios', function(hooks) { assert.equal(this.element.textContent, 'initial value'); - fireEvent(this.element.querySelector('div'), 'click'); - - await settled(); + await click('div'); assert.equal(this.element.textContent, 'async value'); }); @@ -174,9 +171,7 @@ module('settled real-world scenarios', function(hooks) { await this.render(hbs`{{x-test-3}}`); - fireEvent(this.element.querySelector('div'), 'click'); - - await settled(); + await click('div'); assert.equal(this.element.textContent, 'Remote Data!'); }); @@ -186,42 +181,11 @@ module('settled real-world scenarios', function(hooks) { await this.render(hbs`{{x-test-4}}`); - fireEvent(this.element.querySelector('div'), 'click'); - - await settled(); + await click('div'); assert.equal(this.element.textContent, 'Local Data!Remote Data!Remote Data!'); }); - test('it can wait only for AJAX', async function(assert) { - this.owner.register('component:x-test-4', TestComponent4); - - await this.render(hbs`{{x-test-4}}`); - - fireEvent(this.element.querySelector('div'), 'click'); - - await settled({ waitForTimers: false }); - - assert.equal(this.element.textContent, 'Local Data!Remote Data!'); - }); - - // in the wait utility we specific listen for artificial jQuery events - // to start/stop waiting, but when using ember-fetch those events are not - // emitted and instead test waiters are used - // - // therefore, this test is only valid when using jQuery.ajax - (hasjQuery() ? test : skip)('it can wait only for timers', async function(assert) { - this.owner.register('component:x-test-4', TestComponent4); - - await this.render(hbs`{{x-test-4}}`); - - fireEvent(this.element.querySelector('div'), 'click'); - - await settled({ waitForAJAX: false }); - - assert.equal(this.element.textContent, 'Local Data!'); - }); - test('it waits for Ember test waiters', async function(assert) { this.owner.register('component:x-test-5', TestComponent5); diff --git a/tests/test-helper.js b/tests/test-helper.js index 105917a1d..b79701d46 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -2,6 +2,7 @@ import QUnit from 'qunit'; import { registerDeprecationHandler } from '@ember/debug'; import AbstractTestLoader from 'ember-cli-test-loader/test-support/index'; import Ember from 'ember'; +import { Promise } from 'rsvp'; if (QUnit.config.seed) { QUnit.config.reorder = false; @@ -100,3 +101,91 @@ QUnit.assert.deprecationsInclude = function(expected) { message: `expected to find \`${expected}\` deprecation`, }); }; + +// Monkey patch in the fix from https://github.com/qunitjs/qunit/pull/1233 +// This can be removed after qunit > 2.4.1 has been released +const ASSERT_VERIFY_STEPS = QUnit.assert.verifySteps; +QUnit.assert.verifySteps = function() { + ASSERT_VERIFY_STEPS.apply(this, arguments); + this.test.steps.length = 0; +}; + +QUnit.assert.rejects = function(callback, expected, message) { + this.test.ignoreGlobalErrors = true; + + let actual; + let result = false; + + let done = this.async(); + + return Promise.resolve() + .then(callback) + .then(null, reason => { + actual = reason; + }) + .finally(() => { + if (actual) { + const expectedType = typeof expected; + + // We don't want to validate thrown error + if (!expected) { + result = true; + expected = null; + + // Expected is a regexp + } else if (expected instanceof RegExp) { + result = expected.test(errorString(actual)); + + // Expected is a constructor, maybe an Error constructor + } else if (expectedType === 'function' && actual instanceof expected) { + result = true; + + // Expected is an Error object + } else if (expectedType === 'object') { + result = + actual instanceof expected.constructor && + actual.name === expected.name && + actual.message === expected.message; + + // Expected is a validation function which returns true if validation passed + } else if (expectedType === 'function' && expected.call(null, actual) === true) { + expected = null; + result = true; + } + } + + this.pushResult({ + result, + actual, + expected, + message, + }); + + this.test.ignoreGlobalErrors = false; + }) + .finally(() => { + this.test.ignoreGlobalErrors = false; + done(); + }); +}; + +function errorString(error) { + const resultErrorString = error.toString(); + + if (resultErrorString.substring(0, 7) === '[object') { + const name = error.name ? error.name.toString() : 'Error'; + const message = error.message ? error.message.toString() : ''; + + if (name && message) { + return `${name}: ${message}`; + } else if (name) { + return name; + } else if (message) { + return message; + } else { + return 'Error'; + } + } else { + return resultErrorString; + } +} diff --git a/tests/unit/dom/blur-test.js b/tests/unit/dom/blur-test.js new file mode 100644 index 000000000..6caadcf26 --- /dev/null +++ b/tests/unit/dom/blur-test.js @@ -0,0 +1,77 @@ +import { module, test } from 'qunit'; +import { focus, blur, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: blur', function(hooks) { + let context, elementWithFocus; + + hooks.beforeEach(async function(assert) { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + + // create the element and focus in preparation for blur testing + elementWithFocus = buildInstrumentedElement('input'); + await focus(elementWithFocus); + + // verify that focus was ran, and reset steps + assert.verifySteps(['focus', 'focusin']); + assert.equal(document.activeElement, elementWithFocus, 'activeElement updated'); + }); + + hooks.afterEach(function() { + if (elementWithFocus) { + elementWithFocus.parentNode.removeChild(elementWithFocus); + } + unsetContext(); + }); + + test('does not run sync', async function(assert) { + let promise = blur(elementWithFocus); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['blur', 'focusout']); + }); + + test('rejects if selector is not found', async function(assert) { + setContext(context); + + assert.rejects(() => { + return blur(`#foo-bar-baz-not-here-ever-bye-bye`); + }, /Element not found when calling `blur\('#foo-bar-baz-not-here-ever-bye-bye'\)`/); + }); + + test('bluring via selector with context set', async function(assert) { + setContext(context); + await blur(`#${elementWithFocus.id}`); + + assert.verifySteps(['blur', 'focusout']); + assert.notEqual(document.activeElement, elementWithFocus, 'activeElement updated'); + }); + + test('bluring via selector without context set', function(assert) { + assert.rejects(() => { + return blur(`#${elementWithFocus.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); + + test('bluring via element with context set', async function(assert) { + setContext(context); + await blur(elementWithFocus); + + assert.verifySteps(['blur', 'focusout']); + assert.notEqual(document.activeElement, elementWithFocus, 'activeElement updated'); + }); + + test('bluring via element without context set', async function(assert) { + await blur(elementWithFocus); + + assert.verifySteps(['blur', 'focusout']); + assert.notEqual(document.activeElement, elementWithFocus, 'activeElement updated'); + }); +}); diff --git a/tests/unit/dom/click-test.js b/tests/unit/dom/click-test.js new file mode 100644 index 000000000..2cb1f1f39 --- /dev/null +++ b/tests/unit/dom/click-test.js @@ -0,0 +1,118 @@ +import { module, test } from 'qunit'; +import { click, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: click', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + module('non-focusable element types', function() { + test('clicking a div via selector with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await click(`#${element.id}`); + + assert.verifySteps(['mousedown', 'mouseup', 'click']); + }); + + test('clicking a div via element with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await click(element); + + assert.verifySteps(['mousedown', 'mouseup', 'click']); + }); + + test('clicking a div via element without context set', async function(assert) { + element = buildInstrumentedElement('div'); + + await click(element); + + assert.verifySteps(['mousedown', 'mouseup', 'click']); + }); + + test('does not run sync', async function(assert) { + element = buildInstrumentedElement('div'); + + let promise = click(element); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['mousedown', 'mouseup', 'click']); + }); + + test('rejects if selector is not found', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return click(`#foo-bar-baz-not-here-ever-bye-bye`); + }, /Element not found when calling `click\('#foo-bar-baz-not-here-ever-bye-bye'\)`/); + }); + + test('clicking a div via selector without context set', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + return click(`#${element.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); + }); + + module('focusable element types', function() { + test('clicking a input via selector with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await click(`#${element.id}`); + + assert.verifySteps(['mousedown', 'focus', 'focusin', 'mouseup', 'click']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('clicking a input via element with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await click(element); + + assert.verifySteps(['mousedown', 'focus', 'focusin', 'mouseup', 'click']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('clicking a input via element without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + await click(element); + + assert.verifySteps(['mousedown', 'focus', 'focusin', 'mouseup', 'click']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('clicking a input via selector without context set', function(assert) { + element = buildInstrumentedElement('input'); + + assert.rejects(() => { + return click(`#${element.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); + }); +}); diff --git a/tests/unit/dom/fill-in-test.js b/tests/unit/dom/fill-in-test.js new file mode 100644 index 000000000..add29c82a --- /dev/null +++ b/tests/unit/dom/fill-in-test.js @@ -0,0 +1,136 @@ +import { module, test } from 'qunit'; +import { fillIn, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: fillIn', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + test('filling in a non-fillable element', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + assert.rejects(() => { + return fillIn(`#${element.id}`, 'foo'); + }, /`fillIn` is only usable on form controls or contenteditable elements/); + }); + + test('rejects if selector is not found', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return fillIn(`#foo-bar-baz-not-here-ever-bye-bye`, 'foo'); + }, /Element not found when calling `fillIn\('#foo-bar-baz-not-here-ever-bye-bye'\)`/); + }); + + test('rejects if text to fill in is not provided', async function(assert) { + element = buildInstrumentedElement('input'); + + assert.rejects(() => { + return fillIn(element); + }, /Must provide `text` when calling `fillIn`/); + }); + + test('filling an input via selector without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + assert.rejects(() => { + return fillIn(`#${element.id}`, 'foo'); + }, /Must setup rendering context before attempting to interact with elements/); + }); + + test('does not run sync', async function(assert) { + element = buildInstrumentedElement('input'); + + let promise = fillIn(element, 'foo'); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); + + test('filling a textarea via selector with context set', async function(assert) { + element = buildInstrumentedElement('textarea'); + + setContext(context); + await fillIn(`#${element.id}`, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); + + test('filling an input via element with context set', async function(assert) { + element = buildInstrumentedElement('textarea'); + + setContext(context); + await fillIn(element, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); + + test('filling an input via selector with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await fillIn(`#${element.id}`, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); + + test('filling an input via element with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await fillIn(element, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); + + test('filling a content editable div via element with context set', async function(assert) { + element = buildInstrumentedElement('div'); + element.setAttribute('contenteditable', ''); + + setContext(context); + await fillIn(element, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.innerHTML, 'foo'); + }); + + test('filling an input via element without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + await fillIn(element, 'foo'); + + assert.verifySteps(['focus', 'focusin', 'input', 'change']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + assert.equal(element.value, 'foo'); + }); +}); diff --git a/tests/unit/dom/focus-test.js b/tests/unit/dom/focus-test.js new file mode 100644 index 000000000..05fecc480 --- /dev/null +++ b/tests/unit/dom/focus-test.js @@ -0,0 +1,98 @@ +import { module, test } from 'qunit'; +import { focus, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: focus', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + test('focusing a div via selector with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + assert.rejects(() => { + return focus(`#${element.id}`); + }, /is not focusable/); + }); + + test('focusing a div via element with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + assert.rejects(() => { + return focus(element); + }, /is not focusable/); + }); + + test('does not run sync', async function(assert) { + element = buildInstrumentedElement('input'); + + let promise = focus(element); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['focus', 'focusin']); + }); + + test('rejects if selector is not found', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return focus(`#foo-bar-baz-not-here-ever-bye-bye`); + }, /Element not found when calling `focus\('#foo-bar-baz-not-here-ever-bye-bye'\)`/); + }); + + test('focusing a input via selector with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await focus(`#${element.id}`); + + assert.verifySteps(['focus', 'focusin']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('focusing a input via element with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await focus(element); + + assert.verifySteps(['focus', 'focusin']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('focusing a input via element without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + await focus(element); + + assert.verifySteps(['focus', 'focusin']); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('focusing a input via selector without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + assert.rejects(() => { + return focus(`#${element.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); +}); diff --git a/tests/unit/dom/tap-test.js b/tests/unit/dom/tap-test.js new file mode 100644 index 000000000..cb6eab5dd --- /dev/null +++ b/tests/unit/dom/tap-test.js @@ -0,0 +1,142 @@ +import { module, test } from 'qunit'; +import { tap, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: tap', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + module('non-focusable element types', function() { + test('taping a div via selector with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await tap(`#${element.id}`); + + assert.verifySteps(['touchstart', 'touchend', 'mousedown', 'mouseup', 'click']); + }); + + test('tapping a div via element with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await tap(element); + + assert.verifySteps(['touchstart', 'touchend', 'mousedown', 'mouseup', 'click']); + }); + + test('tapping a div via element without context set', async function(assert) { + element = buildInstrumentedElement('div'); + + await tap(element); + + assert.verifySteps(['touchstart', 'touchend', 'mousedown', 'mouseup', 'click']); + }); + + test('does not run sync', async function(assert) { + element = buildInstrumentedElement('div'); + + let promise = tap(element); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['touchstart', 'touchend', 'mousedown', 'mouseup', 'click']); + }); + + test('rejects if selector is not found', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return tap(`#foo-bar-baz-not-here-ever-bye-bye`); + }, /Element not found when calling `tap\('#foo-bar-baz-not-here-ever-bye-bye'\)`/); + }); + + test('tapping a div via selector without context set', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(async () => { + await tap(`#${element.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); + }); + + module('focusable element types', function() { + test('tapping a input via selector with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await tap(`#${element.id}`); + + assert.verifySteps([ + 'touchstart', + 'touchend', + 'mousedown', + 'focus', + 'focusin', + 'mouseup', + 'click', + ]); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('tapping a input via element with context set', async function(assert) { + element = buildInstrumentedElement('input'); + + setContext(context); + await tap(element); + + assert.verifySteps([ + 'touchstart', + 'touchend', + 'mousedown', + 'focus', + 'focusin', + 'mouseup', + 'click', + ]); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('tapping a input via element without context set', async function(assert) { + element = buildInstrumentedElement('input'); + + await tap(element); + + assert.verifySteps([ + 'touchstart', + 'touchend', + 'mousedown', + 'focus', + 'focusin', + 'mouseup', + 'click', + ]); + assert.strictEqual(document.activeElement, element, 'activeElement updated'); + }); + + test('tapping a input via selector without context set', function(assert) { + element = buildInstrumentedElement('input'); + + assert.rejects(async () => { + await tap(`#${element.id}`); + }, /Must setup rendering context before attempting to interact with elements/); + }); + }); +}); diff --git a/tests/unit/dom/trigger-event-test.js b/tests/unit/dom/trigger-event-test.js new file mode 100644 index 000000000..874294634 --- /dev/null +++ b/tests/unit/dom/trigger-event-test.js @@ -0,0 +1,124 @@ +import { module, test } from 'qunit'; +import { triggerEvent, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: triggerEvent', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + test('can trigger arbitrary event types', async function(assert) { + element = buildInstrumentedElement('div'); + element.addEventListener('fliberty', e => { + assert.step('fliberty'); + assert.ok(e instanceof Event, `fliberty listener receives a native event`); + }); + + setContext(context); + await triggerEvent(`#${element.id}`, 'fliberty'); + + assert.verifySteps(['fliberty']); + }); + + test('triggering event via selector with context set fires the given event type', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await triggerEvent(`#${element.id}`, 'mouseenter'); + + assert.verifySteps(['mouseenter']); + }); + + test('triggering event via element with context set fires the given event type', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await triggerEvent(element, 'mouseenter'); + + assert.verifySteps(['mouseenter']); + }); + + test('triggering event via element without context set fires the given event type', async function(assert) { + element = buildInstrumentedElement('div'); + + await triggerEvent(element, 'mouseenter'); + + assert.verifySteps(['mouseenter']); + }); + + test('triggering event via selector without context set', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + return triggerEvent(`#${element.id}`, 'mouseenter'); + }, /Must setup rendering context before attempting to interact with elements/); + }); + + test('does not run sync', async function(assert) { + element = buildInstrumentedElement('div'); + + let promise = triggerEvent(element, 'mouseenter'); + + assert.verifySteps([]); + + await promise; + + assert.verifySteps(['mouseenter']); + }); + + test('rejects if selector is not found', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return triggerEvent(`#foo-bar-baz-not-here-ever-bye-bye`, 'mouseenter'); + }, /Element not found when calling `triggerEvent\('#foo-bar-baz-not-here-ever-bye-bye'/); + }); + + test('rejects if event type is not passed', async function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return triggerEvent(element); + }, /Must provide an `eventType` to `triggerEvent`/); + }); + + test('events properly bubble upwards', async function(assert) { + setContext(context); + element = buildInstrumentedElement('div'); + element.innerHTML = ` +
+
+
+ `; + + let outer = element.querySelector('#outer'); + let inner = element.querySelector('#inner'); + + outer.addEventListener('mouseenter', () => { + assert.step('outer: mouseenter'); + }); + + inner.addEventListener('mouseenter', () => { + assert.step('inner: mouseenter'); + }); + + await triggerEvent('#inner', 'mouseenter'); + + assert.verifySteps(['inner: mouseenter', 'outer: mouseenter', 'mouseenter']); + }); +}); diff --git a/tests/unit/dom/trigger-key-event-test.js b/tests/unit/dom/trigger-key-event-test.js new file mode 100644 index 000000000..a305135c1 --- /dev/null +++ b/tests/unit/dom/trigger-key-event-test.js @@ -0,0 +1,110 @@ +import { module, test } from 'qunit'; +import { triggerKeyEvent, setContext, unsetContext } from '@ember/test-helpers'; +import { buildInstrumentedElement } from '../../helpers/events'; + +module('DOM Helper: triggerKeyEvent', function(hooks) { + let context, element; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(function() { + if (element) { + element.parentNode.removeChild(element); + } + unsetContext(); + }); + + test('rejects if event type is missing', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return triggerKeyEvent(element); + }, /Must provide an `eventType` to `triggerKeyEvent`/); + }); + + test('rejects if event type is invalid', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return triggerKeyEvent(element, 'mouseenter'); + }, /Must provide an `eventType` of keydown, keypress, keyup to `triggerKeyEvent` but you passed `mouseenter`./); + }); + + test('rejects if key code is missing', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + setContext(context); + return triggerKeyEvent(element, 'keypress'); + }, /Must provide a `keyCode` to `triggerKeyEvent`/); + }); + + test('triggering via selector with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await triggerKeyEvent(`#${element.id}`, 'keydown', 13); + + assert.verifySteps(['keydown']); + }); + + test('triggering via element with context set', async function(assert) { + element = buildInstrumentedElement('div'); + + setContext(context); + await triggerKeyEvent(element, 'keydown', 13); + + assert.verifySteps(['keydown']); + }); + + test('triggering via element without context set', async function(assert) { + element = buildInstrumentedElement('div'); + + await triggerKeyEvent(element, 'keydown', 13); + + assert.verifySteps(['keydown']); + }); + + test('triggering via selector without context set', function(assert) { + element = buildInstrumentedElement('div'); + + assert.rejects(() => { + return triggerKeyEvent(`#${element.id}`, 'keydown', 13); + }, /Must setup rendering context before attempting to interact with elements/); + }); + + ['ctrl', 'shift', 'alt', 'meta'].forEach(function(modifierType) { + test(`triggering passing with ${modifierType} pressed`, async function(assert) { + element = buildInstrumentedElement('div'); + element.addEventListener('keypress', e => { + assert.ok(e[`${modifierType}Key`], `has ${modifierType} indicated`); + }); + + setContext(context); + await triggerKeyEvent(element, 'keypress', 13, { [`${modifierType}Key`]: true }); + + assert.verifySteps(['keypress']); + }); + }); + + test(`can combine modifier keys`, async function(assert) { + element = buildInstrumentedElement('div'); + element.addEventListener('keypress', e => { + assert.ok(e.ctrlKey, `has ctrlKey indicated`); + assert.ok(e.altKey, `has altKey indicated`); + }); + + setContext(context); + await triggerKeyEvent(element, 'keypress', 13, { altKey: true, ctrlKey: true }); + + assert.verifySteps(['keypress']); + }); +}); diff --git a/tests/unit/dom/wait-for-test.js b/tests/unit/dom/wait-for-test.js new file mode 100644 index 000000000..bed4e08ec --- /dev/null +++ b/tests/unit/dom/wait-for-test.js @@ -0,0 +1,75 @@ +import { module, test } from 'qunit'; +import { waitFor, setContext, unsetContext } from '@ember/test-helpers'; + +module('DOM Helper: waitFor', function(hooks) { + let context, rootElement; + + hooks.beforeEach(function() { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + rootElement = document.querySelector('#qunit-fixture'); + context = { + element: rootElement, + }; + }); + + hooks.afterEach(function() { + unsetContext(); + }); + + test('wait for selector without context set', async function(assert) { + assert.rejects(() => { + return waitFor('.something'); + }, /Must setup rendering context before attempting to interact with elements/); + }); + + test('wait for selector', async function(assert) { + setContext(context); + + let waitPromise = waitFor('.something'); + + setTimeout(() => { + rootElement.innerHTML = `
Hi!
`; + }, 10); + + let element = await waitPromise; + + assert.equal(element.textContent, 'Hi!'); + }); + + test('wait for count of selector', async function(assert) { + setContext(context); + + let waitPromise = waitFor('.something', { count: 2 }); + + setTimeout(() => { + rootElement.innerHTML = `
No!
`; + }, 10); + + setTimeout(() => { + rootElement.innerHTML = ` +
Hi!
+
Bye!
+ `; + }, 20); + + let elements = await waitPromise; + + assert.deepEqual(elements.map(e => e.textContent), ['Hi!', 'Bye!']); + }); + + test('wait for selector with timeout', async function(assert) { + assert.expect(2); + + setContext(context); + + let start = Date.now(); + try { + await waitFor('.something', { timeout: 100 }); + } catch (error) { + let end = Date.now(); + assert.ok(end - start >= 100, 'timed out after correct time'); + assert.equal(error.message, 'waitUntil timed out'); + } + }); +}); diff --git a/tests/unit/legacy-0-6-x/test-module-for-component-test.js b/tests/unit/legacy-0-6-x/test-module-for-component-test.js index 4644c8016..571987e18 100644 --- a/tests/unit/legacy-0-6-x/test-module-for-component-test.js +++ b/tests/unit/legacy-0-6-x/test-module-for-component-test.js @@ -16,7 +16,7 @@ import wait from 'ember-test-helpers/wait'; import qunitModuleFor from '../../helpers/qunit-module-for'; import hasjQuery from '../../helpers/has-jquery'; import hbs from 'htmlbars-inline-precompile'; -import { fireEvent, focus, blur } from '../../helpers/events'; +import { triggerEvent, focus, blur } from '@ember/test-helpers'; import { htmlSafe } from '@ember/string'; var Service = EmberService || EmberObject; @@ -466,6 +466,8 @@ test('it supports DOM events', function(assert) { }); test('it supports updating an input', function(assert) { + assert.expect(1); + setResolverRegistry({ 'component:my-input': TextField.extend({ value: null, @@ -475,8 +477,9 @@ test('it supports updating an input', function(assert) { let input = this._element.querySelector('input'); input.value = '1'; - fireEvent(input, 'change'); - assert.equal(this.get('value'), '1'); + return triggerEvent(input, 'change').then(() => { + assert.equal(this.get('value'), '1'); + }); }); test('it supports dom triggered focus events', function(assert) { @@ -498,11 +501,14 @@ test('it supports dom triggered focus events', function(assert) { let input = this._element.querySelector('input'); assert.equal(input.value, 'init'); - focus(input); - assert.equal(input.value, 'focusin'); - - blur(input); - assert.equal(input.value, 'focusout'); + return focus(input) + .then(() => { + assert.equal(input.value, 'focusin'); + return blur(input); + }) + .then(() => { + assert.equal(input.value, 'focusout'); + }); }); moduleForComponent('Component Integration Tests: render during setup', { diff --git a/tests/unit/legacy-0-6-x/wait-test.js b/tests/unit/legacy-0-6-x/wait-test.js index f654798d8..64d032a99 100644 --- a/tests/unit/legacy-0-6-x/wait-test.js +++ b/tests/unit/legacy-0-6-x/wait-test.js @@ -7,7 +7,7 @@ import wait from 'ember-test-helpers/wait'; import { module, test } from 'qunit'; import hbs from 'htmlbars-inline-precompile'; import Pretender from 'pretender'; -import { fireEvent } from '../../helpers/events'; +import fireEvent from '@ember/test-helpers/dom/fire-event'; import hasjQuery from '../../helpers/has-jquery'; import require from 'require'; diff --git a/tests/unit/setup-rendering-context-test.js b/tests/unit/setup-rendering-context-test.js index 71b7cf18f..b817c1c44 100644 --- a/tests/unit/setup-rendering-context-test.js +++ b/tests/unit/setup-rendering-context-test.js @@ -16,7 +16,7 @@ import { import hasEmberVersion from 'ember-test-helpers/has-ember-version'; import hasjQuery from '../helpers/has-jquery'; import { setResolverRegistry, application, resolver } from '../helpers/resolver'; -import { focus, blur, fireEvent, click } from '../helpers/events'; +import { triggerEvent, focus, blur, click } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; module('setupRenderingContext', function(hooks) { @@ -172,7 +172,7 @@ module('setupRenderingContext', function(hooks) { await this.render(hbs`{{x-foo}}`); assert.equal(this.element.textContent, 'Click me!', 'precond - component was rendered'); - click(this.element.querySelector('button')); + await click(this.element.querySelector('button')); }); test('can use action based event handling', async function(assert) { @@ -196,7 +196,7 @@ module('setupRenderingContext', function(hooks) { await this.render(hbs`{{x-foo}}`); assert.equal(this.element.textContent, 'Click me!', 'precond - component was rendered'); - click(this.element.querySelector('button')); + await click(this.element.querySelector('button')); }); test('can pass function to be used as a "closure action"', async function(assert) { @@ -211,7 +211,7 @@ module('setupRenderingContext', function(hooks) { await this.render(hbs`{{x-foo clicked=clicked}}`); assert.equal(this.element.textContent, 'Click me!', 'precond - component was rendered'); - click(this.element.querySelector('button')); + await click(this.element.querySelector('button')); }); test('can update a passed in argument with an ', async function(assert) { @@ -226,7 +226,7 @@ module('setupRenderingContext', function(hooks) { // trigger the change input.value = '1'; - fireEvent(input, 'change'); + await triggerEvent(input, 'change'); assert.equal(this.get('value'), '1'); }); @@ -253,10 +253,10 @@ module('setupRenderingContext', function(hooks) { let input = this.element.querySelector('input'); assert.equal(input.value, 'init'); - focus(input); + await focus(input); assert.equal(input.value, 'focusin'); - blur(input); + await blur(input); assert.equal(input.value, 'focusout'); }); @@ -280,7 +280,7 @@ module('setupRenderingContext', function(hooks) { await this.render(hbs`{{my-component foo=foo}}`); assert.equal(this.element.textContent, 'original', 'value after initial render'); - click(this.element.querySelector('button')); + await click(this.element.querySelector('button')); assert.equal(this.element.textContent, 'updated!', 'value after updating'); assert.equal(this.get('foo'), 'updated!'); }); @@ -306,7 +306,7 @@ module('setupRenderingContext', function(hooks) { // works both for things rendered in the component's layout // and those only used in the components JS file await this.render(hbs`{{my-component foo=foo bar=bar}}`); - click(this.element.querySelector('button')); + await click(this.element.querySelector('button')); await this.clearRender(); diff --git a/tests/unit/wait-until-test.js b/tests/unit/wait-until-test.js new file mode 100644 index 000000000..26776a22a --- /dev/null +++ b/tests/unit/wait-until-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { waitUntil } from '@ember/test-helpers'; + +module('DOM helper: waitUntil', function() { + test('waits until the provided function returns true', async function(assert) { + let current = false; + assert.step('before invocation'); + let waiter = waitUntil(() => current).then(() => { + assert.step('after resolved'); + + assert.verifySteps(['before invocation', 'after invocation', 'after resolved']); + }); + assert.step('after invocation'); + + setTimeout(() => (current = true)); + + return waiter; + }); + + test('waits until timeout expires', function(assert) { + assert.step('before invocation'); + let waiter = waitUntil(() => {}, { timeout: 20 }); + assert.step('after invocation'); + + setTimeout(() => assert.step('waiting'), 10); + + return waiter + .catch(reason => { + assert.step(`catch handler: ${reason.message}`); + }) + .finally(() => { + assert.verifySteps([ + 'before invocation', + 'after invocation', + 'waiting', + 'catch handler: waitUntil timed out', + ]); + }); + }); + + test('rejects when callback throws', function(assert) { + return waitUntil(() => { + throw new Error('error goes here'); + }).catch(reason => { + assert.equal(reason.message, 'error goes here', 'valid error was thrown'); + }); + }); +});