Skip to content

Commit 4130646

Browse files
authored
Add support for simulating events on Components (#211)
* Add support for simulating events on Components * Add changelog entry * PR comment cleanup * Add new feature flag to gate new simulateEvent behavior * Add note about change to shallow host node simulate event behavior
1 parent e061efe commit 4130646

File tree

9 files changed

+280
-27
lines changed

9 files changed

+280
-27
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ build/
33
build-cjs/
44
coverage/
55
node_modules/
6+
*.tgz

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
- Add a feature flag (`simulateEventsOnComponents`) for supporting simulating
11+
events on Components
12+
[#211](https://github.com/preactjs/enzyme-adapter-preact-pure/pull/211)
13+
14+
This new feature flag turns on behavior that enables calling `.simulate`
15+
directly on Components. For shallow rendering, this directly calls the
16+
component's corresponding prop. For mount rendering, it finds the first DOM
17+
node in the Component, and dispatches the event from it.
18+
19+
NOTE: This flag changes the behavior of calling `simulate` on shallow rendered
20+
host (a.k.a DOM) nodes. When this flag is off, `simulate` dispatches a native
21+
DOM event on the host node. When this flag is turned on, `simulate` directly
22+
calls the prop of the event handler with arguments passed to `simulate`.
23+
24+
The behavior turned on by this flag matches the behavior of the React 16
25+
enzyme adapter.
26+
827
## [4.0.1] - 2022-04-15
928

1029
- Added a partial fix for an incompatibility between Preact's JSX element type

src/Adapter.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,28 @@ import StringRenderer from './StringRenderer.js';
1515
import { childElements } from './compat.js';
1616
import { rstNodeFromElement } from './preact10-rst.js';
1717
import RootFinder from './RootFinder.js';
18+
import { nodeToHostNode } from './util.js';
1819

1920
export const { EnzymeAdapter } = enzyme;
2021

22+
export interface PreactAdapterOptions {
23+
/**
24+
* Turn on behavior that enables calling `.simulateEvent` directly on
25+
* Components. For shallow rendering, this directly calls the component's
26+
* corresponding prop. For mount rendering, it finds the first DOM node in the
27+
* Component, and dispatches the event from it. This behavior matches the
28+
* behavior of the React 16 enzyme adapter.
29+
*/
30+
simulateEventsOnComponents?: boolean;
31+
}
32+
2133
export default class Adapter extends EnzymeAdapter {
22-
constructor() {
34+
private preactAdapterOptions: PreactAdapterOptions;
35+
36+
constructor(preactAdapterOptions: PreactAdapterOptions = {}) {
2337
super();
2438

39+
this.preactAdapterOptions = preactAdapterOptions;
2540
this.options = {
2641
// Prevent Enzyme's shallow renderer from manually invoking lifecycle
2742
// methods after a render. This manual invocation is needed for React
@@ -45,9 +60,13 @@ export default class Adapter extends EnzymeAdapter {
4560
// The `attachTo` option is only supported for DOM rendering, for
4661
// consistency with React, even though the Preact adapter could easily
4762
// support it for shallow rendering.
48-
return new MountRenderer({ ...options, container: options.attachTo });
63+
return new MountRenderer({
64+
...options,
65+
...this.preactAdapterOptions,
66+
container: options.attachTo,
67+
});
4968
case 'shallow':
50-
return new ShallowRenderer();
69+
return new ShallowRenderer({ ...this.preactAdapterOptions });
5170
case 'string':
5271
return new StringRenderer();
5372
default:
@@ -64,20 +83,7 @@ export default class Adapter extends EnzymeAdapter {
6483
}
6584

6685
nodeToHostNode(node: RSTNode | string): Node | null {
67-
if (typeof node === 'string') {
68-
// Returning `null` here causes `wrapper.text()` to return nothing for a
69-
// wrapper around a `Text` node. That's not intuitive perhaps, but it
70-
// matches the React adapters' behaviour.
71-
return null;
72-
}
73-
74-
if (node.nodeType === 'host') {
75-
return node.instance;
76-
} else if (node.rendered.length > 0) {
77-
return this.nodeToHostNode(node.rendered[0] as RSTNode);
78-
} else {
79-
return null;
80-
}
86+
return nodeToHostNode(node);
8187
}
8288

8389
isValidElement(el: any) {

src/MountRenderer.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
import type { VNode } from 'preact';
77
import { h, createElement } from 'preact';
88
import { act } from 'preact/test-utils';
9+
import type { PreactAdapterOptions } from './Adapter.js';
910

1011
import { render } from './compat.js';
1112
import {
@@ -15,11 +16,11 @@ import {
1516
import { eventMap } from './event-map.js';
1617
import { getLastVNodeRenderedIntoContainer } from './preact10-internals.js';
1718
import { getNode } from './preact10-rst.js';
18-
import { getDisplayName, withReplacedMethod } from './util.js';
19+
import { getDisplayName, nodeToHostNode, withReplacedMethod } from './util.js';
1920

2021
type EventDetails = { [prop: string]: any };
2122

22-
export interface Options extends MountRendererProps {
23+
export interface Options extends MountRendererProps, PreactAdapterOptions {
2324
/**
2425
* The container element to render into.
2526
* If not specified, a detached element (not connected to the body) is used.
@@ -111,11 +112,26 @@ export default class MountRenderer implements AbstractMountRenderer {
111112
}
112113

113114
simulateEvent(node: RSTNode, eventName: string, args: EventDetails = {}) {
114-
if (node.nodeType !== 'host') {
115+
let hostNode: Node;
116+
if (node.nodeType == 'host') {
117+
hostNode = node.instance;
118+
} else if (this._options.simulateEventsOnComponents) {
119+
const possibleHostNode = nodeToHostNode(node);
120+
if (possibleHostNode == null) {
121+
const name = getDisplayName(node);
122+
throw new Error(
123+
`Cannot simulate event on "${name}" which is not a DOM element or contains no DOM element children. ` +
124+
'Find a DOM element or Component that contains a DOM element in the output and simulate an event on that.'
125+
);
126+
}
127+
128+
hostNode = possibleHostNode;
129+
} else {
115130
const name = getDisplayName(node);
116131
throw new Error(
117132
`Cannot simulate event on "${name}" which is not a DOM element. ` +
118-
'Find a DOM element in the output and simulate an event on that.'
133+
'Find a DOM element in the output and simulate an event on that. ' +
134+
'Or, enable the simulateEventsOnComponents option to enable this feature.'
119135
);
120136
}
121137

@@ -137,7 +153,7 @@ export default class MountRenderer implements AbstractMountRenderer {
137153
Object.assign(event, extra);
138154

139155
act(() => {
140-
node.instance.dispatchEvent(event);
156+
hostNode.dispatchEvent(event);
141157
});
142158
}
143159

src/ShallowRenderer.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ import type {
55
} from 'enzyme';
66
import type { VNode } from 'preact';
77

8+
import type { PreactAdapterOptions } from './Adapter.js';
89
import MountRenderer from './MountRenderer.js';
910
import {
1011
withShallowRendering,
1112
shallowRenderVNodeTree,
1213
} from './shallow-render-utils.js';
1314
import { childElements } from './compat.js';
15+
import { propFromEvent } from './util.js';
16+
17+
export interface Options extends PreactAdapterOptions {}
1418

1519
export default class ShallowRenderer implements AbstractShallowRenderer {
1620
private _mountRenderer: MountRenderer;
21+
private _options: Options;
1722

18-
constructor() {
19-
this._mountRenderer = new MountRenderer();
23+
constructor(options: Options = {}) {
24+
this._mountRenderer = new MountRenderer(options);
25+
this._options = options;
2026
}
2127

2228
render(el: VNode, context?: any, options?: ShallowRenderOptions) {
@@ -65,7 +71,14 @@ export default class ShallowRenderer implements AbstractShallowRenderer {
6571

6672
simulateEvent(node: RSTNode, eventName: string, args: Object) {
6773
withShallowRendering(() => {
68-
this._mountRenderer.simulateEvent(node, eventName, args);
74+
if (this._options.simulateEventsOnComponents) {
75+
const handler = node.props[propFromEvent(eventName)];
76+
if (handler) {
77+
handler(args);
78+
}
79+
} else {
80+
this._mountRenderer.simulateEvent(node, eventName, args);
81+
}
6982
});
7083
}
7184

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import Adapter from './Adapter.js';
2+
import type { PreactAdapterOptions } from './Adapter.js';
23

34
export {
45
// Non-default exports for backwards compatibility with earlier v1.x releases.
56
Adapter,
67
Adapter as PreactAdapter,
78
};
89

10+
export type { PreactAdapterOptions };
11+
912
export default Adapter;

src/util.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,94 @@ export function withReplacedMethod(
5656
export function toArray(obj: any) {
5757
return Array.isArray(obj) ? obj : [obj];
5858
}
59+
60+
/**
61+
* @param node The node to start searching for a host node
62+
* @returns The first host node in the children of the passed in node. Will
63+
* return the passed in node if it is a host node
64+
*/
65+
export function nodeToHostNode(node: RSTNode | string | null): Node | null {
66+
if (node == null || typeof node == 'string') {
67+
// Returning `null` here causes `wrapper.text()` to return nothing for a
68+
// wrapper around a `Text` node. That's not intuitive perhaps, but it
69+
// matches the React adapters' behaviour.
70+
return null;
71+
} else if (node.nodeType === 'host') {
72+
return node.instance;
73+
} else if (node.rendered.length > 0) {
74+
for (let child of node.rendered) {
75+
let childHostNode = nodeToHostNode(child);
76+
if (childHostNode) {
77+
return childHostNode;
78+
}
79+
}
80+
}
81+
82+
return null;
83+
}
84+
85+
// Copied from enzyme-adapter-utils. We don't include that package because it depends on React
86+
const nativeToReactEventMap: Record<string, string> = {
87+
compositionend: 'compositionEnd',
88+
compositionstart: 'compositionStart',
89+
compositionupdate: 'compositionUpdate',
90+
keydown: 'keyDown',
91+
keyup: 'keyUp',
92+
keypress: 'keyPress',
93+
contextmenu: 'contextMenu',
94+
dblclick: 'doubleClick',
95+
doubleclick: 'doubleClick',
96+
dragend: 'dragEnd',
97+
dragenter: 'dragEnter',
98+
dragexist: 'dragExit',
99+
dragleave: 'dragLeave',
100+
dragover: 'dragOver',
101+
dragstart: 'dragStart',
102+
mousedown: 'mouseDown',
103+
mousemove: 'mouseMove',
104+
mouseout: 'mouseOut',
105+
mouseover: 'mouseOver',
106+
mouseup: 'mouseUp',
107+
touchcancel: 'touchCancel',
108+
touchend: 'touchEnd',
109+
touchmove: 'touchMove',
110+
touchstart: 'touchStart',
111+
canplay: 'canPlay',
112+
canplaythrough: 'canPlayThrough',
113+
durationchange: 'durationChange',
114+
loadeddata: 'loadedData',
115+
loadedmetadata: 'loadedMetadata',
116+
loadstart: 'loadStart',
117+
ratechange: 'rateChange',
118+
timeupdate: 'timeUpdate',
119+
volumechange: 'volumeChange',
120+
beforeinput: 'beforeInput',
121+
mouseenter: 'mouseEnter',
122+
mouseleave: 'mouseLeave',
123+
transitionend: 'transitionEnd',
124+
animationstart: 'animationStart',
125+
animationiteration: 'animationIteration',
126+
animationend: 'animationEnd',
127+
pointerdown: 'pointerDown',
128+
pointermove: 'pointerMove',
129+
pointerup: 'pointerUp',
130+
pointercancel: 'pointerCancel',
131+
gotpointercapture: 'gotPointerCapture',
132+
lostpointercapture: 'lostPointerCapture',
133+
pointerenter: 'pointerEnter',
134+
pointerleave: 'pointerLeave',
135+
pointerover: 'pointerOver',
136+
pointerout: 'pointerOut',
137+
auxclick: 'auxClick',
138+
};
139+
140+
export function mapNativeEventNames(event: string): string {
141+
return nativeToReactEventMap[event] || event;
142+
}
143+
144+
// 'click' => 'onClick'
145+
// 'mouseEnter' => 'onMouseEnter'
146+
export function propFromEvent(event: string): string {
147+
const nativeEvent = mapNativeEventNames(event);
148+
return `on${nativeEvent[0].toUpperCase()}${nativeEvent.slice(1)}`;
149+
}

test/MountRenderer_test.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,18 +237,31 @@ describe('MountRenderer', () => {
237237
});
238238
});
239239

240-
it('throws if target is not a DOM node', () => {
240+
it('throws if target is not a DOM node (simulateEventsOnComponents: false)', () => {
241241
function Button({ onClick }: any) {
242242
return <button type="button" onClick={onClick} />;
243243
}
244-
const renderer = new MountRenderer();
244+
const renderer = new MountRenderer({ simulateEventsOnComponents: false });
245245
const callback = sinon.stub();
246246
renderer.render(<Button onClick={callback} />);
247247

248248
assert.throws(() => {
249249
renderer.simulateEvent(renderer.getNode() as RSTNode, 'click', {});
250250
});
251251
});
252+
253+
it('fires an event on a Component (simulateEventsOnComponents: true)', () => {
254+
function Button({ onClick }: any) {
255+
return <button type="button" onClick={onClick} />;
256+
}
257+
const renderer = new MountRenderer({ simulateEventsOnComponents: true });
258+
const callback = sinon.stub();
259+
renderer.render(<Button onClick={callback} />);
260+
261+
renderer.simulateEvent(renderer.getNode() as RSTNode, 'click', {});
262+
263+
sinon.assert.called(callback);
264+
});
252265
});
253266

254267
describe('#wrapInvoke', () => {

0 commit comments

Comments
 (0)