extension/src/goTest: disable when exp-vscode-go is installed
Adds 'experimental features' to preview versions. The first experimental
feature: when exp-vscode-go is installed, disable goTest (the test
explorer implementation). Adds a setting to disable this behavior
Notifies the user the first time goTest is disabled for this reason.
Modifies goTest to support being unloaded or reloaded based on
configuration changes.
Change-Id: I7a4b2188b5038f9f6b5841ed47a5b27307e24ef1
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/613695
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
Commit-Queue: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Hongxiang Jiang <hxjiang@golang.org>
Auto-Submit: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1a9f0a..e7df1fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,10 @@
* Extension build target is set to `es2022`. ([Issue 3540](https://github.com/golang/vscode-go/issues/3540))
* The extension release workflow is migrated to the Go project's [Relui](https://pkg.go.dev/golang.org/x/build/cmd/relui#section-readme). ([Issue 3500](https://github.com/golang/vscode-go/issues/3500))
+### Testing
+
+A new extension, [Go Companion](https://marketplace.visualstudio.com/items?itemName=ethan-reesor.exp-vscode-go), has been released with experimental support for gopls-based test discovery. If Go Companion is installed, pre-release versions of this extension will automatically disable its test explorer in favor of Go Companion's implementation. See [experiments](./docs/experiments.md#test-explorer) for details on Go Companion's features and for disabling the automatic switchover.
+
## v0.42.1
Date: 9 Sep, 2024
diff --git a/docs/experiments.md b/docs/experiments.md
new file mode 100644
index 0000000..2fb8c19
--- /dev/null
+++ b/docs/experiments.md
@@ -0,0 +1,31 @@
+# Experiments
+
+Pre-release versions of [vscode-go][vscode-go] include experimental features.
+These features may be individually enabled or disabled via the setting
+`go.experiments`.
+
+[vscode-go]: https://github.com/golang/vscode-go/blob/master/README.md#pre-release-versions
+
+## Test explorer
+
+[Go Companion][exp-vscode-go] includes an experimental test explorer
+implementation based on `gopls`'s test discovery. This requires gopls v0.17.0 or
+newer. If Go Companion is present and vscode-go is a pre-release version,
+vscode-go will prefer Go Companion's test explorer, disabling its own, unless
+the experiment is set to `off`. The experimental test explorer provides more
+robust test discovery by using gopls, including static discovery of _some_
+subtests. It also implements:
+
+- Ignore tests within files excluded by `files.exclude` or
+ `goExp.testExplorer.exclude`.
+- Disable automatic discovery of tests by setting `goExp.testExplorer.discovery`
+ to "off".
+- Control how tests are displayed with `goExp.testExplorer.showFiles`,
+ `goExp.testExplorer.nestPackages`, and `goExp.testExplorer.nestSubtests`.
+- Debugging a test updates its status in the test explorer.
+- Support for continuous runs.
+- Support for code coverage.
+- Code lenses (hidden by default) that are integrated with the test explorer.
+- Integrated viewer for pprof profiles.
+
+[exp-vscode-go]: https://marketplace.visualstudio.com/items?itemName=ethan-reesor.exp-vscode-go
\ No newline at end of file
diff --git a/docs/settings.md b/docs/settings.md
index aa55b82..56149e1 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -218,6 +218,19 @@
"runtest" : true,
}
```
+### `go.experiments`
+
+Disable experimental features. These features are only available in the pre-release version.
+| Properties | Description |
+| --- | --- |
+| `testExplorer` | Prefer the experimental test explorer <br/> Default: `true` |
+
+Default:
+```
+{
+ "testExplorer" : true,
+}
+```
### `go.formatFlags`
Flags to pass to format tool (e.g. ["-s"]). Not applicable when using the language server.
diff --git a/extension/package.json b/extension/package.json
index 6306554..e4f621f 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -1477,6 +1477,20 @@
"description": "Open the test output terminal when a test run is started.",
"scope": "window"
},
+ "go.experiments": {
+ "type": "object",
+ "default": {
+ "testExplorer": true
+ },
+ "description": "Disable experimental features. These features are only available in the pre-release version.",
+ "properties": {
+ "testExplorer": {
+ "type": "boolean",
+ "default": true,
+ "description": "Prefer the experimental test explorer"
+ }
+ }
+ },
"go.generateTestsFlags": {
"type": "array",
"items": {
diff --git a/extension/src/experimental.ts b/extension/src/experimental.ts
new file mode 100644
index 0000000..cabbe55
--- /dev/null
+++ b/extension/src/experimental.ts
@@ -0,0 +1,77 @@
+/*---------------------------------------------------------
+ * Copyright 2024 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+import { EventEmitter, ExtensionContext, ExtensionMode, extensions, workspace } from 'vscode';
+import { extensionInfo } from './config';
+
+type Settings = {
+ testExplorer: boolean;
+};
+
+class Experiments {
+ #didChange = new EventEmitter<Experiments>();
+
+ // Default to disabled
+ #testExplorer = false;
+
+ activate(ctx: ExtensionContext) {
+ // Cleanup the event emitter when the extension is unloaded
+ ctx.subscriptions.push(this.#didChange);
+
+ // Don't enable any experiments in a production release
+ if (ctx.extensionMode === ExtensionMode.Production && !extensionInfo.isPreview) {
+ return;
+ }
+
+ // Check on boot
+ this.#maybeEnableExperiments();
+
+ // Check when an extension is installed or uninstalled
+ ctx.subscriptions.push(extensions.onDidChange(() => this.#maybeEnableExperiments()));
+
+ // Check when the configuration changes
+ ctx.subscriptions.push(
+ workspace.onDidChangeConfiguration((e) => {
+ if (e.affectsConfiguration('go.experiments')) {
+ this.#maybeEnableExperiments();
+ }
+ })
+ );
+ }
+
+ /**
+ * Checks whether experiments should be enabled or disabled. If the
+ * enable/disable state of an experiment changes, an {@link onDidChange}
+ * event is issued.
+ */
+ #maybeEnableExperiments() {
+ const settings = workspace.getConfiguration('go').get<Settings>('experiments');
+
+ // Check if the test explorer experiment should be activated
+ const goExp = extensions.getExtension('ethan-reesor.exp-vscode-go');
+ const testExplorer = settings?.testExplorer !== false && !!goExp;
+ if (testExplorer !== this.#testExplorer) {
+ this.#testExplorer = testExplorer;
+ this.#didChange.fire(this);
+ }
+ }
+
+ /**
+ * onDidChange issues an event whenever the enable/disable status of an
+ * experiment changes. This can happen due to configuration changes or
+ * companion extensions being loaded or unloaded.
+ */
+ readonly onDidChange = this.#didChange.event;
+
+ /**
+ * If true, this extension's test explorer is disabled in favor of Go
+ * Companion's test explorer.
+ */
+ get testExplorer() {
+ return this.#testExplorer;
+ }
+}
+
+export const experiments = new Experiments();
diff --git a/extension/src/goMain.ts b/extension/src/goMain.ts
index 015b1eb..6864e4f 100644
--- a/extension/src/goMain.ts
+++ b/extension/src/goMain.ts
@@ -65,7 +65,7 @@
import { resetSurveyConfigs, showSurveyConfig } from './goSurvey';
import { ExtensionAPI } from './export';
import extensionAPI from './extensionAPI';
-import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
+import { GoTestExplorer } from './goTest/explore';
import { killRunningPprof } from './goTest/profile';
import { GoExplorerProvider } from './goExplorer';
import { GoExtensionContext } from './context';
@@ -73,6 +73,7 @@
import { toggleVulncheckCommandFactory } from './goVulncheck';
import { GoTaskProvider } from './goTaskProvider';
import { setTelemetryEnvVars, telemetryReporter } from './goTelemetry';
+import { experiments } from './experimental';
const goCtx: GoExtensionContext = {};
@@ -147,6 +148,9 @@
GoRunTestCodeLensProvider.activate(ctx, goCtx);
GoDebugConfigurationProvider.activate(ctx, goCtx);
GoDebugFactory.activate(ctx, goCtx);
+ experiments.activate(ctx);
+ GoTestExplorer.setup(ctx, goCtx);
+ GoExplorerProvider.setup(ctx);
goCtx.buildDiagnosticCollection = vscode.languages.createDiagnosticCollection('go');
ctx.subscriptions.push(goCtx.buildDiagnosticCollection);
@@ -185,12 +189,6 @@
registerCommand('go.tools.install', commands.installTools);
registerCommand('go.browse.packages', browsePackages);
- if (isVscodeTestingAPIAvailable && cfg.get<boolean>('testExplorer.enable')) {
- GoTestExplorer.setup(ctx, goCtx);
- }
-
- GoExplorerProvider.setup(ctx);
-
registerCommand('go.test.generate.package', goGenerateTests.generateTestCurrentPackage);
registerCommand('go.test.generate.file', goGenerateTests.generateTestCurrentFile);
registerCommand('go.test.generate.function', goGenerateTests.generateTestCurrentFunction);
@@ -332,15 +330,6 @@
// TODO: actively maintain our own disposables instead of keeping pushing to ctx.subscription.
}
}
- if (e.affectsConfiguration('go.testExplorer.enable')) {
- const msg =
- 'Go test explorer has been enabled or disabled. For this change to take effect, the window must be reloaded.';
- vscode.window.showInformationMessage(msg, 'Reload').then((selected) => {
- if (selected === 'Reload') {
- vscode.commands.executeCommand('workbench.action.reloadWindow');
- }
- });
- }
})
);
}
diff --git a/extension/src/goTest/explore.ts b/extension/src/goTest/explore.ts
index e6901b2..d00abb1 100644
--- a/extension/src/goTest/explore.ts
+++ b/extension/src/goTest/explore.ts
@@ -25,16 +25,54 @@
import { GoTestRunner } from './run';
import { GoTestProfiler } from './profile';
import { GoExtensionContext } from '../context';
-
-// Set true only if the Testing API is available (VSCode version >= 1.59).
-export const isVscodeTestingAPIAvailable =
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- 'object' === typeof (vscode as any).tests && 'function' === typeof (vscode as any).tests.createTestController;
+import { getGoConfig } from '../config';
+import { experiments } from '../experimental';
export class GoTestExplorer {
- static setup(context: ExtensionContext, goCtx: GoExtensionContext): GoTestExplorer {
- if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+ static setup(context: ExtensionContext, goCtx: GoExtensionContext) {
+ // Set the initial state
+ const state: { instance?: GoTestExplorer } = {};
+ this.updateEnableState(context, goCtx, state);
+ context.subscriptions.push({ dispose: () => state.instance?.dispose() });
+ // Update the state when the experimental version is enabled or disabled
+ context.subscriptions.push(experiments.onDidChange(() => this.updateEnableState(context, goCtx, state)));
+
+ // Update the state when the explorer is enabled or disabled via config
+ context.subscriptions.push(
+ vscode.workspace.onDidChangeConfiguration((e) => {
+ if (e.affectsConfiguration('go.testExplorer.enable')) {
+ this.updateEnableState(context, goCtx, state);
+ }
+ })
+ );
+ }
+
+ private static updateEnableState(
+ context: ExtensionContext,
+ goCtx: GoExtensionContext,
+ state: { instance?: GoTestExplorer }
+ ) {
+ // Notify the user if it's the first time we've disabled the test
+ // explorer
+ if (experiments.testExplorer === true) {
+ notifyUserOfExperiment(context.globalState).catch((x) =>
+ outputChannel.error('An error occurred while notifying the user', x)
+ );
+ }
+
+ const enabled = getGoConfig().get<boolean>('testExplorer.enable') && !experiments.testExplorer;
+ if (enabled && !state.instance) {
+ state.instance = this.new(context, goCtx);
+ context.subscriptions.push(state.instance);
+ } else if (!enabled && state.instance) {
+ state.instance.dispose();
+ state.instance = undefined;
+ }
+ }
+
+ static new(context: ExtensionContext, goCtx: GoExtensionContext): GoTestExplorer {
+ // This function is exposed for the purpose of testing
const ctrl = vscode.tests.createTestController('go', 'Go');
const symProvider = GoDocumentSymbolProvider(goCtx, true);
const inst = new this(goCtx, workspace, ctrl, context.workspaceState, (doc) =>
@@ -46,10 +84,9 @@
inst.documentUpdate(ed.document);
});
- context.subscriptions.push(ctrl);
- context.subscriptions.push(vscode.window.registerTreeDataProvider('go.test.profile', inst.profiler.view));
+ inst.subscriptions.push(vscode.window.registerTreeDataProvider('go.test.profile', inst.profiler.view));
- context.subscriptions.push(
+ inst.subscriptions.push(
vscode.commands.registerCommand('go.test.refresh', async (item) => {
if (!item) {
await vscode.window.showErrorMessage('No test selected');
@@ -68,7 +105,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
vscode.commands.registerCommand('go.test.showProfiles', async (item) => {
if (!item) {
await vscode.window.showErrorMessage('No test selected');
@@ -86,7 +123,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
vscode.commands.registerCommand('go.test.captureProfile', async (item) => {
if (!item) {
await vscode.window.showErrorMessage('No test selected');
@@ -110,7 +147,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
vscode.commands.registerCommand('go.test.deleteProfile', async (file) => {
if (!file) {
await vscode.window.showErrorMessage('No profile selected');
@@ -129,13 +166,13 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
vscode.commands.registerCommand('go.test.showProfileFile', async (file: Uri) => {
return inst.profiler.showFile(file.fsPath);
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
workspace.onDidChangeConfiguration(async (x) => {
try {
await inst.didChangeConfiguration(x);
@@ -146,7 +183,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
workspace.onDidOpenTextDocument(async (x) => {
try {
await inst.didOpenTextDocument(x);
@@ -157,7 +194,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
workspace.onDidChangeTextDocument(async (x) => {
try {
await inst.didChangeTextDocument(x);
@@ -168,7 +205,7 @@
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
workspace.onDidChangeWorkspaceFolders(async (x) => {
try {
await inst.didChangeWorkspaceFolders(x);
@@ -180,8 +217,8 @@
);
const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
- context.subscriptions.push(watcher);
- context.subscriptions.push(
+ inst.subscriptions.push(watcher);
+ inst.subscriptions.push(
watcher.onDidCreate(async (x) => {
try {
await inst.didCreateFile(x);
@@ -191,7 +228,7 @@
}
})
);
- context.subscriptions.push(
+ inst.subscriptions.push(
watcher.onDidDelete(async (x) => {
try {
await inst.didDeleteFile(x);
@@ -208,6 +245,7 @@
public readonly resolver: GoTestResolver;
public readonly runner: GoTestRunner;
public readonly profiler: GoTestProfiler;
+ public readonly subscriptions: vscode.Disposable[] = [];
constructor(
private readonly goCtx: GoExtensionContext,
@@ -219,6 +257,12 @@
this.resolver = new GoTestResolver(workspace, ctrl, provideDocumentSymbols);
this.profiler = new GoTestProfiler(this.resolver, workspaceState);
this.runner = new GoTestRunner(goCtx, workspace, ctrl, this.resolver, this.profiler);
+ this.subscriptions.push(ctrl);
+ }
+
+ dispose() {
+ this.subscriptions.forEach((x) => x.dispose());
+ this.subscriptions.splice(0, this.subscriptions.length);
}
/* ***** Listeners ***** */
@@ -324,3 +368,31 @@
this.resolver.updateGoTestContext();
}
}
+
+/**
+ * Notify the user that we're enabling the experimental explorer.
+ */
+async function notifyUserOfExperiment(state: Memento) {
+ // If the user has acknowledged the notification, don't show it again.
+ if (state.get('experiment.testExplorer.didAckNotification') === true) {
+ return;
+ }
+
+ const r = await vscode.window.showInformationMessage(
+ 'Switching to the experimental test explorer. This experiments can be disabled by setting go.experiments.testExplorer to false.',
+ 'Open settings',
+ 'Ok'
+ );
+
+ switch (r) {
+ case 'Open settings':
+ await vscode.commands.executeCommand('workbench.action.openSettings2', {
+ query: 'go.experiments'
+ });
+ break;
+
+ case 'Ok':
+ state.update('experiment.testExplorer.didAckNotification', true);
+ break;
+ }
+}
diff --git a/extension/test/gopls/goTest.explore.test.ts b/extension/test/gopls/goTest.explore.test.ts
index 1810997..6cec1d5 100644
--- a/extension/test/gopls/goTest.explore.test.ts
+++ b/extension/test/gopls/goTest.explore.test.ts
@@ -217,7 +217,12 @@
const env = new Env();
- this.afterEach(async function () {
+ this.beforeAll(() => {
+ testExplorer = GoTestExplorer.new(ctx, env.goCtx);
+ ctx.subscriptions.push(testExplorer);
+ });
+
+ this.afterAll(async function () {
await env.teardown();
// 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
@@ -234,8 +239,6 @@
// used in the previous test suite. Figure out why.
await env.startGopls(uri.fsPath, undefined, fixtureDir);
- testExplorer = GoTestExplorer.setup(ctx, env.goCtx);
-
document = await forceDidOpenTextDocument(workspace, testExplorer, uri);
const tests = testExplorer.resolver.find(document.uri).map((x) => x.id);
assert.deepStrictEqual(tests.sort(), [
diff --git a/extension/test/gopls/goTest.run.test.ts b/extension/test/gopls/goTest.run.test.ts
index 6d5bba1..be27759 100644
--- a/extension/test/gopls/goTest.run.test.ts
+++ b/extension/test/gopls/goTest.run.test.ts
@@ -29,7 +29,8 @@
suite('parseOutput', () => {
const ctx = MockExtensionContext.new();
suiteSetup(async () => {
- testExplorer = GoTestExplorer.setup(ctx, {});
+ testExplorer = GoTestExplorer.new(ctx, {});
+ ctx.subscriptions.push(testExplorer);
});
suiteTeardown(() => ctx.teardown());
@@ -75,7 +76,8 @@
suiteSetup(async () => {
uri = Uri.file(path.join(fixtureDir, 'codelens', 'codelens2_test.go'));
await env.startGopls(uri.fsPath);
- testExplorer = GoTestExplorer.setup(ctx, env.goCtx);
+ testExplorer = GoTestExplorer.new(ctx, env.goCtx);
+ ctx.subscriptions.push(testExplorer);
await forceDidOpenTextDocument(workspace, testExplorer, uri);
});
@@ -199,7 +201,8 @@
// (so initialize request doesn't include workspace dir info). The codelens directory was
// used in the previous test suite. Figure out why.
await env.startGopls(uri.fsPath, undefined, subTestDir);
- testExplorer = GoTestExplorer.setup(ctx, env.goCtx);
+ testExplorer = GoTestExplorer.new(ctx, env.goCtx);
+ ctx.subscriptions.push(testExplorer);
await forceDidOpenTextDocument(workspace, testExplorer, uri);
spy = sandbox.spy(testUtils, 'goTest');