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