1namespace ts.server { 2 const _chai: typeof import("chai") = require("chai"); 3 const expect: typeof _chai.expect = _chai.expect; 4 let lastWrittenToHost: string; 5 const noopFileWatcher: FileWatcher = { close: noop }; 6 const mockHost: ServerHost = { 7 args: [], 8 newLine: "\n", 9 useCaseSensitiveFileNames: true, 10 write(s): void { lastWrittenToHost = s; }, 11 readFile: returnUndefined, 12 writeFile: noop, 13 resolvePath(): string { return undefined!; }, // TODO: GH#18217 14 fileExists: () => false, 15 directoryExists: () => false, 16 getDirectories: () => [], 17 createDirectory: noop, 18 getExecutingFilePath(): string { return ""; }, 19 getCurrentDirectory(): string { return ""; }, 20 getEnvironmentVariable(): string { return ""; }, 21 readDirectory() { return []; }, 22 exit: noop, 23 setTimeout() { return 0; }, 24 clearTimeout: noop, 25 setImmediate: () => 0, 26 clearImmediate: noop, 27 createHash: Harness.mockHash, 28 watchFile: () => noopFileWatcher, 29 watchDirectory: () => noopFileWatcher 30 }; 31 32 class TestSession extends Session { 33 getProjectService() { 34 return this.projectService; 35 } 36 } 37 38 describe("unittests:: tsserver:: Session:: General functionality", () => { 39 let session: TestSession; 40 let lastSent: protocol.Message; 41 42 function createSession(): TestSession { 43 const opts: SessionOptions = { 44 host: mockHost, 45 cancellationToken: nullCancellationToken, 46 useSingleInferredProject: false, 47 useInferredProjectPerProjectRoot: false, 48 typingsInstaller: undefined!, // TODO: GH#18217 49 byteLength: Utils.byteLength, 50 hrtime: process.hrtime, 51 logger: projectSystem.nullLogger(), 52 canUseEvents: true 53 }; 54 return new TestSession(opts); 55 } 56 57 // Disable sourcemap support for the duration of the test, as sourcemapping the errors generated during this test is slow and not something we care to test 58 let oldPrepare: AnyFunction; 59 before(() => { 60 oldPrepare = (Error as any).prepareStackTrace; 61 delete (Error as any).prepareStackTrace; 62 }); 63 64 after(() => { 65 (Error as any).prepareStackTrace = oldPrepare; 66 }); 67 68 beforeEach(() => { 69 session = createSession(); 70 session.send = (msg: protocol.Message) => { 71 lastSent = msg; 72 }; 73 }); 74 75 describe("executeCommand", () => { 76 it("should throw when commands are executed with invalid arguments", () => { 77 const req: protocol.FileRequest = { 78 command: CommandNames.Open, 79 seq: 0, 80 type: "request", 81 arguments: { 82 file: undefined! // TODO: GH#18217 83 } 84 }; 85 86 expect(() => session.executeCommand(req)).to.throw(); 87 }); 88 it("should output an error response when a command does not exist", () => { 89 const req: protocol.Request = { 90 command: "foobar", 91 seq: 0, 92 type: "request" 93 }; 94 95 session.executeCommand(req); 96 97 const expected: protocol.Response = { 98 command: CommandNames.Unknown, 99 type: "response", 100 seq: 0, 101 message: "Unrecognized JSON command: foobar", 102 request_seq: 0, 103 success: false, 104 performanceData: undefined, 105 }; 106 expect(lastSent).to.deep.equal(expected); 107 }); 108 it("should return a tuple containing the response and if a response is required on success", () => { 109 const req: protocol.ConfigureRequest = { 110 command: CommandNames.Configure, 111 seq: 0, 112 type: "request", 113 arguments: { 114 hostInfo: "unit test", 115 formatOptions: { 116 newLineCharacter: "`n" 117 } 118 } 119 }; 120 121 expect(session.executeCommand(req)).to.deep.equal({ 122 responseRequired: false 123 }); 124 expect(lastSent).to.deep.equal({ 125 command: CommandNames.Configure, 126 type: "response", 127 success: true, 128 request_seq: 0, 129 seq: 0, 130 body: undefined, 131 performanceData: undefined, 132 }); 133 }); 134 it("should handle literal types in request", () => { 135 const configureRequest: protocol.ConfigureRequest = { 136 command: CommandNames.Configure, 137 seq: 0, 138 type: "request", 139 arguments: { 140 formatOptions: { 141 indentStyle: protocol.IndentStyle.Block, 142 } 143 } 144 }; 145 146 session.onMessage(JSON.stringify(configureRequest)); 147 148 assert.equal(session.getProjectService().getFormatCodeOptions("" as NormalizedPath).indentStyle, IndentStyle.Block); 149 150 const setOptionsRequest: protocol.SetCompilerOptionsForInferredProjectsRequest = { 151 command: CommandNames.CompilerOptionsForInferredProjects, 152 seq: 1, 153 type: "request", 154 arguments: { 155 options: { 156 module: protocol.ModuleKind.System, 157 target: protocol.ScriptTarget.ES5, 158 jsx: protocol.JsxEmit.React, 159 newLine: protocol.NewLineKind.Lf, 160 moduleResolution: protocol.ModuleResolutionKind.Node, 161 } 162 } 163 }; 164 session.onMessage(JSON.stringify(setOptionsRequest)); 165 assert.deepEqual( 166 session.getProjectService().getCompilerOptionsForInferredProjects(), 167 { 168 module: ModuleKind.System, 169 target: ScriptTarget.ES5, 170 jsx: JsxEmit.React, 171 newLine: NewLineKind.LineFeed, 172 moduleResolution: ModuleResolutionKind.NodeJs, 173 allowNonTsExtensions: true // injected by tsserver 174 } as CompilerOptions); 175 }); 176 177 it("Status request gives ts.version", () => { 178 const req: protocol.StatusRequest = { 179 command: CommandNames.Status, 180 seq: 0, 181 type: "request" 182 }; 183 184 const expected: protocol.StatusResponseBody = { 185 version: ts.version, // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier 186 }; 187 assert.deepEqual(session.executeCommand(req).response, expected); 188 }); 189 }); 190 191 describe("onMessage", () => { 192 const allCommandNames: CommandNames[] = [ 193 CommandNames.Brace, 194 CommandNames.BraceFull, 195 CommandNames.BraceCompletion, 196 CommandNames.Change, 197 CommandNames.Close, 198 CommandNames.Completions, 199 CommandNames.CompletionsFull, 200 CommandNames.CompletionDetails, 201 CommandNames.CompileOnSaveAffectedFileList, 202 CommandNames.Configure, 203 CommandNames.Definition, 204 CommandNames.DefinitionFull, 205 CommandNames.DefinitionAndBoundSpan, 206 CommandNames.DefinitionAndBoundSpanFull, 207 CommandNames.Implementation, 208 CommandNames.ImplementationFull, 209 CommandNames.Exit, 210 CommandNames.FileReferences, 211 CommandNames.FileReferencesFull, 212 CommandNames.Format, 213 CommandNames.Formatonkey, 214 CommandNames.FormatFull, 215 CommandNames.FormatonkeyFull, 216 CommandNames.FormatRangeFull, 217 CommandNames.Geterr, 218 CommandNames.GeterrForProject, 219 CommandNames.SemanticDiagnosticsSync, 220 CommandNames.SyntacticDiagnosticsSync, 221 CommandNames.SuggestionDiagnosticsSync, 222 CommandNames.NavBar, 223 CommandNames.NavBarFull, 224 CommandNames.Navto, 225 CommandNames.NavtoFull, 226 CommandNames.NavTree, 227 CommandNames.NavTreeFull, 228 CommandNames.Occurrences, 229 CommandNames.DocumentHighlights, 230 CommandNames.DocumentHighlightsFull, 231 CommandNames.JsxClosingTag, 232 CommandNames.Open, 233 CommandNames.Quickinfo, 234 CommandNames.QuickinfoFull, 235 CommandNames.References, 236 CommandNames.ReferencesFull, 237 CommandNames.Reload, 238 CommandNames.Rename, 239 CommandNames.RenameInfoFull, 240 CommandNames.RenameLocationsFull, 241 CommandNames.Saveto, 242 CommandNames.SignatureHelp, 243 CommandNames.SignatureHelpFull, 244 CommandNames.Status, 245 CommandNames.TypeDefinition, 246 CommandNames.ProjectInfo, 247 CommandNames.ReloadProjects, 248 CommandNames.Unknown, 249 CommandNames.OpenExternalProject, 250 CommandNames.CloseExternalProject, 251 CommandNames.SynchronizeProjectList, 252 CommandNames.ApplyChangedToOpenFiles, 253 CommandNames.EncodedSemanticClassificationsFull, 254 CommandNames.Cleanup, 255 CommandNames.OutliningSpans, 256 CommandNames.TodoComments, 257 CommandNames.Indentation, 258 CommandNames.DocCommentTemplate, 259 CommandNames.CompilerOptionsDiagnosticsFull, 260 CommandNames.NameOrDottedNameSpan, 261 CommandNames.BreakpointStatement, 262 CommandNames.CompilerOptionsForInferredProjects, 263 CommandNames.GetCodeFixes, 264 CommandNames.GetCodeFixesFull, 265 CommandNames.GetSupportedCodeFixes, 266 CommandNames.GetApplicableRefactors, 267 CommandNames.GetEditsForRefactor, 268 CommandNames.GetEditsForRefactorFull, 269 CommandNames.OrganizeImports, 270 CommandNames.OrganizeImportsFull, 271 CommandNames.GetEditsForFileRename, 272 CommandNames.GetEditsForFileRenameFull, 273 CommandNames.SelectionRange, 274 CommandNames.PrepareCallHierarchy, 275 CommandNames.ProvideCallHierarchyIncomingCalls, 276 CommandNames.ProvideCallHierarchyOutgoingCalls, 277 CommandNames.ToggleLineComment, 278 CommandNames.ToggleMultilineComment, 279 CommandNames.CommentSelection, 280 CommandNames.UncommentSelection, 281 CommandNames.ProvideInlayHints 282 ]; 283 284 it("should not throw when commands are executed with invalid arguments", () => { 285 let i = 0; 286 for (const name of allCommandNames) { 287 const req: protocol.Request = { 288 command: name, 289 seq: i, 290 type: "request" 291 }; 292 i++; 293 session.onMessage(JSON.stringify(req)); 294 req.seq = i; 295 i++; 296 req.arguments = {}; 297 session.onMessage(JSON.stringify(req)); 298 req.seq = i; 299 i++; 300 req.arguments = null; // eslint-disable-line no-null/no-null 301 session.onMessage(JSON.stringify(req)); 302 req.seq = i; 303 i++; 304 req.arguments = ""; 305 session.onMessage(JSON.stringify(req)); 306 req.seq = i; 307 i++; 308 req.arguments = 0; 309 session.onMessage(JSON.stringify(req)); 310 req.seq = i; 311 i++; 312 req.arguments = []; 313 session.onMessage(JSON.stringify(req)); 314 } 315 session.onMessage("GARBAGE NON_JSON DATA"); 316 }); 317 it("should output the response for a correctly handled message", () => { 318 const req: protocol.ConfigureRequest = { 319 command: CommandNames.Configure, 320 seq: 0, 321 type: "request", 322 arguments: { 323 hostInfo: "unit test", 324 formatOptions: { 325 newLineCharacter: "`n" 326 } 327 } 328 }; 329 330 session.onMessage(JSON.stringify(req)); 331 332 expect(lastSent).to.deep.equal({ 333 command: CommandNames.Configure, 334 type: "response", 335 success: true, 336 request_seq: 0, 337 seq: 0, 338 body: undefined, 339 performanceData: undefined, 340 } as protocol.ConfigureResponse); 341 }); 342 }); 343 344 describe("send", () => { 345 it("is an overrideable handle which sends protocol messages over the wire", () => { 346 const msg: protocol.Request = { seq: 0, type: "request", command: "" }; 347 const strmsg = JSON.stringify(msg); 348 const len = 1 + Utils.byteLength(strmsg, "utf8"); 349 const resultMsg = `Content-Length: ${len}\r\n\r\n${strmsg}\n`; 350 351 session.send = Session.prototype.send; 352 assert(session.send); 353 expect(session.send(msg)).to.not.exist; // eslint-disable-line @typescript-eslint/no-unused-expressions 354 expect(lastWrittenToHost).to.equal(resultMsg); 355 }); 356 }); 357 358 describe("addProtocolHandler", () => { 359 it("can add protocol handlers", () => { 360 const respBody = { 361 item: false 362 }; 363 const command = "newhandle"; 364 const result: HandlerResponse = { 365 response: respBody, 366 responseRequired: true 367 }; 368 369 session.addProtocolHandler(command, () => result); 370 371 expect(session.executeCommand({ 372 command, 373 seq: 0, 374 type: "request" 375 })).to.deep.equal(result); 376 }); 377 it("throws when a duplicate handler is passed", () => { 378 const respBody = { 379 item: false 380 }; 381 const resp: HandlerResponse = { 382 response: respBody, 383 responseRequired: true 384 }; 385 const command = "newhandle"; 386 387 session.addProtocolHandler(command, () => resp); 388 389 expect(() => session.addProtocolHandler(command, () => resp)) 390 .to.throw(`Protocol handler already exists for command "${command}"`); 391 }); 392 }); 393 394 describe("event", () => { 395 it("can format event responses and send them", () => { 396 const evt = "notify-test"; 397 const info = { 398 test: true 399 }; 400 401 session.event(info, evt); 402 403 expect(lastSent).to.deep.equal({ 404 type: "event", 405 seq: 0, 406 event: evt, 407 body: info 408 }); 409 }); 410 }); 411 412 describe("output", () => { 413 it("can format command responses and send them", () => { 414 const body = { 415 block: { 416 key: "value" 417 } 418 }; 419 const command = "test"; 420 421 session.output(body, command, /*reqSeq*/ 0); 422 423 expect(lastSent).to.deep.equal({ 424 seq: 0, 425 request_seq: 0, 426 type: "response", 427 command, 428 body, 429 success: true, 430 performanceData: undefined, 431 }); 432 }); 433 }); 434 }); 435 436 describe("unittests:: tsserver:: Session:: exceptions", () => { 437 438 // Disable sourcemap support for the duration of the test, as sourcemapping the errors generated during this test is slow and not something we care to test 439 let oldPrepare: AnyFunction; 440 let oldStackTraceLimit: number; 441 before(() => { 442 oldStackTraceLimit = (Error as any).stackTraceLimit; 443 oldPrepare = (Error as any).prepareStackTrace; 444 delete (Error as any).prepareStackTrace; 445 (Error as any).stackTraceLimit = 10; 446 }); 447 448 after(() => { 449 (Error as any).prepareStackTrace = oldPrepare; 450 (Error as any).stackTraceLimit = oldStackTraceLimit; 451 }); 452 453 const command = "testhandler"; 454 class TestSession extends Session { 455 lastSent: protocol.Message | undefined; 456 private exceptionRaisingHandler(_request: protocol.Request): { response?: any, responseRequired: boolean } { 457 f1(); 458 return Debug.fail(); // unreachable, throw to make compiler happy 459 function f1() { 460 throw new Error("myMessage"); 461 } 462 } 463 464 constructor() { 465 super({ 466 host: mockHost, 467 cancellationToken: nullCancellationToken, 468 useSingleInferredProject: false, 469 useInferredProjectPerProjectRoot: false, 470 typingsInstaller: undefined!, // TODO: GH#18217 471 byteLength: Utils.byteLength, 472 hrtime: process.hrtime, 473 logger: projectSystem.nullLogger(), 474 canUseEvents: true 475 }); 476 this.addProtocolHandler(command, this.exceptionRaisingHandler); 477 } 478 send(msg: protocol.Message) { 479 this.lastSent = msg; 480 } 481 } 482 483 it("raised in a protocol handler generate an event", () => { 484 485 const session = new TestSession(); 486 487 const request = { 488 command, 489 seq: 0, 490 type: "request" 491 }; 492 493 session.onMessage(JSON.stringify(request)); 494 const lastSent = session.lastSent as protocol.Response; 495 496 expect(lastSent).to.contain({ 497 seq: 0, 498 type: "response", 499 command, 500 success: false 501 }); 502 503 expect(lastSent.message).has.string("myMessage").and.has.string("f1"); 504 }); 505 }); 506 507 describe("unittests:: tsserver:: Session:: how Session is extendable via subclassing", () => { 508 class TestSession extends Session { 509 lastSent: protocol.Message | undefined; 510 customHandler = "testhandler"; 511 constructor() { 512 super({ 513 host: mockHost, 514 cancellationToken: nullCancellationToken, 515 useSingleInferredProject: false, 516 useInferredProjectPerProjectRoot: false, 517 typingsInstaller: undefined!, // TODO: GH#18217 518 byteLength: Utils.byteLength, 519 hrtime: process.hrtime, 520 logger: projectSystem.createHasErrorMessageLogger(), 521 canUseEvents: true 522 }); 523 this.addProtocolHandler(this.customHandler, () => { 524 return { response: undefined, responseRequired: true }; 525 }); 526 } 527 send(msg: protocol.Message) { 528 this.lastSent = msg; 529 } 530 } 531 532 it("can override methods such as send", () => { 533 const session = new TestSession(); 534 const body = { 535 block: { 536 key: "value" 537 } 538 }; 539 const command = "test"; 540 541 session.output(body, command, /*reqSeq*/ 0); 542 543 expect(session.lastSent).to.deep.equal({ 544 seq: 0, 545 request_seq: 0, 546 type: "response", 547 command, 548 body, 549 success: true, 550 performanceData: undefined, 551 }); 552 }); 553 it("can add and respond to new protocol handlers", () => { 554 const session = new TestSession(); 555 556 expect(session.executeCommand({ 557 seq: 0, 558 type: "request", 559 command: session.customHandler 560 })).to.deep.equal({ 561 response: undefined, 562 responseRequired: true 563 }); 564 }); 565 it("has access to the project service", () => { 566 new class extends TestSession { 567 constructor() { 568 super(); 569 assert(this.projectService); 570 expect(this.projectService).to.be.instanceOf(ProjectService); 571 } 572 }(); 573 }); 574 }); 575 576 describe("unittests:: tsserver:: Session:: an example of using the Session API to create an in-process server", () => { 577 class InProcSession extends Session { 578 private queue: protocol.Request[] = []; 579 constructor(private client: InProcClient) { 580 super({ 581 host: mockHost, 582 cancellationToken: nullCancellationToken, 583 useSingleInferredProject: false, 584 useInferredProjectPerProjectRoot: false, 585 typingsInstaller: undefined!, // TODO: GH#18217 586 byteLength: Utils.byteLength, 587 hrtime: process.hrtime, 588 logger: projectSystem.createHasErrorMessageLogger(), 589 canUseEvents: true 590 }); 591 this.addProtocolHandler("echo", (req: protocol.Request) => ({ 592 response: req.arguments, 593 responseRequired: true 594 })); 595 } 596 597 send(msg: protocol.Message) { 598 this.client.handle(msg); 599 } 600 601 enqueue(msg: protocol.Request) { 602 this.queue.unshift(msg); 603 } 604 605 handleRequest(msg: protocol.Request) { 606 let response: protocol.Response; 607 try { 608 response = this.executeCommand(msg).response as protocol.Response; 609 } 610 catch (e) { 611 this.output(undefined, msg.command, msg.seq, e.toString()); 612 return; 613 } 614 if (response) { 615 this.output(response, msg.command, msg.seq); 616 } 617 } 618 619 consumeQueue() { 620 while (this.queue.length > 0) { 621 const elem = this.queue.pop()!; 622 this.handleRequest(elem); 623 } 624 } 625 } 626 627 class InProcClient { 628 private server: InProcSession | undefined; 629 private seq = 0; 630 private callbacks: ((resp: protocol.Response) => void)[] = []; 631 private eventHandlers = new Map<string, (args: any) => void>(); 632 633 handle(msg: protocol.Message): void { 634 if (msg.type === "response") { 635 const response = msg as protocol.Response; 636 const handler = this.callbacks[response.request_seq]; 637 if (handler) { 638 handler(response); 639 delete this.callbacks[response.request_seq]; 640 } 641 } 642 else if (msg.type === "event") { 643 const event = msg as protocol.Event; 644 this.emit(event.event, event.body); 645 } 646 } 647 648 emit(name: string, args: any): void { 649 const handler = this.eventHandlers.get(name); 650 if (handler) { 651 handler(args); 652 } 653 } 654 655 on(name: string, handler: (args: any) => void): void { 656 this.eventHandlers.set(name, handler); 657 } 658 659 connect(session: InProcSession): void { 660 this.server = session; 661 } 662 663 execute(command: string, args: any, callback: (resp: protocol.Response) => void): void { 664 if (!this.server) { 665 return; 666 } 667 this.seq++; 668 this.server.enqueue({ 669 seq: this.seq, 670 type: "request", 671 command, 672 arguments: args 673 }); 674 this.callbacks[this.seq] = callback; 675 } 676 } 677 678 it("can be constructed and respond to commands", (done) => { 679 const cli = new InProcClient(); 680 const session = new InProcSession(cli); 681 const toEcho = { 682 data: true 683 }; 684 const toEvent = { 685 data: false 686 }; 687 let responses = 0; 688 689 // Connect the client 690 cli.connect(session); 691 692 // Add an event handler 693 cli.on("testevent", (eventinfo) => { 694 expect(eventinfo).to.equal(toEvent); 695 responses++; 696 expect(responses).to.equal(1); 697 }); 698 699 // Trigger said event from the server 700 session.event(toEvent, "testevent"); 701 702 // Queue an echo command 703 cli.execute("echo", toEcho, (resp) => { 704 assert(resp.success, resp.message); 705 responses++; 706 expect(responses).to.equal(2); 707 expect(resp.body).to.deep.equal(toEcho); 708 }); 709 710 // Queue a configure command 711 cli.execute("configure", { 712 hostInfo: "unit test", 713 formatOptions: { 714 newLineCharacter: "`n" 715 } 716 }, (resp) => { 717 assert(resp.success, resp.message); 718 responses++; 719 expect(responses).to.equal(3); 720 done(); 721 }); 722 723 // Consume the queue and trigger the callbacks 724 session.consumeQueue(); 725 }); 726 }); 727 728 describe("unittests:: tsserver:: Session:: helpers", () => { 729 it(getLocationInNewDocument.name, () => { 730 const text = `// blank line\nconst x = 0;`; 731 const renameLocationInOldText = text.indexOf("0"); 732 const fileName = "/a.ts"; 733 const edits: FileTextChanges = { 734 fileName, 735 textChanges: [ 736 { 737 span: { start: 0, length: 0 }, 738 newText: "const newLocal = 0;\n\n", 739 }, 740 { 741 span: { start: renameLocationInOldText, length: 1 }, 742 newText: "newLocal", 743 }, 744 ], 745 }; 746 const renameLocationInNewText = renameLocationInOldText + edits.textChanges[0].newText.length; 747 const res = getLocationInNewDocument(text, fileName, renameLocationInNewText, [edits]); 748 assert.deepEqual(res, { line: 4, offset: 11 }); 749 }); 750 }); 751} 752