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');