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