Fuzz tester that simulates Sierpinski Triangle demo (#9952)
* Add ReactNoop.yield, ReactNoop.flushAndYield, ReactNoop.flushUnitsOfWork Incremental tests often rely on yielding at a specific point. Using an explicit API should make our tests more resilient to changes in implementation. * Fuzz tester that simulates Sierpinski Triangle demo The tests only assert that the output of the tree is consistent after each action, and that the final result after all work has flushed is the expected value. It does not assert how work is reused, which means it can't detect starvation issues. However, this also means that it is fairly resilient to changes in our incremental algorithm.
Andrew Clark committed
Jun 14, 2017 at 10:54 UTC
462f4debfed19cab94f8ba9c5bb0021c7863d30c
3 files changed
+395
-15
src/renderers/noop/ReactNoop.js
+68
-15
@@ -212,6 +212,40 @@ var rootContainers = new Map();
212
var roots = new Map();
213
var DEFAULT_ROOT_ID = '<default>';
214
215
+let yieldBeforeNextUnitOfWork = false;
216
+let yieldValue = null;
217
+
218
+function* flushUnitsOfWork(n: number): Generator<mixed, void, void> {
219
+ var didStop = false;
220
+ while (!didStop && scheduledDeferredCallback !== null) {
221
+ var cb = scheduledDeferredCallback;
222
+ scheduledDeferredCallback = null;
223
+ yieldBeforeNextUnitOfWork = false;
224
+ yieldValue = null;
225
+ var unitsRemaining = n;
226
+ var didYield = false;
227
+ cb({
228
+ timeRemaining() {
229
+ if (yieldBeforeNextUnitOfWork) {
230
+ didYield = true;
231
+ return 0;
232
+ }
233
+ if (unitsRemaining-- > 0) {
234
+ return 999;
235
+ }
236
+ didStop = true;
237
+ return 0;
238
+ },
239
+ });
240
+
241
+ if (didYield) {
242
+ const valueToYield = yieldValue;
243
+ yieldValue = null;
244
+ yield valueToYield;
245
+ }
246
+ }
247
+}
248
+
249
var ReactNoop = {
250
getChildren(rootID: string = DEFAULT_ROOT_ID) {
251
const container = rootContainers.get(rootID);
@@ -277,22 +311,17 @@ var ReactNoop = {
311
},
312
313
flushDeferredPri(timeout: number = Infinity) {
280
- var cb = scheduledDeferredCallback;
281
- if (cb === null) {
282
- return;
314
+ // The legacy version of this function decremented the timeout before
315
+ // returning the new time.
316
+ // TODO: Convert tests to use flushUnitsOfWork or flushAndYield instead.
317
+ const n = timeout / 5 - 1;
318
+ const iterator = flushUnitsOfWork(n);
319
+ let value = iterator.next();
320
+ while (!value.done) {
321
+ value = iterator.next();
322
}
284
- scheduledDeferredCallback = null;
285
- var timeRemaining = timeout;
286
- cb({
287
- timeRemaining() {
288
- // Simulate a fix amount of time progressing between each call.
289
- timeRemaining -= 5;
290
- if (timeRemaining < 0) {
291
- timeRemaining = 0;
292
- }
293
- return timeRemaining;
294
- },
295
- });
323
+ // Don't flush animation priority in this legacy function. Some tests may
324
+ // still rely on this behavior.
325
},
326
327
flush() {
@@ -300,6 +329,30 @@ var ReactNoop = {
329
ReactNoop.flushDeferredPri();
330
},
331
332
+ *flushAndYield(unitsOfWork: number = Infinity): Generator<mixed, void, void> {
333
+ for (const value of flushUnitsOfWork(unitsOfWork)) {
334
+ yield value;
335
+ }
336
+ ReactNoop.flushAnimationPri();
337
+ },
338
+
339
+ flushUnitsOfWork(n: number) {
340
+ const iterator = flushUnitsOfWork(n);
341
+ let value = iterator.next();
342
+ while (!value.done) {
343
+ value = iterator.next();
344
+ }
345
+ // TODO: We should always flush animation priority after flushing normal/low
346
+ // priority. Move this to flushUnitsOfWork generator once tests
347
+ // are converted.
348
+ ReactNoop.flushAnimationPri();
349
+ },
350
+
351
+ yield(value: mixed) {
352
+ yieldBeforeNextUnitOfWork = true;
353
+ yieldValue = value;
354
+ },
355
+
356
performAnimationWork(fn: Function) {
357
NoopRenderer.performWithPriority(AnimationPriority, fn);
358
},
src/renderers/shared/fiber/__tests__/ReactIncrementalTriangle-test.js
new
+325
@@ -0,0 +1,325 @@
1
+/**
2
+ * Copyright 2013-present, Facebook, Inc.
3
+ * All rights reserved.
4
+ *
5
+ * This source code is licensed under the BSD-style license found in the
6
+ * LICENSE file in the root directory of this source tree. An additional grant
7
+ * of patent rights can be found in the PATENTS file in the same directory.
8
+ *
9
+ * @emails react-core
10
+ */
11
+
12
+'use strict';
13
+
14
+var React;
15
+var ReactNoop;
16
+var ReactFeatureFlags;
17
+
18
+describe('ReactIncrementalTriangle', () => {
19
+ beforeEach(() => {
20
+ jest.resetModules();
21
+ React = require('react');
22
+ ReactNoop = require('ReactNoop');
23
+
24
+ ReactFeatureFlags = require('ReactFeatureFlags');
25
+ ReactFeatureFlags.disableNewFiberFeatures = false;
26
+ });
27
+
28
+ function span(prop) {
29
+ return {type: 'span', children: [], prop};
30
+ }
31
+
32
+ const FLUSH = 'FLUSH';
33
+ function flush(unitsOfWork = Infinity) {
34
+ return {
35
+ type: FLUSH,
36
+ unitsOfWork,
37
+ };
38
+ }
39
+
40
+ const STEP = 'STEP';
41
+ function step(counter) {
42
+ return {
43
+ type: STEP,
44
+ counter,
45
+ };
46
+ }
47
+
48
+ const INTERRUPT = 'INTERRUPT';
49
+ function interrupt(key) {
50
+ return {
51
+ type: INTERRUPT,
52
+ };
53
+ }
54
+
55
+ const TOGGLE = 'TOGGLE';
56
+ function toggle(childIndex) {
57
+ return {
58
+ type: TOGGLE,
59
+ childIndex,
60
+ };
61
+ }
62
+
63
+ function TriangleSimulator() {
64
+ let triangles = [];
65
+ let leafTriangles = [];
66
+ let yieldAfterEachRender = false;
67
+ class Triangle extends React.Component {
68
+ constructor(props) {
69
+ super();
70
+ this.index = triangles.length;
71
+ triangles.push(this);
72
+ if (props.depth === 0) {
73
+ this.leafIndex = leafTriangles.length;
74
+ leafTriangles.push(this);
75
+ }
76
+ this.state = {isActive: false};
77
+ }
78
+ activate() {
79
+ if (this.props.depth !== 0) {
80
+ throw new Error('Cannot activate non-leaf component');
81
+ }
82
+ ReactNoop.performAnimationWork(() => {
83
+ this.setState({isActive: true});
84
+ });
85
+ }
86
+ deactivate() {
87
+ if (this.props.depth !== 0) {
88
+ throw new Error('Cannot deactivate non-leaf component');
89
+ }
90
+ ReactNoop.performAnimationWork(() => {
91
+ this.setState({isActive: false});
92
+ });
93
+ }
94
+ shouldComponentUpdate(nextProps, nextState) {
95
+ return (
96
+ this.props.counter !== nextProps.counter ||
97
+ this.state.isActive !== nextState.isActive
98
+ );
99
+ }
100
+ render() {
101
+ if (yieldAfterEachRender) {
102
+ ReactNoop.yield(this);
103
+ }
104
+ const {counter, depth} = this.props;
105
+ if (depth === 0) {
106
+ if (this.state.isActive) {
107
+ return <span prop={'*' + counter + '*'} />;
108
+ }
109
+ return <span prop={counter} />;
110
+ }
111
+ return [
112
+ <Triangle key={1} counter={counter} depth={depth - 1} />,
113
+ <Triangle key={2} counter={counter} depth={depth - 1} />,
114
+ <Triangle key={3} counter={counter} depth={depth - 1} />,
115
+ ];
116
+ }
117
+ }
118
+
119
+ let appInstance;
120
+ class App extends React.Component {
121
+ state = {counter: 0};
122
+ interrupt() {
123
+ // Triggers a restart from the top.
124
+ ReactNoop.performAnimationWork(() => {
125
+ this.forceUpdate();
126
+ });
127
+ }
128
+ setCounter(counter) {
129
+ const currentCounter = this.state.counter;
130
+ this.setState({counter});
131
+ return currentCounter;
132
+ }
133
+ render() {
134
+ appInstance = this;
135
+ return <Triangle counter={this.state.counter} depth={3} />;
136
+ }
137
+ }
138
+
139
+ const depth = 3;
140
+
141
+ let keyCounter = 0;
142
+ function reset(nextStep = 0) {
143
+ triangles = [];
144
+ leafTriangles = [];
145
+ // Remounts the whole tree by changing the key
146
+ ReactNoop.render(<App depth={depth} key={keyCounter++} />);
147
+ ReactNoop.flush();
148
+ assertConsistentTree();
149
+ return appInstance;
150
+ }
151
+
152
+ reset();
153
+ const totalChildren = leafTriangles.length;
154
+ const totalTriangles = triangles.length;
155
+
156
+ function assertConsistentTree(activeTriangle, counter) {
157
+ const activeIndex = activeTriangle ? activeTriangle.leafIndex : -1;
158
+
159
+ const children = ReactNoop.getChildren();
160
+ for (let i = 0; i < children.length; i++) {
161
+ let child = children[i];
162
+ let num = child.prop;
163
+
164
+ // If an expected counter is not specified, use the value of the
165
+ // first child.
166
+ if (counter === undefined) {
167
+ if (typeof num === 'string') {
168
+ counter = num.substr(1, num.length - 2);
169
+ } else {
170
+ counter = num;
171
+ }
172
+ }
173
+
174
+ if (i === activeIndex) {
175
+ if (num !== `*${counter}*`) {
176
+ throw new Error(
177
+ `Triangle ${i} is inconsistent: ${num} instead of *${counter}*.`,
178
+ );
179
+ }
180
+ } else {
181
+ if (num !== counter) {
182
+ throw new Error(
183
+ `Triangle ${i} is inconsistent: ${num} instead of ${counter}.`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ function simulate(...actions) {
191
+ const app = reset();
192
+ let expectedCounterAtEnd = app.state.counter;
193
+
194
+ let activeTriangle = null;
195
+ for (let i = 0; i < actions.length; i++) {
196
+ const action = actions[i];
197
+ switch (action.type) {
198
+ case FLUSH:
199
+ ReactNoop.flushUnitsOfWork(action.unitsOfWork);
200
+ break;
201
+ case STEP:
202
+ app.setCounter(action.counter);
203
+ expectedCounterAtEnd = action.counter;
204
+ break;
205
+ case INTERRUPT:
206
+ app.interrupt();
207
+ break;
208
+ case TOGGLE:
209
+ const targetTriangle = leafTriangles[action.childIndex];
210
+ if (targetTriangle === undefined) {
211
+ throw new Error('Target index is out of bounds');
212
+ }
213
+ if (targetTriangle === activeTriangle) {
214
+ activeTriangle = null;
215
+ targetTriangle.deactivate();
216
+ } else {
217
+ if (activeTriangle !== null) {
218
+ activeTriangle.deactivate();
219
+ }
220
+ activeTriangle = targetTriangle;
221
+ targetTriangle.activate();
222
+ }
223
+ ReactNoop.flushAnimationPri();
224
+ break;
225
+ default:
226
+ break;
227
+ }
228
+ }
229
+ // Flush remaining work
230
+ ReactNoop.flush();
231
+ assertConsistentTree(activeTriangle, expectedCounterAtEnd);
232
+ }
233
+
234
+ return {simulate, totalChildren, totalTriangles};
235
+ }
236
+
237
+ xit('renders the triangle demo without inconsistencies', () => {
238
+ const {simulate} = TriangleSimulator();
239
+ simulate(step(1));
240
+ simulate(toggle(0), step(1), toggle(0));
241
+ simulate(step(1), toggle(0), flush(2), step(2), toggle(0));
242
+ });
243
+
244
+ xit('fuzz tester', () => {
245
+ // This test is not deterministic because the inputs are randomized. It runs
246
+ // a limited number of tests on every run. If it fails, it will output the
247
+ // case that led to the failure. Add the failing case to the test above
248
+ // to prevent future regressions.
249
+ const {simulate, totalTriangles, totalChildren} = TriangleSimulator();
250
+
251
+ const limit = 1000;
252
+
253
+ function randomInteger(min, max) {
254
+ min = Math.ceil(min);
255
+ max = Math.floor(max);
256
+ return Math.floor(Math.random() * (max - min)) + min;
257
+ }
258
+
259
+ function randomAction() {
260
+ switch (randomInteger(0, 4)) {
261
+ case 0:
262
+ return flush(randomInteger(0, totalTriangles * 1.5));
263
+ case 1:
264
+ return step(randomInteger(0, 10));
265
+ case 2:
266
+ return interrupt();
267
+ case 3:
268
+ return toggle(randomInteger(0, totalChildren));
269
+ default:
270
+ throw new Error('Switch statement should be exhaustive');
271
+ }
272
+ }
273
+
274
+ function randomActions(n) {
275
+ let actions = [];
276
+ for (let i = 0; i < n; i++) {
277
+ actions.push(randomAction());
278
+ }
279
+ return actions;
280
+ }
281
+
282
+ function formatActions(actions) {
283
+ let result = 'simulate(';
284
+ for (let i = 0; i < actions.length; i++) {
285
+ const action = actions[i];
286
+ switch (action.type) {
287
+ case FLUSH:
288
+ result += `flush(${action.unitsOfWork})`;
289
+ break;
290
+ case STEP:
291
+ result += `step(${action.counter})`;
292
+ break;
293
+ case INTERRUPT:
294
+ result += 'interrupt()';
295
+ break;
296
+ case TOGGLE:
297
+ result += `toggle(${action.childIndex})`;
298
+ break;
299
+ default:
300
+ throw new Error('Switch statement should be exhaustive');
301
+ }
302
+ if (i !== actions.length - 1) {
303
+ result += ', ';
304
+ }
305
+ }
306
+ result += ')';
307
+ return result;
308
+ }
309
+
310
+ for (let i = 0; i < limit; i++) {
311
+ const actions = randomActions(5);
312
+ try {
313
+ simulate(...actions);
314
+ } catch (e) {
315
+ console.error(
316
+ `
317
+Triangle fuzz tester error! Copy and paste the following line into the test suite:
318
+ ${formatActions(actions)}
319
+ `,
320
+ );
321
+ throw e;
322
+ }
323
+ }
324
+ });
325
+});
src/renderers/shared/fiber/__tests__/__snapshots__/ReactIncrementalPerf-test.js.snap
+2
@@ -85,6 +85,8 @@ exports[`ReactDebugFiberPerf does not treat setState from cWM or cWRP as cascadi
85
⚛ (Committing Changes)
86
⚛ (Committing Host Effects: 2 Total)
87
⚛ (Calling Lifecycle Methods: 2 Total)
88
+
89
+⚛ (React Tree Reconciliation)
90
"
91
`;
92