[feature] Run individual test cases

We now show VS Code the individual test cases within each test binary,
which lets the user run or debug them individually from the UI.

Fixed: b/340669616
Change-Id: Ie4579251dfa4a54ef7f61379e549eb0222629917
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/1048623
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Jacob Rutherford <jruthe@google.com>
diff --git a/src/ffx.ts b/src/ffx.ts
index ed4adb7..74d8557 100644
--- a/src/ffx.ts
+++ b/src/ffx.ts
@@ -415,7 +415,7 @@
     device: string | undefined,
     args: string[],
     onData: (data: Object) => void
-  ): FfxLog {
+  ): JsonStreamProcess {
     let ffxArgs = (device ? ['--target', device] : []);
     ffxArgs.push('--machine', 'json');
     ffxArgs.push(...args);
@@ -541,5 +541,3 @@
     });
   }
 }
-
-export type FfxLog = JsonStreamProcess;
diff --git a/src/fx.ts b/src/fx.ts
index 4f726fa..c0f1ac3 100644
--- a/src/fx.ts
+++ b/src/fx.ts
@@ -3,7 +3,8 @@
 // found in the LICENSE file.
 
 import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
-import { DataStreamProcess } from './process';
+import { DataStreamProcess, JsonStreamProcess } from './process';
+import * as logger from './logger';
 
 export class Fx {
   constructor(public fuchsiaDir: string | undefined) {
@@ -29,4 +30,17 @@
     }
     return new DataStreamProcess(process, onData, onError);
   }
+
+  public runJsonStreaming(
+    args: string[],
+    onData: (data: Object) => void
+  ): JsonStreamProcess {
+    return new JsonStreamProcess(
+      this.runStreaming(args),
+      onData,
+      (data) => {
+        logger.warn(`Error [fx ${args}]: ${data}`);
+      });
+  }
+
 }
diff --git a/src/process.ts b/src/process.ts
index e7d19f1..5751bf3 100644
--- a/src/process.ts
+++ b/src/process.ts
@@ -78,4 +78,17 @@
   public stop() {
     this.process?.stop();
   }
+
+  /**
+   * A future that resolves when the process exits.
+   */
+  public get exitCode(): Promise<number | null> {
+    return (async () => {
+      const exitCode = await this.process?.exitCode;
+      if (typeof exitCode === 'undefined') {
+        return null;
+      }
+      return exitCode;
+    })();
+  }
 }
diff --git a/src/test_controller.ts b/src/test_controller.ts
index add4bbc..c3a1e2e 100644
--- a/src/test_controller.ts
+++ b/src/test_controller.ts
@@ -21,6 +21,8 @@
   controller.resolveHandler = async (testItem?: TestItem) => {
     if (!testItem) {
       await discoverTests(controller);
+    } else {
+      await discoverTestCases(controller, setup.fx, testItem);
     }
   };
 
@@ -131,10 +133,12 @@
             continue;
           }
           const relativePath = testData.relativePath;
-          const uri = relativePath ? vscode.Uri.joinPath(workspaceFolder.uri, relativePath) : undefined;
+          const uri = relativePath ?
+            vscode.Uri.joinPath(workspaceFolder.uri, relativePath) : undefined;
           const testItem = controller.createTestItem(testData.name, testData.prettyName, uri);
           if (testData.os === 'fuchsia') {
             testItem.tags = [new vscode.TestTag('FuchsiaTest')];
+            testItem.canResolveChildren = true;
           }
           controller.items.add(testItem);
         }
@@ -180,6 +184,42 @@
 }
 
 /**
+ * discover the test cases within a given test item
+ * We use `fx test --list` to enumerate the test cases.
+ */
+async function discoverTestCases(controller: TestController, fx: Fx, testItem: TestItem) {
+  const args = ['test', '--logpath=-', '--list', testItem.id];
+  const process = fx.runJsonStreaming(args, (object: any) => {
+    const payload = object['payload'];
+    if (typeof payload !== 'object') {
+      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}`);
+      return;
+    }
+    for (const testCaseName of testCaseNames) {
+      if (typeof testCaseName !== 'string') {
+        logger.error(`Invalid test_case_names arrray entry: ${testCaseName}`);
+        return;
+      }
+      if (testItem.children.get(testCaseName)) {
+        return;
+      }
+      const testCaseItem = controller.createTestItem(testCaseName, testCaseName, testItem.uri);
+      testItem.children.add(testCaseItem);
+    }
+  });
+
+  await process.exitCode;
+}
+
+/**
  * convert a TestItemCollection into an Iterable<vscode.TestItem>
  */
 function gatherTestItems(collection: vscode.TestItemCollection): Iterable<vscode.TestItem> {
@@ -227,7 +267,14 @@
     for (const testItem of queue) {
       run.started(testItem);
 
-      const args = ['test', '--style', '--no-status', '--output', testItem.id];
+      var args = ['test', '--style', '--no-status', '--output'];
+      const parentTestItem = testItem.parent;
+      if (parentTestItem) {
+        args.push(parentTestItem.id, '--test-filter', testItem.id);
+      } else {
+        args.push(testItem.id);
+      }
+
       const prettyArgs = `fx ${args.join(' ')}`;
       let action = 'Running';