[test] Make test discovery robust with retries

Previously, if `fx test --list` failed, the test discovery user
interaction would silently fail, leaving the user with an empty list of
tests without any notification or way to recover.

This change introduces a `TestcaseDiscoveryQueue` to manage test case
discovery, bringing the following benefits:
 - Error Handling and Retries: When a test discovery fails, an error
   notification is displayed to the user with options to "Retry",
   "Show Logs", or "Dismiss".
 - JSON parsing: Errors from parsing the output of `fx test --list` are
   now explicitly surfaced to users.
 - Batching: Multiple discovery requests are batched together, limiting
   the amount of notification spam should multiple TestItem discoveries
   fail.

The following test improvements are also included:
 - Test coverage of `TestcaseDiscoveryQueue`, and implicit coverage of
   `discoverTestCases()`.
 - `setupMockFfx()` is now generalized to `setupMockedCommand()`,
   allowing `fx` to be expressively mocked in the same way.

Fixed: 436612350, 436866233
Change-Id: I8deac64788aad994ef762b7e034ce706446e80a4
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/1351884
Reviewed-by: Amy Hu <amyhu@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/src/test/suite/ffx.test.ts b/src/test/suite/ffx.test.ts
index f069313..3280b12 100644
--- a/src/test/suite/ffx.test.ts
+++ b/src/test/suite/ffx.test.ts
@@ -12,7 +12,7 @@
 import { Ffx, FfxEventType, FuchsiaDevice } from '../../ffx';
 import * as logger from '../../logger';
 
-import { MockedFfxInvocation, StubbedSpawn, noTimeout, setupMockFfx } from './utils';
+import { MockedCommandInvocation, StubbedSpawn, noTimeout, setupMockFfx } from './utils';
 
 describe('FuchsiaDevice', function() {
   describe('#constructor()', function() {
@@ -75,7 +75,7 @@
     'is_default': false
   });
 
-  const mockFfx = (...ffxInvocations: MockedFfxInvocation[]) => setupMockFfx(
+  const mockFfx = (...ffxInvocations: MockedCommandInvocation[]) => setupMockFfx(
     stubbedSpawn,
     TEST_CWD,
     TEST_FFX,
diff --git a/src/test/suite/test_controller.test.ts b/src/test/suite/test_controller.test.ts
new file mode 100644
index 0000000..29a3eb2
--- /dev/null
+++ b/src/test/suite/test_controller.test.ts
@@ -0,0 +1,891 @@
+// Copyright 2025 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { describe, it } from 'mocha';
+import {createSandbox} from 'sinon';
+
+import {Fx} from '../../fx';
+import {TestcaseDiscoveryQueue} from '../../test_controller';
+
+import {macrotask, MockedCommandInvocation, MOCKED_COMMAND_DURATION, setupMockedCommand, StubbedSpawn} from './utils';
+import {Logger, initLogger} from '../../logger';
+import {Ffx} from '../../ffx';
+
+describe('TestcaseDiscoveryQueue', function() {
+  const sandbox = createSandbox();
+  const TEST_CWD = '/path/to/workspace';
+  let stubbedSpawn: StubbedSpawn;
+  let controller: vscode.TestController;
+  let fx: Fx;
+
+  this.beforeEach(function() {
+    stubbedSpawn = new StubbedSpawn(sandbox);
+    controller = vscode.tests.createTestController('TestcaseDiscoveryQueue', 'TestcaseDiscoveryQueue');
+    const log = vscode.window.createOutputChannel('test_controller.test', { log: true });
+    initLogger(log);
+    const ffx = new Ffx(TEST_CWD);
+    fx = new Fx(TEST_CWD, ffx);
+  });
+
+  this.afterEach(function() {
+    controller.dispose();
+    sandbox.restore();
+  });
+
+  const mockCommands = (...commandInvocations: MockedCommandInvocation[]) => setupMockedCommand(
+    stubbedSpawn,
+    TEST_CWD,
+    commandInvocations,
+  );
+
+  describe('#discover', function() {
+    it('resolves if no tests are discovered', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: {},
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      await queue.discover(testItem);
+
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('populates a TestItem', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'BarTest.testA',
+                  'BarTest.testB',
+                  'BarTest.testC',
+                ]
+              }
+            }
+          },
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'bar');
+
+      await queue.discover(testItem);
+
+      assert.strictEqual(testItem.children.size, 3);
+      assert.ok(testItem.children.get('BarTest.testA'));
+      assert.ok(testItem.children.get('BarTest.testB'));
+      assert.ok(testItem.children.get('BarTest.testC'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('populates independent TestItem discovery requests', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'FooTest.testCase',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'BarTest.testCase',
+                ]
+              }
+            }
+          },
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+
+      await queue.discover(fooTestItem);
+      await queue.discover(barTestItem);
+
+      assert.strictEqual(fooTestItem.children.size, 1);
+      assert.ok(fooTestItem.children.get('FooTest.testCase'));
+      assert.strictEqual(barTestItem.children.size, 1);
+      assert.ok(barTestItem.children.get('BarTest.testCase'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('populates varied concurrent TestItem discovery requests', async function() {
+      const INITIAL_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/initial_discovery.cm';
+      const SYNCHRONOUS_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/synchronous_discovery.cm';
+      const MICROTASK_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/microtask_discovery.cm';
+      const MACROTASK_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/macrotask_discovery.cm';
+      const TIMED_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/timed_discovery.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', INITIAL_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'InitialDiscovery.testCase',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', SYNCHRONOUS_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'SynchronousDiscovery.testCase',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', MICROTASK_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'MicrotaskDiscovery.testCase',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', MACROTASK_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'MacrotaskDiscovery.testCase',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TIMED_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'TimedDiscovery.testCase',
+                ]
+              }
+            }
+          },
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const initialTestItem = controller.createTestItem(INITIAL_TEST_URL, 'initial');
+      const synchronousTestItem = controller.createTestItem(SYNCHRONOUS_TEST_URL, 'synchronous');
+      const microtaskTestItem = controller.createTestItem(MICROTASK_TEST_URL, 'microtask');
+      const macrotaskTestItem = controller.createTestItem(MACROTASK_TEST_URL, 'macrotask');
+      const timedTestItem = controller.createTestItem(TIMED_TEST_URL, 'timed');
+
+      const discovered = {
+        initial: false,
+        synchronous: false,
+        microtask: false,
+        macrotask: false,
+        timed: false,
+      };
+
+      // Test synchronous concurrent discovery requests.
+      void queue.discover(initialTestItem).then(() => discovered.initial = true);
+      void queue.discover(synchronousTestItem).then(() => discovered.synchronous = true);
+
+      // Wait for the `initial` discovery to complete; keep the `synchronous` one in the queue.
+      await singleCommandDuration();
+      assert.deepStrictEqual(
+        discovered,
+        {initial: true, synchronous: false, microtask: false, macrotask: false, timed: false},
+      );
+      assert.strictEqual(initialTestItem.children.size, 1);
+      assert.ok(initialTestItem.children.get('InitialDiscovery.testCase'));
+
+      // Test concurrent discovery request after 1 microtask.
+      await Promise.resolve();
+      void queue.discover(microtaskTestItem).then(() => discovered.microtask = true);
+
+      // Wait for the `synchronous` discovery to complete; keep the `microtask` one in the queue.
+      await singleCommandDuration();
+      assert.deepStrictEqual(
+        discovered,
+        {initial: true, synchronous: true, microtask: false, macrotask: false, timed: false},
+      );
+      assert.strictEqual(synchronousTestItem.children.size, 1);
+      assert.ok(synchronousTestItem.children.get('SynchronousDiscovery.testCase'));
+
+      // Test concurrent discovery request after 1 macrotask.
+      await macrotask();
+      const macrotaskPromise = queue.discover(macrotaskTestItem)
+        .then(() => discovered.macrotask = true);
+
+      // Wait for the `microtask` discovery to complete; keep the `macrotask` one in the queue.
+      await singleCommandDuration();
+      assert.deepStrictEqual(
+        discovered,
+        {initial: true, synchronous: true, microtask: true, macrotask: false, timed: false},
+      );
+      assert.strictEqual(microtaskTestItem.children.size, 1);
+      assert.ok(microtaskTestItem.children.get('MicrotaskDiscovery.testCase'));
+
+      // Test concurrent discovery request halfway through command execution.
+      await new Promise(resolve => setTimeout(resolve, MOCKED_COMMAND_DURATION / 2));
+      void queue.discover(timedTestItem).then(() => discovered.timed = true);
+
+      // Wait for the `macrotask` discovery to complete; keep the `timed` one in the queue.
+      await macrotaskPromise;
+      assert.deepStrictEqual(
+        discovered,
+        {initial: true, synchronous: true, microtask: true, macrotask: true, timed: false},
+      );
+      assert.strictEqual(macrotaskTestItem.children.size, 1);
+      assert.ok(macrotaskTestItem.children.get('MacrotaskDiscovery.testCase'));
+
+      // Wait for the `timed` discovery to complete; the discovery queue should be empty now.
+      await singleCommandDuration();
+      assert.deepStrictEqual(
+        discovered,
+        {initial: true, synchronous: true, microtask: true, macrotask: true, timed: true},
+      );
+      assert.strictEqual(timedTestItem.children.size, 1);
+      assert.ok(timedTestItem.children.get('TimedDiscovery.testCase'));
+
+      // Check whether `TestcaseDiscoveryQueue` makes any extra unnecessary `fx` invocations.
+      await singleCommandDuration();
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('issues a notification without resolving if fx test errors', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .resolves('Dismiss' as any);
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      let discoverDidResolve = false;
+      void queue.discover(testItem).then(() => {
+        discoverDidResolve = true;
+      });
+      // Wait for `fx test --list` complete and propagate up to `queue.discover()`.
+      await singleCommandDuration();
+
+      assert.ok(stubShowErrorMessage.calledOnce);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('ignores irrelevant JSON objects', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const MOCK_FX_JSON_OUTPUTS = [
+        undefined,
+        true,
+        {},
+        {
+          payload: -1,
+        },
+        {
+          payload: {},
+        },
+        {
+          payload: {
+            // eslint-disable-next-line @typescript-eslint/naming-convention
+            enumerate_test_cases: 'invalid',
+          },
+        },
+        {
+          payload: {
+            // eslint-disable-next-line @typescript-eslint/naming-convention
+            enumerate_test_cases: [],
+          },
+        },
+        {
+          payload: {
+            // eslint-disable-next-line @typescript-eslint/naming-convention
+            enumerate_test_cases: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              test_case_names: [],
+            },
+          },
+        },
+        {
+          payload: {
+            // eslint-disable-next-line @typescript-eslint/naming-convention
+            enumerate_test_cases: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              test_case_names: ['FooTest.testA'],
+            },
+          },
+        },
+      ];
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: MOCK_FX_JSON_OUTPUTS.map(obj => JSON.stringify(obj)).join('\n'),
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      await queue.discover(testItem);
+
+      assert.strictEqual(testItem.children.size, 1);
+      assert.ok(testItem.children.get('FooTest.testA'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('deduplicates test cases', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: JSON.stringify({
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: ['FooTest.testA', 'FooTest.testB'],
+              },
+            },
+          }) + '\n' + JSON.stringify({
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: ['FooTest.testB', 'FooTest.testC'],
+              },
+            },
+          }),
+        },
+      );
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      await queue.discover(testItem);
+
+      assert.strictEqual(testItem.children.size, 3);
+      assert.ok(testItem.children.get('FooTest.testA'));
+      assert.ok(testItem.children.get('FooTest.testB'));
+      assert.ok(testItem.children.get('FooTest.testC'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('counts as an error if test_case_names is the wrong type', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: {
+                  fooTest: TEST_URL
+                }
+              }
+            }
+          },
+        },
+      );
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .resolves('Dismiss' as any);
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      let discoverDidResolve = false;
+      void queue.discover(testItem).then(() => {
+        discoverDidResolve = true;
+      });
+      await singleCommandDuration();
+
+      assert.ok(stubShowErrorMessage.calledOnce);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('counts as an error if test_case_names entries are the wrong type', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  123,
+                ]
+              }
+            }
+          },
+        },
+      );
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .resolves(undefined);
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      let discoverDidResolve = false;
+      void queue.discover(testItem).then(() => {
+        discoverDidResolve = true;
+      });
+      await singleCommandDuration();
+
+      assert.ok(stubShowErrorMessage.calledOnce);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('shows error logs if the user clicks show logs', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const CHOICES = ['Show Logs'];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
+      const spyLoggerShow = sandbox.spy(Logger.prototype, 'show');
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      void queue.discover(testItem);
+      // Wait for `fx test --list` complete and propagate up to `queue.discover()`.
+      await singleCommandDuration();
+
+      assert.strictEqual(stubShowErrorMessage.callCount, 2);
+      assert.ok(spyLoggerShow.calledOnce);
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('reattempts a discovery and fails again', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const CHOICES = ['Retry'];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      let discoverDidResolve = false;
+      void queue.discover(testItem).then(() => {
+        discoverDidResolve = true;
+      });
+      // Wait for both discovery failures to complete.
+      await singleCommandDuration();
+      await singleCommandDuration();
+
+      assert.strictEqual(stubShowErrorMessage.callCount, 2);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(testItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('issues one notification when multiple TestItems fail in a batch', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const spyShowErrorMessage = sandbox.spy(vscode.window, 'showErrorMessage');
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+
+      let discoverDidResolve = false;
+      void queue.discover(fooTestItem).then(() => {
+        discoverDidResolve = true;
+      });
+      void queue.discover(barTestItem).then(() => {
+        discoverDidResolve = true;
+      });
+      // Wait for both `fx test --list` (not run in parallel) to complete and propagate up to
+      // `queue.discover()`.
+      await singleCommandDuration();
+      await singleCommandDuration();
+
+      assert.ok(spyShowErrorMessage.calledOnce);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(fooTestItem.children.size, 0);
+      assert.strictEqual(barTestItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('batches previous failures onto future discoveries', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const CHOICES = [
+        undefined, // User clicks the (x) button on the notification.
+        'Retry',
+        'Dismiss',
+      ];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+
+      let discoverDidResolve = false;
+      void queue.discover(fooTestItem).then(() => {
+        discoverDidResolve = true;
+      });
+      // Wait the first discovery to fail.
+      await singleCommandDuration();
+
+      void queue.discover(barTestItem).then(() => {
+        discoverDidResolve = true;
+      });
+      // Wait the second and third discoveries to fail.
+      await singleCommandDuration();
+      await singleCommandDuration();
+      await singleCommandDuration();
+      await singleCommandDuration();
+
+      assert.strictEqual(stubShowErrorMessage.callCount, 3);
+      assert.strictEqual(discoverDidResolve, false);
+      assert.strictEqual(fooTestItem.children.size, 0);
+      assert.strictEqual(barTestItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('reattempts a discovery and succeeds', async function() {
+      const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'FooTest.testA',
+                  'FooTest.testB',
+                ]
+              }
+            }
+          },
+        },
+      );
+      const CHOICES = ['Retry'];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const testItem = controller.createTestItem(TEST_URL, 'foo');
+
+      await queue.discover(testItem);
+
+      assert.ok(stubShowErrorMessage.calledOnce);
+      assert.strictEqual(testItem.children.size, 2);
+      assert.ok(testItem.children.get('FooTest.testA'));
+      assert.ok(testItem.children.get('FooTest.testB'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('only retries failing discoveries when mixed with successful discoveries', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'BarTest.testA',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'FooTest.testA',
+                ]
+              }
+            }
+          },
+        },
+      );
+      const CHOICES = ['Retry'];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+
+      const fooPromise = queue.discover(fooTestItem);
+      await queue.discover(barTestItem);
+      await fooPromise;
+
+      assert.ok(stubShowErrorMessage.calledOnce);
+      assert.strictEqual(fooTestItem.children.size, 1);
+      assert.ok(fooTestItem.children.get('FooTest.testA'));
+      assert.strictEqual(barTestItem.children.size, 1);
+      assert.ok(barTestItem.children.get('BarTest.testA'));
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('batches failing discoveries onto future discoveries when mixed with successful discoveries', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const BAZ_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/baz_test_component.cm';
+      const {actualInvocations, expectedInvocations} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'BarTest.testA',
+                ]
+              }
+            }
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [
+                  'FooTest.testA',
+                ],
+              },
+            },
+          },
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAZ_TEST_URL],
+          output: {
+            payload: {
+              // eslint-disable-next-line @typescript-eslint/naming-convention
+              enumerate_test_cases: {
+                // eslint-disable-next-line @typescript-eslint/naming-convention
+                test_case_names: [],
+              },
+            },
+          },
+        },
+      );
+      const spyShowErrorMessage = sandbox.spy(vscode.window, 'showErrorMessage');
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+      const bazTestItem = controller.createTestItem(BAZ_TEST_URL, 'baz');
+
+      let isFooResolved = false;
+
+      // Discover `foo` and `bar` in the same batch; `foo` should fail while `bar` passes.
+      void queue.discover(fooTestItem).then(() => isFooResolved = true);
+      await queue.discover(barTestItem);
+      assert.strictEqual(isFooResolved, false);
+      assert.strictEqual(spyShowErrorMessage.callCount, 1);
+      assert.strictEqual(barTestItem.children.size, 1);
+      assert.ok(barTestItem.children.get('BarTest.testA'));
+
+      // Now, trigger a new discovery for `baz`. This will start a new discovery batch that also
+      // includes the previously failed `foo` item from the queue, effectively retrying it.
+      await queue.discover(bazTestItem);
+      assert.strictEqual(isFooResolved, true);
+      assert.strictEqual(spyShowErrorMessage.callCount, 1);
+      assert.strictEqual(fooTestItem.children.size, 1);
+      assert.ok(fooTestItem.children.get('FooTest.testA'));
+      assert.strictEqual(bazTestItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+
+    it('deduplicates 2 retry buttons rapidly clicked', async function() {
+      const FOO_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
+      const BAR_TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
+      const {actualInvocations, expectedInvocations, singleCommandDuration} = mockCommands(
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', FOO_TEST_URL],
+          output: new Error('{}'),
+        },
+        {
+          args: ['fx', 'test', '--logpath=-', '--list', BAR_TEST_URL],
+          output: new Error('{}'),
+        },
+      );
+      const pendingErrorMessageToasts: ((choice: string | undefined) => void)[] = [];
+      const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
+        .callsFake(() => new Promise(resolve => pendingErrorMessageToasts.push(resolve as any)));
+
+      const queue = new TestcaseDiscoveryQueue(controller, fx);
+      const fooTestItem = controller.createTestItem(FOO_TEST_URL, 'foo');
+      const barTestItem = controller.createTestItem(BAR_TEST_URL, 'bar');
+
+      const resolved = {
+        foo: false,
+        bar: false,
+      };
+
+      // Discover `bar` after `foo` sequentially, in another batch.
+      void queue.discover(fooTestItem).then(() => resolved.foo = true);
+      await singleCommandDuration();
+      void queue.discover(barTestItem).then(() => resolved.bar = true);
+      await singleCommandDuration();
+      await singleCommandDuration();
+      assert.strictEqual(stubShowErrorMessage.callCount, 2);
+
+      // Now click the 'Retry' button on both notifications simultaneously.
+      pendingErrorMessageToasts.forEach(makeSelection => makeSelection('Retry'));
+      await singleCommandDuration();
+      await singleCommandDuration();
+      await singleCommandDuration(); // Give time to check that we've properly deduped.
+      await singleCommandDuration(); // Give time to check that we've properly deduped.
+      assert.strictEqual(stubShowErrorMessage.callCount, 3);
+      assert.deepStrictEqual(resolved, {foo: false, bar: false});
+      assert.strictEqual(fooTestItem.children.size, 0);
+      assert.strictEqual(barTestItem.children.size, 0);
+      assert.deepStrictEqual(actualInvocations, expectedInvocations);
+    });
+  });
+});
diff --git a/src/test/suite/utils.ts b/src/test/suite/utils.ts
index cb67eb0..cd5c755 100644
--- a/src/test/suite/utils.ts
+++ b/src/test/suite/utils.ts
@@ -83,7 +83,7 @@
  * Creates a fake child process.
  * @returns the child process object.
  */
-export function createFakeChildProcess() {
+export function createFakeChildProcess(): child_process.ChildProcess {
   let spawnEvent: child_process.ChildProcess = new EventEmitter() as child_process.ChildProcess;
   spawnEvent.stdout = new EventEmitter() as Readable;
   spawnEvent.stderr = new EventEmitter() as Readable;
@@ -124,62 +124,88 @@
   }
 }
 
-export type MockedFfxInvocation = {
+export const MOCKED_COMMAND_DURATION = 10;
+
+export type MockedCommandInvocation = {
   args: string[];
   output: (string | Error | Object);
 };
 
 /**
- * Mocks a sequence of expected ffx commands and their outputs.
+ * An ffx-specific version of `setupMockedCommand`.
  *
- * All command executions will be captured by this mock.
- * Non-ffx commands will immediately exit with code 0 and be included in the
- * list of `actualInvocations`.
- * Mismatching ffx commands will emit a warning, exit with code 1, and be
- * included in the list of `actualInvocations.
+ * Prepends the specified `ffxPath` and ffx analytics flags onto the `args` in `ffxInvocations`.
+ *
+ * See `setupMockedCommand` for more details.
+ */
+export function setupMockFfx(
+  stubbedSpawn: StubbedSpawn,
+  cwd: string,
+  ffxPath: string,
+  ffxInvocations: MockedCommandInvocation[]
+): {
+  actualInvocations: string[][],
+  expectedInvocations: string[][],
+  singleCommandDuration: () => Promise<void>
+} {
+  const ANALYTICS_FLAG = ['--config', 'fuchsia.analytics.ffx_invoker=vscode-fuchsia'];
+  return setupMockedCommand(
+    stubbedSpawn,
+    cwd,
+    ffxInvocations.map(inv => ({
+      ...inv,
+      args: [ffxPath, ...ANALYTICS_FLAG, ...inv.args],
+    })),
+  );
+}
+
+/**
+ * Mocks a sequence of expected commands and their outputs.
+ *
+ * All command executions are handled by this mock.
+ * Non-matching command binaries will immediately exit with code 0 and are excluded in the list of
+ * `actualInvocations`.
+ * Mismatching command arguments will emit a warning, exit with code 1, and are included in the list
+ * of `actualInvocations`.
  *
  * Example usage:
  * ```
- * const mockFfx = (...ffxInvocations: MockedFfxInvocation[]) => setupMockFfx(
+ * const mockCommands = (...commandInvocations: MockedCommandInvocation[]) => setupMockedCommand(
  *   stubbedSpawn,
  *   TEST_CWD,
- *   TEST_FFX,
- *   ffxInvocations,
+ *   commandInvocations,
  * );
  *
- * const {actualInvocations, expectedInvocations} = mockFfx(
+ * const {actualInvocations, expectedInvocations} = mockCommands(
  *   {
- *     'args': ['--target', 'sample-device', 'target', 'reboot'],
- *     'output': 'restarted sample-device',
+ *     args: ['fx', 'list-devices'],
+ *     output: '127.0.0.1 fuchsia-emulator',
  *   },
  *   {
- *     'args': ['--target', 'sample-device', 'target', 'wait', '-t', '300'],
- *     'output': new Error(errorMessage),
+ *     args: ['/path/to/ffx', '--target', 'sample-device', 'target', 'reboot'],
+ *     output: {message: 'restarted sample-device', success: true},
  *   },
  *   {
- *     'args': ['--machine', 'json', 'target', 'list'],
- *     'output': [
- *       {'nodename': 'sample-device', 'rcs_state': 'N'},
- *     ],
+ *     args: ['/path/to/ffx', '--target', 'sample-device', 'target', 'wait', '-t', '300'],
+ *     output: new Error('Failed to establish RCS connection to device.'),
  *   },
  * );
  *
- * const ffx = new Ffx(TEST_CWD, TEST_FFX);
- * ffx.targetDevice = SAMPLE_DEVICE;
- *
+ * // This block is fake sample code.
+ * const devices = await enumerateDevices();
+ * assert.strictEqual(devices.length, 1);
  * await assert.rejects(
- *   () => ffx.rebootTarget(),
- *   new Error(`ffx returned with non-zero exit code 1: ${errorMessage}`),
+ *   () => device.rebootAndReconnect(),
+ *   new Error('fuchsia-emulator failed to come back online!').
  * );
  *
  * assert.deepStrictEqual(actualInvocations, expectedInvocations);
  * ```
  *
  * @param stubbedSpawn: A StubbedSpawn instance.
- * @param cwd: The expected working directory that ffx commands are run in.
- * @param ffxPath: The expected ffx binary path to call.
- * @param ffxInvocations an array of objects with the following properties:
- *   args: An array of ffx arguments to match against.
+ * @param cwd: The expected working directory that commands are run in.
+ * @param commandInvocations an array of objects with the following properties:
+ *   args: An array of command and arguments to match against.
  *   output: A string or object to be serialized (for stdout), or an Error
  *     which message will be emitted for stderr. The former will mock exit
  *     code 0 whereas the latter will mock exit code 1.
@@ -187,24 +213,22 @@
  *   actualInvocations: An array of string arrays representing the commands
  *     that were run.
  *   expectedInvocations: An array of string arrays mapped from the input
- *     `ffxInvocations` `args`.
+ *     `commandInvocations` `args`.
  *   singleCommandDuration: A promise that can be `awaited` to wait for a
  *     single ffx command execution macrotask to complete.
  *     This is useful for getter/setter tests in which test code cannot await
  *     getter/setter side-effects involving ffx command execution.
  */
-export function setupMockFfx(
+export function setupMockedCommand(
   stubbedSpawn: StubbedSpawn,
   cwd: string,
-  ffxPath: string,
-  ffxInvocations: MockedFfxInvocation[]
+  commandInvocations: MockedCommandInvocation[]
 ): {
   actualInvocations: string[][],
   expectedInvocations: string[][],
   singleCommandDuration: () => Promise<void>
 } {
-  const ANALYTICS_FLAG = ['--config', 'fuchsia.analytics.ffx_invoker=vscode-fuchsia'];
-  const expectedInvocations = ffxInvocations.map(inv => [ffxPath, ...ANALYTICS_FLAG, ...inv.args]);
+  const expectedInvocations = commandInvocations.map(inv => inv.args);
   const actualInvocations: string[][] = [];
   let idx = 0;
   stubbedSpawn.spawnStubInfo.callsFake((
@@ -212,55 +236,63 @@
     args: readonly string[],
     options: SpawnOptions
   ) => {
-    // Push onto macrotask queue since we need time for ffx to add listeners
-    // for `.on('data')` and `.on('close')`, which may be deferred onto the
-    // microtask queue when invoked within an `async` function context.
+    const fakeSpawnedProcess = createFakeChildProcess();
+
+    // Push onto macrotask queue since we may need time for the command to add listeners for
+    // `.on('data')` and `.on('close')`, which may be deferred onto the microtask queue when invoked
+    // within an `async` function context.
     //
     // Add a 10ms delay to better simulate real-world execution.
     //
-    // For getter/setter tests in which test code cannot await getter/setter
-    // side-effects involving ffx command execution you can use
-    // `await mockFfxResult.singleCommandDuration()` to wait for a single ffx
-    // command execution macrotask to complete.
-    // Note: This alone is insufficient if the getter/setter chains a command
-    // execution macrotask with other macrotasks in series.
-    // In this case, test code will need to await multiple
-    // `singleCommandDuration()` and/or `macrotask()` in series.
+    // For getter/setter tests in which test code cannot await getter/setter side-effects involving
+    // command execution you can use `await mockFfxResult.singleCommandDuration()` to wait for a
+    // single command execution macrotask to complete.
+    // Note: This alone is insufficient if the getter/setter chains a command execution macrotask
+    // with other macrotasks in series.
+    // In this case, test code will need to await multiple `singleCommandDuration()` and/or
+    // `macrotask()` in series.
     setTimeout(() => {
-      if (command !== ffxPath) {
-        console.warn(`[mockFfx] Ignoring non-ffx command ${command}, ${args}, ${options}`);
-        stubbedSpawn.spawnEvent.kill(0);
+      if (command !== expectedInvocations[idx]?.at(0)) {
+        console.warn(
+          `[setupMockedCommand] Ignoring non-matching command ${command}, ${args}, ${options}`
+        );
+        fakeSpawnedProcess.emit('exit', 0);
+        fakeSpawnedProcess.emit('close', 0);
         return;
       }
 
       actualInvocations.push([command, ...args]);
       const [expectedArgs, nextOutput] = [
         expectedInvocations[idx]?.slice(1),
-        ffxInvocations[idx]?.output,
+        commandInvocations[idx]?.output,
       ];
       idx++;
       if (JSON.stringify(expectedArgs) !== JSON.stringify(args) || options.cwd !== cwd) {
-        const errmsg = `[mockFfx] Unexpected call to ffx.
+        const errmsg = `[setupMockedCommand] Unexpected call.
 Expected invocations:\n${expectedInvocations.join('\n')}, CWD=${cwd}
 Actual invocations:\n${actualInvocations.join('\n')}, CWD=${options.cwd}`;
         console.error(errmsg);
-        stubbedSpawn.spawnEvent.stderr?.emit('data', JSON.stringify(errmsg));
-        stubbedSpawn.spawnEvent.kill(1);
+        fakeSpawnedProcess.stderr?.emit('data', JSON.stringify(errmsg));
+        fakeSpawnedProcess.emit('exit', 1);
+        fakeSpawnedProcess.emit('close', 1);
         return;
       }
 
       if (nextOutput instanceof Error) {
-        stubbedSpawn.spawnEvent.stderr?.emit('data', nextOutput.message);
-        stubbedSpawn.spawnEvent.kill(1);
+        fakeSpawnedProcess.stderr?.emit('data', nextOutput.message);
+        fakeSpawnedProcess.emit('exit', 1);
+        fakeSpawnedProcess.emit('close', 1);
       } else if (typeof nextOutput === 'string') {
-        stubbedSpawn.spawnEvent.stdout?.emit('data', nextOutput);
-        stubbedSpawn.spawnEvent.kill(0);
+        fakeSpawnedProcess.stdout?.emit('data', nextOutput);
+        fakeSpawnedProcess.emit('exit', 0);
+        fakeSpawnedProcess.emit('close', 0);
       } else {
-        stubbedSpawn.spawnEvent.stdout?.emit('data', JSON.stringify(nextOutput));
-        stubbedSpawn.spawnEvent.kill(0);
+        fakeSpawnedProcess.stdout?.emit('data', JSON.stringify(nextOutput));
+        fakeSpawnedProcess.emit('exit', 0);
+        fakeSpawnedProcess.emit('close', 0);
       }
-    }, 10);
-    return stubbedSpawn.spawnEvent;
+    }, MOCKED_COMMAND_DURATION);
+    return fakeSpawnedProcess;
   });
 
   return {
diff --git a/src/test_controller.ts b/src/test_controller.ts
index 2daf080..c46d232 100644
--- a/src/test_controller.ts
+++ b/src/test_controller.ts
@@ -12,17 +12,129 @@
 import { Fx } from './fx';
 
 /**
+ * Manages a queue of pending `TestItem` discovery requests, allowing retries and batched background
+ * processing.
+ *
+ * Why this is needed:
+ * VSCode treats `TestController.resolveHandler` `reject()` the same as `resolve()`.
+ * The `resolveHandler` also won't be reinvoked/retried against the same `TestItem` if the user
+ * collapses/re-expands the `TestItem` that failed to discover.
+ * Practically this means throwing/`reject()`-ing during a `TestItem` discovery will silently fail,
+ * resulting in an empty list of test cases with no way to recover.
+ *
+ * How `TestcaseDiscoveryQueue` helps:
+ * Rather than propagating `reject()` from `discoverTestCases()` up to
+ * `TestController.resolveHandler`, we display an error toast notification allowing users to retry
+ * discovery across all failed `TestItem` discoveries and defer `resolve()`-ing to a future
+ * `TestcaseDiscoveryQueue.discover()` attempt.
+ * Batching `TestItem` discovery requests helps avoid duplicate error toast notifications.
+ */
+export class TestcaseDiscoveryQueue {
+  private queue: [TestItem, () => void][] = [];
+
+  // If this is `true`, any discoveries pushed onto `this.queue` will be picked up by the current
+  // discovery background task.
+  private isDiscoveringFromQueue: boolean = false;
+
+  constructor(private readonly controller: vscode.TestController, private readonly fx: Fx) {
+  }
+
+  /**
+   * Enqueues a new `TestItem` to be discovered, returning a `Promise` that never rejects and only
+   * resolves when the test cases on `TestItem` are discovered.
+   *
+   * Discovery on all incomplete `TestItem`s are triggered, if needed.
+   *
+   * @param testItem The `TestItem` to discover.
+   * @returns A promise that never rejects and will be resolved eventually when the test cases on
+   *   `TestItem` are discovered successfully.
+   */
+  discover(testItem: TestItem): Promise<void> {
+    return new Promise(resolve => {
+      this.queue.push([testItem, resolve]);
+      void this.discoverFromQueue();
+    });
+  }
+
+  /**
+   * Performs a concurrency-safe batched discovery attempt across all `TestItem`s in `this.queue` in
+   * the background.
+   *
+   * Any errors will result in a error toast notification, allowing `discoverFromQueue()` to be
+   * reattempted.
+   *
+   * @returns A promise that resolves when discovery is attempted for each item in `this.queue`.
+   */
+  private async discoverFromQueue(): Promise<void> {
+    if (this.isDiscoveringFromQueue) {
+      return;
+    }
+
+    // Setting `this.isDiscoveringFromQueue = true`
+    // ** synchronously after `this.queue.push()` in `discover()` **
+    // helps us no-op/avoid duplicate `discoverFromQueue()` background tasks.
+    this.isDiscoveringFromQueue = true;
+
+    // Critical section.
+    const failedDiscoveries: [TestItem, () => void][] = [];
+    for (const [testItem, discoveredCallback] of this.queue) {
+      try {
+        await discoverTestCases(this.controller, this.fx, testItem);
+        discoveredCallback();
+      } catch {
+        failedDiscoveries.push([testItem, discoveredCallback]);
+      }
+    }
+    this.queue = failedDiscoveries;
+
+    // Setting `this.isDiscoveringFromQueue = false`
+    // ** synchronously after the for-of loop completes **
+    // allows `discover()` to trigger another meaningful `discoverFromQueue()` in the background
+    // whenever the test item added by `this.queue.push()` won't be picked up by the above for-of
+    // loop.
+    this.isDiscoveringFromQueue = false;
+
+    // All of the following code must be safe to run concurrently with another `discoverFromQueue()`
+    // background task.
+    if (failedDiscoveries.length) {
+      const presentErrorToast = async () => {
+        const choice = await vscode.window.showErrorMessage(
+          'Failed to discover test cases.',
+          'Retry',
+          'Show Logs',
+          'Dismiss',
+        );
+
+        switch (choice) {
+          case 'Retry':
+            void this.discoverFromQueue();
+            break;
+          case 'Show Logs':
+            logger.show();
+            // Give the user a way to Retry testcase discovery.
+            void presentErrorToast();
+            break;
+        }
+      };
+      void presentErrorToast();
+    }
+  }
+}
+
+/**
  * register the test controller and related commands
  */
 export function setUpTestController(ctx: vscode.ExtensionContext, setup: Setup) {
   const controller = vscode.tests.createTestController('fuchsiaTests', 'Fuchsia Tests');
   ctx.subscriptions.push(controller);
 
+  const testcaseDiscoveryQueue = new TestcaseDiscoveryQueue(controller, setup.fx);
+
   controller.resolveHandler = async (testItem?: TestItem) => {
     if (!testItem) {
       await discoverTests(controller);
     } else {
-      await discoverTestCases(controller, setup.fx, testItem);
+      await testcaseDiscoveryQueue.discover(testItem);
     }
   };
 
@@ -189,37 +301,53 @@
  */
 async function discoverTestCases(controller: TestController, fx: Fx, testItem: TestItem) {
   const args = ['test', '--logpath=-', '--list', testItem.id];
+  let error: boolean = false;
+  logger.info(`Running command: ${JSON.stringify(['fx', ...args])}`, 'discoverTestCases');
   const process = fx.runJsonStreaming(args, (object: any) => {
-    const payload = object['payload'];
-    if (typeof payload !== 'object') {
+    const testCaseNames = object?.payload?.enumerate_test_cases?.test_case_names;
+    // Skip irrelevant JSON results.
+    if (!testCaseNames) {
       return;
     }
-    const testCases = payload['enumerate_test_cases'];
-    if (typeof testCases !== 'object') {
-      return;
-    }
-    const testCaseNames = testCases['test_case_names'];
-    if (typeof testCaseNames !== 'object') {
-      logger.error(`Invalid test_case_names field: ${testCaseNames}`);
+    if (!Array.isArray(testCaseNames)) {
+      logger.error(
+        `Invalid test_case_names field: ${JSON.stringify(testCaseNames)}`,
+        'discoverTestCases',
+      );
+      error = true;
       return;
     }
     for (const testCaseName of testCaseNames) {
       if (typeof testCaseName !== 'string') {
-        logger.error(`Invalid test_case_names arrray entry: ${testCaseName}`);
+        logger.error(
+          `Invalid test_case_names array entry: ${JSON.stringify(testCaseName)}`,
+          'discoverTestCases',
+        );
+        error = true;
         return;
       }
       if (testItem.children.get(testCaseName)) {
-        return;
+        continue;
       }
       const testCaseItem = controller.createTestItem(testCaseName, testCaseName, testItem.uri);
-      if (testItem.tags.filter(tag => tag.id === 'FuchsiaTest').length > 0) {
+      if (testItem.tags.some(tag => tag.id === 'FuchsiaTest')) {
         testCaseItem.tags = [new vscode.TestTag('FuchsiaTest')];
       }
       testItem.children.add(testCaseItem);
     }
   });
 
-  await process.exitCode;
+  const exitCode = await process.exitCode;
+  if (exitCode !== 0) {
+    logger.error(`fx exited with code ${exitCode}`, 'discoverTestCases');
+    error = true;
+  }
+
+  if (error) {
+    throw new Error(
+      'Test case discovery failed due to a non-zero exit code or JSON parsing error.'
+    );
+  }
 }
 
 /**