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