From 8e93a49c7b6e2e8b964b45a4d0ed651e1aecc182 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 21 Jul 2015 16:05:03 -0700 Subject: [PATCH] Narrow exported session API, Unit tests for session API --- Jakefile.js | 1 + src/harness/harness.ts | 1 + src/server/session.ts | 90 +++--- tests/cases/unittests/session.ts | 458 +++++++++++++++++++++++++++++++ 4 files changed, 505 insertions(+), 45 deletions(-) create mode 100644 tests/cases/unittests/session.ts diff --git a/Jakefile.js b/Jakefile.js index 33ad46c8b58..9ff73d5af14 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -136,6 +136,7 @@ var harnessSources = [ "services/documentRegistry.ts", "services/preProcessFile.ts", "services/patternMatcher.ts", + "session.ts", "versionCache.ts", "convertToBase64.ts", "transpile.ts" diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 9f26887ff90..cf81e172527 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -29,6 +29,7 @@ var Buffer: BufferConstructor = require('buffer').Buffer; // this will work in the browser via browserify var _chai: typeof chai = require('chai'); var assert: typeof _chai.assert = _chai.assert; +var expect: typeof _chai.expect = _chai.expect; declare var __dirname: string; // Node-specific var global = Function("return this").call(null); diff --git a/src/server/session.ts b/src/server/session.ts index e0c540db18b..47c605f5b47 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -109,13 +109,13 @@ namespace ts.server { } export class Session { - projectService: ProjectService; - pendingOperation = false; - fileHash: ts.Map = {}; - nextFileId = 1; - errorTimer: any; /*NodeJS.Timer | number*/ - immediateId: any; - changeSeq = 0; + protected projectService: ProjectService; + private pendingOperation = false; + private fileHash: ts.Map = {}; + private nextFileId = 1; + private errorTimer: any; /*NodeJS.Timer | number*/ + private immediateId: any; + private changeSeq = 0; constructor( private host: ServerHost, @@ -129,7 +129,7 @@ namespace ts.server { }); } - handleEvent(eventName: string, project: Project, fileName: string) { + private handleEvent(eventName: string, project: Project, fileName: string) { if (eventName == "context") { this.projectService.log("got context event, updating diagnostics for" + fileName, "Info"); this.updateErrorCheck([{ fileName, project }], this.changeSeq, @@ -137,7 +137,7 @@ namespace ts.server { } } - logError(err: Error, cmd: string) { + public logError(err: Error, cmd: string) { var typedErr = err; var msg = "Exception on executing command " + cmd; if (typedErr.message) { @@ -149,11 +149,11 @@ namespace ts.server { this.projectService.log(msg); } - sendLineToClient(line: string) { + private sendLineToClient(line: string) { this.host.write(line + this.host.newLine); } - send(msg: protocol.Message) { + public send(msg: protocol.Message) { var json = JSON.stringify(msg); if (this.logger.isVerbose()) { this.logger.info(msg.type + ": " + json); @@ -162,7 +162,7 @@ namespace ts.server { '\r\n\r\n' + json); } - event(info: any, eventName: string) { + public event(info: any, eventName: string) { var ev: protocol.Event = { seq: 0, type: "event", @@ -172,7 +172,7 @@ namespace ts.server { this.send(ev); } - response(info: any, cmdName: string, reqSeq = 0, errorMsg?: string) { + private response(info: any, cmdName: string, reqSeq = 0, errorMsg?: string) { var res: protocol.Response = { seq: 0, type: "response", @@ -189,11 +189,11 @@ namespace ts.server { this.send(res); } - output(body: any, commandName: string, requestSequence = 0, errorMessage?: string) { + public output(body: any, commandName: string, requestSequence = 0, errorMessage?: string) { this.response(body, commandName, requestSequence, errorMessage); } - semanticCheck(file: string, project: Project) { + private semanticCheck(file: string, project: Project) { try { var diags = project.compilerService.languageService.getSemanticDiagnostics(file); @@ -207,7 +207,7 @@ namespace ts.server { } } - syntacticCheck(file: string, project: Project) { + private syntacticCheck(file: string, project: Project) { try { var diags = project.compilerService.languageService.getSyntacticDiagnostics(file); if (diags) { @@ -220,12 +220,12 @@ namespace ts.server { } } - errorCheck(file: string, project: Project) { + private errorCheck(file: string, project: Project) { this.syntacticCheck(file, project); this.semanticCheck(file, project); } - updateProjectStructure(seq: number, matchSeq: (seq: number) => boolean, ms = 1500) { + private updateProjectStructure(seq: number, matchSeq: (seq: number) => boolean, ms = 1500) { setTimeout(() => { if (matchSeq(seq)) { this.projectService.updateProjectStructure(); @@ -233,7 +233,7 @@ namespace ts.server { }, ms); } - updateErrorCheck(checkList: PendingErrorCheck[], seq: number, + private updateErrorCheck(checkList: PendingErrorCheck[], seq: number, matchSeq: (seq: number) => boolean, ms = 1500, followMs = 200) { if (followMs > ms) { followMs = ms; @@ -269,7 +269,7 @@ namespace ts.server { } } - getDefinition(line: number, offset: number, fileName: string): protocol.FileSpan[] { + private getDefinition(line: number, offset: number, fileName: string): protocol.FileSpan[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -291,7 +291,7 @@ namespace ts.server { })); } - getTypeDefinition(line: number, offset: number, fileName: string): protocol.FileSpan[] { + private getTypeDefinition(line: number, offset: number, fileName: string): protocol.FileSpan[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -313,7 +313,7 @@ namespace ts.server { })); } - getOccurrences(line: number, offset: number, fileName: string): protocol.OccurrencesResponseItem[]{ + private getOccurrences(line: number, offset: number, fileName: string): protocol.OccurrencesResponseItem[]{ fileName = ts.normalizePath(fileName); let project = this.projectService.getProjectForFile(fileName); @@ -343,7 +343,7 @@ namespace ts.server { }); } - getProjectInfo(fileName: string, needFileNameList: boolean): protocol.ProjectInfo { + private getProjectInfo(fileName: string, needFileNameList: boolean): protocol.ProjectInfo { fileName = ts.normalizePath(fileName) let project = this.projectService.getProjectForFile(fileName) @@ -358,7 +358,7 @@ namespace ts.server { return projectInfo; } - getRenameLocations(line: number, offset: number, fileName: string,findInComments: boolean, findInStrings: boolean): protocol.RenameResponseBody { + private getRenameLocations(line: number, offset: number, fileName: string,findInComments: boolean, findInStrings: boolean): protocol.RenameResponseBody { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -426,7 +426,7 @@ namespace ts.server { return { info: renameInfo, locs: bakedRenameLocs }; } - getReferences(line: number, offset: number, fileName: string): protocol.ReferencesResponseBody { + private getReferences(line: number, offset: number, fileName: string): protocol.ReferencesResponseBody { // TODO: get all projects for this file; report refs for all projects deleting duplicates // can avoid duplicates by eliminating same ref file from subsequent projects var file = ts.normalizePath(fileName); @@ -473,12 +473,12 @@ namespace ts.server { }; } - openClientFile(fileName: string) { + private openClientFile(fileName: string) { var file = ts.normalizePath(fileName); this.projectService.openClientFile(file); } - getQuickInfo(line: number, offset: number, fileName: string): protocol.QuickInfoResponseBody { + private getQuickInfo(line: number, offset: number, fileName: string): protocol.QuickInfoResponseBody { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -504,7 +504,7 @@ namespace ts.server { }; } - getFormattingEditsForRange(line: number, offset: number, endLine: number, endOffset: number, fileName: string): protocol.CodeEdit[] { + private getFormattingEditsForRange(line: number, offset: number, endLine: number, endOffset: number, fileName: string): protocol.CodeEdit[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -531,7 +531,7 @@ namespace ts.server { }); } - getFormattingEditsAfterKeystroke(line: number, offset: number, key: string, fileName: string): protocol.CodeEdit[] { + private getFormattingEditsAfterKeystroke(line: number, offset: number, key: string, fileName: string): protocol.CodeEdit[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); @@ -607,7 +607,7 @@ namespace ts.server { }); } - getCompletions(line: number, offset: number, prefix: string, fileName: string): protocol.CompletionEntry[] { + private getCompletions(line: number, offset: number, prefix: string, fileName: string): protocol.CompletionEntry[] { if (!prefix) { prefix = ""; } @@ -633,7 +633,7 @@ namespace ts.server { }, []).sort((a, b) => a.name.localeCompare(b.name)); } - getCompletionEntryDetails(line: number, offset: number, + private getCompletionEntryDetails(line: number, offset: number, entryNames: string[], fileName: string): protocol.CompletionEntryDetails[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); @@ -653,7 +653,7 @@ namespace ts.server { }, []); } - getSignatureHelpItems(line: number, offset: number, fileName: string): protocol.SignatureHelpItems { + private getSignatureHelpItems(line: number, offset: number, fileName: string): protocol.SignatureHelpItems { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -682,7 +682,7 @@ namespace ts.server { return result; } - getDiagnostics(delay: number, fileNames: string[]) { + private getDiagnostics(delay: number, fileNames: string[]) { var checkList = fileNames.reduce((accum: PendingErrorCheck[], fileName: string) => { fileName = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(fileName); @@ -697,7 +697,7 @@ namespace ts.server { } } - change(line: number, offset: number, endLine: number, endOffset: number, insertString: string, fileName: string) { + private change(line: number, offset: number, endLine: number, endOffset: number, insertString: string, fileName: string) { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (project) { @@ -712,7 +712,7 @@ namespace ts.server { } } - reload(fileName: string, tempFileName: string, reqSeq = 0) { + private reload(fileName: string, tempFileName: string, reqSeq = 0) { var file = ts.normalizePath(fileName); var tmpfile = ts.normalizePath(tempFileName); var project = this.projectService.getProjectForFile(file); @@ -725,7 +725,7 @@ namespace ts.server { } } - saveToTmp(fileName: string, tempFileName: string) { + private saveToTmp(fileName: string, tempFileName: string) { var file = ts.normalizePath(fileName); var tmpfile = ts.normalizePath(tempFileName); @@ -735,12 +735,12 @@ namespace ts.server { } } - closeClientFile(fileName: string) { + private closeClientFile(fileName: string) { var file = ts.normalizePath(fileName); this.projectService.closeClientFile(file); } - decorateNavigationBarItem(project: Project, fileName: string, items: ts.NavigationBarItem[]): protocol.NavigationBarItem[] { + private decorateNavigationBarItem(project: Project, fileName: string, items: ts.NavigationBarItem[]): protocol.NavigationBarItem[] { if (!items) { return undefined; } @@ -759,7 +759,7 @@ namespace ts.server { })); } - getNavigationBarItems(fileName: string): protocol.NavigationBarItem[] { + private getNavigationBarItems(fileName: string): protocol.NavigationBarItem[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -775,7 +775,7 @@ namespace ts.server { return this.decorateNavigationBarItem(project, fileName, items); } - getNavigateToItems(searchValue: string, fileName: string, maxResultCount?: number): protocol.NavtoItem[] { + private getNavigateToItems(searchValue: string, fileName: string, maxResultCount?: number): protocol.NavtoItem[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); if (!project) { @@ -814,7 +814,7 @@ namespace ts.server { }); } - getBraceMatching(line: number, offset: number, fileName: string): protocol.TextSpan[] { + private getBraceMatching(line: number, offset: number, fileName: string): protocol.TextSpan[] { var file = ts.normalizePath(fileName); var project = this.projectService.getProjectForFile(file); @@ -836,7 +836,7 @@ namespace ts.server { })); } - exit() { + public exit() { } private handlers : Map<(request: protocol.Request) => {response?: any, responseRequired?: boolean}> = { @@ -942,14 +942,14 @@ namespace ts.server { return {response: this.getProjectInfo(file, needFileNameList)}; }, }; - addProtocolHandler(command: string, handler: (request: protocol.Request) => {response?: any, responseRequired: boolean}) { + public addProtocolHandler(command: string, handler: (request: protocol.Request) => {response?: any, responseRequired: boolean}) { if (this.handlers[command]) { throw new Error(`Protocol handler already exists for command "${command}"`); } this.handlers[command] = handler; } - executeCommand(request: protocol.Request) : {response?: any, responseRequired?: boolean} { + public executeCommand(request: protocol.Request) : {response?: any, responseRequired?: boolean} { var handler = this.handlers[request.command]; if (handler) { return handler(request); @@ -960,7 +960,7 @@ namespace ts.server { } } - onMessage(message: string) { + public onMessage(message: string) { if (this.logger.isVerbose()) { this.logger.info("request: " + message); var start = this.hrtime(); diff --git a/tests/cases/unittests/session.ts b/tests/cases/unittests/session.ts new file mode 100644 index 00000000000..53353c12dda --- /dev/null +++ b/tests/cases/unittests/session.ts @@ -0,0 +1,458 @@ +/// + +module ts.server { + let lastWrittenToHost: string, + mockHost: ServerHost = { + args: [], + newLine: '\n', + useCaseSensitiveFileNames: true, + write: (s) => lastWrittenToHost = s, + readFile: () => void 0, + writeFile: () => void 0, + resolvePath: () => void 0, + fileExists: () => false, + directoryExists: () => false, + createDirectory: () => void 0, + getExecutingFilePath: () => void 0, + getCurrentDirectory: () => void 0, + readDirectory: () => void 0, + exit: () => void 0 + }, + mockLogger: Logger = { + close(): void {}, + isVerbose(): boolean { return false; }, + loggingEnabled(): boolean { return false; }, + perftrc(s: string): void {}, + info(s: string): void {}, + startGroup(): void {}, + endGroup(): void {}, + msg(s: string, type?: string): void {}, + }; + + describe('the Session class', () => { + let session:Session, + lastSent:protocol.Message; + + beforeEach(() => { + session = new Session(mockHost, Buffer.byteLength, process.hrtime, mockLogger); + session.send = (msg: protocol.Message) => { + lastSent = msg; + }; + }); + + describe('executeCommand', () => { + it('should throw when commands are executed with invalid arguments', () => { + let req : protocol.FileRequest = { + command: CommandNames.Open, + seq: 0, + type: 'command', + arguments: { + file: undefined + } + }; + + expect(() => session.executeCommand(req)).to.throw(); + }); + it('should output an error response when a command does not exist', () => { + let req : protocol.Request = { + command: 'foobar', + seq: 0, + type: 'command' + }; + + session.executeCommand(req); + + expect(lastSent).to.deep.equal({ + command: CommandNames.Unknown, + type: 'response', + seq: 0, + message: 'Unrecognized JSON command: foobar', + request_seq: 0, + success: false + }); + }); + it('should return a tuple containing the response and if a response is required on success', () => { + let req : protocol.ConfigureRequest = { + command: CommandNames.Configure, + seq: 0, + type: 'command', + arguments: { + hostInfo: 'unit test', + formatOptions: { + newLineCharacter: '`n' + } + } + }; + + expect(session.executeCommand(req)).to.deep.equal({ + responseRequired: false + }); + expect(lastSent).to.deep.equal({ + command: CommandNames.Configure, + type: 'response', + success: true, + request_seq: 0, + seq: 0, + body: undefined + }); + }); + }); + + describe('onMessage', () => { + it('should not throw when commands are executed with invalid arguments', () => { + let i = 0; + for (name in CommandNames) { + if (!Object.prototype.hasOwnProperty.call(CommandNames, name)) { + continue; + } + let req : protocol.Request = { + command: name, + seq: i++, + type: 'command' + }; + session.onMessage(JSON.stringify(req)); + req.seq+=2; + req.arguments = {}; + session.onMessage(JSON.stringify(req)); + req.seq+=2; + req.arguments = null; + session.onMessage(JSON.stringify(req)); + } + }); + it('should output the response for a correctly handled message', () => { + let req : protocol.ConfigureRequest = { + command: CommandNames.Configure, + seq: 0, + type: 'command', + arguments: { + hostInfo: 'unit test', + formatOptions: { + newLineCharacter: '`n' + } + } + }; + + session.onMessage(JSON.stringify(req)); + + expect(lastSent).to.deep.equal({ + command: CommandNames.Configure, + type: 'response', + success: true, + request_seq: 0, + seq: 0, + body: undefined + }); + }); + }); + + describe('exit', () => { + it('is a noop which can be handled by subclasses', () => { + session.exit(); //does nothing, should keep running tests + expect(session).to.exist; + }); + }); + + describe('send', () => { + it('is an overrideable handle which sends protocol messages over the wire', () => { + let msg = {seq: 0, type: 'none'}, + strmsg = JSON.stringify(msg), + len = 1+Buffer.byteLength(strmsg, 'utf8'), + resultMsg = `Content-Length: ${len}\r\n\r\n${strmsg}\n`; + + session.send = Session.prototype.send; + assert(session.send); + expect(session.send(msg)).to.not.exist; + expect(lastWrittenToHost).to.equal(resultMsg); + }); + }); + + describe('addProtocolHandler', () => { + it('can add protocol handlers', () => { + let respBody = { + item: false + }, + command = 'newhandle', + result = { + response: respBody, + responseRequired: true + }; + + session.addProtocolHandler(command, (req) => result); + + expect(session.executeCommand({ + command, + seq: 0, + type: 'command' + })).to.deep.equal(result); + }); + it('throws when a duplicate handler is passed', () => { + let respBody = { + item: false + }, + resp = { + response: respBody, + responseRequired: true + }, + command = 'newhandle'; + + session.addProtocolHandler(command, (req) => resp); + + expect(() => session.addProtocolHandler(command, (req) => resp)) + .to.throw(`Protocol handler already exists for command "${command}"`); + }); + }); + + describe('event', () => { + it('can format event responses and send them', () => { + let evt = 'notify-test', + info = { + test: true + }; + + session.event(info, evt); + + expect(lastSent).to.deep.equal({ + type: 'event', + seq: 0, + event: evt, + body: info + }); + }); + }); + + describe('output', () => { + it('can format command responses and send them', () => { + let body = { + block: { + key: 'value' + } + }, + command = 'test'; + + session.output(body, command); + + expect(lastSent).to.deep.equal({ + seq: 0, + request_seq: 0, + type: 'response', + command, + body: body, + success: true + }); + }); + }); + }); + + describe('how Session is extendable via subclassing', () => { + let TestSession = class extends Session { + lastSent: protocol.Message; + customHandler:string = 'testhandler'; + constructor(){ + super(mockHost, Buffer.byteLength, process.hrtime, mockLogger); + this.addProtocolHandler(this.customHandler, () => { + return {response: undefined, responseRequired: true}; + }); + } + send(msg: protocol.Message) { + this.lastSent = msg; + } + }; + + it('can override methods such as send', () => { + let session = new TestSession(), + body = { + block: { + key: 'value' + } + }, + command = 'test'; + + session.output(body, command); + + expect(session.lastSent).to.deep.equal({ + seq: 0, + request_seq: 0, + type: 'response', + command, + body: body, + success: true + }); + }); + it('can add and respond to new protocol handlers', () => { + let session = new TestSession(); + + expect(session.executeCommand({ + seq: 0, + type: 'command', + command: session.customHandler + })).to.deep.equal({ + response: undefined, + responseRequired: true + }); + }); + it('has access to the project service', () => { + let ServiceSession = class extends TestSession { + constructor() { + super(); + assert(this.projectService); + expect(this.projectService).to.be.instanceOf(ProjectService); + } + }; + new ServiceSession(); + }); + }); + + describe('an example of using the Session API to create an in-process server', () => { + let inProcHost: ServerHost = { + args: [], + newLine: '\n', + useCaseSensitiveFileNames: true, + write: (s) => lastWrittenToHost = s, + readFile: () => void 0, + writeFile: () => void 0, + resolvePath: () => void 0, + fileExists: () => false, + directoryExists: () => false, + createDirectory: () => void 0, + getExecutingFilePath: () => void 0, + getCurrentDirectory: () => void 0, + readDirectory: () => void 0, + exit: () => void 0 + }, + InProcSession = class extends Session { + private queue: protocol.Request[] = []; + constructor(private client: {handle: (msg: protocol.Message) => void}) { + super(inProcHost, Buffer.byteLength, process.hrtime, mockLogger); + this.addProtocolHandler('echo', (req: protocol.Request) => ({ + response: req.arguments, + responseRequired: true + })); + } + + send(msg: protocol.Message) { + this.client.handle(msg); + } + + enqueue(msg: protocol.Request) { + this.queue = [msg].concat(this.queue); + } + + handleRequest(msg: protocol.Request) { + try { + var {response} = this.executeCommand(msg); + } catch (e) { + this.output(undefined, msg.command, msg.seq, e.toString()); + return; + } + if (response) { + this.output(response, msg.command, msg.seq); + } + } + + consumeQueue() { + while (this.queue.length > 0) { + let elem = this.queue[this.queue.length-1]; + this.queue = this.queue.slice(0,this.queue.length-1); + this.handleRequest(elem); + } + } + }, + InProcClient = class { + private server: Session&{enqueue: (msg: protocol.Request) => void}; + private seq: number = 0; + private callbacks: ts.Map<(resp: protocol.Response) => void> = {}; + private eventHandlers: ts.Map<(args: any) => void> = {}; + + handle(msg: protocol.Message): void { + if (msg.type === 'response') { + var response = msg; + if (this.callbacks[response.request_seq]) { + this.callbacks[response.request_seq](response); + delete this.callbacks[response.request_seq]; + } + } else if (msg.type === 'event') { + var event = msg; + this.emit(event.event, event.body); + } + } + + emit(name: string, args: any): void { + if (!this.eventHandlers[name]) { + return; + } + this.eventHandlers[name](args); + } + + on(name: string, handler: (args: any) => void): void { + this.eventHandlers[name] = handler; + } + + connect(session: Session&{enqueue: (msg: protocol.Request) => void}): void { + this.server = session; + } + + execute(command: string, args: any, callback: (resp: protocol.Response) => void): void { + if (!this.server) { + return; + } + this.seq++; + this.server.enqueue({ + seq: this.seq, + type: 'command', + command, + arguments: args + }); + this.callbacks[this.seq] = callback; + } + }; + + it('can be constructed and respond to commands', (done) => { + let cli = new InProcClient(), + session = new InProcSession(cli), + toEcho = { + data: true + }, + toEvent = { + data: false + }, + responses = 0; + + //Connect the client + cli.connect(session); + + //add an event handler + cli.on('testevent', (eventinfo) => { + expect(eventinfo).to.equal(toEvent); + responses++; + expect(responses).to.equal(1); + }); + + //trigger said event from the server + session.event(toEvent,'testevent'); + + //Queue an echo command + cli.execute('echo', toEcho, (resp) => { + assert(resp.success, resp.message); + responses++; + expect(responses).to.equal(2); + expect(resp.body).to.deep.equal(toEcho); + }); + + //Queue a configure command + cli.execute('configure', { + hostInfo: 'unit test', + formatOptions: { + newLineCharacter: '`n' + } + }, (resp) => { + assert(resp.success, resp.message); + responses++; + expect(responses).to.equal(3); + done(); + }); + + //Consume the queue and trigger the callbacks + session.consumeQueue(); + }); + }); +} \ No newline at end of file