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 <CompilerOptions>{ 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 }); 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 ]; 282 283 it("should not throw when commands are executed with invalid arguments", () => { 284 let i = 0; 285 for (const name of allCommandNames) { 286 const req: protocol.Request = { 287 command: name, 288 seq: i, 289 type: "request" 290 }; 291 i++; 292 session.onMessage(JSON.stringify(req)); 293 req.seq = i; 294 i++; 295 req.arguments = {}; 296 session.onMessage(JSON.stringify(req)); 297 req.seq = i; 298 i++; 299 req.arguments = null; // eslint-disable-line no-null/no-null 300 session.onMessage(JSON.stringify(req)); 301 req.seq = i; 302 i++; 303 req.arguments = ""; 304 session.onMessage(JSON.stringify(req)); 305 req.seq = i; 306 i++; 307 req.arguments = 0; 308 session.onMessage(JSON.stringify(req)); 309 req.seq = i; 310 i++; 311 req.arguments = []; 312 session.onMessage(JSON.stringify(req)); 313 } 314 session.onMessage("GARBAGE NON_JSON DATA"); 315 }); 316 it("should output the response for a correctly handled message", () => { 317 const req: protocol.ConfigureRequest = { 318 command: CommandNames.Configure, 319 seq: 0, 320 type: "request", 321 arguments: { 322 hostInfo: "unit test", 323 formatOptions: { 324 newLineCharacter: "`n" 325 } 326 } 327 }; 328 329 session.onMessage(JSON.stringify(req)); 330 331 expect(lastSent).to.deep.equal(<protocol.ConfigureResponse>{ 332 command: CommandNames.Configure, 333 type: "response", 334 success: true, 335 request_seq: 0, 336 seq: 0, 337 body: undefined, 338 performanceData: undefined, 339 }); 340 }); 341 }); 342 343 describe("send", () => { 344 it("is an overrideable handle which sends protocol messages over the wire", () => { 345 const msg: protocol.Request = { seq: 0, type: "request", command: "" }; 346 const strmsg = JSON.stringify(msg); 347 const len = 1 + Utils.byteLength(strmsg, "utf8"); 348 const resultMsg = `Content-Length: ${len}\r\n\r\n${strmsg}\n`; 349 350 session.send = Session.prototype.send; 351 assert(session.send); 352 expect(session.send(msg)).to.not.exist; // eslint-disable-line @typescript-eslint/no-unused-expressions 353 expect(lastWrittenToHost).to.equal(resultMsg); 354 }); 355 }); 356 357 describe("addProtocolHandler", () => { 358 it("can add protocol handlers", () => { 359 const respBody = { 360 item: false 361 }; 362 const command = "newhandle"; 363 const result: HandlerResponse = { 364 response: respBody, 365 responseRequired: true 366 }; 367 368 session.addProtocolHandler(command, () => result); 369 370 expect(session.executeCommand({ 371 command, 372 seq: 0, 373 type: "request" 374 })).to.deep.equal(result); 375 }); 376 it("throws when a duplicate handler is passed", () => { 377 const respBody = { 378 item: false 379 }; 380 const resp: HandlerResponse = { 381 response: respBody, 382 responseRequired: true 383 }; 384 const command = "newhandle"; 385 386 session.addProtocolHandler(command, () => resp); 387 388 expect(() => session.addProtocolHandler(command, () => resp)) 389 .to.throw(`Protocol handler already exists for command "${command}"`); 390 }); 391 }); 392 393 describe("event", () => { 394 it("can format event responses and send them", () => { 395 const evt = "notify-test"; 396 const info = { 397 test: true 398 }; 399 400 session.event(info, evt); 401 402 expect(lastSent).to.deep.equal({ 403 type: "event", 404 seq: 0, 405 event: evt, 406 body: info 407 }); 408 }); 409 }); 410 411 describe("output", () => { 412 it("can format command responses and send them", () => { 413 const body = { 414 block: { 415 key: "value" 416 } 417 }; 418 const command = "test"; 419 420 session.output(body, command, /*reqSeq*/ 0); 421 422 expect(lastSent).to.deep.equal({ 423 seq: 0, 424 request_seq: 0, 425 type: "response", 426 command, 427 body, 428 success: true, 429 performanceData: undefined, 430 }); 431 }); 432 }); 433 }); 434 435 describe("unittests:: tsserver:: Session:: exceptions", () => { 436 437 // 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 438 let oldPrepare: AnyFunction; 439 let oldStackTraceLimit: number; 440 before(() => { 441 oldStackTraceLimit = (Error as any).stackTraceLimit; 442 oldPrepare = (Error as any).prepareStackTrace; 443 delete (Error as any).prepareStackTrace; 444 (Error as any).stackTraceLimit = 10; 445 }); 446 447 after(() => { 448 (Error as any).prepareStackTrace = oldPrepare; 449 (Error as any).stackTraceLimit = oldStackTraceLimit; 450 }); 451 452 const command = "testhandler"; 453 class TestSession extends Session { 454 lastSent: protocol.Message | undefined; 455 private exceptionRaisingHandler(_request: protocol.Request): { response?: any, responseRequired: boolean } { 456 f1(); 457 return Debug.fail(); // unreachable, throw to make compiler happy 458 function f1() { 459 throw new Error("myMessage"); 460 } 461 } 462 463 constructor() { 464 super({ 465 host: mockHost, 466 cancellationToken: nullCancellationToken, 467 useSingleInferredProject: false, 468 useInferredProjectPerProjectRoot: false, 469 typingsInstaller: undefined!, // TODO: GH#18217 470 byteLength: Utils.byteLength, 471 hrtime: process.hrtime, 472 logger: projectSystem.nullLogger, 473 canUseEvents: true 474 }); 475 this.addProtocolHandler(command, this.exceptionRaisingHandler); 476 } 477 send(msg: protocol.Message) { 478 this.lastSent = msg; 479 } 480 } 481 482 it("raised in a protocol handler generate an event", () => { 483 484 const session = new TestSession(); 485 486 const request = { 487 command, 488 seq: 0, 489 type: "request" 490 }; 491 492 session.onMessage(JSON.stringify(request)); 493 const lastSent = session.lastSent as protocol.Response; 494 495 expect(lastSent).to.contain({ 496 seq: 0, 497 type: "response", 498 command, 499 success: false 500 }); 501 502 expect(lastSent.message).has.string("myMessage").and.has.string("f1"); 503 }); 504 }); 505 506 describe("unittests:: tsserver:: Session:: how Session is extendable via subclassing", () => { 507 class TestSession extends Session { 508 lastSent: protocol.Message | undefined; 509 customHandler = "testhandler"; 510 constructor() { 511 super({ 512 host: mockHost, 513 cancellationToken: nullCancellationToken, 514 useSingleInferredProject: false, 515 useInferredProjectPerProjectRoot: false, 516 typingsInstaller: undefined!, // TODO: GH#18217 517 byteLength: Utils.byteLength, 518 hrtime: process.hrtime, 519 logger: projectSystem.createHasErrorMessageLogger().logger, 520 canUseEvents: true 521 }); 522 this.addProtocolHandler(this.customHandler, () => { 523 return { response: undefined, responseRequired: true }; 524 }); 525 } 526 send(msg: protocol.Message) { 527 this.lastSent = msg; 528 } 529 } 530 531 it("can override methods such as send", () => { 532 const session = new TestSession(); 533 const body = { 534 block: { 535 key: "value" 536 } 537 }; 538 const command = "test"; 539 540 session.output(body, command, /*reqSeq*/ 0); 541 542 expect(session.lastSent).to.deep.equal({ 543 seq: 0, 544 request_seq: 0, 545 type: "response", 546 command, 547 body, 548 success: true, 549 performanceData: undefined, 550 }); 551 }); 552 it("can add and respond to new protocol handlers", () => { 553 const session = new TestSession(); 554 555 expect(session.executeCommand({ 556 seq: 0, 557 type: "request", 558 command: session.customHandler 559 })).to.deep.equal({ 560 response: undefined, 561 responseRequired: true 562 }); 563 }); 564 it("has access to the project service", () => { 565 new class extends TestSession { 566 constructor() { 567 super(); 568 assert(this.projectService); 569 expect(this.projectService).to.be.instanceOf(ProjectService); 570 } 571 }(); 572 }); 573 }); 574 575 describe("unittests:: tsserver:: Session:: an example of using the Session API to create an in-process server", () => { 576 class InProcSession extends Session { 577 private queue: protocol.Request[] = []; 578 constructor(private client: InProcClient) { 579 super({ 580 host: mockHost, 581 cancellationToken: nullCancellationToken, 582 useSingleInferredProject: false, 583 useInferredProjectPerProjectRoot: false, 584 typingsInstaller: undefined!, // TODO: GH#18217 585 byteLength: Utils.byteLength, 586 hrtime: process.hrtime, 587 logger: projectSystem.createHasErrorMessageLogger().logger, 588 canUseEvents: true 589 }); 590 this.addProtocolHandler("echo", (req: protocol.Request) => ({ 591 response: req.arguments, 592 responseRequired: true 593 })); 594 } 595 596 send(msg: protocol.Message) { 597 this.client.handle(msg); 598 } 599 600 enqueue(msg: protocol.Request) { 601 this.queue.unshift(msg); 602 } 603 604 handleRequest(msg: protocol.Request) { 605 let response: protocol.Response; 606 try { 607 response = this.executeCommand(msg).response as protocol.Response; 608 } 609 catch (e) { 610 this.output(undefined, msg.command, msg.seq, e.toString()); 611 return; 612 } 613 if (response) { 614 this.output(response, msg.command, msg.seq); 615 } 616 } 617 618 consumeQueue() { 619 while (this.queue.length > 0) { 620 const elem = this.queue.pop()!; 621 this.handleRequest(elem); 622 } 623 } 624 } 625 626 class InProcClient { 627 private server: InProcSession | undefined; 628 private seq = 0; 629 private callbacks: ((resp: protocol.Response) => void)[] = []; 630 private eventHandlers = new Map<string, (args: any) => void>(); 631 632 handle(msg: protocol.Message): void { 633 if (msg.type === "response") { 634 const response = <protocol.Response>msg; 635 const handler = this.callbacks[response.request_seq]; 636 if (handler) { 637 handler(response); 638 delete this.callbacks[response.request_seq]; 639 } 640 } 641 else if (msg.type === "event") { 642 const event = <protocol.Event>msg; 643 this.emit(event.event, event.body); 644 } 645 } 646 647 emit(name: string, args: any): void { 648 const handler = this.eventHandlers.get(name); 649 if (handler) { 650 handler(args); 651 } 652 } 653 654 on(name: string, handler: (args: any) => void): void { 655 this.eventHandlers.set(name, handler); 656 } 657 658 connect(session: InProcSession): void { 659 this.server = session; 660 } 661 662 execute(command: string, args: any, callback: (resp: protocol.Response) => void): void { 663 if (!this.server) { 664 return; 665 } 666 this.seq++; 667 this.server.enqueue({ 668 seq: this.seq, 669 type: "request", 670 command, 671 arguments: args 672 }); 673 this.callbacks[this.seq] = callback; 674 } 675 } 676 677 it("can be constructed and respond to commands", (done) => { 678 const cli = new InProcClient(); 679 const session = new InProcSession(cli); 680 const toEcho = { 681 data: true 682 }; 683 const toEvent = { 684 data: false 685 }; 686 let responses = 0; 687 688 // Connect the client 689 cli.connect(session); 690 691 // Add an event handler 692 cli.on("testevent", (eventinfo) => { 693 expect(eventinfo).to.equal(toEvent); 694 responses++; 695 expect(responses).to.equal(1); 696 }); 697 698 // Trigger said event from the server 699 session.event(toEvent, "testevent"); 700 701 // Queue an echo command 702 cli.execute("echo", toEcho, (resp) => { 703 assert(resp.success, resp.message); 704 responses++; 705 expect(responses).to.equal(2); 706 expect(resp.body).to.deep.equal(toEcho); 707 }); 708 709 // Queue a configure command 710 cli.execute("configure", { 711 hostInfo: "unit test", 712 formatOptions: { 713 newLineCharacter: "`n" 714 } 715 }, (resp) => { 716 assert(resp.success, resp.message); 717 responses++; 718 expect(responses).to.equal(3); 719 done(); 720 }); 721 722 // Consume the queue and trigger the callbacks 723 session.consumeQueue(); 724 }); 725 }); 726 727 describe("unittests:: tsserver:: Session:: helpers", () => { 728 it(getLocationInNewDocument.name, () => { 729 const text = `// blank line\nconst x = 0;`; 730 const renameLocationInOldText = text.indexOf("0"); 731 const fileName = "/a.ts"; 732 const edits: FileTextChanges = { 733 fileName, 734 textChanges: [ 735 { 736 span: { start: 0, length: 0 }, 737 newText: "const newLocal = 0;\n\n", 738 }, 739 { 740 span: { start: renameLocationInOldText, length: 1 }, 741 newText: "newLocal", 742 }, 743 ], 744 }; 745 const renameLocationInNewText = renameLocationInOldText + edits.textChanges[0].newText.length; 746 const res = getLocationInNewDocument(text, fileName, renameLocationInNewText, [edits]); 747 assert.deepEqual(res, { line: 4, offset: 11 }); 748 }); 749 }); 750} 751