From 65b870f8acb92ef1e17aef63e7b0ffc63a34e7f4 Mon Sep 17 00:00:00 2001 From: msivasubramaniaan Date: Sat, 30 May 2026 20:04:31 +0530 Subject: [PATCH] Replaced odo run command Fixes: #5776 Authored-by: msivasubramaniaan Signed-off-by: Victor Rubezhny --- .vscode/launch.json | 4 +- src/cli.ts | 8 +- .../devfileRegistryWrapper.ts | 2 +- src/devfile/commandResolver.ts | 29 +++++ src/devfile/compositeCommand.ts | 24 ++++ src/devfile/devfileCommandRunner.ts | 70 +++++++++++ src/devfile/execCommand.ts | 47 +++++++ src/devfile/parallelCompositeCommand.ts | 27 ++++ src/devfile/variableResolver.ts | 68 ++++++++++ src/explorer.ts | 3 +- src/k8s/vfs/kuberesources.utils.ts | 4 +- src/oc/ocWrapper.ts | 35 +++++- src/odo/command.ts | 4 - src/odo/componentTypeDescription.ts | 1 + src/openshift/component.ts | 26 ++-- src/openshift/openshiftItem.ts | 2 +- src/openshift/serviceHelpers.ts | 2 +- src/serverlessFunction/functions.ts | 3 +- src/serverlessFunction/knative.ts | 3 +- src/tekton/tekton.ts | 3 +- src/util/kubeUtils.ts | 4 +- src/util/utils.ts | 7 ++ test/integration/command.test.ts | 66 +++++----- test/ui/suite/componentCommands.ts | 56 +++++++-- test/unit/openshift/component.test.ts | 118 +++++++++++++++++- 25 files changed, 530 insertions(+), 86 deletions(-) create mode 100644 src/devfile/commandResolver.ts create mode 100644 src/devfile/compositeCommand.ts create mode 100644 src/devfile/devfileCommandRunner.ts create mode 100644 src/devfile/execCommand.ts create mode 100644 src/devfile/parallelCompositeCommand.ts create mode 100644 src/devfile/variableResolver.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index c63fda73e..10d386e7b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -104,7 +104,7 @@ "internalConsoleOptions": "neverOpen" }, { - "type": "pwa-chrome", + "type": "chrome", "request": "launch", "name": "Launch 'Cluster Editor' in Chrome", "file": "${workspaceFolder}/out/clusterViewer/index.html", @@ -112,7 +112,7 @@ "trace": true }, { - "type": "pwa-chrome", + "type": "chrome", "request": "launch", "name": "Launch 'Devfile Registry viewer' in Chrome", "file": "${workspaceFolder}/out/devFileRegistryViewer/index.html", diff --git a/src/cli.ts b/src/cli.ts index a4e2fe7bf..89c8028fc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,15 +8,9 @@ import * as cp from 'child_process'; import { CommandText } from './base/command'; import { ToolsConfig } from './tools'; import { ChildProcessUtil, CliExitData } from './util/childProcessUtil'; -import { hash } from './util/utils'; +import { ExecutionContext } from './util/utils'; import { VsCommandError } from './vscommand'; -export class ExecutionContext extends Map { - public static key(value: string): string { - return hash(value); - } -} - export class CliChannel { private static telemetrySettings = new VSCodeSettings(); diff --git a/src/devfile-registry/devfileRegistryWrapper.ts b/src/devfile-registry/devfileRegistryWrapper.ts index 5d6835b4e..d7f2a5d6e 100644 --- a/src/devfile-registry/devfileRegistryWrapper.ts +++ b/src/devfile-registry/devfileRegistryWrapper.ts @@ -5,9 +5,9 @@ import { get as httpGet } from 'http'; import { get as httpsGet } from 'https'; import * as YAML from 'js-yaml'; -import { ExecutionContext } from '../cli'; import { Registry } from '../odo/componentType'; import { OdoPreference } from '../odo/odoPreference'; +import { ExecutionContext } from '../util/utils'; import { DevfileData, DevfileInfo } from './devfileInfo'; export const DEVFILE_VERSION_LATEST: string = 'latest'; diff --git a/src/devfile/commandResolver.ts b/src/devfile/commandResolver.ts new file mode 100644 index 000000000..6212ad9aa --- /dev/null +++ b/src/devfile/commandResolver.ts @@ -0,0 +1,29 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ +import { Command, Data } from '../odo/componentTypeDescription'; + +export class CommandResolver { + public static getCommand(devfile: Data, commandId: string): Command { + const command = devfile.commands.find( + (c) => c.id.toLowerCase() === commandId.toLowerCase(), + ); + + if (!command) { + throw new Error(`Command '${commandId}' not found`); + } + + return command; + } + + public static getAllCommandsMap(devfile: Data): Map { + const map = new Map(); + + for (const command of devfile.commands) { + map.set(command.id.toLowerCase(), command); + } + + return map; + } +} diff --git a/src/devfile/compositeCommand.ts b/src/devfile/compositeCommand.ts new file mode 100644 index 000000000..186d329d2 --- /dev/null +++ b/src/devfile/compositeCommand.ts @@ -0,0 +1,24 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { Command } from '../odo/componentTypeDescription'; +import { DevfileCommandRunner } from './devfileCommandRunner'; + +export class CompositeCommand { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + + for (const childId of command.composite.commands) { + await DevfileCommandRunner.execute( + componentFolder, + childId, + ); + } + } +} diff --git a/src/devfile/devfileCommandRunner.ts b/src/devfile/devfileCommandRunner.ts new file mode 100644 index 000000000..c6210aa73 --- /dev/null +++ b/src/devfile/devfileCommandRunner.ts @@ -0,0 +1,70 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { Command } from '../odo/componentTypeDescription'; +import { CommandResolver } from './commandResolver'; +import { ExecCommandExecutor } from './execCommand'; + +export class DevfileCommandRunner { + public static async execute( + componentFolder: ComponentWorkspaceFolder, + commandId: string, + ): Promise { + const devfile = componentFolder.component.devfileData.devfile; + + const command = CommandResolver.getCommand(devfile, commandId); + + await this.executeCommand(componentFolder, command); + } + + private static async executeCommand( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + if (command.exec) { + await ExecCommandExecutor.execute(componentFolder, command.id, command.exec); + + return; + } + + if (command.composite) { + const devfile = componentFolder.component.devfileData.devfile; + + const commandMap = CommandResolver.getAllCommandsMap(devfile); + + const children = command.composite.commands.map((id) => { + const child = commandMap.get(id.toLowerCase()); + + if (!child) { + throw new Error(`Command '${id}' not found`); + } + + return child; + }); + + const isParallel = + ( + command.composite as { + parallel?: boolean; + } + ).parallel === true; + + if (isParallel) { + await Promise.all( + children.map((child) => this.executeCommand(componentFolder, child)), + ); + } else { + for (const child of children) { + await this.executeCommand(componentFolder, child); + } + } + + return; + } + + throw new Error(`Unsupported command '${command.id}'`); + } +} diff --git a/src/devfile/execCommand.ts b/src/devfile/execCommand.ts new file mode 100644 index 000000000..e1531379b --- /dev/null +++ b/src/devfile/execCommand.ts @@ -0,0 +1,47 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { CommandOption, CommandText } from '../base/command'; +import { Oc } from '../oc/ocWrapper'; +import { Exec } from '../odo/componentTypeDescription'; +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; +import { DevfileResolver } from './devfileResolver'; +import { VariableResolver } from './variableResolver'; + +export class ExecCommandExecutor { + public static async execute( + componentFolder: ComponentWorkspaceFolder, + commandId: string, + exec: Exec, + ): Promise { + const rawDevfile = componentFolder.component.devfileData.devfile; + + const resolver = new DevfileResolver(); + const devfile = await resolver.resolve(rawDevfile); + + const resolvedExec = VariableResolver.resolveExec(devfile, exec); + + const componentName = devfile.metadata.name; + + const podName = await Oc.Instance.getComponentPod(componentName); + + const command = new CommandText('oc', 'exec', [ + new CommandOption(podName), + new CommandOption('-c'), + new CommandOption(resolvedExec.component), + new CommandOption('--'), + new CommandOption('sh'), + new CommandOption('-c'), + new CommandOption(`cd ${resolvedExec.workingDir} && ${resolvedExec.commandLine}`), + ]); + + void OpenShiftTerminalManager.getInstance().createTerminal( + command, + `Component ${componentName}: Run '${commandId}' Command`, + componentFolder.contextPath, + ); + } +} diff --git a/src/devfile/parallelCompositeCommand.ts b/src/devfile/parallelCompositeCommand.ts new file mode 100644 index 000000000..48f4b324d --- /dev/null +++ b/src/devfile/parallelCompositeCommand.ts @@ -0,0 +1,27 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { Command } from '../odo/componentTypeDescription'; +import { ComponentWorkspaceFolder } from '../odo/workspace'; +import { DevfileCommandRunner } from './devfileCommandRunner'; + +export class ParallelCompositeCommand { + + public static async execute( + componentFolder: ComponentWorkspaceFolder, + command: Command, + ): Promise { + + await Promise.all( + command.composite.commands.map( + childId => + DevfileCommandRunner.execute( + componentFolder, + childId, + ), + ), + ); + } +} diff --git a/src/devfile/variableResolver.ts b/src/devfile/variableResolver.ts new file mode 100644 index 000000000..f0778e8ce --- /dev/null +++ b/src/devfile/variableResolver.ts @@ -0,0 +1,68 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { Container, Data, Exec } from '../odo/componentTypeDescription'; + +export class VariableResolver { + private static readonly VARIABLE_REGEX = /\$\{([^}]+)\}/g; + + public static resolveExec(devfile: Data, exec: Exec): Exec { + return { + ...exec, + workingDir: this.resolveValue(devfile, exec.workingDir ?? '/projects', exec.component), + commandLine: this.resolveValue(devfile, exec.commandLine, exec.component), + }; + } + + public static resolveValue(devfile: Data, value: string, componentName?: string): string { + if (!value) { + return value; + } + + return value.replace(this.VARIABLE_REGEX, (_, variable) => + this.resolveVariable(devfile, variable, componentName), + ); + } + + private static resolveVariable( + devfile: Data, + variable: string, + componentName?: string, + ): string { + if (variable === 'PROJECT_SOURCE') { + return '/projects'; + } + + if (componentName) { + const component = devfile.components.find((c) => c.name === componentName); + + const container = component?.container; + + const envValue = this.findEnvValue(container, variable); + + if (envValue) { + return envValue; + } + } + + return process.env[variable] ?? `\${${variable}}`; + } + + private static findEnvValue( + container: Container | undefined, + variable: string, + ): string | undefined { + const env = ( + container as unknown as { + env?: { + name: string; + value: string; + }[]; + } + )?.env; + + return env?.find((e) => e.name === variable)?.value; + } +} diff --git a/src/explorer.ts b/src/explorer.ts index 4071ba732..afbbca42a 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -25,7 +25,6 @@ import { workspace } from 'vscode'; import { CommandOption, CommandText } from './base/command'; -import { ExecutionContext } from './cli'; import * as Helm from './helm/helm'; import { HelmRepo } from './helm/helmChartType'; import { getOutputFormat, helmfsUri, kubefsUri } from './k8s/vfs/kuberesources.utils'; @@ -37,7 +36,7 @@ import { getKubeConfigFiles, getNamespaceKind, isOpenShiftCluster, KubeConfigInf import { LoginUtil } from './util/loginUtil'; import { Platform } from './util/platform'; import { Progress } from './util/progress'; -import { imagePath } from './util/utils'; +import { ExecutionContext, imagePath } from './util/utils'; import { FileContentChangeNotifier, WatchUtil } from './util/watch'; import { vsCommand } from './vscommand'; import { CustomResourceDefinitionStub, K8sResourceKind } from './webview/common/createServiceTypes'; diff --git a/src/k8s/vfs/kuberesources.utils.ts b/src/k8s/vfs/kuberesources.utils.ts index d2c3f4f7d..536f2aad6 100644 --- a/src/k8s/vfs/kuberesources.utils.ts +++ b/src/k8s/vfs/kuberesources.utils.ts @@ -9,9 +9,9 @@ import * as _ from 'lodash'; import { Diagnostic, DiagnosticSeverity, FileStat, FileType, Range, TextDocument, Uri, workspace } from 'vscode'; import { Document, isMap, isPair, isScalar, isSeq, Pair, parse, ParsedNode, parseDocument, stringify } from 'yaml'; import { CommandOption, CommandText } from '../../base/command'; -import { CliChannel, ExecutionContext } from '../../cli'; +import { CliChannel } from '../../cli'; import { Oc } from '../../oc/ocWrapper'; -import { YAML_STRINGIFY_OPTIONS } from '../../util/utils'; +import { ExecutionContext, YAML_STRINGIFY_OPTIONS } from '../../util/utils'; export const K8S_RESOURCE_SCHEME = 'osmsx'; // Changed from 'k8smsx' to 'osmsx' to not make a conflict with k8s extension export const K8S_RESOURCE_SCHEME_READONLY = 'osmsxro'; // Changed from 'k8smsxro' to 'osmsxro' to not make a conflict with k8s extension diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index ae372e21b..11c883c65 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -6,16 +6,17 @@ import { Cluster, Context, User } from '@kubernetes/client-node'; import { KubernetesObject } from '@kubernetes/client-node/dist/types'; import * as fs from 'fs/promises'; +import path from 'path'; import * as tmp from 'tmp'; import validator from 'validator'; import { CommandOption, CommandText } from '../base/command'; -import { CliChannel, ExecutionContext } from '../cli'; +import { CliChannel } from '../cli'; import { CliExitData } from '../util/childProcessUtil'; import { isOpenShiftCluster, KubeConfigInfo, loadKubeConfig, serializeKubeConfig } from '../util/kubeUtils'; +import { ExecutionContext } from '../util/utils'; +import { findDevfiles, getComponentName } from './devfileUtils'; import { Project } from './project'; import { ClusterType, KubernetesConsole } from './types'; -import { findDevfiles, getComponentName } from './devfileUtils'; -import path from 'path'; /** * A wrapper around the `oc` CLI tool. @@ -1131,4 +1132,32 @@ export class Oc { await this.deleteOdoFiles(componentPath, componentName); } + + public async getComponentPod(componentName: string): Promise { + + const selectors = [ + `app.kubernetes.io/instance=${componentName}`, + `app.kubernetes.io/component=${componentName}`, + `component=${componentName}`, + `app=${componentName}` + ]; + + for (const selector of selectors) { + + const pods = + await this.getKubernetesObjects( + 'pods', + undefined, + selector + ); + + if (pods.length > 0) { + return pods[0].metadata.name as string; + } + } + + throw new Error( + `No running pod found for component '${componentName}'` + ); + } } diff --git a/src/odo/command.ts b/src/odo/command.ts index 385967405..c94c49e62 100644 --- a/src/odo/command.ts +++ b/src/odo/command.ts @@ -33,8 +33,4 @@ export class Command { } return command; } - - static runComponentCommand(commandId : string): CommandText { - return new CommandText('odo', `run ${commandId}`); - } } diff --git a/src/odo/componentTypeDescription.ts b/src/odo/componentTypeDescription.ts index 2b686812d..cda512f40 100644 --- a/src/odo/componentTypeDescription.ts +++ b/src/odo/componentTypeDescription.ts @@ -189,6 +189,7 @@ export interface Apply { export type Composite = { commands: string[]; + parallel?: boolean; group: Group; } diff --git a/src/openshift/component.ts b/src/openshift/component.ts index bbb4bae09..16f53e382 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -20,6 +20,7 @@ import { vsCommand, VsCommandError } from '../vscommand'; import CreateComponentLoader from '../webview/create-component/createComponentLoader'; import { OpenShiftTerminalApi, OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; import OpenShiftItem, { clusterRequired, projectRequired } from './openshiftItem'; +import { DevfileCommandRunner } from '../devfile/devfileCommandRunner'; function createStartDebuggerResult(language: string, message = '') { const result: any = new String(message); @@ -607,19 +608,28 @@ export class Component extends OpenShiftItem { } @vsCommand('openshift.component.commands.command.run', true) - static runComponentCommand(componentFolder: ComponentWorkspaceFolder): Promise { + static async runComponentCommand(componentFolder: ComponentWorkspaceFolder): Promise { const componentName = componentFolder.component.devfileData.devfile.metadata.name; if ('getCommand' in componentFolder) { + const componentCommand = (componentFolder).getCommand(); - const command = Command.runComponentCommand(componentCommand.id); - void OpenShiftTerminalManager.getInstance().createTerminal( - command, - `Component ${componentName}: Run '${componentCommand.id}' Command`, - componentFolder.contextPath, + + if (!componentCommand) { + void window.showErrorMessage( + `No Command found in Component '${componentName}'`, + ); + return; + } + + await DevfileCommandRunner.execute( + componentFolder, + componentCommand.id, ); } else { - void window.showErrorMessage(`No Command found in Component '${componentName}`); + void window.showErrorMessage( + `No Command found in Component '${componentName}'`, + ); } return; - } + } } diff --git a/src/openshift/openshiftItem.ts b/src/openshift/openshiftItem.ts index 26595510d..cec2b2532 100644 --- a/src/openshift/openshiftItem.ts +++ b/src/openshift/openshiftItem.ts @@ -4,12 +4,12 @@ *-----------------------------------------------------------------------------------------------*/ import { commands, QuickPickItem, window } from 'vscode'; -import { ExecutionContext } from '../cli'; import { Oc } from '../oc/ocWrapper'; import { Project } from '../oc/project'; import { ServerlessFunctionView } from '../serverlessFunction/view'; import { inputValue } from '../util/inputValue'; import { getNamespaceKind } from '../util/kubeUtils'; +import { ExecutionContext } from '../util/utils'; import * as NameValidator from './nameValidator'; export class QuickPickCommand implements QuickPickItem { diff --git a/src/openshift/serviceHelpers.ts b/src/openshift/serviceHelpers.ts index 4b86e6acc..86c486f22 100644 --- a/src/openshift/serviceHelpers.ts +++ b/src/openshift/serviceHelpers.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ -import { ExecutionContext } from '../cli'; import { K8sResourceKind } from '../k8s/olm/types'; import { Oc } from '../oc/ocWrapper'; +import { ExecutionContext } from '../util/utils'; import { ClusterServiceVersion, CustomResourceDefinitionStub, diff --git a/src/serverlessFunction/functions.ts b/src/serverlessFunction/functions.ts index eb8c0d89f..d435ff51d 100644 --- a/src/serverlessFunction/functions.ts +++ b/src/serverlessFunction/functions.ts @@ -6,13 +6,14 @@ import Dockerode from 'dockerode'; import validator from 'validator'; import { Uri, commands, window } from 'vscode'; -import { CliChannel, ExecutionContext } from '../cli'; +import { CliChannel } from '../cli'; import { Oc } from '../oc/ocWrapper'; import { Odo } from '../odo/odoWrapper'; import { isTektonAware } from '../tekton/tekton'; import { ChildProcessUtil, CliExitData } from '../util/childProcessUtil'; import { getNamespaceKind, isOpenShiftCluster } from '../util/kubeUtils'; import { Progress } from '../util/progress'; +import { ExecutionContext } from '../util/utils'; import { OpenShiftTerminalApi, OpenShiftTerminalManager } from '../webview/openshift-terminal/openShiftTerminal'; import { ServerlessCommand, Utils } from './commands'; import { GitModel, getGitBranchInteractively, getGitRepoInteractively, getGitStateByPath } from './git/git'; diff --git a/src/serverlessFunction/knative.ts b/src/serverlessFunction/knative.ts index 00109138c..b9706b5f4 100644 --- a/src/serverlessFunction/knative.ts +++ b/src/serverlessFunction/knative.ts @@ -4,7 +4,8 @@ *-----------------------------------------------------------------------------------------------*/ import { CommandText } from '../base/command'; -import { CliChannel, ExecutionContext } from '../cli'; +import { CliChannel } from '../cli'; +import { ExecutionContext } from '../util/utils'; /** * Returns true if the cluster has the Knative Serving CRDs, and false otherwise. diff --git a/src/tekton/tekton.ts b/src/tekton/tekton.ts index a9c86e76f..743e4fc07 100644 --- a/src/tekton/tekton.ts +++ b/src/tekton/tekton.ts @@ -4,7 +4,8 @@ *-----------------------------------------------------------------------------------------------*/ import { CommandText } from '../base/command'; -import { CliChannel, ExecutionContext } from '../cli'; +import { CliChannel } from '../cli'; +import { ExecutionContext } from '../util/utils'; /** * Returns true if the cluster has the Tekton CRDs, and false otherwise. diff --git a/src/util/kubeUtils.ts b/src/util/kubeUtils.ts index b08101971..e25def1ac 100644 --- a/src/util/kubeUtils.ts +++ b/src/util/kubeUtils.ts @@ -10,9 +10,9 @@ import * as path from 'path'; import { QuickPickItem, window } from 'vscode'; import { stringify } from 'yaml'; import { CommandText } from '../base/command'; -import { CliChannel, ExecutionContext } from '../cli'; +import { CliChannel } from '../cli'; import { Platform } from './platform'; -import { YAML_STRINGIFY_OPTIONS } from './utils'; +import { ExecutionContext, YAML_STRINGIFY_OPTIONS } from './utils'; function fileExists(file: string): boolean { try { diff --git a/src/util/utils.ts b/src/util/utils.ts index f4edeacb1..b5698276e 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -17,6 +17,7 @@ import { access as accessOriginal, rm as rmOriginal } from 'fs/promises'; import * as path from 'path'; import { decompress as decompressOriginal } from 'targz'; import { CreateNodeOptions, DocumentOptions, ParseOptions, SchemaOptions, ToStringOptions } from 'yaml'; + /** * Returns the absolute path for a specified relative image path * @@ -48,6 +49,12 @@ export function hashFile(filePath, algorithm = 'sha256') { }); } +export class ExecutionContext extends Map { + public static key(value: string): string { + return hash(value); + } +} + /** * YAML serialization options tailored for Kubernetes manifests and similar configs: * - sortMapEntries: true → ensures object keys are sorted for consistency (useful for diffs) diff --git a/test/integration/command.test.ts b/test/integration/command.test.ts index 5430628b9..6f711e743 100644 --- a/test/integration/command.test.ts +++ b/test/integration/command.test.ts @@ -15,11 +15,13 @@ import { EventEmitter, Terminal, window, workspace } from 'vscode'; import { parse, stringify } from 'yaml'; import { CommandText } from '../../src/base/command'; import { CliChannel } from '../../src/cli'; +import { DevfileCommandRunner } from '../../src/devfile/devfileCommandRunner'; import { odoInit } from '../../src/devfile/init'; import { Oc } from '../../src/oc/ocWrapper'; import { Command } from '../../src/odo/command'; import { OdoPreference } from '../../src/odo/odoPreference'; import { Odo } from '../../src/odo/odoWrapper'; +import { ComponentWorkspaceFolder } from '../../src/odo/workspace'; import { LoginUtil } from '../../src/util/loginUtil'; import { YAML_STRINGIFY_OPTIONS } from '../../src/util/utils'; @@ -272,34 +274,6 @@ suite('odo commands integration', function () { const helloWorldCommandOutput = 'Hello, World!'; const helloWorldCommandExecCommandLine = `echo "${helloWorldCommandOutput}"`; - async function runComponentCommandInTerminal(commandId: string, cwd?) : Promise { - let termOutput = ''; - let termError = ''; - const term = executeCommandInTerminal(Command.runComponentCommand(commandId), cwd, { - onOutput(data) { - termOutput = termOutput.concat(data); - }, - onError(data) { - termError = termError.concat(data); - } - }); - - let hopesLeft = 30; - let commandIdRunning = false; - do { - hopesLeft--; - await new Promise(resolve => setTimeout(resolve, 2000)); - commandIdRunning = termOutput.indexOf(helloWorldCommandOutput) >= -1; - } while (hopesLeft > 0 && !commandIdRunning); - if (!commandIdRunning) { - if (termError.trim().length > 0) { - fail(`Run Component Command failed: ${termError}`); - } - fail('Waiting for command to start executing is timed out'); - } - return term; - } - async function fixupDevFile(devfilePath: string): Promise { // Parse YAML into an Object, add: // @@ -380,17 +354,35 @@ suite('odo commands integration', function () { fail(`Command '${helloWorldCommandId}' doesn't exist in Component '${componentName}'`); } - let devTerm : Terminal; - let runCommandTerm : Terminal; + let devTerm: Terminal; + try { - devTerm = await startDevInTerminal(componentLocation); - runCommandTerm = await runComponentCommandInTerminal(helloWorldCommandId, componentLocation); + + devTerm = + await startDevInTerminal( + componentLocation + ); + + const componentFolder: ComponentWorkspaceFolder = { + contextPath: componentLocation, + component: componentDescription + }; + + await DevfileCommandRunner.execute( + componentFolder, + helloWorldCommandId + ); + + } catch (err) { + + fail( + err instanceof Error + ? err.message + : String(err) + ); + } finally { - // we instruct the pseudo terminals to close the dev session when any text is sent - if (runCommandTerm) { - runCommandTerm.sendText('exit'); - runCommandTerm.dispose(); - } + if (devTerm) { devTerm.sendText('exit'); devTerm.dispose(); diff --git a/test/ui/suite/componentCommands.ts b/test/ui/suite/componentCommands.ts index 5b7bbe2cd..fd20dc8d7 100644 --- a/test/ui/suite/componentCommands.ts +++ b/test/ui/suite/componentCommands.ts @@ -17,15 +17,17 @@ import { Workbench, } from 'vscode-extension-tester'; import { parse } from 'yaml'; -import { itemExists } from '../common/conditions'; +import { DevfileResolver } from '../../../src/devfile/devfileResolver'; +import { waitForItemStable, warn } from '../common/conditions'; import { VIEWS } from '../common/constants'; import { reloadWindow } from '../common/overdrives'; import { OpenshiftTerminalWebviewView } from '../common/ui/webviewView/openshiftTerminalWebviewView'; export function testComponentCommands(path: string) { describe('Component Commands', function () { + this.timeout(30_000); + let view: SideBarView; - let section: ViewSection; let commands: TreeItem[]; const componentName = 'nodejs-starter'; @@ -46,10 +48,15 @@ export function testComponentCommands(path: string) { } // expect component is running - section = await view.getContent().getSection(VIEWS.components); try { - await itemExists(`${componentName} (dev running)`, section); - } catch { + const componentInDevName = `${componentName} (dev running)`; + const item = await waitForItemStable(getSection, componentInDevName, true, 40_000); + if (!item) { + warn(`Component "${componentName}" not found or isn't running in Dev, skipping tests`); + this.skip(); + } + } catch (err) { + warn('Error in before hook: "Component Commands":', err); this.skip(); } }); @@ -63,14 +70,24 @@ export function testComponentCommands(path: string) { //get expected commands const devfile = fs.readFileSync(pth.join(path, componentName, 'devfile.yaml'), 'utf-8'); const parsedDevfile = parse(devfile) as { [key: string]: any }; + + const resolver = new DevfileResolver(); + const resolvedDevfile = await resolver.resolve(parsedDevfile); + const expectedCommands = []; - parsedDevfile.commands.forEach((command) => { + resolvedDevfile.commands.forEach((command) => { expectedCommands.push(command.id); }); //get component + const section = await getSection(); + expect(section).to.exist; + const components = await section.getVisibleItems(); + expect(components).to.exist; + expect(components).to.have.length.greaterThan(0); + const component = components[0] as TreeItem; await component.expand(); @@ -85,27 +102,46 @@ export function testComponentCommands(path: string) { actualCommands.push(await command.getLabel()); } expect(actualCommands).to.include.members(expectedCommands); + + // The sets of devfile component commands (expectedCommands) and actual component commands (actualCommands) + // are to be the same + expect(actualCommands).to.have.members(expectedCommands); + }); it('Command can be ran', async function () { - //get first command's label and select it + + // get first command's label and select it const commandName = await commands[0].getLabel(); await commands[0].select(); - //Check for action button and click it + // Check for action button and click it const actionButton = await commands[0].getActionButton('Run Command'); + expect(actionButton).to.not.be.undefined; + await actionButton.click(); - //check for openshift terminal tab name const terminal = new OpenshiftTerminalWebviewView(); + + // Wait for the terminal created by Run Command + await new Promise((resolve) => setTimeout(resolve, 5000)); + const terminalTabName = await terminal.getActiveTabName(); + expect(terminalTabName).to.contain( `Component ${componentName}: Run '${commandName}' Command`, ); - //check command run to then end + // Wait for command execution to complete + await new Promise((resolve) => setTimeout(resolve, 5000)); + const terminalText = await terminal.getTerminalText(); + expect(terminalText).to.contain('Press any key to close this terminal'); }); + + async function getSection(): Promise { + return await view.getContent().getSection(VIEWS.components); + } }); } diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts index 7fdeb3bf5..e874afae7 100644 --- a/test/unit/openshift/component.test.ts +++ b/test/unit/openshift/component.test.ts @@ -25,7 +25,8 @@ import * as openShiftComponent from '../../../src/openshift/component'; import { Util } from '../../../src/util/async'; import { Util as fsp } from '../../../src/util/utils'; import { OpenShiftTerminalManager } from '../../../src/webview/openshift-terminal/openShiftTerminal'; -import { comp1Folder } from '../../fixtures'; +import { comp1Folder, comp2Folder } from '../../fixtures'; +import { DevfileCommandRunner } from '../../../src/devfile/devfileCommandRunner'; const { expect } = chai; chai.use(sinonChai); @@ -125,6 +126,92 @@ suite('OpenShift/Component', function () { devForwardedPorts: [] } }; + const componentItem2: ComponentWorkspaceFolder = { + contextPath: comp2Folder, + component: { + devfilePath: `${path.join(fixtureFolder, 'components', 'comp2', 'devfile.yaml')}`, + devfileData: { + devfile: { + schemaVersion: '2.1.0', + metadata: { + name: 'comp2', + version: '2.0.1', + displayName: 'React', + description: 'React is a free and open-source front-end JavaScript library for building user interfaces based on UI components. It is maintained by Meta and a community of individual developers and companies.', + tags: [ + 'Node.js', + 'React' + ], + icon: 'https://raw.githubusercontent.com/devfile-samples/devfile-stack-icons/main/react.svg', + projectType: 'React', + language: 'Typescript', + }, + parent: null, + starterProjects: [ + { + name: 'nodejs-react-starter' + } + ], + components: [ + { + name: 'runtime', + container: { + image: 'registry.access.redhat.com/ubi8/nodejs-16:latest', + memoryLimit: '1024Mi', + endpoints: [ + { + name: 'http-react', + targetPort: 3000 + } + ], + mountSources: false, + volumeMounts: [], + } + } + ], + commands: [ + { + id: 'install', + exec: { + group: { + kind: 'build', + isDefault: true + }, + commandLine: 'npm install', + component: 'runtime', + workingDir: '${PROJECT_SOURCE}' + } + }, + { + id: 'run', + exec: { + group: { + kind: 'run', + isDefault: true + }, + commandLine: 'npm run dev', + component: 'runtime', + workingDir: '${PROJECT_SOURCE}' + } + } + ], + events: { + postStart: [] + } + }, + commands: [], + supportedOdoFeatures: { + debug: true, + deploy: true, + dev: true + } + }, + runningIn: null, + runningOn: null, + managedBy: 'odo', + devForwardedPorts: [] + } + }; let Component: typeof openShiftComponent.Component; let commandStub: sinon.SinonStub; @@ -278,14 +365,14 @@ suite('OpenShift/Component', function () { test('calls the correct odo command', async function () { // As `odo describe` has removed, now we need to check the OpenShift terminal output // instead of stubbing 'executeInTerminal' expecting the command to be invoked on the terminal - const compName = componentItem1.component.devfileData.devfile.metadata.name + const compName = componentItem2.component.devfileData.devfile.metadata.name const writeStub = sinon.stub(); sinon.stub(OpenShiftTerminalManager, 'getInstance').returns({ writeToTerminal: writeStub } as any); - await Component.describe(componentItem1); + await Component.describe(componentItem2); const output = writeStub.firstCall.args[0]; expect(writeStub).calledOnce; @@ -494,4 +581,29 @@ suite('OpenShift/Component', function () { expect(caughtError).not.undefined; }); }); + + suite('runComponentCommand', () => { + + test('executes selected devfile command', async () => { + const executeStub = sandbox.stub( + DevfileCommandRunner, + 'execute' + ).resolves(); + + const commandProvider = { + ...componentItem1, + getCommand: () => ({ + id: 'install' + }) + } as ComponentWorkspaceFolder & CommandProvider; + + await Component.runComponentCommand(commandProvider); + + expect(executeStub).calledOnceWithExactly( + commandProvider, + 'install' + ); + }); + + }); });