tests: add integration tests for tool installation

This change tests the tool installation behavior. It uses a fake, file-based module proxy to create stubs for the tools. Follow-up CLs will include installing tools at specific versions and integration tests that use the network and run only in CI.

Updates golang/vscode-go#42

Change-Id: I8845e8c2ffebe9468ac8d2442d3675e514f0787b
GitHub-Last-Rev: 3188bbf0f58da1e4d856995390911aa1d00a962a
GitHub-Pull-Request: golang/vscode-go#36
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/233557
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/package-lock.json b/package-lock.json
index 3d865ca..7640a7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -75,6 +75,15 @@
       "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==",
       "dev": true
     },
+    "@types/adm-zip": {
+      "version": "0.4.33",
+      "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.4.33.tgz",
+      "integrity": "sha512-WM0DCWFLjXtddl0fu0+iN2ZF+qz8RF9RddG5OSy/S90AQz01Fu8lHn/3oTIZDxvG8gVcnBLAHMHOdBLbV6m6Mw==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/deep-equal": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz",
@@ -155,6 +164,12 @@
       "integrity": "sha512-WJZtZlinE3meRdH+I7wTsIhpz/GLhqEQwmPGeh4s1irWLwMzCeTV8WZ+pgPTwrDXoafVUWwo1LiZ9HJVHFlJSQ==",
       "dev": true
     },
+    "adm-zip": {
+      "version": "0.4.14",
+      "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.14.tgz",
+      "integrity": "sha512-/9aQCnQHF+0IiCl0qhXoK7qs//SwYE7zX8lsr/DNk1BRAHYxeLZPL4pguwK29gUEqasYQjqPtEpDRSWEkdHn9g==",
+      "dev": true
+    },
     "agent-base": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
diff --git a/package.json b/package.json
index 018baf6..dec77ba 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
   },
   "extensionDependencies": [],
   "dependencies": {
+    "deep-equal": "^2.0.2",
     "diff": "^4.0.2",
     "json-rpc2": "^1.0.2",
     "moment": "^2.24.0",
@@ -54,10 +55,12 @@
     "vscode-debugprotocol": "^1.40.0",
     "vscode-extension-telemetry": "^0.1.2",
     "vscode-languageclient": "6.1.0",
-    "web-request": "^1.0.7",
-    "deep-equal": "^2.0.2"
+    "web-request": "^1.0.7"
   },
   "devDependencies": {
+    "adm-zip": "^0.4.14",
+    "@types/adm-zip": "^0.4.33",
+    "@types/deep-equal": "^1.0.1",
     "@types/fs-extra": "^8.1.0",
     "@types/glob": "^7.1.1",
     "@types/mocha": "^7.0.2",
@@ -72,8 +75,7 @@
     "sinon": "^9.0.2",
     "tslint": "^6.1.1",
     "typescript": "^3.8.3",
-    "vscode-test": "^1.3.0",
-    "@types/deep-equal": "^1.0.1"
+    "vscode-test": "^1.3.0"
   },
   "engines": {
     "vscode": "^1.41.0"
diff --git a/src/goInstallTools.ts b/src/goInstallTools.ts
index f86c6da..195ee4c 100644
--- a/src/goInstallTools.ts
+++ b/src/goInstallTools.ts
@@ -33,6 +33,7 @@
 	getGoConfig,
 	getGoVersion,
 	getTempFilePath,
+	getToolsEnvVars,
 	getToolsGopath,
 	GoVersion,
 	resolvePath
@@ -113,14 +114,12 @@
 
 	// http.proxy setting takes precedence over environment variables
 	const httpProxy = vscode.workspace.getConfiguration('http', null).get('proxy');
-	let envForTools = Object.assign({}, process.env);
+	const envForTools = Object.assign({}, process.env, getToolsEnvVars());
 	if (httpProxy) {
-		envForTools = Object.assign({}, process.env, {
-			http_proxy: httpProxy,
-			HTTP_PROXY: httpProxy,
-			https_proxy: httpProxy,
-			HTTPS_PROXY: httpProxy
-		});
+		envForTools['http_proxy'] = httpProxy;
+		envForTools['HTTP_PROXY'] = httpProxy;
+		envForTools['https_proxy'] = httpProxy;
+		envForTools['HTTPS_PROXY'] = httpProxy;
 	}
 
 	outputChannel.show();
diff --git a/src/goMain.ts b/src/goMain.ts
index 5003eff..f2afedf 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -58,7 +58,7 @@
 // restartLanguageServer wraps all of the logic needed to restart the
 // language server. It can be used to enable, disable, or otherwise change
 // the configuration of the server.
-export let restartLanguageServer: () => {};
+export let restartLanguageServer = () => { return; };
 
 export function activate(ctx: vscode.ExtensionContext): void {
 	setGlobalState(ctx.globalState);
diff --git a/src/goTools.ts b/src/goTools.ts
index 1552c49..94ca950 100644
--- a/src/goTools.ts
+++ b/src/goTools.ts
@@ -92,6 +92,10 @@
 	return allToolsInformation[name];
 }
 
+export function getToolAtVersion(name: string, version?: semver.SemVer): ToolAtVersion {
+	return { ...allToolsInformation[name], version };
+}
+
 // hasModSuffix returns true if the given tool has a different, module-specific
 // name to avoid conflicts.
 export function hasModSuffix(tool: Tool): boolean {
diff --git a/src/util.ts b/src/util.ts
index 4bc576d..5600bbd 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -330,7 +330,7 @@
 		case 1:
 			vendorSupport =
 				goVersion.sv.minor > 6 ||
-				((goVersion.sv.minor === 5 || goVersion.sv.minor === 6) && process.env['GO15VENDOREXPERIMENT'] === '1')
+					((goVersion.sv.minor === 5 || goVersion.sv.minor === 6) && process.env['GO15VENDOREXPERIMENT'] === '1')
 					? true
 					: false;
 			break;
@@ -905,9 +905,14 @@
 		fs.readdirSync(dir).forEach((file) => {
 			const relPath = path.join(dir, file);
 			if (fs.lstatSync(relPath).isDirectory()) {
-				rmdirRecursive(dir);
+				rmdirRecursive(relPath);
 			} else {
-				fs.unlinkSync(relPath);
+				try {
+					fs.unlinkSync(relPath);
+				} catch (err) {
+					console.log(err);
+				}
+
 			}
 		});
 		fs.rmdirSync(dir);
diff --git a/test/integration/index.ts b/test/integration/index.ts
index 6e96c43..4a082ae 100644
--- a/test/integration/index.ts
+++ b/test/integration/index.ts
@@ -8,7 +8,7 @@
 export function run(): Promise<void> {
 	// Create the mocha test
 	const mocha = new Mocha({
-		ui: 'tdd'
+		ui: 'tdd',
 	});
 	mocha.useColors(true);
 
diff --git a/test/integration/install.test.ts b/test/integration/install.test.ts
new file mode 100644
index 0000000..778e5f2
--- /dev/null
+++ b/test/integration/install.test.ts
@@ -0,0 +1,96 @@
+/*---------------------------------------------------------
+ * Copyright (C) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------*/
+
+import AdmZip = require('adm-zip');
+import * as assert from 'assert';
+import fs = require('fs');
+import os = require('os');
+import path = require('path');
+import sinon = require('sinon');
+import util = require('util');
+import vscode = require('vscode');
+import { installTools } from '../../src/goInstallTools';
+import { getTool, getToolAtVersion } from '../../src/goTools';
+import { getGoVersion, rmdirRecursive } from '../../src/util';
+
+suite('Installation Tests', () => {
+	test('install tools', async () => {
+		const goVersion = await getGoVersion();
+		const testCases: string[][] = [
+			['gopls'],
+			['gopls', 'guru'],
+		];
+		const proxyDir = buildFakeProxy([].concat(...testCases));
+
+		for (const missing of testCases) {
+			// Create a temporary directory in which to install tools.
+			const tmpToolsGopath = fs.mkdtempSync(path.join(os.tmpdir(), 'install-test'));
+			fs.mkdirSync(path.join(tmpToolsGopath, 'bin'));
+			fs.mkdirSync(path.join(tmpToolsGopath, 'src'));
+
+			const sandbox = sinon.createSandbox();
+			const utils = require('../../src/util');
+			const toolsGopathStub = sandbox.stub(utils, 'getToolsGopath').returns(tmpToolsGopath);
+			const goConfig = Object.create(vscode.workspace.getConfiguration('go'), {
+				toolsEnvVars: {
+					value: {
+						GOPROXY: `file://${proxyDir}`,
+						GOSUMDB: 'off',
+					}
+				},
+			});
+			const configStub = sandbox.stub(vscode.workspace, 'getConfiguration').returns(goConfig);
+			// TODO(rstambler): Test with versions as well.
+			const missingTools = missing.map((tool) => getToolAtVersion(tool));
+			await installTools(missingTools, goVersion);
+
+			sinon.assert.calledWith(toolsGopathStub);
+			sinon.assert.calledWith(configStub);
+			sandbox.restore();
+
+			// Read the $GOPATH/bin to confirm that the expected tools were
+			// installed.
+			const readdir = util.promisify(fs.readdir);
+			const files = await readdir(path.join(tmpToolsGopath, 'bin'));
+			assert.deepEqual(files, missing, `tool installation failed for ${missing}`);
+
+			// TODO(rstambler): A module cache gets created in $GOPATH/pkg with
+			// different permissions, and fs.chown doesn't seem to work on it.
+			// Not sure how to remove the files so that the temporary directory
+			// can be deleted.
+		}
+
+		rmdirRecursive(proxyDir);
+	});
+});
+
+// buildFakeProxy creates a fake file-based proxy used for testing. The code is
+// mostly adapted from golang.org/x/tools/internal/proxydir/proxydir.go.
+function buildFakeProxy(tools: string[]) {
+	const proxyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxydir'));
+	for (const toolName of tools) {
+		const tool = getTool(toolName);
+		const module = tool.importPath;
+		const version = `v1.0.0`; // hardcoded for now
+		const dir = path.join(proxyDir, module, '@v');
+		fs.mkdirSync(dir, { recursive: true });
+
+		// Write the list file.
+		fs.writeFileSync(path.join(dir, 'list'), `${version}\n`);
+
+		// Write the go.mod file.
+		fs.writeFileSync(path.join(dir, `${version}.mod`), `module ${module}\n`);
+
+		// Write the info file.
+		fs.writeFileSync(path.join(dir, `${version}.info`), `{ "Version": "${version}", "Time": "2020-04-07T14:45:07Z" } `);
+
+		// Write the zip file.
+		const zip = new AdmZip();
+		const content = `package main; func main() {};`;
+		zip.addFile(path.join(`${module}@${version}`, 'main.go'), Buffer.alloc(content.length, content));
+		zip.writeZip(path.join(dir, `${version}.zip`));
+	}
+	return proxyDir;
+}