diff --git a/package.json b/package.json index 0ef3224f371ae..bb7822f56d387 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "platform": "^1.1.0", "prettier": "1.8.1", "prop-types": "^15.6.0", + "random-seed": "^0.3.0", "rimraf": "^2.6.1", "rollup": "^0.51.7", "rollup-plugin-babel": "^2.7.1", diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js index 607b8385f7c7b..5886e695de8ea 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.js @@ -9,14 +9,16 @@ 'use strict'; -let React; +let React = require('React'); let ReactNoop; +let gen; describe('ReactNewContext', () => { beforeEach(() => { jest.resetModules(); React = require('react'); ReactNoop = require('react-noop-renderer'); + gen = require('random-seed'); }); // function div(...children) { @@ -303,4 +305,211 @@ describe('ReactNewContext', () => { span('Result: 4'), ]); }); + + describe('fuzz test', () => { + const contextKeys = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; + const contexts = new Map( + contextKeys.map(key => { + const Context = React.createContext(0); + Context.displayName = 'Context' + key; + return [key, Context]; + }), + ); + const Fragment = React.Fragment; + + const FLUSH_ALL = 'FLUSH_ALL'; + function flushAll() { + return { + type: FLUSH_ALL, + toString() { + return `flushAll()`; + }, + }; + } + + const FLUSH = 'FLUSH'; + function flush(unitsOfWork) { + return { + type: FLUSH, + unitsOfWork, + toString() { + return `flush(${unitsOfWork})`; + }, + }; + } + + const UPDATE = 'UPDATE'; + function update(key, value) { + return { + type: UPDATE, + key, + value, + toString() { + return `update('${key}', ${value})`; + }, + }; + } + + function randomInteger(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min)) + min; + } + + function randomAction() { + switch (randomInteger(0, 3)) { + case 0: + return flushAll(); + case 1: + return flush(randomInteger(0, 500)); + case 2: + const key = contextKeys[randomInteger(0, contextKeys.length)]; + const value = randomInteger(1, 10); + return update(key, value); + default: + throw new Error('Switch statement should be exhaustive'); + } + } + + function randomActions(n) { + let actions = []; + for (let i = 0; i < n; i++) { + actions.push(randomAction()); + } + return actions; + } + + class ConsumerTree extends React.Component { + shouldComponentUpdate() { + return false; + } + render() { + if (this.props.depth >= this.props.maxDepth) { + return null; + } + const consumers = [0, 1, 2].map(i => { + const randomKey = + contextKeys[this.props.rand.intBetween(0, contextKeys.length - 1)]; + const Context = contexts.get(randomKey); + return Context.consume( + value => ( + + + + + ), + i, + ); + }); + return consumers; + } + } + + function Root(props) { + return contextKeys.reduceRight((children, key) => { + const Context = contexts.get(key); + const value = props.values[key]; + return Context.provide(value, children); + }, ); + } + + const initialValues = contextKeys.reduce( + (result, key, i) => ({...result, [key]: i + 1}), + {}, + ); + + function assertConsistentTree(expectedValues = {}) { + const children = ReactNoop.getChildren(); + children.forEach(child => { + const text = child.prop; + const key = text[0]; + const value = parseInt(text[2], 10); + const expectedValue = expectedValues[key]; + if (expectedValue === undefined) { + // If an expected value was not explicitly passed to this function, + // use the first occurrence. + expectedValues[key] = value; + } else if (value !== expectedValue) { + throw new Error( + `Inconsistent value! Expected: ${key}:${expectedValue}. Actual: ${ + text + }`, + ); + } + }); + } + + function ContextSimulator(maxDepth) { + function simulate(seed, actions) { + const rand = gen.create(seed); + let finalExpectedValues = initialValues; + function updateRoot() { + ReactNoop.render( + , + ); + } + updateRoot(); + + actions.forEach(action => { + switch (action.type) { + case FLUSH_ALL: + ReactNoop.flush(); + break; + case FLUSH: + ReactNoop.flushUnitsOfWork(action.unitsOfWork); + break; + case UPDATE: + finalExpectedValues = { + ...finalExpectedValues, + [action.key]: action.value, + }; + updateRoot(); + break; + default: + throw new Error('Switch statement should be exhaustive'); + } + assertConsistentTree(); + }); + + ReactNoop.flush(); + assertConsistentTree(finalExpectedValues); + } + + return {simulate}; + } + + it('hard-coded tests', () => { + const {simulate} = ContextSimulator(5); + simulate('randomSeed', [flush(3), update('A', 4)]); + }); + + it('generated tests', () => { + const {simulate} = ContextSimulator(5); + + const LIMIT = 100; + for (let i = 0; i < LIMIT; i++) { + const seed = Math.random() + .toString(36) + .substr(2, 5); + const actions = randomActions(5); + try { + simulate(seed, actions); + } catch (error) { + console.error(` +Context fuzz tester error! Copy and paste the following line into the test suite: + simulate('${seed}', ${actions.join(', ')}); +`); + throw error; + } + } + }); + }); }); diff --git a/yarn.lock b/yarn.lock index df11eb2e0b92c..e2457614fb730 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3045,7 +3045,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -3811,6 +3811,12 @@ qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" +random-seed@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/random-seed/-/random-seed-0.3.0.tgz#d945f2e1f38f49e8d58913431b8bf6bb937556cd" + dependencies: + json-stringify-safe "^5.0.1" + randomatic@^1.1.3: version "1.1.6" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.6.tgz#110dcabff397e9dcff7c0789ccc0a49adf1ec5bb"