zustand/tests/devtools.test.tsx
Devansh Jethmalani 99cddcf76f
breaking(types): Add higher kinded mutator types (#725)
* imaginary code that uses uSES

* revert backward compatibility code as this is not going to be v4

* use use-sync-external-store

* revert to react 17

* handle error by our own

* v4.0.0-alpha.2

* fix&refactor a bit

* update uSES experimental package

* remove error propagation hack

* update size snapshot

* update uSES and add dts

* split react.ts and no export wild

* split useStore impl

* context to follow the new api, export wild again

* v4.0.0-alpha.3

* add missing await

* update uSES

* update uSES

* uses uSES extra!

* v4.0.0-alpha.3

* fix(types): Rename from UseStore to UseBoundStore

* breaking(types): drop deprecated UseStore type

* breaking(core): drop v2 hook compatibility

* breaking(middleware): drop deprecated persist options

* breaking(core): drop deprecated store.subscribe with selector

* update uSES

* fix update uSES

* v4.0.0-alpha.5

* combine subscribe type

* intentional undefined type

* add useDebugValue

* update uSES

* update uSES types

* breaking(middleware): make persist options.removeItem required

* update uSES

* v4.0.0-alpha.6

* fix(readme): remove memoization section which is no longer valid with uSES

* feat(readme): add new createStore/useStore usage

* update useSES

* update uSES and deps

* v4.0.0-alpha.7

* update uSES

* update uSES

* shave bytes

* vanilla: add higher kinded mutator types

* persist: add higher kinded mutator types

* persist: try to minimize diff

* use `PopArgument` in vanilla too

* update uSES

* use overloads instead of `createWithState`

* avoid symbols

* add new types to middlewares

* add new types react

* add new types to context

* fix persist types

* add immer

* migrate middleware type tests

* fix react type, export `UseBoundStore`

* migrate vanilla type tests

* rename `_createStore` to `createStoreImpl`

* Default to no mutations in `StateCreator`

* migrate context.test.tsx

* fix devtools.test.tsx type erros

* context: remove callsignature in useStoreApi

* context: remove `UseContextStore` type

* context: fix useBoundStore type

* context: keep `UseContextStore` for tooltip just don't export it

* react: remove duplicate overload in create

* export `WithPersist`

* devtools: preserve try/catch

* devtools: preserve window check

* add a test case for v3 style create

* devtools: preverse test fix from base branch

* remove StoreApiWithFoo types, don't export WithFoo types

* style

* devtools: preverse `originalIsRecording` change

* fix bug in devtools

* empty commit

* 4.0.0-beta.1

* fix lint

* style

* export immer fix tests

* style, minor fixes

* devtools: fix test

* Update tests/devtools.test.tsx

* breaking(middleware/devtools): use official devtools extension types

* type object.create

* avoid emitting @redux-devtools/extension

* fix type with any

* refactor

* fix yarn lock

* temporary fix #829

* v4.0.0-beta.2

* fix lint

* lock date-fns version

* test middleware subtyping

* fix errors in conflict resolution

* lock testing-library/react alpha version

* more correct (and strict) persist types

* migrate tests

* wip release notes

* fix devtools merge with base

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>

* add a test case for persist with partialize option

* update readme

* fix lint

* immer: mutate `store.setState`

* fix devtools merge with base

* immer: fix mutations order

* changes in readme

* move and rename v4 migration md

* add `combine` usage in readme

* typos

* create separate md for typescript, add common recipes

* minor fixes

* devtools: minor type fix
(probably I copy pasted from persist and forgot to remove `U`)

* add more migrations

* context: fix import

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>

* devtools: minor refactor
rearrange code for better diff

* fix lint: run prettier

* wip

* uSES rc.1

* getServerState for #886, no types yet

* uSES v1

* devtools: remove deprecations and warnings

* fix tests

* v4.0.0-beta.2

* wip

* migrate tests

* persist: keep diff minimal

* fix merge in package.json and yarn.lock

* fix merge for persist

* don't use `import type`

* docs(typescript): add slices pattern

* fix selector & equals types
for inference with useCallback, see issue #812

* add test for setState with replace

* remove undefined selector overload

* make immer more generic

* make devtools replace-friendly and more correctly typed

* migrate tests

* make setState bivariant to make the state covariant

* devtools: return the result of `setState`

* devtools: make the fallback branch in `StoreSetStateWithAction` bivariant too
(I forgot to make this bivaraint)

* remove strict replace

* fix lint

Co-authored-by: daishi <daishi@axlight.com>
Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
2022-04-18 09:14:00 +09:00

528 lines
16 KiB
TypeScript

import { devtools, redux } from 'zustand/middleware'
import create, { StoreApi } from 'zustand/vanilla'
let extensionSubscriber: ((message: any) => void) | undefined
const extension = {
subscribe: jest.fn((f) => {
extensionSubscriber = f
return () => {}
}),
unsubscribe: jest.fn(),
send: jest.fn(),
init: jest.fn(),
error: jest.fn(),
}
const extensionConnector = { connect: jest.fn(() => extension) }
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
beforeEach(() => {
extensionConnector.connect.mockClear()
extension.subscribe.mockClear()
extension.unsubscribe.mockClear()
extension.send.mockClear()
extension.init.mockClear()
extension.error.mockClear()
extensionSubscriber = undefined
})
it('connects to the extension by passing the options and initializes', () => {
const options = { name: 'test', foo: 'bar' }
const initialState = { count: 0 }
create(devtools(() => initialState, options))
expect(extensionConnector.connect).toHaveBeenLastCalledWith(options)
expect(extension.init).toHaveBeenLastCalledWith(initialState)
})
describe('If there is no extension installed...', () => {
let savedDEV: boolean
beforeAll(() => {
savedDEV = __DEV__
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined
})
afterAll(() => {
__DEV__ = savedDEV
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
})
it('does not throw', () => {
__DEV__ = false
expect(() => {
create(devtools(() => ({ count: 0 })))
}).not.toThrow()
})
it('[DEV-ONLY] warns in dev env', () => {
__DEV__ = true
const originalConsoleWarn = console.warn
console.warn = jest.fn()
create(devtools(() => ({ count: 0 })))
expect(console.warn).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Please install/enable Redux devtools extension'
)
console.warn = originalConsoleWarn
})
it('[PRD-ONLY] does not warn if not in dev env', () => {
__DEV__ = false
const consoleWarn = jest.spyOn(console, 'warn')
create(devtools(() => ({ count: 0 })))
expect(consoleWarn).not.toBeCalled()
consoleWarn.mockRestore()
})
})
describe('When state changes...', () => {
it("sends { type: setStateName || 'anonymous` } as the action with current state", () => {
const api = create(
devtools(() => ({ count: 0, foo: 'bar' }), { name: 'testOptionsName' })
)
api.setState({ count: 10 }, false, 'testSetStateName')
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'testSetStateName' },
{ count: 10, foo: 'bar' }
)
api.setState({ count: 5, foo: 'baz' }, true)
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'anonymous' },
{ count: 5, foo: 'baz' }
)
})
})
describe('when it receives an message of type...', () => {
describe('ACTION...', () => {
it('does nothing', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
const setState = jest.spyOn(api, 'setState')
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: '{ "type": "INCREMENT" }',
})
expect(api.getState()).toBe(initialState)
expect(setState).not.toBeCalled()
})
it('unless action type is __setState', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: '{ "type": "__setState", "state": { "foo": "bar" } }',
})
expect(api.getState()).toStrictEqual({ ...initialState, foo: 'bar' })
})
it('does nothing even if there is `api.dispatch`', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
;(api as any).dispatch = jest.fn()
const setState = jest.spyOn(api, 'setState')
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: '{ "type": "INCREMENT" }',
})
expect(api.getState()).toBe(initialState)
expect(setState).not.toBeCalled()
expect((api as any).dispatch).not.toBeCalled()
})
it('dispatches with `api.dispatch` when `api.dispatchFromDevtools` is set to true', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
;(api as any).dispatch = jest.fn()
;(api as any).dispatchFromDevtools = true
const setState = jest.spyOn(api, 'setState')
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: '{ "type": "INCREMENT" }',
})
expect(api.getState()).toBe(initialState)
expect(setState).not.toBeCalled()
expect((api as any).dispatch).toHaveBeenLastCalledWith({
type: 'INCREMENT',
})
})
it('does not throw for unsupported payload', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
;(api as any).dispatch = jest.fn()
;(api as any).dispatchFromDevtools = true
const setState = jest.spyOn(api, 'setState')
const originalConsoleError = console.error
console.error = jest.fn()
expect(() => {
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: 'this.increment()',
})
}).not.toThrow()
expect(console.error).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Could not parse the received json',
(() => {
try {
JSON.parse('this.increment()')
} catch (e) {
return e
}
})()
)
expect(() => {
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: { name: 'increment', args: [] },
})
}).not.toThrow()
expect(console.error).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Unsupported action format'
)
expect(api.getState()).toBe(initialState)
expect(setState).not.toBeCalled()
expect((api as any).dispatch).not.toBeCalled()
console.error = originalConsoleError
})
})
describe('DISPATCH and payload of type...', () => {
it('RESET, it inits with initial state', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
api.setState({ count: 1 })
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'RESET' },
})
expect(api.getState()).toStrictEqual(initialState)
expect(extension.init).toHaveBeenLastCalledWith(initialState)
expect(extension.send).not.toBeCalled()
})
it('COMMIT, it inits with current state', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
api.setState({ count: 2 })
const currentState = api.getState()
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'COMMIT' },
})
expect(extension.init).toHaveBeenLastCalledWith(currentState)
expect(extension.send).not.toBeCalled()
})
describe('ROLLBACK...', () => {
it('it updates state without recording and inits with `message.state`', () => {
const initialState = { count: 0, increment: () => {} }
const api = create(devtools(() => initialState))
const newState = { foo: 'bar' }
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'ROLLBACK' },
state: JSON.stringify(newState),
})
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
expect(extension.init).toHaveBeenLastCalledWith({
...initialState,
...newState,
})
expect(extension.send).not.toBeCalled()
})
it('does not throw for unparsable `message.state`', () => {
const increment = () => {}
const initialState = { count: 0, increment }
const api = create(devtools(() => initialState))
const originalConsoleError = console.error
console.error = jest.fn()
extension.init.mockClear()
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'ROLLBACK' },
state: 'foobar',
})
expect(console.error).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Could not parse the received json',
(() => {
try {
JSON.parse('foobar')
} catch (e) {
return e
}
})()
)
expect(api.getState()).toBe(initialState)
expect(extension.init).not.toBeCalled()
expect(extension.send).not.toBeCalled()
console.error = originalConsoleError
})
})
describe('JUMP_TO_STATE...', () => {
const increment = () => {}
it('it updates state without recording with `message.state`', () => {
const initialState = { count: 0, increment }
const api = create(devtools(() => initialState))
const newState = { foo: 'bar' }
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'JUMP_TO_STATE' },
state: JSON.stringify(newState),
})
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
expect(extension.send).not.toBeCalled()
})
it('does not throw for unparsable `message.state`', () => {
const initialState = { count: 0, increment: () => {} }
const api = create(devtools(() => initialState))
const originalConsoleError = console.error
console.error = jest.fn()
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'JUMP_TO_STATE' },
state: 'foobar',
})
expect(console.error).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Could not parse the received json',
(() => {
try {
JSON.parse('foobar')
} catch (e) {
return e
}
})()
)
expect(api.getState()).toBe(initialState)
expect(extension.send).not.toBeCalled()
console.error = originalConsoleError
})
})
describe('JUMP_TO_ACTION...', () => {
it('it updates state without recording with `message.state`', () => {
const initialState = { count: 0, increment: () => {} }
const api = create(devtools(() => initialState))
const newState = { foo: 'bar' }
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'JUMP_TO_ACTION' },
state: JSON.stringify(newState),
})
expect(api.getState()).toStrictEqual({ ...initialState, ...newState })
expect(extension.send).not.toBeCalled()
})
it('does not throw for unparsable `message.state`', () => {
const increment = () => {}
const initialState = { count: 0, increment }
const api = create(devtools(() => initialState))
const originalConsoleError = console.error
console.error = jest.fn()
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'JUMP_TO_ACTION' },
state: 'foobar',
})
expect(console.error).toHaveBeenLastCalledWith(
'[zustand devtools middleware] Could not parse the received json',
(() => {
try {
JSON.parse('foobar')
} catch (e) {
return e
}
})()
)
expect(api.getState()).toBe(initialState)
expect(extension.send).not.toBeCalled()
console.error = originalConsoleError
})
})
it('IMPORT_STATE, it updates state without recording and inits the last computedState', () => {
const initialState = { count: 0, increment: () => {} }
const api = create(devtools(() => initialState))
const nextLiftedState = {
computedStates: [{ state: { count: 4 } }, { state: { count: 5 } }],
}
extension.send.mockClear()
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: {
type: 'IMPORT_STATE',
nextLiftedState,
},
})
expect(api.getState()).toStrictEqual({
...initialState,
...nextLiftedState.computedStates.slice(-1)[0]?.state,
})
expect(extension.send).toHaveBeenLastCalledWith(null, nextLiftedState)
})
it('PAUSE_RECORDING, it toggles the sending of actions', () => {
const api = create(devtools(() => ({ count: 0 })))
api.setState({ count: 1 }, false, 'increment')
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'increment' },
{ count: 1 }
)
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'PAUSE_RECORDING' },
})
api.setState({ count: 2 }, false, 'increment')
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'increment' },
{ count: 1 }
)
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'PAUSE_RECORDING' },
})
api.setState({ count: 3 }, false, 'increment')
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'increment' },
{ count: 3 }
)
})
})
})
describe('with redux middleware', () => {
let api: StoreApi<{
count: number
dispatch: (
action: { type: 'INCREMENT' } | { type: 'DECREMENT' }
) => { type: 'INCREMENT' } | { type: 'DECREMENT' }
}>
it('works as expected', () => {
api = create(
devtools(
redux(
(
{ count },
{ type }: { type: 'INCREMENT' } | { type: 'DECREMENT' }
) => ({
count: count + (type === 'INCREMENT' ? 1 : -1),
}),
{ count: 0 }
)
)
)
;(api as any).dispatch({ type: 'INCREMENT' })
;(api as any).dispatch({ type: 'INCREMENT' })
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: JSON.stringify({ type: 'DECREMENT' }),
})
expect(extension.init.mock.calls).toMatchObject([[{ count: 0 }]])
expect(extension.send.mock.calls).toMatchObject([
[{ type: 'INCREMENT' }, { count: 1 }],
[{ type: 'INCREMENT' }, { count: 2 }],
[{ type: 'DECREMENT' }, { count: 1 }],
])
expect(api.getState()).toMatchObject({ count: 1 })
})
it('[DEV-ONLY] warns about misusage', () => {
const originalConsoleWarn = console.warn
console.warn = jest.fn()
;(api as any).dispatch({ type: '__setState' as any })
expect(console.warn).toHaveBeenLastCalledWith(
'[zustand devtools middleware] "__setState" action type is reserved ' +
'to set state from the devtools. Avoid using it.'
)
console.warn = originalConsoleWarn
})
})
it('works in non-browser env', () => {
const originalWindow = global.window
global.window = undefined as any
expect(() => {
create(devtools(() => ({ count: 0 })))
}).not.toThrow()
global.window = originalWindow
})
it('works in react native env', () => {
const originalWindow = global.window
global.window = {} as any
expect(() => {
create(devtools(() => ({ count: 0 })))
}).not.toThrow()
global.window = originalWindow
})
it('preserves isRecording after setting from devtools', () => {
const api = create(devtools(() => ({ count: 0 })))
;(extensionSubscriber as (message: any) => void)({
type: 'DISPATCH',
payload: { type: 'PAUSE_RECORDING' },
})
;(extensionSubscriber as (message: any) => void)({
type: 'ACTION',
payload: '{ "type": "__setState", "state": { "foo": "bar" } }',
})
api.setState({ count: 1 })
expect(extension.send).not.toBeCalled()
})