diff --git a/.eslintrc b/.eslintrc index a68e9195a..d639d726a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,6 +10,7 @@ "new-cap": [2, { "capIsNewExceptions": ["AND"] }], "react/jsx-pascal-case": [2, { "allowAllCaps": true }], "react/no-find-dom-node": 1, + "import/first": 0, "no-underscore-dangle": [2, { "allowAfterThis": true, "allow": [ diff --git a/.travis.yml b/.travis.yml index 92f8f6d2e..46c458d5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,6 +41,8 @@ matrix: env: KARMA=true REACT=15.4 - node_js: "6" env: KARMA=true REACT=15 + - node_js: "6" + env: KARMA=true REACT=16 allow_failures: - node_js: "6" env: EXAMPLE=react-native @@ -48,8 +50,13 @@ matrix: env: EXAMPLE=mocha - node_js: "6" env: EXAMPLE=karma + - node_js: "6" + env: EXAMPLE=karma-webpack + - node_js: "6" + env: EXAMPLE=jest env: - REACT=0.13 - REACT=0.14 - REACT=15.4 - REACT=15 + - REACT=16 diff --git a/docs/README.md b/docs/README.md index 4fe21f837..88d1c780f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -130,4 +130,5 @@ * [Selectors](/docs/api/selector.md) * [Change Log](/CHANGELOG.md) * [Future](/docs/future.md) + * [Adapter & Compatibility Proposal](/docs/future/compatibility.md) * [Contributing Guide](/CONTRIBUTING.md) diff --git a/docs/future.md b/docs/future.md index fc1c1b436..9ce5c318d 100644 --- a/docs/future.md +++ b/docs/future.md @@ -7,6 +7,11 @@ There are several things we'd like to address with Enzyme that often get asked. of projects that we plan on addressing in the near future: +### Enzyme Adapter & Compatibility + +[See the full proposal](future/compatibility.md) + + #### Improved CSS Selector Support Currently, "hierarchical" CSS selectors are not supported in Enzyme. That means that the only CSS diff --git a/docs/future/compatibility.md b/docs/future/compatibility.md new file mode 100644 index 000000000..a6788f7aa --- /dev/null +++ b/docs/future/compatibility.md @@ -0,0 +1,184 @@ +# Enzyme Adapter & Compatibility Proposal + + +## Motivation + +This proposal is attempting to address a handful of pain points that Enzyme has been +subject to for quite a while. This proposal has resulted mostly [#715](https://github.com/airbnb/enzyme/issues/715), +and a resulting discussion among core maintainers of this project. + +The desired results of this proposal are the following: + +1. Cleaner code, easier maintenance, less bug prone. + +By standardizing on a single tree specification, the implementation of Enzyme would no longer have +to take into account the matrix of supported structures and nuanced differences between different +versions of React, as well as to some extent the differences between `mount` and `shallow`. + +2. Additional libraries can provide compatible adapters + +React API-compatible libraries such as `preact` and `inferno` would be able to provide adapters to Enzyme +for their corresponding libraries, and be able to take full advantage of Enzyme's APIs. + +3. Better user experience (ie, bundlers won't complain about missing deps) + +Enzyme has had a long-standing issue with static-analysis bundlers such as Webpack and Browserify because +of our usage of internal React APIs. With this change, this would be minimized if not removed entirely, +since these things can be localized into the adapter modules, and users will only install the ones they need. + +Additionally, we can even attempt to remove the use of internal react APIs by lobbying for react-maintained packages +such as `react-test-renderer` to utilize the React Standard Tree (RST) format (details below). + +4. Standardization and interopability with other tools + +If we can agree on the tree format (specified below as "React Standard Tree"), other tools can start to use and +understand this format as well. Standardization is a good thing, and could allow tools to be built that maybe +don't even exist yet. + + +## Proposal + + +### React Standard Tree (RST) + +This proposal hinges on a standard tree specification. Keep in mind that this tree needs to account for more +than what is currently satisfied by the output of something like `react-test-renderer`, which is currently +only outputting the "host" nodes (ie, HTML elements). We need a tree format that allows for expressing a full +react component tree, including composite components. + +``` +// Strings and Numbers are rendered as literals. +type LiteralValue = string | number + +// A "node" in an RST is either a LiteralValue, or an RSTNode +type Node = LiteralValue | RSTNode + +// if node.type +type RenderedNode = RSTNode | [Node] + +type SourceLocation = {| + fileName: string + lineNumber: number +|} + +type NodeType = 'class' | 'function' | 'host'; + +// An RSTNode has this specific shape +type RSTNode = {| + // Either a string or a function. A string is considered a "host" node, and + // a function would be a composite component. It would be the component constructor or + // an SFC in the case of a function. + type: string | function; + + // This node's type + nodeType: NodeType; + + // The props object passed to the node, which will include `children` in its raw form, + // exactly as it was passed to the component. + props: object; + + // The backing instance to the node. Can be null in the case of "host" nodes and SFCs. + // Enzyme will expect instances to have the _public interface_ of a React Component, as would + // be expected in the corresponding React release returned by `getTargetVersion` of the + // renderer. Alternative React libraries can choose to provide an object here that implements + // the same interface, and Enzyme functionality that uses this will continue to work (An example + // of this would be the `setState()` prototype method). + instance: ComponentInstance?; + + // For a given node, this corresponds roughly to the result of the `render` function with the + // provided props, but transformed into an RST. For "host" nodes, this will always be `null` or + // an Array. For "composite" nodes, this will always be `null` or an `RSTNode`. + rendered: RenderedNode?; + + // an optional property with source information (useful in debug messages) that would be provided + // by this babel transform: https://babeljs.io/docs/plugins/transform-react-jsx-source/ + __source?: SourceLocation; +|} +``` + +### Enzyme Adapter Protocol + +**Definitions:** + +An `Element` is considered to be whatever data structure is returned by the JSX pragma being used. In the +react case, this would be the data structure returned from `React.createElement` + + +``` +type RendererOptions = { + // An optional predicate function that takes in an `Element` and returns + // whether or not the underlying Renderer should treat it as a "Host" node + // or not. This function should only be called with elements that are + // not required to be considered "host" nodes (ie, with a string `type`), + // so the default implementation of `isHost` is just a function that returns + // false. + ?isHost(Element): boolean; +} + +type EnzymeAdapter = { + // This is a method that will return a semver version string for the _react_ version that + // it expects enzyme to target. This will allow enzyme to know what to expect in the `instance` + // that it finds on an RSTNode, as well as intelligently toggle behavior across react versions + // etc. For react adapters, this will likely just be `() => React.Version`, but for other + // adapters for libraries like inferno or preact, it will allow those libraries to specify + // a version of the API that they are committing to. + getTargetApiVersion(): string; + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + createRenderer(?options: RendererOptions): EnzymeRenderer; + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + nodeToElement(RSTNode): Element; +} + +type EnzymeRenderer = { + // both initial render and updates for the renderer. + render(Element): void; + + // retrieve a frozen-in-time copy of the RST. + getNode(): RSTNode?; +} +``` + + +### Using different adapters with Enzyme + +At the top level, Enzyme would expose a `configure` method, which would allow for an `adapter` +option to be specified and globally configure Enzyme's adapter preference: + +``` +import Enzyme from 'enzyme'; +import ThirdPartyEnzymeAdapter from 'third-party-enzyme-adapter'; + +Enzyme.configure({ adapter: ThirdPartyEnzymeAdapter }); + +``` + +Additionally, each wrapper Enzyme exposes will allow for an overriding `adapter` option that will use a +given adapter for just that wrapper: + +``` +import { shallow } from 'enzyme'; +import ThirdPartyEnzymeAdapter from 'third-party-enzyme-adapter'; + +shallow(, { adapter: ThirdPartyEnzymeAdapter }); +``` + +Enzyme will build adapters for all major versions of React since React 0.13, though will deprecate +adapters as usage of a particular major version fades. + +``` +import React13Adapter from 'enzyme-adapter-react-13'; +import React14Adapter from 'enzyme-adapter-react-14'; +import React15Adapter from 'enzyme-adapter-react-15'; +// ... +``` + +### Validation + +Enzyme will provide an `validate(node): Error?` method that will traverse down a provided `RSTNode` and +return an `Error` if any deviations from the spec are encountered, and `null` otherwise. This will +provide a way for implementors of the adapters to determine whether or not they are in compliance or not. diff --git a/docs/future/migration.md b/docs/future/migration.md new file mode 100644 index 000000000..b56592862 --- /dev/null +++ b/docs/future/migration.md @@ -0,0 +1,372 @@ +# Migration Guide for Enzyme v2.x to v3.x + +The change from Enzyme v2.x to v3.x is a more significant change than in previous major releases, +due to the fact that the internal implementation has been almost completely rewritten. + +The goal of this rewrite was to address a lot of the major issues that have plagued Enzyme since +its initial release. It was also to simultaneously remove a lot of the dependence that Enzyme has +on react internals, and to make enzyme more "pluggable", paving the way for Enzyme to be used +with "React-like" libraries such as Preact and Inferno. + +We have done our best to make Enzyme v3 as API compatible with v2.x as possible, however there are +a hand full of breaking changes that we decided we needed to make, intentionally, in order to +support this new architecture. + +Airbnb has one of the largest Enzyme test suites, coming in at around 30,000 enzyme unit tests. +After upgrading Enzyme to v3.x in Airbnb's code base, 99.6% of these tests succeeded with no +modifications at all. Most of the tests that broke we found to be easy to fix, and some we found to +actually be depending on what could arguably be considered a bug in v2.x, and the breakage was +desired. + +In this guide, we will go over a couple of the most common breakages that we ran into, and how to +fix them. Hopefully this will make your upgrade path that much easier. + + +## Configuring your Adapter + +Enzyme now has an "Adapter" system. This means that you now need to install Enzyme along with +another module that provides the Adapter that tells Enzyme how to work with your version of React +(or whatever other react-like library you are using). + +At the time of writing this, Enzyme publishes "officially supported" adapters for React 0.13.x, +0.14.x, 15.x, and 16.x. These adapters are npm packages of the form `enzyme-adapter-react-{{version}}`. + +You will want to configure Enzyme with the adapter you'd like to use before using enzyme in your +tests. The way to do this is whith `Enzyme.configure(...)`. For example, if your project depends +on React 16, you would want to configure Enzyme this way: + +```js +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); +``` + +The list of adapter npm packages for React semver ranges are as follows: + +- `enzyme-adapter-react-16` for `^16.0.0-0` +- `enzyme-adapter-react-15` for `^15.5.0` +- `enzyme-adapter-react-15.4` for `>= 15.0.0 && <15.5.0` +- `enzyme-adapter-react-14` for `^0.14.x` +- `enzyme-adapter-react-13` for `^0.13.x` + + +## Element referential identity is no longer preserved + +Enzyme's new architecture means that the react "render tree" is transformed into an intermediate +representation that is common across all react versions so that Enzyme can properly traverse it +independent of React's internal representations. A side effect of this is that Enzyme no longer +has access to the actual object references that were returned from `render` in your React +components. This normally isn't much of a problem, but can manifest as a test failure in some +cases. + +For example, consider the following example: + + +```js +import React from 'react'; +import Icon from './path/to/Icon'; + +const ICONS = { + success: , + failure: , +}; + +const StatusLabel = ({ id, label }) =>
{ICONS[id]}{label}{ICONS[id]}
; +``` + +```js +import { shallow } from 'enzyme'; +import StatusLabel from './path/to/StatusLabel'; +import Icon from './path/to/Icon'; + +const wrapper = shallow(); + +const iconCount = wrapper.find(Icon).length; +``` + +In v2.x, `iconCount` would be 1. In v3.x, it will be 2. This is because in v2.x it would find all +of the elements matching the selector, and then remove any duplicates. Since `ICONS.success` is +included twice in the render tree, but it's a constant that's reused, it would show up as a +duplicate in the eyes of Enzyme v2.x. In Enzyme v3, the elements that are traversed are +transformations of the underlying react elements, and are thus different references, resulting in +two elements being found. + +Although this is a breaking change, I believe the new behavior is closer to what people would +actually expect and want. + +## `children()` now has slightly different meaning + +Enzyme has a `.children()` method which is intended to return the rendered children of a wrapper. + +When using `mount(...)`, it can sometimes be unclear exactly what this would mean. Consider for +example the following react components: + + +```js +class Box extends React.Component { + render() { + return
{this.props.children}
; + } +} +class Foo extends React.Component { + render() { + return ( + +
+ + ); + } +} +``` + +## For `mount`, updates are sometimes required when they weren't before + +React applications are dynamic. When testing your react components, you often want to test them +before *and after* certain state changes take place. When using `mount`, any react component +instance in the entire render tree could register code to initiate a state change at any time. + +For instance, consider the following contrived example: + +```js +import React from 'react'; + +class CurrentTime extends React.Component { + constructor(props) { + super(props); + this.state = { + now: Date.now(), + }; + } + componentDidMount() { + this.tick(); + } + componentWillUnmount() { + clearTimeout(this.timer); + } + tick() { + this.setState({ now: Date.now() }); + this.timer = setTimeout(tick, 0); + } + render() { + return {this.state.now}; + } +} +``` + +In this code, there is a timer that continuously changes the rendered output of this component. This +might be a reasonable thing to do in your application. The thing is, Enzyme has no way of knowing +that these changes are taking place, and no way to automatically update the render tree. In Enzyme +v2, Enzyme operated *directly* on the in-memory representation of the render tree that React itself +had. This means that even though Enzyme couldn't know when the render tree was updated, updates +would be reflected anyway, since React *does* know. + +Enzyme v3 architecturally created a layer where React would create an intermediate representation +of the render tree at an instance in time and pass that to Enzyme to traverse and inspect. This has +many advantages, but one of the side effects is that now the intermediate representation does not +receive automatic updates. + +Enzyme does attempt to automatically "update" the root wrapper in most common scenarios, but these +are only the state changes that it knows about. For all other state changes, you may need to call +`wrapper.update()` yourself. + +The most common manifestation of this problem can be shown with the following example: + +```js +class Counter extends React.Component { + constructor(props) { + super(props); + this.state = { count: 0 }; + this.increment = this.increment.bind(this); + this.decrement = this.decrement.bind(this); + } + increment() { + this.setState({ count: this.state.count + 1 }); + } + decrement() { + this.setState({ count: this.state.count - 1 }); + } + render() { + return ( +
+
Count: {this.state.count}
+ + +
+ ); + } +} +``` + +This is a basic "counter" component in React. Here our resulting markup is a function of +`this.state.count`, which can get updated by the `increment` and `decrement` functions. Let's take a +look at what some Enzyme tests with this component might look like, and when we do or don't have to +call `update()`. + +```js +const wrapper = shallow(); +wrapper.find('.count').text(); // => "Count: 0" +``` + +As we can see, we can easily assert on the text and the count of this component. But we haven't +caused any state changes yet. Let's see what it looks like when we simulate a `click` event on +the increment and decrement buttons: + +```js +const wrapper = shallow(); +wrapper.find('.count').text(); // => "Count: 0" +wrapper.find('.inc').simulate('click'); +wrapper.find('.count').text(); // => "Count: 1" +wrapper.find('.inc').simulate('click'); +wrapper.find('.count').text(); // => "Count: 2" +wrapper.find('.dec').simulate('click'); +wrapper.find('.count').text(); // => "Count: 1" +``` + +In this case Enzyme will automatically check for updates after an event simulation takes place, as +it knows that this is a very common place for state changes to occur. In this case there is no +difference between v2 and v3. + +Let's consider a different way this test could have been written. + +```js +const wrapper = shallow(); +wrapper.find('.count').text(); // => "Count: 0" +wrapper.instance().increment(); +wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2) +wrapper.instance().increment(); +wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 2" in v2) +wrapper.instance().decrement(); +wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2) +``` + +The problem here is that once we grab the instance using `wrapper.instance()`, Enzyme has no way of +knowing if you are going to execute something that will cause a state transition, and thus does not +know when to ask for an updated render tree from React. As a result, `.text()` never changes value. + +The fix here is to use Enzyme's `wrapper.update()` method after a state change has occurred: + +```js +const wrapper = shallow(); +wrapper.find('.count').text(); // => "Count: 0" +wrapper.instance().increment(); +wrapper.update(); +wrapper.find('.count').text(); // => "Count: 1" +wrapper.instance().increment(); +wrapper.update(); +wrapper.find('.count').text(); // => "Count: 2" +wrapper.instance().decrement(); +wrapper.update(); +wrapper.find('.count').text(); // => "Count: 1" +``` + +In practice we have found that this isn't actually needed that often, and when it is it is not +difficult to add. This breaking change was worth the architectural benefits of the new adapter +system in v3. + + +## `ref(refName)` now returns the actual ref instead of a wrapper + +In Enzyme v2, the wrapper returned from `mount(...)` had a prototype method on it `ref(refName)` +that returned a wrapper around the actual element of that ref. This has now been changed to +return the actual ref, which I believe is more intuitive. + +Consider the following simple react component: + + +```js +class Box extends React.Component { + render() { + return
Hello
; + } +} +``` + +In this case we can call `.ref('abc')` on a wrapper of `Box`. In this case it will return a wrapper +around the rendered div. To demonstrate, we can see that both `wrapper` and the result of `ref(...)` +share the same constructor: + +```js +const wrapper = mount(); +// this is what would happen with Enzyme v2 +expect(wrapper.ref('abc')).toBeInstanceOf(wrapper.constructor); +``` + +In v3, the contract is slightly changed. The ref is exactly what React would assign as the ref. In +this case, it would be an DOM Element: + +```js +const wrapper = mount(); +// this is what would happen with Enzyme v2 +expect(wrapper.ref('abc')).toBeInstanceOf(Element); +``` + +Similarly, if you have a ref on a composite component, the `ref(...)` method will return an instance +of that element: + + +```js +class Bar extends React.Component { + render() { + return ; + } +} +``` + +```js +const wrapper = mount(); +expect(wrapper.ref('abc')).toBeInstanceOf(Box); +``` + + +In our experience, this is most often what people would actually want and expect out of the `.ref(...)` +method. + + + +# New Features in Enzyme v3 + + +## `instance()` can be called at any level of the tree + +TODO: talk about this + + + + + + + + + + + + + + + + + + + +# Migration Guide (for React 0.13 - React 15.x) + + +## Root Wrapper + +The initially returned wrapper used to be around the element passed +into the `mount` API, and for `shallow` it was around the root node of the rendered output of the element passed in. After the upgrade, the +two APIs are now symmetrical, starting off + + +```js +const x = 'x'; +const Foo = props =>
; +const wrapper = mount(); +``` + +```js +expect(wrapper.props()).to.deep.equal({ outer: x }); +``` + +## for mount, getNode() should not be used. instance() does what it used to. diff --git a/docs/future/rst_examples.js b/docs/future/rst_examples.js new file mode 100644 index 000000000..af2ea2ed7 --- /dev/null +++ b/docs/future/rst_examples.js @@ -0,0 +1,253 @@ +import Adapter from 'enzyme-adapter-foo'; + +let renderer; +let tree; + +// Example Components +// ================================================== + +// Composite returning host. no children props. +const Qoo = () => ( + Hello World! +); + +// composite returning host. passes through children. +const Foo = ({ className, children }) => ( +
+ Literal + {children} +
+); + +// composite returning composite. passes through children. +const Bar = ({ special, children }) => ( + + {children} + +); + +// composite return composite. no children props. +const Bam = () => ( + + + +); + +// Examples +// ================================================== + +// IMPORTANT NOTE: +// in these examples i'm excluding `children` from `props` so that +// it's easier to read the tree output. In reality, `children` will +// be present and unaltered in the props, however enzyme will +// not use it for traversal. + +renderer = Adapter.createRenderer({ + // this would be the default as well. + isHost: el => typeof el.type === 'string', +}); + +// Simple Example +renderer.render(); +// => + +// Expected HTML output: +// +// Hello World! + +// Conceptual debug output: +// +// +// Hello World! +// + +// Expected tree output: +// tree = renderer.getNode(); +tree = { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, +}; + +// Complex Example +renderer.render(); + +// Expected HTML output: +// +//
+// Literal +// Hello World! +//
+ +// Conceptual debug output: +// +// +// +// +//
+// Literal +// +// Hello World! +// +//
+//
+//
+//
+ +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'function', + props: { special: true }, + rendered: { + type: Foo, + nodeType: 'function', + props: { className: 'special' }, + rendered: { + type: 'div', + nodeType: 'host', + props: { className: 'Foo special' }, + rendered: [ + { + type: 'span', + nodeType: 'host', + props: { className: 'Foo2' }, + rendered: ['Literal'], + }, + { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, + }, + ], + }, + }, + }, +}; + + +renderer = Adapter.createRenderer({ + // this is "shallow", but only if we specify + // not to call this on the root node... which + // is kind of strange. + isHost: () => true, +}); + +renderer.render(); + +// Conceptual debug output: +// +// +// +// +// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'host', + props: { special: true }, + rendered: [ + { + type: Qoo, + nodeType: 'host', + props: {}, + rendered: null, + }, + ], + }, +}; + +renderer.render( +
+ +
+); + +// Conceptual debug output: +// +//
+// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: 'div', + nodeType: 'host', + props: {}, + rendered: [ + { + type: Foo, + props: {}, + nodeType: 'host', + rendered: null, + }, + ], +}; + +renderer = Adapter.createRenderer({ + // In this case, we treat `Bar` as a host node + // but `Qoo` is not, so gets rendered + isHost: el => [Bar].includes(el.type), +}); + +renderer.render(); + +// Conceptual debug output: +// +// +// +// Hello World! +// +// +// + +// Expected tree output +// tree = renderer.getNode(); +tree = { + type: Bam, + nodeType: 'function', + props: {}, + rendered: { + type: Bar, + nodeType: 'host', + props: { special: true }, + rendered: [ + { + type: Qoo, + nodeType: 'function', + props: {}, + rendered: { + type: 'span', + nodeType: 'host', + props: { className: 'Qoo' }, + rendered: ['Hello World!'], + }, + }, + ], + }, +}; diff --git a/install-relevant-react.sh b/install-relevant-react.sh index 6303d0ffe..ee3332cc5 100644 --- a/install-relevant-react.sh +++ b/install-relevant-react.sh @@ -19,3 +19,7 @@ fi if [ "$REACT" = "15" ]; then npm run react:15 fi + +if [ "$REACT" = "16" ]; then + npm run react:16 +fi diff --git a/karma.conf.js b/karma.conf.js index c2387224b..02979e540 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,35 +1,42 @@ -/* eslint-disable no-var,prefer-arrow-callback,vars-on-top */ +/* eslint-disable no-var,prefer-arrow-callback,vars-on-top, import/no-extraneous-dependencies */ require('babel-register'); var IgnorePlugin = require('webpack').IgnorePlugin; -var REACT013 = require('./src/version').REACT013; -var REACT155 = require('./src/version').REACT155; +var is = require('./test/_helpers/version').is; function getPlugins() { - var plugins = []; - - /* - this list of conditional IgnorePlugins mirrors the conditional - requires in src/react-compat.js and exists to avoid error - output from the webpack compilation - */ - - if (!REACT013) { - plugins.push(new IgnorePlugin(/react\/lib\/ExecutionEnvironment/)); - plugins.push(new IgnorePlugin(/react\/lib\/ReactContext/)); - plugins.push(new IgnorePlugin(/react\/addons/)); + const adapter13 = new IgnorePlugin(/adapters\/ReactThirteenAdapter/); + const adapter14 = new IgnorePlugin(/adapters\/ReactFourteenAdapter/); + const adapter154 = new IgnorePlugin(/adapters\/ReactFifteenFourAdapter/); + const adapter15 = new IgnorePlugin(/adapters\/ReactFifteenFiveAdapter/); + const adapter16 = new IgnorePlugin(/adapters\/ReactSixteenAdapter/); + + var plugins = [ + adapter13, + adapter14, + adapter154, + adapter15, + adapter16, + ]; + + function not(x) { + return function notPredicate(y) { + return y !== x; + }; } - if (REACT013) { - plugins.push(new IgnorePlugin(/react-dom/)); - } - if (REACT013 || REACT155) { - plugins.push(new IgnorePlugin(/react-addons-test-utils/)); - } - if (!REACT155) { - plugins.push(new IgnorePlugin(/react-test-renderer/)); - plugins.push(new IgnorePlugin(/react-dom\/test-utils/)); - plugins.push(new IgnorePlugin(/create-react-class/)); + + // we want to ignore all of the adapters *except* the one we are currently using + if (is('0.13.x')) { + plugins = plugins.filter(not(adapter13)); + } else if (is('0.14.x')) { + plugins = plugins.filter(not(adapter14)); + } else if (is('^15.5.0')) { + plugins = plugins.filter(not(adapter15)); + } else if (is('^15.0.0-0')) { + plugins = plugins.filter(not(adapter154)); + } else if (is('^16.0.0-0')) { + plugins = plugins.filter(not(adapter16)); } return plugins; diff --git a/package.json b/package.json index 9bdd9f0c4..4117b6509 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,14 @@ "test:watch": "mocha --recursive --watch test", "test:karma": "karma start", "test:env": "sh ./example-test.sh", - "test:all": "npm run react:13 && npm run test:only && npm run react:14 && npm run test:only && npm run react:15.4 && npm run test:only && npm run react:15 && npm run test:only", + "test:all": "npm run react:13 && npm run test:only && npm run react:14 && npm run test:only && npm run react:15.4 && npm run test:only && npm run react:15 && npm run test:only && npm run react:16 && npm run test:only", "clean-local-npm": "rimraf node_modules/.bin/npm node_modules/.bin/npm.cmd", - "react:clean": "npm run clean-local-npm && rimraf node_modules/react node_modules/react-dom node_modules/react-addons-test-utils node_modules/react-test-renderer && npm prune", + "react:clean": "npm run clean-local-npm && rimraf node_modules/react node_modules/react-dom node_modules/react-addons-test-utils node_modules/react-test-renderer node_modules/create-react-class && npm prune", "react:13": "npm run react:clean && npm install && npm i --no-save react@0.13", "react:14": "npm run react:clean && npm install && npm i --no-save react@0.14 react-dom@0.14 react-addons-test-utils@0.14", "react:15.4": "npm run react:clean && npm install && npm i --no-save react@15.4 react-dom@15.4 react-addons-test-utils@15.4", "react:15": "npm run react:clean && npm install && npm i --no-save react@15 react-dom@15 create-react-class@15 react-test-renderer@^15.5.4", + "react:16": "npm run react:clean && npm install && npm i --no-save react@^16.0.0-0 react-dom@^16.0.0-0 create-react-class@^15.6.0 react-test-renderer@^16.0.0-0", "docs:clean": "rimraf _book", "docs:lint": "eslint --ext md --config .eslintrc-markdown .", "docs:prepare": "gitbook install", @@ -66,6 +67,8 @@ "object.entries": "^1.0.4", "object.values": "^1.0.4", "prop-types": "^15.5.10", + "raf": "^3.3.2", + "semver": "^5.3.0", "uuid": "^3.0.1" }, "devDependencies": { @@ -102,13 +105,13 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^1.8.1", "mocha": "^3.5.0", - "react": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x", "rimraf": "^2.6.1", "safe-publish-latest": "^1.1.1", "sinon": "^2.4.1", - "webpack": "^1.13.3" + "webpack": "^1.13.3", + "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" }, "peerDependencies": { - "react": "0.13.x || 0.14.x || ^15.0.0-0 || 15.x" + "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" } } diff --git a/src/Debug.js b/src/Debug.js index 5cf02494f..3a707475b 100644 --- a/src/Debug.js +++ b/src/Debug.js @@ -1,25 +1,12 @@ import without from 'lodash/without'; import escape from 'lodash/escape'; import compact from 'lodash/compact'; -import objectValues from 'object.values'; import functionName from 'function.prototype.name'; import { - childrenOfNode, -} from './ShallowTraversal'; -import { - renderedChildrenOfInst, -} from './MountedTraversal'; -import { - isDOMComponent, - isCompositeComponent, - isElement, -} from './react-compat'; -import { - internalInstance, propsOfNode, -} from './Utils'; -import { REACT013 } from './version'; + childrenOfNode, +} from './RSTTraversal'; export function typeName(node) { return typeof node.type === 'function' @@ -83,60 +70,3 @@ export function debugNode(node, indentLength = 2, options = {}) { export function debugNodes(nodes, options = {}) { return nodes.map(node => debugNode(node, undefined, options)).join('\n\n\n'); } - -export function debugInst(inst, indentLength = 2, options = {}) { - if (typeof inst === 'string' || typeof inst === 'number') return escape(inst); - if (!inst) return ''; - - if (inst._stringText) { - return inst._stringText; - } - - if (!inst.getPublicInstance) { - const internal = internalInstance(inst); - return debugInst(internal, indentLength, options); - } - const publicInst = inst.getPublicInstance(); - - if (typeof publicInst === 'string' || typeof publicInst === 'number') return escape(publicInst); - if (!publicInst && !inst._renderedComponent) return ''; - - // do stuff with publicInst - const currentElement = inst._currentElement; - const type = typeName(currentElement); - const props = options.ignoreProps ? '' : propsString(currentElement); - const children = []; - if (isDOMComponent(publicInst)) { - const renderedChildren = renderedChildrenOfInst(inst); - if (!renderedChildren) { - children.push(...childrenOfNode(currentElement)); - } else { - children.push(...objectValues(renderedChildren)); - } - } else if ( - !REACT013 && - isElement(currentElement) && - typeof currentElement.type === 'function' - ) { - children.push(inst._renderedComponent); - } else if ( - REACT013 && - isCompositeComponent(publicInst) - ) { - children.push(inst._renderedComponent); - } - - const childrenStrs = compact(children.map(n => debugInst(n, indentLength, options))); - - const beforeProps = props ? ' ' : ''; - const nodeClose = childrenStrs.length ? `` : '/>'; - const afterProps = childrenStrs.length - ? '>' - : ' '; - const childrenIndented = indentChildren(childrenStrs, indentLength); - return `<${type}${beforeProps}${props}${afterProps}${childrenIndented}${nodeClose}`; -} - -export function debugInsts(insts, options = {}) { - return insts.map(inst => debugInst(inst, undefined, options)).join('\n\n\n'); -} diff --git a/src/MountedTraversal.js b/src/MountedTraversal.js deleted file mode 100644 index 8c4638e7a..000000000 --- a/src/MountedTraversal.js +++ /dev/null @@ -1,307 +0,0 @@ -import isEmpty from 'lodash/isEmpty'; -import values from 'object.values'; -import isSubset from 'is-subset'; -import { - internalInstance, - nodeEqual, - nodeMatches, - propsOfNode, - isFunctionalComponent, - splitSelector, - selectorType, - isCompoundSelector, - AND, - SELECTOR, - nodeHasType, - nodeHasProperty, -} from './Utils'; -import { - isDOMComponent, - isCompositeComponent, - isCompositeComponentWithType, - isElement, - findDOMNode, -} from './react-compat'; -import { REACT013 } from './version'; - -export function getNode(inst) { - if (!inst || inst._store || typeof inst === 'string') { - return inst; - } - if (inst._currentElement) { - return inst._currentElement; - } - if (internalInstance(inst)) { - return internalInstance(inst)._currentElement; - } - if (inst._reactInternalInstance) { - return inst._reactInternalInstance._currentElement; - } - if (inst._reactInternalComponent) { - return inst._reactInternalComponent._currentElement; - } - return inst; -} - -export function instEqual(a, b, lenComp) { - return nodeEqual(getNode(a), getNode(b), lenComp); -} - -export function instMatches(a, b, lenComp) { - return nodeMatches(getNode(a), getNode(b), lenComp); -} - -export function instHasClassName(inst, className) { - const node = findDOMNode(inst); - if (node === null) { // inst renders null - return false; - } - if (node.classList) { - return node.classList.contains(className); - } - let classes = node.className || ''; - if (typeof classes === 'object') { - classes = classes.baseVal; - } - classes = classes.replace(/\s/g, ' '); - return ` ${classes} `.indexOf(` ${className} `) > -1; -} - -function hasClassName(inst, className) { - if (!isDOMComponent(inst)) { - return false; - } - return instHasClassName(inst, className); -} - -export function instHasId(inst, id) { - if (!isDOMComponent(inst)) return false; - const instId = findDOMNode(inst).id || ''; - return instId === id; -} - -function isFunctionalComponentWithType(inst, func) { - return isFunctionalComponent(inst) && getNode(inst).type === func; -} - -export function instHasType(inst, type) { - switch (typeof type) { - case 'string': - return nodeHasType(getNode(inst), type); - case 'function': - return isCompositeComponentWithType(inst, type) || - isFunctionalComponentWithType(inst, type); - default: - return false; - } -} - -export function instHasProperty(inst, propKey, stringifiedPropValue) { - if (!isDOMComponent(inst)) return false; - - const node = getNode(inst); - - return nodeHasProperty(node, propKey, stringifiedPropValue); -} - -// called with private inst -export function renderedChildrenOfInst(inst) { - return REACT013 - ? inst._renderedComponent._renderedChildren - : inst._renderedChildren; -} - -// called with a private instance -export function childrenOfInstInternal(inst) { - if (!inst) { - return []; - } - if (!inst.getPublicInstance) { - const internal = internalInstance(inst); - return childrenOfInstInternal(internal); - } - - const publicInst = inst.getPublicInstance(); - const currentElement = inst._currentElement; - if (isDOMComponent(publicInst)) { - const renderedChildren = renderedChildrenOfInst(inst); - return values(renderedChildren || {}).filter((node) => { - if (REACT013 && !node.getPublicInstance) { - return false; - } - if (typeof node._stringText !== 'undefined') { - return false; - } - return true; - }).map((node) => { - if (!REACT013 && typeof node._currentElement.type === 'function') { - return node._instance; - } - if (typeof node._stringText === 'string') { - return node; - } - return node.getPublicInstance(); - }); - } else if ( - !REACT013 && - isElement(currentElement) && - typeof currentElement.type === 'function' - ) { - return childrenOfInstInternal(inst._renderedComponent); - } else if ( - REACT013 && - isCompositeComponent(publicInst) - ) { - return childrenOfInstInternal(inst._renderedComponent); - } - return []; -} - -export function internalInstanceOrComponent(node) { - if (REACT013) { - return node; - } else if (node._reactInternalComponent) { - return node._reactInternalComponent; - } else if (node._reactInternalInstance) { - return node._reactInternalInstance; - } - return node; -} - -export function childrenOfInst(node) { - return childrenOfInstInternal(internalInstanceOrComponent(node)); -} - -// This function should be called with an "internal instance". Nevertheless, if it is -// called with a "public instance" instead, the function will call itself with the -// internal instance and return the proper result. -function findAllInRenderedTreeInternal(inst, test) { - if (!inst) { - return []; - } - - if (!inst.getPublicInstance) { - const internal = internalInstance(inst); - return findAllInRenderedTreeInternal(internal, test); - } - const publicInst = inst.getPublicInstance() || inst._instance; - let ret = test(publicInst) ? [publicInst] : []; - const currentElement = inst._currentElement; - if (isDOMComponent(publicInst)) { - const renderedChildren = renderedChildrenOfInst(inst); - values(renderedChildren || {}).filter((node) => { - if (REACT013 && !node.getPublicInstance) { - return false; - } - return true; - }).forEach((node) => { - ret = ret.concat(findAllInRenderedTreeInternal(node, test)); - }); - } else if ( - !REACT013 && - isElement(currentElement) && - typeof currentElement.type === 'function' - ) { - ret = ret.concat( - findAllInRenderedTreeInternal( - inst._renderedComponent, - test, - ), - ); - } else if ( - REACT013 && - isCompositeComponent(publicInst) - ) { - ret = ret.concat( - findAllInRenderedTreeInternal( - inst._renderedComponent, - test, - ), - ); - } - return ret; -} - -// This function could be called with a number of different things technically, so we need to -// pass the *right* thing to our internal helper. -export function treeFilter(node, test) { - return findAllInRenderedTreeInternal(internalInstanceOrComponent(node), test); -} - -function pathFilter(path, fn) { - return path.filter(tree => treeFilter(tree, fn).length !== 0); -} - -export function pathToNode(node, root) { - const queue = [root]; - const path = []; - - const hasNode = testNode => node === testNode; - - while (queue.length) { - const current = queue.pop(); - const children = childrenOfInst(current); - - if (current === node) return pathFilter(path, hasNode); - - path.push(current); - - if (children.length === 0) { - // leaf node. if it isn't the node we are looking for, we pop. - path.pop(); - } - queue.push(...children); - } - - return null; -} - -export function parentsOfInst(inst, root) { - return pathToNode(inst, root).reverse(); -} - -export function instMatchesObjectProps(inst, props) { - if (!isDOMComponent(inst)) return false; - const node = getNode(inst); - return isSubset(propsOfNode(node), props); -} - -export function buildInstPredicate(selector) { - switch (typeof selector) { - case 'function': - // selector is a component constructor - return inst => instHasType(inst, selector); - - case 'string': - if (isCompoundSelector.test(selector)) { - return AND(splitSelector(selector).map(buildInstPredicate)); - } - - switch (selectorType(selector)) { - case SELECTOR.CLASS_TYPE: - return inst => hasClassName(inst, selector.slice(1)); - case SELECTOR.ID_TYPE: - return inst => instHasId(inst, selector.slice(1)); - case SELECTOR.PROP_TYPE: { - const propKey = selector.split(/\[([a-zA-Z][a-zA-Z_\d\-:]*?)(=|])/)[1]; - const propValue = selector.split(/=(.*?)]/)[1]; - - return node => instHasProperty(node, propKey, propValue); - } - default: - // selector is a string. match to DOM tag or constructor displayName - return inst => instHasType(inst, selector); - } - - case 'object': - if (!Array.isArray(selector) && selector !== null && !isEmpty(selector)) { - return node => instMatchesObjectProps(node, selector); - } - throw new TypeError( - 'Enzyme::Selector does not support an array, null, or empty object as a selector', - ); - - default: - throw new TypeError('Enzyme::Selector expects a string, object, or Component Constructor'); - } -} diff --git a/src/ShallowTraversal.js b/src/RSTTraversal.js similarity index 90% rename from src/ShallowTraversal.js rename to src/RSTTraversal.js index 49f3c44d0..730be555c 100644 --- a/src/ShallowTraversal.js +++ b/src/RSTTraversal.js @@ -1,9 +1,8 @@ -import React from 'react'; import isEmpty from 'lodash/isEmpty'; +import flatten from 'lodash/flatten'; import isSubset from 'is-subset'; import functionName from 'function.prototype.name'; import { - propsOfNode, splitSelector, isCompoundSelector, selectorType, @@ -13,17 +12,13 @@ import { nodeHasProperty, } from './Utils'; +export function propsOfNode(node) { + return (node && node.props) || {}; +} export function childrenOfNode(node) { if (!node) return []; - const maybeArray = propsOfNode(node).children; - const result = []; - React.Children.forEach(maybeArray, (child) => { - if (child !== null && child !== false && typeof child !== 'undefined') { - result.push(child); - } - }); - return result; + return Array.isArray(node.rendered) ? flatten(node.rendered, true) : [node.rendered]; } export function hasClassName(node, className) { @@ -110,7 +105,7 @@ export function buildPredicate(selector) { return node => nodeHasId(node, selector.slice(1)); case SELECTOR.PROP_TYPE: { - const propKey = selector.split(/\[([a-zA-Z-]*?)(=|])/)[1]; + const propKey = selector.split(/\[([a-zA-Z][a-zA-Z_\d\-:]*?)(=|])/)[1]; const propValue = selector.split(/=(.*?)]/)[1]; return node => nodeHasProperty(node, propKey, propValue); diff --git a/src/ReactWrapper.jsx b/src/ReactWrapper.jsx index 2ab47c56c..af3e22009 100644 --- a/src/ReactWrapper.jsx +++ b/src/ReactWrapper.jsx @@ -7,34 +7,27 @@ import compact from 'lodash/compact'; import ComplexSelector from './ComplexSelector'; import createWrapperComponent from './ReactWrapperComponent'; import { - instHasClassName, - childrenOfInst, - parentsOfInst, - buildInstPredicate, - instEqual, - instMatches, - treeFilter, - getNode, - internalInstanceOrComponent, -} from './MountedTraversal'; -import { - renderWithOptions, - Simulate, - findDOMNode, - unmountComponentAtNode, -} from './react-compat'; -import { - mapNativeEventNames, containsChildrenSubArray, - propsOfNode, typeOfNode, displayNameOfNode, ITERATOR_SYMBOL, + nodeEqual, + nodeMatches, + getAdapter, } from './Utils'; import { - debugInsts, + debugNodes, } from './Debug'; -import { REACT15 } from './version'; +import { + propsOfNode, + hasClassName, + childrenOfNode, + parentsOfNode, + treeFilter, + buildPredicate, +} from './RSTTraversal'; + +const noop = () => {}; /** * Finds all nodes in the current wrapper nodes' render trees that match the provided predicate @@ -46,7 +39,7 @@ import { REACT15 } from './version'; * @returns {ReactWrapper} */ function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { - return wrapper.flatMap(n => filter(n.getNode(), predicate)); + return wrapper.flatMap(n => filter(n.getNodeInternal(), predicate)); } /** @@ -58,7 +51,15 @@ function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { * @returns {ReactWrapper} */ function filterWhereUnwrapped(wrapper, predicate) { - return wrapper.wrap(compact(wrapper.getNodes().filter(predicate))); + return wrapper.wrap(compact(wrapper.getNodesInternal().filter(predicate))); +} + +function getFromRenderer(renderer) { + const root = renderer.getNode(); + return { + component: root.instance, + node: root.rendered, + }; } /** @@ -73,25 +74,29 @@ class ReactWrapper { } if (!root) { + this.renderer = getAdapter(options).createRenderer({ mode: 'mount', ...options }); const ReactWrapperComponent = createWrapperComponent(nodes, options); - this.component = renderWithOptions( - ( - - ), - options, + this.renderer.render( + , ); this.root = this; - this.node = this.component.getWrappedComponent(); - this.nodes = [this.node]; + const { + component, + node, + } = getFromRenderer(this.renderer); + this.component = component; + this.node = node; + this.nodes = [node]; this.length = 1; } else { - this.component = null; + this.renderer = root.renderer; this.root = root; if (!nodes) { + this.node = null; this.nodes = []; } else if (!Array.isArray(nodes)) { this.node = nodes; @@ -101,12 +106,13 @@ class ReactWrapper { this.nodes = nodes; } this.length = this.nodes.length; + this.component = null; } - this.options = options; + this.options = root ? root.options : options; this.complexSelector = new ComplexSelector( - buildInstPredicate, + buildPredicate, findWhereUnwrapped, - childrenOfInst, + childrenOfNode, ); } @@ -115,7 +121,7 @@ class ReactWrapper { * * @return {ReactComponent} */ - getNode() { + getNodeInternal() { if (this.length !== 1) { throw new Error( 'ReactWrapper::getNode() can only be called when wrapping one node', @@ -129,10 +135,47 @@ class ReactWrapper { * * @return {Array} */ - getNodes() { + getNodesInternal() { return this.nodes; } + /** + * Returns the wrapped ReactElement. + * + * @return {ReactElement} + */ + getElement() { + if (this.length !== 1) { + throw new Error( + 'ReactWrapper::getElement() can only be called when wrapping one node', + ); + } + return getAdapter(this.options).nodeToElement(this.node); + } + + /** + * Returns the wrapped ReactElements. + * + * @return {Array} + */ + getElements() { + return this.nodes.map(getAdapter(this.options).nodeToElement); + } + + // eslint-disable-next-line class-methods-use-this + getNode() { + throw new Error( + 'ReactWrapper::getNode() is no longer supported. Use ReactWrapper::instance() instead', + ); + } + + // eslint-disable-next-line class-methods-use-this + getNodes() { + throw new Error( + 'ReactWrapper::getNodes() is no longer supported.', + ); + } + /** * Returns the outer most DOMComponent of the current wrapper. * @@ -141,7 +184,8 @@ class ReactWrapper { * @returns {DOMComponent} */ getDOMNode() { - return this.single('getDOMNode', findDOMNode); + const adapter = getAdapter(this.options); + return this.single('getDOMNode', n => adapter.nodeToHostNode(n)); } /** @@ -157,7 +201,7 @@ class ReactWrapper { if (this.root !== this) { throw new Error('ReactWrapper::ref(refname) can only be called on the root'); } - return this.wrap(this.instance().refs[refname]); + return this.instance().refs[refname]; } /** @@ -174,10 +218,10 @@ class ReactWrapper { * @returns {ReactComponent} */ instance() { - if (this.root !== this) { - throw new Error('ReactWrapper::instance() can only be called on the root'); + if (this.length !== 1) { + throw new Error('ReactWrapper::instance() can only be called on single nodes'); } - return this.component.getInstance(); + return this.node.instance; } /** @@ -190,11 +234,16 @@ class ReactWrapper { */ update() { if (this.root !== this) { - // TODO(lmr): this requirement may not be necessary for the ReactWrapper throw new Error('ReactWrapper::update() can only be called on the root'); } this.single('update', () => { - this.component.forceUpdate(); + const { + component, + node, + } = getFromRenderer(this.renderer); + this.component = component; + this.node = node; + this.nodes = [node]; }); return this; } @@ -211,6 +260,7 @@ class ReactWrapper { } this.single('unmount', () => { this.component.setState({ mount: false }); + this.update(); }); return this; } @@ -227,6 +277,7 @@ class ReactWrapper { } this.single('mount', () => { this.component.setState({ mount: true }); + this.update(); }); return this; } @@ -245,11 +296,17 @@ class ReactWrapper { * @param {Function} cb - callback function * @returns {ReactWrapper} */ - setProps(props, callback = undefined) { + setProps(props, callback = noop) { if (this.root !== this) { throw new Error('ReactWrapper::setProps() can only be called on the root'); } - this.component.setChildProps(props, callback); + if (typeof callback !== 'function') { + throw new TypeError('ReactWrapper::setProps() expects a function as its second argument'); + } + this.component.setChildProps(props, () => { + this.update(); + callback(); + }); return this; } @@ -266,11 +323,17 @@ class ReactWrapper { * @param {Function} cb - callback function * @returns {ReactWrapper} */ - setState(state, callback = undefined) { + setState(state, callback = noop) { if (this.root !== this) { throw new Error('ReactWrapper::setState() can only be called on the root'); } - this.instance().setState(state, callback); + if (typeof callback !== 'function') { + throw new TypeError('ReactWrapper::setState() expects a function as its second argument'); + } + this.instance().setState(state, () => { + this.update(); + callback(); + }); return this; } @@ -294,6 +357,7 @@ class ReactWrapper { ); } this.component.setChildContext(context); + this.update(); return this; } @@ -314,7 +378,7 @@ class ReactWrapper { * @returns {Boolean} */ matchesElement(node) { - return this.single('matchesElement', () => instMatches(node, this.getNode(), (a, b) => a <= b)); + return this.single('matchesElement', () => nodeMatches(node, this.getNodeInternal(), (a, b) => a <= b)); } /** @@ -331,8 +395,8 @@ class ReactWrapper { */ contains(nodeOrNodes) { const predicate = Array.isArray(nodeOrNodes) - ? other => containsChildrenSubArray(instEqual, other, nodeOrNodes) - : other => instEqual(nodeOrNodes, other); + ? other => containsChildrenSubArray(nodeEqual, other, nodeOrNodes) + : other => nodeEqual(nodeOrNodes, other); return findWhereUnwrapped(this, predicate).length > 0; } @@ -353,7 +417,7 @@ class ReactWrapper { * @returns {Boolean} */ containsMatchingElement(node) { - const predicate = other => instMatches(node, other, (a, b) => a <= b); + const predicate = other => nodeMatches(node, other, (a, b) => a <= b); return findWhereUnwrapped(this, predicate).length > 0; } @@ -424,7 +488,7 @@ class ReactWrapper { * @returns {boolean} */ is(selector) { - const predicate = buildInstPredicate(selector); + const predicate = buildPredicate(selector); return this.single('is', n => predicate(n)); } @@ -434,16 +498,7 @@ class ReactWrapper { * @returns {boolean} */ isEmptyRender() { - return this.single('isEmptyRender', (n) => { - // Stateful components and stateless function components have different internal structures, - // so we need to find the correct internal instance, and validate the rendered node type - // equals 2, which is the `ReactNodeTypes.EMPTY` value. - if (REACT15) { - return internalInstanceOrComponent(n)._renderedNodeType === 2; - } - - return findDOMNode(n) === null; - }); + return this.single('isEmptyRender', n => n.rendered === null); } /** @@ -465,7 +520,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ filter(selector) { - const predicate = buildInstPredicate(selector); + const predicate = buildPredicate(selector); return filterWhereUnwrapped(this, predicate); } @@ -477,7 +532,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ not(selector) { - const predicate = buildInstPredicate(selector); + const predicate = buildPredicate(selector); return filterWhereUnwrapped(this, n => !predicate(n)); } @@ -491,7 +546,8 @@ class ReactWrapper { * @returns {String} */ text() { - return this.single('text', n => findDOMNode(n).textContent); + const adapter = getAdapter(this.options); + return this.single('text', n => adapter.nodeToHostNode(n).textContent); } /** @@ -503,7 +559,9 @@ class ReactWrapper { */ html() { return this.single('html', (n) => { - const node = findDOMNode(n); + if (n === null) return null; + const adapter = getAdapter(this.options); + const node = adapter.nodeToHostNode(n); return node === null ? null : node.outerHTML.replace(/\sdata-(reactid|reactroot)+="([^"]*)+"/g, ''); }); @@ -531,13 +589,8 @@ class ReactWrapper { */ simulate(event, mock = {}) { this.single('simulate', (n) => { - const mappedEvent = mapNativeEventNames(event); - const eventFn = Simulate[mappedEvent]; - if (!eventFn) { - throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); - } - - eventFn(findDOMNode(n), mock); + this.renderer.simulateEvent(n, event, mock); + this.root.update(); }); return this; } @@ -586,7 +639,11 @@ class ReactWrapper { if (this.root !== this) { throw new Error('ReactWrapper::context() can only be called on the root'); } - const _context = this.single('context', () => this.instance().context); + const instance = this.single('context', () => this.instance()); + if (instance === null) { + throw new Error('ReactWrapper::context() can only be called on components with instances'); + } + const _context = instance.context; if (name !== undefined) { return _context[name]; } @@ -600,7 +657,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ children(selector) { - const allChildren = this.flatMap(n => childrenOfInst(n.getNode())); + const allChildren = this.flatMap(n => childrenOfNode(n.getNodeInternal()).filter(x => typeof x === 'object')); return selector ? allChildren.filter(selector) : allChildren; } @@ -625,7 +682,7 @@ class ReactWrapper { */ parents(selector) { const allParents = this.wrap( - this.single('parents', n => parentsOfInst(n, this.root.getNode())), + this.single('parents', n => parentsOfNode(n, this.root.getNodeInternal())), ); return selector ? allParents.filter(selector) : allParents; } @@ -664,7 +721,7 @@ class ReactWrapper { * @returns {String} */ key() { - return this.single('key', n => getNode(n).key); + return this.single('key', n => n.key); } /** @@ -674,7 +731,7 @@ class ReactWrapper { * @returns {String|Function} */ type() { - return this.single('type', n => typeOfNode(getNode(n))); + return this.single('type', n => typeOfNode(n)); } /** @@ -685,7 +742,7 @@ class ReactWrapper { * @returns {String} */ name() { - return this.single('name', n => displayNameOfNode(getNode(n))); + return this.single('name', n => displayNameOfNode(n)); } /** @@ -704,7 +761,7 @@ class ReactWrapper { 'hasClass() expects a class name, not a CSS selector.', ); } - return this.single('hasClass', n => instHasClassName(n, className)); + return this.single('hasClass', n => hasClassName(n, className)); } /** @@ -715,7 +772,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ forEach(fn) { - this.getNodes().forEach((n, i) => fn.call(this, this.wrap(n), i)); + this.getNodesInternal().forEach((n, i) => fn.call(this, this.wrap(n), i)); return this; } @@ -727,7 +784,7 @@ class ReactWrapper { * @returns {Array} */ map(fn) { - return this.getNodes().map((n, i) => fn.call(this, this.wrap(n), i)); + return this.getNodesInternal().map((n, i) => fn.call(this, this.wrap(n), i)); } /** @@ -739,7 +796,7 @@ class ReactWrapper { * @returns {*} */ reduce(fn, initialValue) { - return this.getNodes().reduce( + return this.getNodesInternal().reduce( (accum, n, i) => fn.call(this, accum, this.wrap(n), i), initialValue, ); @@ -754,7 +811,7 @@ class ReactWrapper { * @returns {*} */ reduceRight(fn, initialValue) { - return this.getNodes().reduceRight( + return this.getNodesInternal().reduceRight( (accum, n, i) => fn.call(this, accum, this.wrap(n), i), initialValue, ); @@ -769,7 +826,7 @@ class ReactWrapper { * @returns {ShallowWrapper} */ slice(begin, end) { - return this.wrap(this.getNodes().slice(begin, end)); + return this.wrap(this.getNodesInternal().slice(begin, end)); } /** @@ -782,8 +839,8 @@ class ReactWrapper { if (this.root === this) { throw new Error('ReactWrapper::some() can not be called on the root'); } - const predicate = buildInstPredicate(selector); - return this.getNodes().some(predicate); + const predicate = buildPredicate(selector); + return this.getNodesInternal().some(predicate); } /** @@ -793,7 +850,7 @@ class ReactWrapper { * @returns {Boolean} */ someWhere(predicate) { - return this.getNodes().some((n, i) => predicate.call(this, this.wrap(n), i)); + return this.getNodesInternal().some((n, i) => predicate.call(this, this.wrap(n), i)); } /** @@ -803,8 +860,8 @@ class ReactWrapper { * @returns {Boolean} */ every(selector) { - const predicate = buildInstPredicate(selector); - return this.getNodes().every(predicate); + const predicate = buildPredicate(selector); + return this.getNodesInternal().every(predicate); } /** @@ -814,7 +871,7 @@ class ReactWrapper { * @returns {Boolean} */ everyWhere(predicate) { - return this.getNodes().every((n, i) => predicate.call(this, this.wrap(n), i)); + return this.getNodesInternal().every((n, i) => predicate.call(this, this.wrap(n), i)); } /** @@ -826,7 +883,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ flatMap(fn) { - const nodes = this.getNodes().map((n, i) => fn.call(this, this.wrap(n), i)); + const nodes = this.getNodesInternal().map((n, i) => fn.call(this, this.wrap(n), i)); const flattened = flatten(nodes, true); const uniques = unique(flattened); const compacted = compact(uniques); @@ -851,7 +908,7 @@ class ReactWrapper { * @returns {ReactElement} */ get(index) { - return this.getNodes()[index]; + return this.getElements()[index]; } /** @@ -861,7 +918,7 @@ class ReactWrapper { * @returns {ReactWrapper} */ at(index) { - return this.wrap(this.getNodes()[index]); + return this.wrap(this.getNodesInternal()[index]); } /** @@ -918,7 +975,7 @@ class ReactWrapper { `Method “${fnName}” is only meant to be run on a single node. ${this.length} found instead.`, ); } - return callback.call(this, this.getNode()); + return callback.call(this, this.getNodeInternal()); } /** @@ -943,7 +1000,7 @@ class ReactWrapper { * @returns {String} */ debug(options = {}) { - return debugInsts(this.getNodes(), options); + return debugNodes(this.getNodesInternal(), options); } /** @@ -976,7 +1033,7 @@ class ReactWrapper { '`mount()`.', ); } - unmountComponentAtNode(this.options.attachTo); + this.renderer.unmount(); } } @@ -985,7 +1042,20 @@ if (ITERATOR_SYMBOL) { Object.defineProperty(ReactWrapper.prototype, ITERATOR_SYMBOL, { configurable: true, value: function iterator() { - return this.nodes[ITERATOR_SYMBOL](); + const iter = this.nodes[ITERATOR_SYMBOL](); + const adapter = getAdapter(this.options); + return { + next() { + const next = iter.next(); + if (next.done) { + return { done: true }; + } + return { + done: false, + value: adapter.nodeToElement(next.value), + }; + }, + }; }, }); } diff --git a/src/ShallowWrapper.js b/src/ShallowWrapper.js index 1e081a13b..586a03781 100644 --- a/src/ShallowWrapper.js +++ b/src/ShallowWrapper.js @@ -9,34 +9,27 @@ import { nodeEqual, nodeMatches, containsChildrenSubArray, - propFromEvent, withSetStateAllowed, - propsOfNode, typeOfNode, isReactElementAlike, displayNameOfNode, isFunctionalComponent, isCustomComponentElement, ITERATOR_SYMBOL, + getAdapter, } from './Utils'; import { debugNodes, } from './Debug'; import { + propsOfNode, getTextFromNode, hasClassName, childrenOfNode, parentsOfNode, treeFilter, buildPredicate, -} from './ShallowTraversal'; -import { - createShallowRenderer, - renderToStaticMarkup, - batchedUpdates, - isDOMComponentElement, -} from './react-compat'; -import { REACT155 } from './version'; +} from './RSTTraversal'; /** * Finds all nodes in the current wrapper nodes' render trees that match the provided predicate @@ -48,7 +41,7 @@ import { REACT155 } from './version'; * @returns {ShallowWrapper} */ function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { - return wrapper.flatMap(n => filter(n.getNode(), predicate)); + return wrapper.flatMap(n => filter(n.getNodeInternal(), predicate)); } /** @@ -60,7 +53,7 @@ function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { * @returns {ShallowWrapper} */ function filterWhereUnwrapped(wrapper, predicate) { - return wrapper.wrap(compact(wrapper.getNodes().filter(predicate))); + return wrapper.wrap(compact(wrapper.getNodesInternal().filter(predicate))); } /** @@ -98,15 +91,11 @@ function validateOptions(options) { } } - -function performBatchedUpdates(wrapper, fn) { - const renderer = wrapper.root.renderer; - if (REACT155 && renderer.unstable_batchedUpdates) { - // React 15.5+ exposes batching on shallow renderer itself - return renderer.unstable_batchedUpdates(fn); +function getRootNode(node) { + if (node.nodeType === 'host') { + return node; } - // React <15.5: Fallback to ReactDOM - return batchedUpdates(fn); + return node.rendered; } /** @@ -118,27 +107,25 @@ class ShallowWrapper { if (!root) { this.root = this; this.unrendered = nodes; - this.renderer = createShallowRenderer(); - withSetStateAllowed(() => { - performBatchedUpdates(this, () => { - this.renderer.render(nodes, options.context); - const instance = this.instance(); - if ( - options.lifecycleExperimental && - instance && - typeof instance.componentDidMount === 'function' - ) { - instance.componentDidMount(); - } + this.renderer = getAdapter(options).createRenderer({ mode: 'shallow', ...options }); + this.renderer.render(nodes, options.context); + const instance = this.renderer.getNode().instance; + if ( + options.lifecycleExperimental && + instance && + typeof instance.componentDidMount === 'function' + ) { + this.renderer.batchedUpdates(() => { + instance.componentDidMount(); }); - }); - this.node = this.renderer.getRenderOutput(); + } + this.node = getRootNode(this.renderer.getNode()); this.nodes = [this.node]; this.length = 1; } else { this.root = root; this.unrendered = null; - this.renderer = null; + this.renderer = root.renderer; if (!Array.isArray(nodes)) { this.node = nodes; this.nodes = [nodes]; @@ -148,22 +135,31 @@ class ShallowWrapper { } this.length = this.nodes.length; } - this.options = options; + this.options = root ? root.options : options; this.complexSelector = new ComplexSelector(buildPredicate, findWhereUnwrapped, childrenOfNode); } + getNodeInternal() { + if (this.length !== 1) { + throw new Error( + 'ShallowWrapper::getNode() can only be called when wrapping one node', + ); + } + return this.node; + } + /** * Returns the wrapped ReactElement. * * @return {ReactElement} */ - getNode() { + getElement() { if (this.length !== 1) { throw new Error( - 'ShallowWrapper::getNode() can only be called when wrapping one node', + 'ShallowWrapper::getElement() can only be called when wrapping one node', ); } - return this.root === this ? this.renderer.getRenderOutput() : this.node; + return getAdapter(this.options).nodeToElement(this.node); } /** @@ -171,8 +167,26 @@ class ShallowWrapper { * * @return {Array} */ + getElements() { + return this.nodes.map(getAdapter(this.options).nodeToElement); + } + + // eslint-disable-next-line class-methods-use-this + getNode() { + throw new Error( + 'ShallowWrapper::getNode() is no longer supported. Use ShallowWrapper::getElement() instead', + ); + } + + getNodesInternal() { + return this.nodes; + } + + // eslint-disable-next-line class-methods-use-this getNodes() { - return this.root === this ? [this.renderer.getRenderOutput()] : this.nodes; + throw new Error( + 'ShallowWrapper::getNodes() is no longer supported. Use ShallowWrapper::getElements() instead', + ); } /** @@ -192,7 +206,7 @@ class ShallowWrapper { if (this.root !== this) { throw new Error('ShallowWrapper::instance() can only be called on the root'); } - return this.renderer._instance ? this.renderer._instance._instance : null; + return this.renderer.getNode().instance; } /** @@ -208,7 +222,7 @@ class ShallowWrapper { throw new Error('ShallowWrapper::update() can only be called on the root'); } this.single('update', () => { - this.node = this.renderer.getRenderOutput(); + this.node = getRootNode(this.renderer.getNode()); this.nodes = [this.node]; }); return this; @@ -227,13 +241,19 @@ class ShallowWrapper { rerender(props, context) { this.single('rerender', () => { withSetStateAllowed(() => { - const instance = this.instance(); + // NOTE(lmr): In react 16, instances will be null for SFCs, but + // rerendering with props/context is still a valid thing to do. In + // this case, state will be undefined, but props/context will exist. + const instance = this.instance() || {}; const state = instance.state; - const prevProps = instance.props; - const prevContext = instance.context; + const prevProps = instance.props || this.unrendered.props; + const prevContext = instance.context || this.options.context; const nextProps = props || prevProps; const nextContext = context || prevContext; - performBatchedUpdates(this, () => { + if (context) { + this.options = { ...this.options, context: nextContext }; + } + this.renderer.batchedUpdates(() => { let shouldRender = true; // dirty hack: // make sure that componentWillReceiveProps is called before shouldComponentUpdate @@ -326,7 +346,7 @@ class ShallowWrapper { if (this.root !== this) { throw new Error('ShallowWrapper::setState() can only be called on the root'); } - if (isFunctionalComponent(this.instance())) { + if (this.instance() === null || isFunctionalComponent(this.instance())) { throw new Error('ShallowWrapper::setState() can only be called on class components'); } this.single('setState', () => { @@ -373,16 +393,20 @@ class ShallowWrapper { * @returns {Boolean} */ contains(nodeOrNodes) { - if (!isReactElementAlike(nodeOrNodes)) { + const adapter = getAdapter(this.options); + if (!isReactElementAlike(nodeOrNodes, adapter)) { throw new Error( 'ShallowWrapper::contains() can only be called with ReactElement (or array of them), ' + 'string or number as argument.', ); } - const predicate = Array.isArray(nodeOrNodes) - ? other => containsChildrenSubArray(nodeEqual, other, nodeOrNodes) - : other => nodeEqual(nodeOrNodes, other); + ? other => containsChildrenSubArray( + nodeEqual, + other, + nodeOrNodes.map(adapter.elementToNode), + ) + : other => nodeEqual(adapter.elementToNode(nodeOrNodes), other); return findWhereUnwrapped(this, predicate).length > 0; } @@ -472,7 +496,7 @@ class ShallowWrapper { * @returns {Boolean} */ equals(node) { - return this.single('equals', () => nodeEqual(this.getNode(), node)); + return this.single('equals', () => nodeEqual(this.getNodeInternal(), node)); } /** @@ -493,7 +517,7 @@ class ShallowWrapper { * @returns {Boolean} */ matchesElement(node) { - return this.single('matchesElement', () => nodeMatches(node, this.getNode(), (a, b) => a <= b)); + return this.single('matchesElement', () => nodeMatches(node, this.getNodeInternal(), (a, b) => a <= b)); } /** @@ -585,7 +609,12 @@ class ShallowWrapper { * @returns {String} */ html() { - return this.single('html', n => (this.type() === null ? null : renderToStaticMarkup(n))); + return this.single('html', (n) => { + if (this.type() === null) return null; + const adapter = getAdapter(this.options); + const renderer = adapter.createRenderer({ ...this.options, mode: 'string' }); + return renderer.render(adapter.nodeToElement(n)); + }); } /** @@ -618,18 +647,10 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ simulate(event, ...args) { - const handler = this.prop(propFromEvent(event)); - if (handler) { - withSetStateAllowed(() => { - // TODO(lmr): create/use synthetic events - // TODO(lmr): emulate React's event propagation - performBatchedUpdates(this, () => { - handler(...args); - }); - this.root.update(); - }); - } - return this; + return this.single('simulate', (n) => { + this.renderer.simulateEvent(n, event, ...args); + this.root.update(); + }); } /** @@ -656,7 +677,7 @@ class ShallowWrapper { if (this.root !== this) { throw new Error('ShallowWrapper::state() can only be called on the root'); } - if (isFunctionalComponent(this.instance())) { + if (this.instance() === null || isFunctionalComponent(this.instance())) { throw new Error('ShallowWrapper::state() can only be called on class components'); } const _state = this.single('state', () => this.instance().state); @@ -685,6 +706,12 @@ class ShallowWrapper { 'a context option', ); } + if (this.instance() === null) { + throw new Error( + 'ShallowWrapper::context() can only be called on wrapped nodes that have a non-null ' + + 'instance', + ); + } const _context = this.single('context', () => this.instance().context); if (name) { return _context[name]; @@ -699,7 +726,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ children(selector) { - const allChildren = this.flatMap(n => childrenOfNode(n.getNode())); + const allChildren = this.flatMap(n => childrenOfNode(n.getNodeInternal())); return selector ? allChildren.filter(selector) : allChildren; } @@ -724,7 +751,7 @@ class ShallowWrapper { */ parents(selector) { const allParents = this.wrap( - this.single('parents', n => parentsOfNode(n, this.root.getNode())), + this.single('parents', n => parentsOfNode(n, this.root.node)), ); return selector ? allParents.filter(selector) : allParents; } @@ -756,7 +783,9 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ shallow(options) { - return this.single('shallow', n => this.wrap(n, null, options)); + return this.single('shallow', n => this.wrap( + getAdapter(this.options).nodeToElement(n), null, options), + ); } /** @@ -826,7 +855,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ forEach(fn) { - this.getNodes().forEach((n, i) => fn.call(this, this.wrap(n), i)); + this.getNodesInternal().forEach((n, i) => fn.call(this, this.wrap(n), i)); return this; } @@ -838,7 +867,7 @@ class ShallowWrapper { * @returns {Array} */ map(fn) { - return this.getNodes().map((n, i) => fn.call(this, this.wrap(n), i)); + return this.getNodesInternal().map((n, i) => fn.call(this, this.wrap(n), i)); } /** @@ -850,7 +879,7 @@ class ShallowWrapper { * @returns {*} */ reduce(fn, initialValue) { - return this.getNodes().reduce( + return this.getNodesInternal().reduce( (accum, n, i) => fn.call(this, accum, this.wrap(n), i), initialValue, ); @@ -865,7 +894,7 @@ class ShallowWrapper { * @returns {*} */ reduceRight(fn, initialValue) { - return this.getNodes().reduceRight( + return this.getNodesInternal().reduceRight( (accum, n, i) => fn.call(this, accum, this.wrap(n), i), initialValue, ); @@ -880,7 +909,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ slice(begin, end) { - return this.wrap(this.getNodes().slice(begin, end)); + return this.wrap(this.getNodesInternal().slice(begin, end)); } /** @@ -894,7 +923,7 @@ class ShallowWrapper { throw new Error('ShallowWrapper::some() can not be called on the root'); } const predicate = buildPredicate(selector); - return this.getNodes().some(predicate); + return this.getNodesInternal().some(predicate); } /** @@ -904,7 +933,7 @@ class ShallowWrapper { * @returns {Boolean} */ someWhere(predicate) { - return this.getNodes().some((n, i) => predicate.call(this, this.wrap(n), i)); + return this.getNodesInternal().some((n, i) => predicate.call(this, this.wrap(n), i)); } /** @@ -915,7 +944,7 @@ class ShallowWrapper { */ every(selector) { const predicate = buildPredicate(selector); - return this.getNodes().every(predicate); + return this.getNodesInternal().every(predicate); } /** @@ -925,7 +954,7 @@ class ShallowWrapper { * @returns {Boolean} */ everyWhere(predicate) { - return this.getNodes().every((n, i) => predicate.call(this, this.wrap(n), i)); + return this.getNodesInternal().every((n, i) => predicate.call(this, this.wrap(n), i)); } /** @@ -937,7 +966,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ flatMap(fn) { - const nodes = this.getNodes().map((n, i) => fn.call(this, this.wrap(n), i)); + const nodes = this.getNodesInternal().map((n, i) => fn.call(this, this.wrap(n), i)); const flattened = flatten(nodes, true); const uniques = unique(flattened); const compacted = compact(uniques); @@ -963,7 +992,7 @@ class ShallowWrapper { * @returns {ReactElement} */ get(index) { - return this.getNodes()[index]; + return getAdapter(this.options).nodeToElement(this.getNodesInternal()[index]); } /** @@ -973,7 +1002,7 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ at(index) { - return this.wrap(this.getNodes()[index]); + return this.wrap(this.getNodesInternal()[index]); } /** @@ -1030,7 +1059,7 @@ class ShallowWrapper { `Method “${fnName}” is only meant to be run on a single node. ${this.length} found instead.`, ); } - return callback.call(this, this.getNode()); + return callback.call(this, this.getNodeInternal()); } /** @@ -1055,7 +1084,7 @@ class ShallowWrapper { * @returns {String} */ debug(options = {}) { - return debugNodes(this.getNodes(), options); + return debugNodes(this.getNodesInternal(), options); } /** @@ -1077,15 +1106,17 @@ class ShallowWrapper { * @returns {ShallowWrapper} */ dive(options = {}) { + const adapter = getAdapter(this.options); const name = 'dive'; return this.single(name, (n) => { - if (isDOMComponentElement(n)) { - throw new TypeError(`ShallowWrapper::${name}() can not be called on DOM components`); + if (n && n.nodeType === 'host') { + throw new TypeError(`ShallowWrapper::${name}() can not be called on Host Components`); } - if (!isCustomComponentElement(n)) { + const el = getAdapter(this.options).nodeToElement(n); + if (!isCustomComponentElement(el, adapter)) { throw new TypeError(`ShallowWrapper::${name}() can only be called on components`); } - return this.wrap(n, null, { ...this.options, ...options }); + return this.wrap(el, null, { ...this.options, ...options }); }); } } @@ -1094,7 +1125,20 @@ if (ITERATOR_SYMBOL) { Object.defineProperty(ShallowWrapper.prototype, ITERATOR_SYMBOL, { configurable: true, value: function iterator() { - return this.nodes[ITERATOR_SYMBOL](); + const iter = this.nodes[ITERATOR_SYMBOL](); + const adapter = getAdapter(this.options); + return { + next() { + const next = iter.next(); + if (next.done) { + return { done: true }; + } + return { + done: false, + value: adapter.nodeToElement(next.value), + }; + }, + }; }, }); } diff --git a/src/Utils.js b/src/Utils.js index 8dd62bfeb..63079867b 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1,56 +1,35 @@ /* eslint no-use-before-define:0 */ import isEqual from 'lodash/isEqual'; -import React from 'react'; import is from 'object-is'; import uuidv4 from 'uuid/v4'; import entries from 'object.entries'; import functionName from 'function.prototype.name'; -import { - isDOMComponent, - findDOMNode, - childrenToArray, -} from './react-compat'; -import { - REACT013, - REACT15, -} from './version'; +import configuration from './configuration'; +import validateAdapter from './validateAdapter'; export const ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; -function internalInstanceKey(node) { - return Object.keys(Object(node)).filter(key => key.match(/^__reactInternalInstance\$/))[0]; -} - -export function internalInstance(inst) { - return inst._reactInternalInstance || - inst[internalInstanceKey(inst)]; +export function getAdapter(options = {}) { + if (options.adapter) { + validateAdapter(options.adapter); + return options.adapter; + } + const adapter = configuration.get().adapter; + validateAdapter(adapter); + return adapter; } +// TODO(lmr): we shouldn't need this export function isFunctionalComponent(inst) { return !!inst && !!inst.constructor && typeof inst.constructor === 'function' && functionName(inst.constructor) === 'StatelessComponent'; } -export function isCustomComponentElement(inst) { - return !!inst && React.isValidElement(inst) && typeof inst.type === 'function'; +export function isCustomComponentElement(inst, adapter) { + return !!inst && adapter.isValidElement(inst) && typeof inst.type === 'function'; } -export function propsOfNode(node) { - if (REACT013 && node && node._store) { - return (node._store.props) || {}; - } - if (node && node._reactInternalComponent && node._reactInternalComponent._currentElement) { - return (node._reactInternalComponent._currentElement.props) || {}; - } - if (node && node._currentElement) { - return (node._currentElement.props) || {}; - } - if (REACT15 && node) { - if (internalInstance(node) && internalInstance(node)._currentElement) { - return (internalInstance(node)._currentElement.props) || {}; - } - } - +function propsOfNode(node) { return (node && node.props) || {}; } @@ -58,10 +37,6 @@ export function typeOfNode(node) { return node ? node.type : null; } -export function getNode(node) { - return isDOMComponent(node) ? findDOMNode(node) : node; -} - export function nodeHasType(node, type) { if (!type || !node) return false; if (!node.type) return false; @@ -86,11 +61,11 @@ function internalChildrenCompare(a, b, lenComp, isLoose) { return true; } -export function childrenMatch(a, b, lenComp) { +function childrenMatch(a, b, lenComp) { return internalChildrenCompare(a, b, lenComp, true); } -export function childrenEqual(a, b, lenComp) { +function childrenEqual(a, b, lenComp) { return internalChildrenCompare(a, b, lenComp, false); } @@ -167,6 +142,22 @@ function arraysEqual(match, left, right) { return left.length === right.length && left.every((el, i) => match(el, right[i])); } +function childrenToArray(children) { + const result = []; + + const push = (el) => { + if (el === null || el === false || el === undefined) return; + result.push(el); + }; + + if (Array.isArray(children)) { + children.forEach(push); + } else { + push(children); + } + return result; +} + export function childrenToSimplifiedArray(nodeChildren) { const childrenArray = childrenToArray(nodeChildren); const simplifiedArray = []; @@ -198,17 +189,11 @@ function isTextualNode(node) { return typeof node === 'string' || typeof node === 'number'; } -export function isReactElementAlike(arg) { - return React.isValidElement(arg) || isTextualNode(arg) || Array.isArray(arg); -} - -// 'click' => 'onClick' -// 'mouseEnter' => 'onMouseEnter' -export function propFromEvent(event) { - const nativeEvent = mapNativeEventNames(event); - return `on${nativeEvent[0].toUpperCase()}${nativeEvent.slice(1)}`; +export function isReactElementAlike(arg, adapter) { + return adapter.isValidElement(arg) || isTextualNode(arg) || Array.isArray(arg); } +// TODO(lmr): can we get rid of this outside of the adapter? export function withSetStateAllowed(fn) { // NOTE(lmr): // this is currently here to circumvent a React bug where `setState()` is @@ -273,7 +258,7 @@ export function isPseudoClassSelector(selector) { return false; } -export function selectorError(selector, type = '') { +function selectorError(selector, type = '') { return new TypeError( `Enzyme received a ${type} CSS selector ('${selector}') that it does not currently support`, ); @@ -376,54 +361,6 @@ export function nodeHasProperty(node, propKey, stringifiedPropValue) { return Object.prototype.hasOwnProperty.call(nodeProps, propKey); } -export function mapNativeEventNames(event) { - const nativeToReactEventMap = { - compositionend: 'compositionEnd', - compositionstart: 'compositionStart', - compositionupdate: 'compositionUpdate', - keydown: 'keyDown', - keyup: 'keyUp', - keypress: 'keyPress', - contextmenu: 'contextMenu', - dblclick: 'doubleClick', - doubleclick: 'doubleClick', // kept for legacy. TODO: remove with next major. - dragend: 'dragEnd', - dragenter: 'dragEnter', - dragexist: 'dragExit', - dragleave: 'dragLeave', - dragover: 'dragOver', - dragstart: 'dragStart', - mousedown: 'mouseDown', - mousemove: 'mouseMove', - mouseout: 'mouseOut', - mouseover: 'mouseOver', - mouseup: 'mouseUp', - touchcancel: 'touchCancel', - touchend: 'touchEnd', - touchmove: 'touchMove', - touchstart: 'touchStart', - canplay: 'canPlay', - canplaythrough: 'canPlayThrough', - durationchange: 'durationChange', - loadeddata: 'loadedData', - loadedmetadata: 'loadedMetadata', - loadstart: 'loadStart', - ratechange: 'rateChange', - timeupdate: 'timeUpdate', - volumechange: 'volumeChange', - beforeinput: 'beforeInput', - }; - - if (!REACT013) { - // these could not be simulated in React 0.13: - // https://github.com/facebook/react/issues/1297 - nativeToReactEventMap.mouseenter = 'mouseEnter'; - nativeToReactEventMap.mouseleave = 'mouseLeave'; - } - - return nativeToReactEventMap[event] || event; -} - export function displayNameOfNode(node) { const { type } = node; diff --git a/src/adapters/.eslintrc b/src/adapters/.eslintrc new file mode 100644 index 000000000..557c66e59 --- /dev/null +++ b/src/adapters/.eslintrc @@ -0,0 +1,12 @@ +{ + "rules": { + "import/no-extraneous-dependencies": 0, + "import/no-unresolved": 0, + "import/extensions": 0, + "react/no-deprecated": 0, + "react/no-find-dom-node": 0, + "react/no-multi-comp": 0, + "no-underscore-dangle": 0, + "class-methods-use-this": 0 + } +} diff --git a/src/adapters/EnzymeAdapter.js b/src/adapters/EnzymeAdapter.js new file mode 100644 index 000000000..76122a50b --- /dev/null +++ b/src/adapters/EnzymeAdapter.js @@ -0,0 +1,40 @@ +function unimplementedError(methodName, classname) { + return new Error( + `${methodName} is a required method of ${classname}, but was not implemented.`, + ); +} + +class EnzymeAdapter { + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + throw unimplementedError('createRenderer', 'EnzymeAdapter'); + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + throw unimplementedError('nodeToElement', 'EnzymeAdapter'); + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars + isValidElement(element) { + throw unimplementedError('isValidElement', 'EnzymeAdapter'); + } + + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createElement(type, props, ...children) { + throw unimplementedError('createElement', 'EnzymeAdapter'); + } +} + +EnzymeAdapter.MODES = { + STRING: 'string', + MOUNT: 'mount', + SHALLOW: 'shallow', +}; + +module.exports = EnzymeAdapter; diff --git a/src/adapters/ReactFifteenAdapter.js b/src/adapters/ReactFifteenAdapter.js new file mode 100644 index 000000000..0d7602338 --- /dev/null +++ b/src/adapters/ReactFifteenAdapter.js @@ -0,0 +1,213 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactDOMServer from 'react-dom/server'; +import TestUtils from 'react-dom/test-utils'; +import PropTypes from 'prop-types'; +import values from 'object.values'; +import EnzymeAdapter from './EnzymeAdapter'; +import elementToTree from './elementToTree'; +import { + mapNativeEventNames, + propFromEvent, + withSetStateAllowed, + assertDomAvailable, +} from './Utils'; + +function compositeTypeToNodeType(type) { + switch (type) { + case 0: + case 1: return 'class'; + case 2: return 'function'; + default: + throw new Error(`Enzyme Internal Error: unknown composite type ${type}`); + } +} + +function instanceToTree(inst) { + if (!inst || typeof inst !== 'object') { + return inst; + } + const el = inst._currentElement; + if (!el) { + return null; + } + if (inst._renderedChildren) { + return { + nodeType: inst._hostNode ? 'host' : compositeTypeToNodeType(inst._compositeType), + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: values(inst._renderedChildren).map(instanceToTree), + }; + } + if (inst._hostNode) { + if (typeof el !== 'object') { + return el; + } + const children = inst._renderedChildren || { '.0': el.props.children }; + return { + nodeType: 'host', + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: values(children).map(instanceToTree), + }; + } + if (inst._renderedComponent) { + return { + nodeType: compositeTypeToNodeType(inst._compositeType), + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: instanceToTree(inst._renderedComponent), + }; + } + throw new Error('Enzyme Internal Error: unknown instance encountered'); +} + +class SimpleWrapper extends React.Component { + render() { + return this.props.node || null; + } +} + +SimpleWrapper.propTypes = { node: PropTypes.node.isRequired }; + +class ReactFifteenAdapter extends EnzymeAdapter { + createMountRenderer(options) { + assertDomAvailable('mount'); + const domNode = options.attachTo || global.document.createElement('div'); + let instance = null; + return { + render(el/* , context */) { + const wrappedEl = React.createElement(SimpleWrapper, { + node: el, + }); + instance = ReactDOM.render(wrappedEl, domNode); + }, + unmount() { + ReactDOM.unmountComponentAtNode(domNode); + }, + getNode() { + return instanceToTree(instance._reactInternalInstance._renderedComponent); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + // eslint-disable-next-line react/no-find-dom-node + eventFn(ReactDOM.findDOMNode(node.instance), mock); + }, + batchedUpdates(fn) { + return ReactDOM.unstable_batchedUpdates(fn); + }, + }; + } + + createShallowRenderer(/* options */) { + const renderer = TestUtils.createRenderer(); + let isDOM = false; + let cachedNode = null; + return { + render(el, context) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else { + isDOM = false; + return withSetStateAllowed(() => renderer.render(el, context)); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: 'class', + type: cachedNode.type, + props: cachedNode.props, + key: cachedNode.key, + ref: cachedNode.ref, + instance: renderer._instance._instance, + rendered: elementToTree(output), + }; + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + renderer.unstable_batchedUpdates(() => { + handler(...args); + }); + }); + } + }, + batchedUpdates(fn) { + return withSetStateAllowed(() => renderer.unstable_batchedUpdates(fn)); + }, + }; + } + + createStringRenderer(/* options */) { + return { + render(el /* , context */) { + return ReactDOMServer.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + return React.createElement(node.type, node.props); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node) { + return ReactDOM.findDOMNode(node.instance); + } + + isValidElement(element) { + return React.isValidElement(element); + } + + createElement(...args) { + return React.createElement(...args); + } +} + +module.exports = ReactFifteenAdapter; diff --git a/src/adapters/ReactFifteenFourAdapter.js b/src/adapters/ReactFifteenFourAdapter.js new file mode 100644 index 000000000..43c161868 --- /dev/null +++ b/src/adapters/ReactFifteenFourAdapter.js @@ -0,0 +1,213 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactDOMServer from 'react-dom/server'; +import TestUtils from 'react-addons-test-utils'; +import PropTypes from 'prop-types'; +import values from 'object.values'; +import EnzymeAdapter from './EnzymeAdapter'; +import elementToTree from './elementToTree'; +import { + mapNativeEventNames, + propFromEvent, + withSetStateAllowed, + assertDomAvailable, +} from './Utils'; + +function compositeTypeToNodeType(type) { + switch (type) { + case 0: + case 1: return 'class'; + case 2: return 'function'; + default: + throw new Error(`Enzyme Internal Error: unknown composite type ${type}`); + } +} + +function instanceToTree(inst) { + if (!inst || typeof inst !== 'object') { + return inst; + } + const el = inst._currentElement; + if (!el) { + return null; + } + if (inst._renderedChildren) { + return { + nodeType: inst._hostNode ? 'host' : compositeTypeToNodeType(inst._compositeType), + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: values(inst._renderedChildren).map(instanceToTree), + }; + } + if (inst._hostNode) { + if (typeof el !== 'object') { + return el; + } + const children = inst._renderedChildren || { '.0': el.props.children }; + return { + nodeType: 'host', + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: values(children).map(instanceToTree), + }; + } + if (inst._renderedComponent) { + return { + nodeType: compositeTypeToNodeType(inst._compositeType), + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: instanceToTree(inst._renderedComponent), + }; + } + throw new Error('Enzyme Internal Error: unknown instance encountered'); +} + +class SimpleWrapper extends React.Component { + render() { + return this.props.node || null; + } +} + +SimpleWrapper.propTypes = { node: PropTypes.node.isRequired }; + +class ReactFifteenFourAdapter extends EnzymeAdapter { + createMountRenderer(options) { + assertDomAvailable('mount'); + const domNode = options.attachTo || global.document.createElement('div'); + let instance = null; + return { + render(el/* , context */) { + const wrappedEl = React.createElement(SimpleWrapper, { + node: el, + }); + instance = ReactDOM.render(wrappedEl, domNode); + }, + unmount() { + ReactDOM.unmountComponentAtNode(domNode); + }, + getNode() { + return instanceToTree(instance._reactInternalInstance._renderedComponent); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + // eslint-disable-next-line react/no-find-dom-node + eventFn(ReactDOM.findDOMNode(node.instance), mock); + }, + batchedUpdates(fn) { + return ReactDOM.unstable_batchedUpdates(fn); + }, + }; + } + + createShallowRenderer(/* options */) { + const renderer = TestUtils.createRenderer(); + let isDOM = false; + let cachedNode = null; + return { + render(el, context) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else { + isDOM = false; + return withSetStateAllowed(() => renderer.render(el, context)); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: 'class', + type: cachedNode.type, + props: cachedNode.props, + key: cachedNode.key, + ref: cachedNode.ref, + instance: renderer._instance._instance, + rendered: elementToTree(output), + }; + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + ReactDOM.unstable_batchedUpdates(() => { + handler(...args); + }); + }); + } + }, + batchedUpdates(fn) { + return withSetStateAllowed(() => ReactDOM.unstable_batchedUpdates(fn)); + }, + }; + } + + createStringRenderer(/* options */) { + return { + render(el /* , context */) { + return ReactDOMServer.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + return React.createElement(node.type, node.props); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node) { + return ReactDOM.findDOMNode(node.instance); + } + + isValidElement(element) { + return React.isValidElement(element); + } + + createElement(...args) { + return React.createElement(...args); + } +} + +module.exports = ReactFifteenFourAdapter; diff --git a/src/adapters/ReactFourteenAdapter.js b/src/adapters/ReactFourteenAdapter.js new file mode 100644 index 000000000..0a383b933 --- /dev/null +++ b/src/adapters/ReactFourteenAdapter.js @@ -0,0 +1,205 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactDOMServer from 'react-dom/server'; +import TestUtils from 'react-addons-test-utils'; +import PropTypes from 'prop-types'; +import values from 'object.values'; +import EnzymeAdapter from './EnzymeAdapter'; +import elementToTree from './elementToTree'; +import { + mapNativeEventNames, + propFromEvent, + withSetStateAllowed, + assertDomAvailable, +} from './Utils'; + +function typeToNodeType(type) { + if (typeof type === 'function') { + if (typeof type.prototype.render === 'function') { + return 'class'; + } + return 'function'; + } + return 'host'; +} + +function instanceToTree(inst) { + if (!inst || typeof inst !== 'object') { + return inst; + } + const el = inst._currentElement; + if (!el) { + return null; + } + if (typeof el !== 'object') { + return el; + } + if (inst._tag) { + if (typeof el !== 'object') { + return el; + } + const children = inst._renderedChildren || { '.0': el.props.children }; + return { + nodeType: 'host', + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: ReactDOM.findDOMNode(inst.getPublicInstance()) || null, + rendered: values(children).map(instanceToTree), + }; + } + if (inst._renderedComponent) { + return { + nodeType: typeToNodeType(el.type), + type: el.type, + props: el.props, + key: el.key, + ref: el.ref, + instance: inst._instance || null, + rendered: instanceToTree(inst._renderedComponent), + }; + } + throw new Error('Enzyme Internal Error: unknown instance encountered'); +} + +class SimpleWrapper extends React.Component { + render() { + return this.props.node || null; + } +} + +SimpleWrapper.propTypes = { node: PropTypes.node.isRequired }; + +class ReactFifteenAdapter extends EnzymeAdapter { + createMountRenderer(options) { + assertDomAvailable('mount'); + const domNode = options.attachTo || global.document.createElement('div'); + let instance = null; + return { + render(el/* , context */) { + const wrappedEl = React.createElement(SimpleWrapper, { + node: el, + }); + instance = ReactDOM.render(wrappedEl, domNode); + }, + unmount() { + ReactDOM.unmountComponentAtNode(domNode); + }, + getNode() { + return instanceToTree(instance._reactInternalInstance._renderedComponent); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + // eslint-disable-next-line react/no-find-dom-node + eventFn(ReactDOM.findDOMNode(node.instance), mock); + }, + batchedUpdates(fn) { + return ReactDOM.unstable_batchedUpdates(fn); + }, + }; + } + + createShallowRenderer(/* options */) { + const renderer = TestUtils.createRenderer(); + let isDOM = false; + let cachedNode = null; + return { + render(el, context) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else { + isDOM = false; + return withSetStateAllowed(() => renderer.render(el, context)); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: 'class', + type: cachedNode.type, + props: cachedNode.props, + key: cachedNode.key, + ref: cachedNode.ref, + instance: renderer._instance._instance, + rendered: elementToTree(output), + }; + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + ReactDOM.unstable_batchedUpdates(() => { + handler(...args); + }); + }); + } + }, + batchedUpdates(fn) { + return withSetStateAllowed(() => ReactDOM.unstable_batchedUpdates(fn)); + }, + }; + } + + createStringRenderer(/* options */) { + return { + render(el /* , context */) { + return ReactDOMServer.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + return React.createElement(node.type, node.props); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node) { + return ReactDOM.findDOMNode(node.instance); + } + + isValidElement(element) { + return React.isValidElement(element); + } + + createElement(...args) { + return React.createElement(...args); + } +} + +module.exports = ReactFifteenAdapter; diff --git a/src/adapters/ReactSixteenAdapter.js b/src/adapters/ReactSixteenAdapter.js new file mode 100644 index 000000000..e40bb0d24 --- /dev/null +++ b/src/adapters/ReactSixteenAdapter.js @@ -0,0 +1,283 @@ +/* eslint no-use-before-define: 0 */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactDOMServer from 'react-dom/server'; +import ShallowRenderer from 'react-test-renderer/shallow'; +import TestUtils from 'react-dom/test-utils'; +import PropTypes from 'prop-types'; +import EnzymeAdapter from './EnzymeAdapter'; +import elementToTree from './elementToTree'; +import { + mapNativeEventNames, + propFromEvent, + assertDomAvailable, + withSetStateAllowed, +} from './Utils'; + +const HostRoot = 3; +const ClassComponent = 2; +const Fragment = 10; +const FunctionalComponent = 1; +const HostComponent = 5; +const HostText = 6; + +function nodeAndSiblingsArray(nodeWithSibling) { + const array = []; + let node = nodeWithSibling; + while (node != null) { + array.push(node); + node = node.sibling; + } + return array; +} + +function flatten(arr) { + const result = []; + const stack = [{ i: 0, array: arr }]; + while (stack.length) { + const n = stack.pop(); + while (n.i < n.array.length) { + const el = n.array[n.i]; + n.i += 1; + if (Array.isArray(el)) { + stack.push(n); + stack.push({ i: 0, array: el }); + break; + } + result.push(el); + } + } + return result; +} + +function toTree(vnode) { + if (vnode == null) { + return null; + } + // TODO(lmr): I'm not really sure I understand whether or not this is what + // i should be doing, or if this is a hack for something i'm doing wrong + // somewhere else. Should talk to sebastian about this perhaps + const node = vnode.alternate !== null ? vnode.alternate : vnode; + switch (node.tag) { + case HostRoot: // 3 + return toTree(node.child); + case ClassComponent: + return { + nodeType: 'class', + type: node.type, + props: { ...vnode.memoizedProps }, + key: node.key, + ref: node.ref, + instance: node.stateNode, + rendered: childrenToTree(node.child), + }; + case Fragment: // 10 + return childrenToTree(node.child); + case FunctionalComponent: // 1 + return { + nodeType: 'function', + type: node.type, + props: { ...vnode.memoizedProps }, + key: node.key, + ref: node.ref, + instance: null, + rendered: childrenToTree(node.child), + }; + case HostComponent: { // 5 + let renderedNodes = flatten(nodeAndSiblingsArray(node.child).map(toTree)); + if (renderedNodes.length === 0) { + renderedNodes = [node.memoizedProps.children]; + } + return { + nodeType: 'host', + type: node.type, + props: { ...node.memoizedProps }, + key: node.key, + ref: node.ref, + instance: node.stateNode, + rendered: renderedNodes, + }; + } + case HostText: // 6 + return node.memoizedProps; + default: + throw new Error(`Enzyme Internal Error: unknown node with tag ${node.tag}`); + } +} + +function childrenToTree(node) { + if (!node) { + return null; + } + const children = nodeAndSiblingsArray(node); + if (children.length === 0) { + return null; + } else if (children.length === 1) { + return toTree(children[0]); + } + return flatten(children.map(toTree)); +} + +function nodeToHostNode(_node) { + // NOTE(lmr): node could be a function component + // which wont have an instance prop, but we can get the + // host node associated with its return value at that point. + // Although this breaks down if the return value is an array, + // as is possible with React 16. + let node = _node; + while (node && !Array.isArray(node) && node.instance === null) { + node = node.rendered; + } + if (Array.isArray(node)) { + // TODO(lmr): throw warning regarding not being able to get a host node here + throw new Error('Trying to get host node of an array'); + } + // if the SFC returned null effectively, there is no host node. + if (!node) { + return null; + } + return ReactDOM.findDOMNode(node.instance); +} + +class SimpleWrapper extends React.Component { + render() { + return this.props.node || null; + } +} + +SimpleWrapper.propTypes = { node: PropTypes.node.isRequired }; + +class ReactSixteenAdapter extends EnzymeAdapter { + createMountRenderer(options) { + assertDomAvailable('mount'); + const domNode = options.attachTo || global.document.createElement('div'); + let instance = null; + return { + render(el/* , context */) { + const wrappedEl = React.createElement(SimpleWrapper, { + node: el, + }); + instance = ReactDOM.render(wrappedEl, domNode); + }, + unmount() { + ReactDOM.unmountComponentAtNode(domNode); + }, + getNode() { + return toTree(instance._reactInternalInstance.child); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + // eslint-disable-next-line react/no-find-dom-node + eventFn(nodeToHostNode(node), mock); + }, + batchedUpdates(fn) { + return fn(); + // return ReactDOM.unstable_batchedUpdates(fn); + }, + }; + } + + createShallowRenderer(/* options */) { + const renderer = new ShallowRenderer(); + let isDOM = false; + let cachedNode = null; + return { + render(el, context) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else { + isDOM = false; + return withSetStateAllowed(() => renderer.render(el, context)); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: 'class', + type: cachedNode.type, + props: cachedNode.props, + key: cachedNode.key, + ref: cachedNode.ref, + instance: renderer._instance, + rendered: elementToTree(output), + }; + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + // ReactDOM.unstable_batchedUpdates(() => { + handler(...args); + // }); + }); + } + }, + batchedUpdates(fn) { + return fn(); + // return ReactDOM.unstable_batchedUpdates(fn); + }, + }; + } + + createStringRenderer(/* options */) { + return { + render(el /* , context */) { + return ReactDOMServer.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + return React.createElement(node.type, node.props); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node) { + return nodeToHostNode(node); + } + + isValidElement(element) { + return React.isValidElement(element); + } + + createElement(...args) { + return React.createElement(...args); + } +} + +module.exports = ReactSixteenAdapter; diff --git a/src/adapters/ReactThirteenAdapter.js b/src/adapters/ReactThirteenAdapter.js new file mode 100644 index 000000000..a223f2167 --- /dev/null +++ b/src/adapters/ReactThirteenAdapter.js @@ -0,0 +1,233 @@ +import React from 'react'; +import ReactAddons from 'react/addons'; +import ReactContext from 'react/lib/ReactContext'; +import PropTypes from 'prop-types'; +import values from 'object.values'; +import EnzymeAdapter from './EnzymeAdapter'; +import elementToTree from './elementToTree'; +import mapNativeEventNames from './ReactThirteenMapNativeEventNames'; +import { + propFromEvent, + withSetStateAllowed, + assertDomAvailable, +} from './Utils'; + +// this fixes some issues in React 0.13 with setState and jsdom... +// see issue: https://github.com/airbnb/enzyme/issues/27 +// eslint-disable-next-line import/no-unresolved +require('react/lib/ExecutionEnvironment').canUseDOM = true; + +const { TestUtils, batchedUpdates } = ReactAddons.addons; + +const getEmptyElementType = (() => { + let EmptyElementType = null; + // eslint-disable-next-line react/prefer-stateless-function + class Foo extends React.Component { + render() { + return null; + } + } + + return () => { + if (EmptyElementType === null) { + const instance = TestUtils.renderIntoDocument(React.createElement(Foo)); + EmptyElementType = instance._reactInternalInstance._renderedComponent._currentElement.type; + } + return EmptyElementType; + }; +})(); + +const createShallowRenderer = function createRendererCompatible() { + const renderer = TestUtils.createRenderer(); + renderer.render = (originalRender => function contextCompatibleRender(node, context = {}) { + ReactContext.current = context; + originalRender.call(this, React.createElement(node.type, node.props), context); + ReactContext.current = {}; + return renderer.getRenderOutput(); + })(renderer.render); + return renderer; +}; + + +function instanceToTree(inst) { + if (typeof inst !== 'object') { + return inst; + } + const el = inst._currentElement; + if (!el) { + return null; + } + if (typeof el !== 'object') { + return el; + } + if (el.type === getEmptyElementType()) { + return null; + } + if (typeof el.type === 'string') { + const innerInst = inst._renderedComponent; + const children = innerInst._renderedChildren || { '.0': el._store.props.children }; + return { + nodeType: 'host', + type: el.type, + props: el._store.props, + key: el.key, + ref: el.ref, + instance: inst._instance.getDOMNode(), + rendered: values(children).map(instanceToTree), + }; + } + if (inst._renderedComponent) { + return { + nodeType: 'class', + type: el.type, + props: el._store.props, + key: el.key, + ref: el.ref, + instance: inst._instance || inst._hostNode || null, + rendered: instanceToTree(inst._renderedComponent), + }; + } + throw new Error('Enzyme Internal Error: unknown instance encountered'); +} + +class SimpleWrapper extends React.Component { + render() { + return this.props.node || null; + } +} + +SimpleWrapper.propTypes = { node: PropTypes.node.isRequired }; + +class ReactThirteenAdapter extends EnzymeAdapter { + createMountRenderer(options) { + assertDomAvailable('mount'); + const domNode = options.attachTo || global.document.createElement('div'); + let instance = null; + return { + render(el/* , context */) { + const wrappedEl = React.createElement(SimpleWrapper, { + node: el, + }); + instance = React.render(wrappedEl, domNode); + }, + unmount() { + React.unmountComponentAtNode(domNode); + }, + getNode() { + return instanceToTree(instance._reactInternalInstance._renderedComponent); + }, + simulateEvent(node, event, mock) { + const mappedEvent = mapNativeEventNames(event); + const eventFn = TestUtils.Simulate[mappedEvent]; + if (!eventFn) { + throw new TypeError(`ReactWrapper::simulate() event '${event}' does not exist`); + } + // eslint-disable-next-line react/no-find-dom-node + eventFn(React.findDOMNode(node.instance), mock); + }, + batchedUpdates(fn) { + return batchedUpdates(fn); + }, + }; + } + + createShallowRenderer(/* options */) { + const renderer = createShallowRenderer(); + let isDOM = false; + let cachedNode = null; + return { + render(el, context) { + cachedNode = el; + /* eslint consistent-return: 0 */ + if (typeof el.type === 'string') { + isDOM = true; + } else { + isDOM = false; + // return withSetStateAllowed(() => renderer.render(el, context)); + return renderer.render(el, context); + } + }, + unmount() { + renderer.unmount(); + }, + getNode() { + if (isDOM) { + return elementToTree(cachedNode); + } + const output = renderer.getRenderOutput(); + return { + nodeType: 'class', + type: cachedNode.type, + props: cachedNode.props, + key: cachedNode.key, + ref: cachedNode.ref, + instance: renderer._instance._instance, + rendered: elementToTree(output), + }; + }, + simulateEvent(node, event, ...args) { + const handler = node.props[propFromEvent(event)]; + if (handler) { + withSetStateAllowed(() => { + // TODO(lmr): create/use synthetic events + // TODO(lmr): emulate React's event propagation + batchedUpdates(() => { + handler(...args); + }); + }); + } + }, + batchedUpdates(fn) { + return withSetStateAllowed(() => batchedUpdates(fn)); + }, + }; + } + + createStringRenderer(/* options */) { + return { + render(el /* , context */) { + return React.renderToStaticMarkup(el); + }, + }; + } + + // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation + // specific, like `attach` etc. for React, but not part of this interface explicitly. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + createRenderer(options) { + switch (options.mode) { + case EnzymeAdapter.MODES.MOUNT: return this.createMountRenderer(options); + case EnzymeAdapter.MODES.SHALLOW: return this.createShallowRenderer(options); + case EnzymeAdapter.MODES.STRING: return this.createStringRenderer(options); + default: + throw new Error(`Enzyme Internal Error: Unrecognized mode: ${options.mode}`); + } + } + + // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed + // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should + // be pretty straightforward for people to implement. + // eslint-disable-next-line class-methods-use-this, no-unused-vars + nodeToElement(node) { + if (!node || typeof node !== 'object') return null; + return React.createElement(node.type, node.props); + } + + elementToNode(element) { + return elementToTree(element); + } + + nodeToHostNode(node) { + return React.findDOMNode(node.instance); + } + + isValidElement(element) { + return React.isValidElement(element); + } + + createElement(...args) { + return React.createElement(...args); + } +} + +module.exports = ReactThirteenAdapter; diff --git a/src/adapters/ReactThirteenMapNativeEventNames.js b/src/adapters/ReactThirteenMapNativeEventNames.js new file mode 100644 index 000000000..b18f41528 --- /dev/null +++ b/src/adapters/ReactThirteenMapNativeEventNames.js @@ -0,0 +1,39 @@ +export default function mapNativeEventNames(event) { + const nativeToReactEventMap = { + compositionend: 'compositionEnd', + compositionstart: 'compositionStart', + compositionupdate: 'compositionUpdate', + keydown: 'keyDown', + keyup: 'keyUp', + keypress: 'keyPress', + contextmenu: 'contextMenu', + dblclick: 'doubleClick', + doubleclick: 'doubleClick', // kept for legacy. TODO: remove with next major. + dragend: 'dragEnd', + dragenter: 'dragEnter', + dragexist: 'dragExit', + dragleave: 'dragLeave', + dragover: 'dragOver', + dragstart: 'dragStart', + mousedown: 'mouseDown', + mousemove: 'mouseMove', + mouseout: 'mouseOut', + mouseover: 'mouseOver', + mouseup: 'mouseUp', + touchcancel: 'touchCancel', + touchend: 'touchEnd', + touchmove: 'touchMove', + touchstart: 'touchStart', + canplay: 'canPlay', + canplaythrough: 'canPlayThrough', + durationchange: 'durationChange', + loadeddata: 'loadedData', + loadedmetadata: 'loadedMetadata', + loadstart: 'loadStart', + ratechange: 'rateChange', + timeupdate: 'timeUpdate', + volumechange: 'volumeChange', + beforeinput: 'beforeInput', + }; + return nativeToReactEventMap[event] || event; +} diff --git a/src/adapters/Utils.js b/src/adapters/Utils.js new file mode 100644 index 000000000..302f9da54 --- /dev/null +++ b/src/adapters/Utils.js @@ -0,0 +1,76 @@ +export function mapNativeEventNames(event) { + const nativeToReactEventMap = { + compositionend: 'compositionEnd', + compositionstart: 'compositionStart', + compositionupdate: 'compositionUpdate', + keydown: 'keyDown', + keyup: 'keyUp', + keypress: 'keyPress', + contextmenu: 'contextMenu', + dblclick: 'doubleClick', + doubleclick: 'doubleClick', // kept for legacy. TODO: remove with next major. + dragend: 'dragEnd', + dragenter: 'dragEnter', + dragexist: 'dragExit', + dragleave: 'dragLeave', + dragover: 'dragOver', + dragstart: 'dragStart', + mousedown: 'mouseDown', + mousemove: 'mouseMove', + mouseout: 'mouseOut', + mouseover: 'mouseOver', + mouseup: 'mouseUp', + touchcancel: 'touchCancel', + touchend: 'touchEnd', + touchmove: 'touchMove', + touchstart: 'touchStart', + canplay: 'canPlay', + canplaythrough: 'canPlayThrough', + durationchange: 'durationChange', + loadeddata: 'loadedData', + loadedmetadata: 'loadedMetadata', + loadstart: 'loadStart', + ratechange: 'rateChange', + timeupdate: 'timeUpdate', + volumechange: 'volumeChange', + beforeinput: 'beforeInput', + mouseenter: 'mouseEnter', + mouseleave: 'mouseLeave', + }; + + return nativeToReactEventMap[event] || event; +} + +// 'click' => 'onClick' +// 'mouseEnter' => 'onMouseEnter' +export function propFromEvent(event) { + const nativeEvent = mapNativeEventNames(event); + return `on${nativeEvent[0].toUpperCase()}${nativeEvent.slice(1)}`; +} + +export function withSetStateAllowed(fn) { + // NOTE(lmr): + // this is currently here to circumvent a React bug where `setState()` is + // not allowed without global being defined. + let cleanup = false; + if (typeof global.document === 'undefined') { + cleanup = true; + global.document = {}; + } + const result = fn(); + if (cleanup) { + // This works around a bug in node/jest in that developers aren't able to + // delete things from global when running in a node vm. + global.document = undefined; + delete global.document; + } + return result; +} + +export function assertDomAvailable(feature) { + if (!global || !global.document || !global.document.createElement) { + throw new Error( + `Enzyme's ${feature} expects a DOM environment to be loaded, but found none`, + ); + } +} diff --git a/src/adapters/elementToTree.js b/src/adapters/elementToTree.js new file mode 100644 index 000000000..b9c95cc00 --- /dev/null +++ b/src/adapters/elementToTree.js @@ -0,0 +1,38 @@ +import flatten from 'lodash/flatten'; + +function nodeTypeFromType(type) { + if (typeof type === 'string') { + return 'host'; + } + if ( + type && + type.prototype && + (type.prototype.isReactComponent || typeof type.prototype.render === 'function') + ) { + return 'class'; + } + return 'function'; +} + +export default function elementToTree(el) { + if (el === null || typeof el !== 'object' || !('type' in el)) { + return el; + } + const { type, props, key, ref } = el; + const { children } = props; + let rendered = null; + if (Array.isArray(children)) { + rendered = flatten(children, true).map(elementToTree); + } else if (children !== undefined) { + rendered = elementToTree(children); + } + return { + nodeType: nodeTypeFromType(type), + type, + props, + key, + ref, + instance: null, + rendered, + }; +} diff --git a/src/configuration.js b/src/configuration.js new file mode 100644 index 000000000..755c39504 --- /dev/null +++ b/src/configuration.js @@ -0,0 +1,13 @@ +import validateAdapter from './validateAdapter'; + +const configuration = {}; + +module.exports = { + get() { return { ...configuration }; }, + merge(extra) { + if (extra.adapter) { + validateAdapter(extra.adapter); + } + Object.assign(configuration, extra); + }, +}; diff --git a/src/index.js b/src/index.js index e5ef7ce8e..2c3a41b9b 100644 --- a/src/index.js +++ b/src/index.js @@ -4,11 +4,13 @@ import ShallowWrapper from './ShallowWrapper'; import mount from './mount'; import shallow from './shallow'; import render from './render'; +import { merge as configure } from './configuration'; -export { +module.exports = { render, shallow, mount, ShallowWrapper, ReactWrapper, + configure, }; diff --git a/src/react-compat.js b/src/react-compat.js deleted file mode 100644 index fe1f0613d..000000000 --- a/src/react-compat.js +++ /dev/null @@ -1,208 +0,0 @@ -/* eslint - global-require: 0, - import/no-mutable-exports: 0, - import/no-unresolved: 0, - react/no-deprecated: 0, - react/no-render-return-value: 0, -*/ - -import { REACT013, REACT155 } from './version'; - -let TestUtils; -let createShallowRenderer; -let renderToStaticMarkup; -let renderIntoDocument; -let findDOMNode; -let childrenToArray; -let renderWithOptions; -let unmountComponentAtNode; -let batchedUpdates; -let shallowRendererFactory; - -const React = require('react'); - -if (REACT013) { - renderToStaticMarkup = React.renderToStaticMarkup; - /* eslint-disable react/no-deprecated */ - findDOMNode = React.findDOMNode; - unmountComponentAtNode = React.unmountComponentAtNode; - /* eslint-enable react/no-deprecated */ - TestUtils = require('react/addons').addons.TestUtils; - batchedUpdates = require('react/addons').addons.batchedUpdates; - const ReactContext = require('react/lib/ReactContext'); - - // Shallow rendering in 0.13 did not properly support context. This function provides a shim - // around `TestUtils.createRenderer` that instead returns a ShallowRenderer that actually - // works with context. See https://github.com/facebook/react/issues/3721 for more details. - createShallowRenderer = function createRendererCompatible() { - const renderer = TestUtils.createRenderer(); - renderer.render = (originalRender => function contextCompatibleRender(node, context = {}) { - ReactContext.current = context; - originalRender.call(this, React.createElement(node.type, node.props), context); - ReactContext.current = {}; - return renderer.getRenderOutput(); - })(renderer.render); - return renderer; - }; - renderIntoDocument = TestUtils.renderIntoDocument; - // this fixes some issues in React 0.13 with setState and jsdom... - // see issue: https://github.com/airbnb/enzyme/issues/27 - require('react/lib/ExecutionEnvironment').canUseDOM = true; - - // in 0.13, a Children.toArray function was not exported. Make our own instead. - childrenToArray = (children) => { - const results = []; - if (children !== undefined && children !== null && children !== false) { - React.Children.forEach(children, (el) => { - if (el !== undefined && el !== null && el !== false) { - results.push(el); - } - }); - } - return results; - }; - - renderWithOptions = (node, options) => { - if (options.attachTo) { - return React.render(node, options.attachTo); - } - return TestUtils.renderIntoDocument(node); - }; -} else { - let ReactDOM; - - try { - // eslint-disable-next-line import/no-extraneous-dependencies - ReactDOM = require('react-dom'); - } catch (e) { - throw new Error( - 'react-dom is an implicit dependency in order to support react@0.13-14. ' + - 'Please add the appropriate version to your devDependencies. ' + - 'See https://github.com/airbnb/enzyme#installation', - ); - } - - // eslint-disable-next-line import/no-extraneous-dependencies - renderToStaticMarkup = require('react-dom/server').renderToStaticMarkup; - - findDOMNode = ReactDOM.findDOMNode; - unmountComponentAtNode = ReactDOM.unmountComponentAtNode; - batchedUpdates = ReactDOM.unstable_batchedUpdates; - // We require the testutils, but they don't come with 0.14 out of the box, so we - // require them here through this node module. The bummer is that we are not able - // to list this as a dependency in package.json and have 0.13 work properly. - // As a result, right now this is basically an implicit dependency. - try { - try { - // This is for react v15.5 and up... - - // eslint-disable-next-line import/no-extraneous-dependencies - TestUtils = require('react-dom/test-utils'); - // eslint-disable-next-line import/no-extraneous-dependencies - shallowRendererFactory = require('react-test-renderer/shallow').createRenderer; - } catch (e) { - // This is for react < v15.5. Note that users who have `react^15.4.x` in their package.json - // will arrive here, too. They need to upgrade. React will print a nice warning letting - // them know they need to upgrade, though, so we're good. Also note we explicitly do not - // use TestUtils from react-dom/test-utils here, mainly so the user still gets a warning for - // requiring 'react-addons-test-utils', which lets them know there's action required. - - // eslint-disable-next-line import/no-extraneous-dependencies - TestUtils = require('react-addons-test-utils'); - shallowRendererFactory = TestUtils.createRenderer; - } - } catch (e) { - if (REACT155) { - throw new Error( - 'react-dom@15.5+ and react-test-renderer are implicit dependencies when using ' + - 'react@15.5+ with enzyme. Please add the appropriate version to your ' + - 'devDependencies. See https://github.com/airbnb/enzyme#installation', - ); - } else { - throw new Error( - 'react-addons-test-utils is an implicit dependency in order to support react@0.13-14. ' + - 'Please add the appropriate version to your devDependencies. ' + - 'See https://github.com/airbnb/enzyme#installation', - ); - } - } - - // Shallow rendering changed from 0.13 => 0.14 in such a way that - // 0.14 now does not allow shallow rendering of native DOM elements. - // This is mainly because the result of such a call should not realistically - // be any different than the JSX you passed in (result of `React.createElement`. - // In order to maintain the same behavior across versions, this function - // is essentially a replacement for `TestUtils.createRenderer` that doesn't use - // shallow rendering when it's just a DOM element. - createShallowRenderer = function createRendererCompatible() { - const renderer = shallowRendererFactory(); - const originalRender = renderer.render; - const originalRenderOutput = renderer.getRenderOutput; - let isDOM = false; - let cachedNode; - return Object.assign(renderer, { - render(node, context) { - /* eslint consistent-return: 0 */ - if (typeof node.type === 'string') { - isDOM = true; - cachedNode = node; - } else { - isDOM = false; - return originalRender.call(this, node, context); - } - }, - getRenderOutput() { - if (isDOM) { - return cachedNode; - } - return originalRenderOutput.call(this); - }, - }); - }; - renderIntoDocument = TestUtils.renderIntoDocument; - childrenToArray = React.Children.toArray; - - renderWithOptions = (node, options) => { - if (options.attachTo) { - return ReactDOM.render(node, options.attachTo); - } - return TestUtils.renderIntoDocument(node); - }; -} - -function isDOMComponentElement(inst) { - return React.isValidElement(inst) && typeof inst.type === 'string'; -} - -const { - mockComponent, - isElement, - isElementOfType, - isDOMComponent, - isCompositeComponent, - isCompositeComponentWithType, - isCompositeComponentElement, - Simulate, - findAllInRenderedTree, -} = TestUtils; - -export { - createShallowRenderer, - renderToStaticMarkup, - renderIntoDocument, - mockComponent, - isElement, - isElementOfType, - isDOMComponent, - isDOMComponentElement, - isCompositeComponent, - isCompositeComponentWithType, - isCompositeComponentElement, - Simulate, - findDOMNode, - findAllInRenderedTree, - childrenToArray, - renderWithOptions, - unmountComponentAtNode, - batchedUpdates, -}; diff --git a/src/render.jsx b/src/render.jsx index b0d25a64f..cd9bc0438 100644 --- a/src/render.jsx +++ b/src/render.jsx @@ -1,6 +1,6 @@ import React from 'react'; import cheerio from 'cheerio'; -import { renderToStaticMarkup } from './react-compat'; +import { getAdapter } from './Utils'; /** * Renders a react component into static HTML and provides a cheerio wrapper around it. This is @@ -30,15 +30,17 @@ function createContextWrapperForNode(node, context, childContextTypes) { } export default function render(node, options = {}) { + const adapter = getAdapter(options); + const renderer = adapter.createRenderer({ mode: 'string', ...options }); if (options.context && (node.type.contextTypes || options.childContextTypes)) { const childContextTypes = { ...(node.type.contextTypes || {}), ...options.childContextTypes, }; const ContextWrapper = createContextWrapperForNode(node, options.context, childContextTypes); - const html = renderToStaticMarkup(); + const html = renderer.render(); return cheerio.load(html).root(); } - const html = renderToStaticMarkup(node); + const html = renderer.render(node); return cheerio.load(html).root(); } diff --git a/src/validateAdapter.js b/src/validateAdapter.js new file mode 100644 index 000000000..329a1b983 --- /dev/null +++ b/src/validateAdapter.js @@ -0,0 +1,22 @@ +import EnzymeAdapter from './adapters/EnzymeAdapter'; + +export default function validateAdapter(adapter) { + if (!adapter) { + throw new Error(` + Enzyme Internal Error: Enzyme expects an adapter to be configured, but found none. To + configure an adapter, you should call \`Enzyme.configure({ adapter: new Adapter() })\` + before using any of Enzyme's top level APIs, where \`Adapter\` is the adapter + corresponding to the library currently being tested. For example: + + import Adapter from 'enzyme-adapter-react-15'; + + To find out more about this, see http://airbnb.io/enzyme/docs/installation/index.html + `); + } + if (!(adapter instanceof EnzymeAdapter)) { + throw new Error( + 'Enzyme Internal Error: configured enzyme adapter did not inherit from the EnzymeAdapter ' + + 'base class', + ); + } +} diff --git a/test/Adapter-spec.jsx b/test/Adapter-spec.jsx new file mode 100644 index 000000000..a16e32daa --- /dev/null +++ b/test/Adapter-spec.jsx @@ -0,0 +1,529 @@ +import './_helpers/setupAdapters'; +import React from 'react'; +import { expect } from 'chai'; + +import { REACT013, REACT16 } from './_helpers/version'; +import configuration from '../src/configuration'; +import { itIf, describeWithDOM } from './_helpers'; + +const { adapter } = configuration.get(); + +const prettyFormat = o => JSON.stringify(o, null, 2); + +// Kind of hacky, but we nullify all the instances to test the tree structure +// with jasmine's deep equality function, and test the instances separate. We +// also delete children props because testing them is more annoying and not +// really important to verify. +function cleanNode(node) { + if (!node) { + return; + } + if (node && node.instance) { + node.instance = null; + } + if (node && node.props && node.props.children) { + // eslint-disable-next-line no-unused-vars + const { children, ...props } = node.props; + node.props = props; + } + if (Array.isArray(node.rendered)) { + node.rendered.forEach(cleanNode); + } else if (typeof node.rendered === 'object') { + cleanNode(node.rendered); + } +} + +describe('Adapter', () => { + describeWithDOM('mounted render', () => { + it('treats mixed children correctlyf', () => { + class Foo extends React.Component { + render() { + return ( +
hello{4}{'world'}
+ ); + } + } + + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + renderer.render(); + + const node = renderer.getNode(); + + cleanNode(node); + + expect(prettyFormat(node)).to.equal(prettyFormat({ + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'div', + props: {}, + key: null, + ref: null, + instance: null, + rendered: [ + 'hello', + REACT16 ? '4' : 4, + 'world', + ], + }, + })); + }); + + it('treats null renders correctly', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + renderer.render(); + + const node = renderer.getNode(); + + cleanNode(node); + + expect(prettyFormat(node)).to.equal(prettyFormat({ + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: null, + })); + }); + + itIf(!REACT013, 'renders simple components returning host components', () => { + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + const Qoo = () => Hello World!; + + renderer.render(); + + const node = renderer.getNode(); + + cleanNode(node); + + expect(prettyFormat(node)).to.equal(prettyFormat({ + nodeType: 'function', + type: Qoo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'span', + props: { className: 'Qoo' }, + key: null, + ref: null, + instance: null, + rendered: ['Hello World!'], + }, + })); + }); + + it('renders simple components returning host components', () => { + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + class Qoo extends React.Component { + render() { + return ( + Hello World! + ); + } + } + + renderer.render(); + + const node = renderer.getNode(); + + cleanNode(node); + + expect(prettyFormat(node)).to.equal(prettyFormat({ + nodeType: 'class', + type: Qoo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'span', + props: { className: 'Qoo' }, + key: null, + ref: null, + instance: null, + rendered: ['Hello World!'], + }, + })); + }); + + it('handles null rendering components', () => { + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + class Foo extends React.Component { + render() { + return null; + } + } + + renderer.render(); + + const node = renderer.getNode(); + + expect(node.instance).to.be.instanceof(Foo); + + cleanNode(node); + + expect(prettyFormat(node)).to.equal(prettyFormat({ + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: null, + })); + }); + + + itIf(!REACT013, 'renders complicated trees of composites and hosts', () => { + // SFC returning host. no children props. + const Qoo = () => Hello World!; + + // SFC returning host. passes through children. + const Foo = ({ className, children }) => ( +
+ Literal + {children} +
+ ); + + // class composite returning composite. passes through children. + class Bar extends React.Component { + render() { + const { special, children } = this.props; + return ( + + {children} + + ); + } + } + + // class composite return composite. no children props. + class Bam extends React.Component { + render() { + return ( + + + + ); + } + } + + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + renderer.render(); + + const tree = renderer.getNode(); + + // we test for the presence of instances before nulling them out + expect(tree.instance).to.be.instanceof(Bam); + expect(tree.rendered.instance).to.be.instanceof(Bar); + + cleanNode(tree); + + expect(prettyFormat(tree)).to.equal( + prettyFormat({ + nodeType: 'class', + type: Bam, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'class', + type: Bar, + props: { special: true }, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'function', + type: Foo, + props: { className: 'special' }, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'div', + props: { className: 'Foo special' }, + key: null, + ref: null, + instance: null, + rendered: [ + { + nodeType: 'host', + type: 'span', + props: { className: 'Foo2' }, + key: null, + ref: null, + instance: null, + rendered: ['Literal'], + }, + { + nodeType: 'function', + type: Qoo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'span', + props: { className: 'Qoo' }, + key: null, + ref: null, + instance: null, + rendered: ['Hello World!'], + }, + }, + ], + }, + }, + }, + }), + ); + }); + + it('renders complicated trees of composites and hosts', () => { + // class returning host. no children props. + class Qoo extends React.Component { + render() { + return ( + Hello World! + ); + } + } + + class Foo extends React.Component { + render() { + const { className, children } = this.props; + return ( +
+ Literal + {children} +
+ ); + } + } + + // class composite returning composite. passes through children. + class Bar extends React.Component { + render() { + const { special, children } = this.props; + return ( + + {children} + + ); + } + } + + // class composite return composite. no children props. + class Bam extends React.Component { + render() { + return ( + + + + ); + } + } + + const options = { mode: 'mount' }; + const renderer = adapter.createRenderer(options); + + renderer.render(); + + const tree = renderer.getNode(); + + // we test for the presence of instances before nulling them out + expect(tree.instance).to.be.instanceof(Bam); + expect(tree.rendered.instance).to.be.instanceof(Bar); + + cleanNode(tree); + + expect(prettyFormat(tree)).to.equal( + prettyFormat({ + nodeType: 'class', + type: Bam, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'class', + type: Bar, + props: { special: true }, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'class', + type: Foo, + props: { className: 'special' }, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'div', + props: { className: 'Foo special' }, + key: null, + ref: null, + instance: null, + rendered: [ + { + nodeType: 'host', + type: 'span', + props: { className: 'Foo2' }, + key: null, + ref: null, + instance: null, + rendered: ['Literal'], + }, + { + nodeType: 'class', + type: Qoo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'host', + type: 'span', + props: { className: 'Qoo' }, + key: null, + ref: null, + instance: null, + rendered: ['Hello World!'], + }, + }, + ], + }, + }, + }, + }), + ); + }); + }); + + it('renders basic shallow as well', () => { + // eslint-disable-next-line react/require-render-return + class Bar extends React.Component { + constructor(props) { + super(props); + throw new Error('Bar constructor should not be called'); + } + render() { + throw new Error('Bar render method should not be called'); + } + } + + // eslint-disable-next-line react/require-render-return + class Foo extends React.Component { + render() { + throw new Error('Foo render method should not be called'); + } + } + + // class composite return composite. no children props. + class Bam extends React.Component { + render() { + return ( + + + + + + ); + } + } + + const options = { mode: 'shallow' }; + const renderer = adapter.createRenderer(options); + + renderer.render(); + + const tree = renderer.getNode(); + + cleanNode(tree); + + expect(prettyFormat(tree)).to.equal( + prettyFormat({ + nodeType: 'class', + type: Bam, + props: {}, + key: null, + ref: null, + instance: null, + rendered: { + nodeType: 'class', + type: Bar, + props: {}, + key: null, + ref: null, + instance: null, + rendered: [ + { + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: null, + }, + { + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: null, + }, + { + nodeType: 'class', + type: Foo, + props: {}, + key: null, + ref: null, + instance: null, + rendered: null, + }, + ], + }, + }), + ); + }); + +}); diff --git a/test/ComplexSelector-spec.jsx b/test/ComplexSelector-spec.jsx index b8a271d41..e0f6cb963 100644 --- a/test/ComplexSelector-spec.jsx +++ b/test/ComplexSelector-spec.jsx @@ -1,3 +1,4 @@ +import './_helpers/setupAdapters'; import React from 'react'; import { expect } from 'chai'; diff --git a/test/Debug-spec.jsx b/test/Debug-spec.jsx index 7108e645f..6f63fc274 100644 --- a/test/Debug-spec.jsx +++ b/test/Debug-spec.jsx @@ -1,3 +1,4 @@ +import './_helpers/setupAdapters'; import { expect } from 'chai'; import React from 'react'; import { @@ -12,7 +13,12 @@ import { describeIf, itIf, } from './_helpers'; -import { REACT013 } from '../src/version'; +import { REACT013 } from './_helpers/version'; +import configuration from '../src/configuration'; + +const { adapter } = configuration.get(); + +const debugElement = element => debugNode(adapter.elementToNode(element)); describe('debug', () => { describe('spaces(n)', () => { @@ -37,11 +43,11 @@ describe('debug', () => { describe('debugNode(node)', () => { it('should render a node with no props or children as single single xml tag', () => { - expect(debugNode(
)).to.equal('
'); + expect(debugElement(
)).to.equal('
'); }); it('should render props inline inline', () => { - expect(debugNode( + expect(debugElement(
, )).to.equal( '
', @@ -49,7 +55,7 @@ describe('debug', () => { }); it('should render children on newline and indented', () => { - expect(debugNode( + expect(debugElement(
, @@ -61,7 +67,7 @@ describe('debug', () => { }); it('should render mixed children', () => { - expect(debugNode( + expect(debugElement(
hello{'world'}
, )).to.equal( `
@@ -72,7 +78,7 @@ describe('debug', () => { }); it('should render props on root and children', () => { - expect(debugNode( + expect(debugElement(
, @@ -84,7 +90,7 @@ describe('debug', () => { }); it('should render text on new line and indented', () => { - expect(debugNode( + expect(debugElement( some text, )).to.equal( ` @@ -99,7 +105,7 @@ describe('debug', () => { } Foo.displayName = 'Bar'; - expect(debugNode( + expect(debugElement(
, @@ -116,7 +122,7 @@ describe('debug', () => { render() { return
; } } - expect(debugNode( + expect(debugElement(
, @@ -132,7 +138,7 @@ describe('debug', () => { const Foo = () =>
; - expect(debugNode( + expect(debugElement(
, @@ -145,7 +151,7 @@ describe('debug', () => { }); it('should render mapped children properly', () => { - expect(debugNode( + expect(debugElement(
not in array {['a', 'b', 'c']} @@ -163,7 +169,7 @@ describe('debug', () => { }); it('should render number children properly', () => { - expect(debugNode( + expect(debugElement(
{-1} {0} @@ -179,7 +185,7 @@ describe('debug', () => { }); it('renders html entities properly', () => { - expect(debugNode( + expect(debugElement(
>
, )).to.equal( `
@@ -189,7 +195,7 @@ describe('debug', () => { }); it('should not render falsy children ', () => { - expect(debugNode( + expect(debugElement(
{false} {null} @@ -504,7 +510,7 @@ describe('debug', () => { } } - expect(debugNodes(shallow().getNodes())).to.eql( + expect(debugNodes(shallow().getNodesInternal())).to.eql( `
inside Foo @@ -536,7 +542,7 @@ describe('debug', () => { } } - expect(debugNodes(shallow().children().getNodes())).to.eql( + expect(debugNodes(shallow().children().getElements())).to.eql( ` @@ -560,7 +566,7 @@ describe('debug', () => { } } - expect(debugNodes(shallow().children().getNodes())).to.eql( + expect(debugNodes(shallow().children().getNodesInternal())).to.eql( ` span1 text diff --git a/test/ShallowTraversal-spec.jsx b/test/RSTTraversal-spec.jsx similarity index 88% rename from test/ShallowTraversal-spec.jsx rename to test/RSTTraversal-spec.jsx index a6398e283..a5656bbe1 100644 --- a/test/ShallowTraversal-spec.jsx +++ b/test/RSTTraversal-spec.jsx @@ -1,9 +1,11 @@ +import './_helpers/setupAdapters'; import React from 'react'; import sinon from 'sinon'; import { expect } from 'chai'; import { splitSelector, } from '../src/Utils'; +import elementToTree from '../src/adapters/elementToTree'; import { hasClassName, nodeHasProperty, @@ -12,12 +14,13 @@ import { pathToNode, getTextFromNode, buildPredicate, -} from '../src/ShallowTraversal'; +} from '../src/RSTTraversal'; import { describeIf } from './_helpers'; -import { REACT013 } from '../src/version'; +import { REACT013 } from './_helpers/version'; -describe('ShallowTraversal', () => { +const $ = elementToTree; +describe('RSTTraversal', () => { describe('splitSelector', () => { const fn = splitSelector; it('splits multiple class names', () => { @@ -46,13 +49,13 @@ describe('ShallowTraversal', () => { describe('hasClassName', () => { it('should work for standalone classNames', () => { - const node = (
); + const node = $(
); expect(hasClassName(node, 'foo')).to.equal(true); expect(hasClassName(node, 'bar')).to.equal(false); }); it('should work for multiple classNames', () => { - const node = (
); + const node = $(
); expect(hasClassName(node, 'foo')).to.equal(true); expect(hasClassName(node, 'bar')).to.equal(true); expect(hasClassName(node, 'baz')).to.equal(true); @@ -60,14 +63,14 @@ describe('ShallowTraversal', () => { }); it('should also allow hyphens', () => { - const node = (
); + const node = $(
); expect(hasClassName(node, 'foo-bar')).to.equal(true); }); it('should work if className has a function in toString property', () => { function classes() {} classes.toString = () => 'foo-bar'; - const node = (
); + const node = $(
); expect(hasClassName(node, 'foo-bar')).to.equal(true); }); }); @@ -76,32 +79,32 @@ describe('ShallowTraversal', () => { it('should find properties', () => { function noop() {} - const node = (
); + const node = $(
); expect(nodeHasProperty(node, 'onChange')).to.equal(true); expect(nodeHasProperty(node, 'title', '"foo"')).to.equal(true); }); it('should not match on html attributes', () => { - const node = (
); + const node = $(
); expect(nodeHasProperty(node, 'for', '"foo"')).to.equal(false); }); it('should not find undefined properties', () => { - const node = (
); + const node = $(
); expect(nodeHasProperty(node, 'title')).to.equal(false); }); it('should parse false as a literal', () => { - const node = (
); + const node = $(
); expect(nodeHasProperty(node, 'foo', 'false')).to.equal(true); }); it('should parse false as a literal', () => { - const node = (
); + const node = $(
); expect(nodeHasProperty(node, 'foo', 'true')).to.equal(true); }); @@ -154,7 +157,7 @@ describe('ShallowTraversal', () => { }); it('should throw when un unquoted string is passed in', () => { - const node = (
); + const node = $(
); expect(() => nodeHasProperty(node, 'title', 'foo')).to.throw(); }); @@ -165,17 +168,17 @@ describe('ShallowTraversal', () => { it('should be called once for a leaf node', () => { const spy = sinon.spy(); - const node = (
); + const node = $(
); treeForEach(node, spy); expect(spy.calledOnce).to.equal(true); }); it('should handle a single child', () => { const spy = sinon.spy(); - const node = ( + const node = $(
-
+
, ); treeForEach(node, spy); expect(spy.callCount).to.equal(2); @@ -183,11 +186,11 @@ describe('ShallowTraversal', () => { it('should handle several children', () => { const spy = sinon.spy(); - const node = ( + const node = $(
-
+
, ); treeForEach(node, spy); expect(spy.callCount).to.equal(3); @@ -195,13 +198,13 @@ describe('ShallowTraversal', () => { it('should handle multiple hierarchies', () => { const spy = sinon.spy(); - const node = ( + const node = $(
-
+
, ); treeForEach(node, spy); expect(spy.callCount).to.equal(4); @@ -209,10 +212,10 @@ describe('ShallowTraversal', () => { it('should not get trapped from empty strings', () => { const spy = sinon.spy(); - const node = ( + const node = $(

{''}

-
+
, ); treeForEach(node, spy); expect(spy.callCount).to.equal(3); @@ -220,13 +223,13 @@ describe('ShallowTraversal', () => { it('should pass in the node', () => { const spy = sinon.spy(); - const node = ( + const node = $(
+
, ); treeForEach(node, spy); expect(spy.callCount).to.equal(4); @@ -239,14 +242,14 @@ describe('ShallowTraversal', () => { }); describe('treeFilter', () => { - const tree = ( + const tree = $(
+
, ); it('should return an empty array for falsy test', () => { @@ -273,17 +276,18 @@ describe('ShallowTraversal', () => { it('should return trees from the root node', () => { const node =
, ); - const result = pathToNode(node, tree); + const nodeInTree = tree.rendered[1].rendered[0]; + const result = pathToNode(nodeInTree, tree); expect(result.length).to.equal(2); expect(result[0].type).to.equal('div'); expect(result[1].type).to.equal('nav'); @@ -291,17 +295,18 @@ describe('ShallowTraversal', () => { it('should return trees from the root node except the sibling node', () => { const node =
, ); - const result = pathToNode(node, tree); + const nodeInTree = tree.rendered[1].rendered[0]; + const result = pathToNode(nodeInTree, tree); expect(result.length).to.equal(2); expect(result[0].type).to.equal('div'); expect(result[1].type).to.equal('nav'); @@ -324,7 +329,7 @@ describe('ShallowTraversal', () => { } } Subject.displayName = 'CustomSubject'; - const node = ; + const node = $(); const result = getTextFromNode(node); expect(result).to.equal(''); }); @@ -337,7 +342,7 @@ describe('ShallowTraversal', () => { ); } } - const node = ; + const node = $(); const result = getTextFromNode(node); expect(result).to.equal(''); }); @@ -348,7 +353,7 @@ describe('ShallowTraversal', () => { const Subject = () =>
; Subject.displayName = 'CustomSubject'; - const node = ; + const node = $(); const result = getTextFromNode(node); expect(result).to.equal(''); }); @@ -356,7 +361,7 @@ describe('ShallowTraversal', () => { it('should return function name if displayName is not provided', () => { const Subject = () =>
; - const node = ; + const node = $(); const result = getTextFromNode(node); expect(result).to.equal(''); }); diff --git a/test/ReactWrapper-spec.jsx b/test/ReactWrapper-spec.jsx index 75b62307d..9a5983d17 100644 --- a/test/ReactWrapper-spec.jsx +++ b/test/ReactWrapper-spec.jsx @@ -1,10 +1,9 @@ /* globals document */ - +import './_helpers/setupAdapters'; import React from 'react'; import PropTypes from 'prop-types'; import { expect } from 'chai'; import sinon from 'sinon'; -import { batchedUpdates } from '../src/react-compat'; import { createClass } from './_helpers/react-compat'; import { @@ -20,9 +19,42 @@ import { ReactWrapper, } from '../src'; import { ITERATOR_SYMBOL } from '../src/Utils'; -import { REACT013, REACT014, REACT15 } from '../src/version'; +import { REACT013, REACT014, REACT16, is } from './_helpers/version'; describeWithDOM('mount', () => { + describe('top level wrapper', () => { + it('does what i expect', () => { + class Box extends React.Component { + render() { + return
{this.props.children}
; + } + } + class Foo extends React.Component { + render() { + return ( + +
+ + ); + } + } + + const wrapper = mount(); + + expect(wrapper.type()).to.equal(Foo); + expect(wrapper.props()).to.deep.equal({ bar: true }); + expect(wrapper.instance()).to.be.instanceOf(Foo); + expect(wrapper.children().at(0).type()).to.equal(Box); + expect(wrapper.find(Box).children().props().className).to.equal('box'); + expect(wrapper.find(Box).instance()).to.be.instanceOf(Box); + expect(wrapper.find(Box).children().at(0).props().className).to.equal('box'); + expect(wrapper.find(Box).children().props().className).to.equal('box'); + expect(wrapper.children().type()).to.equal(Box); + expect(wrapper.children().instance()).to.be.instanceOf(Box); + expect(wrapper.children().props().bam).to.equal(true); + }); + }); + describe('context', () => { it('can pass in context', () => { const SimpleComponent = createClass({ @@ -73,7 +105,7 @@ describeWithDOM('mount', () => { expect(() => mount(, { context })).to.not.throw(); }); - it('is instrospectable through context API', () => { + it('is introspectable through context API', () => { const SimpleComponent = createClass({ contextTypes: { name: PropTypes.string, @@ -130,7 +162,7 @@ describeWithDOM('mount', () => { expect(() => mount(, { context })).to.not.throw(); }); - it('is instrospectable through context API', () => { + itIf(!REACT16, 'is introspectable through context API', () => { const SimpleComponent = (props, context) => (
{context.name}
); @@ -143,7 +175,7 @@ describeWithDOM('mount', () => { expect(wrapper.context('name')).to.equal(context.name); }); - it('works with stateless components', () => { + itIf(!REACT16, 'works with stateless components', () => { const Foo = ({ foo }) => (
bar
@@ -967,7 +999,7 @@ describeWithDOM('mount', () => { expect(wrapper.props().d).to.equal('e'); }); - it('should throw if an exception occurs during render', () => { + itIf(!REACT16, 'should throw if an exception occurs during render', () => { class Trainwreck extends React.Component { render() { const { user } = this.props; @@ -1005,7 +1037,7 @@ describeWithDOM('mount', () => { expect(setInvalidProps).to.throw(TypeError, similarException.message); }); - it('should call the callback when setProps has completed', () => { + itIf(!REACT16, 'should call the callback when setProps has completed', () => { class Foo extends React.Component { render() { return ( @@ -1018,7 +1050,7 @@ describeWithDOM('mount', () => { const wrapper = mount(); expect(wrapper.find('.foo').length).to.equal(1); - batchedUpdates(() => { + wrapper.renderer.batchedUpdates(() => { wrapper.setProps({ id: 'bar', foo: 'bla' }, () => { expect(wrapper.find('.bar').length).to.equal(1); }); @@ -1058,7 +1090,7 @@ describeWithDOM('mount', () => { expect(wrapper.props().d).to.equal('e'); }); - it('should throw if an exception occurs during render', () => { + itIf(!REACT16, 'should throw if an exception occurs during render', () => { const Trainwreck = ({ user }) => (
{user.name.givenName} @@ -1408,7 +1440,7 @@ describeWithDOM('mount', () => { expect(() => wrapper.setState({ id: 'bar' }, 1)).to.throw(Error); }); - itIf(REACT15, 'should throw error when cb is not a function', () => { + itIf(is('>=15 || ^16.0.0-alpha'), 'should throw error when cb is not a function', () => { class Foo extends React.Component { constructor(props) { super(props); @@ -1422,11 +1454,7 @@ describeWithDOM('mount', () => { } const wrapper = mount(); expect(wrapper.state()).to.eql({ id: 'foo' }); - expect(() => wrapper.setState({ id: 'bar' }, 1)).to.throw( - Error, - 'setState(...): Expected the last optional `callback` argument ' + - 'to be a function. Instead received: number.', - ); + expect(() => wrapper.setState({ id: 'bar' }, 1)).to.throw(Error); }); }); @@ -1492,7 +1520,7 @@ describeWithDOM('mount', () => { expect(wrapper.isEmptyRender()).to.equal(false); }); - describeIf(REACT15, 'stateless function components', () => { + describeIf(is('>=15 || ^16.0.0-alpha'), 'stateless function components', () => { itWithData(emptyRenderValues, 'when a component returns: ', (data) => { function Foo() { return data.value; @@ -1892,10 +1920,10 @@ describeWithDOM('mount', () => { ]} />, ); - expect(wrapper.children().length).to.equal(3); - expect(wrapper.children().at(0).hasClass('foo')).to.equal(true); - expect(wrapper.children().at(1).hasClass('bar')).to.equal(true); - expect(wrapper.children().at(2).hasClass('baz')).to.equal(true); + expect(wrapper.children().children().length).to.equal(3); + expect(wrapper.children().children().at(0).hasClass('foo')).to.equal(true); + expect(wrapper.children().children().at(1).hasClass('bar')).to.equal(true); + expect(wrapper.children().children().at(2).hasClass('baz')).to.equal(true); }); it('should optionally allow a selector to filter by', () => { @@ -1935,10 +1963,10 @@ describeWithDOM('mount', () => { ]} />, ); - expect(wrapper.children().length).to.equal(3); - expect(wrapper.children().at(0).hasClass('foo')).to.equal(true); - expect(wrapper.children().at(1).hasClass('bar')).to.equal(true); - expect(wrapper.children().at(2).hasClass('baz')).to.equal(true); + expect(wrapper.children().children().length).to.equal(3); + expect(wrapper.children().children().at(0).hasClass('foo')).to.equal(true); + expect(wrapper.children().children().at(1).hasClass('bar')).to.equal(true); + expect(wrapper.children().children().at(2).hasClass('baz')).to.equal(true); }); }); }); @@ -2122,12 +2150,19 @@ describeWithDOM('mount', () => { const Foo = () =>
; const wrapper = mount(); - expect(wrapper.hasClass('foo')).to.equal(true); - expect(wrapper.hasClass('bar')).to.equal(true); - expect(wrapper.hasClass('baz')).to.equal(true); - expect(wrapper.hasClass('some-long-string')).to.equal(true); - expect(wrapper.hasClass('FoOo')).to.equal(true); + expect(wrapper.hasClass('foo')).to.equal(false); + expect(wrapper.hasClass('bar')).to.equal(false); + expect(wrapper.hasClass('baz')).to.equal(false); + expect(wrapper.hasClass('some-long-string')).to.equal(false); + expect(wrapper.hasClass('FoOo')).to.equal(false); expect(wrapper.hasClass('doesnt-exist')).to.equal(false); + + expect(wrapper.children().hasClass('foo')).to.equal(true); + expect(wrapper.children().hasClass('bar')).to.equal(true); + expect(wrapper.children().hasClass('baz')).to.equal(true); + expect(wrapper.children().hasClass('some-long-string')).to.equal(true); + expect(wrapper.children().hasClass('FoOo')).to.equal(true); + expect(wrapper.children().hasClass('doesnt-exist')).to.equal(false); }); }); @@ -2140,12 +2175,19 @@ describeWithDOM('mount', () => { } const wrapper = mount(); - expect(wrapper.hasClass('foo')).to.equal(true); - expect(wrapper.hasClass('bar')).to.equal(true); - expect(wrapper.hasClass('baz')).to.equal(true); - expect(wrapper.hasClass('some-long-string')).to.equal(true); - expect(wrapper.hasClass('FoOo')).to.equal(true); + expect(wrapper.hasClass('foo')).to.equal(false); + expect(wrapper.hasClass('bar')).to.equal(false); + expect(wrapper.hasClass('baz')).to.equal(false); + expect(wrapper.hasClass('some-long-string')).to.equal(false); + expect(wrapper.hasClass('FoOo')).to.equal(false); expect(wrapper.hasClass('doesnt-exist')).to.equal(false); + + expect(wrapper.children().hasClass('foo')).to.equal(true); + expect(wrapper.children().hasClass('bar')).to.equal(true); + expect(wrapper.children().hasClass('baz')).to.equal(true); + expect(wrapper.children().hasClass('some-long-string')).to.equal(true); + expect(wrapper.children().hasClass('FoOo')).to.equal(true); + expect(wrapper.children().hasClass('doesnt-exist')).to.equal(false); }); }); @@ -2163,12 +2205,21 @@ describeWithDOM('mount', () => { } const wrapper = mount(); - expect(wrapper.hasClass('foo')).to.equal(true); - expect(wrapper.hasClass('bar')).to.equal(true); - expect(wrapper.hasClass('baz')).to.equal(true); - expect(wrapper.hasClass('some-long-string')).to.equal(true); - expect(wrapper.hasClass('FoOo')).to.equal(true); + expect(wrapper.hasClass('foo')).to.equal(false); + expect(wrapper.hasClass('bar')).to.equal(false); + expect(wrapper.hasClass('baz')).to.equal(false); + expect(wrapper.hasClass('some-long-string')).to.equal(false); + expect(wrapper.hasClass('FoOo')).to.equal(false); expect(wrapper.hasClass('doesnt-exist')).to.equal(false); + + // NOTE(lmr): the fact that this no longer works is a semantically + // meaningfull deviation in behavior. But this will be remedied with the ".root()" change + expect(wrapper.children().hasClass('foo')).to.equal(false); + expect(wrapper.children().hasClass('bar')).to.equal(false); + expect(wrapper.children().hasClass('baz')).to.equal(false); + expect(wrapper.children().hasClass('some-long-string')).to.equal(false); + expect(wrapper.children().hasClass('FoOo')).to.equal(false); + expect(wrapper.children().hasClass('doesnt-exist')).to.equal(false); }); }); @@ -2481,7 +2532,7 @@ describeWithDOM('mount', () => {
, ); - const nodes = wrapper.find('.foo').flatMap(w => w.children().getNodes()); + const nodes = wrapper.find('.foo').flatMap(w => w.children().getNodesInternal()); expect(nodes.length).to.equal(6); expect(nodes.at(0).hasClass('bar')).to.equal(true); @@ -2593,10 +2644,10 @@ describeWithDOM('mount', () => {
, ); - expect(wrapper.find('.bar').get(0)).to.equal(wrapper.find('.foo').getNode()); - expect(wrapper.find('.bar').get(1)).to.equal(wrapper.find('.bax').getNode()); - expect(wrapper.find('.bar').get(2)).to.equal(wrapper.find('.bux').getNode()); - expect(wrapper.find('.bar').get(3)).to.equal(wrapper.find('.baz').getNode()); + expect(wrapper.find('.bar').get(0)).to.deep.equal(wrapper.find('.foo').getElement()); + expect(wrapper.find('.bar').get(1)).to.deep.equal(wrapper.find('.bax').getElement()); + expect(wrapper.find('.bar').get(2)).to.deep.equal(wrapper.find('.bux').getElement()); + expect(wrapper.find('.bar').get(3)).to.deep.equal(wrapper.find('.baz').getElement()); }); }); @@ -2615,8 +2666,16 @@ describeWithDOM('mount', () => { } } const wrapper = mount(); - expect(wrapper.ref('secondRef').prop('data-amount')).to.equal(4); - expect(wrapper.ref('secondRef').text()).to.equal('Second'); + // React 13 and 14 return instances whereas 15+ returns actual DOM nodes. In this case, + // the public API of enzyme is to just return what `this.refs[refName]` would be expected + // to return for the version of react you're using. + if (REACT013 || REACT014) { + expect(wrapper.ref('secondRef').getDOMNode().getAttribute('data-amount')).to.equal('4'); + expect(wrapper.ref('secondRef').getDOMNode().textContent).to.equal('Second'); + } else { + expect(wrapper.ref('secondRef').getAttribute('data-amount')).to.equal('4'); + expect(wrapper.ref('secondRef').textContent).to.equal('Second'); + } }); }); @@ -2835,7 +2894,7 @@ describeWithDOM('mount', () => { expect(rendered.html()).to.equal(null); }); - itIf(REACT15, 'works with SFCs that return null', () => { + itIf(is('>=15 || ^16.0.0-alpha'), 'works with SFCs that return null', () => { const Foo = () => null; const wrapper = mount(); @@ -3346,15 +3405,14 @@ describeWithDOM('mount', () => { }); describe('.ref()', () => { - it('unavailable ref should return empty nodes', () => { + it('unavailable ref should return undefined', () => { class WithoutRef extends React.Component { render() { return
; } } const wrapper = mount(); const ref = wrapper.ref('not-a-ref'); - expect(ref.length).to.equal(0); - expect(ref.exists()).to.equal(false); + expect(ref).to.equal(undefined); }); }); }); @@ -3379,14 +3437,14 @@ describeWithDOM('mount', () => { const b1 = wrapper.find('a').get(1); const c1 = wrapper.find('a').get(2); const d1 = wrapper.find('a').get(3); - expect(a1).to.equal(a); - expect(b1).to.equal(b); - expect(c1).to.equal(c); - expect(d1).to.equal(d); + expect(a1).to.deep.equal(a); + expect(b1).to.deep.equal(b); + expect(c1).to.deep.equal(c); + expect(d1).to.deep.equal(d); }); }); - describe('.getNode()', () => { + describe('.instance()', () => { class Test extends React.Component { render() { return ( @@ -3400,16 +3458,16 @@ describeWithDOM('mount', () => { it('should return the wrapped component instance', () => { const wrapper = mount(); - expect(wrapper.getNode()).to.be.an.instanceof(Test); + expect(wrapper.instance()).to.be.an.instanceof(Test); }); it('should throw when wrapping multiple elements', () => { const wrapper = mount().find('span'); - expect(() => wrapper.getNode()).to.throw(Error); + expect(() => wrapper.instance()).to.throw(Error); }); }); - describe('.getNodes()', () => { + describe('.getElements()', () => { it('should return the wrapped elements', () => { class Test extends React.Component { render() { @@ -3423,7 +3481,7 @@ describeWithDOM('mount', () => { } const wrapper = mount(); - expect(wrapper.find('span').getNodes()).to.have.lengthOf(2); + expect(wrapper.find('span').getElements()).to.have.lengthOf(2); }); }); @@ -3502,14 +3560,14 @@ describeWithDOM('mount', () => { it('works with a name', () => { const wrapper = mount(
); wrapper.single('foo', (node) => { - expect(node).to.equal(wrapper.get(0)); + expect(node).to.equal(wrapper.getNodeInternal()); }); }); it('works without a name', () => { const wrapper = mount(
); wrapper.single((node) => { - expect(node).to.equal(wrapper.get(0)); + expect(node).to.equal(wrapper.getNodeInternal()); }); }); }); diff --git a/test/ShallowWrapper-spec.jsx b/test/ShallowWrapper-spec.jsx index 4d256a90a..366dd2700 100644 --- a/test/ShallowWrapper-spec.jsx +++ b/test/ShallowWrapper-spec.jsx @@ -1,3 +1,4 @@ +import './_helpers/setupAdapters'; import React from 'react'; import PropTypes from 'prop-types'; import { expect } from 'chai'; @@ -7,9 +8,44 @@ import { createClass } from './_helpers/react-compat'; import { shallow, render, ShallowWrapper } from '../src/'; import { describeIf, itIf, itWithData, generateEmptyRenderData } from './_helpers'; import { ITERATOR_SYMBOL, withSetStateAllowed } from '../src/Utils'; -import { REACT013, REACT014, REACT15 } from '../src/version'; +import { REACT013, REACT014, REACT16, is } from './_helpers/version'; + +// The shallow renderer in react 16 does not yet support batched updates. When it does, +// we should be able to go un-skip all of the tests that are skipped with this flag. +const BATCHING = !REACT16; describe('shallow', () => { + describe('top level wrapper', () => { + it('does what i expect', () => { + class Box extends React.Component { + render() { + return
{this.props.children}
; + } + } + class Foo extends React.Component { + render() { + return ( + +
+ + ); + } + } + + const wrapper = shallow(); + + expect(wrapper.type()).to.equal(Box); + expect(wrapper.props().bam).to.equal(true); + expect(wrapper.instance()).to.be.instanceOf(Foo); + expect(wrapper.children().at(0).type()).to.equal('div'); + expect(wrapper.find(Box).children().props().className).to.equal('div'); + expect(wrapper.find(Box).children().at(0).props().className).to.equal('div'); + expect(wrapper.find(Box).children().props().className).to.equal('div'); + expect(wrapper.children().type()).to.equal('div'); + expect(wrapper.children().props().bam).to.equal(undefined); + }); + }); + describe('context', () => { it('can pass in context', () => { const SimpleComponent = createClass({ @@ -37,7 +73,7 @@ describe('shallow', () => { expect(() => shallow(, { context })).to.not.throw(); }); - it('is instrospectable through context API', () => { + it('is introspectable through context API', () => { const SimpleComponent = createClass({ contextTypes: { name: PropTypes.string, @@ -75,7 +111,7 @@ describe('shallow', () => { expect(() => shallow(, { context })).not.to.throw(); }); - it('is instrospectable through context API', () => { + itIf(!REACT16, 'is introspectable through context API', () => { const SimpleComponent = (props, context) => (
{context.name}
); @@ -86,6 +122,24 @@ describe('shallow', () => { expect(wrapper.context().name).to.equal(context.name); expect(wrapper.context('name')).to.equal(context.name); }); + + itIf(REACT16, 'is not introspectable through context API', () => { + const SimpleComponent = (props, context) => ( +
{context.name}
+ ); + SimpleComponent.contextTypes = { name: PropTypes.string }; + + const wrapper = shallow(, { context }); + + expect(() => wrapper.context()).to.throw( + Error, + 'ShallowWrapper::context() can only be called on wrapped nodes that have a non-null instance', + ); + expect(() => wrapper.context('name')).to.throw( + Error, + 'ShallowWrapper::context() can only be called on wrapped nodes that have a non-null instance', + ); + }); }); }); @@ -1140,7 +1194,7 @@ describe('shallow', () => { }); }); - it('should be batched updates', () => { + itIf(BATCHING, 'should be batched updates', () => { let renderCount = 0; class Foo extends React.Component { constructor(props) { @@ -1297,7 +1351,7 @@ describe('shallow', () => { expect(wrapper.isEmptyRender()).to.equal(false); }); - describeIf(REACT15, 'stateless function components', () => { + describeIf(is('>=15 || ^16.0.0-alpha'), 'stateless function components', () => { itWithData(emptyRenderValues, 'when a component returns: ', (data) => { function Foo() { return data.value; @@ -1859,7 +1913,6 @@ describe('shallow', () => {
, ); - expect(wrapper.find('.baz').parent().hasClass('bar')).to.equal(true); }); @@ -2265,7 +2318,7 @@ describe('shallow', () => {
, ); - const nodes = wrapper.find('.foo').flatMap(w => w.children().getNodes()); + const nodes = wrapper.find('.foo').flatMap(w => w.children().getElements()); expect(nodes.length).to.equal(6); expect(nodes.at(0).hasClass('bar')).to.equal(true); @@ -2350,7 +2403,7 @@ describe('shallow', () => { expect(() => wrapper.find(Bar).shallow({ context })).to.not.throw(); }); - it('is instrospectable through context API', () => { + it('is introspectable through context API', () => { class Bar extends React.Component { render() { return
{this.context.name}
; @@ -2428,7 +2481,7 @@ describe('shallow', () => { expect(() => wrapper.find(Bar).shallow({ context })).to.not.throw(); }); - it('is instrospectable through context API', () => { + itIf(!REACT16, 'is introspectable through context API', () => { const Bar = (props, context) => (
{context.name}
); @@ -2445,6 +2498,30 @@ describe('shallow', () => { expect(wrapper.context().name).to.equal(context.name); expect(wrapper.context('name')).to.equal(context.name); }); + + itIf(REACT16, 'will throw when trying to inspect context', () => { + const Bar = (props, context) => ( +
{context.name}
+ ); + Bar.contextTypes = { name: PropTypes.string }; + const Foo = () => ( +
+ +
+ ); + + const context = { name: 'foo' }; + const wrapper = shallow().find(Bar).shallow({ context }); + + expect(() => wrapper.context()).to.throw( + Error, + 'ShallowWrapper::context() can only be called on wrapped nodes that have a non-null instance', + ); + expect(() => wrapper.context('name')).to.throw( + Error, + 'ShallowWrapper::context() can only be called on wrapped nodes that have a non-null instance', + ); + }); }); }); }); @@ -2549,10 +2626,10 @@ describe('shallow', () => {
, ); - expect(wrapper.find('.bar').get(0)).to.equal(wrapper.find('.foo').getNode()); - expect(wrapper.find('.bar').get(1)).to.equal(wrapper.find('.bax').getNode()); - expect(wrapper.find('.bar').get(2)).to.equal(wrapper.find('.bux').getNode()); - expect(wrapper.find('.bar').get(3)).to.equal(wrapper.find('.baz').getNode()); + expect(wrapper.find('.bar').get(0)).to.deep.equal(wrapper.find('.foo').getElement()); + expect(wrapper.find('.bar').get(1)).to.deep.equal(wrapper.find('.bax').getElement()); + expect(wrapper.find('.bar').get(2)).to.deep.equal(wrapper.find('.bux').getElement()); + expect(wrapper.find('.bar').get(3)).to.deep.equal(wrapper.find('.baz').getElement()); }); }); @@ -2781,7 +2858,7 @@ describe('shallow', () => { ]); }); - describeIf(REACT013 || REACT15, 'setContext', () => { + describeIf(!REACT014 && !REACT16, 'setContext', () => { it('calls expected methods when receiving new context', () => { wrapper.setContext({ foo: 'foo' }); expect(spy.args).to.deep.equal([ @@ -2793,6 +2870,17 @@ describe('shallow', () => { }); }); + describeIf(REACT16, 'setContext', () => { + it('calls expected methods when receiving new context', () => { + wrapper.setContext({ foo: 'foo' }); + expect(spy.args).to.deep.equal([ + ['shouldComponentUpdate'], + ['componentWillUpdate'], + ['render'], + ]); + }); + }); + describeIf(REACT014, 'setContext', () => { it('calls expected methods when receiving new context', () => { wrapper.setContext({ foo: 'foo' }); @@ -2804,7 +2892,7 @@ describe('shallow', () => { }); }); - it('calls expected methods for setState', () => { + itIf(!REACT16, 'calls expected methods for setState', () => { wrapper.setState({ bar: 'bar' }); expect(spy.args).to.deep.equal([ ['shouldComponentUpdate'], @@ -2814,6 +2902,16 @@ describe('shallow', () => { ]); }); + // componentDidUpdate does not seem to get called in react 16 beta. + itIf(REACT16, 'calls expected methods for setState', () => { + wrapper.setState({ bar: 'bar' }); + expect(spy.args).to.deep.equal([ + ['shouldComponentUpdate'], + ['componentWillUpdate'], + ['render'], + ]); + }); + it('calls expected methods when unmounting', () => { wrapper.unmount(); expect(spy.args).to.deep.equal([ @@ -2865,7 +2963,7 @@ describe('shallow', () => { ]); }); - it('should be batching updates', () => { + itIf(BATCHING, 'should be batching updates', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -2943,7 +3041,7 @@ describe('shallow', () => { [ 'componentWillReceiveProps', { foo: 'bar' }, { foo: 'baz' }, - { foo: 'context' }, + { foo: 'context' }, // this will be fixed ], [ 'shouldComponentUpdate', @@ -2964,7 +3062,7 @@ describe('shallow', () => { 'componentDidUpdate', { foo: 'bar' }, { foo: 'baz' }, { foo: 'state' }, { foo: 'state' }, - { foo: 'context' }, + { foo: 'context' }, // this will be gone in 16 ], [ 'componentWillReceiveProps', @@ -3034,7 +3132,7 @@ describe('shallow', () => { ]); }); - it('should not provoke another renders to call setState in componentWillReceiveProps', () => { + itIf(BATCHING, 'should not provoke another renders to call setState in componentWillReceiveProps', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3059,7 +3157,7 @@ describe('shallow', () => { expect(result.state('count')).to.equal(1); }); - it('should provoke an another render to call setState twice in componentWillUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentWillUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3088,7 +3186,7 @@ describe('shallow', () => { expect(result.state('count')).to.equal(1); }); - it('should provoke an another render to call setState twice in componentDidUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentDidUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3121,7 +3219,9 @@ describe('shallow', () => { }); context('updating state', () => { - it('should call shouldComponentUpdate, componentWillUpdate and componentDidUpdate', () => { + // NOTE: There is a bug in react 16 shallow renderer where prevContext is not passed + // into componentDidUpdate. Skip this test for react 16 only. add back in if it gets fixed. + itIf(!REACT16, 'should call shouldComponentUpdate, componentWillUpdate and componentDidUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { @@ -3217,7 +3317,7 @@ describe('shallow', () => { expect(spy.args).to.deep.equal([['render'], ['shouldComponentUpdate']]); }); - it('should provoke an another render to call setState twice in componentWillUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentWillUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3247,7 +3347,7 @@ describe('shallow', () => { expect(result.state('count')).to.equal(1); }); - it('should provoke an another render to call setState twice in componentDidUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentDidUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3378,7 +3478,7 @@ describe('shallow', () => { expect(spy.args).to.deep.equal([['render'], ['shouldComponentUpdate']]); }); - it('should provoke an another render to call setState twice in componentWillUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentWillUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3413,7 +3513,7 @@ describe('shallow', () => { expect(result.state('count')).to.equal(1); }); - it('should provoke an another render to call setState twice in componentDidUpdate', () => { + itIf(BATCHING, 'should provoke an another render to call setState twice in componentDidUpdate', () => { const spy = sinon.spy(); class Foo extends React.Component { constructor(props) { @@ -3531,7 +3631,7 @@ describe('shallow', () => { expect(rendered.html()).to.equal(null); }); - itIf(REACT15, 'works with SFCs that return null', () => { + itIf(is('>=15 || ^16.0.0-alpha'), 'works with SFCs that return null', () => { const Foo = () => null; const wrapper = shallow(); @@ -4040,7 +4140,7 @@ describe('shallow', () => { expect(() => { wrapper.dive(); }).to.throw( TypeError, - 'ShallowWrapper::dive() can not be called on DOM components', + 'ShallowWrapper::dive() can not be called on Host Components', ); }); @@ -4102,35 +4202,10 @@ describe('shallow', () => { const b1 = wrapper.find('a').get(1); const c1 = wrapper.find('a').get(2); const d1 = wrapper.find('a').get(3); - expect(a1).to.equal(a); - expect(b1).to.equal(b); - expect(c1).to.equal(c); - expect(d1).to.equal(d); - }); - }); - - describe('.getNode()', () => { - const element = ( -
- - -
- ); - - class Test extends React.Component { - render() { - return element; - } - } - - it('should return the wrapped element', () => { - const wrapper = shallow(); - expect(wrapper.getNode()).to.equal(element); - }); - - it('should throw when wrapping multiple elements', () => { - const wrapper = shallow().find('span'); - expect(() => wrapper.getNode()).to.throw(Error); + expect(a1).to.deep.equal(a); + expect(b1).to.deep.equal(b); + expect(c1).to.deep.equal(c); + expect(d1).to.deep.equal(d); }); }); @@ -4151,7 +4226,7 @@ describe('shallow', () => { } const wrapper = shallow(); - expect(wrapper.find('span').getNodes()).to.deep.equal([one, two]); + expect(wrapper.find('span').getElements()).to.deep.equal([one, two]); }); }); @@ -4198,6 +4273,7 @@ describe('shallow', () => { const wrapper = shallow(); wrapper.find('.async-btn').simulate('click'); setImmediate(() => { + wrapper.update(); expect(wrapper.find('.show-me').length).to.equal(1); done(); }); @@ -4206,6 +4282,7 @@ describe('shallow', () => { it('should have updated output after child prop callback invokes setState', () => { const wrapper = shallow(); wrapper.find(Child).props().callback(); + wrapper.update(); expect(wrapper.find('.show-me').length).to.equal(1); }); }); @@ -4223,14 +4300,14 @@ describe('shallow', () => { it('works with a name', () => { const wrapper = shallow(
); wrapper.single('foo', (node) => { - expect(node).to.equal(wrapper.get(0)); + expect(node).to.equal(wrapper.node); }); }); it('works without a name', () => { const wrapper = shallow(
); wrapper.single((node) => { - expect(node).to.equal(wrapper.get(0)); + expect(node).to.equal(wrapper.node); }); }); }); diff --git a/test/Utils-spec.jsx b/test/Utils-spec.jsx index 4ecf61de6..e289de285 100644 --- a/test/Utils-spec.jsx +++ b/test/Utils-spec.jsx @@ -1,50 +1,25 @@ -/* globals window */ - +import './_helpers/setupAdapters'; import React from 'react'; import { expect } from 'chai'; -import { describeWithDOM, describeIf } from './_helpers'; -import { mount } from '../src'; +import { describeIf } from './_helpers'; import { coercePropValue, childrenToSimplifiedArray, - getNode, nodeEqual, nodeMatches, isPseudoClassSelector, - propFromEvent, SELECTOR, selectorType, - mapNativeEventNames, displayNameOfNode, } from '../src/Utils'; -import { REACT013 } from '../src/version'; +import { + mapNativeEventNames, + propFromEvent, +} from '../src/adapters/Utils'; +import { REACT013 } from './_helpers/version'; describe('Utils', () => { - - describeWithDOM('getNode', () => { - it('should return a DOMNode when a DOMComponent is given', () => { - const div = mount(
).getNode(); - expect(getNode(div)).to.be.instanceOf(window.HTMLElement); - }); - - it('should return the component when a component is given', () => { - class Foo extends React.Component { - render() { return
; } - } - const foo = mount().getNode(); - expect(getNode(foo)).to.equal(foo); - }); - - describeIf(!REACT013, 'stateless function components', () => { - it('should return the component when a component is given', () => { - const Foo = () =>
; - const foo = mount().getNode(); - expect(getNode(foo)).to.equal(foo); - }); - }); - }); - describe('nodeEqual', () => { it('should match empty elements of same tag', () => { expect(nodeEqual( diff --git a/test/_helpers/react-compat.js b/test/_helpers/react-compat.js index 261ba2a8d..65f33621a 100644 --- a/test/_helpers/react-compat.js +++ b/test/_helpers/react-compat.js @@ -4,11 +4,11 @@ import/prefer-default-export: 0, */ -import { REACT155 } from '../../src/version'; +import { is } from './version'; let createClass; -if (REACT155) { +if (is('>=15.5 || ^16.0.0-alpha')) { // eslint-disable-next-line import/no-extraneous-dependencies createClass = require('create-react-class'); } else { diff --git a/test/_helpers/setupAdapters.js b/test/_helpers/setupAdapters.js new file mode 100644 index 000000000..40077a083 --- /dev/null +++ b/test/_helpers/setupAdapters.js @@ -0,0 +1,25 @@ +/* eslint global-require: 0 */ +/** + * This file is needed only because we run our unit tests on multiple + * versions of React at a time. This file basically figures out which + * version of React is loaded, and configures enzyme to use the right + * corresponding adapter. + */ +const Version = require('.//version'); +const Enzyme = require('../../src'); + +let Adapter = null; + +if (Version.REACT013) { + Adapter = require('../../src/adapters/ReactThirteenAdapter'); +} else if (Version.REACT014) { + Adapter = require('../../src/adapters/ReactFourteenAdapter'); +} else if (Version.REACT155) { + Adapter = require('../../src/adapters/ReactFifteenAdapter'); +} else if (Version.REACT15) { + Adapter = require('../../src/adapters/ReactFifteenFourAdapter'); +} else if (Version.REACT16) { + Adapter = require('../../src/adapters/ReactSixteenAdapter'); +} + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/version.js b/test/_helpers/version.js similarity index 55% rename from src/version.js rename to test/_helpers/version.js index d55f7f3df..a3e5b9183 100644 --- a/src/version.js +++ b/test/_helpers/version.js @@ -1,4 +1,5 @@ import React from 'react'; +import semver from 'semver'; export const VERSION = React.version; @@ -8,3 +9,8 @@ export const REACT013 = VERSION.slice(0, 4) === '0.13'; export const REACT014 = VERSION.slice(0, 4) === '0.14'; export const REACT15 = major === '15'; export const REACT155 = REACT15 && minor >= 5; +export const REACT16 = major === '16'; + +export function gt(v) { return semver.gt(VERSION, v); } +export function lt(v) { return semver.lt(VERSION, v); } +export function is(range) { return semver.satisfies(VERSION, range); } diff --git a/test/mocha.opts b/test/mocha.opts index d699ed08d..7ecaceb4d 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,4 +1,3 @@ ---require withDom.js +--require withDom.js ./test/_helpers/setupAdapters.js --compilers js:babel-core/register,jsx:babel-core/register --extensions js,jsx ---reporter dot diff --git a/test/staticRender-spec.jsx b/test/staticRender-spec.jsx index 85566a6f5..9be393098 100644 --- a/test/staticRender-spec.jsx +++ b/test/staticRender-spec.jsx @@ -1,9 +1,10 @@ +import './_helpers/setupAdapters'; import React from 'react'; import PropTypes from 'prop-types'; import { expect } from 'chai'; import { describeWithDOM, describeIf } from './_helpers'; import { render } from '../src/'; -import { REACT013 } from '../src/version'; +import { REACT013 } from './_helpers/version'; import { createClass } from './_helpers/react-compat'; describeWithDOM('render', () => { diff --git a/withDom.js b/withDom.js index 215d39c8f..985026d7c 100644 --- a/withDom.js +++ b/withDom.js @@ -1,3 +1,5 @@ +require('raf/polyfill'); + if (!global.document) { try { const jsdom = require('jsdom').jsdom; // could throw