blob: c46d23270aef0b0ee7df5e88a6680da39264fb05 [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';
/**
* 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 testcaseDiscoveryQueue.discover(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];
let error: boolean = false;
logger.info(`Running command: ${JSON.stringify(['fx', ...args])}`, 'discoverTestCases');
const process = fx.runJsonStreaming(args, (object: any) => {
const testCaseNames = object?.payload?.enumerate_test_cases?.test_case_names;
// Skip irrelevant JSON results.
if (!testCaseNames) {
return;
}
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 array entry: ${JSON.stringify(testCaseName)}`,
'discoverTestCases',
);
error = true;
return;
}
if (testItem.children.get(testCaseName)) {
continue;
}
const testCaseItem = controller.createTestItem(testCaseName, testCaseName, testItem.uri);
if (testItem.tags.some(tag => tag.id === 'FuchsiaTest')) {
testCaseItem.tags = [new vscode.TestTag('FuchsiaTest')];
}
testItem.children.add(testCaseItem);
}
});
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.'
);
}
}
/**
* 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);
const testURL: string = testItem.parent?.id || testItem.id;
const args = ['test', '--style', '--no-status', '--output', testURL];
if (testItem.id !== testURL) {
args.push('--test-filter', testItem.id);
}
const prettyArgs = `fx ${args.join(' ')}`;
let action = 'Running';
if (debug) {
action = 'Debugging';
const hasAttached = await vscode.commands.executeCommand('fuchsia.zxdb.attach', testURL);
if (!hasAttached) {
const message = new vscode.TestMessage(
`Unable to attach zxdb to ${testURL}\n` +
'See "Fuchsia Extension" in the `Output` tab for more details.\n' +
'Alternatively, you can run the test without debugging.'
);
run.errored(testItem, message);
continue;
}
}
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();
}
}