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