diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b0f1f..74dfe62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] + +### Added + +* New setting to allow / deny device operations on Nano X (denied by default). +* Support parsing of `test.dependencies` fields from the [app manifest specification](https://github.com/LedgerHQ/ledgered/blob/master/doc/utils/manifest.md). Speed up the setup for running functional tests by automating the clone/build of tests dependencies when needed. +* Add button to rebuild test dependencies (if any) in treeview. + +### Changed + +* Refactor of `appSelected.ts` for better maintainability. + +### Fixed + +* Replace TOML parsing package (previous one couldn't parse 1.0.0 TOML) +* Update Udev rules for sideloading following hidapi python package update. Display warning message when rules need to be updated. +* Wording in some tree view items. + ## [0.4.0] ### Added diff --git a/README.md b/README.md index acbe570..1fbd3da 100644 --- a/README.md +++ b/README.md @@ -54,14 +54,25 @@ This extension contributes the following settings: * `ledgerDevTools.onboardingPin`: Set the device quick onboarding PIN code. * `ledgerDevTools.onboardingSeed`: Set the device quick onboarding 24-word Seed phrase. * `ledgerDevTools.dockerImage`: Set the Ledger developer tools Docker image. -* `ledgerDevTools.additionalDepsPerApp`: Add dependencies for current app's functional tests (for instance 'apk add python3-protobuf'). +* `ledgerDevTools.additionalReqsPerApp`: Add prerequisites for current app's functional tests (for instance 'apk add python3-protobuf'). * `ledgerDevTools.keepContainerTerminal`: Indicates to keep the Terminal window opened after a successful Container Update. * `ledgerDevTools.containerUpdateRetries`: Set the max number of Container Update retries. * `ledgerDevTools.userScpPrivateKey`: Use the host's `SCP_PRIVKEY` environment variable when loading/deleting app on device. Cf. * `ledgerDevTools.defaultDevice`: Select the default Device +* `ledgerDevTools.enableDeviceOpsForNanoX`: Allow device operations on Nano X (requires special development device) ## Release Notes +## 0.5.0 + +* New setting to allow / deny device operations on Nano X (denied by default). +* Support parsing of `test.dependencies` fields from the [app manifest specification](https://github.com/LedgerHQ/ledgered/blob/master/doc/utils/manifest.md). Speed up the setup for running functional tests by automating the clone/build of tests dependencies when needed. +* Add button to rebuild test dependencies (if any) in treeview. +* Refactor of `appSelected.ts` for better maintainability. +* Replace TOML parsing package (previous one couldn't parse 1.0.0 TOML) +* Update Udev rules for sideloading following hidapi python package update. Display warning message when rules need to be updated. +* Wording in some tree view items. + ## 0.4.0 * Add "select all targets" command with a button in the main tree view. diff --git a/package-lock.json b/package-lock.json index 4d0471c..5e63d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "ledger-dev-tools", - "version": "0.3.4", + "version": "0.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ledger-dev-tools", - "version": "0.3.4", + "version": "0.5.0", "license": "Apache", "devDependencies": { + "@ltd/j-toml": "^1.38.0", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", "@types/node": "20.2.5", @@ -20,7 +21,6 @@ "fast-glob": "^3.3.0", "glob": "^8.1.0", "mocha": "^10.2.0", - "toml": "^3.0.0", "ts-loader": "^9.4.3", "typescript": "^5.1.3", "webpack": "^5.85.0", @@ -195,6 +195,12 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@ltd/j-toml": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.38.0.tgz", + "integrity": "sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==", + "dev": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3063,13 +3069,6 @@ "node": ">=8.0" } }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "dev": true, - "license": "MIT" - }, "node_modules/ts-loader": { "version": "9.4.4", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.4.tgz", @@ -3571,6 +3570,12 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@ltd/j-toml": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.38.0.tgz", + "integrity": "sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==", + "dev": true + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5689,12 +5694,6 @@ "is-number": "^7.0.0" } }, - "toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "dev": true - }, "ts-loader": { "version": "9.4.4", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.4.tgz", diff --git a/package.json b/package.json index 4c353e2..81f9393 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ledger-dev-tools", "displayName": "Ledger Dev Tools", "description": "Tools to accelerate development of apps for Ledger devices.", - "version": "0.4.0", + "version": "0.5.0", "publisher": "LedgerHQ", "license": "Apache", "icon": "resources/ledger-square.png", @@ -21,14 +21,29 @@ ], "main": "./dist/extension.js", "contributes": { - "menus":{ - "view/item/context": [ - { - "command": "toggleAllTargets", - "when": "view == mainView && viewItem == selectTarget && ledgerDevTools.showToggleAllTargets", - "group": "inline" - } - ] + "menus": { + "view/item/context": [ + { + "command": "toggleAllTargets", + "when": "view == mainView && viewItem == selectTarget && ledgerDevTools.showToggleAllTargets", + "group": "inline" + }, + { + "command": "selectTestUseCase", + "when": "view == mainView && viewItem == functionalTests && ledgerDevTools.showSelectTestUseCase", + "group": "inline" + }, + { + "command": "rebuildTestUseCaseDeps", + "when": "view == mainView && viewItem == functionalTests && ledgerDevTools.showRebuildTestUseCaseDeps", + "group": "inline" + }, + { + "command": "rebuildTestUseCaseDepsSpin", + "when": "view == mainView && viewItem == functionalTests && ledgerDevTools.showrebuildTestUseCaseDepsSpin", + "group": "inline" + } + ] }, "taskDefinitions": [ { @@ -86,6 +101,30 @@ "icon": "$(check-all)", "enablement": "ledgerDevTools.showToggleAllTargets" }, + { + "command": "selectTestUseCase", + "title": "Select test use case", + "category": "Ledger", + "tooltip": "Select the test use case you want to run, when manifest contains multiple test use cases.", + "icon": "$(selection)", + "enablement": "ledgerDevTools.showSelectTestUseCase" + }, + { + "command": "rebuildTestUseCaseDeps", + "title": "Rebuild test use case dependencies", + "category": "Ledger", + "tooltip": "Rebuild the dependencies for the selected test use case.", + "icon": "$(sync)", + "enablement": "ledgerDevTools.showRebuildTestUseCaseDeps" + }, + { + "command": "rebuildTestUseCaseDepsSpin", + "title": "Building test dependencies...", + "category": "Ledger", + "tooltip": "Rebuild the dependencies for the selected test use case.", + "icon": "$(sync~spin)", + "enablement": "ledgerDevTools.showrebuildTestUseCaseDepsSpin" + }, { "command": "showAppList", "title": "Select app", @@ -93,7 +132,7 @@ "tooltip": "Select the app you want to build." }, { - "command": "additionalDepsPerApp", + "command": "additionalReqsPerApp", "title": "Add test dependencies", "category": "Ledger", "tooltip": "Add additional test dependencies to install for selected app." @@ -117,13 +156,13 @@ "default": "glory promote mansion idle axis finger extra february uncover one trip resource lawn turtle enact monster seven myth punch hobby comfort wild raise skin", "markdownDescription": "Device quick onboarding default 24-word Seed phrase." }, - "ledgerDevTools.additionalDepsPerApp": { + "ledgerDevTools.additionalReqsPerApp": { "type": "object", "default": { "app-boilerplate": "apk add gcc musl-dev python3-dev" }, "scope": "application", - "description": "Additional dependencies to install for each app." + "description": "Additional functional tests prerequisites to install in the current app's docker container." }, "ledgerDevTools.keepContainerTerminal": { "type": "boolean", @@ -150,6 +189,11 @@ "Stax" ], "markdownDescription": "Select the default Device" + }, + "ledgerDevTools.enableDeviceOpsForNanoX": { + "type": "boolean", + "default": false, + "markdownDescription": "Allow device operations on Nano X (requires special development device)" } } } @@ -166,6 +210,7 @@ "test": "node ./out/test/runTest.js" }, "devDependencies": { + "@ltd/j-toml": "^1.38.0", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", "@types/node": "20.2.5", @@ -177,7 +222,6 @@ "fast-glob": "^3.3.0", "glob": "^8.1.0", "mocha": "^10.2.0", - "toml": "^3.0.0", "ts-loader": "^9.4.3", "typescript": "^5.1.3", "webpack": "^5.85.0", diff --git a/src/appSelector.ts b/src/appSelector.ts index c887329..721e52b 100644 --- a/src/appSelector.ts +++ b/src/appSelector.ts @@ -2,167 +2,230 @@ import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; import * as fg from "fast-glob"; -import * as toml from "toml"; -import { TreeDataProvider } from "./treeView"; +import * as toml from "@ltd/j-toml"; +import { platform } from "node:process"; +import * as cp from "child_process"; import { TaskProvider } from "./taskProvider"; -import { ContainerManager } from "./containerManager"; -import { TargetSelector, LedgerDevice } from "./targetSelector"; +import { LedgerDevice, TargetSelector } from "./targetSelector"; import { pushError } from "./extension"; const APP_DETECTION_FILES: string[] = ["Makefile", "ledger_app.toml"]; const C_APP_DETECTION_STRING: string = "include $(BOLOS_SDK)/Makefile.defines"; const C_APP_NAME_MAKEFILE_VAR: string = "APPNAME"; const PYTEST_DETECTION_FILE: string = "conftest.py"; +type AppType = "manifest" | "legacyManifest" | "makefile"; + // Define valid app languages const validLanguages = ["Rust", "C"] as const; // Define the AppLanguage type export type AppLanguage = (typeof validLanguages)[number]; +export interface TestDependency { + gitRepoUrl: string; + gitRepoRef: string; + useCase: string; +} +export interface TestUseCase { + name: string; + dependencies: TestDependency[]; +} +export interface BuildUseCase { + name: string; + options: string; +} + export interface App { - appName: string; - appFolderName: string; - appFolder: vscode.WorkspaceFolder; + name: string; + folderName: string; + folderUri: vscode.Uri; containerName: string; buildDirPath: string; language: AppLanguage; + // If the manifest has a pytest_directory property, it is parsed here functionalTestsDir?: string; // The new manifest format allows to specify the compatible devices compatibleDevices: LedgerDevice[]; // If the app is a Rust app, the package name is parsed from the Cargo.toml packageName?: string; + // If the app manifest has a tests dependencies (optional) section with use cases, they are parsed here + testsUseCases?: TestUseCase[]; + selectedTestUseCase?: TestUseCase; + builtTestDependencies?: boolean; + // If the app manifest has build use cases (optional) section they are parsed here + buildUseCases?: BuildUseCase[]; + selectedBuildUseCase?: BuildUseCase; } let appList: App[] = []; let selectedApp: App | undefined; -// Define a sorting function to sort glob results so that ledger_app.toml files are first -const sortByLedgerAppToml = (a: string, b: string) => { - if (a.includes("ledger_app.toml") && !b.includes("ledger_app.toml")) { - return -1; - } else if (!a.includes("ledger_app.toml") && b.includes("ledger_app.toml")) { - return 1; +let appSelectedEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + +export const onAppSelectedEvent: vscode.Event = appSelectedEmitter.event; + +let testUseCaseSelected: vscode.EventEmitter = new vscode.EventEmitter(); + +export const onTestUseCaseSelected: vscode.Event = testUseCaseSelected.event; + +function detectAppType(appFolder: vscode.Uri): [AppType?, string?] { + const searchPatterns = APP_DETECTION_FILES.map((file) => path.join(appFolder.fsPath, `**/${file}`).replace(/\\/g, "/")); + const makefileOrToml = fg.sync(searchPatterns, { onlyFiles: true, deep: 2 }); + + let appTypeAndFile: [AppType?, string?] = [undefined, undefined]; + + if (makefileOrToml.length > 0) { + const manifest = makefileOrToml.find((file) => file.endsWith("ledger_app.toml")); + if (manifest) { + const fileContent = fs.readFileSync(manifest, "utf-8"); + const tomlContent = toml.parse(fileContent); + if (tomlContent["rust-app"]) { + appTypeAndFile = ["legacyManifest", manifest]; + } else { + appTypeAndFile = ["manifest", manifest]; + } + } else { + const makefile = makefileOrToml.find((file) => file.endsWith("Makefile")); + if (makefile) { + const fileContent = fs.readFileSync(makefile, "utf-8"); + if (fileContent.includes(C_APP_DETECTION_STRING)) { + appTypeAndFile = ["makefile", makefile]; + } + } + } + } + return appTypeAndFile; +} + +export function findAppInFolder(folderUri: vscode.Uri): App | undefined { + let app: App | undefined = undefined; + + const appFolderUri = folderUri; + const appFolderName = path.basename(folderUri.toString()); + const containerName = `${appFolderName}-container`; + + let appName = "unknown"; + let appLanguage: AppLanguage = "C"; + let testsDir = undefined; + let packageName = undefined; + let compatibleDevices: LedgerDevice[] = ["Nano S", "Nano S Plus", "Nano X", "Stax"]; + let testsUseCases = undefined; + let buildUseCases = undefined; + + let found = true; + + let [appType, appFile] = detectAppType(folderUri); + const fileContent = fs.readFileSync(appFile || "", "utf-8"); + + let buildDirPath = path.relative(folderUri.fsPath, path.dirname(appFile || "")); + buildDirPath = buildDirPath === "" ? "./" : buildDirPath; + + try { + switch (appType) { + case "manifest": { + console.log("Found manifest in " + appFolderName); + let tomlContent = toml.parse(fileContent); + [appLanguage, buildDirPath, compatibleDevices, testsDir, testsUseCases, buildUseCases] = parseManifest(tomlContent); + [appName, packageName] = findAdditionalInfo(appLanguage, buildDirPath, appFolderUri); + break; + } + case "legacyManifest": { + console.log("Found deprecated rust manifest in " + appFolderName); + let tomlContent = toml.parse(fileContent); + [buildDirPath, appName, packageName] = parseLegacyRustManifest(tomlContent, appFolderUri); + testsDir = findFunctionalTestsWithoutManifest(appFolderUri); + compatibleDevices = ["Nano S", "Nano S Plus", "Nano X"]; + appLanguage = "Rust"; + showManifestWarning(appFolderName, true); + break; + } + case "makefile": { + appName = getAppNameFromMakefile(fileContent); + testsDir = findFunctionalTestsWithoutManifest(appFolderUri); + showManifestWarning(appFolderName, false); + break; + } + default: + found = false; + break; + } + } catch (error) { + let err = new Error(); + if (!(error instanceof Error)) { + err.message = String(error); + } else { + err = error; + } + pushError("App detection failed in " + appFolderName + ". " + err.message); + found = false; + } + + // Add the app to the list + if (found) { + // Log all found fields + console.log(`Found app ${appName} in folder ${appFolderName} with buildDirPath ${buildDirPath} and language ${appLanguage}`); + app = { + name: appName, + folderName: appFolderName, + folderUri: appFolderUri, + containerName: containerName, + buildDirPath: buildDirPath, + language: appLanguage, + functionalTestsDir: testsDir, + compatibleDevices: compatibleDevices, + packageName: packageName, + testsUseCases: testsUseCases, + selectedTestUseCase: testsUseCases ? testsUseCases[0] : undefined, + builtTestDependencies: false, + buildUseCases: buildUseCases, + selectedBuildUseCase: buildUseCases ? buildUseCases[0] : undefined, + }; } - return 0; -}; + + return app; +} export function findAppsInWorkspace(): App[] | undefined { const workspaceFolders = vscode.workspace.workspaceFolders; appList = []; - let blacklistedApps: string[] = []; if (workspaceFolders) { workspaceFolders.forEach((folder) => { - const appFolder = folder; - const appFolderName = folder.name; - const containerName = `${appFolderName}-container`; - const searchPatterns = APP_DETECTION_FILES.map((file) => path.join(folder.uri.fsPath, `**/${file}`).replace(/\\/g, "/")); - const makefileOrToml = fg.sync(searchPatterns, { onlyFiles: true, deep: 2 }); - let found = false; - - // Sort the results so that ledger_app.toml files are first - makefileOrToml.sort(sortByLedgerAppToml); - - makefileOrToml.forEach((file) => { - found = false; - let buildDirPath = path.relative(appFolder.uri.fsPath, path.dirname(file)); - buildDirPath = buildDirPath === "" ? "./" : buildDirPath; - let appName = "unknown"; - let appLanguage: AppLanguage = "C"; - let testsDir = undefined; - let packageName = undefined; - let compatibleDevices: LedgerDevice[] = ["Nano S", "Nano S Plus", "Nano X", "Stax"]; - const fileContent = fs.readFileSync(file, "utf-8"); - - try { - // Parse the manifest (either legacy or new format) - if (file.endsWith("ledger_app.toml")) { - const tomlContent = toml.parse(fileContent); - // Legacy rust manifest - if (tomlContent["rust-app"]) { - console.log("Found deprecated rust manifest in " + appFolderName); - [buildDirPath, appName, packageName] = parseLegacyRustManifest(tomlContent, appFolder); - testsDir = findFunctionalTestsWithoutManifest(appFolder); - compatibleDevices = ["Nano S", "Nano S Plus", "Nano X"]; - appLanguage = "Rust"; - found = true; - showManifestWarning(appFolderName, true); - } - // New manifest - else { - console.log("Found manifest in " + appFolderName); - [appLanguage, buildDirPath, appName, compatibleDevices, packageName, testsDir] = parseManifest( - tomlContent, - appFolder - ); - found = true; - } - } else { - console.log("Found Makefile in " + appFolderName); - // Check from appList that an app with the same folder name does not already exist or - // that the app is not blacklisted (from a previous failed detection) - const existingApp = - appList.find((app) => app.appFolderName === appFolderName) || blacklistedApps.includes(appFolderName); - - if (fileContent.includes(C_APP_DETECTION_STRING) && !existingApp) { - appName = getAppNameFromMakefile(fileContent); - found = true; - testsDir = findFunctionalTestsWithoutManifest(appFolder); - showManifestWarning(appFolderName, false); - } - } - } catch (error) { - let err = new Error(); - if (!(error instanceof Error)) { - err.message = String(error); - } else { - err = error; - } - pushError("App detection failed in " + appFolder.name + ". " + err.message); - blacklistedApps.push(appFolderName); - } - - // Add the app to the list - if (found) { - // Log all found fields - console.log( - `Found app ${appName} in folder ${appFolderName} with buildDirPath ${buildDirPath} and language ${appLanguage}` - ); - appList.push({ - appName: appName, - appFolderName: appFolderName, - appFolder: appFolder, - containerName: containerName, - buildDirPath: buildDirPath, - language: appLanguage, - functionalTestsDir: testsDir, - compatibleDevices: compatibleDevices, - packageName: packageName, - }); - } - }); + const app = findAppInFolder(folder.uri); + if (app) { + appList.push(app); + } }); } return appList; } -export async function showAppSelectorMenu( - treeDataProvider: TreeDataProvider, - taskProvider: TaskProvider, - containerManager: ContainerManager, - targetSelector: TargetSelector -) { - const appFolderNames = appList.map((app) => app.appFolderName); +export async function showAppSelectorMenu(targetSelector: TargetSelector) { + const appFolderNames = appList.map((app) => app.folderName); const result = await vscode.window.showQuickPick(appFolderNames, { placeHolder: "Please select an app", onDidSelectItem: (item) => { - selectedApp = appList.find((app) => app.appFolderName === item); + setSelectedApp(appList.find((app) => app.folderName === item)); + testUseCaseSelected.fire(); }, }); - taskProvider.generateTasks(); - containerManager.manageContainer(); - treeDataProvider.updateAppAndTargetLabels(); - targetSelector.updateTargetsInfos(); + getAndBuildAppTestsDependencies(targetSelector); + return result; +} + +export async function showTestUseCaseSelectorMenu(targetSelector: TargetSelector) { + const testUseCaseNames = selectedApp?.testsUseCases?.map((testUseCase) => testUseCase.name); + let result = undefined; + if (testUseCaseNames) { + result = await vscode.window.showQuickPick(testUseCaseNames, { + placeHolder: "Please select a test use case", + onDidSelectItem: (item) => { + selectedApp!.selectedTestUseCase = selectedApp!.testsUseCases?.find((testUseCase) => testUseCase.name === item); + appSelectedEmitter.fire(); + }, + }); + } + getAndBuildAppTestsDependencies(targetSelector, true); return result; } @@ -170,24 +233,35 @@ export function getSelectedApp() { return selectedApp; } -export function setSelectedApp(app: App) { +export function setSelectedApp(app: App | undefined) { selectedApp = app; + if (app && app.testsUseCases) { + vscode.commands.executeCommand("setContext", "ledgerDevTools.showRebuildTestUseCaseDeps", true); + if (app.testsUseCases.length > 1) { + vscode.commands.executeCommand("setContext", "ledgerDevTools.showSelectTestUseCase", true); + } else { + vscode.commands.executeCommand("setContext", "ledgerDevTools.showSelectTestUseCase", false); + } + } else { + vscode.commands.executeCommand("setContext", "ledgerDevTools.showRebuildTestUseCaseDeps", false); + vscode.commands.executeCommand("setContext", "ledgerDevTools.showSelectTestUseCase", false); + } } export function getAppList() { return appList; } -export function setAppTestsDependencies(taskProvider: TaskProvider) { +export function setAppTestsPrerequisites(taskProvider: TaskProvider) { const currentApp = getSelectedApp(); const conf = vscode.workspace.getConfiguration("ledgerDevTools"); let currentValue = ""; - const additionalDepsPerApp = conf.get>("additionalDepsPerApp"); + const additionalReqsPerApp = conf.get>("additionalReqsPerApp"); if (currentApp) { - if (additionalDepsPerApp && additionalDepsPerApp[currentApp.appFolderName]) { - currentValue = additionalDepsPerApp[currentApp.appFolderName]; + if (additionalReqsPerApp && additionalReqsPerApp[currentApp.folderName]) { + currentValue = additionalReqsPerApp[currentApp.folderName]; } - // Let user input string in a popup and save it in the additionalDepsPerApp configuration + // Let user input string in a popup and save it in the additionalReqsPerApp configuration vscode.window .showInputBox({ prompt: "Please enter additional test dependencies for this app", @@ -197,21 +271,21 @@ export function setAppTestsDependencies(taskProvider: TaskProvider) { .then((value) => { if (value) { const conf = vscode.workspace.getConfiguration("ledgerDevTools"); - const additionalDepsPerApp = conf.get>("additionalDepsPerApp"); + const additionalReqsPerApp = conf.get>("additionalReqsPerApp"); // Account for the fact that maybe the app is not yet in the configuration - if (additionalDepsPerApp && additionalDepsPerApp[currentApp.appFolderName]) { - additionalDepsPerApp[currentApp.appFolderName] = value; - conf.update("additionalDepsPerApp", additionalDepsPerApp, vscode.ConfigurationTarget.Global); + if (additionalReqsPerApp && additionalReqsPerApp[currentApp.folderName]) { + additionalReqsPerApp[currentApp.folderName] = value; + conf.update("additionalReqsPerApp", additionalReqsPerApp, vscode.ConfigurationTarget.Global); console.log( - `Ledger: additionalDepsPerApp configuration found (current value: ${additionalDepsPerApp[ - currentApp.appFolderName - ].toString()}), updating it with ${currentApp.appFolderName}:${value}` + `Ledger: additionalReqsPerApp configuration found (current value: ${additionalReqsPerApp[ + currentApp.folderName + ].toString()}), updating it with ${currentApp.folderName}:${value}` ); } else { console.log( - `Ledger: no additionalDepsPerApp configuration found, creating it with ${currentApp.appFolderName}:${value}` + `Ledger: no additionalReqsPerApp configuration found, creating it with ${currentApp.folderName}:${value}` ); - conf.update("additionalDepsPerApp", { [currentApp.appFolderName]: value }, vscode.ConfigurationTarget.Global); + conf.update("additionalReqsPerApp", { [currentApp.folderName]: value }, vscode.ConfigurationTarget.Global); } } }); @@ -238,19 +312,24 @@ async function showManifestWarning(appFolderName: string, deprecated: boolean) { } // Convert a manifest device to a LedgerDevice -function manifestDeviceToLedgerDevice(manifestDevice: string): LedgerDevice { - switch (manifestDevice) { - case "nanos": - return "Nano S"; - case "nanox": - return "Nano X"; - case "nanos+": - return "Nano S Plus"; - case "stax": - return "Stax"; - default: - throw new Error("Invalid device in manifest : " + manifestDevice); - } +function manifestDevicesToLedgerDevices(manifestDevices: string): LedgerDevice[] { + return manifestDevices + .toString() + .split(",") + .map((device: string) => { + switch (device) { + case "nanos": + return "Nano S"; + case "nanox": + return "Nano X"; + case "nanos+": + return "Nano S Plus"; + case "stax": + return "Stax"; + default: + throw new Error("Invalid device in manifest : " + device); + } + }); } // Get the app name from the Makefile (for C apps) @@ -278,69 +357,206 @@ function isValidLanguage(value: string): AppLanguage { // Parse Cargo.toml and return app name and package name function parseCargoToml(cargoTomlPath: string): [string, string] { const cargoTomlContent = toml.parse(fs.readFileSync(cargoTomlPath, "utf-8")); - let packageName = getNestedProperty(cargoTomlContent, "package.name"); - let appName = getNestedProperty(cargoTomlContent, "package.metadata.ledger.name"); + let packageName = getPropertyOrThrow(cargoTomlContent, "package.name"); + let appName = getPropertyOrThrow(cargoTomlContent, "package.metadata.ledger.name"); return [appName, packageName]; } // Check if pytest functional tests are present for an app without manifest // or if the manifest does not specify the pytest directory (legacy manifest) -function findFunctionalTestsWithoutManifest(appFolder: any): string | undefined { +function findFunctionalTestsWithoutManifest(appFolderUri: vscode.Uri): string | undefined { // Check if pytest functional tests are present let testsDir = undefined; - const searchPattern = path.join(appFolder.uri.fsPath, `**/${PYTEST_DETECTION_FILE}`).replace(/\\/g, "/"); + const searchPattern = path.join(appFolderUri.fsPath, `**/${PYTEST_DETECTION_FILE}`).replace(/\\/g, "/"); const conftestFile = fg.sync(searchPattern, { onlyFiles: true, deep: 2 })[0]; if (conftestFile) { // Get the tests folder path relative to current app folder - testsDir = path.relative(appFolder.uri.fsPath, path.dirname(conftestFile)); + testsDir = path.relative(appFolderUri.fsPath, path.dirname(conftestFile)); } return testsDir; } +// Get a nested property from a toml object, return undefined if not found +function getProperty(obj: any, objectPath: string): string | undefined { + return objectPath.split(".").reduce((acc, key) => acc?.[key], obj); +} + // Get a nested property from a toml object, throw an error if not found -function getNestedProperty(obj: any, path: string): string { - const value = path.split(".").reduce((acc, key) => acc?.[key], obj); +function getPropertyOrThrow(obj: any, path: string): string | any { + const value = getProperty(obj, path); if (value === undefined) { - throw new Error(`Wrong manifest format. Property "${path}" not found`); + throw new Error(`Wrong manifest format. Mandatory property "${path}" not found in "${JSON.stringify(obj)}"`); } return value; } -// Parse manifest. Returns app language, build dir path, app name, devices, package name (for rust app), functional tests dir path (if any) -function parseManifest(tomlContent: any, appFolder: any): [AppLanguage, string, string, LedgerDevice[], string?, string?] { - // Check that the manifest is valid - getNestedProperty(tomlContent, "app"); +function parseTestsUsesCasesFromManifest(tomlContent: any): TestUseCase[] | undefined { + let dependenciesSection = getProperty(tomlContent, "tests.dependencies"); + let testUseCases: TestUseCase[] | undefined = undefined; + if (dependenciesSection) { + testUseCases = []; + const useCases = Object.keys(dependenciesSection); + for (let useCase of useCases) { + let testUseCase: TestUseCase = { + name: useCase, + dependencies: [], + }; + let useCaseDependencies = getPropertyOrThrow(dependenciesSection, useCase); + useCaseDependencies.forEach((dependency: any) => { + testUseCase.dependencies.push({ + gitRepoUrl: getPropertyOrThrow(dependency, "url"), + gitRepoRef: getPropertyOrThrow(dependency, "ref"), + useCase: getPropertyOrThrow(dependency, "use_case"), + }); + }); + testUseCases.push(testUseCase); + console.log(`Found test use case ${useCase} with dependencies ${JSON.stringify(testUseCase.dependencies)}`); + } + } + return testUseCases; +} + +function parseBuildUseCasesFromManifest(tomlContent: any): BuildUseCase[] | undefined { + let useCasesSection = getProperty(tomlContent, "use_cases"); + let buildUseCases: BuildUseCase[] | undefined = undefined; + if (useCasesSection) { + console.log(`Found use_cases section in manifest`); + buildUseCases = []; + const useCases = Object.keys(useCasesSection); + for (let useCase of useCases) { + let buildUseCase: BuildUseCase = { + name: useCase, + options: getPropertyOrThrow(useCasesSection, useCase), + }; + buildUseCases.push(buildUseCase); + console.log(`Found build use case ${useCase} with options ${JSON.stringify(buildUseCase.options)}`); + } + } + return buildUseCases; +} + +export function getAndBuildAppTestsDependencies(targetSelector: TargetSelector, clean: boolean = false) { + const testDepDir = ".test_dependencies"; + if (selectedApp && selectedApp.selectedTestUseCase && selectedApp.functionalTestsDir) { + const testDepDirPath = path.join(selectedApp.functionalTestsDir, testDepDir); + + if (clean) { + let cleanCmd = `docker exec -t ${ + selectedApp!.containerName + } bash -c 'if [ -d '${testDepDirPath}' ]; then rm -rf ${testDepDirPath}; fi'`; + try { + // Check if folder exists before removing it + cp.execSync(cleanCmd, { stdio: "inherit" }); + vscode.commands.executeCommand("setContext", "ledgerDevTools.showRebuildTestUseCaseDeps", false); + vscode.commands.executeCommand("setContext", "ledgerDevTools.showrebuildTestUseCaseDepsSpin", true); + } catch (error) { + pushError(`Clean of test dependencies failed. ${error}`); + } + selectedApp.builtTestDependencies = false; + } + + if (!selectedApp.builtTestDependencies) { + selectedApp.selectedTestUseCase.dependencies.forEach((dep) => { + let depFolderName = path.basename(dep.gitRepoUrl, ".git") + "-" + dep.useCase; + let depFolderPath = path.join(testDepDirPath, depFolderName); + let gitCloneCommand = `git clone ${dep.gitRepoUrl} --branch ${dep.gitRepoRef} ${depFolderPath}`; + let execGitCloneCommand = `docker exec -t ${ + selectedApp!.containerName + } bash -c 'if [ ! -d '${depFolderPath}' ]; then ${gitCloneCommand}; fi'`; + + try { + cp.execSync(execGitCloneCommand, { stdio: "inherit" }); + } catch (error) { + pushError(`Git clone of test dependency ${depFolderName} failed. ${error}`); + } + + let depApp = findAppInFolder(vscode.Uri.parse(path.join(selectedApp!.folderUri.fsPath, depFolderPath))); + if (depApp) { + let depAppBuildUseCase = depApp.buildUseCases?.find((useCase) => useCase.name === dep.useCase); + if (depAppBuildUseCase) { + if (depApp.language === "C") { + console.log(`Ledger: building C app ${depApp.name} in ${depFolderPath}`); + + let submodulesCommand = `cd ${depFolderPath} && git submodule update --init --recursive;`; + if (platform === "win32") { + // Execute git command in cmd.exe on host, no docker + submodulesCommand = `cd ${depFolderPath} && cmd.exe /c "git submodule update --init --recursive";`; + } + + let buildCommand = ""; + let target = targetSelector.getSelectedTarget(); + targetSelector.getTargetsArray().forEach((target) => { + targetSelector.setSelectedTarget(target); + buildCommand += `export BOLOS_SDK=$(echo ${targetSelector.getSelectedSDK()}) && make -C ${path.join( + depFolderPath, + depApp!.buildDirPath + )} -j ${depAppBuildUseCase!.options} ; `; + }); + targetSelector.setSelectedTarget(target); + + let execBuildCommand = `${submodulesCommand} docker exec -t ${ + selectedApp!.containerName + } bash -c '${buildCommand}'`; + + vscode.commands.executeCommand("setContext", "ledgerDevTools.showRebuildTestUseCaseDeps", false); + vscode.commands.executeCommand("setContext", "ledgerDevTools.showrebuildTestUseCaseDepsSpin", true); + + // Executing the command with a callback + cp.exec(execBuildCommand, (error, stdout, stderr) => { + if (error) { + pushError(`Build of test use case ${dep.useCase} dependency ${depApp!.folderName} failed. ${error}`); + return; + } else { + selectedApp!.builtTestDependencies = true; + vscode.window.showInformationMessage( + `Build of test dependency ${depApp!.folderName} for all supported targets succeeded.` + ); + } + vscode.commands.executeCommand("setContext", "ledgerDevTools.showRebuildTestUseCaseDeps", true); + vscode.commands.executeCommand("setContext", "ledgerDevTools.showrebuildTestUseCaseDepsSpin", false); + }); + } + } else { + pushError(`Build use case ${dep.useCase} not found in ${depApp.folderName} manifest. Cannot build test dependency.`); + } + } + }); + } + } +} +// Parse manifest. Returns app language, build dir path, app name, devices, package name (for rust app), functional tests dir path (if any) +function parseManifest(tomlContent: any): [AppLanguage, string, LedgerDevice[], string?, TestUseCase[]?, BuildUseCase[]?] { // Parse app language - const language = getNestedProperty(tomlContent, "app.sdk"); - const appLanguage = isValidLanguage(language); + const appLanguage = isValidLanguage(getPropertyOrThrow(tomlContent, "app.sdk")); // Parse build dir path - let buildDirPath = getNestedProperty(tomlContent, "app.build_directory"); + let buildDirPath = getPropertyOrThrow(tomlContent, "app.build_directory"); // Parse compatible devices - const compatibleDevicesStr: string = getNestedProperty(tomlContent, "app.devices"); - const compatibleDevices: LedgerDevice[] = compatibleDevicesStr - .toString() - .split(",") - .map((device: string) => manifestDeviceToLedgerDevice(device)); + const compatibleDevices: LedgerDevice[] = manifestDevicesToLedgerDevices(getPropertyOrThrow(tomlContent, "app.devices")); // Check if pytest functional tests are present - let functionalTestsDir = undefined; - if (tomlContent["tests"] && tomlContent["tests"]["pytest_directory"]) { - functionalTestsDir = tomlContent["tests"]["pytest_directory"]; - } + let functionalTestsDir = getProperty(tomlContent, "tests.pytest_directory"); - // If C app, parse app name from Makefile - let appName = "unknown"; + // Parse test dependencies, if any. + let testUseCases = parseTestsUsesCasesFromManifest(tomlContent); + + // Parse build use cases, if any. + let buildUseCases = parseBuildUseCasesFromManifest(tomlContent); + + return [appLanguage, buildDirPath, compatibleDevices, functionalTestsDir, testUseCases, buildUseCases]; +} + +// Find app name and package name from build dir path, in Makefile for C apps or Cargo.toml for Rust apps +function findAdditionalInfo(appLanguage: AppLanguage, buildDirPath: string, appFolder: vscode.Uri): [string, string?] { + let appName: string; let packageName: string | undefined; // Get the build dir path on the host to search for the Makefile or Cargo.toml - let hostBuildDirPath = buildDirPath; - if (buildDirPath.startsWith("./")) { - hostBuildDirPath = path.join(appFolder.uri.fsPath, buildDirPath); - } + let hostBuildDirPath = buildDirPath.startsWith("./") ? path.join(appFolder.fsPath, buildDirPath) : buildDirPath; + // If C app, parse app name from Makefile if (appLanguage === "C") { // Search for Makefile in build dir const searchPattern = path.join(hostBuildDirPath, `**/Makefile`).replace(/\\/g, "/"); @@ -355,16 +571,15 @@ function parseManifest(tomlContent: any, appFolder: any): [AppLanguage, string, else { [appName, packageName] = parseCargoToml(path.join(hostBuildDirPath, "Cargo.toml")); } - return [appLanguage, buildDirPath, appName, compatibleDevices, packageName, functionalTestsDir]; + return [appName, packageName]; } // Parse legacy rust manifest and return build dir path, app name and package name -function parseLegacyRustManifest(tomlContent: any, appFolder: any): [string, string, string] { - getNestedProperty(tomlContent, "rust-app"); - let cargoTomlPath = getNestedProperty(tomlContent, "rust-app.manifest-path"); +function parseLegacyRustManifest(tomlContent: any, appFolderUri: vscode.Uri): [string, string, string] { + let cargoTomlPath = getPropertyOrThrow(tomlContent, "rust-app.manifest-path"); const buildDirPath = path.dirname(cargoTomlPath); if (cargoTomlPath.startsWith("./")) { - cargoTomlPath = path.join(appFolder.uri.fsPath, cargoTomlPath); + cargoTomlPath = path.join(appFolderUri.fsPath, cargoTomlPath); } let [appName, packageName] = parseCargoToml(cargoTomlPath); return [buildDirPath, appName, packageName]; diff --git a/src/extension.ts b/src/extension.ts index 709a42b..45aaba2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,7 +6,17 @@ import { TreeDataProvider } from "./treeView"; import { TargetSelector } from "./targetSelector"; import { StatusBarManager } from "./statusBar"; import { ContainerManager, DevImageStatus } from "./containerManager"; -import { findAppsInWorkspace, getSelectedApp, setSelectedApp, showAppSelectorMenu, setAppTestsDependencies } from "./appSelector"; +import { + findAppsInWorkspace, + getSelectedApp, + setSelectedApp, + showAppSelectorMenu, + setAppTestsPrerequisites, + onAppSelectedEvent, + showTestUseCaseSelectorMenu, + onTestUseCaseSelected, + getAndBuildAppTestsDependencies, +} from "./appSelector"; let outputChannel: vscode.OutputChannel; const appDetectionFiles = ["Cargo.toml", "ledger_app.toml", "Makefile"]; @@ -41,6 +51,9 @@ export function activate(context: vscode.ExtensionContext) { containerManager.onStatusEvent((data) => { statusBarManager.updateDevImageItem(data); treeProvider.updateContainerLabel(data); + if (data === DevImageStatus.running) { + getAndBuildAppTestsDependencies(targetSelector); + } }) ); @@ -50,11 +63,30 @@ export function activate(context: vscode.ExtensionContext) { targetSelector.onTargetSelectedEvent((data) => { taskProvider.generateTasks(); statusBarManager.updateTargetItem(data); - treeProvider.updateAppAndTargetLabels(); + treeProvider.updateDynamicLabels(); containerManager.manageContainer(); }) ); + // Event listener for app selection. + // This event is fired when the user selects an app in the appSelector menu + context.subscriptions.push( + onAppSelectedEvent(() => { + taskProvider.generateTasks(); + containerManager.manageContainer(); + treeProvider.updateDynamicLabels(); + targetSelector.updateTargetsInfos(); + }) + ); + + // Event listener for test use case selection. + // This event is fired when the user selects a test use case in the testUseCaseSelector menu + context.subscriptions.push( + onTestUseCaseSelected(() => { + treeProvider.updateDynamicLabels(); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand("selectTarget", () => { targetSelector.showTargetSelectorMenu(); @@ -62,8 +94,8 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.commands.registerCommand("addTestsDependencies", () => { - setAppTestsDependencies(taskProvider); + vscode.commands.registerCommand("addTestsPrerequisites", () => { + setAppTestsPrerequisites(taskProvider); }) ); @@ -74,14 +106,28 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.commands.registerCommand("toggleAllTargets", (taskName: string) => { + vscode.commands.registerCommand("toggleAllTargets", () => { targetSelector.toggleAllTargetSelection(); }) ); + context.subscriptions.push( + vscode.commands.registerCommand("selectTestUseCase", () => { + showTestUseCaseSelectorMenu(targetSelector); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("rebuildTestUseCaseDeps", () => { + getAndBuildAppTestsDependencies(targetSelector, true); + }) + ); + + context.subscriptions.push(vscode.commands.registerCommand("rebuildTestUseCaseDepsSpin", () => {})); + context.subscriptions.push( vscode.commands.registerCommand("showAppList", () => { - showAppSelectorMenu(treeProvider, taskProvider, containerManager, targetSelector); + showAppSelectorMenu(targetSelector); }) ); @@ -90,7 +136,7 @@ export function activate(context: vscode.ExtensionContext) { if (taskName.startsWith("Update Container")) { containerManager.triggerStatusEvent(DevImageStatus.syncing); } - if (taskName.startsWith("Quick device onboarding")) { + if (taskName.startsWith("Quick initial device")) { const conf = vscode.workspace.getConfiguration("ledgerDevTools"); const seedValue = conf.get("onboardingSeed"); const defaultSeed = conf.inspect("onboardingSeed")?.defaultValue; @@ -115,7 +161,7 @@ export function activate(context: vscode.ExtensionContext) { setSelectedApp(appList[0]); } treeProvider.addDefaultTreeItems(); - treeProvider.updateAppAndTargetLabels(); + treeProvider.updateDynamicLabels(); targetSelector.updateTargetsInfos(); taskProvider.provideTasks(); containerManager.manageContainer(); @@ -143,7 +189,7 @@ export function activate(context: vscode.ExtensionContext) { ); taskProvider.generateTasks(); statusBarManager.updateTargetItem(targetSelector.getSelectedTarget()); - treeProvider.updateAppAndTargetLabels(); + treeProvider.updateDynamicLabels(); } }); diff --git a/src/statusBar.ts b/src/statusBar.ts index b0a173c..bf543e7 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -38,7 +38,7 @@ export class StatusBarManager { public updateDevImageItem(status: DevImageStatus): void { const currentApp = getSelectedApp(); if (currentApp) { - this.devImageItem.text = `$(${status.toString()}) L : ${currentApp.appFolderName}`; + this.devImageItem.text = `$(${status.toString()}) L : ${currentApp.folderName}`; let statusText = "[stopped] "; switch (status) { case DevImageStatus.running: diff --git a/src/taskProvider.ts b/src/taskProvider.ts index 69432aa..41c75c9 100644 --- a/src/taskProvider.ts +++ b/src/taskProvider.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; +import * as fs from "fs"; import { platform } from "node:process"; import { TargetSelector } from "./targetSelector"; import { getSelectedApp, App, AppLanguage } from "./appSelector"; @@ -10,10 +11,12 @@ import { TreeDataProvider } from "./treeView"; export const taskType = "L"; // Udev rules (for Linux app loading requirements) +const udevRulesFilePath = "/etc/udev/rules.d/"; const udevRulesFile = "20-ledger.ledgerblue.rules"; -const udevRules = `SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0006|6000|6001|6002|6003|6004|6005|6006|6007|6008|6009|600a|600b|600c|600d|600e|600f|6010|6011|6012|6013|6014|6015|6016|6017|6018|6019|601a|601b|601c|601d|601e|601f", TAG+="uaccess", TAG+="udev-acl"`; +const udevRules = `SUBSYSTEMS=="usb", KERNEL=="hidraw*", ATTRS{idVendor}=="2c97", MODE="0666", ATTRS{idProduct}=="0006|6000|6001|6002|6003|6004|6005|6006|6007|6008|6009|600a|600b|600c|600d|600e|600f|6010|6011|6012|6013|6014|6015|6016|6017|6018|6019|601a|601b|601c|601d|601e|601f", TAG+="uaccess", TAG+="udev-acl"`; -type ExecBuilder = () => string; +type CustomTaskFunction = () => void; +type ExecBuilder = () => string | [string, CustomTaskFunction]; type TaskTargetLanguage = AppLanguage | "Both"; type BuilderForLanguage = Partial>; @@ -30,6 +33,21 @@ export interface TaskSpec { allSelectedBehavior: BehaviorWhenAllTargetsSelected; } +class MyTask extends vscode.Task { + public customFunction: CustomTaskFunction | undefined; + constructor( + definition: vscode.TaskDefinition, + scope: vscode.TaskScope, + name: string, + source: string, + execution: vscode.ShellExecution, + customFunction: CustomTaskFunction | undefined + ) { + super(definition, scope, name, source, execution); + this.customFunction = customFunction; + } +} + export class TaskProvider implements vscode.TaskProvider { private treeProvider: TreeDataProvider; private tgtSelector: TargetSelector; @@ -37,16 +55,17 @@ export class TaskProvider implements vscode.TaskProvider { private onboardPin: string; private onboardSeed: string; private scpConfig: boolean; - private additionalDeps?: string; + private enableNanoxOps: boolean; + private additionalReqs?: string; private buildDir: string; private workspacePath: string; private containerName: string; - private appFolder?: vscode.WorkspaceFolder; + private appFolderUri?: vscode.Uri; private appName: string; private appLanguage: AppLanguage; private functionalTestsDir?: string; private packageName?: string; - private tasks: vscode.Task[] = []; + private tasks: MyTask[] = []; private currentApp?: App; private taskSpecs: TaskSpec[] = [ { @@ -85,9 +104,9 @@ export class TaskProvider implements vscode.TaskProvider { }, { group: "Build", - name: "Clean build files", + name: "Clean the build files", builders: { ["C"]: this.cCleanExec, ["Rust"]: this.rustCleanExec }, - toolTip: "Clean app build files", + toolTip: "Clean the app build files", state: "enabled", allSelectedBehavior: "enable", }, @@ -95,7 +114,7 @@ export class TaskProvider implements vscode.TaskProvider { group: "Functional Tests", name: "Run with emulator", builders: { ["Both"]: this.runInSpeculosExec }, - toolTip: "Run app with Speculos emulator", + toolTip: "Run app with emulator (Speculos)", state: "enabled", allSelectedBehavior: "disable", }, @@ -103,7 +122,7 @@ export class TaskProvider implements vscode.TaskProvider { group: "Functional Tests", name: "Kill emulator", builders: { ["Both"]: this.killSpeculosExec }, - toolTip: "Kill Speculos emulator instance", + toolTip: "Kill emulator (Speculos) instance", state: "enabled", allSelectedBehavior: "disable", }, @@ -164,10 +183,10 @@ export class TaskProvider implements vscode.TaskProvider { }, { group: "Device Operations", - name: "Quick device onboarding", + name: "Quick initial device setup", builders: { ["Both"]: this.deviceOnboardingExec }, dependsOn: this.appLoadRequirementsExec, - toolTip: "Onboard a physical device with a seed and PIN code", + toolTip: "Automatic initial device setup with pre-defined test seed and PIN", state: "enabled", allSelectedBehavior: "disable", }, @@ -188,9 +207,10 @@ export class TaskProvider implements vscode.TaskProvider { this.onboardPin = conf.get("onboardingPin") || ""; this.onboardSeed = conf.get("onboardingSeed") || ""; this.scpConfig = conf.get("userScpPrivateKey") || false; - const allDeps = conf.get>("additionalDepsPerApp"); - if (this.currentApp && allDeps && allDeps[this.currentApp.appFolderName]) { - this.additionalDeps = allDeps[this.currentApp.appFolderName]; + this.enableNanoxOps = conf.get("enableDeviceOpsForNanoX") || false; + const configReqs = conf.get>("additionalReqsPerApp"); + if (this.currentApp && configReqs && configReqs[this.currentApp.folderName]) { + this.additionalReqs = configReqs[this.currentApp.folderName]; } this.generateTasks(); } @@ -207,21 +227,22 @@ export class TaskProvider implements vscode.TaskProvider { this.onboardPin = conf.get("onboardingPin") || ""; this.onboardSeed = conf.get("onboardingSeed") || ""; this.scpConfig = conf.get("userScpPrivateKey") || false; + this.enableNanoxOps = conf.get("enableDeviceOpsForNanoX") || false; this.currentApp = getSelectedApp(); if (this.currentApp) { - const allDeps = conf.get>("additionalDepsPerApp"); - if (allDeps && allDeps[this.currentApp.appFolderName]) { - this.additionalDeps = allDeps[this.currentApp.appFolderName]; + const configReqs = conf.get>("additionalReqsPerApp"); + if (configReqs && configReqs[this.currentApp.folderName]) { + this.additionalReqs = configReqs[this.currentApp.folderName]; } else { - this.additionalDeps = undefined; + this.additionalReqs = undefined; } this.functionalTestsDir = this.currentApp.functionalTestsDir; - this.appName = this.currentApp.appName; + this.appName = this.currentApp.name; this.appLanguage = this.currentApp.language; this.containerName = this.currentApp.containerName; - this.appFolder = this.currentApp.appFolder; + this.appFolderUri = this.currentApp.folderUri; this.buildDir = this.currentApp.buildDirPath; - this.workspacePath = this.currentApp.appFolder.uri.path; + this.workspacePath = this.currentApp.folderUri.path; this.packageName = this.currentApp.packageName; this.checkDisabledTasks(); this.pushAllTasks(); @@ -245,6 +266,9 @@ export class TaskProvider implements vscode.TaskProvider { public executeTaskByName(taskName: string) { const task = this.getTaskByName(taskName); if (task) { + if (task.customFunction) { + task.customFunction(); + } vscode.tasks.executeTask(task); } } @@ -343,12 +367,29 @@ export class TaskProvider implements vscode.TaskProvider { return exec; } - private appLoadRequirementsExec(): string { + private appLoadRequirementsExec(): string | [string, CustomTaskFunction] { let exec = ""; if (platform === "linux") { // Linux - // Copies the ledger udev rule file to the /etc/udev/rules.d/ directory if it does not exist, then reloads the rules and triggers udev. - exec = `if [ ! -f '/etc/udev/rules.d/${udevRulesFile}' ]; then echo '${udevRules}' > ${udevRulesFile} && sudo mv ${udevRulesFile} /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger; fi`; + // Copies the ledger udev rule file to the /etc/udev/rules.d/ directory if it does not exist or if the content needs to be updated, then reloads the rules and triggers udev. + exec = `if [ ! -f '${udevRulesFilePath}${udevRulesFile}' ] || ! cmp -s '${udevRulesFilePath}${udevRulesFile}' <(echo -n '${udevRules}') ; then echo -n '${udevRules}' > ${udevRulesFile} && sudo mv ${udevRulesFile} ${udevRulesFilePath} && sudo udevadm control --reload-rules && sudo udevadm trigger; fi`; + const customFunction = () => { + let showSudoMsg: boolean = false; + if (fs.existsSync(`${udevRulesFilePath}${udevRulesFile}`)) { + const filesContent = fs.readFileSync(`${udevRulesFilePath}${udevRulesFile}`, "utf8"); + if (filesContent !== udevRules) { + showSudoMsg = true; + } + } else { + showSudoMsg = true; + } + if (showSudoMsg) { + vscode.window.showWarningMessage( + `Udev rules need to be updated for sideloading to be executed properly. Please enter your password in the terminal panel to update ${udevRulesFilePath}${udevRulesFile}.` + ); + } + }; + return [exec, customFunction]; } else if (platform === "darwin") { // macOS // Checks that virtual env is installed, otherwise installs it. Then installs ledgerblue in a virtualenv. @@ -364,7 +405,7 @@ export class TaskProvider implements vscode.TaskProvider { private appLoadExec(): string { let exec = ""; let keyconfig = ""; - const hostBuildDirPath = path.join(this.appFolder!.uri.fsPath, this.buildDir); + const hostBuildDirPath = path.join(this.appFolderUri!.fsPath, this.buildDir); const tgtBuildDir = this.tgtSelector.getTargetBuildDirName(); if (this.scpConfig === true) { @@ -437,7 +478,7 @@ export class TaskProvider implements vscode.TaskProvider { } else { // Assume windows // Side loads the app APDU file using ledgerblue runScript. - exec = `cmd.exe /C '.\\ledger\\Scripts\\activate.bat && python -m ledgerblue.hostOnboard --apdu --id 0 --pin ${this.onboardPin} --prefix \"\" --passphrase \"\" --words \"${this.onboardSeed}\"'`; + exec = `cmd.exe /C '.\\ledger\\Scripts\\activate.bat && python -m ledgerblue.hostOnboard --apdu --id 0 --pin ${this.onboardPin} --prefix=\"\" --passphrase=\"\" --words \"${this.onboardSeed}\"'`; } return exec; } @@ -475,50 +516,83 @@ export class TaskProvider implements vscode.TaskProvider { } private functionalTestsRequirementsExec(): string { - // Use additionalDepsPerApp configuration to install additional dependencies for current app. - let addDepsExec = ""; - if (this.additionalDeps) { - addDepsExec = `${this.additionalDeps} &&`; - console.log(`Ledger: Installing additional dependencies : ${addDepsExec}`); + // Use additionalReqsPerApp configuration to install additional dependencies for current app. + let addReqsExec = ""; + if (this.additionalReqs) { + addReqsExec = `${this.additionalReqs} &&`; + console.log(`Ledger: Installing additional dependencies : ${addReqsExec}`); } const reqFilePath = this.functionalTestsDir + "/requirements.txt"; - const exec = `docker exec -it -u 0 ${this.containerName} bash -c '${addDepsExec} [ -f ${reqFilePath} ] && pip install -r ${reqFilePath}'`; + const exec = `docker exec -it -u 0 ${this.containerName} bash -c '${addReqsExec} [ -f ${reqFilePath} ] && pip install -r ${reqFilePath}'`; return exec; } private pushAllTasks(): void { - let defineExec = (item: TaskSpec) => { + type DefineExecFunc = (item: TaskSpec) => [string, CustomTaskFunction | undefined]; + let defineExec: DefineExecFunc = (item: TaskSpec) => { let dependExec = ""; + let customFunc: CustomTaskFunction | undefined; + let dependFunc: CustomTaskFunction | undefined; if (item.dependsOn) { - dependExec = item.dependsOn.call(this) + ";"; + let dependResult = item.dependsOn.call(this); + if (typeof dependResult === "string") { + dependExec = dependExec + " ; "; + } else { + dependExec = dependResult[0] + " ; "; + dependFunc = dependResult[1]; + } + } + const builderResult = item.builders[this.appLanguage]?.call(this) || item.builders["Both"]?.call(this) || ""; + let languageExec = ""; + if (typeof builderResult === "string") { + languageExec = builderResult; + } else { + languageExec = builderResult[0]; + customFunc = builderResult[1]; + } + + let func: CustomTaskFunction | undefined; + if (dependFunc || customFunc) { + func = () => { + if (dependFunc) { + dependFunc(); + } + if (customFunc) { + customFunc(); + } + }; } - const languageExec = item.builders[this.appLanguage]?.call(this) || item.builders["Both"]?.call(this) || ""; const exec = dependExec + languageExec; - return exec; + return [exec, func]; }; this.taskSpecs.forEach((item) => { if (this.currentApp && item.state === "enabled") { - console.log("Pushing task: " + item.name + " for app: " + this.currentApp.appName); - - let exec = defineExec(item); + console.log("Pushing task: " + item.name + " for app: " + this.currentApp.name); + let exec = ""; + let customFunction: CustomTaskFunction | undefined; + let defineResult = defineExec(item); + exec = defineResult[0]; + customFunction = defineResult[1]; // If the selected target is all and the task behavior is to be executed for all targets, // define the exec for all targets of the app if (this.tgtSelector.getSelectedTarget() === "All" && item.allSelectedBehavior === "executeForEveryTarget") { exec = ""; this.tgtSelector.getTargetsArray().forEach((target) => { this.tgtSelector.setSelectedTarget(target); - exec += defineExec(item) + " ; "; + exec += defineExec(item)[0] + " ; "; }); this.tgtSelector.setSelectedTarget("All"); + customFunction = undefined; } - const task = new vscode.Task( + const task = new MyTask( { type: taskType, task: item.name }, - this.currentApp.appFolder, + vscode.TaskScope.Workspace, item.name, taskType, - new vscode.ShellExecution(exec) + new vscode.ShellExecution(exec), + customFunction ); task.group = vscode.TaskGroup.Build; this.tasks.push(task); @@ -554,6 +628,12 @@ export class TaskProvider implements vscode.TaskProvider { item.state = "disabled"; } }); + } else if (this.tgtSelector.getSelectedTarget() === "Nano X" && this.enableNanoxOps === false) { + this.taskSpecs.forEach((item) => { + if (item.group === "Device Operations") { + item.state = "disabled"; + } + }); } } } diff --git a/src/treeView.ts b/src/treeView.ts index b8f76bc..e8a25ba 100644 --- a/src/treeView.ts +++ b/src/treeView.ts @@ -14,7 +14,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this.data = []; this.targetSelector = targetSelector; this.addDefaultTreeItems(); - this.updateAppAndTargetLabels(); + this.updateDynamicLabels(); this.fileDecorationProvider = new ViewFileDecorationProvider(this); vscode.window.registerFileDecorationProvider(this.fileDecorationProvider); } @@ -62,6 +62,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { rootItem.iconPath = new vscode.ThemeIcon("tools"); } if (rootItem.label?.toString().startsWith("Functional")) { + rootItem.contextValue = "functionalTests"; rootItem.iconPath = new vscode.ThemeIcon("test-view-icon"); } if (rootItem.label?.toString().startsWith("Device")) { @@ -96,7 +97,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } } }); - this.updateAppAndTargetLabels(); + this.updateDynamicLabels(); } private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter< @@ -113,32 +114,30 @@ export class TreeDataProvider implements vscode.TreeDataProvider { // Check functional tests root item exists let testsRootItem = this.data.find((item) => item.label?.toString().startsWith("Functional")); // Check if dependencies item already exists - let addTestDependenciesItem = testsRootItem?.children?.find((item) => - item.label?.toString().startsWith("Add test dependencies") - ); - if (testsRootItem && !addTestDependenciesItem) { + let addTestReqsItem = testsRootItem?.children?.find((item) => item.label?.toString().startsWith("Add test prerequisites")); + if (testsRootItem && !addTestReqsItem) { // Add item to add new test requirements - let addTestDependenciesItem = new TreeItem("Add test dependencies"); - addTestDependenciesItem.tooltip = - "Add Python test dependencies for current app (for instance 'apk add python3-protobuf'). This will be saved in your global configuration."; - addTestDependenciesItem.command = { + let addTestReqsItem = new TreeItem("Add test prerequisites"); + addTestReqsItem.tooltip = + "Add Python tests prerequisites for current app (for instance 'apk add python3-protobuf'). This will be saved in your global configuration."; + addTestReqsItem.command = { // Command that let's user input string saved for each app present in workspace - command: "addTestsDependencies", - title: "Add test dependencies", + command: "addTestsPrerequisites", + title: "Add test prerequisites", arguments: [], }; - addTestDependenciesItem.iconPath = new vscode.ThemeIcon("circle-filled"); - addTestDependenciesItem.resourceUri = vscode.Uri.from({ + addTestReqsItem.iconPath = new vscode.ThemeIcon("circle-filled"); + addTestReqsItem.resourceUri = vscode.Uri.from({ scheme: "devtools-treeview", authority: "task", path: "/" + testsRootItem.label + "/enabled", }); - testsRootItem.addChild(addTestDependenciesItem); - } else if (testsRootItem && addTestDependenciesItem) { - // Move addTestDependenciesItem item to the end of the list - testsRootItem.children?.splice(testsRootItem.children?.indexOf(addTestDependenciesItem), 1); + testsRootItem.addChild(addTestReqsItem); + } else if (testsRootItem && addTestReqsItem) { + // Move addTestReqsItem item to the end of the list + testsRootItem.children?.splice(testsRootItem.children?.indexOf(addTestReqsItem), 1); } } @@ -176,17 +175,23 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this.refresh(); } - public updateAppAndTargetLabels(): void { + public updateDynamicLabels(): void { const currentApp = getSelectedApp(); if (currentApp) { let selectTargetItem = this.data.find((item) => item.label && item.label.toString().startsWith("Select target")); let selectAppItem = this.data.find((item) => item.label && item.label.toString().startsWith("Select app")); + let functionalTestsItem = this.data.find((item) => item.label && item.label.toString().startsWith("Functional")); if (selectAppItem) { - selectAppItem.label = `Select app [${currentApp.appFolderName}]`; + selectAppItem.label = `Select app [${currentApp.folderName}]`; } if (selectTargetItem) { selectTargetItem.label = `Select target [${this.targetSelector.getSelectedTarget()}]`; } + if (functionalTestsItem && currentApp.selectedTestUseCase) { + functionalTestsItem.label = `Functional Tests [${currentApp.selectedTestUseCase.name}]`; + } else if (functionalTestsItem) { + functionalTestsItem.label = `Functional Tests`; + } } else { // Remove all tree items. The welcome view will be displayed instead. this.data = [];