Add TestUtils and a generic way to isolate connected components
This commit is contained in:
parent
0fcd8cbb03
commit
bdf0aa57be
@ -1,5 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface IButtonProps {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
export class Button extends React.PureComponent {
|
export class Button extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,15 +2,16 @@ import React from 'react'
|
|||||||
|
|
||||||
export interface IInputProps {
|
export interface IInputProps {
|
||||||
name: string
|
name: string
|
||||||
type: 'text' | 'password' | 'hidden'
|
type: 'text' | 'password' | 'hidden' | 'submit'
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (name: string, value: string) => void
|
onChange?: (name: this['name'], value: string) => void
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Input extends React.PureComponent<IInputProps> {
|
export class Input extends React.PureComponent<IInputProps> {
|
||||||
handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (this.props.onChange) {
|
if (this.props.onChange) {
|
||||||
this.props.onChange(e.target.name, e.target.value)
|
this.props.onChange(this.props.name, e.target.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
@ -19,6 +20,8 @@ export class Input extends React.PureComponent<IInputProps> {
|
|||||||
name={this.props.name}
|
name={this.props.name}
|
||||||
type={this.props.type}
|
type={this.props.type}
|
||||||
value={this.props.value}
|
value={this.props.value}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
readOnly={!!this.props.readOnly}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,30 @@
|
|||||||
import assert from 'assert'
|
import assert from 'assert'
|
||||||
import axios, {AxiosInstance} from 'axios'
|
import axios from 'axios'
|
||||||
import {IHTTPClient} from './IHTTPClient'
|
import {IHTTPClient} from './IHTTPClient'
|
||||||
import {IHeader} from './IHeader'
|
import {IHeader} from './IHeader'
|
||||||
import {IMethod, IRoutes} from '@rondo/common'
|
import {IMethod, IRoutes} from '@rondo/common'
|
||||||
|
import {IRequest} from './IRequest'
|
||||||
|
import {IResponse} from './IResponse'
|
||||||
import {ITypedRequestParams} from './ITypedRequestParams'
|
import {ITypedRequestParams} from './ITypedRequestParams'
|
||||||
|
|
||||||
export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
|
interface IRequestor {
|
||||||
protected readonly axios: AxiosInstance
|
request: (params: IRequest) => Promise<IResponse>
|
||||||
|
}
|
||||||
|
|
||||||
constructor(baseURL = '', headers?: IHeader) {
|
export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
|
||||||
this.axios = axios.create({
|
protected readonly requestor: IRequestor
|
||||||
baseURL,
|
|
||||||
headers,
|
constructor(
|
||||||
|
protected readonly baseURL = '',
|
||||||
|
protected readonly headers?: IHeader,
|
||||||
|
) {
|
||||||
|
this.requestor = this.createRequestor()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createRequestor(): IRequestor {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: this.baseURL,
|
||||||
|
headers: this.headers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +40,7 @@ export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
|
|||||||
return params.params![key]
|
return params.params![key]
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await this.axios.request({
|
const response = await this.requestor.request({
|
||||||
method: params.method,
|
method: params.method,
|
||||||
url,
|
url,
|
||||||
params: params.query,
|
params: params.query,
|
||||||
|
|||||||
26
packages/client/src/http/HTTPClientMock.test.ts
Normal file
26
packages/client/src/http/HTTPClientMock.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {HTTPClientMock} from './HTTPClientMock'
|
||||||
|
import {getError} from '../test-utils'
|
||||||
|
|
||||||
|
describe('HTTPClientMock', () => {
|
||||||
|
|
||||||
|
const http = new HTTPClientMock<any>()
|
||||||
|
|
||||||
|
describe('mockAdd and mockClear', () => {
|
||||||
|
it('adds a mock', async () => {
|
||||||
|
const value = {a: 1}
|
||||||
|
http.mockAdd({
|
||||||
|
method: 'get',
|
||||||
|
url: '/test',
|
||||||
|
}, value)
|
||||||
|
|
||||||
|
const result = await http.get('/test')
|
||||||
|
expect(result).toBe(value)
|
||||||
|
|
||||||
|
http.mockClear()
|
||||||
|
|
||||||
|
const error = await getError(http.get('/test'))
|
||||||
|
expect(error.message).toMatch(/mock/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
44
packages/client/src/http/HTTPClientMock.ts
Normal file
44
packages/client/src/http/HTTPClientMock.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {HTTPClient} from './HTTPClient'
|
||||||
|
import {IRoutes} from '@rondo/common'
|
||||||
|
import {IRequest} from './IRequest'
|
||||||
|
import {IResponse} from './IResponse'
|
||||||
|
|
||||||
|
export class HTTPClientMock<T extends IRoutes> extends HTTPClient<T> {
|
||||||
|
mocks: {[key: string]: any} = {}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
createRequestor() {
|
||||||
|
return {
|
||||||
|
request: (r: IRequest): Promise<IResponse> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const key = this.serialize(r)
|
||||||
|
if (!this.mocks.hasOwnProperty(key)) {
|
||||||
|
setImmediate(() => {
|
||||||
|
reject(new Error('No mock for request: ' + key))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setImmediate(() => {
|
||||||
|
resolve({data: this.mocks[key]})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(r: IRequest) {
|
||||||
|
return JSON.stringify(r, null, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
mockAdd(r: IRequest, response: any) {
|
||||||
|
this.mocks[this.serialize(r)] = response
|
||||||
|
}
|
||||||
|
|
||||||
|
mockClear() {
|
||||||
|
this.mocks = []
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
8
packages/client/src/http/IRequest.ts
Normal file
8
packages/client/src/http/IRequest.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {IMethod} from '@rondo/common'
|
||||||
|
|
||||||
|
export interface IRequest {
|
||||||
|
method: IMethod,
|
||||||
|
url: string,
|
||||||
|
params?: {[key: string]: any},
|
||||||
|
data?: any,
|
||||||
|
}
|
||||||
3
packages/client/src/http/IResponse.ts
Normal file
3
packages/client/src/http/IResponse.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export interface IResponse {
|
||||||
|
data: any
|
||||||
|
}
|
||||||
@ -1,35 +1,27 @@
|
|||||||
import {ILoginState} from './LoginReducer'
|
import {ILoginState} from './LoginReducer'
|
||||||
import {LoginActions} from './LoginActions'
|
import {LoginActions} from './LoginActions'
|
||||||
import {LoginForm} from './LoginForm'
|
import {LoginForm} from './LoginForm'
|
||||||
import {bindActionCreators, Dispatch} from 'redux'
|
import {bindActionCreators} from 'redux'
|
||||||
import {connect} from 'react-redux'
|
import {IStateSelector} from '../redux'
|
||||||
import {IStateSlicer} from '../redux'
|
import {Connector} from '../redux/Connector'
|
||||||
|
|
||||||
export class LoginConnector<GlobalState> {
|
export class LoginConnector extends Connector {
|
||||||
constructor(
|
|
||||||
protected readonly loginActions: LoginActions,
|
|
||||||
protected readonly slice: IStateSlicer<GlobalState, ILoginState>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
connect() {
|
constructor(protected readonly loginActions: LoginActions) {
|
||||||
return connect(
|
super()
|
||||||
this.mapStateToProps,
|
|
||||||
this.mapDispatchToProps,
|
|
||||||
)(LoginForm)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mapStateToProps = (globalState: GlobalState) => {
|
connect<State>(getLocalState: IStateSelector<State, ILoginState>) {
|
||||||
const state = this.slice(globalState)
|
return this.wrap(
|
||||||
return {
|
getLocalState,
|
||||||
csrfToken: '123', // TODO this should be read from the state too
|
state => ({
|
||||||
error: state.error,
|
error: state.error,
|
||||||
user: state.user,
|
user: state.user,
|
||||||
}
|
}),
|
||||||
}
|
dispatch => ({
|
||||||
|
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
|
||||||
mapDispatchToProps = (dispatch: Dispatch) => {
|
}),
|
||||||
return {
|
LoginForm,
|
||||||
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {ICredentials} from '@rondo/common'
|
|||||||
|
|
||||||
export interface ILoginFormProps {
|
export interface ILoginFormProps {
|
||||||
error?: string
|
error?: string
|
||||||
csrfToken: string
|
|
||||||
onSubmit: (credentials: ICredentials) => void
|
onSubmit: (credentials: ICredentials) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,7 +24,7 @@ export class LoginForm extends React.PureComponent<
|
|||||||
handleSubmit = () => {
|
handleSubmit = () => {
|
||||||
this.props.onSubmit(this.state)
|
this.props.onSubmit(this.state)
|
||||||
}
|
}
|
||||||
handleChange = (name: keyof ILoginFormState, value: string) => {
|
handleChange = (name: string, value: string) => {
|
||||||
this.setState(
|
this.setState(
|
||||||
{[name]: value} as Pick<ILoginFormState, keyof ILoginFormState>,
|
{[name]: value} as Pick<ILoginFormState, keyof ILoginFormState>,
|
||||||
)
|
)
|
||||||
@ -33,21 +32,23 @@ export class LoginForm extends React.PureComponent<
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<Input
|
|
||||||
type='hidden'
|
|
||||||
name='_csrf'
|
|
||||||
value={this.props.csrfToken}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
name='username'
|
name='username'
|
||||||
type='text'
|
type='text'
|
||||||
|
onChange={this.handleChange}
|
||||||
value={this.state.username}
|
value={this.state.username}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name='password'
|
name='password'
|
||||||
type='password'
|
type='password'
|
||||||
|
onChange={this.handleChange}
|
||||||
value={this.state.password}
|
value={this.state.password}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
name='submit'
|
||||||
|
type='submit'
|
||||||
|
value='Log In'
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,5 +22,7 @@ export function Login(
|
|||||||
return {...state, user: undefined}
|
return {...state, user: undefined}
|
||||||
case LoginActionKeys.USER_LOG_IN_REJECTED:
|
case LoginActionKeys.USER_LOG_IN_REJECTED:
|
||||||
return {...state, error: action.error.message}
|
return {...state, error: action.error.message}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
packages/client/src/login/index.test.tsx
Normal file
22
packages/client/src/login/index.test.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as Feature from './'
|
||||||
|
// import React from 'react'
|
||||||
|
import {TestUtils} from '../test-utils'
|
||||||
|
|
||||||
|
const test = new TestUtils()
|
||||||
|
|
||||||
|
describe('Login', () => {
|
||||||
|
|
||||||
|
const loginActions = new Feature.LoginActions({} as any)
|
||||||
|
|
||||||
|
const t = test.withProvider({
|
||||||
|
reducers: {Login: Feature.Login},
|
||||||
|
state: {Login: {user: {id: 1}}},
|
||||||
|
connector: new Feature.LoginConnector(loginActions),
|
||||||
|
select: state => state.Login,
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render', () => {
|
||||||
|
t.render()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
4
packages/client/src/login/index.ts
Normal file
4
packages/client/src/login/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './LoginActions'
|
||||||
|
export * from './LoginConnector'
|
||||||
|
export * from './LoginForm'
|
||||||
|
export * from './LoginReducer'
|
||||||
@ -1,19 +1,9 @@
|
|||||||
import {createStore, applyMiddleware, Store} from 'redux'
|
import {createStore, applyMiddleware, Store} from 'redux'
|
||||||
import {PromiseMiddleware} from './PromiseMiddleware'
|
import {PromiseMiddleware} from './PromiseMiddleware'
|
||||||
|
import {getError} from '../test-utils'
|
||||||
|
|
||||||
describe('PromiseMiddleware', () => {
|
describe('PromiseMiddleware', () => {
|
||||||
|
|
||||||
async function getError(promise: Promise<any>): Promise<Error> {
|
|
||||||
let error: Error
|
|
||||||
try {
|
|
||||||
await promise
|
|
||||||
} catch (err) {
|
|
||||||
error = err
|
|
||||||
}
|
|
||||||
expect(error!).toBeTruthy()
|
|
||||||
return error!
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('throws an error when action types are the same', () => {
|
it('throws an error when action types are the same', () => {
|
||||||
expect(() => new PromiseMiddleware('a', 'a', 'a')).toThrowError()
|
expect(() => new PromiseMiddleware('a', 'a', 'a')).toThrowError()
|
||||||
|
|||||||
36
packages/client/src/redux/Connector.ts
Normal file
36
packages/client/src/redux/Connector.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {IStateSelector} from './IStateSelector'
|
||||||
|
import {connect, Omit} from 'react-redux'
|
||||||
|
import {Dispatch} from 'redux'
|
||||||
|
import {ComponentType} from 'react'
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/54277411
|
||||||
|
export abstract class Connector {
|
||||||
|
|
||||||
|
abstract connect<State, LocalState>(
|
||||||
|
getLocalState: IStateSelector<State, LocalState>,
|
||||||
|
): ComponentType<any>
|
||||||
|
|
||||||
|
protected wrap<
|
||||||
|
State,
|
||||||
|
LocalState,
|
||||||
|
StateProps,
|
||||||
|
DispatchProps,
|
||||||
|
Props
|
||||||
|
>(
|
||||||
|
getLocalState: IStateSelector<State, LocalState>,
|
||||||
|
mapStateToProps: (state: LocalState) => StateProps,
|
||||||
|
mapDispatchToProps: (dispatch: Dispatch) => DispatchProps,
|
||||||
|
Component: React.ComponentType<Props>,
|
||||||
|
): ComponentType<
|
||||||
|
Omit<Props, keyof Props & (keyof StateProps | keyof DispatchProps)>
|
||||||
|
> {
|
||||||
|
|
||||||
|
return connect(
|
||||||
|
(state: State) => {
|
||||||
|
const l = getLocalState(state)
|
||||||
|
return mapStateToProps(l)
|
||||||
|
},
|
||||||
|
mapDispatchToProps,
|
||||||
|
)(Component as any) as any
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/client/src/redux/IStateSelector.ts
Normal file
2
packages/client/src/redux/IStateSelector.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type IStateSelector<GlobalState, StateSlice>
|
||||||
|
= (state: GlobalState) => StateSlice
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export type IStateSlicer<GlobalState, StateSlice>
|
|
||||||
= (state: GlobalState) => StateSlice
|
|
||||||
|
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './IStateSlicer'
|
export * from './Connector'
|
||||||
|
export * from './IStateSelector'
|
||||||
|
|||||||
34
packages/client/src/redux/temp-test.tsx
Normal file
34
packages/client/src/redux/temp-test.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// import React from 'react'
|
||||||
|
// import {connect, Omit} from 'react-redux'
|
||||||
|
|
||||||
|
// interface IProps {
|
||||||
|
// a: number
|
||||||
|
// }
|
||||||
|
|
||||||
|
// class NumberDisplay extends React.PureComponent<IProps> {
|
||||||
|
// render() {
|
||||||
|
// return `${this.props.a}`
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Case 1: this works
|
||||||
|
// function mapStateToProps(state: any) {
|
||||||
|
// return {a: 1}
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const ConnectedNumberDisplay1 = connect(mapStateToProps)(NumberDisplay)
|
||||||
|
|
||||||
|
// export const display1 = <ConnectedNumberDisplay1 />
|
||||||
|
|
||||||
|
// // Case 2: this doesn't work
|
||||||
|
// function wrap<State, StateProps, ComponentProps>(
|
||||||
|
// mapState: (state: State) => StateProps,
|
||||||
|
// Component: React.ComponentType<ComponentProps>,
|
||||||
|
// ): React.ComponentType<
|
||||||
|
// Omit<ComponentProps, keyof StateProps & keyof ComponentProps>
|
||||||
|
// > {
|
||||||
|
// return connect(mapState)(Component as any) as any
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const ConnectedNumberDisplay2 = wrap(mapStateToProps, NumberDisplay)
|
||||||
|
// export const display2 = <ConnectedNumberDisplay2 />
|
||||||
81
packages/client/src/test-utils/TestUtils.tsx
Normal file
81
packages/client/src/test-utils/TestUtils.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import T from 'react-dom/test-utils'
|
||||||
|
import {Connector, IStateSelector} from '../redux'
|
||||||
|
import {Provider} from 'react-redux'
|
||||||
|
import {PromiseMiddleware} from '../middleware'
|
||||||
|
import {
|
||||||
|
Action,
|
||||||
|
AnyAction,
|
||||||
|
DeepPartial,
|
||||||
|
Middleware,
|
||||||
|
Reducer,
|
||||||
|
ReducersMapObject,
|
||||||
|
Store,
|
||||||
|
applyMiddleware,
|
||||||
|
combineReducers,
|
||||||
|
createStore,
|
||||||
|
} from 'redux'
|
||||||
|
|
||||||
|
interface IStoreParams<State, A extends Action<any>> {
|
||||||
|
reducer: Reducer<State, A>
|
||||||
|
state?: DeepPartial<State>
|
||||||
|
middleware?: Middleware[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRenderParams<State> {
|
||||||
|
reducers: ReducersMapObject<State, any>
|
||||||
|
state?: DeepPartial<State>
|
||||||
|
connector: Connector
|
||||||
|
select: IStateSelector<State, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestUtils {
|
||||||
|
render(jsx: JSX.Element) {
|
||||||
|
const component = T.renderIntoDocument(jsx) as React.Component<any>
|
||||||
|
const node = ReactDOM.findDOMNode(component)
|
||||||
|
return {component, node}
|
||||||
|
}
|
||||||
|
|
||||||
|
combineReducers<S>(reducers: ReducersMapObject<S, any>): Reducer<S>
|
||||||
|
combineReducers<S, A extends Action = AnyAction>(
|
||||||
|
reducers: ReducersMapObject<S, A>,
|
||||||
|
): Reducer<S, A> {
|
||||||
|
return combineReducers(reducers)
|
||||||
|
}
|
||||||
|
|
||||||
|
createStore<State, A extends Action<any> = AnyAction>(
|
||||||
|
params: IStoreParams<State, A>,
|
||||||
|
): Store<State, A> {
|
||||||
|
const middleware = params.middleware || [new PromiseMiddleware().handle]
|
||||||
|
return createStore(
|
||||||
|
params.reducer,
|
||||||
|
params.state,
|
||||||
|
applyMiddleware(...middleware),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
withProvider<State, A extends Action<any> = AnyAction>(
|
||||||
|
params: IRenderParams<State>,
|
||||||
|
) {
|
||||||
|
const store = this.createStore({
|
||||||
|
reducer: this.combineReducers(params.reducers),
|
||||||
|
state: params.state,
|
||||||
|
})
|
||||||
|
const Component = params.connector.connect(params.select)
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
return this.render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Component />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
store,
|
||||||
|
Component,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/client/src/test-utils/getError.ts
Normal file
10
packages/client/src/test-utils/getError.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export async function getError(promise: Promise<any>): Promise<Error> {
|
||||||
|
let error: Error
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
} catch (err) {
|
||||||
|
error = err
|
||||||
|
}
|
||||||
|
expect(error!).toBeTruthy()
|
||||||
|
return error!
|
||||||
|
}
|
||||||
2
packages/client/src/test-utils/index.ts
Normal file
2
packages/client/src/test-utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './TestUtils'
|
||||||
|
export * from './getError'
|
||||||
Loading…
x
Reference in New Issue
Block a user