mirror of
https://github.com/wassname/talk.git
synced 2026-08-12 12:30:39 +08:00
[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:
@@ -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! };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user