Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.
at main 21 kB view raw
1import { expect, afterAll, beforeAll, it, describe } from 'vitest'; 2import { TSServer } from './server'; 3import path from 'node:path'; 4import fs from 'node:fs'; 5import url from 'node:url'; 6import ts from 'typescript/lib/tsserverlibrary'; 7 8const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 9 10const projectPath = path.resolve(__dirname, 'fixture-project-tada'); 11describe('Fragment + operations', () => { 12 const outfileCombo = path.join(projectPath, 'simple.ts'); 13 const outfileTypeCondition = path.join(projectPath, 'type-condition.ts'); 14 const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts'); 15 const outfileUsedFragmentMask = path.join( 16 projectPath, 17 'used-fragment-mask.ts' 18 ); 19 const outfileCombinations = path.join(projectPath, 'fragment.ts'); 20 21 let server: TSServer; 22 beforeAll(async () => { 23 server = new TSServer(projectPath, { debugLog: false }); 24 25 server.sendCommand('open', { 26 file: outfileCombo, 27 fileContent: '// empty', 28 scriptKindName: 'TS', 29 } satisfies ts.server.protocol.OpenRequestArgs); 30 server.sendCommand('open', { 31 file: outfileTypeCondition, 32 fileContent: '// empty', 33 scriptKindName: 'TS', 34 } satisfies ts.server.protocol.OpenRequestArgs); 35 server.sendCommand('open', { 36 file: outfileCombinations, 37 fileContent: '// empty', 38 scriptKindName: 'TS', 39 } satisfies ts.server.protocol.OpenRequestArgs); 40 server.sendCommand('open', { 41 file: outfileUnusedFragment, 42 fileContent: '// empty', 43 scriptKindName: 'TS', 44 } satisfies ts.server.protocol.OpenRequestArgs); 45 server.sendCommand('open', { 46 file: outfileUsedFragmentMask, 47 fileContent: '// empty', 48 scriptKindName: 'TS', 49 } satisfies ts.server.protocol.OpenRequestArgs); 50 51 server.sendCommand('updateOpen', { 52 openFiles: [ 53 { 54 file: outfileCombinations, 55 fileContent: fs.readFileSync( 56 path.join(projectPath, 'fixtures/fragment.ts'), 57 'utf-8' 58 ), 59 }, 60 { 61 file: outfileTypeCondition, 62 fileContent: fs.readFileSync( 63 path.join(projectPath, 'fixtures/type-condition.ts'), 64 'utf-8' 65 ), 66 }, 67 { 68 file: outfileCombo, 69 fileContent: fs.readFileSync( 70 path.join(projectPath, 'fixtures/simple.ts'), 71 'utf-8' 72 ), 73 }, 74 { 75 file: outfileUnusedFragment, 76 fileContent: fs.readFileSync( 77 path.join(projectPath, 'fixtures/unused-fragment.ts'), 78 'utf-8' 79 ), 80 }, 81 { 82 file: outfileUsedFragmentMask, 83 fileContent: fs.readFileSync( 84 path.join(projectPath, 'fixtures/used-fragment-mask.ts'), 85 'utf-8' 86 ), 87 }, 88 ], 89 } satisfies ts.server.protocol.UpdateOpenRequestArgs); 90 91 server.sendCommand('saveto', { 92 file: outfileCombo, 93 tmpfile: outfileCombo, 94 } satisfies ts.server.protocol.SavetoRequestArgs); 95 server.sendCommand('saveto', { 96 file: outfileTypeCondition, 97 tmpfile: outfileTypeCondition, 98 } satisfies ts.server.protocol.SavetoRequestArgs); 99 server.sendCommand('saveto', { 100 file: outfileCombinations, 101 tmpfile: outfileCombinations, 102 } satisfies ts.server.protocol.SavetoRequestArgs); 103 server.sendCommand('saveto', { 104 file: outfileUnusedFragment, 105 tmpfile: outfileUnusedFragment, 106 } satisfies ts.server.protocol.SavetoRequestArgs); 107 server.sendCommand('saveto', { 108 file: outfileUsedFragmentMask, 109 tmpfile: outfileUsedFragmentMask, 110 } satisfies ts.server.protocol.SavetoRequestArgs); 111 }); 112 113 afterAll(() => { 114 try { 115 fs.unlinkSync(outfileUnusedFragment); 116 fs.unlinkSync(outfileUsedFragmentMask); 117 fs.unlinkSync(outfileCombinations); 118 fs.unlinkSync(outfileCombo); 119 fs.unlinkSync(outfileTypeCondition); 120 } catch {} 121 }); 122 123 it('gives semantic-diagnostics with preceding fragments', async () => { 124 await server.waitForResponse( 125 e => e.type === 'event' && e.event === 'semanticDiag' 126 ); 127 const res = server.responses.filter( 128 resp => 129 resp.type === 'event' && 130 resp.event === 'semanticDiag' && 131 resp.body?.file === outfileCombo 132 ); 133 expect(res[0].body.diagnostics).toMatchInlineSnapshot(` 134 [ 135 { 136 "category": "warning", 137 "code": 52004, 138 "end": { 139 "line": 12, 140 "offset": 1, 141 }, 142 "start": { 143 "line": 11, 144 "offset": 7, 145 }, 146 "text": "The field Pokemon.classification is deprecated. And this is the reason why", 147 }, 148 ] 149 `); 150 }, 30000); 151 152 it('gives quick-info with preceding fragments', async () => { 153 server.send({ 154 seq: 9, 155 type: 'request', 156 command: 'quickinfo', 157 arguments: { 158 file: outfileCombinations, 159 line: 7, 160 offset: 8, 161 }, 162 }); 163 164 await server.waitForResponse( 165 response => 166 response.type === 'response' && response.command === 'quickinfo' 167 ); 168 169 const res = server.responses 170 .reverse() 171 .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); 172 173 expect(res).toBeDefined(); 174 expect(typeof res?.body).toEqual('object'); 175 expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); 176 }, 30000); 177 178 it('gives quick-info with documents', async () => { 179 server.send({ 180 seq: 9, 181 type: 'request', 182 command: 'quickinfo', 183 arguments: { 184 file: outfileCombo, 185 line: 7, 186 offset: 10, 187 }, 188 }); 189 190 await server.waitForResponse( 191 response => 192 response.type === 'response' && response.command === 'quickinfo' 193 ); 194 195 const res = server.responses 196 .reverse() 197 .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); 198 199 expect(res).toBeDefined(); 200 expect(typeof res?.body).toEqual('object'); 201 expect(res?.body.documentation).toEqual( 202 `Query.pokemons: [Pokemon] 203 204List out all Pokémon, optionally in pages` 205 ); 206 }, 30000); 207 208 it('gives suggestions with preceding fragments', async () => { 209 server.send({ 210 seq: 10, 211 type: 'request', 212 command: 'completionInfo', 213 arguments: { 214 file: outfileCombinations, 215 line: 8, 216 offset: 5, 217 includeExternalModuleExports: true, 218 includeInsertTextCompletions: true, 219 triggerKind: 1, 220 }, 221 }); 222 223 await server.waitForResponse( 224 response => 225 response.type === 'response' && response.command === 'completionInfo' 226 ); 227 228 const res = server.responses 229 .reverse() 230 .find( 231 resp => resp.type === 'response' && resp.command === 'completionInfo' 232 ); 233 234 expect(res).toBeDefined(); 235 expect(typeof res?.body.entries).toEqual('object'); 236 expect(res?.body.entries).toMatchInlineSnapshot(` 237 [ 238 { 239 "kind": "var", 240 "kindModifiers": "declare", 241 "labelDetails": { 242 "detail": " AttacksConnection", 243 }, 244 "name": "attacks", 245 "sortText": "0attacks", 246 }, 247 { 248 "kind": "var", 249 "kindModifiers": "declare", 250 "labelDetails": { 251 "detail": " [EvolutionRequirement]", 252 }, 253 "name": "evolutionRequirements", 254 "sortText": "2evolutionRequirements", 255 }, 256 { 257 "kind": "var", 258 "kindModifiers": "declare", 259 "labelDetails": { 260 "detail": " [Pokemon]", 261 }, 262 "name": "evolutions", 263 "sortText": "3evolutions", 264 }, 265 { 266 "kind": "var", 267 "kindModifiers": "declare", 268 "labelDetails": { 269 "description": "Likelihood of an attempt to catch a Pokémon to fail.", 270 "detail": " Float", 271 }, 272 "name": "fleeRate", 273 "sortText": "4fleeRate", 274 }, 275 { 276 "kind": "var", 277 "kindModifiers": "declare", 278 "labelDetails": { 279 "detail": " PokemonDimension", 280 }, 281 "name": "height", 282 "sortText": "5height", 283 }, 284 { 285 "kind": "var", 286 "kindModifiers": "declare", 287 "labelDetails": { 288 "detail": " ID!", 289 }, 290 "name": "id", 291 "sortText": "6id", 292 }, 293 { 294 "kind": "var", 295 "kindModifiers": "declare", 296 "labelDetails": { 297 "description": "Maximum combat power a Pokémon may achieve at max level.", 298 "detail": " Int", 299 }, 300 "name": "maxCP", 301 "sortText": "7maxCP", 302 }, 303 { 304 "kind": "var", 305 "kindModifiers": "declare", 306 "labelDetails": { 307 "description": "Maximum health points a Pokémon may achieve at max level.", 308 "detail": " Int", 309 }, 310 "name": "maxHP", 311 "sortText": "8maxHP", 312 }, 313 { 314 "kind": "var", 315 "kindModifiers": "declare", 316 "labelDetails": { 317 "detail": " String!", 318 }, 319 "name": "name", 320 "sortText": "9name", 321 }, 322 { 323 "kind": "var", 324 "kindModifiers": "declare", 325 "labelDetails": { 326 "detail": " [PokemonType]", 327 }, 328 "name": "resistant", 329 "sortText": "10resistant", 330 }, 331 { 332 "kind": "var", 333 "kindModifiers": "declare", 334 "labelDetails": { 335 "detail": " [PokemonType]", 336 }, 337 "name": "types", 338 "sortText": "11types", 339 }, 340 { 341 "kind": "var", 342 "kindModifiers": "declare", 343 "labelDetails": { 344 "detail": " [PokemonType]", 345 }, 346 "name": "weaknesses", 347 "sortText": "12weaknesses", 348 }, 349 { 350 "kind": "var", 351 "kindModifiers": "declare", 352 "labelDetails": { 353 "detail": " PokemonDimension", 354 }, 355 "name": "weight", 356 "sortText": "13weight", 357 }, 358 { 359 "kind": "var", 360 "kindModifiers": "declare", 361 "labelDetails": { 362 "description": "The name of the current Object type at runtime.", 363 "detail": " String!", 364 }, 365 "name": "__typename", 366 "sortText": "14__typename", 367 }, 368 ] 369 `); 370 }, 30000); 371 372 it('gives semantic-diagnostics with unused fragments', async () => { 373 server.sendCommand('saveto', { 374 file: outfileUnusedFragment, 375 tmpfile: outfileUnusedFragment, 376 } satisfies ts.server.protocol.SavetoRequestArgs); 377 378 await server.waitForResponse( 379 e => 380 e.type === 'event' && 381 e.event === 'semanticDiag' && 382 e.body?.file === outfileUnusedFragment 383 ); 384 385 const res = server.responses.filter( 386 resp => 387 resp.type === 'event' && 388 resp.event === 'semanticDiag' && 389 resp.body?.file === outfileUnusedFragment 390 ); 391 expect(res[0].body.diagnostics).toMatchInlineSnapshot(` 392 [ 393 { 394 "category": "warning", 395 "code": 52003, 396 "end": { 397 "line": 2, 398 "offset": 37, 399 }, 400 "start": { 401 "line": 2, 402 "offset": 25, 403 }, 404 "text": "Unused co-located fragment definition(s) \\"pokemonFields\\" in './fragment'", 405 }, 406 ] 407 `); 408 }, 30000); 409 410 it('should not warn about unused fragments when using maskFragments', async () => { 411 server.sendCommand('saveto', { 412 file: outfileUsedFragmentMask, 413 tmpfile: outfileUsedFragmentMask, 414 } satisfies ts.server.protocol.SavetoRequestArgs); 415 416 await server.waitForResponse( 417 e => 418 e.type === 'event' && 419 e.event === 'semanticDiag' && 420 e.body?.file === outfileUsedFragmentMask 421 ); 422 423 const res = server.responses.filter( 424 resp => 425 resp.type === 'event' && 426 resp.event === 'semanticDiag' && 427 resp.body?.file === outfileUsedFragmentMask 428 ); 429 // Should have no diagnostics about unused fragments since maskFragments uses them 430 expect(res[0].body.diagnostics).toMatchInlineSnapshot(`[]`); 431 }, 30000); 432 433 it('gives quick-info at start of word (#15)', async () => { 434 server.send({ 435 seq: 11, 436 type: 'request', 437 command: 'quickinfo', 438 arguments: { 439 file: outfileCombinations, 440 line: 7, 441 offset: 5, 442 }, 443 }); 444 445 await server.waitForResponse( 446 response => 447 response.type === 'response' && response.command === 'quickinfo' 448 ); 449 450 const res = server.responses 451 .reverse() 452 .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); 453 454 expect(res).toBeDefined(); 455 expect(typeof res?.body).toEqual('object'); 456 expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); 457 }, 30000); 458 459 it('gives suggestions with empty line (#190)', async () => { 460 server.send({ 461 seq: 12, 462 type: 'request', 463 command: 'completionInfo', 464 arguments: { 465 file: outfileCombinations, 466 line: 19, 467 offset: 3, 468 includeExternalModuleExports: true, 469 includeInsertTextCompletions: true, 470 triggerKind: 1, 471 }, 472 }); 473 474 await server.waitForResponse( 475 response => 476 response.type === 'response' && response.command === 'completionInfo' 477 ); 478 479 const res = server.responses 480 .reverse() 481 .find( 482 resp => resp.type === 'response' && resp.command === 'completionInfo' 483 ); 484 485 expect(res).toBeDefined(); 486 expect(typeof res?.body.entries).toEqual('object'); 487 expect(res?.body.entries).toMatchInlineSnapshot(` 488 [ 489 { 490 "kind": "var", 491 "kindModifiers": "declare", 492 "labelDetails": { 493 "detail": " AttacksConnection", 494 }, 495 "name": "attacks", 496 "sortText": "0attacks", 497 }, 498 { 499 "kind": "var", 500 "kindModifiers": "declare", 501 "labelDetails": { 502 "detail": " [EvolutionRequirement]", 503 }, 504 "name": "evolutionRequirements", 505 "sortText": "2evolutionRequirements", 506 }, 507 { 508 "kind": "var", 509 "kindModifiers": "declare", 510 "labelDetails": { 511 "detail": " [Pokemon]", 512 }, 513 "name": "evolutions", 514 "sortText": "3evolutions", 515 }, 516 { 517 "kind": "var", 518 "kindModifiers": "declare", 519 "labelDetails": { 520 "description": "Likelihood of an attempt to catch a Pokémon to fail.", 521 "detail": " Float", 522 }, 523 "name": "fleeRate", 524 "sortText": "4fleeRate", 525 }, 526 { 527 "kind": "var", 528 "kindModifiers": "declare", 529 "labelDetails": { 530 "detail": " PokemonDimension", 531 }, 532 "name": "height", 533 "sortText": "5height", 534 }, 535 { 536 "kind": "var", 537 "kindModifiers": "declare", 538 "labelDetails": { 539 "detail": " ID!", 540 }, 541 "name": "id", 542 "sortText": "6id", 543 }, 544 { 545 "kind": "var", 546 "kindModifiers": "declare", 547 "labelDetails": { 548 "description": "Maximum combat power a Pokémon may achieve at max level.", 549 "detail": " Int", 550 }, 551 "name": "maxCP", 552 "sortText": "7maxCP", 553 }, 554 { 555 "kind": "var", 556 "kindModifiers": "declare", 557 "labelDetails": { 558 "description": "Maximum health points a Pokémon may achieve at max level.", 559 "detail": " Int", 560 }, 561 "name": "maxHP", 562 "sortText": "8maxHP", 563 }, 564 { 565 "kind": "var", 566 "kindModifiers": "declare", 567 "labelDetails": { 568 "detail": " String!", 569 }, 570 "name": "name", 571 "sortText": "9name", 572 }, 573 { 574 "kind": "var", 575 "kindModifiers": "declare", 576 "labelDetails": { 577 "detail": " [PokemonType]", 578 }, 579 "name": "resistant", 580 "sortText": "10resistant", 581 }, 582 { 583 "kind": "var", 584 "kindModifiers": "declare", 585 "labelDetails": { 586 "detail": " [PokemonType]", 587 }, 588 "name": "types", 589 "sortText": "11types", 590 }, 591 { 592 "kind": "var", 593 "kindModifiers": "declare", 594 "labelDetails": { 595 "detail": " [PokemonType]", 596 }, 597 "name": "weaknesses", 598 "sortText": "12weaknesses", 599 }, 600 { 601 "kind": "var", 602 "kindModifiers": "declare", 603 "labelDetails": { 604 "detail": " PokemonDimension", 605 }, 606 "name": "weight", 607 "sortText": "13weight", 608 }, 609 { 610 "kind": "var", 611 "kindModifiers": "declare", 612 "labelDetails": { 613 "description": "The name of the current Object type at runtime.", 614 "detail": " String!", 615 }, 616 "name": "__typename", 617 "sortText": "14__typename", 618 }, 619 ] 620 `); 621 }, 30000); 622 623 it('gives suggestions for type-conditions (#261)', async () => { 624 server.send({ 625 seq: 13, 626 type: 'request', 627 command: 'completionInfo', 628 arguments: { 629 file: outfileTypeCondition, 630 line: 14, 631 offset: 14, 632 includeExternalModuleExports: true, 633 includeInsertTextCompletions: true, 634 triggerKind: 1, 635 }, 636 }); 637 638 await server.waitForResponse( 639 response => 640 response.type === 'response' && response.command === 'completionInfo' 641 ); 642 643 const res = server.responses 644 .reverse() 645 .find( 646 resp => resp.type === 'response' && resp.command === 'completionInfo' 647 ); 648 649 expect(res).toBeDefined(); 650 expect(typeof res?.body.entries).toEqual('object'); 651 expect(res?.body.entries).toMatchInlineSnapshot(` 652 [ 653 { 654 "kind": "var", 655 "kindModifiers": "declare", 656 "labelDetails": { 657 "description": "", 658 }, 659 "name": "Pokemon", 660 "sortText": "0", 661 }, 662 ] 663 `); 664 }, 30000); 665}); 666 667describe('Fragment dependencies - Issue #494', () => { 668 const projectPath = path.resolve(__dirname, 'fixture-project-tada'); 669 const outfileMissingFragmentDep = path.join( 670 projectPath, 671 'missing-fragment-dep.ts' 672 ); 673 674 let server: TSServer; 675 beforeAll(async () => { 676 server = new TSServer(projectPath, { debugLog: false }); 677 678 server.sendCommand('open', { 679 file: outfileMissingFragmentDep, 680 fileContent: '// empty', 681 scriptKindName: 'TS', 682 } satisfies ts.server.protocol.OpenRequestArgs); 683 684 server.sendCommand('updateOpen', { 685 openFiles: [ 686 { 687 file: outfileMissingFragmentDep, 688 fileContent: fs.readFileSync( 689 path.join(projectPath, 'fixtures/missing-fragment-dep.ts'), 690 'utf-8' 691 ), 692 }, 693 ], 694 } satisfies ts.server.protocol.UpdateOpenRequestArgs); 695 696 server.sendCommand('saveto', { 697 file: outfileMissingFragmentDep, 698 tmpfile: outfileMissingFragmentDep, 699 } satisfies ts.server.protocol.SavetoRequestArgs); 700 }); 701 702 afterAll(() => { 703 try { 704 fs.unlinkSync(outfileMissingFragmentDep); 705 } catch {} 706 }); 707 708 it('warns about missing fragment dep even when fragment is used in another query in same file', async () => { 709 await server.waitForResponse( 710 e => 711 e.type === 'event' && 712 e.event === 'semanticDiag' && 713 e.body?.file === outfileMissingFragmentDep 714 ); 715 716 const res = server.responses.filter( 717 resp => 718 resp.type === 'event' && 719 resp.event === 'semanticDiag' && 720 resp.body?.file === outfileMissingFragmentDep 721 ); 722 723 // Should have a diagnostic about the unknown fragment in SecondQuery 724 expect(res.length).toBeGreaterThan(0); 725 expect(res[0].body.diagnostics.length).toBeGreaterThan(0); 726 727 const fragmentError = res[0].body.diagnostics.find((diag: any) => 728 diag.text.includes('PokemonBasicInfo') 729 ); 730 731 expect(fragmentError).toBeDefined(); 732 expect(fragmentError.text).toBe('Unknown fragment "PokemonBasicInfo".'); 733 expect(fragmentError.code).toBe(52001); 734 expect(fragmentError.category).toBe('error'); 735 }, 30000); 736});