Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support wrappingComponent & wrappingComponentProps #157

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ declare module 'enzyme' {
// Required methods.
createElement(
type: string | Function,
props: Object,
props: Object | null,
...children: ReactElement[]
): ReactElement;
createRenderer(options: AdapterOptions): Renderer;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@types/mocha": "^9.0.0",
"@types/sinon": "^10.0.6",
"chai": "^4.3.4",
"enzyme": "^3.8.0",
"enzyme": "^3.11.0",
"jsdom": "^16.0.1",
"minimist": "^1.2.0",
"mocha": "^9.1.3",
Expand All @@ -29,7 +29,7 @@
"yalc": "^1.0.0-pre.34"
},
"peerDependencies": {
"enzyme": "^3.8.0",
"enzyme": "^3.11.0",
"preact": "^10.0.0"
},
"scripts": {
Expand Down
38 changes: 33 additions & 5 deletions src/Adapter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import type { AdapterOptions, RSTNode } from 'enzyme';
import type {
AdapterOptions,
MountRendererProps,
RSTNode,
ShallowRendererProps,
} from 'enzyme';
import enzyme from 'enzyme';
import type { ReactElement } from 'react';
import { VNode, h } from 'preact';
import type { VNode } from 'preact';
import { h } from 'preact';

import MountRenderer from './MountRenderer.js';
import ShallowRenderer from './ShallowRenderer.js';
import StringRenderer from './StringRenderer.js';
import { rstNodeFromElement } from './preact10-rst.js';
import wrapWithWrappingComponent from './wrapWithWrappingComponent.js';
import RootFinder from './RootFinder.js';

export const { EnzymeAdapter } = enzyme;

Expand All @@ -31,13 +39,13 @@ export default class Adapter extends EnzymeAdapter {
this.nodeToElement = this.nodeToElement.bind(this);
}

createRenderer(options: AdapterOptions) {
createRenderer(options: AdapterOptions & MountRendererProps) {
switch (options.mode) {
case 'mount':
// The `attachTo` option is only supported for DOM rendering, for
// consistency with React, even though the Preact adapter could easily
// support it for shallow rendering.
return new MountRenderer({ container: options.attachTo });
return new MountRenderer({ ...options, container: options.attachTo });
case 'shallow':
return new ShallowRenderer();
case 'string':
Expand Down Expand Up @@ -91,7 +99,7 @@ export default class Adapter extends EnzymeAdapter {

createElement(
type: string | Function,
props: Object,
props: Object | null,
...children: ReactElement[]
) {
return h(type as any, props, ...children);
Expand All @@ -100,4 +108,24 @@ export default class Adapter extends EnzymeAdapter {
elementToNode(el: ReactElement): RSTNode {
return rstNodeFromElement(el as VNode) as RSTNode;
}

// This function is only called during shallow rendering
wrapWithWrappingComponent = (
node: ReactElement,
/**
* Tip:
* The use of `wrappingComponent` and `wrappingComponentProps` is discouraged.
* Using those props complicates a potential future migration to a different testing library.
* Instead, wrap a component like this:
* ```
* shallow(<Wrapper><Component/></Wrapper>).dive()
* ```
*/
options?: ShallowRendererProps
) => {
return {
RootFinder: RootFinder,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To check my understanding, RootFinder is essentially a marker to allow location of the wrapped component in the rendered tree?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand the Enzyme source code correctly, it's purpose is to properly pass down context, see here https://github.com/enzymejs/enzyme/blob/57baba5ceaccec7a3ada40d7559f5bf71289cbe7/packages/enzyme/src/ShallowWrapper.js#L356 and here https://github.com/enzymejs/enzyme/blob/57baba5ceaccec7a3ada40d7559f5bf71289cbe7/packages/enzyme/src/ShallowWrapper.js#L322. I tried what happens if I don't insert RootFinder into wrapWithWrappingComponent and it didn't work.

node: wrapWithWrappingComponent(this.createElement, node, options),
};
};
}
37 changes: 31 additions & 6 deletions src/MountRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { MountRenderer as AbstractMountRenderer, RSTNode } from 'enzyme';
import { VNode, h } from 'preact';
import type {
MountRenderer as AbstractMountRenderer,
MountRendererProps,
RSTNode,
} from 'enzyme';
import { VNode, h, createElement } from 'preact';
import type { ReactElement } from 'react';
import { act } from 'preact/test-utils';

import { render } from './compat.js';
Expand All @@ -14,7 +19,7 @@ import { getDisplayName, withReplacedMethod } from './util.js';

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

export interface Options {
export interface Options extends MountRendererProps {
/**
* The container element to render into.
* If not specified, a detached element (not connected to the body) is used.
Expand All @@ -34,17 +39,32 @@ function constructEvent(type: string, init: EventInit) {
export default class MountRenderer implements AbstractMountRenderer {
private _container: HTMLElement;
private _getNode: typeof getNode;
private _options: Options;

constructor({ container }: Options = {}) {
constructor(options: Options = {}) {
installDebounceHook();

this._container = container || document.createElement('div');
this._container = options.container || document.createElement('div');
this._getNode = getNode;
this._options = options;
}

render(el: VNode, context?: any, callback?: () => any) {
act(() => {
render(el, this._container);
if (!this._options.wrappingComponent) {
render(el, this._container);
return;
}

// `this._options.wrappingComponent` is only available during mount-rendering,
// even though ShallowRenderer uses an instance of MountRenderer under the hood.
// For shallow-rendered components, we need to utilize `wrapWithWrappingComponent`.
const wrappedComponent: ReactElement = createElement(
this._options.wrappingComponent,
this._options.wrappingComponentProps || null,
el
);
render(wrappedComponent, this._container);
});

if (callback) {
Expand All @@ -70,6 +90,7 @@ export default class MountRenderer implements AbstractMountRenderer {
) {
return null;
}

return this._getNode(this._container);
}

Expand Down Expand Up @@ -135,4 +156,8 @@ export default class MountRenderer implements AbstractMountRenderer {
});
return result;
}

getWrappingComponentRenderer() {
return this;
}
}
7 changes: 7 additions & 0 deletions src/RootFinder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Component } from 'preact';

export default class RootFinder extends Component {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to have a brief comment explaining what RootFinder is used for.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

render() {
return this.props.children;
}
}
39 changes: 39 additions & 0 deletions src/wrapWithWrappingComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ReactElement } from 'react';
import type { EnzymeAdapter, ShallowRendererProps } from 'enzyme';
import RootFinder from './RootFinder.js';
import { childElements } from './compat.js';
import { cloneElement } from 'preact';

/** Based on the equivalent function in `enzyme-adapter-utils` */
export default function wrapWithWrappingComponent(
createElement: EnzymeAdapter['createElement'],
node: ReactElement,
options: ShallowRendererProps = {}
) {
const { wrappingComponent, wrappingComponentProps = {} } = options;

if (!wrappingComponent) {
return node;
}

let nodeWithValidChildren = node;

if (typeof nodeWithValidChildren.props.children === 'string') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To summarize, this is normalizing VNodes like { type: Widget, props: { children: 'test' }, ... } to { type: Widget, props: { children: ['test'] } }?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// This prevents an error when `.dive()` is used:
// `TypeError: ShallowWrapper::dive() can only be called on components`.
// ---------------------------------------------------------------------
// VNode before: `{ type: Widget, props: { children: 'test' }, ... }`
// VNode after: `{ type: Widget, props: { children: ['test'] }, ... }`
nodeWithValidChildren = cloneElement(
nodeWithValidChildren,
nodeWithValidChildren.props,
childElements(nodeWithValidChildren)
);
}

return createElement(
wrappingComponent,
wrappingComponentProps,
createElement(RootFinder, null, nodeWithValidChildren)
);
}
50 changes: 50 additions & 0 deletions test/Adapter_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,54 @@ describe('Adapter', () => {
});
});
});

describe('#wrapWithWrappingComponent', () => {
function Button() {
return <button>Click me</button>;
}

function WrappingComponent({
children,
...wrappingComponentProps
}: {
children: preact.ComponentChildren;
}) {
return <div {...wrappingComponentProps}>{children}</div>;
}

it('returns original component when not wrapped', () => {
const button = <Button />;
const adapter = new Adapter();
const hostNode = adapter.wrapWithWrappingComponent(button);

assert.ok(hostNode);
assert.typeOf(hostNode.RootFinder, 'function');
assert.deepEqual(hostNode.node, button);
});

it('returns wrapped component', () => {
const button = <Button />;
const wrappingComponentProps = { foo: 'bar' };
const wrappedComponent = (
<WrappingComponent {...wrappingComponentProps}>
{button}
</WrappingComponent>
);

const adapter = new Adapter();
const hostNode = adapter.wrapWithWrappingComponent(button, {
wrappingComponent: WrappingComponent,
wrappingComponentProps,
});

assert.ok(hostNode);

const rootFinderInHostNode = hostNode.node.props.children.type;
assert.equal(hostNode.RootFinder, rootFinderInHostNode);

const buttonInWrappedComponent = wrappedComponent.props.children;
const buttonInHostNode = hostNode.node.props.children.props.children;
assert.equal(buttonInHostNode, buttonInWrappedComponent);
});
});
});
30 changes: 30 additions & 0 deletions test/MountRenderer_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,34 @@ describe('MountRenderer', () => {
assert.equal(result, 'test');
});
});

describe('getNode', () => {
it('can get the node', () => {
const renderedTree = [
{
nodeType: 'host',
type: 'div',
props: {},
key: null,
ref: null,
instance: document.createElement('span'),
rendered: [],
},
];

const Widget = () => <span />;
const renderer = new MountRenderer();
renderer.render(<Widget />);

const result = renderer.getWrappingComponentRenderer();

assert.equal(result.getNode()?.rendered.length, 1);

const resultInstance = (result.getNode()?.rendered[0] as RSTNode)
.instance;
const expectedInstance = renderedTree[0].instance;

assert.equal(resultInstance.outerHTML, expectedInstance.outerHTML);
});
});
});
Loading