[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) {