From fe013b30dc6fafba21c111fade0ae3a84b1b1cd5 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 26 Mar 2020 18:17:02 -0700 Subject: [PATCH] Add dom-event-testing-libary as a private package This is copied from facebook/react with various fixes applied (which I'll push upstream at a later date). Necessary for testing the Responder Event System rewrite. --- packages/dom-event-testing-library/README.md | 156 +++++++ packages/dom-event-testing-library/index.js | 1 + .../dom-event-testing-library/package.json | 10 + .../__snapshots__/index-test.js.snap | 15 + .../src/__tests__/index-test.js | 365 +++++++++++++++ .../src/constants.js | 63 +++ .../src/createEvent.js | 268 +++++++++++ .../src/domEnvironment.js | 67 +++ .../src/domEventSequences.js | 382 +++++++++++++++ .../src/domEvents.js | 438 ++++++++++++++++++ .../dom-event-testing-library/src/index.js | 126 +++++ .../src/testHelpers.js | 33 ++ .../src/touchStore.js | 84 ++++ 13 files changed, 2008 insertions(+) create mode 100644 packages/dom-event-testing-library/README.md create mode 100644 packages/dom-event-testing-library/index.js create mode 100644 packages/dom-event-testing-library/package.json create mode 100644 packages/dom-event-testing-library/src/__tests__/__snapshots__/index-test.js.snap create mode 100644 packages/dom-event-testing-library/src/__tests__/index-test.js create mode 100644 packages/dom-event-testing-library/src/constants.js create mode 100644 packages/dom-event-testing-library/src/createEvent.js create mode 100644 packages/dom-event-testing-library/src/domEnvironment.js create mode 100644 packages/dom-event-testing-library/src/domEventSequences.js create mode 100644 packages/dom-event-testing-library/src/domEvents.js create mode 100644 packages/dom-event-testing-library/src/index.js create mode 100644 packages/dom-event-testing-library/src/testHelpers.js create mode 100644 packages/dom-event-testing-library/src/touchStore.js diff --git a/packages/dom-event-testing-library/README.md b/packages/dom-event-testing-library/README.md new file mode 100644 index 00000000..481ef073 --- /dev/null +++ b/packages/dom-event-testing-library/README.md @@ -0,0 +1,156 @@ +# `dom-event-testing-library` + +A library for unit testing high-level interactions via simple pointer events, e.g., +`pointerdown`, that produce realistic and complete DOM event sequences. + +There are number of challenges involved in unit testing modules that work with +DOM events. + +1. Testing environments with and without support for the `PointerEvent` API. +2. Testing various user interaction modes including mouse, touch, and pen use. +3. Testing against the event sequences browsers actually produce (e.g., emulated + touch and mouse events.) +4. Testing against the event properties DOM events include (i.e., more complete + mock data) +4. Testing against "virtual" events produced by tools like screen-readers. + +Writing unit tests to cover all these scenarios is tedious and error prone. This +event testing library is designed to avoid these issues by allowing developers to +more easily dispatch events in unit tests, and to more reliably test interactions +while using an API based on `PointerEvent`. + +## Example + +```js +import { + describeWithPointerEvent, + testWithPointerType, + clearPointers, + createEventTarget, + setPointerEvent, +} from 'dom-event-testing-library'; + +describeWithPointerEvent('useTap', hasPointerEvent => { + beforeEach(() => { + // basic PointerEvent mock + setPointerEvent(hasPointerEvent); + }); + + afterEach(() => { + // clear active pointers between test runs + clearPointers(); + }); + + // test all the pointer types supported by the environment + testWithPointerType('pointer down', pointerType => { + const ref = createRef(null); + const onTapStart = jest.fn(); + render(() => { + useTap(ref, { onTapStart }); + return
+ }); + + // create an event target + const target = createEventTarget(ref.current); + // dispatch high-level pointer event + target.pointerdown({ pointerType }); + expect(onTapStart).toBeCalled(); + }); +}); +``` + +This tests the interaction in multiple scenarios. In each case, a realistic DOM +event sequence–with complete mock events–is produced. When running in a mock +environment without the `PointerEvent` API, the test runs for both `mouse` and +`touch` pointer types. When `touch` is the pointer type it produces emulated mouse +events. When running in a mock environment with the `PointerEvent` API, the test +runs for `mouse`, `touch`, and `pen` pointer types. + +It's important to cover all these scenarios because it's very easy to introduce +bugs – e.g., double calling of callbacks – if not accounting for emulated mouse +events, differences in target capturing between `touch` and `mouse` pointers, and +the different semantics of `button` across event APIs. + +Default values are provided for the expected native events properties. They can +also be customized as needed in a test. + +```js +target.pointerdown({ + button: 0, + buttons: 1, + pageX: 10, + pageY: 10, + pointerType, + // NOTE: use x,y instead of clientX,clientY + x: 10, + y: 10 +}); +``` + +Tests that dispatch multiple pointer events will dispatch multi-touch native events +on the target. + +```js +// first pointer is active +target.pointerdown({pointerId: 1, pointerType}); +// second pointer is active +target.pointerdown({pointerId: 2, pointerType}); +``` + +## API + +### Target and events + +To create a new event target pass the DOM node to `createEventTarget(node)`. This target can then be used to dispatch event sequences and customize the event payload. The following are currently supported: + +* `blur` +* `click` +* `contextmenu` +* `focus` +* `keydown` +* `keyup` +* `pointercancel` +* `pointerdown` +* `pointerhover` (moves when pointer is not down) +* `pointermove` (moves when pointer is down) +* `pointerover` +* `pointerout` +* `scroll` +* `select` +* `selectionchange` +* `tap` (equivalent to `pointerdown` followed by `pointerup`) +* `virtualclick` + +The target also has `node` property equal to the node that was used to create the target, and a `setBoundClientRect({x,y,width,height})` method that can be used to mock the return value of `getBoundingClientRect`. + +### Jest helpers + +#### `describeWithPointerEvent` + +This is just like `describe` but it will run the entire test suite twice, once in an environment with `PointerEvent` mocked and once without. + +```js +describeWithPointerEvent('useTap', hasPointerEvent => { + // test suite +}); +``` + +#### `testWithPointerType` + +The is just like `test` but it will run the test for every pointer type supported by the environment. When `PointerEvent` is mocked, the pointer types will be `mouse`, `touch`, and `pen`; otherwise the pointer types will be `mouse` and `touch`. + +```js +testWithPointerType('pointer down', pointerType => { + // test unit +}); +``` + +### jsdom environment helpers + +#### platform + +Interactions that account for Windows / macOS differences can change the platform by calling `platform.set(value)`, where `value` can be either `'mac'` or `'windows'`. To retreive the current platform call `platform.get()`, and the clear it call `platform.clear()`. + +#### hasPointerEvent / setPointerEvent + +Interactions implemented using `PointerEvent` can create a basic mock for jsdom by calling `setPointerEvent(true)` (disable with `setPointerEvent(false)`), and check whether `PointerEvent` is available by calling `hasPointerEvent()`. diff --git a/packages/dom-event-testing-library/index.js b/packages/dom-event-testing-library/index.js new file mode 100644 index 00000000..cba18435 --- /dev/null +++ b/packages/dom-event-testing-library/index.js @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/packages/dom-event-testing-library/package.json b/packages/dom-event-testing-library/package.json new file mode 100644 index 00000000..1c96ceb6 --- /dev/null +++ b/packages/dom-event-testing-library/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "name": "dom-event-testing-library", + "version": "0.0.0", + "main": "index.js", + "description": "Browser event sequences for unit tests", + "author": "Nicolas Gallagher", + "license": "MIT", + "homepage": "https://github.com/necolas/react-native-web/tree/master/packages/dom-event-testing-library" +} diff --git a/packages/dom-event-testing-library/src/__tests__/__snapshots__/index-test.js.snap b/packages/dom-event-testing-library/src/__tests__/__snapshots__/index-test.js.snap new file mode 100644 index 00000000..ddb68961 --- /dev/null +++ b/packages/dom-event-testing-library/src/__tests__/__snapshots__/index-test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`describeWithPointerEvent: MouseEvent/TouchEvent provides boolean to tests 1`] = `false`; + +exports[`describeWithPointerEvent: MouseEvent/TouchEvent testWithPointerType: mouse 1`] = `"mouse"`; + +exports[`describeWithPointerEvent: MouseEvent/TouchEvent testWithPointerType: touch 1`] = `"touch"`; + +exports[`describeWithPointerEvent: PointerEvent provides boolean to tests 1`] = `true`; + +exports[`describeWithPointerEvent: PointerEvent testWithPointerType: mouse 1`] = `"mouse"`; + +exports[`describeWithPointerEvent: PointerEvent testWithPointerType: pen 1`] = `"pen"`; + +exports[`describeWithPointerEvent: PointerEvent testWithPointerType: touch 1`] = `"touch"`; diff --git a/packages/dom-event-testing-library/src/__tests__/index-test.js b/packages/dom-event-testing-library/src/__tests__/index-test.js new file mode 100644 index 00000000..eaac1c88 --- /dev/null +++ b/packages/dom-event-testing-library/src/__tests__/index-test.js @@ -0,0 +1,365 @@ +/* eslint-env jasmine, jest */ + +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { defaultBrowserChromeSize } from '../constants'; + +import { + clearPointers, + createEventTarget, + describeWithPointerEvent, + testWithPointerType +} from '../index'; + +/** + * Unit test helpers + */ +describeWithPointerEvent('describeWithPointerEvent', pointerEvent => { + test('provides boolean to tests', () => { + expect(pointerEvent).toMatchSnapshot(); + }); + + testWithPointerType('testWithPointerType', pointerType => { + expect(pointerType).toMatchSnapshot(); + }); +}); + +/** + * createEventTarget + */ +describe('createEventTarget', () => { + let node; + beforeEach(() => { + node = document.createElement('div'); + }); + + afterEach(() => { + node = null; + clearPointers(); + }); + + test('returns expected API', () => { + const target = createEventTarget(node); + expect(target.node).toEqual(node); + expect(Object.keys(target)).toMatchInlineSnapshot(` + Array [ + "node", + "blur", + "click", + "focus", + "keydown", + "keyup", + "scroll", + "select", + "selectionchange", + "virtualclick", + "contextmenu", + "pointercancel", + "pointerdown", + "pointerhover", + "pointermove", + "pointerover", + "pointerout", + "pointerup", + "tap", + "setBoundingClientRect", + ] + `); + }); + + /** + * Simple events + */ + + describe('.blur()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('blur', e => { + expect(e.relatedTarget).toBeUndefined(); + }); + target.blur(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + const relatedTarget = document.createElement('div'); + node.addEventListener('blur', e => { + expect(e.relatedTarget).toBe(relatedTarget); + }); + target.blur({ relatedTarget }); + }); + }); + + describe('.click()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('click', e => { + expect(e.altKey).toEqual(false); + expect(e.button).toEqual(0); + expect(e.buttons).toEqual(0); + expect(e.clientX).toEqual(0); + expect(e.clientY).toEqual(0); + expect(e.ctrlKey).toEqual(false); + expect(e.detail).toEqual(1); + expect(typeof e.getModifierState).toEqual('function'); + expect(e.metaKey).toEqual(false); + expect(e.movementX).toEqual(0); + expect(e.movementY).toEqual(0); + expect(e.offsetX).toEqual(0); + expect(e.offsetY).toEqual(0); + expect(e.pageX).toEqual(0); + expect(e.pageY).toEqual(0); + expect(typeof e.preventDefault).toEqual('function'); + expect(e.screenX).toEqual(0); + expect(e.screenY).toEqual(defaultBrowserChromeSize); + expect(e.shiftKey).toEqual(false); + expect(typeof e.timeStamp).toEqual('number'); + }); + target.click(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + node.addEventListener('click', e => { + expect(e.altKey).toEqual(true); + expect(e.button).toEqual(1); + expect(e.buttons).toEqual(4); + expect(e.clientX).toEqual(10); + expect(e.clientY).toEqual(20); + expect(e.ctrlKey).toEqual(true); + expect(e.metaKey).toEqual(true); + expect(e.movementX).toEqual(1); + expect(e.movementY).toEqual(2); + expect(e.offsetX).toEqual(5); + expect(e.offsetY).toEqual(5); + expect(e.pageX).toEqual(50); + expect(e.pageY).toEqual(50); + expect(e.screenX).toEqual(10); + expect(e.screenY).toEqual(20 + defaultBrowserChromeSize); + expect(e.shiftKey).toEqual(true); + }); + target.click({ + altKey: true, + button: 1, + buttons: 4, + x: 10, + y: 20, + ctrlKey: true, + metaKey: true, + movementX: 1, + movementY: 2, + offsetX: 5, + offsetY: 5, + pageX: 50, + pageY: 50, + shiftKey: true + }); + }); + }); + + describe('.focus()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('focus', e => { + expect(e.relatedTarget).toBeUndefined(); + }); + target.focus(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + const relatedTarget = document.createElement('div'); + node.addEventListener('focus', e => { + expect(e.relatedTarget).toBe(relatedTarget); + }); + target.focus({ relatedTarget }); + }); + }); + + describe('.keydown()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('keydown', e => { + expect(e.altKey).toEqual(false); + expect(e.ctrlKey).toEqual(false); + expect(typeof e.getModifierState).toEqual('function'); + expect(e.key).toEqual(''); + expect(e.metaKey).toEqual(false); + expect(typeof e.preventDefault).toEqual('function'); + expect(e.shiftKey).toEqual(false); + expect(typeof e.timeStamp).toEqual('number'); + }); + target.keydown(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + node.addEventListener('keydown', e => { + expect(e.altKey).toEqual(true); + expect(e.ctrlKey).toEqual(true); + expect(e.isComposing).toEqual(true); + expect(e.key).toEqual('Enter'); + expect(e.metaKey).toEqual(true); + expect(e.shiftKey).toEqual(true); + }); + target.keydown({ + altKey: true, + ctrlKey: true, + isComposing: true, + key: 'Enter', + metaKey: true, + shiftKey: true + }); + }); + }); + + describe('.keyup()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('keyup', e => { + expect(e.altKey).toEqual(false); + expect(e.ctrlKey).toEqual(false); + expect(typeof e.getModifierState).toEqual('function'); + expect(e.key).toEqual(''); + expect(e.metaKey).toEqual(false); + expect(typeof e.preventDefault).toEqual('function'); + expect(e.shiftKey).toEqual(false); + expect(typeof e.timeStamp).toEqual('number'); + }); + target.keydown(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + node.addEventListener('keyup', e => { + expect(e.altKey).toEqual(true); + expect(e.ctrlKey).toEqual(true); + expect(e.isComposing).toEqual(true); + expect(e.key).toEqual('Enter'); + expect(e.metaKey).toEqual(true); + expect(e.shiftKey).toEqual(true); + }); + target.keyup({ + altKey: true, + ctrlKey: true, + isComposing: true, + key: 'Enter', + metaKey: true, + shiftKey: true + }); + }); + }); + + describe('.scroll()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('scroll', e => { + expect(e.type).toEqual('scroll'); + }); + target.scroll(); + }); + }); + + describe('.virtualclick()', () => { + test('default', () => { + const target = createEventTarget(node); + node.addEventListener('click', e => { + expect(e.altKey).toEqual(false); + expect(e.button).toEqual(0); + expect(e.buttons).toEqual(0); + expect(e.clientX).toEqual(0); + expect(e.clientY).toEqual(0); + expect(e.ctrlKey).toEqual(false); + expect(e.detail).toEqual(0); + expect(typeof e.getModifierState).toEqual('function'); + expect(e.metaKey).toEqual(false); + expect(e.movementX).toEqual(0); + expect(e.movementY).toEqual(0); + expect(e.offsetX).toEqual(0); + expect(e.offsetY).toEqual(0); + expect(e.pageX).toEqual(0); + expect(e.pageY).toEqual(0); + expect(typeof e.preventDefault).toEqual('function'); + expect(e.screenX).toEqual(0); + expect(e.screenY).toEqual(0); + expect(e.shiftKey).toEqual(false); + expect(typeof e.timeStamp).toEqual('number'); + }); + target.virtualclick(); + }); + + test('custom payload', () => { + const target = createEventTarget(node); + node.addEventListener('click', e => { + // expect most of the custom payload to be ignored + expect(e.altKey).toEqual(true); + expect(e.button).toEqual(1); + expect(e.buttons).toEqual(0); + expect(e.clientX).toEqual(0); + expect(e.clientY).toEqual(0); + expect(e.ctrlKey).toEqual(true); + expect(e.detail).toEqual(0); + expect(e.metaKey).toEqual(true); + expect(e.pageX).toEqual(0); + expect(e.pageY).toEqual(0); + expect(e.screenX).toEqual(0); + expect(e.screenY).toEqual(0); + expect(e.shiftKey).toEqual(true); + }); + target.virtualclick({ + altKey: true, + button: 1, + buttons: 4, + x: 10, + y: 20, + ctrlKey: true, + metaKey: true, + pageX: 50, + pageY: 50, + shiftKey: true + }); + }); + }); + + /** + * Complex event sequences + * + * ...coming soon + */ + + /** + * Other APIs + */ + + test('.setBoundingClientRect()', () => { + const target = createEventTarget(node); + expect(node.getBoundingClientRect()).toMatchInlineSnapshot(` + Object { + "bottom": 0, + "height": 0, + "left": 0, + "right": 0, + "top": 0, + "width": 0, + } + `); + target.setBoundingClientRect({ x: 10, y: 20, width: 100, height: 200 }); + expect(node.getBoundingClientRect()).toMatchInlineSnapshot(` + Object { + "bottom": 220, + "height": 200, + "left": 10, + "right": 110, + "top": 20, + "width": 100, + } + `); + }); +}); diff --git a/packages/dom-event-testing-library/src/constants.js b/packages/dom-event-testing-library/src/constants.js new file mode 100644 index 00000000..10d67746 --- /dev/null +++ b/packages/dom-event-testing-library/src/constants.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +export const defaultPointerId = 1; +export const defaultPointerSize = 23; +export const defaultBrowserChromeSize = 50; + +/** + * Button property + * This property only guarantees to indicate which buttons are pressed during events caused by pressing or + * releasing one or multiple buttons. As such, it is not reliable for events such as 'mouseenter', 'mouseleave', + * 'mouseover', 'mouseout' or 'mousemove'. Furthermore, the semantics differ for PointerEvent, where the value + * for 'pointermove' will always be -1. + */ + +export const buttonType = { + // no change since last event + none: -1, + // left-mouse + // touch contact + // pen contact + primary: 0, + // right-mouse + // pen barrel button + secondary: 2, + // middle mouse + auxiliary: 1, + // back mouse + back: 3, + // forward mouse + forward: 4, + // pen eraser + eraser: 5 +}; + +/** + * Buttons bitmask + */ + +export const buttonsType = { + none: 0, + // left-mouse + // touch contact + // pen contact + primary: 1, + // right-mouse + // pen barrel button + secondary: 2, + // middle mouse + auxiliary: 4, + // back mouse + back: 8, + // forward mouse + forward: 16, + // pen eraser + eraser: 32 +}; diff --git a/packages/dom-event-testing-library/src/createEvent.js b/packages/dom-event-testing-library/src/createEvent.js new file mode 100644 index 00000000..84f483d8 --- /dev/null +++ b/packages/dom-event-testing-library/src/createEvent.js @@ -0,0 +1,268 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const defaultConfig = { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: true, composed: true } +}; + +const eventConfigs = { + // Focus Events + blur: { + constructorType: 'FocusEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + focus: { + constructorType: 'FocusEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + focusin: { + constructorType: 'FocusEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + focusout: { + constructorType: 'FocusEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + // Keyboard Events + keydown: { + constructorType: 'KeyboardEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + keyup: { + constructorType: 'KeyboardEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Mouse Events + click: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + contextmenu: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + dblclick: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + drag: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + dragend: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + dragenter: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + dragexit: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + dragleave: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + dragover: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + dragstart: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + drop: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + mousedown: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + mouseenter: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + mouseleave: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + mousemove: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + mouseout: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + mouseover: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + mouseup: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Selection events + select: { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: false } + }, + // Touch events + touchcancel: { + constructorType: 'TouchEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + touchend: { + constructorType: 'TouchEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + touchmove: { + constructorType: 'TouchEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + touchstart: { + constructorType: 'TouchEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Pointer events + gotpointercapture: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + lostpointercapture: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: false, cancelable: false, composed: true } + }, + pointercancel: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + pointerdown: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + pointerenter: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: false, cancelable: false } + }, + pointerleave: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: false, cancelable: false } + }, + pointermove: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + pointerout: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + pointerover: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + pointerup: { + constructorType: 'PointerEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Image events + error: { + constructorType: 'Event', + defaultInit: { bubbles: false, cancelable: false } + }, + load: { + constructorType: 'UIEvent', + defaultInit: { bubbles: false, cancelable: false } + }, + // Form Events + change: { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: false } + }, + input: { + constructorType: 'InputEvent', + defaultInit: { bubbles: true, cancelable: false, composed: true } + }, + invalid: { + constructorType: 'Event', + defaultInit: { bubbles: false, cancelable: true } + }, + submit: { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: true } + }, + reset: { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: true } + }, + // Clipboard Events + copy: { + constructorType: 'ClipboardEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + cut: { + constructorType: 'ClipboardEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + paste: { + constructorType: 'ClipboardEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Composition Events + compositionend: { + constructorType: 'CompositionEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + compositionstart: { + constructorType: 'CompositionEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + compositionupdate: { + constructorType: 'CompositionEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + }, + // Other events + scroll: { + constructorType: 'UIEvent', + defaultInit: { bubbles: false, cancelable: false } + }, + wheel: { + constructorType: 'WheelEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } + } +}; + +function getEventConfig(type) { + return eventConfigs[type] || defaultConfig; +} + +export default function createEvent(type, init) { + const config = getEventConfig(type); + const { constructorType, defaultInit } = config; + const eventInit = { ...init, ...defaultInit }; + + const event = document.createEvent(constructorType); + const { bubbles, cancelable, ...data } = eventInit; + event.initEvent(type, bubbles, cancelable); + + if (data != null) { + Object.keys(data).forEach(key => { + const value = data[key]; + if (key === 'timeStamp' && !value) { + return; + } + Object.defineProperty(event, key, { value }); + }); + } + return event; +} diff --git a/packages/dom-event-testing-library/src/domEnvironment.js b/packages/dom-event-testing-library/src/domEnvironment.js new file mode 100644 index 00000000..39f3efe9 --- /dev/null +++ b/packages/dom-event-testing-library/src/domEnvironment.js @@ -0,0 +1,67 @@ +/* eslint-env jasmine, jest */ + +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +/** + * Change environment support for PointerEvent. + */ + +const emptyFunction = function() {}; + +export function hasPointerEvent() { + return global != null && global.PointerEvent != null; +} + +export function setPointerEvent(bool) { + const pointerCaptureFn = name => id => { + if (typeof id !== 'number') { + if (process.env.NODE_DEV !== 'production') { + console.error('A pointerId must be passed to "%s"', name); + } + } + }; + global.PointerEvent = bool ? emptyFunction : undefined; + global.HTMLElement.prototype.setPointerCapture = bool + ? pointerCaptureFn('setPointerCapture') + : undefined; + global.HTMLElement.prototype.releasePointerCapture = bool + ? pointerCaptureFn('releasePointerCapture') + : undefined; +} + +/** + * Change environment host platform. + */ + +const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); + +export const platform = { + clear() { + platformGetter.mockClear(); + }, + get() { + return global.navigator.platform === 'MacIntel' ? 'mac' : 'windows'; + }, + set(name) { + switch (name) { + case 'mac': { + platformGetter.mockReturnValue('MacIntel'); + break; + } + case 'windows': { + platformGetter.mockReturnValue('Win32'); + break; + } + default: { + break; + } + } + } +}; diff --git a/packages/dom-event-testing-library/src/domEventSequences.js b/packages/dom-event-testing-library/src/domEventSequences.js new file mode 100644 index 00000000..bbd31e2d --- /dev/null +++ b/packages/dom-event-testing-library/src/domEventSequences.js @@ -0,0 +1,382 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { + buttonType, + buttonsType, + defaultPointerId, + defaultPointerSize, + defaultBrowserChromeSize +} from './constants'; +import * as domEvents from './domEvents'; +import { hasPointerEvent, platform } from './domEnvironment'; +import * as touchStore from './touchStore'; + +/** + * Converts a PointerEvent payload to a Touch + */ +function createTouch(target, payload) { + const { + height = defaultPointerSize, + pageX, + pageY, + pointerId, + pressure = 1, + twist = 0, + width = defaultPointerSize, + x = 0, + y = 0 + } = payload; + + return { + clientX: x, + clientY: y, + force: pressure, + identifier: pointerId, + pageX: pageX || x, + pageY: pageY || y, + radiusX: width / 2, + radiusY: height / 2, + rotationAngle: twist, + target, + screenX: x, + screenY: y + defaultBrowserChromeSize + }; +} + +/** + * Converts a PointerEvent to a TouchEvent + */ +function createTouchEventPayload(target, touch, payload) { + const { + altKey = false, + ctrlKey = false, + metaKey = false, + preventDefault, + shiftKey = false, + timeStamp + } = payload; + + return { + altKey, + changedTouches: [touch], + ctrlKey, + metaKey, + preventDefault, + shiftKey, + targetTouches: touchStore.getTargetTouches(target), + timeStamp, + touches: touchStore.getTouches() + }; +} + +function getPointerType(payload) { + let pointerType = 'mouse'; + if (payload != null && payload.pointerType != null) { + pointerType = payload.pointerType; + } + return pointerType; +} + +/** + * Pointer events sequences. + * + * Creates representative browser event sequences for high-level gestures based on pointers. + * This allows unit tests to be written in terms of simple pointer interactions while testing + * that the responses to those interactions account for the complex sequence of events that + * browsers produce as a result. + * + * Every time a new pointer touches the surface a 'touchstart' event should be dispatched. + * - 'changedTouches' contains the new touch. + * - 'targetTouches' contains all the active pointers for the target. + * - 'touches' contains all the active pointers on the surface. + * + * Every time an existing pointer moves a 'touchmove' event should be dispatched. + * - 'changedTouches' contains the updated touch. + * + * Every time an existing pointer leaves the surface a 'touchend' event should be dispatched. + * - 'changedTouches' contains the released touch. + * - 'targetTouches' contains any of the remaining active pointers for the target. + */ + +export function contextmenu(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + + const { + ctrlKey, + // eslint-disable-next-line + pointerType: _, + ...restPayload + } = defaultPayload; + + const payload = { + pointerId: defaultPointerId, + ...restPayload, + button: buttonType.primary, + buttons: buttonsType.primary, + pointerType + }; + + const preventDefault = payload.preventDefault; + + if (pointerType === 'touch') { + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown(payload)); + } + const touch = createTouch(target, payload); + touchStore.addTouch(touch); + const touchEventPayload = createTouchEventPayload(target, touch, payload); + dispatch(domEvents.touchstart(touchEventPayload)); + dispatch( + domEvents.mousemove({ + ...payload, + button: buttonType.primary, + buttons: buttonsType.none + }) + ); + dispatch( + domEvents.contextmenu({ + ...payload, + button: buttonType.primary, + buttons: buttonsType.none, + preventDefault + }) + ); + touchStore.removeTouch(touch); + } else if (pointerType === 'mouse') { + if (ctrlKey === true) { + const { button, buttons } = payload; + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown({ ...payload, ctrlKey })); + } + dispatch(domEvents.mousedown({ ...payload, ctrlKey })); + if (platform.get() === 'mac') { + dispatch(domEvents.contextmenu({ button, buttons, ctrlKey, preventDefault })); + } + } else { + const button = buttonType.secondary; + const buttons = buttonsType.secondary; + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown({ ...payload, button, buttons })); + } + dispatch(domEvents.mousedown({ ...payload, button, buttons })); + dispatch(domEvents.contextmenu({ ...payload, button, buttons, preventDefault })); + } + } +} + +export function focus(target, defaultPayload = {}) { + const dispatch = arg => target.dispatchEvent(arg); + const { relatedTarget, ...payload } = defaultPayload; + if (relatedTarget) { + relatedTarget.dispatchEvent(domEvents.focusout({ ...payload, relatedTarget: target })); + } + dispatch(domEvents.focusin({ ...payload, relatedTarget })); + if (relatedTarget) { + relatedTarget.dispatchEvent(domEvents.blur({ ...payload, relatedTarget: target })); + } + dispatch(domEvents.focus({ ...payload, relatedTarget })); +} + +export function pointercancel(target, defaultPayload) { + const dispatchEvent = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + + const payload = { + pointerId: defaultPointerId, + pointerType, + ...defaultPayload + }; + + if (hasPointerEvent()) { + dispatchEvent(domEvents.pointercancel(payload)); + } else { + if (pointerType === 'mouse') { + dispatchEvent(domEvents.dragstart(payload)); + } else { + const touch = createTouch(target, payload); + touchStore.removeTouch(touch); + const touchEventPayload = createTouchEventPayload(target, touch, payload); + dispatchEvent(domEvents.touchcancel(touchEventPayload)); + } + } +} + +export function pointerdown(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + + const payload = { + button: buttonType.primary, + buttons: buttonsType.primary, + pointerId: defaultPointerId, + pointerType, + ...defaultPayload + }; + + if (pointerType === 'mouse') { + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + } + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mouseenter(payload)); + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown(payload)); + } + dispatch(domEvents.mousedown(payload)); + focus(target); + } else { + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + dispatch(domEvents.pointerdown(payload)); + } + const touch = createTouch(target, payload); + touchStore.addTouch(touch); + const touchEventPayload = createTouchEventPayload(target, touch, payload); + dispatch(domEvents.touchstart(touchEventPayload)); + if (hasPointerEvent()) { + dispatch(domEvents.gotpointercapture(payload)); + } + } +} + +export function pointerover(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + + const payload = { + pointerId: defaultPointerId, + ...defaultPayload + }; + + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + } + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mouseenter(payload)); +} + +export function pointerout(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + + const payload = { + pointerId: defaultPointerId, + ...defaultPayload + }; + + if (hasPointerEvent()) { + dispatch(domEvents.pointerout(payload)); + dispatch(domEvents.pointerleave(payload)); + } + dispatch(domEvents.mouseout(payload)); + dispatch(domEvents.mouseleave(payload)); +} + +// pointer is not down while moving +export function pointerhover(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + + const payload = { + pointerId: defaultPointerId, + ...defaultPayload + }; + + if (hasPointerEvent()) { + dispatch(domEvents.pointermove(payload)); + } + dispatch(domEvents.mousemove(payload)); +} + +// pointer is down while moving +export function pointermove(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + + const payload = { + button: buttonType.primary, + buttons: buttonsType.primary, + pointerId: defaultPointerId, + pointerType, + ...defaultPayload + }; + + if (pointerType === 'mouse') { + if (hasPointerEvent()) { + dispatch(domEvents.pointermove({ pressure: 0.5, button: -1, ...payload })); + } + dispatch(domEvents.mousemove(payload)); + } else { + if (hasPointerEvent()) { + dispatch( + domEvents.pointermove({ + pressure: 1, + button: -1, + ...payload + }) + ); + } + const touch = createTouch(target, payload); + touchStore.updateTouch(touch); + const touchEventPayload = createTouchEventPayload(target, touch, payload); + dispatch(domEvents.touchmove(touchEventPayload)); + } +} + +export function pointerup(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + + const payload = { + pointerId: defaultPointerId, + pointerType, + ...defaultPayload + }; + + if (pointerType === 'mouse') { + if (hasPointerEvent()) { + dispatch(domEvents.pointerup(payload)); + } + dispatch(domEvents.mouseup(payload)); + dispatch(domEvents.click(payload)); + } else { + if (hasPointerEvent()) { + dispatch(domEvents.pointerup(payload)); + dispatch(domEvents.lostpointercapture(payload)); + dispatch(domEvents.pointerout(payload)); + dispatch(domEvents.pointerleave(payload)); + } + const touch = createTouch(target, payload); + const isGesture = touchStore.removeTouch(touch); + const touchEventPayload = createTouchEventPayload(target, touch, payload); + dispatch(domEvents.touchend(touchEventPayload)); + // emulated mouse events don't occur for multi-touch or after 'touchmove' + if (!isGesture) { + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mousemove(payload)); + dispatch(domEvents.mousedown(payload)); + } + focus(target); + if (!isGesture) { + dispatch(domEvents.mouseup(payload)); + } + dispatch(domEvents.click(payload)); + } +} + +/** + * This function should be called after each test to ensure the touchStore is cleared + * in cases where the mock pointers weren't released before the test completed + * (e.g., a test failed or ran a partial gesture). + */ +export function clearPointers() { + touchStore.clear(); +} diff --git a/packages/dom-event-testing-library/src/domEvents.js b/packages/dom-event-testing-library/src/domEvents.js new file mode 100644 index 00000000..7fc86430 --- /dev/null +++ b/packages/dom-event-testing-library/src/domEvents.js @@ -0,0 +1,438 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import createEvent from './createEvent'; +import { buttonType, buttonsType, defaultPointerSize, defaultBrowserChromeSize } from './constants'; + +/** + * Native event object mocks for higher-level events. + * + * 1. Each event type defines the exact object that it accepts. This ensures + * that no arbitrary properties can be assigned to events, and the properties + * that don't exist on specific event types (e.g., 'pointerType') are not added + * to the respective native event. + * + * 2. Properties that cannot be relied on due to inconsistent browser support (e.g., 'x' and 'y') are not + * added to the native event. Others that shouldn't be arbitrarily customized (e.g., 'screenX') + * are automatically inferred from associated values. + * + * 3. PointerEvent and TouchEvent fields are normalized (e.g., 'rotationAngle' -> 'twist') + */ + +function emptyFunction() {} + +function createGetModifierState(keyArg, data) { + if (keyArg === 'Alt') { + return data.altKey || false; + } + if (keyArg === 'Control') { + return data.ctrlKey || false; + } + if (keyArg === 'Meta') { + return data.metaKey || false; + } + if (keyArg === 'Shift') { + return data.shiftKey || false; + } +} + +/** + * KeyboardEvent + */ + +function createKeyboardEvent( + type, + { + altKey = false, + ctrlKey = false, + isComposing = false, + key = '', + metaKey = false, + preventDefault = emptyFunction, + shiftKey = false + } = {} +) { + const modifierState = { altKey, ctrlKey, metaKey, shiftKey }; + + const eventPayload = { + altKey, + ctrlKey, + getModifierState(keyArg) { + return createGetModifierState(keyArg, modifierState); + }, + isComposing, + key, + metaKey, + preventDefault, + shiftKey + }; + + if (isComposing) { + eventPayload.keyCode = 229; + } + + return createEvent(type, eventPayload); +} + +/** + * MouseEvent + */ + +function createMouseEvent( + type, + { + altKey = false, + button = buttonType.none, + buttons = buttonsType.none, + ctrlKey = false, + detail = 1, + metaKey = false, + movementX = 0, + movementY = 0, + offsetX = 0, + offsetY = 0, + pageX, + pageY, + preventDefault = emptyFunction, + screenX, + screenY, + shiftKey = false, + timeStamp, + x = 0, + y = 0 + } = {} +) { + const modifierState = { altKey, ctrlKey, metaKey, shiftKey }; + + return createEvent(type, { + altKey, + button, + buttons, + clientX: x, + clientY: y, + ctrlKey, + detail, + getModifierState(keyArg) { + return createGetModifierState(keyArg, modifierState); + }, + metaKey, + movementX, + movementY, + offsetX, + offsetY, + pageX: pageX || x, + pageY: pageY || y, + preventDefault, + screenX: screenX === 0 ? screenX : x, + screenY: screenY === 0 ? screenY : y + defaultBrowserChromeSize, + shiftKey, + timeStamp + }); +} + +/** + * PointerEvent + */ + +function createPointerEvent( + type, + { + altKey = false, + button = buttonType.none, + buttons = buttonsType.none, + ctrlKey = false, + detail = 1, + height, + metaKey = false, + movementX = 0, + movementY = 0, + offsetX = 0, + offsetY = 0, + pageX, + pageY, + pointerId, + pressure = 0, + preventDefault = emptyFunction, + pointerType = 'mouse', + screenX, + screenY, + shiftKey = false, + tangentialPressure = 0, + tiltX = 0, + tiltY = 0, + timeStamp, + twist = 0, + width, + x = 0, + y = 0 + } = {} +) { + const modifierState = { altKey, ctrlKey, metaKey, shiftKey }; + const isMouse = pointerType === 'mouse'; + + return createEvent(type, { + altKey, + button, + buttons, + clientX: x, + clientY: y, + ctrlKey, + detail, + getModifierState(keyArg) { + return createGetModifierState(keyArg, modifierState); + }, + height: isMouse ? 1 : height != null ? height : defaultPointerSize, + metaKey, + movementX, + movementY, + offsetX, + offsetY, + pageX: pageX || x, + pageY: pageY || y, + pointerId, + pointerType, + pressure, + preventDefault, + releasePointerCapture: emptyFunction, + screenX: screenX === 0 ? screenX : x, + screenY: screenY === 0 ? screenY : y + defaultBrowserChromeSize, + setPointerCapture: emptyFunction, + shiftKey, + tangentialPressure, + tiltX, + tiltY, + timeStamp, + twist, + width: isMouse ? 1 : width != null ? width : defaultPointerSize + }); +} + +/** + * TouchEvent + */ + +function createTouchEvent(type, payload) { + return createEvent(type, { + preventDefault: emptyFunction, + ...payload, + detail: 0, + sourceCapabilities: { + firesTouchEvents: true + } + }); +} + +/** + * DOM events + */ + +export function blur({ relatedTarget } = {}) { + return createEvent('blur', { relatedTarget }); +} + +export function click(payload) { + return createMouseEvent('click', { + button: buttonType.primary, + ...payload + }); +} + +export function contextmenu(payload) { + return createMouseEvent('contextmenu', { + ...payload, + detail: 0 + }); +} + +export function dragstart(payload) { + return createMouseEvent('dragstart', { + ...payload, + detail: 0 + }); +} + +export function focus({ relatedTarget } = {}) { + return createEvent('focus', { relatedTarget }); +} + +export function focusin({ relatedTarget } = {}) { + return createEvent('focusin', { relatedTarget }); +} + +export function focusout({ relatedTarget } = {}) { + return createEvent('focusout', { relatedTarget }); +} + +export function gotpointercapture(payload) { + return createPointerEvent('gotpointercapture', payload); +} + +export function keydown(payload) { + return createKeyboardEvent('keydown', payload); +} + +export function keyup(payload) { + return createKeyboardEvent('keyup', payload); +} + +export function lostpointercapture(payload) { + return createPointerEvent('lostpointercapture', payload); +} + +export function mousedown(payload) { + // The value of 'button' and 'buttons' for 'mousedown' must not be none. + const button = + payload != null && payload.button !== buttonType.none ? payload.button : buttonType.primary; + const buttons = + payload != null && payload.buttons !== buttonsType.none ? payload.buttons : buttonsType.primary; + + return createMouseEvent('mousedown', { + ...payload, + button, + buttons + }); +} + +export function mouseenter(payload) { + return createMouseEvent('mouseenter', payload); +} + +export function mouseleave(payload) { + return createMouseEvent('mouseleave', payload); +} + +export function mousemove(payload) { + return createMouseEvent('mousemove', { + // 0 is also the uninitialized value (i.e., don't assume it means primary button down) + button: 0, + buttons: 0, + ...payload + }); +} + +export function mouseout(payload) { + return createMouseEvent('mouseout', payload); +} + +export function mouseover(payload) { + return createMouseEvent('mouseover', payload); +} + +export function mouseup(payload) { + return createMouseEvent('mouseup', { + button: buttonType.primary, + ...payload, + buttons: buttonsType.none + }); +} +export function pointercancel(payload) { + return createPointerEvent('pointercancel', { + ...payload, + buttons: 0, + detail: 0, + height: 1, + pageX: 0, + pageY: 0, + pressure: 0, + screenX: 0, + screenY: 0, + width: 1, + x: 0, + y: 0 + }); +} + +export function pointerdown(payload) { + const isTouch = payload != null && payload.pointerType === 'touch'; + return createPointerEvent('pointerdown', { + button: buttonType.primary, + buttons: buttonsType.primary, + pressure: isTouch ? 1 : 0.5, + ...payload + }); +} + +export function pointerenter(payload) { + return createPointerEvent('pointerenter', payload); +} + +export function pointerleave(payload) { + return createPointerEvent('pointerleave', payload); +} + +export function pointermove(payload) { + return createPointerEvent('pointermove', { + ...payload, + button: buttonType.none, + buttons: buttonsType.none + }); +} + +export function pointerout(payload) { + return createPointerEvent('pointerout', payload); +} + +export function pointerover(payload) { + return createPointerEvent('pointerover', payload); +} + +export function pointerup(payload) { + return createPointerEvent('pointerup', { + button: buttonType.primary, + ...payload, + buttons: buttonsType.none, + pressure: 0 + }); +} + +export function scroll() { + return createEvent('scroll', { bubbles: false }); +} + +export function select() { + return createEvent('select'); +} + +export function selectionchange() { + return createEvent('selectionchange'); +} + +export function touchcancel(payload) { + return createTouchEvent('touchcancel', payload); +} + +export function touchend(payload) { + return createTouchEvent('touchend', payload); +} + +export function touchmove(payload) { + return createTouchEvent('touchmove', payload); +} + +export function touchstart(payload) { + return createTouchEvent('touchstart', payload); +} + +export function virtualclick(payload) { + return createMouseEvent('click', { + button: 0, + ...payload, + buttons: 0, + detail: 0, + height: 1, + pageX: 0, + pageY: 0, + pressure: 0, + screenX: 0, + screenY: 0, + width: 1, + x: 0, + y: 0 + }); +} diff --git a/packages/dom-event-testing-library/src/index.js b/packages/dom-event-testing-library/src/index.js new file mode 100644 index 00000000..dc3703bc --- /dev/null +++ b/packages/dom-event-testing-library/src/index.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { buttonType, buttonsType } from './constants'; +import * as domEvents from './domEvents'; +import * as domEventSequences from './domEventSequences'; +import { hasPointerEvent, setPointerEvent, platform } from './domEnvironment'; +import { describeWithPointerEvent, testWithPointerType } from './testHelpers'; + +const createEventTarget = node => ({ + node, + /** + * Simple events abstraction. + */ + blur(payload) { + node.dispatchEvent(domEvents.blur(payload)); + }, + click(payload) { + node.dispatchEvent(domEvents.click(payload)); + }, + focus(payload) { + domEventSequences.focus(node, payload); + try { + node.focus(); + } catch (e) {} + }, + keydown(payload) { + node.dispatchEvent(domEvents.keydown(payload)); + }, + keyup(payload) { + node.dispatchEvent(domEvents.keyup(payload)); + }, + scroll(payload) { + node.dispatchEvent(domEvents.scroll(payload)); + }, + select(payload) { + node.dispatchEvent(domEvents.select(payload)); + }, + // selectionchange is only dispatched on 'document' + selectionchange(payload) { + document.dispatchEvent(domEvents.selectionchange(payload)); + }, + virtualclick(payload) { + node.dispatchEvent(domEvents.virtualclick(payload)); + }, + /** + * PointerEvent abstraction. + * Dispatches the expected sequence of PointerEvents, MouseEvents, and + * TouchEvents for a given environment. + */ + contextmenu(payload) { + domEventSequences.contextmenu(node, payload); + }, + // node no longer receives events for the pointer + pointercancel(payload) { + domEventSequences.pointercancel(node, payload); + }, + // node dispatches down events + pointerdown(payload) { + domEventSequences.pointerdown(node, payload); + }, + // node dispatches move events (pointer is not down) + pointerhover(payload) { + domEventSequences.pointerhover(node, payload); + }, + // node dispatches move events (pointer is down) + pointermove(payload) { + domEventSequences.pointermove(node, payload); + }, + // node dispatches enter & over events + pointerover(payload) { + domEventSequences.pointerover(node, payload); + }, + // node dispatches exit & leave events + pointerout(payload) { + domEventSequences.pointerout(node, payload); + }, + // node dispatches up events + pointerup(payload) { + domEventSequences.pointerup(node, payload); + }, + /** + * Gesture abstractions. + * Helpers for event sequences expected in a gesture. + * target.tap({ pointerType: 'touch' }) + */ + tap(payload) { + domEventSequences.pointerdown(payload); + domEventSequences.pointerup(payload); + }, + /** + * Utilities + */ + setBoundingClientRect({ x, y, width, height }) { + node.getBoundingClientRect = function() { + return { + width, + height, + left: x, + right: x + width, + top: y, + bottom: y + height + }; + }; + } +}); + +const clearPointers = domEventSequences.clearPointers; + +export { + buttonType, + buttonsType, + clearPointers, + createEventTarget, + describeWithPointerEvent, + platform, + hasPointerEvent, + setPointerEvent, + testWithPointerType +}; diff --git a/packages/dom-event-testing-library/src/testHelpers.js b/packages/dom-event-testing-library/src/testHelpers.js new file mode 100644 index 00000000..29d48057 --- /dev/null +++ b/packages/dom-event-testing-library/src/testHelpers.js @@ -0,0 +1,33 @@ +/* eslint-env jasmine, jest */ + +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import { hasPointerEvent, setPointerEvent } from './domEnvironment'; + +export function describeWithPointerEvent(message, describeFn) { + const pointerEvent = 'PointerEvent'; + const fallback = 'MouseEvent/TouchEvent'; + describe.each` + value | name + ${true} | ${pointerEvent} + ${false} | ${fallback} + `(`${message}: $name`, entry => { + const hasPointerEvents = entry.value; + setPointerEvent(hasPointerEvents); + describeFn(hasPointerEvents); + }); +} + +export function testWithPointerType(message, testFn) { + const table = hasPointerEvent() ? ['mouse', 'touch', 'pen'] : ['mouse', 'touch']; + test.each(table)(`${message}: %s`, pointerType => { + testFn(pointerType); + }); +} diff --git a/packages/dom-event-testing-library/src/touchStore.js b/packages/dom-event-testing-library/src/touchStore.js new file mode 100644 index 00000000..81330aed --- /dev/null +++ b/packages/dom-event-testing-library/src/touchStore.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +/** + * Touch events state machine. + * + * Keeps track of the active pointers and allows them to be reflected in touch events. + */ + +let isGesture = false; +const activeTouches = new Map(); + +export function addTouch(touch) { + const identifier = touch.identifier; + const target = touch.target; + if (!activeTouches.has(target)) { + activeTouches.set(target, new Map()); + } + if (activeTouches.get(target).get(identifier)) { + // Do not allow existing touches to be overwritten + console.error( + 'Touch with identifier %s already exists. Did not record touch start.', + identifier + ); + } else { + activeTouches.get(target).set(identifier, touch); + } + isGesture = activeTouches.size > 1; +} + +export function updateTouch(touch) { + const identifier = touch.identifier; + const target = touch.target; + if (activeTouches.get(target) != null) { + activeTouches.get(target).set(identifier, touch); + isGesture = true; + } else { + console.error( + 'Touch with identifier %s does not exist. Cannot record touch move without a touch start.', + identifier + ); + } +} + +export function removeTouch(touch) { + const identifier = touch.identifier; + const target = touch.target; + if (activeTouches.get(target) != null) { + if (activeTouches.get(target).has(identifier)) { + activeTouches.get(target).delete(identifier); + } else { + console.error( + 'Touch with identifier %s does not exist. Cannot record touch end without a touch start.', + identifier + ); + } + } + return isGesture; +} + +export function getTouches() { + const touches = []; + activeTouches.forEach((_, target) => { + touches.push(...getTargetTouches(target)); + }); + return touches; +} + +export function getTargetTouches(target) { + if (activeTouches.get(target) != null) { + return Array.from(activeTouches.get(target).values()); + } + return []; +} + +export function clear() { + activeTouches.clear(); +}