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