diff --git a/src/harness/unittests/telemetry.ts b/src/harness/unittests/telemetry.ts index 7c09806632180..9bb2db738013d 100644 --- a/src/harness/unittests/telemetry.ts +++ b/src/harness/unittests/telemetry.ts @@ -5,9 +5,9 @@ namespace ts.projectSystem { describe("project telemetry", () => { it("does nothing for inferred project", () => { const file = makeFile("/a.js"); - const et = new EventTracker([file]); + const et = new TestServerEventManager([file]); et.service.openClientFile(file.path); - assert.equal(et.getEvents().length, 0); + et.hasZeroEvent(ts.server.ProjectInfoTelemetryEvent); }); it("only sends an event once", () => { @@ -15,7 +15,7 @@ namespace ts.projectSystem { const file2 = makeFile("/b.ts"); const tsconfig = makeFile("/a/tsconfig.json", {}); - const et = new EventTracker([file, file2, tsconfig]); + const et = new TestServerEventManager([file, file2, tsconfig]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({}, tsconfig.path); @@ -25,12 +25,12 @@ namespace ts.projectSystem { et.service.openClientFile(file2.path); checkNumberOfProjects(et.service, { inferredProjects: 1 }); - assert.equal(et.getEvents().length, 0); + et.hasZeroEvent(ts.server.ProjectInfoTelemetryEvent); et.service.openClientFile(file.path); checkNumberOfProjects(et.service, { configuredProjects: 1, inferredProjects: 1 }); - assert.equal(et.getEvents().length, 0); + et.hasZeroEvent(ts.server.ProjectInfoTelemetryEvent); }); it("counts files by extension", () => { @@ -39,7 +39,7 @@ namespace ts.projectSystem { const compilerOptions: ts.CompilerOptions = { allowJs: true }; const tsconfig = makeFile("/tsconfig.json", { compilerOptions, include: ["src"] }); - const et = new EventTracker([...files, notIncludedFile, tsconfig]); + const et = new TestServerEventManager([...files, notIncludedFile, tsconfig]); et.service.openClientFile(files[0].path); et.assertProjectInfoTelemetryEvent({ fileStats: { ts: 2, tsx: 1, js: 1, jsx: 1, dts: 1 }, @@ -50,7 +50,7 @@ namespace ts.projectSystem { it("works with external project", () => { const file1 = makeFile("/a.ts"); - const et = new EventTracker([file1]); + const et = new TestServerEventManager([file1]); const compilerOptions: ts.server.protocol.CompilerOptions = { strict: true }; const projectFileName = "/hunter2/foo.csproj"; @@ -148,7 +148,7 @@ namespace ts.projectSystem { (compilerOptions as any).unknownCompilerOption = "hunter2"; // These are always ignored. const tsconfig = makeFile("/tsconfig.json", { compilerOptions, files: ["/a.ts"] }); - const et = new EventTracker([file, tsconfig]); + const et = new TestServerEventManager([file, tsconfig]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({ @@ -168,7 +168,7 @@ namespace ts.projectSystem { compileOnSave: true, }); - const et = new EventTracker([tsconfig, file]); + const et = new TestServerEventManager([tsconfig, file]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({ extends: true, @@ -198,7 +198,7 @@ namespace ts.projectSystem { exclude: [], }, }); - const et = new EventTracker([jsconfig, file]); + const et = new TestServerEventManager([jsconfig, file]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({ projectId: Harness.mockHash("/jsconfig.json"), @@ -216,10 +216,10 @@ namespace ts.projectSystem { it("detects whether language service was disabled", () => { const file = makeFile("/a.js"); const tsconfig = makeFile("/jsconfig.json", {}); - const et = new EventTracker([tsconfig, file]); + const et = new TestServerEventManager([tsconfig, file]); et.host.getFileSize = () => server.maxProgramSizeForNonTsFiles + 1; et.service.openClientFile(file.path); - et.getEvent(server.ProjectLanguageServiceStateEvent, /*mayBeMore*/ true); + et.getEvent(server.ProjectLanguageServiceStateEvent); et.assertProjectInfoTelemetryEvent({ projectId: Harness.mockHash("/jsconfig.json"), fileStats: fileStats({ js: 1 }), @@ -235,63 +235,7 @@ namespace ts.projectSystem { }); }); - class EventTracker { - private events: server.ProjectServiceEvent[] = []; - readonly service: TestProjectService; - readonly host: projectSystem.TestServerHost; - - constructor(files: projectSystem.FileOrFolder[]) { - this.host = createServerHost(files); - this.service = createProjectService(this.host, { - eventHandler: event => { - this.events.push(event); - }, - }); - } - - getEvents(): ReadonlyArray { - const events = this.events; - this.events = []; - return events; - } - - assertProjectInfoTelemetryEvent(partial: Partial, configFile?: string): void { - assert.deepEqual(this.getEvent(ts.server.ProjectInfoTelemetryEvent), { - projectId: Harness.mockHash(configFile || "/tsconfig.json"), - fileStats: fileStats({ ts: 1 }), - compilerOptions: {}, - extends: false, - files: false, - include: false, - exclude: false, - compileOnSave: false, - typeAcquisition: { - enable: false, - exclude: false, - include: false, - }, - configFileName: "tsconfig.json", - projectType: "configured", - languageServiceEnabled: true, - version: ts.version, - ...partial, - }); - } - - getEvent(eventName: T["eventName"], mayBeMore = false): T["data"] { - if (mayBeMore) { assert(this.events.length !== 0); } - else { assert.equal(this.events.length, 1); } - const event = this.events.shift(); - assert.equal(event.eventName, eventName); - return event.data; - } - } - function makeFile(path: string, content: {} = ""): projectSystem.FileOrFolder { return { path, content: isString(content) ? "" : JSON.stringify(content) }; } - - function fileStats(nonZeroStats: Partial): server.FileStats { - return { ts: 0, tsx: 0, dts: 0, js: 0, jsx: 0, ...nonZeroStats }; - } } diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index f7d082128cdf1..716c36d1e5d2a 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -132,16 +132,77 @@ namespace ts.projectSystem { return map(fileNames, toExternalFile); } - class TestServerEventManager { - public events: server.ProjectServiceEvent[] = []; + export function fileStats(nonZeroStats: Partial): server.FileStats { + return { ts: 0, tsx: 0, dts: 0, js: 0, jsx: 0, ...nonZeroStats }; + } + + export class TestServerEventManager { + private events: server.ProjectServiceEvent[] = []; + readonly session: TestSession; + readonly service: server.ProjectService; + readonly host: projectSystem.TestServerHost; + constructor(files: projectSystem.FileOrFolder[]) { + this.host = createServerHost(files); + this.session = createSession(this.host, { + canUseEvents: true, + eventHandler: event => this.events.push(event), + }); + this.service = this.session.getProjectService(); + } + + getEvents(): ReadonlyArray { + const events = this.events; + this.events = []; + return events; + } + + getEvent(eventName: T["eventName"]): T["data"] { + let eventData: T["data"]; + filterMutate(this.events, e => { + if (e.eventName === eventName) { + if (eventData !== undefined) { + assert(false, "more than one event found"); + } + eventData = e.data; + return false; + } + return true; + }); + assert.isDefined(eventData); + return eventData; + } - handler: server.ProjectServiceEventHandler = (event: server.ProjectServiceEvent) => { - this.events.push(event); + hasZeroEvent(eventName: T["eventName"]) { + this.events.forEach(event => assert.notEqual(event.eventName, eventName)); } - checkEventCountOfType(eventType: "configFileDiag", expectedCount: number) { - const eventsOfType = filter(this.events, e => e.eventName === eventType); - assert.equal(eventsOfType.length, expectedCount, `The actual event counts of type ${eventType} is ${eventsOfType.length}, while expected ${expectedCount}`); + checkSingleConfigFileDiagEvent(configFileName: string, triggerFile: string) { + const eventData = this.getEvent(server.ConfigFileDiagEvent); + assert.equal(eventData.configFileName, configFileName); + assert.equal(eventData.triggerFile, triggerFile); + } + + assertProjectInfoTelemetryEvent(partial: Partial, configFile?: string): void { + assert.deepEqual(this.getEvent(ts.server.ProjectInfoTelemetryEvent), { + projectId: Harness.mockHash(configFile || "/tsconfig.json"), + fileStats: fileStats({ ts: 1 }), + compilerOptions: {}, + extends: false, + files: false, + include: false, + exclude: false, + compileOnSave: false, + typeAcquisition: { + enable: false, + exclude: false, + include: false, + }, + configFileName: "tsconfig.json", + projectType: "configured", + languageServiceEnabled: true, + version: ts.version, + ...partial, + }); } } @@ -461,7 +522,7 @@ namespace ts.projectSystem { const { configFileName, configFileErrors } = projectService.openClientFile(file1.path); assert(configFileName, "should find config file"); - assert.isTrue(!configFileErrors, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`); + assert.isTrue(!configFileErrors || configFileErrors.length === 0, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`); checkNumberOfInferredProjects(projectService, 0); checkNumberOfConfiguredProjects(projectService, 1); @@ -501,7 +562,7 @@ namespace ts.projectSystem { const { configFileName, configFileErrors } = projectService.openClientFile(file1.path); assert(configFileName, "should find config file"); - assert.isTrue(!configFileErrors, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`); + assert.isTrue(!configFileErrors || configFileErrors.length === 0, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`); checkNumberOfInferredProjects(projectService, 0); checkNumberOfConfiguredProjects(projectService, 1); @@ -3159,7 +3220,6 @@ namespace ts.projectSystem { describe("Configure file diagnostics events", () => { it("are generated when the config file has errors", () => { - const serverEventManager = new TestServerEventManager(); const file = { path: "/a/b/app.ts", content: "let x = 10" @@ -3173,26 +3233,12 @@ namespace ts.projectSystem { } }` }; - - const host = createServerHost([file, configFile]); - const session = createSession(host, { - canUseEvents: true, - eventHandler: serverEventManager.handler - }); - openFilesForSession([file], session); - serverEventManager.checkEventCountOfType("configFileDiag", 1); - - for (const event of serverEventManager.events) { - if (event.eventName === "configFileDiag") { - assert.equal(event.data.configFileName, configFile.path); - assert.equal(event.data.triggerFile, file.path); - return; - } - } + const serverEventManager = new TestServerEventManager([file, configFile]); + openFilesForSession([file], serverEventManager.session); + serverEventManager.checkSingleConfigFileDiagEvent(configFile.path, file.path); }); it("are generated when the config file doesn't have errors", () => { - const serverEventManager = new TestServerEventManager(); const file = { path: "/a/b/app.ts", content: "let x = 10" @@ -3203,18 +3249,12 @@ namespace ts.projectSystem { "compilerOptions": {} }` }; - - const host = createServerHost([file, configFile]); - const session = createSession(host, { - canUseEvents: true, - eventHandler: serverEventManager.handler - }); - openFilesForSession([file], session); - serverEventManager.checkEventCountOfType("configFileDiag", 1); + const serverEventManager = new TestServerEventManager([file, configFile]); + openFilesForSession([file], serverEventManager.session); + serverEventManager.checkSingleConfigFileDiagEvent(configFile.path, file.path); }); it("are generated when the config file changes", () => { - const serverEventManager = new TestServerEventManager(); const file = { path: "/a/b/app.ts", content: "let x = 10" @@ -3226,29 +3266,70 @@ namespace ts.projectSystem { }` }; - const host = createServerHost([file, configFile]); - const session = createSession(host, { - canUseEvents: true, - eventHandler: serverEventManager.handler - }); - openFilesForSession([file], session); - serverEventManager.checkEventCountOfType("configFileDiag", 1); + const serverEventManager = new TestServerEventManager([file, configFile]); + openFilesForSession([file], serverEventManager.session); + serverEventManager.checkSingleConfigFileDiagEvent(configFile.path, file.path); configFile.content = `{ "compilerOptions": { "haha": 123 } }`; - host.reloadFS([file, configFile]); - host.runQueuedTimeoutCallbacks(); - serverEventManager.checkEventCountOfType("configFileDiag", 2); + serverEventManager.host.reloadFS([file, configFile]); + serverEventManager.host.runQueuedTimeoutCallbacks(); + serverEventManager.checkSingleConfigFileDiagEvent(configFile.path, configFile.path); configFile.content = `{ "compilerOptions": {} }`; - host.reloadFS([file, configFile]); - host.runQueuedTimeoutCallbacks(); - serverEventManager.checkEventCountOfType("configFileDiag", 3); + serverEventManager.host.reloadFS([file, configFile]); + serverEventManager.host.runQueuedTimeoutCallbacks(); + serverEventManager.checkSingleConfigFileDiagEvent(configFile.path, configFile.path); + }); + + it("are not generated when the config file doesnot include file opened and config file has errors", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const file2 = { + path: "/a/b/test.ts", + content: "let x = 10" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": { + "foo": "bar", + "allowJS": true + }, + "files": ["app.ts"] + }` + }; + const serverEventManager = new TestServerEventManager([file, file2, libFile, configFile]); + openFilesForSession([file2], serverEventManager.session); + serverEventManager.hasZeroEvent("configFileDiag"); + }); + + it("are not generated when the config file doesnot include file opened and doesnt contain any errors", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const file2 = { + path: "/a/b/test.ts", + content: "let x = 10" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "files": ["app.ts"] + }` + }; + + const serverEventManager = new TestServerEventManager([file, file2, libFile, configFile]); + openFilesForSession([file2], serverEventManager.session); + serverEventManager.hasZeroEvent("configFileDiag"); }); }); diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 379a5fbfe7e59..0cbd0a7e3a2b0 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -1579,14 +1579,17 @@ namespace ts.server { project.watchWildcards(projectOptions.wildcardDirectories); } this.updateNonInferredProject(project, projectOptions.files, fileNamePropertyReader, projectOptions.compilerOptions, projectOptions.typeAcquisition, projectOptions.compileOnSave); + this.sendConfigFileDiagEvent(project, configFileName); + } + private sendConfigFileDiagEvent(project: ConfiguredProject, triggerFile: NormalizedPath) { if (!this.eventHandler) { return; } this.eventHandler({ eventName: ConfigFileDiagEvent, - data: { configFileName, diagnostics: project.getGlobalProjectErrors() || [], triggerFile: configFileName } + data: { configFileName: project.getConfigFilePath(), diagnostics: project.getAllProjectErrors(), triggerFile } }); } @@ -1907,6 +1910,7 @@ namespace ts.server { openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult { let configFileName: NormalizedPath; + let sendConfigFileDiagEvent = false; let configFileErrors: ReadonlyArray; const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, fileContent, scriptKind, hasMixedContent); @@ -1917,14 +1921,8 @@ namespace ts.server { project = this.findConfiguredProjectByProjectName(configFileName); if (!project) { project = this.createConfiguredProject(configFileName); - - // even if opening config file was successful, it could still - // contain errors that were tolerated. - const errors = project.getGlobalProjectErrors(); - if (errors && errors.length > 0) { - // set configFileErrors only when the errors array is non-empty - configFileErrors = errors; - } + // Send the event only if the project got created as part of this open request + sendConfigFileDiagEvent = true; } } } @@ -1938,10 +1936,19 @@ namespace ts.server { // At this point if file is part of any any configured or external project, then it would be present in the containing projects // So if it still doesnt have any containing projects, it needs to be part of inferred project if (info.isOrphan()) { + // Since the file isnt part of configured project, do not send config file event + configFileName = undefined; + sendConfigFileDiagEvent = false; + this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); } this.addToListOfOpenFiles(info); + if (sendConfigFileDiagEvent) { + configFileErrors = project.getAllProjectErrors(); + this.sendConfigFileDiagEvent(project as ConfiguredProject, fileName); + } + // Remove the configured projects that have zero references from open files. // This was postponed from closeOpenFile to after opening next file, // so that we can reuse the project if we need to right away @@ -1957,6 +1964,7 @@ namespace ts.server { // the file from that old project is reopened because of opening file from here. this.deleteOrphanScriptInfoNotInAnyProject(); this.printProjects(); + return { configFileName, configFileErrors }; } diff --git a/src/server/project.ts b/src/server/project.ts index 9c66f8b4a6c49..7f7c3132136f2 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1271,14 +1271,14 @@ namespace ts.server { * Get the errors that dont have any file name associated */ getGlobalProjectErrors(): ReadonlyArray { - return filter(this.projectErrors, diagnostic => !diagnostic.file); + return filter(this.projectErrors, diagnostic => !diagnostic.file) || emptyArray; } /** * Get all the project errors */ getAllProjectErrors(): ReadonlyArray { - return this.projectErrors; + return this.projectErrors || emptyArray; } setProjectErrors(projectErrors: Diagnostic[]) { @@ -1333,6 +1333,8 @@ namespace ts.server { } this.stopWatchingWildCards(); + this.projectErrors = undefined; + this.configFileSpecs = undefined; super.close(); } diff --git a/src/server/session.ts b/src/server/session.ts index df9c77b9005ea..800d09ff6c283 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -969,13 +969,7 @@ namespace ts.server { * @param fileContent is a version of the file content that is known to be more up to date than the one on disk */ private openClientFile(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, projectRootPath?: NormalizedPath) { - const { configFileName, configFileErrors } = this.projectService.openClientFileWithNormalizedPath(fileName, fileContent, scriptKind, /*hasMixedContent*/ false, projectRootPath); - if (this.eventHandler) { - this.eventHandler({ - eventName: "configFileDiag", - data: { triggerFile: fileName, configFileName, diagnostics: configFileErrors || emptyArray } - }); - } + this.projectService.openClientFileWithNormalizedPath(fileName, fileContent, scriptKind, /*hasMixedContent*/ false, projectRootPath); } private getPosition(args: protocol.FileLocationRequestArgs, scriptInfo: ScriptInfo): number { diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index d862513c29387..b1595030df088 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -7523,6 +7523,7 @@ declare namespace ts.server { private createConfiguredProject(configFileName); private updateNonInferredProjectFiles(project, files, propertyReader); private updateNonInferredProject(project, newUncheckedFiles, propertyReader, newOptions, newTypeAcquisition, compileOnSave); + private sendConfigFileDiagEvent(project, triggerFile); private getOrCreateInferredProjectForProjectRootPathIfEnabled(info, projectRootPath); private getOrCreateSingleInferredProjectIfEnabled(); private createInferredProject(currentDirectory, isSingleInferredProject?, projectRootPath?);