diff --git a/src/harness/unittests/compileOnSave.ts b/src/harness/unittests/compileOnSave.ts index fde83170f3..24fd47ee0c 100644 --- a/src/harness/unittests/compileOnSave.ts +++ b/src/harness/unittests/compileOnSave.ts @@ -3,6 +3,10 @@ /// namespace ts.projectSystem { + function createTestTypingsInstaller(host: server.ServerHost) { + return new TestTypingsInstaller("/a/data/", /*throttleLimit*/5, host); + } + describe("CompileOnSave affected list", () => { function sendAffectedFileRequestAndCheckResult(session: server.Session, request: server.protocol.Request, expectedFileList: { projectFileName: string, files: FileOrFolder[] }[]) { const response: server.protocol.CompileOnSaveAffectedFileListSingleProject[] = session.executeCommand(request).response; @@ -105,7 +109,7 @@ namespace ts.projectSystem { it("should contains only itself if a module file's shape didn't change, and all files referencing it if its shape changed", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1, file1Consumer1], session); @@ -130,7 +134,7 @@ namespace ts.projectSystem { it("should be up-to-date with the reference map changes", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1, file1Consumer1], session); @@ -177,7 +181,7 @@ namespace ts.projectSystem { it("should be up-to-date with changes made in non-open files", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); @@ -195,7 +199,7 @@ namespace ts.projectSystem { it("should be up-to-date with deleted files", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); @@ -210,7 +214,7 @@ namespace ts.projectSystem { it("should be up-to-date with newly created files", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); @@ -247,7 +251,7 @@ namespace ts.projectSystem { }; const host = createServerHost([moduleFile1, file1Consumer1, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1, file1Consumer1], session); @@ -264,7 +268,7 @@ namespace ts.projectSystem { it("should return all files if a global file changed shape", () => { const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([globalFile3], session); @@ -290,7 +294,7 @@ namespace ts.projectSystem { }; const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, []); @@ -308,7 +312,7 @@ namespace ts.projectSystem { }; const host = createServerHost([moduleFile1, file1Consumer1, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); @@ -337,7 +341,7 @@ namespace ts.projectSystem { }; const host = createServerHost([moduleFile1, file1Consumer1, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1], session); @@ -359,7 +363,7 @@ namespace ts.projectSystem { content: `import {y} from "./file1Consumer1";` }; const host = createServerHost([moduleFile1, file1Consumer1, file1Consumer1Consumer1, globalFile3, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([moduleFile1, file1Consumer1], session); @@ -392,7 +396,7 @@ namespace ts.projectSystem { export var t2 = 10;` }; const host = createServerHost([file1, file2, configFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([file1, file2], session); @@ -475,7 +479,7 @@ namespace ts.projectSystem { content: `{}` }; const host = createServerHost([file1, file2, configFile, libFile]); - const typingsInstaller = new TestTypingsInstaller("/a/data/", host); + const typingsInstaller = createTestTypingsInstaller(host); const session = new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); openFilesForSession([file1, file2], session); diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 4575ce06b4..f34dfa31fc 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -2,6 +2,8 @@ /// namespace ts.projectSystem { + import TI = server.typingsInstaller; + const safeList = { path: "/safeList.json", content: JSON.stringify({ @@ -13,6 +15,14 @@ namespace ts.projectSystem { }) }; + export interface PostExecAction { + readonly requestKind: TI.RequestKind; + readonly error: Error; + readonly stdout: string; + readonly stderr: string; + readonly callback: (err: Error, stdout: string, stderr: string) => void; + } + export function notImplemented(): any { throw new Error("Not yet implemented"); } @@ -39,21 +49,27 @@ namespace ts.projectSystem { content: libFileContent }; - export class TestTypingsInstaller extends server.typingsInstaller.TypingsInstaller implements server.ITypingsInstaller { + export class TestTypingsInstaller extends TI.TypingsInstaller implements server.ITypingsInstaller { protected projectService: server.ProjectService; - constructor(readonly globalTypingsCacheLocation: string, readonly installTypingHost: server.ServerHost) { - super(globalTypingsCacheLocation, safeList.path); + constructor(readonly globalTypingsCacheLocation: string, throttleLimit: number, readonly installTypingHost: server.ServerHost) { + super(globalTypingsCacheLocation, "npm", safeList.path, throttleLimit); this.init(); } safeFileList = safeList.path; - postInstallActions: ((map: (t: string[]) => string[]) => void)[] = []; + protected postExecActions: PostExecAction[] = []; - runPostInstallActions(map: (t: string[]) => string[]) { - for (const f of this.postInstallActions) { - f(map); + executePendingCommands() { + const actionsToRun = this.postExecActions; + this.postExecActions = []; + for (const action of actionsToRun) { + action.callback(action.error, action.stdout, action.stderr); } - this.postInstallActions = []; + } + + checkPendingCommands(expected: TI.RequestKind[]) { + assert.equal(this.postExecActions.length, expected.length, `Expected ${expected.length} post install actions`); + this.postExecActions.forEach((act, i) => assert.equal(act.requestKind, expected[i], "Unexpected post install action")); } onProjectClosed(p: server.Project) { @@ -67,14 +83,15 @@ namespace ts.projectSystem { return this.installTypingHost; } - isPackageInstalled(packageName: string) { - return true; - } - - runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void) { - this.postInstallActions.push(map => { - postInstallAction(map(typingsToInstall)); - }); + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: (err: Error, stdout: string, stderr: string) => void): void { + switch (requestKind) { + case TI.NpmViewRequest: + case TI.NpmInstallRequest: + break; + default: + assert.isTrue(false, `request ${requestKind} is not supported`); + } + this.addPostExecAction(requestKind, "success", cb); } sendResponse(response: server.SetTypings | server.InvalidateCachedTypings) { @@ -85,6 +102,26 @@ namespace ts.projectSystem { const request = server.createInstallTypingsRequest(project, typingOptions, this.globalTypingsCacheLocation); this.install(request); } + + addPostExecAction(requestKind: TI.RequestKind, stdout: string | string[], cb: TI.RequestCompletedAction) { + const out = typeof stdout === "string" ? stdout : createNpmPackageJsonString(stdout); + const action: PostExecAction = { + error: undefined, + stdout: out, + stderr: "", + callback: cb, + requestKind + }; + this.postExecActions.push(action); + } + } + + function createNpmPackageJsonString(installedTypings: string[]): string { + const dependencies: MapLike = {}; + for (const typing of installedTypings) { + dependencies[typing] = "1.0.0"; + } + return JSON.stringify({ dependencies: dependencies }); } export function getExecutingFilePathFromLibFile(libFilePath: string): string { @@ -124,7 +161,7 @@ namespace ts.projectSystem { export function createSession(host: server.ServerHost, typingsInstaller?: server.ITypingsInstaller) { if (typingsInstaller === undefined) { - typingsInstaller = new TestTypingsInstaller("/a/data/", host); + typingsInstaller = new TestTypingsInstaller("/a/data/", /*throttleLimit*/5, host); } return new server.Session(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ false); } @@ -1615,7 +1652,7 @@ namespace ts.projectSystem { const host: TestServerHost & ModuleResolutionHost = createServerHost([file1, lib]); const resolutionTrace: string[] = []; host.trace = resolutionTrace.push.bind(resolutionTrace); - const projectService = createProjectService(host, { typingsInstaller: new TestTypingsInstaller("/a/cache", host) }); + const projectService = createProjectService(host, { typingsInstaller: new TestTypingsInstaller("/a/cache", /*throttleLimit*/5, host) }); projectService.setCompilerOptionsForInferredProjects({ traceResolution: true, allowJs: true }); projectService.openClientFile(file1.path); diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index 47373bfd26..9dd1247c89 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -1,9 +1,50 @@ -/// +/// /// /// namespace ts.projectSystem { - describe("typings installer", () => { + import TI = server.typingsInstaller; + + interface InstallerParams { + globalTypingsCacheLocation?: string; + throttleLimit?: number; + } + + class Installer extends TestTypingsInstaller { + constructor(host: server.ServerHost, p?: InstallerParams) { + super( + (p && p.globalTypingsCacheLocation) || "/a/data", + (p && p.throttleLimit) || 5, + host); + } + + installAll(expectedView: typeof TI.NpmViewRequest[], expectedInstall: typeof TI.NpmInstallRequest[]) { + this.checkPendingCommands(expectedView); + this.executePendingCommands(); + this.checkPendingCommands(expectedInstall); + this.executePendingCommands(); + } + } + + describe("typingsInstaller", () => { + function executeCommand(self: Installer, host: TestServerHost, installedTypings: string[], typingFiles: FileOrFolder[], requestKind: TI.RequestKind, cb: TI.RequestCompletedAction): void { + switch (requestKind) { + case TI.NpmInstallRequest: + self.addPostExecAction(requestKind, installedTypings, (err, stdout, stderr) => { + for (const file of typingFiles) { + host.createFileOrFolder(file, /*createParentDirectory*/ true); + } + cb(err, stdout, stderr); + }); + break; + case TI.NpmViewRequest: + self.addPostExecAction(requestKind, installedTypings, cb); + break; + default: + assert.isTrue(false, `unexpected request kind ${requestKind}`); + break; + } + } it("configured projects (typings installed) 1", () => { const file1 = { path: "/a/b/app.js", @@ -34,9 +75,18 @@ namespace ts.projectSystem { path: "/a/data/node_modules/@types/jquery/index.d.ts", content: "declare const $: { x: number }" }; - const host = createServerHost([file1, tsconfig, packageJson]); - const installer = new TestTypingsInstaller("/a/data/", host); + const installer = new (class extends Installer { + constructor() { + super(host); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: server.typingsInstaller.RequestCompletedAction) { + const installedTypings = ["@types/jquery"]; + const typingFiles = [jquery]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); + const projectService = createProjectService(host, { useSingleInferredProject: true, typingsInstaller: installer }); projectService.openClientFile(file1.path); @@ -44,13 +94,8 @@ namespace ts.projectSystem { const p = projectService.configuredProjects[0]; checkProjectActualFiles(p, [file1.path]); - assert(host.fileExists(combinePaths(installer.globalTypingsCacheLocation, "package.json"))); + installer.installAll([TI.NpmViewRequest], [TI.NpmInstallRequest]); - installer.runPostInstallActions(t => { - assert.deepEqual(t, ["jquery"]); - host.createFileOrFolder(jquery, /*createParentDirectory*/ true); - return ["@types/jquery"]; - }); checkNumberOfProjects(projectService, { configuredProjects: 1 }); checkProjectActualFiles(p, [file1.path, jquery.path]); }); @@ -75,7 +120,16 @@ namespace ts.projectSystem { content: "declare const $: { x: number }" }; const host = createServerHost([file1, packageJson]); - const installer = new TestTypingsInstaller("/a/data/", host); + const installer = new (class extends Installer { + constructor() { + super(host); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: server.typingsInstaller.RequestCompletedAction) { + const installedTypings = ["@types/jquery"]; + const typingFiles = [jquery]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); const projectService = createProjectService(host, { useSingleInferredProject: true, typingsInstaller: installer }); projectService.openClientFile(file1.path); @@ -84,13 +138,8 @@ namespace ts.projectSystem { const p = projectService.inferredProjects[0]; checkProjectActualFiles(p, [file1.path]); - assert(host.fileExists(combinePaths(installer.globalTypingsCacheLocation, "package.json"))); + installer.installAll([TI.NpmViewRequest], [TI.NpmInstallRequest]); - installer.runPostInstallActions(t => { - assert.deepEqual(t, ["jquery"]); - host.createFileOrFolder(jquery, /*createParentDirectory*/ true); - return ["@types/jquery"]; - }); checkNumberOfProjects(projectService, { inferredProjects: 1 }); checkProjectActualFiles(p, [file1.path, jquery.path]); }); @@ -101,9 +150,9 @@ namespace ts.projectSystem { content: "" }; const host = createServerHost([file1]); - const installer = new (class extends TestTypingsInstaller { + const installer = new (class extends Installer { constructor() { - super("", host); + super(host); } enqueueInstallTypingsRequest() { assert(false, "auto discovery should not be enabled"); @@ -117,6 +166,7 @@ namespace ts.projectSystem { options: {}, rootFiles: [toExternalFile(file1.path)] }); + installer.checkPendingCommands([]); // by default auto discovery will kick in if project contain only .js/.d.ts files // in this case project contain only ts files - no auto discovery projectService.checkNumberOfProjects({ externalProjects: 1 }); @@ -128,9 +178,9 @@ namespace ts.projectSystem { content: "" }; const host = createServerHost([file1]); - const installer = new (class extends TestTypingsInstaller { + const installer = new (class extends Installer { constructor() { - super("", host); + super(host); } enqueueInstallTypingsRequest() { assert(false, "auto discovery should not be enabled"); @@ -145,6 +195,7 @@ namespace ts.projectSystem { rootFiles: [toExternalFile(file1.path)], typingOptions: { include: ["jquery"] } }); + installer.checkPendingCommands([]); // by default auto discovery will kick in if project contain only .js/.d.ts files // in this case project contain only ts files - no auto discovery even if typing options is set projectService.checkNumberOfProjects({ externalProjects: 1 }); @@ -155,21 +206,24 @@ namespace ts.projectSystem { path: "/a/b/app.ts", content: "" }; + const jquery = { + path: "/a/data/node_modules/@types/jquery/index.d.ts", + content: "declare const $: { x: number }" + }; const host = createServerHost([file1]); let enqueueIsCalled = false; - let runInstallIsCalled = false; - const installer = new (class extends TestTypingsInstaller { + const installer = new (class extends Installer { constructor() { - super("", host); + super(host); } enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions) { enqueueIsCalled = true; super.enqueueInstallTypingsRequest(project, typingOptions); } - runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void): void { - assert.deepEqual(typingsToInstall, ["node"]); - runInstallIsCalled = true; - super.runInstall(cachePath, typingsToInstall, postInstallAction); + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + const installedTypings = ["@types/jquery"]; + const typingFiles = [jquery]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); } })(); @@ -181,10 +235,12 @@ namespace ts.projectSystem { rootFiles: [toExternalFile(file1.path)], typingOptions: { enableAutoDiscovery: true, include: ["node"] } }); + + assert.isTrue(enqueueIsCalled, "expected enqueueIsCalled to be true"); + installer.installAll([TI.NpmViewRequest], [TI.NpmInstallRequest]); + // autoDiscovery is set in typing options - use it even if project contains only .ts files projectService.checkNumberOfProjects({ externalProjects: 1 }); - assert.isTrue(enqueueIsCalled, "expected 'enqueueIsCalled' to be true"); - assert.isTrue(runInstallIsCalled, "expected 'runInstallIsCalled' to be true"); }); it("external project - no typing options, with only js, jsx, d.ts files", () => { @@ -214,7 +270,16 @@ namespace ts.projectSystem { }; const host = createServerHost([file1, file2, file3]); - const installer = new TestTypingsInstaller("/a/data/", host); + const installer = new (class extends Installer { + constructor() { + super(host); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + const installedTypings = ["@types/lodash", "@types/react"]; + const typingFiles = [lodash, react]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); const projectFileName = "/a/app/test.csproj"; const projectService = createProjectService(host, { typingsInstaller: installer }); @@ -229,12 +294,7 @@ namespace ts.projectSystem { projectService.checkNumberOfProjects({ externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path]); - installer.runPostInstallActions(t => { - assert.deepEqual(t, ["lodash", "react"]); - host.createFileOrFolder(lodash, /*createParentDirectory*/ true); - host.createFileOrFolder(react, /*createParentDirectory*/ true); - return ["@types/lodash", "@types/react"]; - }); + installer.installAll([TI.NpmViewRequest, TI.NpmViewRequest], [TI.NpmInstallRequest], ); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path, lodash.path, react.path]); @@ -254,19 +314,18 @@ namespace ts.projectSystem { const host = createServerHost([file1, file2]); let enqueueIsCalled = false; - let runInstallIsCalled = false; - let runPostInstallIsCalled = false; - const installer = new (class extends TestTypingsInstaller { + const installer = new (class extends Installer { constructor() { - super("/a/data/", host); + super(host); } enqueueInstallTypingsRequest(project: server.Project, typingOptions: TypingOptions) { enqueueIsCalled = true; super.enqueueInstallTypingsRequest(project, typingOptions); } - runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void): void { - runInstallIsCalled = true; - super.runInstall(cachePath, typingsToInstall, postInstallAction); + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + const installedTypings: string[] = []; + const typingFiles: FileOrFolder[] = []; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); } })(); @@ -283,16 +342,10 @@ namespace ts.projectSystem { projectService.checkNumberOfProjects({ externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path]); - installer.runPostInstallActions(t => { - runPostInstallIsCalled = true; - return []; - }); + installer.checkPendingCommands([]); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path]); - assert.isFalse(enqueueIsCalled, "expected 'enqueueIsCalled' to be false"); - assert.isFalse(runInstallIsCalled, "expected 'runInstallIsCalled' to be false"); - assert.isFalse(runPostInstallIsCalled, "expected 'runPostInstallIsCalled' to be false"); }); it("external project - with typing options, with only js, d.ts files", () => { @@ -340,7 +393,16 @@ namespace ts.projectSystem { }; const host = createServerHost([file1, file2, file3, packageJson]); - const installer = new TestTypingsInstaller("/a/data/", host); + const installer = new (class extends Installer { + constructor() { + super(host); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + const installedTypings = ["@types/commander", "@types/express", "@types/jquery", "@types/moment"]; + const typingFiles = [commander, express, jquery, moment]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); const projectFileName = "/a/app/test.csproj"; const projectService = createProjectService(host, { typingsInstaller: installer }); @@ -355,17 +417,215 @@ namespace ts.projectSystem { projectService.checkNumberOfProjects({ externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path]); - installer.runPostInstallActions(t => { - assert.deepEqual(t, ["jquery", "moment", "express", "commander" ]); - host.createFileOrFolder(commander, /*createParentDirectory*/ true); - host.createFileOrFolder(express, /*createParentDirectory*/ true); - host.createFileOrFolder(jquery, /*createParentDirectory*/ true); - host.createFileOrFolder(moment, /*createParentDirectory*/ true); - return ["@types/commander", "@types/express", "@types/jquery", "@types/moment"]; - }); + installer.installAll( + [TI.NpmViewRequest, TI.NpmViewRequest, TI.NpmViewRequest, TI.NpmViewRequest], + [TI.NpmInstallRequest] + ); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path, commander.path, express.path, jquery.path, moment.path]); }); + + it("Throttle - delayed typings to install", () => { + const lodashJs = { + path: "/a/b/lodash.js", + content: "" + }; + const commanderJs = { + path: "/a/b/commander.js", + content: "" + }; + const file3 = { + path: "/a/b/file3.d.ts", + content: "" + }; + const packageJson = { + path: "/a/b/package.json", + content: JSON.stringify({ + name: "test", + dependencies: { + express: "^3.1.0" + } + }) + }; + + const commander = { + path: "/a/data/node_modules/@types/commander/index.d.ts", + content: "declare const commander: { x: number }" + }; + const express = { + path: "/a/data/node_modules/@types/express/index.d.ts", + content: "declare const express: { x: number }" + }; + const jquery = { + path: "/a/data/node_modules/@types/jquery/index.d.ts", + content: "declare const jquery: { x: number }" + }; + const moment = { + path: "/a/data/node_modules/@types/moment/index.d.ts", + content: "declare const moment: { x: number }" + }; + const lodash = { + path: "/a/data/node_modules/@types/lodash/index.d.ts", + content: "declare const lodash: { x: number }" + }; + + const typingFiles = [commander, express, jquery, moment, lodash]; + const host = createServerHost([lodashJs, commanderJs, file3, packageJson]); + const installer = new (class extends Installer { + constructor() { + super(host, { throttleLimit: 3 }); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + const installedTypings = ["@types/commander", "@types/express", "@types/jquery", "@types/moment", "@types/lodash"]; + executeCommand(this, host, installedTypings, typingFiles, requestKind, cb); + } + })(); + + const projectFileName = "/a/app/test.csproj"; + const projectService = createProjectService(host, { typingsInstaller: installer }); + projectService.openExternalProject({ + projectFileName, + options: { allowJS: true, moduleResolution: ModuleResolutionKind.NodeJs }, + rootFiles: [toExternalFile(lodashJs.path), toExternalFile(commanderJs.path), toExternalFile(file3.path)], + typingOptions: { include: ["jquery", "moment"] } + }); + + const p = projectService.externalProjects[0]; + projectService.checkNumberOfProjects({ externalProjects: 1 }); + checkProjectActualFiles(p, [lodashJs.path, commanderJs.path, file3.path]); + // expected 3 view requests in the queue + installer.checkPendingCommands([TI.NpmViewRequest, TI.NpmViewRequest, TI.NpmViewRequest]); + assert.equal(installer.pendingRunRequests.length, 2, "expected 2 pending requests"); + + // push view requests + installer.executePendingCommands(); + // expected 2 remaining view requests in the queue + installer.checkPendingCommands([TI.NpmViewRequest, TI.NpmViewRequest]); + // push view requests + installer.executePendingCommands(); + // expected one install request + installer.checkPendingCommands([TI.NpmInstallRequest]); + installer.executePendingCommands(); + // expected all typings file to exist + for (const f of typingFiles) { + assert.isTrue(host.fileExists(f.path), `expected file ${f.path} to exist`); + } + + checkNumberOfProjects(projectService, { externalProjects: 1 }); + checkProjectActualFiles(p, [lodashJs.path, commanderJs.path, file3.path, commander.path, express.path, jquery.path, moment.path, lodash.path]); + }); + + it("Throttle - delayed run install requests", () => { + const lodashJs = { + path: "/a/b/lodash.js", + content: "" + }; + const commanderJs = { + path: "/a/b/commander.js", + content: "" + }; + const file3 = { + path: "/a/b/file3.d.ts", + content: "" + }; + + const commander = { + path: "/a/data/node_modules/@types/commander/index.d.ts", + content: "declare const commander: { x: number }", + typings: "@types/commander" + }; + const jquery = { + path: "/a/data/node_modules/@types/jquery/index.d.ts", + content: "declare const jquery: { x: number }", + typings: "@types/jquery" + }; + const lodash = { + path: "/a/data/node_modules/@types/lodash/index.d.ts", + content: "declare const lodash: { x: number }", + typings: "@types/lodash" + }; + const cordova = { + path: "/a/data/node_modules/@types/cordova/index.d.ts", + content: "declare const cordova: { x: number }", + typings: "@types/cordova" + }; + const grunt = { + path: "/a/data/node_modules/@types/grunt/index.d.ts", + content: "declare const grunt: { x: number }", + typings: "@types/grunt" + }; + const gulp = { + path: "/a/data/node_modules/@types/gulp/index.d.ts", + content: "declare const gulp: { x: number }", + typings: "@types/gulp" + }; + + const host = createServerHost([lodashJs, commanderJs, file3]); + const installer = new (class extends Installer { + constructor() { + super(host, { throttleLimit: 3 }); + } + runCommand(requestKind: TI.RequestKind, requestId: number, command: string, cwd: string, cb: TI.RequestCompletedAction): void { + if (requestKind === TI.NpmInstallRequest) { + let typingFiles: (FileOrFolder & { typings: string}) [] = []; + if (command.indexOf("commander") >= 0) { + typingFiles = [commander, jquery, lodash, cordova]; + } + else { + typingFiles = [grunt, gulp]; + } + executeCommand(this, host, typingFiles.map(f => f.typings), typingFiles, requestKind, cb); + } + else { + executeCommand(this, host, [], [], requestKind, cb); + } + } + })(); + + // Create project #1 with 4 typings + const projectService = createProjectService(host, { typingsInstaller: installer }); + const projectFileName1 = "/a/app/test1.csproj"; + projectService.openExternalProject({ + projectFileName: projectFileName1, + options: { allowJS: true, moduleResolution: ModuleResolutionKind.NodeJs }, + rootFiles: [toExternalFile(lodashJs.path), toExternalFile(commanderJs.path), toExternalFile(file3.path)], + typingOptions: { include: ["jquery", "cordova" ] } + }); + + installer.checkPendingCommands([TI.NpmViewRequest, TI.NpmViewRequest, TI.NpmViewRequest]); + assert.equal(installer.pendingRunRequests.length, 1, "expect one throttled request"); + + // Create project #2 with 2 typings + const projectFileName2 = "/a/app/test2.csproj"; + projectService.openExternalProject({ + projectFileName: projectFileName2, + options: { allowJS: true, moduleResolution: ModuleResolutionKind.NodeJs }, + rootFiles: [toExternalFile(file3.path)], + typingOptions: { include: ["grunt", "gulp"] } + }); + assert.equal(installer.pendingRunRequests.length, 3, "expect three throttled request"); + + const p1 = projectService.externalProjects[0]; + const p2 = projectService.externalProjects[1]; + projectService.checkNumberOfProjects({ externalProjects: 2 }); + checkProjectActualFiles(p1, [lodashJs.path, commanderJs.path, file3.path]); + checkProjectActualFiles(p2, [file3.path]); + + + installer.executePendingCommands(); + // expected one view request from the first project and two - from the second one + installer.checkPendingCommands([TI.NpmViewRequest, TI.NpmViewRequest, TI.NpmViewRequest]); + assert.equal(installer.pendingRunRequests.length, 0, "expected no throttled requests"); + + installer.executePendingCommands(); + + // should be two install requests from both projects + installer.checkPendingCommands([TI.NpmInstallRequest, TI.NpmInstallRequest]); + installer.executePendingCommands(); + + checkProjectActualFiles(p1, [lodashJs.path, commanderJs.path, file3.path, commander.path, jquery.path, lodash.path, cordova.path]); + checkProjectActualFiles(p2, [file3.path, grunt.path, gulp.path ]); + }); }); } \ No newline at end of file diff --git a/src/server/typingsInstaller/nodeTypingsInstaller.ts b/src/server/typingsInstaller/nodeTypingsInstaller.ts index 0d22b1b364..7df1e9a787 100644 --- a/src/server/typingsInstaller/nodeTypingsInstaller.ts +++ b/src/server/typingsInstaller/nodeTypingsInstaller.ts @@ -1,12 +1,16 @@ -/// +/// /// namespace ts.server.typingsInstaller { - const fs: { appendFileSync(file: string, content: string): void } = require("fs"); + const path: { + join(...parts: string[]): string; + dirname(path: string): string; + } = require("path"); + class FileLog implements Log { constructor(private readonly logFile?: string) { } @@ -20,37 +24,26 @@ namespace ts.server.typingsInstaller { } export class NodeTypingsInstaller extends TypingsInstaller { - private execSync: { (command: string, options: { stdio: "ignore" | "pipe", cwd?: string }): Buffer | string }; - private exec: { (command: string, options: { cwd: string }, callback?: (error: Error, stdout: string, stderr: string) => void): any }; - private npmBinPath: string; + private readonly exec: { (command: string, options: { cwd: string }, callback?: (error: Error, stdout: string, stderr: string) => void): any }; - private installRunCount = 1; readonly installTypingHost: InstallTypingHost = sys; - constructor(globalTypingsCacheLocation: string, log: Log) { - super(globalTypingsCacheLocation, toPath("typingSafeList.json", __dirname, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), log); + constructor(globalTypingsCacheLocation: string, throttleLimit: number, log: Log) { + super( + globalTypingsCacheLocation, + /*npmPath*/ `"${path.join(path.dirname(process.argv[0]), "npm")}"`, + toPath("typingSafeList.json", __dirname, createGetCanonicalFileName(sys.useCaseSensitiveFileNames)), + throttleLimit, + log); if (this.log.isEnabled()) { this.log.writeLine(`Process id: ${process.pid}`); } - const { exec, execSync } = require("child_process"); - this.execSync = execSync; + const { exec } = require("child_process"); this.exec = exec; } init() { super.init(); - try { - this.npmBinPath = this.execSync("npm -g bin", { stdio: "pipe" }).toString().trim(); - if (this.log.isEnabled()) { - this.log.writeLine(`Global npm bin path '${this.npmBinPath}'`); - } - } - catch (e) { - this.npmBinPath = ""; - if (this.log.isEnabled()) { - this.log.writeLine(`Error when getting npm bin path: ${e}. Set bin path to ""`); - } - } process.on("message", (req: DiscoverTypings | CloseProject) => { switch (req.kind) { case "discover": @@ -62,23 +55,6 @@ namespace ts.server.typingsInstaller { }); } - protected isPackageInstalled(packageName: string) { - try { - const output = this.execSync(`npm list --silent --global --depth=1 ${packageName}`, { stdio: "pipe" }).toString(); - if (this.log.isEnabled()) { - this.log.writeLine(`IsPackageInstalled::stdout '${output}'`); - } - return true; - } - catch (e) { - if (this.log.isEnabled()) { - this.log.writeLine(`IsPackageInstalled::err::stdout '${e.stdout && e.stdout.toString()}'`); - this.log.writeLine(`IsPackageInstalled::err::stderr '${e.stdout && e.stderr.toString()}'`); - } - return false; - } - } - protected sendResponse(response: SetTypings | InvalidateCachedTypings) { if (this.log.isEnabled()) { this.log.writeLine(`Sending response: ${JSON.stringify(response)}`); @@ -89,61 +65,16 @@ namespace ts.server.typingsInstaller { } } - protected runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void): void { - const id = this.installRunCount; - this.installRunCount++; - let execInstallCmdCount = 0; - const filteredTypings: string[] = []; - for (const typing of typingsToInstall) { - const command = `npm view @types/${typing} --silent name`; - this.execAsync("npm view", command, cachePath, id, (err, stdout, stderr) => { - if (stdout) { - filteredTypings.push(typing); - } - execInstallCmdCount++; - if (execInstallCmdCount === typingsToInstall.length) { - installFilteredTypings(this, filteredTypings); - } - }); - } - - function installFilteredTypings(self: NodeTypingsInstaller, filteredTypings: string[]) { - const command = `npm install ${filteredTypings.map(t => "@types/" + t).join(" ")} --save-dev`; - self.execAsync("npm install", command, cachePath, id, (err, stdout, stderr) => { - if (stdout) { - reportInstalledTypings(self); - } - }); - } - - function reportInstalledTypings(self: NodeTypingsInstaller) { - const command = "npm ls -json"; - self.execAsync("npm ls", command, cachePath, id, (err, stdout, stderr) => { - let installedTypings: string[]; - try { - const response = JSON.parse(stdout); - if (response.dependencies) { - installedTypings = getOwnKeys(response.dependencies); - } - } - catch (e) { - self.log.writeLine(`Error parsing installed @types dependencies. Error details: ${e.message}`); - } - postInstallAction(installedTypings || []); - }); - } - } - - private execAsync(prefix: string, command: string, cwd: string, requestId: number, cb: (err: Error, stdout: string, stderr: string) => void) { + protected runCommand(requestKind: RequestKind, requestId: number, command: string, cwd: string, onRequestCompleted: RequestCompletedAction): void { if (this.log.isEnabled()) { this.log.writeLine(`#${requestId} running command '${command}'.`); } this.exec(command, { cwd }, (err, stdout, stderr) => { if (this.log.isEnabled()) { - this.log.writeLine(`${prefix} #${requestId} stdout: ${stdout}`); - this.log.writeLine(`${prefix} #${requestId} stderr: ${stderr}`); + this.log.writeLine(`${requestKind} #${requestId} stdout: ${stdout}`); + this.log.writeLine(`${requestKind} #${requestId} stderr: ${stderr}`); } - cb(err, stdout, stderr); + onRequestCompleted(err, stdout, stderr); }); } } @@ -169,6 +100,6 @@ namespace ts.server.typingsInstaller { } process.exit(0); }); - const installer = new NodeTypingsInstaller(globalTypingsCacheLocation, log); + const installer = new NodeTypingsInstaller(globalTypingsCacheLocation, /*throttleLimit*/5, log); installer.init(); } \ No newline at end of file diff --git a/src/server/typingsInstaller/typingsInstaller.ts b/src/server/typingsInstaller/typingsInstaller.ts index e4870a568c..1113e6e270 100644 --- a/src/server/typingsInstaller/typingsInstaller.ts +++ b/src/server/typingsInstaller/typingsInstaller.ts @@ -1,4 +1,4 @@ -/// +/// /// /// /// @@ -23,15 +23,38 @@ namespace ts.server.typingsInstaller { return result.resolvedModule && result.resolvedModule.resolvedFileName; } + export const NpmViewRequest: "npm view" = "npm view"; + export const NpmInstallRequest: "npm install" = "npm install"; + + export type RequestKind = typeof NpmViewRequest | typeof NpmInstallRequest; + + export type RequestCompletedAction = (err: Error, stdout: string, stderr: string) => void; + type PendingRequest = { + requestKind: RequestKind; + requestId: number; + command: string; + cwd: string; + onRequestCompleted: RequestCompletedAction + }; + export abstract class TypingsInstaller { - private packageNameToTypingLocation: Map = createMap(); - private missingTypingsSet: Map = createMap(); - private knownCachesSet: Map = createMap(); - private projectWatchers: Map = createMap(); + private readonly packageNameToTypingLocation: Map = createMap(); + private readonly missingTypingsSet: Map = createMap(); + private readonly knownCachesSet: Map = createMap(); + private readonly projectWatchers: Map = createMap(); + readonly pendingRunRequests: PendingRequest[] = []; + + private installRunCount = 1; + private inFlightRequestCount = 0; abstract readonly installTypingHost: InstallTypingHost; - constructor(readonly globalCachePath: string, readonly safeListPath: Path, protected readonly log = nullLog) { + constructor( + readonly globalCachePath: string, + readonly npmPath: string, + readonly safeListPath: Path, + readonly throttleLimit: number, + protected readonly log = nullLog) { if (this.log.isEnabled()) { this.log.writeLine(`Global cache location '${globalCachePath}', safe file path '${safeListPath}'`); } @@ -224,6 +247,42 @@ namespace ts.server.typingsInstaller { }); } + private runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void): void { + const requestId = this.installRunCount; + + this.installRunCount++; + let execInstallCmdCount = 0; + const filteredTypings: string[] = []; + for (const typing of typingsToInstall) { + execNpmViewTyping(this, typing); + } + + function execNpmViewTyping(self: TypingsInstaller, typing: string) { + const command = `${self.npmPath} view @types/${typing} --silent name`; + self.execAsync(NpmViewRequest, requestId, command, cachePath, (err, stdout, stderr) => { + if (stdout) { + filteredTypings.push(typing); + } + execInstallCmdCount++; + if (execInstallCmdCount === typingsToInstall.length) { + installFilteredTypings(self, filteredTypings); + } + }); + } + + function installFilteredTypings(self: TypingsInstaller, filteredTypings: string[]) { + if (filteredTypings.length === 0) { + postInstallAction([]); + return; + } + const scopedTypings = filteredTypings.map(t => "@types/" + t); + const command = `${self.npmPath} install ${scopedTypings.join(" ")} --save-dev`; + self.execAsync(NpmInstallRequest, requestId, command, cachePath, (err, stdout, stderr) => { + postInstallAction(stdout ? scopedTypings : []); + }); + } + } + private ensureDirectoryExists(directory: string, host: InstallTypingHost): void { const directoryName = getDirectoryPath(directory); if (!host.directoryExists(directoryName)) { @@ -267,8 +326,24 @@ namespace ts.server.typingsInstaller { }; } - protected abstract isPackageInstalled(packageName: string): boolean; + private execAsync(requestKind: RequestKind, requestId: number, command: string, cwd: string, onRequestCompleted: RequestCompletedAction): void { + this.pendingRunRequests.unshift({ requestKind, requestId, command, cwd, onRequestCompleted }); + this.executeWithThrottling(); + } + + private executeWithThrottling() { + while (this.inFlightRequestCount < this.throttleLimit && this.pendingRunRequests.length) { + this.inFlightRequestCount++; + const request = this.pendingRunRequests.pop(); + this.runCommand(request.requestKind, request.requestId, request.command, request.cwd, (err, stdout, stderr) => { + this.inFlightRequestCount--; + request.onRequestCompleted(err, stdout, stderr); + this.executeWithThrottling(); + }); + } + } + + protected abstract runCommand(requestKind: RequestKind, requestId: number, command: string, cwd: string, onRequestCompleted: RequestCompletedAction): void; protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings): void; - protected abstract runInstall(cachePath: string, typingsToInstall: string[], postInstallAction: (installedTypings: string[]) => void): void; } } \ No newline at end of file