Skip to content

Commit 9f0bcf5

Browse files
authored
Merge pull request #901 from gadget-inc/add-testing-to-core
add a mockUrqlClient testing util to @gadgetinc/core
2 parents e53e9aa + b63058a commit 9f0bcf5

File tree

4 files changed

+345
-3
lines changed

4 files changed

+345
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ packages/*/dist
1414
packages/sample-app
1515
*.orig
1616
cypress.env.json
17+
packages/core/testing
1718
packages/shopify-extensions/react
1819
# generated graphql queries
1920
packages/react/src/internal/gql

packages/core/package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
22
"name": "@gadgetinc/core",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"files": [
55
"README.md",
6-
"dist/**/*"
6+
"dist/**/*",
7+
"testing/**/*"
78
],
89
"license": "MIT",
910
"repository": "github:gadget-inc/js-clients",
@@ -15,6 +16,11 @@
1516
"import": "./dist/esm/index.js",
1617
"require": "./dist/cjs/index.js",
1718
"default": "./dist/esm/index.js"
19+
},
20+
"./testing": {
21+
"import": "./dist/esm/testing/index.js",
22+
"require": "./dist/cjs/testing/index.js",
23+
"default": "./dist/esm/testing/index.js"
1824
}
1925
},
2026
"source": "src/index.ts",
@@ -24,7 +30,7 @@
2430
"typecheck:main": "tsc --noEmit",
2531
"typecheck": "tsc --noEmit",
2632
"clean": "rimraf dist/ *.tsbuildinfo **/*.tsbuildinfo",
27-
"prebuild": "mkdir -p dist/cjs dist/esm && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && echo '{\"type\": \"module\"}' > dist/esm/package.json",
33+
"prebuild": "mkdir -p dist/cjs dist/esm testing/ && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && echo '{\"type\": \"module\"}' > dist/esm/package.json && echo '{\"main\": \"../dist/cjs/testing/index.js\"}' > testing/package.json",
2834
"build": "pnpm clean && pnpm prebuild && tsc -b tsconfig.cjs.json tsconfig.esm.json",
2935
"prepublishOnly": "pnpm build",
3036
"prerelease": "gitpkg publish"

packages/core/src/testing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./mockUrqlClient.js";
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { jest } from "@jest/globals";
2+
import type { Client, GraphQLRequest, OperationContext, OperationResult, OperationResultSource } from "@urql/core";
3+
import { createRequest, makeErrorResult } from "@urql/core";
4+
import type { DocumentNode, ExecutionResult, OperationDefinitionNode } from "graphql";
5+
import type { SubscribePayload, Client as SubscriptionClient, Sink as SubscriptionSink } from "graphql-ws";
6+
import type { FunctionLike } from "jest-mock";
7+
import pRetry from "p-retry";
8+
import type { Sink, Source, Subject } from "wonka";
9+
import { filter, makeSubject, pipe, subscribe, take, toPromise } from "wonka";
10+
11+
type ActFn = <T>(callback: () => T | Promise<T>) => Promise<T>;
12+
let act: ActFn = async (fn) => {
13+
const result = fn();
14+
if (typeof result === "object" && result !== null && "then" in result) {
15+
return await result;
16+
}
17+
return result;
18+
};
19+
20+
const findLast = <T>(array: readonly T[], predicate: (item: T) => boolean): T | undefined => {
21+
for (let i = array.length - 1; i >= 0; i--) {
22+
if (predicate(array[i])) {
23+
return array[i];
24+
}
25+
}
26+
};
27+
28+
const find = <T>(array: readonly T[], predicate: (item: T) => boolean): T | undefined => {
29+
for (let i = 0; i < array.length; i++) {
30+
if (predicate(array[i])) {
31+
return array[i];
32+
}
33+
}
34+
};
35+
36+
export const setAct = (actFn: ActFn) => {
37+
act = actFn;
38+
};
39+
40+
/** Patches a `toPromise` method onto the `Source` passed to it.
41+
* @param source$ - the Wonka {@link Source} to patch.
42+
* @returns The passed `source$` with a patched `toPromise` method as a {@link PromisifiedSource}.
43+
* copied from https://github.com/urql-graphql/urql/blob/656495100ea3861075b70b48516b10914efbcfd6/packages/core/src/utils/streamUtils.ts#L10
44+
*/
45+
export function withPromise<T extends OperationResult>(_source$: Source<T>): OperationResultSource<T> {
46+
const source$ = ((sink: Sink<T>) => _source$(sink)) as OperationResultSource<T>;
47+
source$.toPromise = () =>
48+
pipe(
49+
source$,
50+
filter((result) => !result.stale && !result.hasNext),
51+
take(1),
52+
toPromise
53+
);
54+
source$.then = (onResolve, onReject) => source$.toPromise().then(onResolve, onReject);
55+
source$.subscribe = (onResult) => subscribe(onResult)(source$);
56+
return source$;
57+
}
58+
59+
export type MockOperationFn<F extends FunctionLike> = jest.Mock<(...args: any[]) => any> & {
60+
subjects: Record<string, Subject<OperationResult>>;
61+
/**
62+
* Push a response to any subscribed listeners from an `executeXYZ` call in an urql client.
63+
*
64+
* The key word here is "subscribed". If no query/mutation/subscription call has been made yet, the pushed response will be "dropped".
65+
* One should ensure the appropriate `executeXYZ` call has been made by urql, then call this function.
66+
*/
67+
pushResponse: (key: string, response: Omit<OperationResult, "operation">) => void;
68+
/**
69+
*
70+
* Waits for a subject to be created for a given key. This is useful for ensuring waiting in a test for a query or mutation to be run
71+
*/
72+
waitForSubject: (key: string) => Promise<void>;
73+
};
74+
75+
export type MockFetchFn = jest.Mock & {
76+
requests: { args: any[]; resolve: (response: Response) => void; reject: (error: Error) => void }[];
77+
pushResponse: (response: Response) => Promise<void>;
78+
waitForRequest: (options?: pRetry.Options) => Promise<void>;
79+
};
80+
81+
const $gadgetConnection = Symbol.for("gadget/connection");
82+
83+
export interface MockUrqlClient extends Client {
84+
executeQuery: MockOperationFn<Client["executeQuery"]>;
85+
executeMutation: MockOperationFn<Client["executeMutation"]>;
86+
executeSubscription: MockOperationFn<Client["executeSubscription"]>;
87+
[$gadgetConnection]: {
88+
fetch: MockFetchFn;
89+
};
90+
mockFetch: MockFetchFn;
91+
_react?: any;
92+
}
93+
94+
export const graphqlDocumentName = (doc: DocumentNode) => {
95+
const lastDefinition: OperationDefinitionNode | undefined = findLast(doc.definitions, (d) => d.kind === "OperationDefinition") as any;
96+
if (lastDefinition) {
97+
if (lastDefinition.name) {
98+
return lastDefinition.name.value;
99+
}
100+
const firstSelection = find(lastDefinition.selectionSet.selections, (s) => s.kind === "Field") as any;
101+
return firstSelection.name.value;
102+
}
103+
};
104+
105+
/**
106+
* Create a new function for reading/writing to a mock graphql backend
107+
*/
108+
const newMockOperationFn = (assertions?: (request: GraphQLRequest) => void) => {
109+
const subjects: Record<string, Subject<OperationResult>> = {};
110+
111+
const fn = jest.fn((request: GraphQLRequest, options?: Partial<OperationContext>) => {
112+
const { query } = request;
113+
114+
const fetchOptions = options?.fetchOptions;
115+
const key = graphqlDocumentName(query) ?? "unknown";
116+
117+
subjects[key] ??= makeSubject<OperationResult>();
118+
119+
if (fetchOptions && typeof fetchOptions != "function") {
120+
const signal = fetchOptions.signal;
121+
if (signal) {
122+
signal.addEventListener("abort", () => {
123+
subjects[key].next(makeErrorResult(null as any, new Error("AbortError")));
124+
});
125+
}
126+
}
127+
128+
if (assertions) {
129+
assertions(request);
130+
}
131+
132+
return withPromise(subjects[key].source);
133+
}) as unknown as MockOperationFn<any>;
134+
135+
fn.subjects = subjects;
136+
fn.pushResponse = (key, response) => {
137+
if (!subjects[key]) {
138+
throw new Error(`No mock client subject started for key ${key}, options are ${Object.keys(subjects).join(", ")}`);
139+
}
140+
141+
void act(() => {
142+
subjects[key].next({
143+
operation: null as any,
144+
...response,
145+
});
146+
147+
if (!response.hasNext) {
148+
subjects[key].complete();
149+
delete subjects[key];
150+
}
151+
});
152+
};
153+
154+
fn.waitForSubject = async (key: string, options?: pRetry.Options) => {
155+
await pRetry(
156+
() => {
157+
if (subjects[key]) {
158+
return;
159+
}
160+
throw new Error(`No mock client subject started for key ${key}, options are ${Object.keys(subjects).join(", ")}`);
161+
},
162+
{
163+
...options,
164+
retries: options?.retries ?? 20,
165+
minTimeout: options?.minTimeout ?? 10,
166+
maxTimeout: options?.maxTimeout ?? 250,
167+
}
168+
);
169+
};
170+
171+
return fn;
172+
};
173+
174+
/**
175+
* Create a new function for reading/writing to a mock graphql backend
176+
*/
177+
const newMockFetchFn = () => {
178+
const requests: any[] = [];
179+
180+
const fn = jest.fn((...args) => {
181+
return new Promise<Response>((resolve, reject) => {
182+
const signal = (args[1] as any)?.signal;
183+
184+
const request = {
185+
args,
186+
resolve,
187+
reject,
188+
};
189+
190+
if (signal) {
191+
signal.addEventListener("abort", () => {
192+
const idx = requests.findIndex((r) => r === request);
193+
if (idx !== -1) {
194+
request.reject(new Error("AbortError: The user aborted a request."));
195+
requests.splice(idx, 1);
196+
}
197+
});
198+
}
199+
200+
requests.push(request);
201+
});
202+
}) as unknown as MockFetchFn;
203+
204+
fn.requests = requests;
205+
fn.pushResponse = async (response) => {
206+
await act(async () => {
207+
const request = requests.shift();
208+
if (!request) {
209+
throw new Error("no requests started for response pushing");
210+
}
211+
const signal = request.args[1]?.signal;
212+
if (signal && signal.aborted) {
213+
throw new Error("signal on request has been aborted, can't respond to a mock fetch that has been aborted");
214+
}
215+
216+
await request.resolve(response);
217+
});
218+
};
219+
220+
fn.waitForRequest = async (options?: pRetry.Options) => {
221+
const requestCount = requests.length;
222+
await act(async () => {
223+
await pRetry(
224+
async () => {
225+
if (requests.length > requestCount) {
226+
return;
227+
}
228+
throw new Error("request not found");
229+
},
230+
{
231+
...options,
232+
retries: options?.retries ?? 20,
233+
minTimeout: options?.minTimeout ?? 10,
234+
maxTimeout: options?.maxTimeout ?? 250,
235+
}
236+
);
237+
});
238+
};
239+
240+
return fn;
241+
};
242+
243+
export const createMockUrqlClient = (assertions?: {
244+
mutationAssertions?: (request: GraphQLRequest) => void;
245+
queryAssertions?: (request: GraphQLRequest) => void;
246+
}) => {
247+
const fetch = newMockFetchFn();
248+
249+
return {
250+
executeQuery: newMockOperationFn(assertions?.queryAssertions),
251+
executeMutation: newMockOperationFn(assertions?.mutationAssertions),
252+
executeSubscription: newMockOperationFn(),
253+
[$gadgetConnection]: {
254+
fetch,
255+
},
256+
mockFetch: fetch,
257+
suspense: true,
258+
query(query, variables, context) {
259+
return this.executeQuery(createRequest(query, variables), context);
260+
},
261+
262+
subscription(query, variables, context) {
263+
return this.executeSubscription(createRequest(query, variables), context);
264+
},
265+
mutation(query, variables, context) {
266+
return this.executeMutation(createRequest(query, variables), context);
267+
},
268+
} as MockUrqlClient;
269+
};
270+
271+
export interface MockSubscription {
272+
payload: SubscribePayload;
273+
sink: SubscriptionSink<ExecutionResult<any, any>>;
274+
push: (result: ExecutionResult<any, any>) => void;
275+
disposed: boolean;
276+
}
277+
export type MockSubscribeFn = ((payload: SubscribePayload, sink: SubscriptionSink<ExecutionResult<any, any>>) => () => void) & {
278+
subscriptions: MockSubscription[];
279+
};
280+
281+
export interface MockGraphQLWSClient extends SubscriptionClient {
282+
subscribe: MockSubscribeFn;
283+
}
284+
285+
/**
286+
* Create a new function for mocking subscriptions passed to graphql-ws
287+
*/
288+
function newMockSubscribeFn(): MockSubscribeFn {
289+
const subscriptions: MockSubscription[] = [];
290+
291+
const fn: SubscriptionClient["subscribe"] = (payload: SubscribePayload, sink: SubscriptionSink<ExecutionResult<any, any>>) => {
292+
const subscription: MockSubscription = {
293+
payload,
294+
sink,
295+
disposed: false,
296+
push: (result) => {
297+
void act(() => {
298+
sink.next(result);
299+
});
300+
},
301+
};
302+
303+
subscriptions.push(subscription);
304+
305+
return () => {
306+
subscription.disposed = true;
307+
};
308+
};
309+
310+
return Object.assign(fn, { subscriptions });
311+
}
312+
313+
export const mockUrqlClient = createMockUrqlClient();
314+
export const mockGraphQLWSClient = {} as MockGraphQLWSClient;
315+
316+
beforeEach(() => {
317+
const fetch = newMockFetchFn();
318+
319+
mockUrqlClient.executeQuery = newMockOperationFn();
320+
mockUrqlClient.executeMutation = newMockOperationFn();
321+
mockUrqlClient.executeSubscription = newMockOperationFn();
322+
mockUrqlClient[$gadgetConnection] = {
323+
fetch,
324+
};
325+
mockUrqlClient.mockFetch = fetch;
326+
327+
mockGraphQLWSClient.subscribe = newMockSubscribeFn();
328+
});
329+
330+
afterEach(() => {
331+
// force clear _react, which useQuery sets on the client if not present
332+
mockUrqlClient._react = undefined;
333+
jest.clearAllMocks();
334+
});

0 commit comments

Comments
 (0)