Mirror: The spec-compliant minimum of client-side GraphQL.
1import { describe, it, expect } from 'vitest';
2import { Kind, parse, print } from 'graphql';
3import { visit, BREAK } from '../visitor';
4
5function checkVisitorFnArgs(ast, args, isEdited = false) {
6 const [node, key, parent, path, ancestors] = args;
7
8 expect(node).toBeInstanceOf(Object);
9 expect(Object.values(Kind)).toContain(node.kind);
10
11 const isRoot = key === undefined;
12 if (isRoot) {
13 if (!isEdited) {
14 expect(node).toEqual(ast);
15 }
16 expect(parent).toEqual(undefined);
17 expect(path).toEqual([]);
18 expect(ancestors).toEqual([]);
19 return;
20 }
21
22 expect(typeof key).toMatch(/number|string/);
23
24 expect(parent).toHaveProperty([key]);
25
26 expect(path).toBeInstanceOf(Array);
27 expect(path[path.length - 1]).toEqual(key);
28
29 expect(ancestors).toBeInstanceOf(Array);
30 expect(ancestors.length).toEqual(path.length - 1);
31
32 if (!isEdited) {
33 let currentNode = ast;
34 for (let i = 0; i < ancestors.length; ++i) {
35 expect(ancestors[i]).toEqual(currentNode);
36
37 currentNode = currentNode[path[i]];
38 expect(currentNode).not.toEqual(undefined);
39 }
40 }
41}
42
43function getValue(node: any) {
44 return 'value' in node ? node.value : undefined;
45}
46
47describe('Visitor', () => {
48 it('handles empty visitor', () => {
49 const ast = parse('{ a }', { noLocation: true });
50 expect(() => visit(ast, {})).not.toThrow();
51 });
52
53 it('validates path argument', () => {
54 const visited: any[] = [];
55
56 const ast = parse('{ a }', { noLocation: true });
57
58 visit(ast, {
59 enter(_node, _key, _parent, path) {
60 checkVisitorFnArgs(ast, arguments);
61 visited.push(['enter', path.slice()]);
62 },
63 leave(_node, _key, _parent, path) {
64 checkVisitorFnArgs(ast, arguments);
65 visited.push(['leave', path.slice()]);
66 },
67 });
68
69 expect(visited).toEqual([
70 ['enter', []],
71 ['enter', ['definitions', 0]],
72 ['enter', ['definitions', 0, 'selectionSet']],
73 ['enter', ['definitions', 0, 'selectionSet', 'selections', 0]],
74 ['enter', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']],
75 ['leave', ['definitions', 0, 'selectionSet', 'selections', 0, 'name']],
76 ['leave', ['definitions', 0, 'selectionSet', 'selections', 0]],
77 ['leave', ['definitions', 0, 'selectionSet']],
78 ['leave', ['definitions', 0]],
79 ['leave', []],
80 ]);
81 });
82
83 it('validates ancestors argument', () => {
84 const ast = parse('{ a }', { noLocation: true });
85 const visitedNodes: any[] = [];
86
87 visit(ast, {
88 enter(node, key, parent, _path, ancestors) {
89 const inArray = typeof key === 'number';
90 if (inArray) {
91 visitedNodes.push(parent);
92 }
93 visitedNodes.push(node);
94
95 const expectedAncestors = visitedNodes.slice(0, -2);
96 expect(ancestors).toEqual(expectedAncestors);
97 },
98 leave(_node, key, _parent, _path, ancestors) {
99 const expectedAncestors = visitedNodes.slice(0, -2);
100 expect(ancestors).toEqual(expectedAncestors);
101
102 const inArray = typeof key === 'number';
103 if (inArray) {
104 visitedNodes.pop();
105 }
106 visitedNodes.pop();
107 },
108 });
109 });
110
111 it('allows editing a node both on enter and on leave', () => {
112 const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true });
113
114 let selectionSet;
115
116 const editedAST = visit(ast, {
117 OperationDefinition: {
118 enter(node) {
119 checkVisitorFnArgs(ast, arguments);
120 selectionSet = node.selectionSet;
121 return {
122 ...node,
123 selectionSet: {
124 kind: 'SelectionSet',
125 selections: [],
126 },
127 didEnter: true,
128 };
129 },
130 leave(node) {
131 checkVisitorFnArgs(ast, arguments, /* isEdited */ true);
132 return {
133 ...node,
134 selectionSet,
135 didLeave: true,
136 };
137 },
138 },
139 });
140
141 expect(editedAST).toEqual({
142 ...ast,
143 definitions: [
144 {
145 ...ast.definitions[0],
146 didEnter: true,
147 didLeave: true,
148 },
149 ],
150 });
151 });
152
153 it('allows editing the root node on enter and on leave', () => {
154 const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true });
155
156 const { definitions } = ast;
157
158 const editedAST = visit(ast, {
159 Document: {
160 enter(node) {
161 checkVisitorFnArgs(ast, arguments);
162 return {
163 ...node,
164 definitions: [],
165 didEnter: true,
166 };
167 },
168 leave(node) {
169 checkVisitorFnArgs(ast, arguments, /* isEdited */ true);
170 return {
171 ...node,
172 definitions,
173 didLeave: true,
174 };
175 },
176 },
177 });
178
179 expect(editedAST).toEqual({
180 ...ast,
181 didEnter: true,
182 didLeave: true,
183 });
184 });
185
186 it('allows for editing on enter', () => {
187 const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true });
188 const editedAST = visit(ast, {
189 enter(node) {
190 checkVisitorFnArgs(ast, arguments);
191 if (node.kind === 'Field' && node.name.value === 'b') {
192 return null;
193 }
194 },
195 });
196
197 expect(ast).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true }));
198
199 expect(editedAST).toEqual(parse('{ a, c { a, c } }', { noLocation: true }));
200 });
201
202 it('allows for editing on leave', () => {
203 const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true });
204 const editedAST = visit(ast, {
205 leave(node) {
206 checkVisitorFnArgs(ast, arguments, /* isEdited */ true);
207 if (node.kind === 'Field' && node.name.value === 'b') {
208 return null;
209 }
210 },
211 });
212
213 expect(ast).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true }));
214
215 expect(editedAST).toEqual(parse('{ a, c { a, c } }', { noLocation: true }));
216 });
217
218 it('ignores false returned on leave', () => {
219 const ast = parse('{ a, b, c { a, b, c } }', { noLocation: true });
220 const returnedAST = visit(ast, {
221 leave() {
222 return false;
223 },
224 });
225
226 expect(returnedAST).toEqual(parse('{ a, b, c { a, b, c } }', { noLocation: true }));
227 });
228
229 it('visits edited node', () => {
230 const addedField = {
231 kind: 'Field',
232 name: {
233 kind: 'Name',
234 value: '__typename',
235 },
236 };
237
238 let didVisitAddedField;
239
240 const ast = parse('{ a { x } }', { noLocation: true });
241 visit(ast, {
242 enter(node) {
243 checkVisitorFnArgs(ast, arguments, /* isEdited */ true);
244 if (node.kind === 'Field' && node.name.value === 'a') {
245 return {
246 kind: 'Field',
247 selectionSet: [addedField, node.selectionSet],
248 };
249 }
250 if (node === addedField) {
251 didVisitAddedField = true;
252 }
253 },
254 });
255
256 expect(didVisitAddedField).toEqual(true);
257 });
258
259 it('allows skipping a sub-tree', () => {
260 const visited: any[] = [];
261
262 const ast = parse('{ a, b { x }, c }', { noLocation: true });
263 visit(ast, {
264 enter(node) {
265 checkVisitorFnArgs(ast, arguments);
266 visited.push(['enter', node.kind, getValue(node)]);
267 if (node.kind === 'Field' && node.name.value === 'b') {
268 return false;
269 }
270 },
271
272 leave(node) {
273 checkVisitorFnArgs(ast, arguments);
274 visited.push(['leave', node.kind, getValue(node)]);
275 },
276 });
277
278 expect(visited).toEqual([
279 ['enter', 'Document', undefined],
280 ['enter', 'OperationDefinition', undefined],
281 ['enter', 'SelectionSet', undefined],
282 ['enter', 'Field', undefined],
283 ['enter', 'Name', 'a'],
284 ['leave', 'Name', 'a'],
285 ['leave', 'Field', undefined],
286 ['enter', 'Field', undefined],
287 ['enter', 'Field', undefined],
288 ['enter', 'Name', 'c'],
289 ['leave', 'Name', 'c'],
290 ['leave', 'Field', undefined],
291 ['leave', 'SelectionSet', undefined],
292 ['leave', 'OperationDefinition', undefined],
293 ['leave', 'Document', undefined],
294 ]);
295 });
296
297 it('allows early exit while visiting', () => {
298 const visited: any[] = [];
299
300 const ast = parse('{ a, b { x }, c }', { noLocation: true });
301 visit(ast, {
302 enter(node) {
303 checkVisitorFnArgs(ast, arguments);
304 visited.push(['enter', node.kind, getValue(node)]);
305 if (node.kind === 'Name' && node.value === 'x') {
306 return BREAK;
307 }
308 },
309 leave(node) {
310 checkVisitorFnArgs(ast, arguments);
311 visited.push(['leave', node.kind, getValue(node)]);
312 },
313 });
314
315 expect(visited).toEqual([
316 ['enter', 'Document', undefined],
317 ['enter', 'OperationDefinition', undefined],
318 ['enter', 'SelectionSet', undefined],
319 ['enter', 'Field', undefined],
320 ['enter', 'Name', 'a'],
321 ['leave', 'Name', 'a'],
322 ['leave', 'Field', undefined],
323 ['enter', 'Field', undefined],
324 ['enter', 'Name', 'b'],
325 ['leave', 'Name', 'b'],
326 ['enter', 'SelectionSet', undefined],
327 ['enter', 'Field', undefined],
328 ['enter', 'Name', 'x'],
329 ]);
330 });
331
332 it('allows early exit while leaving', () => {
333 const visited: any[] = [];
334
335 const ast = parse('{ a, b { x }, c }', { noLocation: true });
336 visit(ast, {
337 enter(node) {
338 checkVisitorFnArgs(ast, arguments);
339 visited.push(['enter', node.kind, getValue(node)]);
340 },
341
342 leave(node) {
343 checkVisitorFnArgs(ast, arguments);
344 visited.push(['leave', node.kind, getValue(node)]);
345 if (node.kind === 'Name' && node.value === 'x') {
346 return BREAK;
347 }
348 },
349 });
350
351 expect(visited).toEqual([
352 ['enter', 'Document', undefined],
353 ['enter', 'OperationDefinition', undefined],
354 ['enter', 'SelectionSet', undefined],
355 ['enter', 'Field', undefined],
356 ['enter', 'Name', 'a'],
357 ['leave', 'Name', 'a'],
358 ['leave', 'Field', undefined],
359 ['enter', 'Field', undefined],
360 ['enter', 'Name', 'b'],
361 ['leave', 'Name', 'b'],
362 ['enter', 'SelectionSet', undefined],
363 ['enter', 'Field', undefined],
364 ['enter', 'Name', 'x'],
365 ['leave', 'Name', 'x'],
366 ]);
367 });
368
369 it('allows a named functions visitor API', () => {
370 const visited: any[] = [];
371
372 const ast = parse('{ a, b { x }, c }', { noLocation: true });
373 visit(ast, {
374 Name(node) {
375 checkVisitorFnArgs(ast, arguments);
376 visited.push(['enter', node.kind, getValue(node)]);
377 },
378 SelectionSet: {
379 enter(node) {
380 checkVisitorFnArgs(ast, arguments);
381 visited.push(['enter', node.kind, getValue(node)]);
382 },
383 leave(node) {
384 checkVisitorFnArgs(ast, arguments);
385 visited.push(['leave', node.kind, getValue(node)]);
386 },
387 },
388 });
389
390 expect(visited).toEqual([
391 ['enter', 'SelectionSet', undefined],
392 ['enter', 'Name', 'a'],
393 ['enter', 'Name', 'b'],
394 ['enter', 'SelectionSet', undefined],
395 ['enter', 'Name', 'x'],
396 ['leave', 'SelectionSet', undefined],
397 ['enter', 'Name', 'c'],
398 ['leave', 'SelectionSet', undefined],
399 ]);
400 });
401
402 it('handles deep immutable edits correctly when using "enter"', () => {
403 const formatNode = node => {
404 if (
405 node.selectionSet &&
406 !node.selectionSet.selections.some(
407 node => node.kind === Kind.FIELD && node.name.value === '__typename' && !node.alias
408 )
409 ) {
410 return {
411 ...node,
412 selectionSet: {
413 ...node.selectionSet,
414 selections: [
415 ...node.selectionSet.selections,
416 {
417 kind: Kind.FIELD,
418 name: {
419 kind: Kind.NAME,
420 value: '__typename',
421 },
422 },
423 ],
424 },
425 };
426 }
427 };
428 const ast = parse('{ players { nodes { id } } }');
429 const expected = parse('{ players { nodes { id __typename } __typename } }');
430 const visited = visit(ast, {
431 Field: formatNode,
432 InlineFragment: formatNode,
433 });
434
435 expect(print(visited)).toEqual(print(expected));
436 });
437});