[feature] Add the ability to run tests

We can now run tests using `fx test`.

Change-Id: I58cc44e101a3b75e9478b2d7b47fbe6b9af8086f
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/1030333
Reviewed-by: Christopher Johnson <crjohns@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Jacob Rutherford <jruthe@google.com>
diff --git a/src/extension.ts b/src/extension.ts
index 2703e1c..3ec7dc1 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -12,6 +12,7 @@
 import { LoggingViewProvider } from './logging/view_provider';
 import { ToolFinder } from './tool_finder';
 import { Ffx } from './ffx';
+import { Fx } from './fx';
 import * as logger from './logger';
 import { TargetStatusBarItem } from './target_status_bar_item';
 import { FuchsiaDevice } from './ffx';
@@ -29,10 +30,15 @@
    */
   ffx: Ffx;
 
-  constructor(ctx: vscode.ExtensionContext) {
+  /**
+   * a handle to interact with fx
+   */
+  fx: Fx;
 
+  constructor(ctx: vscode.ExtensionContext) {
     const toolFinder = new ToolFinder();
     this.ffx = toolFinder.ffx;
+    this.fx = toolFinder.fx;
   }
 }
 
diff --git a/src/ffx.ts b/src/ffx.ts
index 87f2662..2a54728 100644
--- a/src/ffx.ts
+++ b/src/ffx.ts
@@ -8,6 +8,7 @@
 // @ts-ignore: no @types/jsonparse available.
 import JsonParser from 'jsonparse';
 import { ToolFinder } from './tool_finder';
+import { JsonStreamProcess } from './process';
 
 /**
  * FuchsiaDevice represents the object returned from `ffx target list`.
@@ -531,44 +532,3 @@
 }
 
 export type FfxLog = JsonStreamProcess;
-
-/**
- * A stream of JSON Data coming from a process.
- */
-export class JsonStreamProcess {
-  private parser: JsonParser;
-
-  constructor(
-    private process: ChildProcessWithoutNullStreams | undefined,
-    private onData: (data: Object) => void,
-    private onError: (data: string) => void,
-  ) {
-    this.parser = new JsonParser();
-    this.process?.stdout.on('data', (chunk) => {
-      this.parser.write(chunk);
-    });
-    this.process?.stderr.on('data', this.onError);
-    let self = this;
-    this.parser.onValue = function (value: Object) {
-      if (this.stack.length === 0) {
-        self.onData(value);
-      }
-    };
-  }
-
-  /**
-   * Kills the underlying process and all its children.
-   */
-  public stop() {
-    const pid = this.process?.pid;
-    if (pid) {
-      try {
-        // This handles the in-tree use case where `ffx` launches a subprocess `fx ffx`.
-        process.kill(-pid);
-      } catch {
-        // If the process with the given `pid` didn't exist, this error will rise, which we can
-        // just swallow.
-      }
-    }
-  }
-}
diff --git a/src/fx.ts b/src/fx.ts
new file mode 100644
index 0000000..4f726fa
--- /dev/null
+++ b/src/fx.ts
@@ -0,0 +1,32 @@
+// Copyright 2024 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 { ChildProcessWithoutNullStreams, spawn } from 'child_process';
+import { DataStreamProcess } from './process';
+
+export class Fx {
+  constructor(public fuchsiaDir: string | undefined) {
+  }
+
+  public runStreaming(args: string[]): ChildProcessWithoutNullStreams | undefined {
+    if (!this.fuchsiaDir) {
+      return;
+    }
+    let env = { ...process.env };
+    env['PATH'] = `${this.fuchsiaDir}/.jiri_root/bin:${env.PATH}`;
+    const options = { detached: true, cwd: this.fuchsiaDir, env };
+    return spawn('fx', args, options);
+  }
+
+  public runAsync(
+    args: string[],
+    onData: (data: Buffer) => void,
+    onError: (data: Buffer) => void): DataStreamProcess | undefined {
+    const process = this.runStreaming(args);
+    if (!process) {
+      return;
+    }
+    return new DataStreamProcess(process, onData, onError);
+  }
+}
diff --git a/src/process.ts b/src/process.ts
new file mode 100644
index 0000000..e7d19f1
--- /dev/null
+++ b/src/process.ts
@@ -0,0 +1,81 @@
+// Copyright 2024 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 { ChildProcessWithoutNullStreams } from 'child_process';
+// @ts-ignore: no @types/jsonparse available.
+import JsonParser from 'jsonparse';
+
+/**
+ * A stream of Data coming from a process.
+ */
+export class DataStreamProcess {
+  constructor(
+    private process: ChildProcessWithoutNullStreams,
+    onData: (data: Buffer) => void,
+    onError: (data: Buffer) => void,
+  ) {
+    this.process.stdout.on('data', onData);
+    this.process.stderr.on('data', onError);
+  }
+
+  /**
+   * A future that resolves when the process exits.
+   */
+  public get exitCode(): Promise<number | null> {
+    return new Promise(resolve => {
+      this.process.on('exit', resolve);
+    });
+  }
+
+  /**
+   * Kills the underlying process and all its children.
+   */
+  public stop() {
+    const pid = this.process?.pid;
+    if (pid) {
+      try {
+        // This handles the in-tree use case where `ffx` launches a subprocess `fx ffx`.
+        process.kill(-pid);
+      } catch {
+        // If the process with the given `pid` didn't exist, this error will rise, which we can
+        // just swallow.
+      }
+    }
+  }
+}
+
+/**
+ * A stream of JSON Data coming from a process.
+ */
+export class JsonStreamProcess {
+  private process: DataStreamProcess | undefined;
+  private parser: JsonParser;
+
+  constructor(
+    process: ChildProcessWithoutNullStreams | undefined,
+    private onData: (data: Object) => void,
+    onError: (data: Buffer) => void,
+  ) {
+    this.parser = new JsonParser();
+    if (process) {
+      this.process = new DataStreamProcess(process, (chunk) => {
+        this.parser.write(chunk);
+      }, onError);
+
+    }
+    let self = this;
+    this.parser.onValue = function (value: Object) {
+      if (this.stack.length === 0) {
+        self.onData(value);
+      }
+    };
+  }
+
+  /**
+   * Kills the underlying process and all its children.
+   */
+  public stop() {
+    this.process?.stop();
+  }
+}
diff --git a/src/test/suite/logging/view_provider.test.ts b/src/test/suite/logging/view_provider.test.ts
index f190d8f..edb531f 100644
--- a/src/test/suite/logging/view_provider.test.ts
+++ b/src/test/suite/logging/view_provider.test.ts
@@ -9,7 +9,8 @@
 import { createSandbox, SinonSandbox, SinonStubbedInstance } from 'sinon';
 
 import { LoggingViewProvider, TOGGLE_LOG_VIEW_COMMAND } from '../../../logging/view_provider';
-import { Ffx, FuchsiaDevice, JsonStreamProcess } from '../../../ffx';
+import { Ffx, FuchsiaDevice } from '../../../ffx';
+import { JsonStreamProcess } from '../../../process';
 import { StubbedSpawn } from '../utils';
 import { ChildProcessWithoutNullStreams } from 'child_process';
 
diff --git a/src/testing/controller.ts b/src/testing/controller.ts
index 86f89b2..82da7cb 100644
--- a/src/testing/controller.ts
+++ b/src/testing/controller.ts
@@ -8,6 +8,7 @@
   TestItem,
 } from 'vscode';
 import { Setup } from '../extension';
+import { Fx } from '../fx';
 
 /**
  * register the test controller and related commands
@@ -21,14 +22,20 @@
       await discoverTests(controller);
     }
   };
+
+  controller.createRunProfile(
+    'Run',
+    vscode.TestRunProfileKind.Run,
+    (request, token) => {
+      void runTest(controller, setup.fx, request, token);
+    }
+  );
 }
 
 class FuchsiaTestData {
   constructor(
     readonly label: string,
     readonly name: string,
-    readonly packageLabel: string,
-    readonly packageUrl: string,
   ) { }
 
   get prettyName() {
@@ -45,10 +52,6 @@
     return prettyName;
   }
 
-  get uri() {
-    return vscode.Uri.parse(this.packageUrl);
-  }
-
   static fromJSON(object: any): FuchsiaTestData | null {
     const label = object['label'];
     if (typeof label !== 'string') {
@@ -58,25 +61,11 @@
     if (typeof name !== 'string') {
       return null;
     }
-    const packageLabel = object['package_label'];
-    if (typeof packageLabel !== 'string') {
-      return null;
-    }
-    const packageUrl = object['package_url'];
-    if (typeof packageUrl !== 'string') {
-      return null;
-    }
-    return new FuchsiaTestData(label, name, packageLabel, packageUrl);
+    return new FuchsiaTestData(label, name);
   }
 }
 
 /**
- * test data associated with each test item
- * The VS Code docs recommend this approach rather than trying to subclass TestItem.
- */
-const testDataMap = new WeakMap<vscode.TestItem, FuchsiaTestData>();
-
-/**
  * search the workspace for Fuchsia tests
  * We look at the `.fx-build-dir` file to find the build directory. Inside the build
  * directory, we look for the `tests.json` file.
@@ -88,7 +77,13 @@
 
   return await Promise.all(
     vscode.workspace.workspaceFolders.map(async (workspaceFolder: vscode.WorkspaceFolder) => {
+      /**
+       * read test items from the given test.json file.
+       */
       async function scanTestsJson(uri: vscode.Uri) {
+        // Remove the old items.
+        controller.items.forEach(item => controller.items.delete(item.id));
+
         const bytes = await vscode.workspace.fs.readFile(uri);
         const content = new TextDecoder().decode(bytes);
         const tests = JSON.parse(content);
@@ -104,13 +99,15 @@
           if (controller.items.get(testData.name)) {
             continue;
           }
-          const testItem = controller.createTestItem(testData.name, testData.prettyName,
-            testData.uri);
-          testDataMap.set(testItem, testData);
+          // TODO: Compute the URI of the test from the test label and the workspace folder.
+          const testItem = controller.createTestItem(testData.name, testData.prettyName);
           controller.items.add(testItem);
         }
       }
 
+      /**
+       * watch for a tests.json file in the given build directory.
+       */
       async function scanBuildDir(uri: vscode.Uri) {
         const bytes = await vscode.workspace.fs.readFile(uri);
         const content = new TextDecoder().decode(bytes).trim();
@@ -136,3 +133,80 @@
     })
   );
 }
+
+/**
+ * convert a TestItemCollection into an Iterable<vscode.TestItem>
+ */
+function gatherTestItems(collection: vscode.TestItemCollection): Iterable<vscode.TestItem> {
+  const items: vscode.TestItem[] = [];
+  collection.forEach(item => items.push(item));
+  return items;
+}
+
+/**
+ * prepare the given buffer for display in the test results terminal
+ */
+function formatForTerminal(buffer: Buffer) {
+  let string = new TextDecoder().decode(buffer);
+  return string.replace(/\n/g, '\r\n').replace(/\r\r\n/g, '\r\n');
+}
+
+/**
+ * run the given tests
+ */
+async function runTest(
+  controller: TestController,
+  fx: Fx,
+  request: vscode.TestRunRequest,
+  token: vscode.CancellationToken
+) {
+  const run = controller.createTestRun(request);
+  try {
+    const queue: TestItem[] = [];
+    for (const testItem of request.include ?? gatherTestItems(controller.items)) {
+      if (request.exclude?.includes(testItem)) {
+        continue;
+      }
+      queue.push(testItem);
+      run.enqueued(testItem);
+    }
+
+    /**
+     * append the given buffer to the output for the current test run
+     */
+    function handleOutput(buffer: Buffer) {
+      run.appendOutput(formatForTerminal(buffer));
+    }
+
+    for (const testItem of queue) {
+      run.started(testItem);
+
+      const args = ['test', '--style', '--no-status', '--output', testItem.id];
+      const prettyArgs = `fx ${args.join(' ')}`;
+
+      run.appendOutput(`Running: \x1b[1m${prettyArgs}\x1b[0m\r\n`);
+
+      const testProcess = fx.runAsync(args, handleOutput, handleOutput);
+      if (!testProcess) {
+        const message = new vscode.TestMessage(`Cannot start '${prettyArgs}'`);
+        run.errored(testItem, message);
+        continue;
+      }
+
+      const exitCode = await testProcess.exitCode;
+      if (exitCode === 0) {
+        run.passed(testItem);
+      } else {
+        let message: vscode.TestMessage;
+        if (typeof exitCode === 'number') {
+          message = new vscode.TestMessage(`'${prettyArgs}' exited with code ${exitCode}.`);
+        } else {
+          message = new vscode.TestMessage(`'${prettyArgs}' terminated unexpectedly.`);
+        }
+        run.failed(testItem, message);
+      }
+    }
+  } finally {
+    run.end();
+  }
+}
diff --git a/src/tool_finder.ts b/src/tool_finder.ts
index c2e8c7b..d27e4f5 100644
--- a/src/tool_finder.ts
+++ b/src/tool_finder.ts
@@ -8,6 +8,7 @@
 import * as logger from './logger';
 import * as path from 'path';
 import { Ffx } from './ffx';
+import { Fx } from './fx';
 
 const CONFIG_ROOT_NAME = 'fuchsia';
 
@@ -18,6 +19,7 @@
 export class ToolFinder {
   private ffxPathInternal: string | undefined;
   private ffxInternal: Ffx;
+  private fxInternal: Fx;
   private readonly onDidUpdateFfxEmitter = new vscode.EventEmitter<{ isInitial: boolean }>();
 
   /**
@@ -30,6 +32,7 @@
   constructor() {
     const cwd = vscode.workspace.workspaceFolders?.map(folder => folder.uri.path)[0];
     this.ffxInternal = new Ffx(cwd, undefined, this);
+    this.fxInternal = new Fx(cwd);
     this.updateFfxPath(true).catch((err) => logger.error('unable to configure initial ffx location', undefined, err));
     vscode.workspace.onDidChangeConfiguration(event => {
       if (event.affectsConfiguration(`${CONFIG_ROOT_NAME}.ffxPath`)) {
@@ -47,6 +50,14 @@
   }
 
   /**
+   * Get the single Fx class instance.
+   * @returns Fx class instance or null of this workspace does not support fx.
+   */
+  public get fx(): Fx {
+    return this.fxInternal;
+  }
+
+  /**
    * For testing: get the path of the ffx binary.
    * @returns the path to the ffx binary.
    */
@@ -85,18 +96,18 @@
   }
 
   /**
-   * Tests if the ffx path exists and if it's an executable.
+   * Tests if the tool path exists and if it's an executable.
    *
-   * @param ffxPath absolute path to the ffx binary.
+   * @param toolPath absolute path to the ffx binary.
    *
    * @throws error if this test fails.
    */
-  async validateFfxPath(ffxPath: string) {
-    if (!(await fs.promises.stat(ffxPath)).isFile()) {
-      throw new Error(`The path for ffx is not a file. path = "${ffxPath}".`);
+  async validateToolPath(toolPath: string) {
+    if (!(await fs.promises.stat(toolPath)).isFile()) {
+      throw new Error(`The path for ffx is not a file. path = "${toolPath}".`);
     }
     // Check if ffx is executable. This has no effect on Windows.
-    await fs.promises.access(ffxPath, fs.constants.X_OK);
+    await fs.promises.access(toolPath, fs.constants.X_OK);
   }
 
   // TODO(fxbug.dev/98459): Handle searching multiple workspace folders.
@@ -115,7 +126,7 @@
     for (const ffxPath of ffxPaths) {
       const fullFfxPath = path.join(workspacePath, ffxPath);
       try {
-        await this.validateFfxPath(fullFfxPath);
+        await this.validateToolPath(fullFfxPath);
         return ffxPath;
       } catch (err) {
         if (err instanceof Error) {
@@ -126,6 +137,17 @@
     throw new Error('Ffx could not be found.');
   }
 
+  async isFuchsiaDir(workspacePath: string): Promise<boolean> {
+    try {
+      const fxPath = path.join('.jiri_root', 'bin', 'fx');
+      const fullFxPath = path.join(workspacePath, fxPath);
+      await this.validateToolPath(fullFxPath);
+      return true;
+    } catch (err) {
+      return false;
+    }
+  }
+
   /**
    * Update the ffx binary path. This method is generally called when initializing, or when the
    * configuration path settings have changed.
@@ -139,8 +161,13 @@
         throw new Error('Could not get the ffxPath configuration.');
       }
 
-      let ffxPath = '';
+      if (workspacePath && await this.isFuchsiaDir(workspacePath)) {
+        this.fxInternal.fuchsiaDir = workspacePath;
+      } else {
+        this.fxInternal.fuchsiaDir = undefined;
+      }
 
+      let ffxPath = '';
       if (configFfxPath.length === 0) {
         ffxPath = await this.findFfxPath(workspacePath);
         // don't update here -- it's unexpected to change the user's configuration for them, and it
@@ -153,7 +180,7 @@
       }
 
       ffxPath = this.resolveAbsolutePath(ffxPath, workspacePath);
-      await this.validateFfxPath(ffxPath);
+      await this.validateToolPath(ffxPath);
 
       this.ffxPathInternal = ffxPath;
     } catch (err) {