[CORL-331] Better tests with types (#2270)

* feat: suspending, banning, now propogation

* feat: new mutation api with hooks support

* feat: better types in tests and refactor

* fix: lint
This commit is contained in:
Kiwi
2019-04-23 19:46:14 +00:00
committed by Wyatt Johnson
parent dbbc1af42e
commit 5150cdf60e
136 changed files with 2546 additions and 2465 deletions
@@ -114,8 +114,8 @@ function valueToState(
// Start from the first unit,
// keep first unit if value is set to 0,
// otherwise use better matching unit if the value is fully dividable by the unit.
unit = units.reduce(
(x, cur) => (parsed % cur === 0 && parsed !== 0 ? cur : x)
unit = units.reduce((x, cur) =>
parsed % cur === 0 && parsed !== 0 ? cur : x
);
}
// Compute new value relative to the selected unit.
@@ -6,9 +6,9 @@ import { PasswordField as PasswordFieldUI } from "talk-ui/components";
export interface Props
extends Omit<
PropTypesOf<typeof PasswordFieldUI>,
"showPasswordTitle" | "hidePasswordTitle"
> {}
PropTypesOf<typeof PasswordFieldUI>,
"showPasswordTitle" | "hidePasswordTitle"
> {}
const PasswordField: StatelessComponent<Props> = props => (
<Localized
@@ -26,10 +26,10 @@ import { PostMessageService } from "../postMessage";
import SendPymReady from "./SendPymReady";
import { TalkContext, TalkContextProvider } from "./TalkContext";
export type InitLocalState = ((
export type InitLocalState = (
environment: Environment,
context: TalkContext
) => void | Promise<void>);
) => void | Promise<void>;
interface CreateContextArguments {
/** Locales that the user accepts, usually `navigator.languages`. */
@@ -104,7 +104,7 @@ function createRelayEnvironment() {
return { environment, tokenGetter };
}
function createRestAPI(tokenGetter: (() => string)) {
function createRestAPI(tokenGetter: () => string) {
return new RestClient("/api", tokenGetter);
}
@@ -5,11 +5,7 @@ import React from "react";
interface Props {
form: FormApi;
rootKey?: string;
children: (
params: {
onInitValues: (data: any) => void;
}
) => React.ReactNode;
children: (params: { onInitValues: (data: any) => void }) => React.ReactNode;
}
/**
@@ -7,11 +7,9 @@ import { AddSubmitHook, SubmitHook, SubmitHookContextProvider } from "./";
interface Props {
onExecute: (data: any, form: FormApi) => Promise<void>;
children: (
params: {
onSubmit: (settings: any, form: FormApi) => void;
}
) => React.ReactNode;
children: (params: {
onSubmit: (settings: any, form: FormApi) => void;
}) => React.ReactNode;
}
/**
@@ -3,7 +3,7 @@ export interface BundledLocales {
}
export interface LoadableLocales {
[locale: string]: (() => Promise<string>);
[locale: string]: () => Promise<string>;
}
/**
@@ -15,6 +15,8 @@ import { TalkContext, withContext } from "../bootstrap";
* and the signature (input: I) => Promise<R>. Calling
* this will call the specified `commit` callback with
* the Relay `environment` provided by the context.
*
* @deprecated
*/
function createMutationContainer<T extends string, I, R>(
propName: T,
@@ -9,8 +9,8 @@ type RecordSourceProxy<T> = T extends object
readonly [P in keyof T]?: T[P] extends Array<infer U>
? ReadonlyArray<RecordSourceProxy<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<RecordSourceProxy<V>>
: RecordSourceProxy<T[P]>
? ReadonlyArray<RecordSourceProxy<V>>
: RecordSourceProxy<T[P]>
}
: T;
@@ -35,14 +35,18 @@ export type MutationProp<
> = T extends Mutation<any, infer I, infer R>
? Parameters<T["commit"]>[1] extends undefined
? () => R
: keyof Parameters<T["commit"]>[1] extends never ? () => R : (input: I) => R
: keyof Parameters<T["commit"]>[1] extends never
? () => R
: (input: I) => R
: never;
type RemoveClientMutationID<T> = T extends Promise<infer U>
? Promise<
U extends { clientMutationId: any } ? Omit<U, "clientMutationId"> : U
>
: T extends { clientMutationId: any } ? Omit<T, "clientMutationId"> : T;
: T extends { clientMutationId: any }
? Omit<T, "clientMutationId">
: T;
export function createMutation<N extends string, I, R>(
name: N,
@@ -10,21 +10,18 @@ export default function useLoadMore(
count: number
): [() => void, boolean] {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const loadMore = useCallback(
() => {
if (!relay.hasMore() || relay.isLoading()) {
return;
const loadMore = useCallback(() => {
if (!relay.hasMore() || relay.isLoading()) {
return;
}
setIsLoadingMore(true);
relay.loadMore(count, error => {
setIsLoadingMore(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
setIsLoadingMore(true);
relay.loadMore(count, error => {
setIsLoadingMore(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
});
},
[relay]
);
});
}, [relay]);
return [loadMore, isLoadingMore];
}
@@ -15,34 +15,31 @@ export default function useRefetch<V = Variables>(
): [() => void, boolean] {
const [manualRefetchCount, setManualRefetchCount] = useState(0);
const [refetching, setRefetching] = useState(false);
useEffectAfterMount(
() => {
setRefetching(true);
const disposable = relay.refetchConnection(
10,
error => {
setRefetching(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
},
variables
);
return () => {
if (disposable) {
disposable.dispose();
useEffectAfterMount(() => {
setRefetching(true);
const disposable = relay.refetchConnection(
10,
error => {
setRefetching(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
};
},
[
relay,
manualRefetchCount,
...Object.keys(variables).reduce<any[]>((a, k) => {
a.push((variables as any)[k]);
return a;
}, []),
]
);
},
variables
);
return () => {
if (disposable) {
disposable.dispose();
}
};
}, [
relay,
manualRefetchCount,
...Object.keys(variables).reduce<any[]>((a, k) => {
a.push((variables as any)[k]);
return a;
}, []),
]);
return [() => setManualRefetchCount(manualRefetchCount + 1), refetching];
}
@@ -1,7 +1,7 @@
import createPymStorage from "./PymStorage";
class PymStub {
public listeners: Record<string, ((msg: string) => void)> = {};
public listeners: Record<string, (msg: string) => void> = {};
public messages: Array<{ key: string; value: string }> = [];
public type: string;
@@ -14,7 +14,7 @@ class PymStorage implements PromisifiedStorage {
/** A Map of requestID => {resolve, reject} */
private requests: Record<
string,
{ resolve: ((v: any) => void); reject: ((v: any) => void) }
{ resolve: (v: any) => void; reject: (v: any) => void }
> = {};
/** Requests method with parameters over pym. */
@@ -1,24 +1,46 @@
import { merge } from "lodash";
import { pureMerge } from "talk-common/utils";
import { DeepPartial } from "talk-framework/types";
/**
* Fixture prepares schema type to be used in fixtures.
* It adds an optional `__typename` to the schema type and
* marks fields as optional.
* Callbackify turns Fields e.g. `{a: string}`
* to also allow a callback `{a: string | () => string}`
*/
export type Fixture<T> = T extends object
export type Callbackify<T> = T extends object
?
| {
[P in keyof T]: T[P] extends Array<infer U>
? Array<Callbackify<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Callbackify<V>>
: Callbackify<T[P]>
}
| (() => {
[P in keyof T]: T[P] extends Array<infer U>
? Array<Callbackify<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Callbackify<V>>
: Callbackify<T[P]>
})
: T | (() => T);
/**
* WithTypename adds `__typename` to allowed props deeply.
*/
export type WithTypename<T> = T extends object
? {
// (cvle): We don't use & { __typename?: string } because for some reason
// typescript would allow field names that are not defined!
[P in keyof T | "__typename"]?: P extends keyof T
? T[P] extends Array<infer U>
? Array<Fixture<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Fixture<V>>
: Fixture<T[P]>
: string
}
[P in keyof T]: T[P] extends Array<infer U>
? Array<WithTypename<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<WithTypename<V>>
: WithTypename<T[P]>
} & { __typename?: string }
: T;
/**
* Fixture adds typenames and is deeply partial.
*/
export type Fixture<T> = DeepPartial<WithTypename<T>>;
/**
* createFixture lets you input the data of a schema object as deep partial
* including it's `__typename`, merged it with `base` and return it as the
@@ -26,9 +48,12 @@ export type Fixture<T> = T extends object
* to only include fields that exists in `data` and `base` though to
* type this it seems we need partial generic inferation support.
*/
export default function createFixture<T>(data: Fixture<T>, base?: T): T {
export default function createFixture<T>(
data: Fixture<T>,
base?: T
): WithTypename<T> {
if (base) {
return merge({}, base, data);
return pureMerge(base, data) as any;
}
return data as T;
return data as any;
}
@@ -1,4 +1,4 @@
import createFixture, { Fixture } from "./createFixture";
import createFixture, { Fixture, WithTypename } from "./createFixture";
/**
* createFixtures lets you input an array of data of a schema object as deep partial
@@ -10,6 +10,6 @@ import createFixture, { Fixture } from "./createFixture";
export default function createFixtures<T>(
data: Array<Fixture<T>>,
base?: T
): T[] {
return data.map(d => createFixture(d, base)) as T[];
): Array<WithTypename<T>> {
return data.map(d => createFixture(d, base)) as any;
}
@@ -1,16 +1,36 @@
import { identity, omit } from "lodash";
import sinon from "sinon";
import { Mutation } from "talk-framework/lib/relay/mutation";
import { Omit } from "talk-framework/types";
import { Fixture } from "./createFixture";
import { Resolver } from "./createTestRenderer";
export type NoClientMutationID<T> = T extends { clientMutationId: any }
? Omit<T, "clientMutationId">
: T;
export type MutationResult<T> = NoClientMutationID<Fixture<T>>;
export type MutationResultVariations<T> = T extends Resolver<any, infer R>
? MutationResult<R>
: never;
export type MutationResolverCallback<T extends Resolver<any, any>> = (data: {
variables: T extends Resolver<infer V, any>
? V extends { input: infer W }
? NoClientMutationID<W>
: never
: never;
callCount: number;
typecheck: (data: MutationResultVariations<T>) => MutationResultVariations<T>;
}) => MutationResultVariations<T>;
/**
* createMutationResolverStub makes it easier to write a SinonStub.
* Given a `ResolverType` from the Schema it'll provide types as well!.
*/
export default function createMutationResolverStub<
T extends Mutation<any, any, any>
>(
callback: (
variables: T extends Mutation<any, infer I, any> ? I : never,
callCount: number
) => T extends Mutation<any, any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
) {
T extends Resolver<any, any>
>(callback: MutationResolverCallback<T>) {
let callCount = 0;
const lastClientMutationIds: any[] = [];
const resolver = async (_: any, data: any) => {
@@ -18,7 +38,11 @@ export default function createMutationResolverStub<
expectAndFail(clientMutationId).toBeTruthy();
expectAndFail(lastClientMutationIds).not.toContain(clientMutationId);
lastClientMutationIds.push(clientMutationId);
const result = await callback(data.input, callCount++);
const result = await callback({
variables: omit(data.input, "clientMutationId"),
callCount: callCount++,
typecheck: identity,
});
expectAndFail(result.clientMutationId).toBeUndefined();
result.clientMutationId = clientMutationId;
return result;
@@ -1,17 +1,33 @@
import { identity } from "lodash";
import sinon from "sinon";
type Resolver<V, R> = (parent: any, args: V, context: any, info: any) => R;
import { Fixture } from "./createFixture";
import { Resolver } from "./createTestRenderer";
export type QueryResult<T> = Fixture<T>;
export type QueryResultVariations<
T extends Resolver<any, any>
> = T extends Resolver<any, infer R> ? QueryResult<R> : never;
export type QueryResolverCallback<T extends Resolver<any, any>> = (data: {
variables: T extends Resolver<infer V, any> ? V : never;
callCount: number;
typecheck: (data: QueryResultVariations<T>) => QueryResultVariations<T>;
}) => QueryResultVariations<T>;
/**
* createQueryResolverStub makes it easier to write a SinonStub.
* Given a `ResolverType` from the Schema it'll provide types as well!.
*/
export default function createQueryResolverStub<T extends Resolver<any, any>>(
callback: (
variables: T extends Resolver<infer V, any> ? V : never,
callCount: number
) => T extends Resolver<any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
callback: QueryResolverCallback<T>
) {
let callCount = 0;
return sinon
.stub()
.callsFake((_: any, data: any) => callback(data, callCount++));
return sinon.stub().callsFake((fallback: any, variables: any) => {
return callback({
variables: variables || fallback,
callCount: callCount++,
typecheck: identity,
});
});
}
@@ -28,7 +28,7 @@ export interface CreateRelayEnvironmentNetworkParams {
/** project name of graphql-config */
projectName: string;
/** graphql resolvers */
resolvers: IResolvers<any, any>;
resolvers?: IResolvers<any, any>;
/** If enabled, graphql responses will be logged to the console */
logNetwork?: boolean;
/** If enabled, graphql errors will be muted */
@@ -99,7 +99,7 @@ export default function createRelayEnvironment(
if (params.network) {
const schema = loadSchema(
params.network.projectName,
params.network.resolvers,
params.network.resolvers || {},
{ requireResolversForResolveType: false }
);
network = Network.create(
@@ -0,0 +1,84 @@
import { mapValues } from "lodash";
import { SinonStub } from "sinon";
import createMutationResolverStub, {
MutationResolverCallback,
} from "./createMutationResolverStub";
import createQueryResolverStub, {
QueryResolverCallback,
} from "./createQueryResolverStub";
import { Resolvers } from "./createTestRenderer";
export interface ResolversTemplate<T extends Resolvers = any> {
Query?: {
[P in keyof Required<T>["Query"]]: QueryResolverCallback<
Required<T>["Query"][P]
>
};
Mutation?: {
[P in keyof Required<T>["Mutation"]]: MutationResolverCallback<
Required<T>["Mutation"][P]
>
};
}
export interface ResolversStub<T extends Resolvers = any> {
Query?: { [P in keyof Required<T>["Query"]]: SinonStub };
Mutation?: { [P in keyof Required<T>["Mutation"]]: SinonStub };
}
function isSinonStub(v: any) {
return v.called !== undefined;
}
/**
* createResolversStub is a helper for creating resolvers.
*
* Instead of writing:
* ```ts
* {
* Query: {
* settings: createQueryResolverStub<QueryToSettingsResolver>(({ variables }) => settings)
* users: createQueryResolverStub<QueryToUsersResolver>(() => users)
* viewer: createQueryResolverStub<QueryToSettingsResolver>(() => viewer)
* },
* Mutation: {
* // Same goes for Mutations
* }
* },
* ```
*
* You can do
* ```ts
* createResolversStub<GQLResolver>({
* Query: {
* settings: ({ variables }) => settings
* users: () => users
* viewer: () => viewer
* },
* Mutation: {
* // Same goes for Mutations
* }
* }),
* ```
*/
export default function createResolversStub<T extends Resolvers = any>(
resolvers: ResolversTemplate<T>
): ResolversStub<T> {
const result: any = {};
if (resolvers.Query) {
result.Query = mapValues(resolvers.Query, v =>
typeof v === "function" && !isSinonStub(v)
? createQueryResolverStub(v)
: v
);
}
if (resolvers.Mutation) {
result.Mutation = mapValues(resolvers.Mutation, v =>
typeof v === "function" && !isSinonStub(v)
? createMutationResolverStub(v)
: v
);
}
return result;
}
@@ -0,0 +1,107 @@
import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
import { noop } from "lodash";
import path from "path";
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createPromisifiedStorage } from "talk-framework/lib/storage";
import { createUUIDGenerator } from "talk-framework/testHelpers";
import createFluentBundle from "./createFluentBundle";
import createRelayEnvironment from "./createRelayEnvironment";
export type Resolver<V, R> = (
parent: any,
args: V,
context: any,
info: any
) => R;
export interface Resolvers<Q extends Resolver<any, any> = any, M = any> {
Query?: Q;
Mutation?: M;
}
export interface TestResolvers<T extends Resolvers = any> {
Query?: { [P in keyof Required<T>["Query"]]: (() => any) };
Mutation?: { [P in keyof Required<T>["Mutation"]]: (() => any) };
}
function createNodeMock(element: React.ReactElement<any>) {
if (element.type === "div") {
return {
innerHtml: "",
className: "",
focus: noop,
};
}
return null;
}
export interface CreateTestRendererParams<T extends Resolvers = any> {
logNetwork?: boolean;
muteNetworkErrors?: boolean;
resolvers?: TestResolvers<T>;
browserInfo?: TalkContext["browserInfo"];
initLocalState?: (
local: RecordProxy,
source: RecordSourceProxy,
environment: Environment
) => void;
}
export default function createTestRenderer<
T extends { Query?: any; Mutation?: any } = any
>(
target: string,
element: React.ReactNode,
params: CreateTestRendererParams<T>
) {
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: params.logNetwork,
resolvers: params.resolvers as IResolvers<any, any>,
muteNetworkErrors: params.muteNetworkErrors,
projectName: "tenant",
},
initLocalState: (localRecord, source, env) => {
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
},
});
const context: TalkContext = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [
createFluentBundle(
target,
path.resolve(__dirname, "../../../../locales/en-US")
),
],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
browserInfo: params.browserInfo || { ios: false },
uuidGenerator: createUUIDGenerator(),
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
clearSession: () => Promise.resolve(),
};
let testRenderer: ReactTestRenderer;
TestRenderer.act(() => {
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>{element}</TalkContextProvider>,
{ createNodeMock }
);
});
return { context, testRenderer: testRenderer! };
}
+10 -2
View File
@@ -11,7 +11,6 @@ export {
export { default as createUUIDGenerator } from "./createUUIDGenerator";
export * from "./denormalize";
export { default as limitSnapshotTo } from "./limitSnapshotTo";
export { default as inputPredicate } from "./inputPredicate";
export { default as within } from "./within";
export { default as wait } from "./wait";
export { default as waitForElement } from "./waitForElement";
@@ -22,9 +21,18 @@ export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
export { default as createAccessToken } from "./createAccessToken";
export { default as findParentsWithType } from "./findParentsWithType";
export { default as findParentWithType } from "./findParentWithType";
export { default as createFixture } from "./createFixture";
export {
default as createFixture,
Fixture,
WithTypename,
} from "./createFixture";
export { default as createFixtures } from "./createFixtures";
export {
default as createMutationResolverStub,
} from "./createMutationResolverStub";
export { default as createQueryResolverStub } from "./createQueryResolverStub";
export {
default as createTestRenderer,
CreateTestRendererParams,
} from "./createTestRenderer";
export { default as createResolversStub } from "./createResolversStub";
@@ -1,10 +0,0 @@
import { ReactTestInstance } from "react-test-renderer";
const inputPredicate = (nameOrID: string) => (n: ReactTestInstance) => {
return (
[n.props.name, n.props.id].indexOf(nameOrID) > -1 &&
["input", "button"].indexOf(n.type as string) > -1
);
};
export default inputPredicate;
@@ -12,8 +12,8 @@ export type NoFragmentRefs<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs2<U>> // TODO: (cvle) this should normally reference itself but it complains about a circular reference.
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs2<U>> // TODO: (cvle) this should normally reference itself but it complains about a circular reference.
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
// TODO: (cvle) these NoFragmentRefX are a workaround for above issue
@@ -21,16 +21,16 @@ export type NoFragmentRefs2<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs3<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs3<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs3<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs4<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs4<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs4<T> = T extends object
+12 -18
View File
@@ -1,9 +1,6 @@
import { ReactTestInstance } from "react-test-renderer";
import {
getByID,
queryByID,
} from "./byID";
import { getByID, queryByID } from "./byID";
import {
getAllByLabelText,
getByLabelText,
@@ -17,12 +14,7 @@ import {
queryByTestID,
} from "./byTestID";
import { getAllByText, getByText, queryAllByText, queryByText } from "./byText";
import {
getAllByType,
getByType,
queryAllByType,
queryByType,
} from "./byType";
import { getAllByType, getByType, queryAllByType, queryByType } from "./byType";
import toJSON from "./toJSON";
type Func0<R> = () => R;
@@ -30,13 +22,15 @@ type Func1<A, R> = (a?: A) => R;
type Func2<A, B, R> = (a: A, b?: B) => R;
type Func3<A, B, C, R> = (a: A, b: B, c?: C) => R;
type RemoveFirstArgument<T, R> =
T extends [any, any, any, any?] ? Func3<T[1], T[2], T[3], R> :
T extends [any, any, any?] ? Func2<T[1], T[2], R> :
T extends [any, any?] ? Func1<T[1], R> :
T extends [any] ? Func0<R> :
unknown
;
type RemoveFirstArgument<T, R> = T extends [any, any, any, any?]
? Func3<T[1], T[2], T[3], R>
: T extends [any, any, any?]
? Func2<T[1], T[2], R>
: T extends [any, any?]
? Func1<T[1], R>
: T extends [any]
? Func0<R>
: unknown;
// tslint:disable
// @TODO: currently tslint fails to parse this: `...any[]`.
@@ -66,5 +60,5 @@ export default function within(container: ReactTestInstance) {
queryByType: applyContainer(container, queryByType),
queryAllByType: applyContainer(container, queryAllByType),
toJSON: () => toJSON(container),
}
};
}