blob: 0b9ed01d278cbd51fa8c84930e960448d4b86480 [file] [log] [blame] [edit]
// Copyright 2025 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 {
Memento,
TestController,
TestItem,
} from 'vscode';
import { Setup } from '../extension';
import { FuchsiaTestData } from './test_data';
import { Fx } from '../fx';
/**
* Adds a `refreshHandler` and `resolveHandler` to a given `TestController`.
*/
export function setUpTestControllerDiscovery(
controller: TestController,
setup: Setup,
savedState: Memento,
) {
const testcaseDiscoveryQueue = TestcaseDiscoveryQueue.create(controller, setup.fx, savedState);
controller.refreshHandler = async () => {
// TODO(https://fxbug.dev/441360934): Re-enable watching when file watchers are properly managed.
await discoverTests(controller, /*watch=*/ false);
};
controller.resolveHandler = async (testItem?: TestItem) => {
if (!testItem) {
// TODO(https://fxbug.dev/441360934): Re-enable watching when file watchers are properly managed.
await discoverTests(controller, /*watch=*/ false);
} else {
await testcaseDiscoveryQueue.discover(testItem);
}
};
}
type DiscoverTestCasesFn = (
controller: vscode.TestController,
fx: Fx,
testItem: vscode.TestItem,
savedState: Memento,
) => Promise<void>;
/**
* Manages a queue of pending `TestItem` discovery requests, allowing retries and concurrent
* 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][] = [];
private pendingDiscoveries: number = 0;
private constructor(
private readonly controller: vscode.TestController,
private readonly fx: Fx,
private readonly discoverTestCasesFn: DiscoverTestCasesFn,
private readonly savedState: Memento,
) {}
static create(controller: vscode.TestController, fx: Fx, savedState: Memento) {
return new TestcaseDiscoveryQueue(controller, fx, discoverTestCases, savedState);
}
static createForTesting(
controller: vscode.TestController,
fx: Fx,
discoverTestCasesFn: DiscoverTestCasesFn,
// Disable caching in tests by default.
savedState: Memento = {
keys: () => [],
get: () => {},
update: async () => {},
},
) {
return new TestcaseDiscoveryQueue(controller, fx, discoverTestCasesFn, savedState);
}
/**
* Enqueues a new `TestItem` to be discovered, returning a `Promise` that never rejects and only
* resolves when the test cases on `TestItem` are discovered.
*
* Has the side-effect of retrying failed `TestItem` discoveries.
*
* @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]);
this.discoverFromQueue();
});
}
/**
* Synchronously dequeues and triggers testcase discovery on all `TestItem`s in `this.queue`.
*
* All testcase discoveries are performed concurrently in the background.
*
* Once all background testcase discoveries are settled, any failed discoveries will result in an
* error toast notification, allowing `discoverFromQueue()` to be reattempted.
*/
private discoverFromQueue() {
// Since the increment of `this.pendingDiscoveries` and draining of `this.queue` happens
// ** synchronously after `this.queue.push()` in `discover()`, **
// we won't need to worry about erroneous failed discovery messages within the async
// `discoverTestCasesFn` result handler.
this.pendingDiscoveries += this.queue.length;
for (const [testItem, discoveredCallback] of this.queue) {
void (async () => {
try {
await this.discoverTestCasesFn(this.controller, this.fx, testItem, this.savedState);
discoveredCallback();
} catch (e) {
// Defer this failed discovery for a future user-initiated event, such as clicking 'Retry'
// or starting testcase discovery for a different test.
this.queue.push([testItem, discoveredCallback]);
logger.error(`Testcase discovery failed: ${testItem.id}`, 'discoverFromQueue');
logger.trace(`Error details: ${e}`, 'discoverFromQueue');
}
// After all pending discoveries settle, check if any were nonsuccessful and present an
// error notification.
if (--this.pendingDiscoveries === 0 && this.queue.length) {
const presentErrorToast = async () => {
const choice = await vscode.window.showErrorMessage(
'Failed to discover some 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();
}
})();
}
this.queue = [];
}
}
/**
* 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, watch: boolean = true) {
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);
if (watch) {
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => scanTestsJson(uri));
watcher.onDidChange(uri => scanTestsJson(uri));
// TODO: Dispose of this watcher when the build directory changes or workspace closes.
}
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';
if (watch) {
const pattern = new vscode.RelativePattern(workspaceFolder, fxBuildDirFileName);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => scanBuildDir(uri));
watcher.onDidChange(uri => scanBuildDir(uri));
// TODO: Dispose of this watcher when the workspace folder is removed.
}
// 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}`);
}
})
);
}
/**
* Performs discovery on `testItem` and inserts the results into `testItem.children`.
*
* Discovery sources are tiered and fetched in parallel in the following order to deliver fast and
* accurate results to users:
* 1. Test cases are populated from cache, which were saved in the last run of step #3.
* 2. Test cases are populated, bypassing the build with `fx test --list <test> --no-build`.
* 3. Test cases are populated with the most accurate result, obtained by running
* `fx test --list <test>`. The result of this is cached for a future step #1.
*
* Since the slower later steps are expected to be more accurate, they'll take precedence over
* earlier step results as their results become available.
*/
async function discoverTestCases(
controller: TestController,
fx: Fx,
testItem: TestItem,
savedState: Memento,
) {
await discoverTestCasesLogic(
() => queryTestCaseCache(savedState, controller, testItem.id),
() => fxListTestCases(controller, fx, testItem, true),
() => fxListTestCases(controller, fx, testItem, false),
(children: TestItem[]) => testItem.children.replace(children),
(children: TestItem[]) => cacheTestCases(savedState, testItem.id, children)
);
}
/**
* The core logic for `discoverTestCases`.
*
* This is a separate function to make it easier to test.
* See `discoverTestCases` for more details.
*/
export async function discoverTestCasesLogic(
queryCache: () => TestItem[] | undefined,
listTestCasesNoBuild: () => Promise<TestItem[]>,
listTestCasesWithBuild: () => Promise<TestItem[]>,
updateChildren: (children: TestItem[]) => void,
cacheResult: (children: TestItem[]) => Promise<void>
) {
// 1. Restore test cases from cache, if available.
let children = queryCache();
if (children) {
updateChildren(children);
}
// 2. Populate test cases with `fx test --list <test> --no-build`.
// Allow the result to be ignored in the unlikely case that step #3 completes before this one.
let ignoreIntermediateResult = false;
void listTestCasesNoBuild().then(async (children: TestItem[]) => {
if (!ignoreIntermediateResult) {
updateChildren(children);
await cacheResult(children);
}
});
// 3. Populate the most accurate list of test cases with `fx test --list <test>`.
children = await listTestCasesWithBuild();
updateChildren(children);
ignoreIntermediateResult = true;
await cacheResult(children);
}
/**
* Saves the `testCases` associated with a `testId` to `savedState`.
*/
export async function cacheTestCases(
savedState: Memento,
testId: string,
testCases: TestItem[],
) {
await savedState.update(testId, testCases.map(testCase => ({
id: testCase.id,
label: testCase.label,
uri: testCase.uri?.toString(),
tags: testCase.tags.map(tag => tag.id),
})));
}
/**
* Restores a list of TestItem cases associated with `testId` from `savedState`.
*/
export function queryTestCaseCache(
savedState: Memento,
controller: TestController,
testId: string,
): TestItem[] | undefined {
return savedState.get<{id: string, label: string, uri?: string, tags: string[]}[]>(testId)
?.map(({id, label, uri, tags}) => {
const child = controller.createTestItem(
id,
label,
uri === undefined ? undefined : vscode.Uri.parse(uri),
);
child.tags = tags.map(tag => new vscode.TestTag(tag));
return child;
});
}
/**
* Returns a list of test cases discovered with `testItem`.
*
* We use `fx test --list` to enumerate the test cases.
*/
export async function fxListTestCases(
controller: TestController,
fx: Fx,
testItem: TestItem,
skipBuild: boolean,
): Promise<TestItem[]> {
const args = ['test', '--logpath=-', '--list', testItem.id];
if (skipBuild) {
args.push('--no-build');
}
let error: boolean = false;
const testCases: TestItem[] = [];
const seenTestCases = new Set<string>();
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 (seenTestCases.has(testCaseName)) {
// This can happen for tests like `netstack3-bin-instrumented_test` for an unknown reason.
logger.warn(`Deduplicating test case name "${testCaseName}".`, 'discoverTestCases');
continue;
}
const testCaseItem = controller.createTestItem(testCaseName, testCaseName, testItem.uri);
if (testItem.tags.some(tag => tag.id === 'FuchsiaTest')) {
testCaseItem.tags = [new vscode.TestTag('FuchsiaTest')];
}
testCases.push(testCaseItem);
seenTestCases.add(testCaseName);
}
});
const exitCode = await process.exitCode;
if (exitCode !== 0) {
// Since `discoverTestCases` races the same discovery with and without `--no-build`, omit this
// error message to reduce logger noise, since `fx test --list <test> --no-build` failures are
// non-fatal and expected to fail often.
if (!skipBuild) {
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.'
+ `\nCommand output:\n${process.rawOutput}`
);
}
return testCases;
}