@gleanql/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/testing.ts ADDED
@@ -0,0 +1,220 @@
1
+ import type { SchemaModel } from "@gleanql/core";
2
+ import { GraphRuntime } from "./runtime.js";
3
+ import { bindGraph, type BoundGraph } from "./proxy.js";
4
+ import type { FieldValue } from "./cache.js";
5
+ import type { GraphHydrationPayload } from "./serialize.js";
6
+ import type { GraphClientAdapter, GraphOperation, GraphRequestContext, GraphResult } from "./adapter.js";
7
+
8
+ /**
9
+ * The consumer-facing test harness. An app imports this through the generated
10
+ * `@gleanql/client/testing` entrypoint (which bakes the schema in), so a test
11
+ * seeds a real runtime from plain JSON and reads through real graph proxies —
12
+ * no GraphQL server, no compiled operations, no Vite.
13
+ *
14
+ * Three pieces:
15
+ * - {@link buildTestGraph}: plain data → a seeded runtime + bound `glean` +
16
+ * a hydration payload that rides the production hydration path
17
+ * (`<GraphHydrator payload={…}>` in RSC, `hydrate(payload)` isomorphic).
18
+ * - {@link createMockAdapter}: a recording adapter with canned responses per
19
+ * operation name — for `runRoute`/`runMutation`/integration-level tests.
20
+ * - {@link mockGraphFetch}: intercepts the generated client's `fetch` to the
21
+ * graph endpoint — for `useMutation`/`refresh` island tests in jsdom.
22
+ */
23
+
24
+ export interface TestGraphOptions {
25
+ readonly schema: SchemaModel;
26
+ /**
27
+ * Operation-shaped result JSON: root fields at the top, `__typename` on every
28
+ * object (and `id` where the type has one) so records normalize by identity.
29
+ */
30
+ readonly data: Record<string, unknown>;
31
+ /** Label for the page pointer (defaults to `"TestGraph"`). */
32
+ readonly operationName?: string;
33
+ readonly variables?: Record<string, unknown>;
34
+ /** Client-safe request context (locale etc.) the page would have carried. */
35
+ readonly context?: Record<string, unknown>;
36
+ /**
37
+ * What a read of an UNSEEDED field does. `"error"` (default) rejects with a
38
+ * message naming the fields — a test should seed everything it renders.
39
+ * `"undefined"` resolves the miss to `undefined`, like the generated client.
40
+ */
41
+ readonly onMiss?: "error" | "undefined";
42
+ }
43
+
44
+ export interface TestGraph {
45
+ /** A bound graph: `glean.product({ handle }).title` reads like app code. */
46
+ readonly glean: BoundGraph;
47
+ readonly runtime: GraphRuntime;
48
+ readonly roots: Record<string, FieldValue>;
49
+ /**
50
+ * A real {@link GraphHydrationPayload} carrying the seeded records — feed it
51
+ * to the generated client's `<GraphHydrator payload={…}>` (RSC) or
52
+ * `hydrate(payload)` (isomorphic) and `useGlean()` reads warm in jsdom.
53
+ */
54
+ readonly payload: GraphHydrationPayload;
55
+ }
56
+
57
+ /** Seed a real runtime from plain operation-shaped JSON and bind a graph over it. */
58
+ export function buildTestGraph(options: TestGraphOptions): TestGraph {
59
+ const { schema, data, operationName = "TestGraph", variables = {}, context = {}, onMiss = "error" } = options;
60
+ const runtime = new GraphRuntime({
61
+ keyOf: (typename, obj) => schema.identityOf(typename, obj),
62
+ fetchMissing: async (misses) => {
63
+ if (onMiss === "error") {
64
+ const fields = misses.map((m) => m.fieldKey).join(", ");
65
+ throw new Error(`test graph: read of unseeded field(s): ${fields} — seed them in createTestGraph({ data })`);
66
+ }
67
+ return misses.map((m) => ({ ref: m.ref, fieldKey: m.fieldKey, value: undefined }));
68
+ },
69
+ });
70
+ const roots = runtime.seedResult(data);
71
+ const glean = bindGraph({ schema, getRuntime: () => runtime, roots });
72
+ return {
73
+ glean,
74
+ runtime,
75
+ roots,
76
+ payload: { operationName, variables, snapshot: runtime.snapshot(), roots, context },
77
+ };
78
+ }
79
+
80
+ /** What a {@link createMockAdapter} handler may return: the operation's data, or a full result. */
81
+ export type MockResponder =
82
+ | unknown
83
+ | ((variables: Record<string, unknown>) => unknown | Promise<unknown>);
84
+
85
+ export interface MockAdapterCall {
86
+ readonly name: string;
87
+ readonly kind: "query" | "mutation" | "subscription";
88
+ readonly variables: Record<string, unknown>;
89
+ }
90
+
91
+ export interface MockAdapter extends GraphClientAdapter {
92
+ /** Every operation the adapter saw, in order. */
93
+ readonly calls: readonly MockAdapterCall[];
94
+ /** Push a payload to every live subscription of `operationName`. */
95
+ push(operationName: string, data: unknown): void;
96
+ /** Complete every live subscription of `operationName`. */
97
+ end(operationName: string): void;
98
+ }
99
+
100
+ /** A handler's return value becomes `{ data }` unless it already carries an `errors` array. */
101
+ function toResult<TData>(value: unknown): GraphResult<TData> {
102
+ if (value && typeof value === "object" && Array.isArray((value as { errors?: unknown }).errors)) {
103
+ return value as GraphResult<TData>;
104
+ }
105
+ return { data: value as TData };
106
+ }
107
+
108
+ /**
109
+ * A recording adapter with canned responses per operation name. Handlers return
110
+ * the operation's DATA (or a `{ data?, errors }` result, or a function of the
111
+ * variables). Subscriptions are push-driven: `adapter.push(name, data)`.
112
+ */
113
+ export function createMockAdapter(handlers: Record<string, MockResponder> = {}): MockAdapter {
114
+ const calls: MockAdapterCall[] = [];
115
+ const streams = new Map<string, Set<(result: GraphResult<unknown> | null) => void>>();
116
+
117
+ const respond = async (operation: GraphOperation, variables: unknown): Promise<GraphResult<unknown>> => {
118
+ const handler = handlers[operation.name];
119
+ if (handler === undefined) return { errors: [{ message: `mock adapter: no handler for "${operation.name}"` }] };
120
+ const value = typeof handler === "function" ? await (handler as Function)(variables) : handler;
121
+ return toResult(value);
122
+ };
123
+
124
+ return {
125
+ calls,
126
+ async execute<TData, TVariables>(
127
+ operation: GraphOperation<TData, TVariables>,
128
+ variables: TVariables,
129
+ _context: GraphRequestContext,
130
+ ): Promise<GraphResult<TData>> {
131
+ calls.push({ name: operation.name, kind: operation.kind, variables: (variables ?? {}) as Record<string, unknown> });
132
+ return (await respond(operation, variables)) as GraphResult<TData>;
133
+ },
134
+ subscribe<TData, TVariables>(
135
+ operation: GraphOperation<TData, TVariables>,
136
+ variables: TVariables,
137
+ _context: GraphRequestContext,
138
+ ): AsyncIterable<GraphResult<TData>> {
139
+ calls.push({ name: operation.name, kind: operation.kind, variables: (variables ?? {}) as Record<string, unknown> });
140
+ const listeners = streams.get(operation.name) ?? new Set();
141
+ streams.set(operation.name, listeners);
142
+ return {
143
+ [Symbol.asyncIterator]() {
144
+ const queue: Array<GraphResult<unknown> | null> = [];
145
+ let wake: (() => void) | undefined;
146
+ const listener = (result: GraphResult<unknown> | null) => {
147
+ queue.push(result);
148
+ wake?.();
149
+ };
150
+ listeners.add(listener);
151
+ return {
152
+ async next(): Promise<IteratorResult<GraphResult<TData>>> {
153
+ while (queue.length === 0) await new Promise<void>((r) => (wake = r));
154
+ const value = queue.shift()!;
155
+ if (value === null) {
156
+ listeners.delete(listener);
157
+ return { done: true, value: undefined };
158
+ }
159
+ return { done: false, value: value as GraphResult<TData> };
160
+ },
161
+ async return(): Promise<IteratorResult<GraphResult<TData>>> {
162
+ listeners.delete(listener);
163
+ return { done: true, value: undefined };
164
+ },
165
+ };
166
+ },
167
+ };
168
+ },
169
+ push(operationName: string, data: unknown): void {
170
+ for (const listener of streams.get(operationName) ?? []) listener(toResult(data));
171
+ },
172
+ end(operationName: string): void {
173
+ for (const listener of streams.get(operationName) ?? []) listener(null);
174
+ },
175
+ };
176
+ }
177
+
178
+ export interface MockGraphFetch {
179
+ /** Every graph request the endpoint saw, in order. */
180
+ readonly calls: readonly MockAdapterCall[];
181
+ /** Put the original `fetch` back. */
182
+ restore(): void;
183
+ }
184
+
185
+ /**
186
+ * Intercept the generated client's `fetch` to the graph endpoint, answering by
187
+ * `operationName` — so a jsdom test of a `useMutation`/`refresh` island needs no
188
+ * server and no adapter seam. Non-graph requests fall through to the real fetch.
189
+ */
190
+ export function mockGraphFetch(
191
+ handlers: Record<string, MockResponder>,
192
+ options: { readonly endpoint?: string } = {},
193
+ ): MockGraphFetch {
194
+ const endpoint = options.endpoint ?? "/graphql";
195
+ const calls: MockAdapterCall[] = [];
196
+ const original = globalThis.fetch;
197
+
198
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
199
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
200
+ const isGraph = url === endpoint || url.split("?")[0]!.endsWith(endpoint);
201
+ if (!isGraph || (init?.method ?? "GET").toUpperCase() !== "POST") {
202
+ return original(input as RequestInfo, init);
203
+ }
204
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
205
+ operationName?: string;
206
+ variables?: Record<string, unknown>;
207
+ };
208
+ const name = body.operationName ?? "(anonymous)";
209
+ const variables = body.variables ?? {};
210
+ calls.push({ name, kind: "query", variables });
211
+ const handler = handlers[name];
212
+ const result =
213
+ handler === undefined
214
+ ? { errors: [{ message: `mockGraphFetch: no handler for "${name}"` }] }
215
+ : toResult(typeof handler === "function" ? await (handler as Function)(variables) : handler);
216
+ return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
217
+ }) as typeof fetch;
218
+
219
+ return { calls, restore: () => void (globalThis.fetch = original) };
220
+ }