diff --git a/.vscode/launch.json b/.vscode/launch.json index 4f23b75e3d..3ec6c82573 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -64,10 +64,14 @@ "--timeout", "999999" ], + "env": { + "VSCODE_GO_IN_TEST": "1" // Disable code that shouldn't be used in test + }, "stopOnEntry": false, "sourceMaps": true, "smartStep": true, "outFiles": [ + "${workspaceFolder}/out/**/*.js", "${workspaceFolder}/out/test/**/*.js" ], "preLaunchTask": "npm: watch" @@ -83,11 +87,14 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/out/test/gopls/index", "--timeout", "999999", - "${workspaceFolder}/test/gopls/testfixtures/src/workspace" // gopls requires a workspace to work with. ], + "env": { + "VSCODE_GO_IN_TEST": "1" // Disable code that shouldn't be used in test + }, "stopOnEntry": false, "sourceMaps": true, "outFiles": [ + "${workspaceFolder}/out/**/*.js", "${workspaceFolder}/out/test/**/*.js" ], "preLaunchTask": "npm: watch", diff --git a/src/goCheck.ts b/src/goCheck.ts index 597e018b11..327a5f8be1 100644 --- a/src/goCheck.ts +++ b/src/goCheck.ts @@ -59,7 +59,7 @@ export function check(fileUri: vscode.Uri, goConfig: vscode.WorkspaceConfigurati // If a user has enabled diagnostics via a language server, // then we disable running build or vet to avoid duplicate errors and warnings. - const lspConfig = buildLanguageServerConfig(); + const lspConfig = buildLanguageServerConfig(goConfig); const disableBuildAndVet = lspConfig.enabled && lspConfig.features.diagnostics; let testPromise: Thenable; diff --git a/src/goInstallTools.ts b/src/goInstallTools.ts index 59974cf211..2e3a06aafa 100644 --- a/src/goInstallTools.ts +++ b/src/goInstallTools.ts @@ -466,6 +466,11 @@ export async function offerToInstallTools() { }); } + const goConfig = getGoConfig(); + if (!goConfig['useLanguageServer']) { + return; + } + const usingSourceGraph = getToolFromToolPath(getLanguageServerToolPath()) === 'go-langserver'; if (usingSourceGraph && goVersion.gt('1.10')) { const promptMsg = @@ -476,7 +481,6 @@ export async function offerToInstallTools() { if (selected === installLabel) { await installTools([getTool('gopls')], goVersion); } else if (selected === disableLabel) { - const goConfig = getGoConfig(); const inspectLanguageServerSetting = goConfig.inspect('useLanguageServer'); if (inspectLanguageServerSetting.globalValue === true) { goConfig.update('useLanguageServer', false, vscode.ConfigurationTarget.Global); diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts index 9d0dcf7b21..30256c967b 100644 --- a/src/goLanguageServer.ts +++ b/src/goLanguageServer.ts @@ -58,7 +58,7 @@ import { getFromGlobalState, updateGlobalState } from './stateUtils'; import { getBinPath, getCurrentGoPath, getGoConfig, getGoplsConfig, getWorkspaceFolderPath } from './util'; import { getToolFromToolPath } from './utils/pathUtils'; -interface LanguageServerConfig { +export interface LanguageServerConfig { serverName: string; path: string; version: string; @@ -102,7 +102,7 @@ let lastUserAction: Date = new Date(); // startLanguageServerWithFallback starts the language server, if enabled, // or falls back to the default language providers. export async function startLanguageServerWithFallback(ctx: vscode.ExtensionContext, activation: boolean) { - const cfg = buildLanguageServerConfig(); + const cfg = buildLanguageServerConfig(getGoConfig()); // If the language server is gopls, we enable a few additional features. // These include prompting for updates and surveys. @@ -134,7 +134,7 @@ function scheduleGoplsSuggestions(tool: Tool) { const update = async () => { setTimeout(update, timeDay); - const cfg = buildLanguageServerConfig(); + const cfg = buildLanguageServerConfig(getGoConfig()); if (!cfg.enabled) { return; } @@ -146,7 +146,7 @@ function scheduleGoplsSuggestions(tool: Tool) { const survey = async () => { setTimeout(survey, timeDay); - const cfg = buildLanguageServerConfig(); + const cfg = buildLanguageServerConfig(getGoConfig()); if (!goplsSurveyOn || !cfg.enabled) { return; } @@ -176,7 +176,7 @@ async function startLanguageServer(ctx: vscode.ExtensionContext, config: Languag // Track the latest config used to start the language server, // and rebuild the language client. latestConfig = config; - languageClient = await buildLanguageClient(config); + languageClient = await buildLanguageClient(buildLanguageClientOption(config)); crashCount = 0; } @@ -206,21 +206,38 @@ async function startLanguageServer(ctx: vscode.ExtensionContext, config: Languag return true; } -async function buildLanguageClient(cfg: LanguageServerConfig): Promise { - // Reuse the same output channel for each instance of the server. - if (cfg.enabled) { - if (!serverOutputChannel) { - serverOutputChannel = vscode.window.createOutputChannel(cfg.serverName + ' (server)'); - } - if (!serverTraceChannel) { - serverTraceChannel = vscode.window.createOutputChannel(cfg.serverName); +export interface BuildLanguageClientOption extends LanguageServerConfig { + outputChannel?: vscode.OutputChannel; + traceOutputChannel?: vscode.OutputChannel; +} + +// buildLanguageClientOption returns the default, extra configuration +// used in building a new LanguageClient instance. Options specified +// in LanguageServerConfig +function buildLanguageClientOption(cfg: LanguageServerConfig): BuildLanguageClientOption { + // Reuse the same output channel for each instance of the server. + if (cfg.enabled) { + if (!serverOutputChannel) { + serverOutputChannel = vscode.window.createOutputChannel(cfg.serverName + ' (server)'); + } + if (!serverTraceChannel) { + serverTraceChannel = vscode.window.createOutputChannel(cfg.serverName); + } } - } + return Object.assign({ + outputChannel: serverOutputChannel, + traceOutputChannel: serverTraceChannel + }, cfg); +} + +// buildLanguageClient returns a language client built using the given language server config. +// The returned language client need to be started before use. +export async function buildLanguageClient(cfg: BuildLanguageClientOption): Promise { let goplsWorkspaceConfig = getGoplsConfig(); goplsWorkspaceConfig = await adjustGoplsWorkspaceConfiguration(cfg, goplsWorkspaceConfig); const c = new LanguageClient( 'go', // id - cfg.serverName, // name + cfg.serverName, // name e.g. gopls { command: cfg.path, args: ['-mode=stdio', ...cfg.flags], @@ -235,8 +252,8 @@ async function buildLanguageClient(cfg: LanguageServerConfig): Promise vscode.Uri.parse(uri) }, - outputChannel: serverOutputChannel, - traceOutputChannel: serverTraceChannel, + outputChannel: cfg.outputChannel, + traceOutputChannel: cfg.traceOutputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, initializationFailedHandler: (error: WebRequest.ResponseError): boolean => { vscode.window.showErrorMessage( @@ -550,8 +567,8 @@ export function watchLanguageServerConfiguration(e: vscode.ConfigurationChangeEv } } -export function buildLanguageServerConfig(): LanguageServerConfig { - const goConfig = getGoConfig(); +export function buildLanguageServerConfig(goConfig: vscode.WorkspaceConfiguration): LanguageServerConfig { + const cfg: LanguageServerConfig = { serverName: '', path: '', @@ -604,9 +621,6 @@ Please try reinstalling it.`); */ export function getLanguageServerToolPath(): string { const goConfig = getGoConfig(); - if (!goConfig['useLanguageServer']) { - return; - } // Check that all workspace folders are configured with the same GOPATH. if (!allFoldersHaveSameGopath()) { vscode.window.showInformationMessage( diff --git a/src/goMain.ts b/src/goMain.ts index 78099aeed8..7c370c5e6c 100644 --- a/src/goMain.ts +++ b/src/goMain.ts @@ -83,6 +83,9 @@ export let vetDiagnosticCollection: vscode.DiagnosticCollection; export let restartLanguageServer = () => { return; }; export function activate(ctx: vscode.ExtensionContext) { + if (process.env['VSCODE_GO_IN_TEST'] === '1') { // Make sure this does not run when running in test. + return; + } const cfg = getGoConfig(); setLogConfig(cfg['logging']); diff --git a/src/goStatus.ts b/src/goStatus.ts index 08ec245501..bcd4bbb3fd 100644 --- a/src/goStatus.ts +++ b/src/goStatus.ts @@ -12,7 +12,7 @@ import { formatGoVersion, GoEnvironmentOption, terminalCreationListener } from ' import { buildLanguageServerConfig, getLocalGoplsVersion, serverOutputChannel } from './goLanguageServer'; import { isGoFile } from './goMode'; import { getModFolderPath, isModSupported } from './goModules'; -import { getGoVersion } from './util'; +import { getGoConfig, getGoVersion } from './util'; export let outputChannel = vscode.window.createOutputChannel('Go'); @@ -47,7 +47,7 @@ export async function expandGoStatusBar() { ]; // Get the gopls configuration - const cfg = buildLanguageServerConfig(); + const cfg = buildLanguageServerConfig(getGoConfig()); if (cfg.serverName === 'gopls') { const goplsVersion = await getLocalGoplsVersion(cfg); options.push({label: `${languageServerIcon}Open 'gopls' trace`, description: `${goplsVersion}`}); @@ -101,7 +101,7 @@ export async function initGoStatusBar() { // Add an icon to indicate that the 'gopls' server is running. // Assume if it is configured it is already running, since the // icon will be updated on an attempt to start. - const cfg = buildLanguageServerConfig(); + const cfg = buildLanguageServerConfig(getGoConfig()); updateLanguageServerIconGoStatusBar(true, cfg.serverName); showGoStatusBar(); diff --git a/test/gopls/extension.test.ts b/test/gopls/extension.test.ts index 4df045f7d1..7e82399576 100644 --- a/test/gopls/extension.test.ts +++ b/test/gopls/extension.test.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------*/ import * as assert from 'assert'; import cp = require('child_process'); -import * as fs from 'fs-extra'; +import { EventEmitter } from 'events'; import * as path from 'path'; import sinon = require('sinon'); import * as vscode from 'vscode'; -import { extensionId } from '../../src/const'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { buildLanguageClient, BuildLanguageClientOption, buildLanguageServerConfig } from '../../src/goLanguageServer'; +import { getGoConfig } from '../../src/util'; // FakeOutputChannel is a fake output channel used to buffer // the output of the tested language client in an in-memory @@ -21,6 +23,16 @@ class FakeOutputChannel implements vscode.OutputChannel { private buf = [] as string[]; + private eventEmitter = new EventEmitter(); + private registeredPatterns = new Set(); + public onPattern(msg: string, listener: () => void) { + this.registeredPatterns.add(msg); + this.eventEmitter.once(msg, () => { + this.registeredPatterns.delete(msg); + listener(); + }); + } + public append = (v: string) => this.enqueue(v); public appendLine = (v: string) => this.enqueue(v); public clear = () => { this.buf = []; }; @@ -29,98 +41,75 @@ class FakeOutputChannel implements vscode.OutputChannel { } private enqueue = (v: string) => { + this.registeredPatterns?.forEach((p) => { + if (v.includes(p)) { + this.eventEmitter.emit(p); + } + }); + if (this.buf.length > 1024) { this.buf.shift(); } this.buf.push(v.trim()); } } -// Env is a collection of test related variables -// that define the test environment such as vscode workspace. +// Env is a collection of test-related variables and lsp client. +// Currently, this works only in module-aware mode. class Env { - - // Currently gopls requires a workspace and does not work in a single-file mode. - // Code in test environment does not support dynamically adding folders. - // tslint:disable-next-line:max-line-length - // https://github.com/microsoft/vscode/blob/890f62dfd9f3e70198931f788c5c332b3e8b7ad7/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts#L281 - // - // So, when we start the gopls tests, we start the test extension host with a - // dummy workspace, ${projectDir}/test/gopls/testdata/src/workspace - // (see test/runTest.ts and launch.json). - // Then copy necessary files to the workspace using Env.reset() from the - // fixturesRoot directory. - public workspaceDir: string; - public fixturesRoot: string; - - public extension: vscode.Extension; - + public languageClient?: LanguageClient; private fakeOutputChannel: FakeOutputChannel; - - constructor(projectDir: string) { - if (!projectDir) { - assert.fail('project directory cannot be determined'); - } - this.workspaceDir = path.resolve(projectDir, 'test/gopls/testdata/src/workspace'); - this.fixturesRoot = path.resolve(projectDir, 'test/testdata'); - this.extension = vscode.extensions.getExtension(extensionId); - this.fakeOutputChannel = new FakeOutputChannel(); - - // Ensure the vscode extension host is configured as expected. - const workspaceFolder = path.resolve(vscode.workspace.workspaceFolders[0].uri.fsPath); - if (this.workspaceDir !== workspaceFolder) { - assert.fail(`specified workspaceDir: ${this.workspaceDir} does not match the workspace folder: ${workspaceFolder}`); - } - } + private disposables = [] as { dispose(): any }[]; public flushTrace(print: boolean) { if (print) { console.log(this.fakeOutputChannel.toString()); - this.fakeOutputChannel.clear(); } } - public async setup() { - // stub the language server's output channel to intercept the trace. - sinon.stub(vscode.window, 'createOutputChannel') - .callThrough().withArgs('gopls (server)').returns(this.fakeOutputChannel); - - await this.reset(); - await this.extension.activate(); - await sleep(2000); // allow the language server to start. - // TODO(hyangah): find a better way to check the language server's status. - // I thought I'd check the languageClient.onReady(), - // but couldn't make it working yet. + // This is a hack to check the progress of package loading. + // TODO(hyangah): use progress message middleware hook instead + // once it becomes available. + public onMessageInTrace(msg: string, timeoutMS: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.flushTrace(true); + reject(`Timed out while waiting for '${msg}'`); + }, timeoutMS); + this.fakeOutputChannel.onPattern(msg, () => { + clearTimeout(timeout); + resolve(); + }); + }); } - public teardown() { - sinon.restore(); - } + public async setup(filePath: string) { // file path to open. + this.fakeOutputChannel = new FakeOutputChannel(); + const pkgLoadingDone = this.onMessageInTrace('Finished loading packages.', 60_000); - public async reset(fixtureDirName?: string) { // name of the fixtures subdirectory to use. - try { - // clean everything except the .gitignore file - // needed to keep the empty directory in vcs. - await fs.readdir(this.workspaceDir).then((files) => { - return Promise.all( - files.filter((filename) => filename !== '.gitignore' && filename !== '.vscode').map((file) => { - fs.remove(path.resolve(this.workspaceDir, file)); - })); - }); + // Start the language server with the fakeOutputChannel. + const goConfig = Object.create(getGoConfig(), { + useLanguageServer: { value: true }, + languageServerFlags: { value: ['-rpc.trace'] }, // enable rpc tracing to monitor progress reports + }); + const cfg: BuildLanguageClientOption = buildLanguageServerConfig(goConfig); + cfg.outputChannel = this.fakeOutputChannel; // inject our fake output channel. + this.languageClient = await buildLanguageClient(cfg); + this.disposables.push(this.languageClient.start()); + + await this.languageClient.onReady(); + await this.openDoc(filePath); + await pkgLoadingDone; + } - if (!fixtureDirName) { - return; - } - const src = path.resolve(this.fixturesRoot, fixtureDirName); - const dst = this.workspaceDir; - await fs.copy(src, dst, { recursive: true }); - } catch (err) { - assert.fail(err); + public async teardown() { + await this.languageClient?.stop(); + for (const d of this.disposables) { + d.dispose(); } + this.languageClient = undefined; } - // openDoc opens the file in the workspace with the given path (paths - // are the path elements of a file). public async openDoc(...paths: string[]) { - const uri = vscode.Uri.file(path.resolve(this.workspaceDir, ...paths)); + const uri = vscode.Uri.file(path.resolve(...paths)); const doc = await vscode.workspace.openTextDocument(uri); return { uri, doc }; } @@ -133,12 +122,11 @@ async function sleep(ms: number) { suite('Go Extension Tests With Gopls', function () { this.timeout(300000); const projectDir = path.join(__dirname, '..', '..', '..'); - const env = new Env(projectDir); + const testdataDir = path.join(projectDir, 'test', 'testdata'); + const env = new Env(); - suiteSetup(async () => { - await env.setup(); - }); - suiteTeardown(async () => { await env.reset(); }); + suiteSetup(async () => await env.setup(path.resolve(testdataDir, 'gogetdocTestData', 'test.go'))); + suiteTeardown(() => env.teardown()); this.afterEach(function () { // Note: this shouldn't use () => {...}. Arrow functions do not have 'this'. @@ -148,11 +136,7 @@ suite('Go Extension Tests With Gopls', function () { }); test('HoverProvider', async () => { - await env.reset('gogetdocTestData'); - const { uri, doc } = await env.openDoc('test.go'); - - // TODO(hyangah): find a way to wait for the language server to complete processing. - + const { uri } = await env.openDoc(testdataDir, 'gogetdocTestData', 'test.go'); const testCases: [string, vscode.Position, string | null, string | null][] = [ // [new vscode.Position(3,3), '/usr/local/go/src/fmt'], ['keyword', new vscode.Position(0, 3), null, null], // keyword @@ -186,8 +170,7 @@ suite('Go Extension Tests With Gopls', function () { }); test('Completion middleware', async () => { - await env.reset('gogetdocTestData'); - const { uri } = await env.openDoc('test.go'); + const { uri } = await env.openDoc(testdataDir, 'gogetdocTestData', 'test.go'); const testCases: [string, vscode.Position, string][] = [ ['fmt.P<>', new vscode.Position(19, 6), 'Print'], ]; @@ -213,14 +196,18 @@ suite('Go Extension Tests With Gopls', function () { if (!list.isIncomplete) { assert.fail(`gopls should provide an incomplete list by default`); } - // TODO(rstambler): For some reason, the filter text gets deleted - // from the first item. I can't reproduce this outside of the test - // suite. - for (let i = 1; i < list.items.length; i++) { - const item = list.items[i]; - assert.equal(item.filterText, wantFilterText, `${uri}:${name} failed, unexpected filter text (got ${item.filterText}, want ${wantFilterText})`); - } + + // vscode.executeCompletionItemProvider will return results from all + // registered completion item providers, not only gopls but also snippets. + // Alternative is to directly query the language client, but that will + // prevent us from detecting problems caused by issues between the language + // client library and the vscode. for (const item of list.items) { + if (item.kind === vscode.CompletionItemKind.Snippet) { continue; } // gopls does not supply Snippet yet. + assert.strictEqual(item.filterText ?? item.label, wantFilterText, + `${uri}:${name} failed, unexpected filter text ` + + `(got ${item.filterText ?? item.label}, want ${wantFilterText})\n` + + `${JSON.stringify(item, null, 2)}`); if (item.kind === vscode.CompletionItemKind.Method || item.kind === vscode.CompletionItemKind.Function) { assert.ok(item.command, `${uri}:${name}: expected command associated with ${item.label}, found none`); } diff --git a/test/integration/extension.test.ts b/test/integration/extension.test.ts index b7ca7632f2..66dd9c365a 100644 --- a/test/integration/extension.test.ts +++ b/test/integration/extension.test.ts @@ -429,7 +429,7 @@ It returns the number of bytes written and any write error encountered. ]; // If a user has enabled diagnostics via a language server, // then we disable running build or vet to avoid duplicate errors and warnings. - const lspConfig = buildLanguageServerConfig(); + const lspConfig = buildLanguageServerConfig(getGoConfig()); const expectedBuildVetErrors = lspConfig.enabled ? [] : [{ line: 11, severity: 'error', msg: 'undefined: prin' }]; const expected = [...expectedLintErrors, ...expectedBuildVetErrors]; @@ -1370,7 +1370,7 @@ encountered. }); test('Build Tags checking', async () => { - const goplsConfig = buildLanguageServerConfig(); + const goplsConfig = buildLanguageServerConfig(getGoConfig()); if (goplsConfig.enabled) { // Skip this test if gopls is enabled. Build/Vet checks this test depend on are // disabled when the language server is enabled, and gopls is not handling tags yet. diff --git a/test/runTest.ts b/test/runTest.ts index 22dc780855..c0476913ea 100644 --- a/test/runTest.ts +++ b/test/runTest.ts @@ -2,6 +2,9 @@ import * as path from 'path'; import { runTests } from 'vscode-test'; async function main() { + // We are in test mode. + process.env['VSCODE_GO_IN_TEST'] = '1'; + // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` const extensionDevelopmentPath = path.resolve(__dirname, '../../'); @@ -26,20 +29,14 @@ async function main() { // Integration tests using gopls. try { - // Currently gopls requires a workspace. Code in test environment does not support - // dynamically adding folders. + // Note: Code in test environment does not support dynamically adding folders. // tslint:disable-next-line:max-line-length // https://github.com/microsoft/vscode/blob/890f62dfd9f3e70198931f788c5c332b3e8b7ad7/src/vs/workbench/services/workspaces/browser/abstractWorkspaceEditingService.ts#L281 - // So, we start the test extension host with a dummy workspace (test/gopls/testdata/src/workspace) - // and copy necessary files to the workspace. - const ws = path.resolve(extensionDevelopmentPath, 'test/gopls/testdata/src/workspace'); - await runTests({ extensionDevelopmentPath, extensionTestsPath: path.resolve(__dirname, './gopls/index'), launchArgs: [ '--disable-extensions', // disable all other extensions - ws // dummy workspace to start with ], }); } catch (err) {