Skip to content

Commit

Permalink
Add a typeIn helper with optional delay
Browse files Browse the repository at this point in the history
  • Loading branch information
mfeckie committed Aug 1, 2018
1 parent a4253d1 commit 3478211
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 3 deletions.
98 changes: 98 additions & 0 deletions addon-test-support/@ember/test-helpers/dom/type-in.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { nextTickPromise } from '../-utils';
import settled from '../settled';
import getElement from './-get-element';
import isFormControl from './-is-form-control';
import { __focus__ } from './focus';
import { Promise } from 'rsvp';
import fireEvent from './fire-event';

/**
* Mimics character by character entry into the target `input` or `textarea` element.
*
* Allows for simulation of slow entry by passing an optional millisecond delay
* between key events.
* The major difference between `typeIn` and `fillIn` is that `typeIn` triggers
* keyboard events as well as `input` and `change`.
* Typically this looks like `focus` -> `focusin` -> `keydown` -> `keypress` -> `keyup` -> `input` -> `change`
* per character of the passed text (this may vary on some browsers).
*
* @public
* @param {string|Element} target the element or selector to enter text into
* @param {string} text the test to fill the element with
* @param {Object} options {delay: x} (default 50) number of milliseconds to wait per keypress
* @return {Promise<void>} resolves when the application is settled
*/
export default function typeIn(target, text, options = { delay: 50 }) {
return nextTickPromise().then(() => {
if (!target) {
throw new Error('Must pass an element or selector to `typeIn`.');
}

const element = getElement(target);
if (!element) {
throw new Error(`Element not found when calling \`typeIn('${target}')\``);
}
let isControl = isFormControl(element);
if (!isControl) {
throw new Error('`typeIn` is only usable on form controls.');
}

if (typeof text === 'undefined' || text === null) {
throw new Error('Must provide `text` when calling `typeIn`.');
}

__focus__(element);

return fillOut(element, text, options.delay)
.then(() => fireEvent(element, 'change'))
.then(settled);
});
}

// eslint-disable-next-line require-jsdoc
function fillOut(element, text, delay) {
return new Promise(resolve => {
executeEvents(element, text, delay).then(resolve);
});
}

// eslint-disable-next-line require-jsdoc
function keyEntry(element, character) {
const charCode = character.charCodeAt();

const eventOptions = {
bubbles: true,
cancellable: true,
charCode,
};

const keyEvents = {
down: new KeyboardEvent('keydown', eventOptions),
press: new KeyboardEvent('keypress', eventOptions),
up: new KeyboardEvent('keyup', eventOptions),
};

return function() {
element.dispatchEvent(keyEvents.down);
element.dispatchEvent(keyEvents.press);
element.value = element.value + character;
fireEvent(element, 'input');
element.dispatchEvent(keyEvents.up);
};
}

// eslint-disable-next-line require-jsdoc
function executeEvents(element, text, delay) {
const inputFunctions = text.split('').map(character => keyEntry(element, character, delay));
return inputFunctions.reduce((currentPromise, func) => {
return currentPromise.then(() => delayedExecute(func, delay));
}, Promise.resolve());
}

// eslint-disable-next-line require-jsdoc
function delayedExecute(func, delay) {
return new Promise(resolve => {
setTimeout(resolve, delay);
}).then(func);
}
1 change: 1 addition & 0 deletions addon-test-support/@ember/test-helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export { default as waitFor } from './dom/wait-for';
export { default as getRootElement } from './dom/get-root-element';
export { default as find } from './dom/find';
export { default as findAll } from './dom/find-all';
export { default as typeIn } from './dom/type-in';
7 changes: 4 additions & 3 deletions documentation.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
toc:
- name: DOM Interaction Helpers
children:
- blur
- click
- doubleClick
- tap
- fillIn
- focus
- blur
- tap
- triggerEvent
- triggerKeyEvent
- fillIn
- typeIn

- name: DOM Query Helpers
children:
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/dom/type-in-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { module, test } from 'qunit';
import { typeIn, setupContext, teardownContext } from '@ember/test-helpers';
import { buildInstrumentedElement } from '../../helpers/events';
import { isIE11 } from '../../helpers/browser-detect';
import hasEmberVersion from 'ember-test-helpers/has-ember-version';

/*
* Event order based on https://jsbin.com/zitazuxabe/edit?html,js,console,output
*/

let expectedEvents = [
'focus',
'focusin',
'keydown',
'keypress',
'input',
'keyup',
'keydown',
'keypress',
'input',
'keyup',
'keydown',
'keypress',
'input',
'keyup',
'change',
];

if (isIE11) {
expectedEvents = [
'focusin',
'keydown',
'keypress',
'keyup',
'keydown',
'keypress',
'keyup',
'keydown',
'keypress',
'keyup',
'input',
'change',
'focus',
];
}

module('DOM Helper: typeIn', function(hooks) {
if (!hasEmberVersion(2, 4)) {
return;
}

let context, element;

hooks.beforeEach(function() {
context = {};
});

hooks.afterEach(async function() {
element.setAttribute('data-skip-steps', true);

if (element) {
element.parentNode.removeChild(element);
}
if (context.owner) {
await teardownContext(context);
}

document.getElementById('ember-testing').innerHTML = '';
});

test('filling in an input', async function(assert) {
element = buildInstrumentedElement('input');
await typeIn(element, 'foo');

assert.verifySteps(expectedEvents);
assert.strictEqual(document.activeElement, element, 'activeElement updated');
assert.equal(element.value, 'foo');
});

test('filling in an input with a delay', async function(assert) {
element = buildInstrumentedElement('input');
await typeIn(element, 'foo', { delay: 150 });

assert.verifySteps(expectedEvents);
assert.strictEqual(document.activeElement, element, 'activeElement updated');
assert.equal(element.value, 'foo');
});

test('filling in a textarea', async function(assert) {
element = buildInstrumentedElement('textarea');
await typeIn(element, 'foo');

assert.verifySteps(expectedEvents);
assert.strictEqual(document.activeElement, element, 'activeElement updated');
assert.equal(element.value, 'foo');
});

test('filling in a non-fillable element', async function(assert) {
element = buildInstrumentedElement('div');

await setupContext(context);
assert.rejects(typeIn(`#${element.id}`, 'foo'), /`typeIn` is only usable on form controls/);
});

test('rejects if selector is not found', async function(assert) {
element = buildInstrumentedElement('div');

await setupContext(context);

assert.rejects(
typeIn(`#foo-bar-baz-not-here-ever-bye-bye`, 'foo'),
/Element not found when calling `typeIn\('#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(typeIn(element), /Must provide `text` when calling `typeIn`/);
});

test('does not run sync', async function(assert) {
element = buildInstrumentedElement('input');

let promise = typeIn(element, 'foo');

assert.verifySteps([]);

await promise;

assert.verifySteps(expectedEvents);
assert.strictEqual(document.activeElement, element, 'activeElement updated');
assert.equal(element.value, 'foo');
});
});

0 comments on commit 3478211

Please sign in to comment.