blob: c3a1e2eb9ac7f6c3a7979d2bdfa831444b2195cc [file] [log] [blame]
// 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 * as vscode from 'vscode';
import * as logger from './logger';
import {
TestController,
TestItem,
} from 'vscode';
import { Setup } from './extension';
import { Fx } from './fx';
/**
* 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);
controller.resolveHandler = async (testItem?: TestItem) => {
if (!testItem) {
await discoverTests(controller);
} else {
await discoverTestCases(controller, setup.fx, testItem);
}
};
controller.createRunProfile(
'Run',
vscode.TestRunProfileKind.Run,
(request, token) => {
void runTest(controller, setup.fx, false, request, token);
}
);
controller.createRunProfile(
'Debug',
vscode.TestRunProfileKind.Debug,
(request, token) => {
void runTest(controller, setup.fx, true, request, token);
},
false,
new vscode.TestTag('FuchsiaTest')
);
}
class FuchsiaTestData {
constructor(
readonly label: string,
readonly name: string,
readonly os: string,
) { }
get prettyName(): string {
let prettyName = this.name;
let result = prettyName.match(/^([^_:]+)_[^/:]+\/(.+)/);
if (result) {
let targetName = result[1];
let testName = result[2];
return `<${targetName}> · ${testName}`;
}
const FUCHSIA_PKG_PREFIX = 'fuchsia-pkg://fuchsia.com/';
if (prettyName.startsWith(FUCHSIA_PKG_PREFIX)) {
prettyName = prettyName.slice(FUCHSIA_PKG_PREFIX.length);
}
prettyName = prettyName.replace('#meta/', ' · ');
const CM_SUFFIX = '.cm';
if (prettyName.endsWith(CM_SUFFIX)) {
prettyName = prettyName.slice(0, -CM_SUFFIX.length);
}
return prettyName;
}
get relativePath(): string | null {
let relativePath = this.label;
let result = relativePath.match(/^\/\/([^(:]+)(?::[^(]+)?(?:\([^)]+\))?$/);
if (result) {
return `${result[1]}/BUILD.gn`;
}
return null;
}
static fromJSON(object: any): FuchsiaTestData | null {
const label = object['label'];
if (typeof label !== 'string') {
return null;
}
const name = object['name'];
if (typeof name !== 'string') {
return null;
}
const os = object['os'];
if (typeof os !== 'string') {
return null;
}
return new FuchsiaTestData(label, name, os);
}
}
/**
* 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.
*/
async function discoverTests(controller: TestController) {
if (!vscode.workspace.workspaceFolders) {
return [];
}
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);
for (const testDescriptor of tests) {
const test = testDescriptor.test;
if (!test) {
continue;
}
const testData = FuchsiaTestData.fromJSON(test);
if (!testData) {
continue;
}
if (controller.items.get(testData.name)) {
continue;
}
const relativePath = testData.relativePath;
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);
}
}
/**
* 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();
const testsJsonFilePath = `${content}/tests.json`;
const pattern = new vscode.RelativePattern(workspaceFolder, testsJsonFilePath);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => scanTestsJson(uri));
watcher.onDidChange(uri => scanTestsJson(uri));
const testsJsonFile = vscode.Uri.joinPath(workspaceFolder.uri, testsJsonFilePath);
// TODO: We need to remove all the test items when this file is deleted.
try {
await scanTestsJson(testsJsonFile);
} catch (ex) {
// The tests.json file doesn't exist yet.
logger.debug(`Unable to scan tests.json: ${ex}`);
}
}
const fxBuildDirFileName = '.fx-build-dir';
const pattern = new vscode.RelativePattern(workspaceFolder, fxBuildDirFileName);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => scanBuildDir(uri));
watcher.onDidChange(uri => scanBuildDir(uri));
// TODO: We need to remove all the test items when this file is deleted.
const fxBuildDirFile = vscode.Uri.joinPath(workspaceFolder.uri, fxBuildDirFileName);
try {
await scanBuildDir(fxBuildDirFile);
} catch (ex) {
// The .fx-build-dir file doesn't exist yet.
logger.debug(`Unable to scan .fx-build-dir: ${ex}`);
}
})
);
}
/**
* 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> {
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,
debug: boolean,
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);
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';
if (debug) {
action = 'Debugging';
await vscode.commands.executeCommand('fuchsia.zxdb.attach', testItem.id);
}
let exitCode;
try {
run.appendOutput(`${action}: \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;
}
exitCode = await testProcess.exitCode;
} finally {
if (debug) {
await vscode.debug.stopDebugging();
}
}
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();
}
}