rewrite devtools (#675)

* rewrite `devtools`

* style

* merge state instead of replace, fix lint

* fix lint, don't use `Array.prototype.at`

* fix typo

* more lint fixing

* feat: allow setting state from the devtools

* deference dispatch in the store to use latest `api.dispatch`
that gets replaced in devtools middleware
This commit is contained in:
Devansh Jethmalani 2021-12-03 18:42:31 +05:30 committed by GitHub
parent c8243e58c0
commit 58baf27476
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 684 additions and 95 deletions

View File

@ -1,12 +1,18 @@
import { GetState, PartialState, SetState, State, StoreApi } from '../vanilla'
const DEVTOOLS = Symbol()
type DevtoolsType = {
/**
* @deprecated along with `api.devtools`, `api.devtools.prefix` is deprecated.
* We no longer prefix the actions/names, because the `name` option already
* creates a separate instance of devtools for each store.
*/
prefix: string
subscribe: (dispatch: any) => () => void
unsubscribe: () => void
send: (action: string, state: any) => void
send: {
(action: string | { type: unknown }, state: any): void
(action: null, liftedState: any): void
}
init: (state: any) => void
error: (payload: any) => void
}
@ -20,12 +26,18 @@ export type NamedSet<T extends State> = {
>(
partial: PartialState<T, K1, K2, K3, K4>,
replace?: boolean,
name?: string
name?: string | { type: unknown }
): void
}
export type StoreApiWithDevtools<T extends State> = StoreApi<T> & {
setState: NamedSet<T>
/**
* @deprecated `devtools` property on the store is deprecated
* it will be removed in the next major.
* You shouldn't interact with the extension directly. But in case you still want to
* you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly
*/
devtools?: DevtoolsType
}
@ -41,6 +53,7 @@ export const devtools =
| string
| {
name?: string
anonymousActionType?: string
serialize?: {
options:
| boolean
@ -61,109 +74,204 @@ export const devtools =
(
set: CustomSetState,
get: CustomGetState,
api: CustomStoreApi & StoreApiWithDevtools<S> & { dispatch?: unknown }
api: CustomStoreApi &
StoreApiWithDevtools<S> & {
dispatch?: unknown
dispatchFromDevtools?: boolean
}
): S => {
let extension
try {
extension =
(window as any).__REDUX_DEVTOOLS_EXTENSION__ ||
(window as any).top.__REDUX_DEVTOOLS_EXTENSION__
} catch {
// ignored
}
const devtoolsOptions =
options === undefined
? { name: undefined, anonymousActionType: undefined }
: typeof options === 'string'
? { name: options }
: options
if (!extension) {
const extensionConnector =
(window as any).__REDUX_DEVTOOLS_EXTENSION__ ??
(window as any).top.__REDUX_DEVTOOLS_EXTENSION__
if (!extensionConnector) {
if (
process.env.NODE_ENV === 'development' &&
typeof window !== 'undefined'
) {
console.warn('Please install/enable Redux devtools extension')
console.warn(
'[zustand devtools middleware] Please install/enable Redux devtools extension'
)
}
delete api.devtools
return fn(set, get, api)
}
const namedSet: NamedSet<S> = (state, replace, name) => {
set(state, replace)
if (!api.dispatch && api.devtools) {
api.devtools.send(api.devtools.prefix + (name || 'action'), get())
}
}
api.setState = namedSet
const initialState = fn(namedSet, get, api)
if (!api.devtools) {
const savedSetState = api.setState
api.setState = <
K1 extends keyof S = keyof S,
K2 extends keyof S = K1,
K3 extends keyof S = K2,
K4 extends keyof S = K3
>(
state: PartialState<S, K1, K2, K3, K4>,
replace?: boolean
) => {
const newState = api.getState()
if (state !== newState) {
savedSetState(state, replace)
if (state !== (newState as any)[DEVTOOLS] && api.devtools) {
api.devtools.send(api.devtools.prefix + 'setState', api.getState())
}
let extension = Object.create(extensionConnector.connect(devtoolsOptions))
// We're using `Object.defineProperty` to set `prefix`, so if extensionConnector.connect
// returns the same reference we'd get cannot redefine property prefix error
// hence we `Object.create` to make a new reference
let didWarnAboutDevtools = false
Object.defineProperty(api, 'devtools', {
get: () => {
if (!didWarnAboutDevtools) {
console.warn(
'[zustand devtools middleware] `devtools` property on the store is deprecated ' +
'it will be removed in the next major.\n' +
"You shouldn't interact with the extension directly. But in case you still want to " +
'you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly'
)
didWarnAboutDevtools = true
}
}
options = typeof options === 'string' ? { name: options } : options
const connection = (api.devtools = extension.connect({ ...options }))
connection.prefix = options?.name ? `${options.name} > ` : ''
connection.subscribe((message: any) => {
if (message.type === 'ACTION' && message.payload) {
try {
api.setState(JSON.parse(message.payload))
} catch (e) {
console.error(
'please dispatch a serializable value that JSON.parse() support\n',
e
)
}
} else if (message.type === 'DISPATCH' && message.state) {
const jumpState =
message.payload.type === 'JUMP_TO_ACTION' ||
message.payload.type === 'JUMP_TO_STATE'
const newState = api.getState()
;(newState as any)[DEVTOOLS] = JSON.parse(message.state)
return extension
},
set: (value) => {
if (!didWarnAboutDevtools) {
console.warn(
'[zustand devtools middleware] `api.devtools` is deprecated, ' +
'it will be removed in the next major.\n' +
"You shouldn't interact with the extension directly. But in case you still want to " +
'you can patch `window.__REDUX_DEVTOOLS_EXTENSION__` directly'
)
didWarnAboutDevtools = true
}
extension = value
},
})
if (!api.dispatch && !jumpState) {
api.setState(newState)
} else if (jumpState) {
api.setState((newState as any)[DEVTOOLS])
} else {
savedSetState(newState)
}
} else if (
message.type === 'DISPATCH' &&
message.payload?.type === 'COMMIT'
) {
connection.init(api.getState())
} else if (
message.type === 'DISPATCH' &&
message.payload?.type === 'IMPORT_STATE'
) {
const actions = message.payload.nextLiftedState?.actionsById
const computedStates =
message.payload.nextLiftedState?.computedStates || []
let didWarnAboutPrefix = false
Object.defineProperty(extension, 'prefix', {
get: () => {
if (!didWarnAboutPrefix) {
console.warn(
'[zustand devtools middleware] along with `api.devtools`, `api.devtools.prefix` is deprecated.\n' +
'We no longer prefix the actions/names' +
devtoolsOptions.name ===
undefined
? ', pass the `name` option to create a separate instance of devtools for each store.'
: ', because the `name` option already creates a separate instance of devtools for each store.'
)
didWarnAboutPrefix = true
}
return ''
},
set: () => {
if (!didWarnAboutPrefix) {
console.warn(
'[zustand devtools middleware] along with `api.devtools`, `api.devtools.prefix` is deprecated.\n' +
'We no longer prefix the actions/names' +
devtoolsOptions.name ===
undefined
? ', pass the `name` option to create a separate instance of devtools for each store.'
: ', because the `name` option already creates a separate instance of devtools for each store.'
)
didWarnAboutPrefix = true
}
},
})
computedStates.forEach(
({ state }: { state: PartialState<S> }, index: number) => {
const action = actions[index] || 'No action found'
let isRecording = true
;(api.setState as NamedSet<S>) = (state, replace, nameOrAction) => {
set(state, replace)
if (!isRecording) return
extension.send(
nameOrAction === undefined
? { type: devtoolsOptions.anonymousActionType ?? 'anonymous' }
: typeof nameOrAction === 'string'
? { type: nameOrAction }
: nameOrAction,
get()
)
}
const setStateFromDevtools: SetState<S> = (...a) => {
isRecording = false
set(...a)
isRecording = true
}
if (index === 0) {
connection.init(state)
} else {
savedSetState(state)
connection.send(action, api.getState())
const initialState = fn(api.setState, get, api)
extension.init(initialState)
extension.subscribe((message: any) => {
switch (message.type) {
case 'ACTION':
return parseJsonThen<{ type: unknown; state?: PartialState<S> }>(
message.payload,
(action) => {
if (action.type === '__setState') {
setStateFromDevtools(action.state as PartialState<S>)
return
}
if (!api.dispatchFromDevtools) return
if (typeof api.dispatch !== 'function') return
;(api.dispatch as any)(action)
}
)
case 'DISPATCH':
switch (message.payload.type) {
case 'RESET':
setStateFromDevtools(initialState)
return extension.init(api.getState())
case 'COMMIT':
return extension.init(api.getState())
case 'ROLLBACK':
return parseJsonThen<S>(message.state, (state) => {
setStateFromDevtools(state)
extension.init(api.getState())
})
case 'JUMP_TO_STATE':
case 'JUMP_TO_ACTION':
return parseJsonThen<S>(message.state, (state) => {
setStateFromDevtools(state)
})
case 'IMPORT_STATE': {
const { nextLiftedState } = message.payload
const lastComputedState =
nextLiftedState.computedStates.slice(-1)[0]?.state
if (!lastComputedState) return
setStateFromDevtools(lastComputedState)
extension.send(null, nextLiftedState)
return
}
case 'PAUSE_RECORDING':
return (isRecording = !isRecording)
}
return
}
})
if (api.dispatchFromDevtools && typeof api.dispatch === 'function') {
let didWarnAboutReservedActionType = false
const originalDispatch = api.dispatch
api.dispatch = (...a: any[]) => {
if (a[0].type === '__setState' && !didWarnAboutReservedActionType) {
console.warn(
'[zustand devtools middleware] "__setState" action type is reserved ' +
'to set state from the devtools. Avoid using it.'
)
didWarnAboutReservedActionType = true
}
})
connection.init(initialState)
;(originalDispatch as any)(...a)
}
}
return initialState
}
const parseJsonThen = <T>(stringified: string, f: (parsed: T) => void) => {
let parsed: T | undefined
try {
parsed = JSON.parse(stringified)
} catch (e) {
console.error(
'[zustand devtools middleware] Could not parse the received json',
e
)
}
if (parsed !== undefined) f(parsed as T)
}

View File

@ -1,4 +1,5 @@
import { GetState, SetState, State, StoreApi } from '../vanilla'
import { NamedSet } from './devtools'
type DevtoolsType = {
prefix: string
@ -14,6 +15,7 @@ export type StoreApiWithRedux<
A extends { type: unknown }
> = StoreApi<T & { dispatch: (a: A) => A }> & {
dispatch: (a: A) => A
dispatchFromDevtools: boolean
}
export const redux =
@ -27,11 +29,10 @@ export const redux =
api: StoreApiWithRedux<S, A> & { devtools?: DevtoolsType }
): S & { dispatch: (a: A) => A } => {
api.dispatch = (action: A) => {
set((state: S) => reducer(state, action))
if (api.devtools) {
api.devtools.send(api.devtools.prefix + action.type, get())
}
;(set as NamedSet<S>)((state: S) => reducer(state, action), false, action)
return action
}
return { dispatch: api.dispatch, ...initial }
api.dispatchFromDevtools = true
return { dispatch: (...a) => api.dispatch(...a), ...initial }
}

480
tests/devtools.test.tsx Normal file
View File

@ -0,0 +1,480 @@
import { devtools, redux } from 'zustand/middleware'
import create 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...', () => {
beforeAll(() => {
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined
})
afterAll(() => {
;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector
})
it('does not throw', () => {
expect(() => {
create(devtools(() => ({ count: 0 })))
}).not.toThrow()
})
it('warns in dev env', () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
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'
)
process.env.NODE_ENV = originalNodeEnv
console.warn = originalConsoleWarn
})
it('does not warn if not in dev env', () => {
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 }, true)
expect(extension.send).toHaveBeenLastCalledWith(
{ type: 'anonymous' },
{ count: 5 }
)
})
})
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.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.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.dispatch = jest.fn()
api.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.dispatch).toHaveBeenLastCalledWith({ type: 'INCREMENT' })
})
it('does not throw for unsupported payload', () => {
const initialState = { count: 0 }
const api = create(devtools(() => initialState))
api.dispatch = jest.fn()
api.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] Could not parse the received json',
(() => {
try {
JSON.parse({ name: 'increment', args: [] } as unknown as string)
} catch (e) {
return e
}
})()
)
expect(api.getState()).toBe(initialState)
expect(setState).not.toBeCalled()
expect(api.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 }
)
})
})
})
it('works with redux middleware', () => {
const api = create(
devtools(
redux(
({ count }, { type }: { type: 'INCREMENT' | 'DECREMENT' }) => ({
count: count + (type === 'INCREMENT' ? 1 : -1),
}),
{ count: 0 }
)
)
)
api.dispatch({ type: 'INCREMENT' })
api.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 })
const originalConsoleWarn = console.warn
console.warn = jest.fn()
api.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
})