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