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