blob: 47a67a9895c9b5efa13f96d55f58476d2da3942a [file] [log] [blame]
/* eslint-disable @typescript-eslint/no-explicit-any */
/*---------------------------------------------------------
* Copyright (C) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
import assert from 'assert';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as vscode from 'vscode';
import { LanguageClient } from 'vscode-languageclient/node';
import { getGoConfig } from '../../src/config';
import { buildLanguageClient, BuildLanguageClientOption, buildLanguageServerConfig } from '../../src/goLanguageServer';
import sinon = require('sinon');
import { getGoVersion, GoVersion } from '../../src/util';
// FakeOutputChannel is a fake output channel used to buffer
// the output of the tested language client in an in-memory
// string array until cleared.
class FakeOutputChannel implements vscode.OutputChannel {
public name = 'FakeOutputChannel';
public show = sinon.fake(); // no-empty
public hide = sinon.fake(); // no-empty
public dispose = sinon.fake(); // no-empty
private buf = [] as string[];
private eventEmitter = new EventEmitter();
private registeredPatterns = new Set<string>();
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 = [];
};
public toString = () => {
return this.buf.join('\n');
};
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 and lsp client.
// Currently, this works only in module-aware mode.
class Env {
public languageClient?: LanguageClient;
private fakeOutputChannel: FakeOutputChannel;
private disposables = [] as { dispose(): any }[];
public flushTrace(print: boolean) {
if (print) {
console.log(this.fakeOutputChannel.toString());
}
this.fakeOutputChannel.clear();
}
// 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<void> {
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 async setup(filePath: string) {
// file path to open.
this.fakeOutputChannel = new FakeOutputChannel();
const pkgLoadingDone = this.onMessageInTrace('Finished loading packages.', 60_000);
// 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
formatTool: { value: 'nonexistent' } // to test custom formatters
});
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;
}
public async teardown() {
await this.languageClient?.stop();
for (const d of this.disposables) {
d.dispose();
}
this.languageClient = undefined;
}
public async openDoc(...paths: string[]) {
const uri = vscode.Uri.file(path.resolve(...paths));
const doc = await vscode.workspace.openTextDocument(uri);
return { uri, doc };
}
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
suite('Go Extension Tests With Gopls', function () {
this.timeout(300000);
const projectDir = path.join(__dirname, '..', '..', '..');
const testdataDir = path.join(projectDir, 'test', 'testdata');
const env = new Env();
let goVersion: GoVersion;
suiteSetup(async () => {
await env.setup(path.resolve(testdataDir, 'gogetdocTestData', 'test.go'));
goVersion = await getGoVersion();
});
suiteTeardown(() => env.teardown());
this.afterEach(function () {
// Note: this shouldn't use () => {...}. Arrow functions do not have 'this'.
// I don't know why but this.currentTest.state does not have the expected value when
// used with teardown.
env.flushTrace(this.currentTest.state === 'failed');
});
test('HoverProvider', async () => {
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
['inside a string', new vscode.Position(23, 14), null, null], // inside a string
['just a }', new vscode.Position(20, 0), null, null], // just a }
['inside a number', new vscode.Position(28, 16), null, null], // inside a number
['func main()', new vscode.Position(22, 5), 'func main()', null],
['import "math"', new vscode.Position(40, 23), 'package math', '`math` on'],
[
'func Println()',
new vscode.Position(19, 6),
goVersion.lt('1.18')
? 'func fmt.Println(a ...interface{}) (n int, err error)'
: 'func fmt.Println(a ...any) (n int, err error)',
'Println formats '
],
['func print()', new vscode.Position(23, 4), 'func print(txt string)', 'This is an unexported function ']
];
const promises = testCases.map(async ([name, position, expectedSignature, expectedDoc]) => {
const hovers = (await vscode.commands.executeCommand(
'vscode.executeHoverProvider',
uri,
position
)) as vscode.Hover[];
if (expectedSignature === null && expectedDoc === null) {
assert.equal(hovers.length, 0, `check hovers over ${name} failed: unexpected non-empty hover message.`);
return;
}
const hover = hovers[0];
assert.equal(
hover.contents.length,
1,
`check hovers over ${name} failed: unexpected number of hover messages.`
);
const gotMessage = (<vscode.MarkdownString>hover.contents[0]).value;
assert.ok(
gotMessage.includes('```go\n' + expectedSignature + '\n```') &&
(!expectedDoc || gotMessage.includes(expectedDoc)),
`check hovers over ${name} failed: got ${gotMessage}`
);
});
return Promise.all(promises);
});
test('Completion middleware', async () => {
const { uri } = await env.openDoc(testdataDir, 'gogetdocTestData', 'test.go');
const testCases: [string, vscode.Position, string][] = [['fmt.P<>', new vscode.Position(19, 6), 'Print']];
for (const [name, position, wantFilterText] of testCases) {
let list: vscode.CompletionList<vscode.CompletionItem>;
// Query completion items. We expect the hard coded filter text hack
// has been applied and gopls returns an incomplete list by default
// to avoid reordering by vscode. But, if the query is made before
// gopls is ready, we observed that gopls returns an empty result
// as a complete result, and vscode returns a general completion list instead.
// Retry a couple of times if we see a complete result as a workaround.
// (github.com/golang/vscode-go/issues/363)
for (let i = 0; i < 3; i++) {
list = (await vscode.commands.executeCommand(
'vscode.executeCompletionItemProvider',
uri,
position
)) as vscode.CompletionList;
if (list.isIncomplete) {
break;
}
await sleep(100);
console.log(`${new Date()}: retrying...`);
}
// Confirm that the hardcoded filter text hack has been applied.
if (!list.isIncomplete) {
assert.fail('gopls should provide an incomplete list by default');
}
// 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`
);
}
}
}
});
test('Nonexistent formatter', async () => {
const { uri } = await env.openDoc(testdataDir, 'gogetdocTestData', 'format.go');
const result = (await vscode.commands.executeCommand(
'vscode.executeFormatDocumentProvider',
uri,
{} // empty options
)) as vscode.TextEdit[];
if (result) {
assert.fail(`expected no result, got one: ${result}`);
}
});
});