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});