1 | import { Store, MiddlewareAPI, Dispatch } from 'redux'
|
2 | import {
|
3 | createImmutableStateInvariantMiddleware,
|
4 | isImmutableDefault,
|
5 | trackForMutations,
|
6 | ImmutableStateInvariantMiddlewareOptions
|
7 | } from './immutableStateInvariantMiddleware'
|
8 | import { mockConsole, createConsole, getLog } from 'console-testing-library'
|
9 |
|
10 | describe('createImmutableStateInvariantMiddleware', () => {
|
11 | let state: { foo: { bar: number[]; baz: string } }
|
12 | const getState: Store['getState'] = () => state
|
13 |
|
14 | function middleware(options: ImmutableStateInvariantMiddlewareOptions = {}) {
|
15 | return createImmutableStateInvariantMiddleware(options)({
|
16 | getState
|
17 | } as MiddlewareAPI)
|
18 | }
|
19 |
|
20 | beforeEach(() => {
|
21 | state = { foo: { bar: [2, 3, 4], baz: 'baz' } }
|
22 | })
|
23 |
|
24 | it('sends the action through the middleware chain', () => {
|
25 | const next: Dispatch = action => ({ ...action, returned: true })
|
26 | const dispatch = middleware()(next)
|
27 |
|
28 | expect(dispatch({ type: 'SOME_ACTION' })).toEqual({
|
29 | type: 'SOME_ACTION',
|
30 | returned: true
|
31 | })
|
32 | })
|
33 |
|
34 | it('throws if mutating inside the dispatch', () => {
|
35 | const next: Dispatch = action => {
|
36 | state.foo.bar.push(5)
|
37 | return action
|
38 | }
|
39 |
|
40 | const dispatch = middleware()(next)
|
41 |
|
42 | expect(() => {
|
43 | dispatch({ type: 'SOME_ACTION' })
|
44 | }).toThrow(new RegExp('foo\\.bar\\.3'))
|
45 | })
|
46 |
|
47 | it('throws if mutating between dispatches', () => {
|
48 | const next: Dispatch = action => action
|
49 |
|
50 | const dispatch = middleware()(next)
|
51 |
|
52 | dispatch({ type: 'SOME_ACTION' })
|
53 | state.foo.bar.push(5)
|
54 | expect(() => {
|
55 | dispatch({ type: 'SOME_OTHER_ACTION' })
|
56 | }).toThrow(new RegExp('foo\\.bar\\.3'))
|
57 | })
|
58 |
|
59 | it('does not throw if not mutating inside the dispatch', () => {
|
60 | const next: Dispatch = action => {
|
61 | state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
|
62 | return action
|
63 | }
|
64 |
|
65 | const dispatch = middleware()(next)
|
66 |
|
67 | expect(() => {
|
68 | dispatch({ type: 'SOME_ACTION' })
|
69 | }).not.toThrow()
|
70 | })
|
71 |
|
72 | it('does not throw if not mutating between dispatches', () => {
|
73 | const next: Dispatch = action => action
|
74 |
|
75 | const dispatch = middleware()(next)
|
76 |
|
77 | dispatch({ type: 'SOME_ACTION' })
|
78 | state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
|
79 | expect(() => {
|
80 | dispatch({ type: 'SOME_OTHER_ACTION' })
|
81 | }).not.toThrow()
|
82 | })
|
83 |
|
84 | it('works correctly with circular references', () => {
|
85 | const next: Dispatch = action => action
|
86 |
|
87 | const dispatch = middleware()(next)
|
88 |
|
89 | let x: any = {}
|
90 | let y: any = {}
|
91 | x.y = y
|
92 | y.x = x
|
93 |
|
94 | expect(() => {
|
95 | dispatch({ type: 'SOME_ACTION', x })
|
96 | }).not.toThrow()
|
97 | })
|
98 |
|
99 | it('respects "isImmutable" option', function() {
|
100 | const isImmutable = (value: any) => true
|
101 | const next: Dispatch = action => {
|
102 | state.foo.bar.push(5)
|
103 | return action
|
104 | }
|
105 |
|
106 | const dispatch = middleware({ isImmutable })(next)
|
107 |
|
108 | expect(() => {
|
109 | dispatch({ type: 'SOME_ACTION' })
|
110 | }).not.toThrow()
|
111 | })
|
112 |
|
113 | it('respects "ignoredPaths" option', () => {
|
114 | const next: Dispatch = action => {
|
115 | state.foo.bar.push(5)
|
116 | return action
|
117 | }
|
118 |
|
119 | const dispatch = middleware({ ignoredPaths: ['foo.bar'] })(next)
|
120 |
|
121 | expect(() => {
|
122 | dispatch({ type: 'SOME_ACTION' })
|
123 | }).not.toThrow()
|
124 | })
|
125 |
|
126 | it('alias "ignore" to "ignoredPath" and respects option', () => {
|
127 | const next: Dispatch = action => {
|
128 | state.foo.bar.push(5)
|
129 | return action
|
130 | }
|
131 |
|
132 | const dispatch = middleware({ ignore: ['foo.bar'] })(next)
|
133 |
|
134 | expect(() => {
|
135 | dispatch({ type: 'SOME_ACTION' })
|
136 | }).not.toThrow()
|
137 | })
|
138 |
|
139 | it('Should print a warning if execution takes too long', () => {
|
140 | state.foo.bar = new Array(10000).fill({ value: 'more' })
|
141 |
|
142 | const next: Dispatch = action => action
|
143 |
|
144 | const dispatch = middleware({ warnAfter: 4 })(next)
|
145 |
|
146 | const restore = mockConsole(createConsole())
|
147 | try {
|
148 | dispatch({ type: 'SOME_ACTION' })
|
149 | expect(getLog().log).toMatch(
|
150 | /^ImmutableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
|
151 | )
|
152 | } finally {
|
153 | restore()
|
154 | }
|
155 | })
|
156 |
|
157 | it('Should not print a warning if "next" takes too long', () => {
|
158 | const next: Dispatch = action => {
|
159 | const started = Date.now()
|
160 | while (Date.now() - started < 8) {}
|
161 | return action
|
162 | }
|
163 |
|
164 | const dispatch = middleware({ warnAfter: 4 })(next)
|
165 |
|
166 | const restore = mockConsole(createConsole())
|
167 | try {
|
168 | dispatch({ type: 'SOME_ACTION' })
|
169 | expect(getLog().log).toEqual('')
|
170 | } finally {
|
171 | restore()
|
172 | }
|
173 | })
|
174 | })
|
175 |
|
176 | describe('trackForMutations', () => {
|
177 | function testCasesForMutation(spec: any) {
|
178 | it('returns true and the mutated path', () => {
|
179 | const state = spec.getState()
|
180 | const options = spec.middlewareOptions || {}
|
181 | const { isImmutable = isImmutableDefault, ignoredPaths } = options
|
182 | const tracker = trackForMutations(isImmutable, ignoredPaths, state)
|
183 | const newState = spec.fn(state)
|
184 |
|
185 | expect(tracker.detectMutations()).toEqual({
|
186 | wasMutated: true,
|
187 | path: spec.path.join('.')
|
188 | })
|
189 | })
|
190 | }
|
191 |
|
192 | function testCasesForNonMutation(spec: any) {
|
193 | it('returns false', () => {
|
194 | const state = spec.getState()
|
195 | const options = spec.middlewareOptions || {}
|
196 | const { isImmutable = isImmutableDefault, ignoredPaths } = options
|
197 | const tracker = trackForMutations(isImmutable, ignoredPaths, state)
|
198 | const newState = spec.fn(state)
|
199 |
|
200 | expect(tracker.detectMutations()).toEqual({ wasMutated: false })
|
201 | })
|
202 | }
|
203 |
|
204 | interface TestConfig {
|
205 | getState: Store['getState']
|
206 | fn: (s: any) => typeof s | object
|
207 | middlewareOptions?: ImmutableStateInvariantMiddlewareOptions
|
208 | path?: string[]
|
209 | }
|
210 |
|
211 | const mutations: Record<string, TestConfig> = {
|
212 | 'adding to nested array': {
|
213 | getState: () => ({
|
214 | foo: {
|
215 | bar: [2, 3, 4],
|
216 | baz: 'baz'
|
217 | },
|
218 | stuff: []
|
219 | }),
|
220 | fn: s => {
|
221 | s.foo.bar.push(5)
|
222 | return s
|
223 | },
|
224 | path: ['foo', 'bar', '3']
|
225 | },
|
226 | 'adding to nested array and setting new root object': {
|
227 | getState: () => ({
|
228 | foo: {
|
229 | bar: [2, 3, 4],
|
230 | baz: 'baz'
|
231 | },
|
232 | stuff: []
|
233 | }),
|
234 | fn: s => {
|
235 | s.foo.bar.push(5)
|
236 | return { ...s }
|
237 | },
|
238 | path: ['foo', 'bar', '3']
|
239 | },
|
240 | 'changing nested string': {
|
241 | getState: () => ({
|
242 | foo: {
|
243 | bar: [2, 3, 4],
|
244 | baz: 'baz'
|
245 | },
|
246 | stuff: []
|
247 | }),
|
248 | fn: s => {
|
249 | s.foo.baz = 'changed!'
|
250 | return s
|
251 | },
|
252 | path: ['foo', 'baz']
|
253 | },
|
254 | 'removing nested state': {
|
255 | getState: () => ({
|
256 | foo: {
|
257 | bar: [2, 3, 4],
|
258 | baz: 'baz'
|
259 | },
|
260 | stuff: []
|
261 | }),
|
262 | fn: s => {
|
263 | delete s.foo
|
264 | return s
|
265 | },
|
266 | path: ['foo']
|
267 | },
|
268 | 'adding to array': {
|
269 | getState: () => ({
|
270 | foo: {
|
271 | bar: [2, 3, 4],
|
272 | baz: 'baz'
|
273 | },
|
274 | stuff: []
|
275 | }),
|
276 | fn: s => {
|
277 | s.stuff.push(1)
|
278 | return s
|
279 | },
|
280 | path: ['stuff', '0']
|
281 | },
|
282 | 'adding object to array': {
|
283 | getState: () => ({
|
284 | stuff: []
|
285 | }),
|
286 | fn: s => {
|
287 | s.stuff.push({ foo: 1, bar: 2 })
|
288 | return s
|
289 | },
|
290 | path: ['stuff', '0']
|
291 | },
|
292 | 'mutating previous state and returning new state': {
|
293 | getState: () => ({ counter: 0 }),
|
294 | fn: s => {
|
295 | s.mutation = true
|
296 | return { ...s, counter: s.counter + 1 }
|
297 | },
|
298 | path: ['mutation']
|
299 | },
|
300 | 'mutating previous state with non immutable type and returning new state': {
|
301 | getState: () => ({ counter: 0 }),
|
302 | fn: s => {
|
303 | s.mutation = [1, 2, 3]
|
304 | return { ...s, counter: s.counter + 1 }
|
305 | },
|
306 | path: ['mutation']
|
307 | },
|
308 | 'mutating previous state with non immutable type and returning new state without that property': {
|
309 | getState: () => ({ counter: 0 }),
|
310 | fn: s => {
|
311 | s.mutation = [1, 2, 3]
|
312 | return { counter: s.counter + 1 }
|
313 | },
|
314 | path: ['mutation']
|
315 | },
|
316 | 'mutating previous state with non immutable type and returning new simple state': {
|
317 | getState: () => ({ counter: 0 }),
|
318 | fn: s => {
|
319 | s.mutation = [1, 2, 3]
|
320 | return 1
|
321 | },
|
322 | path: ['mutation']
|
323 | },
|
324 | 'mutating previous state by deleting property and returning new state without that property': {
|
325 | getState: () => ({ counter: 0, toBeDeleted: true }),
|
326 | fn: s => {
|
327 | delete s.toBeDeleted
|
328 | return { counter: s.counter + 1 }
|
329 | },
|
330 | path: ['toBeDeleted']
|
331 | },
|
332 | 'mutating previous state by deleting nested property': {
|
333 | getState: () => ({ nested: { counter: 0, toBeDeleted: true }, foo: 1 }),
|
334 | fn: s => {
|
335 | delete s.nested.toBeDeleted
|
336 | return { nested: { counter: s.counter + 1 } }
|
337 | },
|
338 | path: ['nested', 'toBeDeleted']
|
339 | },
|
340 | 'update reference': {
|
341 | getState: () => ({ foo: {} }),
|
342 | fn: s => {
|
343 | s.foo = {}
|
344 | return s
|
345 | },
|
346 | path: ['foo']
|
347 | },
|
348 | 'cannot ignore root state': {
|
349 | getState: () => ({ foo: {} }),
|
350 | fn: s => {
|
351 | s.foo = {}
|
352 | return s
|
353 | },
|
354 | middlewareOptions: {
|
355 | ignoredPaths: ['']
|
356 | },
|
357 | path: ['foo']
|
358 | },
|
359 | 'catching state mutation in non-ignored branch': {
|
360 | getState: () => ({
|
361 | foo: {
|
362 | bar: [1, 2]
|
363 | },
|
364 | boo: {
|
365 | yah: [1, 2]
|
366 | }
|
367 | }),
|
368 | fn: s => {
|
369 | s.foo.bar.push(3)
|
370 | s.boo.yah.push(3)
|
371 | return s
|
372 | },
|
373 | middlewareOptions: {
|
374 | ignoredPaths: ['foo']
|
375 | },
|
376 | path: ['boo', 'yah', '2']
|
377 | }
|
378 | }
|
379 |
|
380 | Object.keys(mutations).forEach(mutationDesc => {
|
381 | describe(mutationDesc, () => {
|
382 | testCasesForMutation(mutations[mutationDesc])
|
383 | })
|
384 | })
|
385 |
|
386 | const nonMutations: Record<string, TestConfig> = {
|
387 | 'not doing anything': {
|
388 | getState: () => ({ a: 1, b: 2 }),
|
389 | fn: s => s
|
390 | },
|
391 | 'from undefined to something': {
|
392 | getState: () => undefined,
|
393 | fn: s => ({ foo: 'bar' })
|
394 | },
|
395 | 'returning same state': {
|
396 | getState: () => ({
|
397 | foo: {
|
398 | bar: [2, 3, 4],
|
399 | baz: 'baz'
|
400 | },
|
401 | stuff: []
|
402 | }),
|
403 | fn: s => s
|
404 | },
|
405 | 'returning a new state object with nested new string': {
|
406 | getState: () => ({
|
407 | foo: {
|
408 | bar: [2, 3, 4],
|
409 | baz: 'baz'
|
410 | },
|
411 | stuff: []
|
412 | }),
|
413 | fn: s => {
|
414 | return { ...s, foo: { ...s.foo, baz: 'changed!' } }
|
415 | }
|
416 | },
|
417 | 'returning a new state object with nested new array': {
|
418 | getState: () => ({
|
419 | foo: {
|
420 | bar: [2, 3, 4],
|
421 | baz: 'baz'
|
422 | },
|
423 | stuff: []
|
424 | }),
|
425 | fn: s => {
|
426 | return { ...s, foo: { ...s.foo, bar: [...s.foo.bar, 5] } }
|
427 | }
|
428 | },
|
429 | 'removing nested state': {
|
430 | getState: () => ({
|
431 | foo: {
|
432 | bar: [2, 3, 4],
|
433 | baz: 'baz'
|
434 | },
|
435 | stuff: []
|
436 | }),
|
437 | fn: s => {
|
438 | return { ...s, foo: {} }
|
439 | }
|
440 | },
|
441 | 'having a NaN in the state': {
|
442 | getState: () => ({ a: NaN, b: Number.NaN }),
|
443 | fn: s => s
|
444 | },
|
445 | 'ignoring branches from mutation detection': {
|
446 | getState: () => ({
|
447 | foo: {
|
448 | bar: 'bar'
|
449 | }
|
450 | }),
|
451 | fn: s => {
|
452 | s.foo.bar = 'baz'
|
453 | return s
|
454 | },
|
455 | middlewareOptions: {
|
456 | ignoredPaths: ['foo']
|
457 | }
|
458 | },
|
459 | 'ignoring nested branches from mutation detection': {
|
460 | getState: () => ({
|
461 | foo: {
|
462 | bar: [1, 2],
|
463 | boo: {
|
464 | yah: [1, 2]
|
465 | }
|
466 | }
|
467 | }),
|
468 | fn: s => {
|
469 | s.foo.bar.push(3)
|
470 | s.foo.boo.yah.push(3)
|
471 | return s
|
472 | },
|
473 | middlewareOptions: {
|
474 | ignoredPaths: ['foo.bar', 'foo.boo.yah']
|
475 | }
|
476 | },
|
477 | 'ignoring nested array indices from mutation detection': {
|
478 | getState: () => ({
|
479 | stuff: [{ a: 1 }, { a: 2 }]
|
480 | }),
|
481 | fn: s => {
|
482 | s.stuff[1].a = 3
|
483 | return s
|
484 | },
|
485 | middlewareOptions: {
|
486 | ignoredPaths: ['stuff.1']
|
487 | }
|
488 | }
|
489 | }
|
490 |
|
491 | Object.keys(nonMutations).forEach(nonMutationDesc => {
|
492 | describe(nonMutationDesc, () => {
|
493 | testCasesForNonMutation(nonMutations[nonMutationDesc])
|
494 | })
|
495 | })
|
496 | })
|