| // 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(); |
| } |
| } |