From 8d2010afc21cef5dae15d6254c575cf75c89e03e Mon Sep 17 00:00:00 2001 From: Thomas Schmitt Date: Tue, 10 Dec 2024 13:23:54 +0200 Subject: [PATCH] Add support to analyze and package studio projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated uipathcli with UiPath Studio to build, package and analyze studio projects. Added two new plugin commands: - `uipath studio package analyze` - `uipath studio package pack` Implementation: - Created infrastructure to download external plugins like the uipcli. The studio commands download the uipcli to the user cache dir and use it for packaging any studio project. Depending on the targetFramework the uipathcli either downloads the tool chain for building and packaging cross-platform or windows Studio projects. - Added `ExecCmd` abstraction which is used to start processes and can easily be faked in unit tests in order to validate the behavior with different exit codes - Refactored the existing browser launcher to use the `ExecCmd` abstraction - Extended the progress bar rendering to allow displaying a simple bar without any percentage or bytes indicator so that the build process can be visualized without knowing the total time in advance. - Increment the uipathcli version to 2.0. There are no backwards-incompatible changes. The major version increase only indicates that an important new feature has been added. Examples: `uipath studio package analyze --source plugin/studio/projects/crossplatform` ``` analyzing... |██████████ | ``` ``` { "error": null, "status": "Succeeded", "violations": [ ... ] } ``` `uipath studio package pack --source plugin/studio/projects/crossplatform --destination . --debug` ``` uipcli Information: 0 : Packing project(s) at path plugin\studio\projects\crossplatform\project.json... uipcli Information: 0 : Orchestrator information is not provided, hence, orchestrator feeds will not be used. uipcli Information: 0 : Proceeding with the local feeds... uipcli Information: 0 : Detected schema version 4.0 ... uipcli Information: 0 : Packaged project MyProcess v1.0.2 saved to MyProcess.1.0.2.nupkg. ``` ``` { "description": "Blank Process", "error": null, "name": "MyProcess", "output": "MyProcess.1.0.2.nupkg", "projectId": "9011ee47-8dd4-4726-8850-299bd6ef057c", "status": "Succeeded", "version": "1.0.2" } ``` The following snippet allows users to package a local UiPath studio project, upload the package, create a release and run a job: ``` uipath studio package pack --source plugin/studio/projects/crossplatform --destination . --package-version 1.0.0 uipath orchestrator processes upload-package --file MyProcess.1.0.0.nupkg $folderId = uipath orchestrator folders get --query "value[0].Id" $releaseKey = uipath orchestrator releases post --folder-id $folderId --name "MyProcess" --process-key "MyProcess" --process-version "1.0.0" --query "Key" --output text $jobId = uipath orchestrator jobs start-jobs --folder-id $folderId --start-info "ReleaseKey=$releaseKey" --query "value[0].Id" --output text uipath orchestrator jobs get-by-id --folder-id $folderId --key $jobId ``` --- .github/workflows/ci.yaml | 2 +- auth/browser_launcher.go | 36 +- auth/browser_launcher_darwin.go | 11 + auth/browser_launcher_linux.go | 11 + auth/browser_launcher_windows.go | 11 + auth/exec_browser_launcher.go | 47 --- auth/oauth_authenticator_test.go | 30 +- cache/file_cache.go | 11 +- definitions/studio.yaml | 14 + executor/http_executor.go | 2 +- log/debug_logger.go | 4 + log/default_logger.go | 3 + log/logger.go | 1 + main.go | 11 +- plugin/digitizer/digitize_command.go | 31 +- plugin/digitizer/digitizer_plugin_test.go | 22 +- plugin/external_plugin.go | 108 +++++ plugin/orchestrator/download_command.go | 37 +- .../orchestrator/orchestrator_plugin_test.go | 42 +- plugin/orchestrator/upload_command.go | 51 ++- plugin/studio/analyze_result_json.go | 25 ++ plugin/studio/package_analyze_command.go | 288 ++++++++++++++ plugin/studio/package_analyze_result.go | 38 ++ plugin/studio/package_pack_command.go | 270 +++++++++++++ plugin/studio/package_pack_params.go | 22 ++ plugin/studio/package_pack_result.go | 19 + plugin/studio/projects/.gitignore | 6 + .../studio/projects/crossplatform/Main.xaml | 87 +++++ .../projects/crossplatform/project.json | 61 +++ plugin/studio/projects/windows/Main.xaml | 94 +++++ plugin/studio/projects/windows/project.json | 62 +++ plugin/studio/studio_plugin_notwin_test.go | 83 ++++ plugin/studio/studio_plugin_test.go | 368 ++++++++++++++++++ plugin/studio/studio_plugin_windows_test.go | 153 ++++++++ plugin/studio/studio_project_json.go | 8 + plugin/studio/studio_project_reader.go | 44 +++ plugin/studio/target_framework.go | 8 + plugin/studio/uipcli.go | 66 ++++ plugin/zip_archive.go | 75 ++++ test/setup.go | 2 +- utils/directories.go | 43 ++ utils/exec_cmd.go | 11 + utils/exec_custom_cmd.go | 35 ++ utils/exec_custom_process.go | 33 ++ utils/exec_default_cmd.go | 30 ++ utils/exec_default_process.go | 14 + utils/exec_process.go | 5 + utils/progress_bar.go | 36 +- utils/progress_bar_test.go | 52 ++- 49 files changed, 2341 insertions(+), 182 deletions(-) create mode 100644 auth/browser_launcher_darwin.go create mode 100644 auth/browser_launcher_linux.go create mode 100644 auth/browser_launcher_windows.go delete mode 100644 auth/exec_browser_launcher.go create mode 100644 definitions/studio.yaml create mode 100644 plugin/external_plugin.go create mode 100644 plugin/studio/analyze_result_json.go create mode 100644 plugin/studio/package_analyze_command.go create mode 100644 plugin/studio/package_analyze_result.go create mode 100644 plugin/studio/package_pack_command.go create mode 100644 plugin/studio/package_pack_params.go create mode 100644 plugin/studio/package_pack_result.go create mode 100644 plugin/studio/projects/.gitignore create mode 100644 plugin/studio/projects/crossplatform/Main.xaml create mode 100644 plugin/studio/projects/crossplatform/project.json create mode 100644 plugin/studio/projects/windows/Main.xaml create mode 100644 plugin/studio/projects/windows/project.json create mode 100644 plugin/studio/studio_plugin_notwin_test.go create mode 100644 plugin/studio/studio_plugin_test.go create mode 100644 plugin/studio/studio_plugin_windows_test.go create mode 100644 plugin/studio/studio_project_json.go create mode 100644 plugin/studio/studio_project_reader.go create mode 100644 plugin/studio/target_framework.go create mode 100644 plugin/studio/uipcli.go create mode 100644 plugin/zip_archive.go create mode 100644 utils/directories.go create mode 100644 utils/exec_cmd.go create mode 100644 utils/exec_custom_cmd.go create mode 100644 utils/exec_custom_process.go create mode 100644 utils/exec_default_cmd.go create mode 100644 utils/exec_default_process.go create mode 100644 utils/exec_process.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0aab6fe..9574ed9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI on: [push] env: - UIPATHCLI_BASE_VERSION: "v1.1" + UIPATHCLI_BASE_VERSION: "v2.0" GO_VERSION: "1.22.2" jobs: diff --git a/auth/browser_launcher.go b/auth/browser_launcher.go index 7698c7c..f60aece 100644 --- a/auth/browser_launcher.go +++ b/auth/browser_launcher.go @@ -1,6 +1,36 @@ package auth -// BrowserLauncher interface for opening browser windows. -type BrowserLauncher interface { - Open(url string) error +import ( + "fmt" + "time" + + "github.com/UiPath/uipathcli/utils" +) + +// BrowserLauncher tries to open the default browser on the local system. +type BrowserLauncher struct { + Exec utils.ExecProcess +} + +func (l BrowserLauncher) Open(url string) error { + cmd := l.openBrowser(url) + err := cmd.Start() + if err != nil { + return err + } + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + return fmt.Errorf("Timed out waiting for browser to start") + } +} + +func NewBrowserLauncher() *BrowserLauncher { + return &BrowserLauncher{utils.NewExecProcess()} } diff --git a/auth/browser_launcher_darwin.go b/auth/browser_launcher_darwin.go new file mode 100644 index 0000000..013ef5e --- /dev/null +++ b/auth/browser_launcher_darwin.go @@ -0,0 +1,11 @@ +//go:build darwin + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("open", url) +} diff --git a/auth/browser_launcher_linux.go b/auth/browser_launcher_linux.go new file mode 100644 index 0000000..03d7160 --- /dev/null +++ b/auth/browser_launcher_linux.go @@ -0,0 +1,11 @@ +//go:build linux + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("xdg-open", url) +} diff --git a/auth/browser_launcher_windows.go b/auth/browser_launcher_windows.go new file mode 100644 index 0000000..45ab3b2 --- /dev/null +++ b/auth/browser_launcher_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package auth + +import ( + "github.com/UiPath/uipathcli/utils" +) + +func (l BrowserLauncher) openBrowser(url string) utils.ExecCmd { + return l.Exec.Command("rundll32", "url.dll,FileProtocolHandler", url) +} diff --git a/auth/exec_browser_launcher.go b/auth/exec_browser_launcher.go deleted file mode 100644 index e949029..0000000 --- a/auth/exec_browser_launcher.go +++ /dev/null @@ -1,47 +0,0 @@ -package auth - -import ( - "fmt" - "os/exec" - "runtime" - "time" -) - -// ExecBrowserLauncher is the default implementation for the browser launcher which -// tries to open the default browser on the local system. -type ExecBrowserLauncher struct{} - -func (l ExecBrowserLauncher) Open(url string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "darwin": - cmd = exec.Command("open", url) - default: - return fmt.Errorf("Platform not supported: %s", runtime.GOOS) - } - - err := cmd.Start() - if err != nil { - return err - } - done := make(chan error) - go func() { - done <- cmd.Wait() - }() - - select { - case err := <-done: - return err - case <-time.After(5 * time.Second): - return fmt.Errorf("Timed out waiting for browser to start") - } -} - -func NewExecBrowserLauncher() *ExecBrowserLauncher { - return &ExecBrowserLauncher{} -} diff --git a/auth/oauth_authenticator_test.go b/auth/oauth_authenticator_test.go index 7a9563f..36a3961 100644 --- a/auth/oauth_authenticator_test.go +++ b/auth/oauth_authenticator_test.go @@ -11,10 +11,12 @@ import ( "net/http" "net/http/httptest" "net/url" + "runtime" "strings" "testing" "github.com/UiPath/uipathcli/cache" + "github.com/UiPath/uipathcli/utils" ) func TestOAuthAuthenticatorNotEnabled(t *testing.T) { @@ -25,7 +27,7 @@ func TestOAuthAuthenticatorNotEnabled(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", map[string]string{}) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "" { t.Errorf("Expected no error when oauth flow is skipped, but got: %v", result.Error) @@ -46,7 +48,7 @@ func TestOAuthAuthenticatorPreservesExistingHeaders(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", headers) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "" { t.Errorf("Expected no error when oauth flow is skipped, but got: %v", result.Error) @@ -65,7 +67,7 @@ func TestOAuthAuthenticatorInvalidConfig(t *testing.T) { request := NewAuthenticatorRequest("http:/localhost", map[string]string{}) context := NewAuthenticatorContext("login", config, createIdentityUrl(""), false, false, *request) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(*context) if result.Error != "Invalid oauth authenticator configuration: Invalid value for clientId: '1'" { t.Errorf("Expected error with invalid config, but got: %v", result.Error) @@ -122,7 +124,7 @@ func TestOAuthFlowIsCached(t *testing.T) { performLogin(loginUrl, t) <-resultChannel - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), nil) + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), *NewBrowserLauncher()) result := authenticator.Auth(context) if result.Error != "" { @@ -208,8 +210,15 @@ func TestMissingCodeShowsErrorMessage(t *testing.T) { func callAuthenticator(context AuthenticatorContext) (url.URL, chan AuthenticatorResult) { loginChan := make(chan string) - authenticator := NewOAuthAuthenticator(cache.NewFileCache(), NoOpBrowserLauncher{ - loginUrlChannel: loginChan, + authenticator := NewOAuthAuthenticator(cache.NewFileCache(), BrowserLauncher{ + Exec: utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + switch runtime.GOOS { + case "windows": + loginChan <- args[1] + default: + loginChan <- args[0] + } + }), }) resultChannel := make(chan AuthenticatorResult) @@ -323,12 +332,3 @@ func (i identityServerFake) writeValidationErrorResponse(response http.ResponseW response.WriteHeader(400) _, _ = response.Write([]byte(message)) } - -type NoOpBrowserLauncher struct { - loginUrlChannel chan string -} - -func (l NoOpBrowserLauncher) Open(url string) error { - l.loginUrlChannel <- url - return nil -} diff --git a/cache/file_cache.go b/cache/file_cache.go index 54cbc30..00a8822 100644 --- a/cache/file_cache.go +++ b/cache/file_cache.go @@ -9,11 +9,11 @@ import ( "strconv" "strings" "time" + + "github.com/UiPath/uipathcli/utils" ) const cacheFilePermissions = 0600 -const cacheDirectoryPermissions = 0700 -const cacheDirectory = "uipath" const separator = "|" // The FileCache stores data on disk in order to preserve them across @@ -63,15 +63,12 @@ func (c FileCache) readValue(key string) (int64, string, error) { } func (c FileCache) cacheFilePath(key string) (string, error) { - userCacheDirectory, err := os.UserCacheDir() + cacheDirectory, err := utils.Directories{}.Cache() if err != nil { return "", err } - cacheDirectory := filepath.Join(userCacheDirectory, cacheDirectory) - _ = os.MkdirAll(cacheDirectory, cacheDirectoryPermissions) - hash := sha256.Sum256([]byte(key)) - fileName := fmt.Sprintf("%x.cache", hash) + fileName := fmt.Sprintf("%x", hash) return filepath.Join(cacheDirectory, fileName), nil } diff --git a/definitions/studio.yaml b/definitions/studio.yaml new file mode 100644 index 0000000..6c742bc --- /dev/null +++ b/definitions/studio.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/{organization}/studio_/backend + description: The production url + variables: + organization: + description: The organization name (or id) + default: my-org +paths: + {} \ No newline at end of file diff --git a/executor/http_executor.go b/executor/http_executor.go index bba736a..536f765 100644 --- a/executor/http_executor.go +++ b/executor/http_executor.go @@ -169,7 +169,7 @@ func (e HttpExecutor) progressReader(text string, completedText string, reader i if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } diff --git a/log/debug_logger.go b/log/debug_logger.go index e72aea5..05963c5 100644 --- a/log/debug_logger.go +++ b/log/debug_logger.go @@ -47,6 +47,10 @@ func (l DebugLogger) LogResponse(response ResponseInfo) { fmt.Fprint(l.writer, "\n\n\n") } +func (l DebugLogger) Log(message string) { + fmt.Fprint(l.writer, message) +} + func (l DebugLogger) LogError(message string) { fmt.Fprint(l.writer, message) } diff --git a/log/default_logger.go b/log/default_logger.go index 45c08e1..c14a2d7 100644 --- a/log/default_logger.go +++ b/log/default_logger.go @@ -18,6 +18,9 @@ func (l *DefaultLogger) LogRequest(request RequestInfo) { func (l DefaultLogger) LogResponse(response ResponseInfo) { } +func (l DefaultLogger) Log(message string) { +} + func (l DefaultLogger) LogError(message string) { fmt.Fprint(l.writer, message) } diff --git a/log/logger.go b/log/logger.go index de132df..a65e75f 100644 --- a/log/logger.go +++ b/log/logger.go @@ -8,6 +8,7 @@ package log // The Logger interface which is used to provide additional information to the // user about what operations the CLI is performing. type Logger interface { + Log(message string) LogError(message string) LogRequest(request RequestInfo) LogResponse(response ResponseInfo) diff --git a/main.go b/main.go index 17153b4..7bcdfae 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/UiPath/uipathcli/plugin" plugin_digitizer "github.com/UiPath/uipathcli/plugin/digitizer" plugin_orchestrator "github.com/UiPath/uipathcli/plugin/orchestrator" + plugin_studio "github.com/UiPath/uipathcli/plugin/studio" "github.com/UiPath/uipathcli/utils" ) @@ -27,7 +28,7 @@ var embedded embed.FS func authenticators() []auth.Authenticator { return []auth.Authenticator{ auth.NewPatAuthenticator(), - auth.NewOAuthAuthenticator(cache.NewFileCache(), auth.NewExecBrowserLauncher()), + auth.NewOAuthAuthenticator(cache.NewFileCache(), *auth.NewBrowserLauncher()), auth.NewBearerAuthenticator(cache.NewFileCache()), } } @@ -63,9 +64,11 @@ func main() { commandline.NewDefinitionFileStore(os.Getenv("UIPATH_DEFINITIONS_PATH"), embedded), parser.NewOpenApiParser(), []plugin.CommandPlugin{ - plugin_digitizer.DigitizeCommand{}, - plugin_orchestrator.UploadCommand{}, - plugin_orchestrator.DownloadCommand{}, + plugin_digitizer.NewDigitizeCommand(), + plugin_orchestrator.NewUploadCommand(), + plugin_orchestrator.NewDownloadCommand(), + plugin_studio.NewPackagePackCommand(), + plugin_studio.NewPackageAnalyzeCommand(), }, ), *configProvider, diff --git a/plugin/digitizer/digitize_command.go b/plugin/digitizer/digitize_command.go index 6b847be..c0495dd 100644 --- a/plugin/digitizer/digitize_command.go +++ b/plugin/digitizer/digitize_command.go @@ -129,12 +129,9 @@ func (c DigitizeCommand) createDigitizeRequest(context plugin.ExecutionContext, var err error file := context.Input if file == nil { - file, err = c.getFileParameter(context.Parameters) - if err != nil { - return nil, err - } + file = c.getFileParameter(context.Parameters) } - contentType, _ := c.getParameter("content-type", context.Parameters) + contentType := c.getParameter("content-type", context.Parameters) if contentType == "" { contentType = "application/octet-stream" } @@ -164,7 +161,7 @@ func (c DigitizeCommand) progressReader(text string, completedText string, reade if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -262,33 +259,37 @@ func (c DigitizeCommand) sendRequest(request *http.Request, insecure bool) (*htt } func (c DigitizeCommand) getProjectId(parameters []plugin.ExecutionParameter) string { - projectId, _ := c.getParameter("project-id", parameters) + projectId := c.getParameter("project-id", parameters) if projectId == "" { projectId = "00000000-0000-0000-0000-000000000000" } return projectId } -func (c DigitizeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c DigitizeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c DigitizeCommand) getFileParameter(parameters []plugin.ExecutionParameter) (utils.Stream, error) { +func (c DigitizeCommand) getFileParameter(parameters []plugin.ExecutionParameter) utils.Stream { + var result utils.Stream for _, p := range parameters { if p.Name == "file" { if stream, ok := p.Value.(utils.Stream); ok { - return stream, nil + result = stream + break } } } - return nil, fmt.Errorf("Could not find 'file' parameter") + return result } func (c DigitizeCommand) logRequest(logger log.Logger, request *http.Request) { @@ -304,3 +305,7 @@ func (c DigitizeCommand) logResponse(logger log.Logger, response *http.Response, responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewDigitizeCommand() *DigitizeCommand { + return &DigitizeCommand{} +} diff --git a/plugin/digitizer/digitizer_plugin_test.go b/plugin/digitizer/digitizer_plugin_test.go index 37d55ff..b4f3512 100644 --- a/plugin/digitizer/digitizer_plugin_test.go +++ b/plugin/digitizer/digitizer_plugin_test.go @@ -20,7 +20,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234"}, context) @@ -47,7 +47,7 @@ paths: context := test.NewContextBuilder(). WithConfig(config). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234", "--file", "does-not-exist"}, context) @@ -67,7 +67,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--project-id", "1234", "--file", "hello-world"}, context) @@ -87,7 +87,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). Build() result := test.RunCli([]string{"du", "digitization", "digitize", "--organization", "myorg", "--project-id", "1234", "--file", "hello-world"}, context) @@ -117,7 +117,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(400, "validation error"). Build() @@ -148,7 +148,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"04908673-2b65-4647-8ab3-dde8a3aa7885"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/04908673-2b65-4647-8ab3-dde8a3aa7885?api-version=1", 400, `validation error`). Build() @@ -190,7 +190,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"648ea1c2-7dbe-42a8-b112-6474d07e61c1"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/00000000-0000-0000-0000-000000000000/digitization/result/648ea1c2-7dbe-42a8-b112-6474d07e61c1?api-version=1", 200, `{"status":"Done"}`). Build() @@ -236,7 +236,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). Build() @@ -272,7 +272,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"pages":[],"status":"Done"}`). Build() @@ -313,7 +313,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithStdIn(stdIn). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). @@ -360,7 +360,7 @@ paths: context := test.NewContextBuilder(). WithDefinition("du", definition). WithConfig(config). - WithCommandPlugin(DigitizeCommand{}). + WithCommandPlugin(NewDigitizeCommand()). WithResponse(202, `{"documentId":"eb80e441-05de-4a13-9aaa-f65b1babba05"}`). WithUrlResponse("/my-org/my-tenant/du_/api/framework/projects/1234/digitization/result/eb80e441-05de-4a13-9aaa-f65b1babba05?api-version=1", 200, `{"status":"Done"}`). Build() diff --git a/plugin/external_plugin.go b/plugin/external_plugin.go new file mode 100644 index 0000000..35d2a8c --- /dev/null +++ b/plugin/external_plugin.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "math" + "math/big" + "net/http" + "os" + "path/filepath" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/utils" +) + +const pluginDirectoryPermissions = 0700 + +type ExternalPlugin struct { + Logger log.Logger +} + +func (p ExternalPlugin) GetTool(name string, url string, executable string) (string, error) { + pluginDirectory, err := p.pluginDirectory(name, url) + if err != nil { + return "", fmt.Errorf("Could not download %s: %v", name, err) + } + path := filepath.Join(pluginDirectory, executable) + if _, err := os.Stat(path); err == nil { + return path, nil + } + + tmpPluginDirectory := pluginDirectory + "-" + p.randomFolderName() + _ = os.MkdirAll(tmpPluginDirectory, pluginDirectoryPermissions) + + progressBar := utils.NewProgressBar(p.Logger) + defer progressBar.Remove() + progressBar.UpdatePercentage("downloading...", 0) + zipArchivePath := filepath.Join(tmpPluginDirectory, name) + err = p.download(name, url, zipArchivePath, progressBar) + if err != nil { + return "", err + } + err = newZipArchive().Extract(zipArchivePath, tmpPluginDirectory, pluginDirectoryPermissions) + if err != nil { + return "", fmt.Errorf("Could not extract %s archive: %v", name, err) + } + os.Remove(zipArchivePath) + err = os.Rename(tmpPluginDirectory, pluginDirectory) + if err != nil { + return "", fmt.Errorf("Could not install %s: %v", name, err) + } + return path, nil +} + +func (p ExternalPlugin) download(name string, url string, destination string, progressBar *utils.ProgressBar) error { + out, err := os.Create(destination) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + defer out.Close() + + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + downloadReader := p.progressReader("downloading...", "installing... ", response.Body, response.ContentLength, progressBar) + _, err = io.Copy(out, downloadReader) + if err != nil { + return fmt.Errorf("Could not download %s: %v", name, err) + } + return nil +} + +func (p ExternalPlugin) progressReader(text string, completedText string, reader io.Reader, length int64, progressBar *utils.ProgressBar) io.Reader { + progressReader := utils.NewProgressReader(reader, func(progress utils.Progress) { + displayText := text + if progress.Completed { + displayText = completedText + } + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) + }) + return progressReader +} + +func (p ExternalPlugin) pluginDirectory(name string, url string) (string, error) { + pluginDirectory, err := utils.Directories{}.Plugin() + if err != nil { + return "", err + } + hash := sha256.Sum256([]byte(url)) + subdirectory := fmt.Sprintf("%s-%x", name, hash) + return filepath.Join(pluginDirectory, subdirectory), nil +} + +func (p ExternalPlugin) randomFolderName() string { + value, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + return value.String() +} + +func NewExternalPlugin(logger log.Logger) *ExternalPlugin { + return &ExternalPlugin{logger} +} diff --git a/plugin/orchestrator/download_command.go b/plugin/orchestrator/download_command.go index c0dfb6c..7818114 100644 --- a/plugin/orchestrator/download_command.go +++ b/plugin/orchestrator/download_command.go @@ -77,7 +77,7 @@ func (c DownloadCommand) progressReader(text string, completedText string, reade if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -119,18 +119,9 @@ func (c DownloadCommand) createReadUrlRequest(context plugin.ExecutionContext) ( if context.Tenant == "" { return nil, errors.New("Tenant is not set") } - folderId, err := c.getIntParameter("folder-id", context.Parameters) - if err != nil { - return nil, err - } - bucketId, err := c.getIntParameter("key", context.Parameters) - if err != nil { - return nil, err - } - path, err := c.getStringParameter("path", context.Parameters) - if err != nil { - return nil, err - } + folderId := c.getIntParameter("folder-id", context.Parameters) + bucketId := c.getIntParameter("key", context.Parameters) + path := c.getStringParameter("path", context.Parameters) uri := c.formatUri(context.BaseUri, context.Organization, context.Tenant) + fmt.Sprintf("/odata/Buckets(%d)/UiPath.Server.Configuration.OData.GetReadUri?path=%s", bucketId, path) request, err := http.NewRequest("GET", uri, &bytes.Buffer{}) @@ -182,26 +173,30 @@ func (c DownloadCommand) sendRequest(request *http.Request, insecure bool) (*htt return client.Do(request) } -func (c DownloadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c DownloadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c DownloadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) (int, error) { +func (c DownloadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) int { + result := 0 for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(int); ok { - return data, nil + result = data + break } } } - return 0, fmt.Errorf("Could not find '%s' parameter", name) + return result } func (c DownloadCommand) logRequest(logger log.Logger, request *http.Request) { @@ -217,3 +212,7 @@ func (c DownloadCommand) logResponse(logger log.Logger, response *http.Response, responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewDownloadCommand() *DownloadCommand { + return &DownloadCommand{} +} diff --git a/plugin/orchestrator/orchestrator_plugin_test.go b/plugin/orchestrator/orchestrator_plugin_test.go index 6fcd320..d4d3a99 100644 --- a/plugin/orchestrator/orchestrator_plugin_test.go +++ b/plugin/orchestrator/orchestrator_plugin_test.go @@ -16,7 +16,7 @@ import ( func TestUploadWithoutFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--key", "2", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -29,7 +29,7 @@ func TestUploadWithoutFolderIdParameterShowsValidationError(t *testing.T) { func TestUploadWithoutKeyParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -42,7 +42,7 @@ func TestUploadWithoutKeyParameterShowsValidationError(t *testing.T) { func TestUploadWithInvalidFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "invalid", "--key", "2", "--path", "file.txt", "--file", "does-not-exist"}, context) @@ -55,7 +55,7 @@ func TestUploadWithInvalidFolderIdParameterShowsValidationError(t *testing.T) { func TestUploadWithoutPathParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--file", "does-not-exist"}, context) @@ -68,7 +68,7 @@ func TestUploadWithoutPathParameterShowsValidationError(t *testing.T) { func TestUploadWithoutFileParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -88,7 +88,7 @@ func TestUploadFileDoesNotExistShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithConfig(config). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"http://localhost"}`). Build() @@ -102,7 +102,7 @@ func TestUploadFileDoesNotExistShowsValidationError(t *testing.T) { func TestUploadWithoutOrganizationShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--folder-id", "1", "--key", "2", "--path", "file.txt", "--file", "hello-world"}, context) @@ -115,7 +115,7 @@ func TestUploadWithoutOrganizationShowsValidationError(t *testing.T) { func TestUploadWithoutTenantShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "upload", "--organization", "myorg", "--folder-id", "1", "--key", "2", "--path", "file.txt", "--file", "hello-world"}, context) @@ -138,7 +138,7 @@ func TestUploadWithFailedResponseReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). WithConfig(config). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(400, "validation error"). Build() @@ -197,7 +197,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). WithConfig(config). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -242,7 +242,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -277,7 +277,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(UploadCommand{}). + WithCommandPlugin(NewUploadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+"/upload/file.txt"+`"}`). Build() @@ -294,7 +294,7 @@ servers: func TestDownloadWithoutFolderIdParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--key", "2", "--path", "file.txt"}, context) @@ -307,7 +307,7 @@ func TestDownloadWithoutFolderIdParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutKeyParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--path", "file.txt"}, context) @@ -320,7 +320,7 @@ func TestDownloadWithoutKeyParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutPathParameterShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--key", "2"}, context) @@ -333,7 +333,7 @@ func TestDownloadWithoutPathParameterShowsValidationError(t *testing.T) { func TestDownloadWithoutOrganizationShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -346,7 +346,7 @@ func TestDownloadWithoutOrganizationShowsValidationError(t *testing.T) { func TestDownloadWithoutTenantShowsValidationError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). Build() result := test.RunCli([]string{"orchestrator", "buckets", "download", "--organization", "myorg", "--folder-id", "1", "--key", "2", "--path", "file.txt"}, context) @@ -366,7 +366,7 @@ func TestDownloadWithFailedResponseReturnsError(t *testing.T) { context := test.NewContextBuilder(). WithDefinition("orchestrator", ""). WithConfig(config). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(400, "validation error"). Build() @@ -411,7 +411,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). WithConfig(config). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -452,7 +452,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`"}`). Build() @@ -485,7 +485,7 @@ servers: context := test.NewContextBuilder(). WithDefinition("orchestrator", definition). - WithCommandPlugin(DownloadCommand{}). + WithCommandPlugin(NewDownloadCommand()). WithResponse(200, `{"Uri":"`+srv.URL+`/download/file.txt"}`). Build() diff --git a/plugin/orchestrator/upload_command.go b/plugin/orchestrator/upload_command.go index 10f587b..ba5bb36 100644 --- a/plugin/orchestrator/upload_command.go +++ b/plugin/orchestrator/upload_command.go @@ -70,11 +70,7 @@ func (c UploadCommand) upload(context plugin.ExecutionContext, logger log.Logger func (c UploadCommand) createUploadRequest(context plugin.ExecutionContext, url string, uploadBar *utils.ProgressBar, requestError chan error) (*http.Request, error) { file := context.Input if file == nil { - var err error - file, err = c.getFileParameter(context.Parameters) - if err != nil { - return nil, err - } + file = c.getFileParameter(context.Parameters) } bodyReader, bodyWriter := io.Pipe() contentType, contentLength := c.writeBody(bodyWriter, file, requestError) @@ -118,7 +114,7 @@ func (c UploadCommand) progressReader(text string, completedText string, reader if progress.Completed { displayText = completedText } - progressBar.Update(displayText, progress.BytesRead, length, progress.BytesPerSecond) + progressBar.UpdateProgress(displayText, progress.BytesRead, length, progress.BytesPerSecond) }) return progressReader } @@ -160,18 +156,9 @@ func (c UploadCommand) createWriteUrlRequest(context plugin.ExecutionContext) (* if context.Tenant == "" { return nil, errors.New("Tenant is not set") } - folderId, err := c.getIntParameter("folder-id", context.Parameters) - if err != nil { - return nil, err - } - bucketId, err := c.getIntParameter("key", context.Parameters) - if err != nil { - return nil, err - } - path, err := c.getStringParameter("path", context.Parameters) - if err != nil { - return nil, err - } + folderId := c.getIntParameter("folder-id", context.Parameters) + bucketId := c.getIntParameter("key", context.Parameters) + path := c.getStringParameter("path", context.Parameters) uri := c.formatUri(context.BaseUri, context.Organization, context.Tenant) + fmt.Sprintf("/odata/Buckets(%d)/UiPath.Server.Configuration.OData.GetWriteUri?path=%s", bucketId, path) request, err := http.NewRequest("GET", uri, &bytes.Buffer{}) @@ -223,37 +210,43 @@ func (c UploadCommand) sendRequest(request *http.Request, insecure bool) (*http. return client.Do(request) } -func (c UploadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) (string, error) { +func (c UploadCommand) getStringParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(string); ok { - return data, nil + result = data + break } } } - return "", fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c UploadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) (int, error) { +func (c UploadCommand) getIntParameter(name string, parameters []plugin.ExecutionParameter) int { + result := 0 for _, p := range parameters { if p.Name == name { if data, ok := p.Value.(int); ok { - return data, nil + result = data + break } } } - return 0, fmt.Errorf("Could not find '%s' parameter", name) + return result } -func (c UploadCommand) getFileParameter(parameters []plugin.ExecutionParameter) (utils.Stream, error) { +func (c UploadCommand) getFileParameter(parameters []plugin.ExecutionParameter) utils.Stream { + var result utils.Stream for _, p := range parameters { if p.Name == "file" { if stream, ok := p.Value.(utils.Stream); ok { - return stream, nil + result = stream + break } } } - return nil, fmt.Errorf("Could not find 'file' parameter") + return result } func (c UploadCommand) logRequest(logger log.Logger, request *http.Request) { @@ -269,3 +262,7 @@ func (c UploadCommand) logResponse(logger log.Logger, response *http.Response, b responseInfo := log.NewResponseInfo(response.StatusCode, response.Status, response.Proto, response.Header, bytes.NewReader(body)) logger.LogResponse(*responseInfo) } + +func NewUploadCommand() *UploadCommand { + return &UploadCommand{} +} diff --git a/plugin/studio/analyze_result_json.go b/plugin/studio/analyze_result_json.go new file mode 100644 index 0000000..e034383 --- /dev/null +++ b/plugin/studio/analyze_result_json.go @@ -0,0 +1,25 @@ +package studio + +type analyzeResultJson []struct { + ErrorCode string `json:"ErrorCode"` + Description string `json:"Description"` + RuleName string `json:"RuleName"` + FilePath string `json:"FilePath"` + ActivityId *analyzeResultActivityId `json:"ActivityId"` + ActivityDisplayName string `json:"ActivityDisplayName"` + WorkflowDisplayName string `json:"WorkflowDisplayName"` + Item *analyzeResultItem `json:"Item"` + ErrorSeverity int `json:"ErrorSeverity"` + Recommendation string `json:"Recommendation"` + DocumentationLink string `json:"DocumentationLink"` +} + +type analyzeResultActivityId struct { + Id string `json:"Id"` + IdRef string `json:"IdRef"` +} + +type analyzeResultItem struct { + Name string `json:"Name"` + Type int `json:"Type"` +} diff --git a/plugin/studio/package_analyze_command.go b/plugin/studio/package_analyze_command.go new file mode 100644 index 0000000..5aef3da --- /dev/null +++ b/plugin/studio/package_analyze_command.go @@ -0,0 +1,288 @@ +package studio + +import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +// The PackageAnalyzeCommand runs static code analyis on the project to detect common errors. +type PackageAnalyzeCommand struct { + Exec utils.ExecProcess +} + +func (c PackageAnalyzeCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("package", "Package", "UiPath Studio package-related actions"). + WithOperation("analyze", "Analyze Project", "Runs static code analysis on the project to detect common errors"). + WithParameter("source", plugin.ParameterTypeString, "Path to a project.json file or a folder containing project.json file", true). + WithParameter("treat-warnings-as-errors", plugin.ParameterTypeBoolean, "Treat warnings as errors", false). + WithParameter("stop-on-rule-violation", plugin.ParameterTypeBoolean, "Fail when any rule is violated", false) +} + +func (c PackageAnalyzeCommand) Execute(context plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source, err := c.getSource(context) + if err != nil { + return err + } + treatWarningsAsErrors := c.getBoolParameter("treat-warnings-as-errors", context.Parameters) + stopOnRuleViolation := c.getBoolParameter("stop-on-rule-violation", context.Parameters) + exitCode, result, err := c.execute(source, treatWarningsAsErrors, stopOnRuleViolation, context.Debug, logger) + if err != nil { + return err + } + + json, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("analyze command failed: %v", err) + } + err = writer.WriteResponse(*output.NewResponseInfo(200, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(json))) + if err != nil { + return err + } + if exitCode != 0 { + return errors.New("") + } + return nil +} + +func (c PackageAnalyzeCommand) execute(source string, treatWarningsAsErrors bool, stopOnRuleViolation bool, debug bool, logger log.Logger) (int, *packageAnalyzeResult, error) { + if !debug { + bar := c.newAnalyzingProgressBar(logger) + defer close(bar) + } + + jsonResultFilePath, err := c.getTemporaryJsonResultFilePath() + if err != nil { + return 1, nil, err + } + defer os.Remove(jsonResultFilePath) + + args := []string{"package", "analyze", source, "--resultPath", jsonResultFilePath} + if treatWarningsAsErrors { + args = append(args, "--treatWarningsAsErrors") + } + if stopOnRuleViolation { + args = append(args, "--stopOnRuleViolation") + } + + projectReader := newStudioProjectReader(source) + + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(projectReader.GetTargetFramework(), args...) + if err != nil { + return 1, nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + defer stdout.Close() + stderr, err := cmd.StderrPipe() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + defer stderr.Close() + err = cmd.Start() + if err != nil { + return 1, nil, fmt.Errorf("Could not run analyze command: %v", err) + } + + stderrOutputBuilder := new(strings.Builder) + stderrReader := io.TeeReader(stderr, stderrOutputBuilder) + + var wg sync.WaitGroup + wg.Add(3) + go c.readOutput(stdout, logger, &wg) + go c.readOutput(stderrReader, logger, &wg) + go c.wait(cmd, &wg) + wg.Wait() + + violations, err := c.readAnalyzeResult(jsonResultFilePath) + if err != nil { + return 1, nil, err + } + + exitCode := cmd.ExitCode() + var result *packageAnalyzeResult + if exitCode == 0 { + result = newSucceededPackageAnalyzeResult(violations) + } else { + result = newFailedPackageAnalyzeResult( + violations, + stderrOutputBuilder.String(), + ) + } + return exitCode, result, nil +} + +func (c PackageAnalyzeCommand) getTemporaryJsonResultFilePath() (string, error) { + tempDirectory, err := utils.Directories{}.Temp() + if err != nil { + return "", err + } + fileName := c.randomJsonResultFileName() + return filepath.Join(tempDirectory, fileName), nil +} + +func (c PackageAnalyzeCommand) randomJsonResultFileName() string { + value, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + return "analyzeresult-" + value.String() + ".json" +} + +func (c PackageAnalyzeCommand) readAnalyzeResult(path string) ([]packageAnalyzeViolation, error) { + file, err := os.Open(path) + if err != nil && errors.Is(err, os.ErrNotExist) { + return []packageAnalyzeViolation{}, nil + } + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error reading %s file: %v", filepath.Base(path), err) + } + defer file.Close() + byteValue, err := io.ReadAll(file) + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error reading %s file: %v", filepath.Base(path), err) + } + + var result analyzeResultJson + err = json.Unmarshal(byteValue, &result) + if err != nil { + return []packageAnalyzeViolation{}, fmt.Errorf("Error parsing %s file: %v", filepath.Base(path), err) + } + return c.convertToViolations(result), nil +} + +func (c PackageAnalyzeCommand) convertToViolations(json analyzeResultJson) []packageAnalyzeViolation { + violations := []packageAnalyzeViolation{} + for _, entry := range json { + var activityId *packageAnalyzeActivityId + if entry.ActivityId != nil { + activityId = &packageAnalyzeActivityId{ + Id: entry.ActivityId.Id, + IdRef: entry.ActivityId.IdRef, + } + } + var item *packageAnalyzeItem + if entry.Item != nil { + item = &packageAnalyzeItem{ + Name: entry.Item.Name, + Type: entry.Item.Type, + } + } + violation := packageAnalyzeViolation{ + ErrorCode: entry.ErrorCode, + Description: entry.Description, + RuleName: entry.RuleName, + FilePath: entry.FilePath, + ActivityDisplayName: entry.ActivityDisplayName, + WorkflowDisplayName: entry.WorkflowDisplayName, + ErrorSeverity: entry.ErrorSeverity, + Recommendation: entry.Recommendation, + DocumentationLink: entry.DocumentationLink, + ActivityId: activityId, + Item: item, + } + violations = append(violations, violation) + } + return violations +} + +func (c PackageAnalyzeCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { + defer wg.Done() + _ = cmd.Wait() +} + +func (c PackageAnalyzeCommand) newAnalyzingProgressBar(logger log.Logger) chan struct{} { + progressBar := utils.NewProgressBar(logger) + ticker := time.NewTicker(10 * time.Millisecond) + cancel := make(chan struct{}) + var percent float64 = 0 + go func() { + for { + select { + case <-ticker.C: + progressBar.UpdatePercentage("analyzing... ", percent) + percent = percent + 1 + if percent > 100 { + percent = 0 + } + case <-cancel: + ticker.Stop() + progressBar.Remove() + return + } + } + }() + return cancel +} + +func (c PackageAnalyzeCommand) getSource(context plugin.ExecutionContext) (string, error) { + source := c.getParameter("source", context.Parameters) + if source == "" { + return "", errors.New("source is not set") + } + source, _ = filepath.Abs(source) + fileInfo, err := os.Stat(source) + if err != nil { + return "", fmt.Errorf("%s not found", defaultProjectJson) + } + if fileInfo.IsDir() { + source = filepath.Join(source, defaultProjectJson) + } + return source, nil +} + +func (c PackageAnalyzeCommand) readOutput(output io.Reader, logger log.Logger, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(output) + scanner.Split(bufio.ScanRunes) + for scanner.Scan() { + logger.Log(scanner.Text()) + } +} + +func (c PackageAnalyzeCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func (c PackageAnalyzeCommand) getBoolParameter(name string, parameters []plugin.ExecutionParameter) bool { + result := false + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(bool); ok { + result = data + break + } + } + } + return result +} + +func NewPackageAnalyzeCommand() *PackageAnalyzeCommand { + return &PackageAnalyzeCommand{utils.NewExecProcess()} +} diff --git a/plugin/studio/package_analyze_result.go b/plugin/studio/package_analyze_result.go new file mode 100644 index 0000000..5a58dc2 --- /dev/null +++ b/plugin/studio/package_analyze_result.go @@ -0,0 +1,38 @@ +package studio + +type packageAnalyzeResult struct { + Status string `json:"status"` + Violations []packageAnalyzeViolation `json:"violations"` + Error *string `json:"error"` +} + +type packageAnalyzeViolation struct { + ErrorCode string `json:"errorCode"` + Description string `json:"description"` + RuleName string `json:"ruleName"` + FilePath string `json:"filePath"` + ActivityId *packageAnalyzeActivityId `json:"activityId"` + ActivityDisplayName string `json:"activityDisplayName"` + WorkflowDisplayName string `json:"workflowDisplayName"` + Item *packageAnalyzeItem `json:"item"` + ErrorSeverity int `json:"errorSeverity"` + Recommendation string `json:"recommendation"` + DocumentationLink string `json:"documentationLink"` +} +type packageAnalyzeActivityId struct { + Id string `json:"id"` + IdRef string `json:"idRef"` +} + +type packageAnalyzeItem struct { + Name string `json:"name"` + Type int `json:"type"` +} + +func newSucceededPackageAnalyzeResult(violations []packageAnalyzeViolation) *packageAnalyzeResult { + return &packageAnalyzeResult{"Succeeded", violations, nil} +} + +func newFailedPackageAnalyzeResult(violations []packageAnalyzeViolation, err string) *packageAnalyzeResult { + return &packageAnalyzeResult{"Failed", violations, &err} +} diff --git a/plugin/studio/package_pack_command.go b/plugin/studio/package_pack_command.go new file mode 100644 index 0000000..1f43fcc --- /dev/null +++ b/plugin/studio/package_pack_command.go @@ -0,0 +1,270 @@ +package studio + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/output" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +const defaultProjectJson = "project.json" + +// The PackagePackCommand packs a project into a single NuGet package +type PackagePackCommand struct { + Exec utils.ExecProcess +} + +func (c PackagePackCommand) Command() plugin.Command { + return *plugin.NewCommand("studio"). + WithCategory("package", "Package", "UiPath Studio package-related actions"). + WithOperation("pack", "Package Project", "Packs a project into a single package"). + WithParameter("source", plugin.ParameterTypeString, "Path to a project.json file or a folder containing project.json file", true). + WithParameter("destination", plugin.ParameterTypeString, "The output folder", true). + WithParameter("package-version", plugin.ParameterTypeString, "The package version", false). + WithParameter("auto-version", plugin.ParameterTypeBoolean, "Auto-generate package version", false). + WithParameter("output-type", plugin.ParameterTypeString, "Force the output to a specific type", false). + WithParameter("split-output", plugin.ParameterTypeBoolean, "Enables the output split to runtime and design libraries", false). + WithParameter("release-notes", plugin.ParameterTypeString, "Add release notes", false) +} + +func (c PackagePackCommand) Execute(context plugin.ExecutionContext, writer output.OutputWriter, logger log.Logger) error { + source, err := c.getSource(context) + if err != nil { + return err + } + destination, err := c.getDestination(context) + if err != nil { + return err + } + packageVersion := c.getParameter("package-version", context.Parameters) + autoVersion := c.getBoolParameter("auto-version", context.Parameters) + outputType := c.getParameter("output-type", context.Parameters) + splitOutput := c.getBoolParameter("split-output", context.Parameters) + releaseNotes := c.getParameter("release-notes", context.Parameters) + params := newPackagePackParams(source, destination, packageVersion, autoVersion, outputType, splitOutput, releaseNotes) + + result, err := c.execute(*params, context.Debug, logger) + if err != nil { + return err + } + + json, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("pack command failed: %v", err) + } + return writer.WriteResponse(*output.NewResponseInfo(200, "200 OK", "HTTP/1.1", map[string][]string{}, bytes.NewReader(json))) +} + +func (c PackagePackCommand) execute(params packagePackParams, debug bool, logger log.Logger) (*packagePackResult, error) { + if !debug { + bar := c.newPackagingProgressBar(logger) + defer close(bar) + } + + args := []string{"package", "pack", params.Source, "--output", params.Destination} + if params.PackageVersion != "" { + args = append(args, "--version", params.PackageVersion) + } + if params.AutoVersion { + args = append(args, "--autoVersion") + } + if params.OutputType != "" { + args = append(args, "--outputType", params.OutputType) + } + if params.SplitOutput { + args = append(args, "--splitOutput") + } + if params.ReleaseNotes != "" { + args = append(args, "--releaseNotes", params.ReleaseNotes) + } + + projectReader := newStudioProjectReader(params.Source) + + uipcli := newUipcli(c.Exec, logger) + cmd, err := uipcli.Execute(projectReader.GetTargetFramework(), args...) + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + defer stdout.Close() + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + defer stderr.Close() + err = cmd.Start() + if err != nil { + return nil, fmt.Errorf("Could not run pack command: %v", err) + } + + stderrOutputBuilder := new(strings.Builder) + stderrReader := io.TeeReader(stderr, stderrOutputBuilder) + + var wg sync.WaitGroup + wg.Add(3) + go c.readOutput(stdout, logger, &wg) + go c.readOutput(stderrReader, logger, &wg) + go c.wait(cmd, &wg) + wg.Wait() + + project, err := projectReader.ReadMetadata() + if err != nil { + return nil, err + } + + exitCode := cmd.ExitCode() + var result *packagePackResult + if exitCode == 0 { + nupkgFile := c.findNupkg(params.Destination) + version := c.extractVersion(nupkgFile) + result = newSucceededPackagePackResult( + filepath.Join(params.Destination, nupkgFile), + project.Name, + project.Description, + project.ProjectId, + version) + } else { + result = newFailedPackagePackResult( + stderrOutputBuilder.String(), + &project.Name, + &project.Description, + &project.ProjectId) + } + return result, nil +} + +func (c PackagePackCommand) findNupkg(destination string) string { + newestFile := "" + newestTime := time.Time{} + + files, _ := os.ReadDir(destination) + for _, file := range files { + extension := filepath.Ext(file.Name()) + if strings.EqualFold(extension, ".nupkg") { + fileInfo, _ := file.Info() + time := fileInfo.ModTime() + if time.After(newestTime) { + newestTime = time + newestFile = file.Name() + } + } + } + return newestFile +} + +func (c PackagePackCommand) extractVersion(nupkgFile string) string { + parts := strings.Split(nupkgFile, ".") + len := len(parts) + if len < 4 { + return "" + } + return fmt.Sprintf("%s.%s.%s", parts[len-4], parts[len-3], parts[len-2]) +} + +func (c PackagePackCommand) wait(cmd utils.ExecCmd, wg *sync.WaitGroup) { + defer wg.Done() + _ = cmd.Wait() +} + +func (c PackagePackCommand) newPackagingProgressBar(logger log.Logger) chan struct{} { + progressBar := utils.NewProgressBar(logger) + ticker := time.NewTicker(10 * time.Millisecond) + cancel := make(chan struct{}) + var percent float64 = 0 + go func() { + for { + select { + case <-ticker.C: + progressBar.UpdatePercentage("packaging... ", percent) + percent = percent + 1 + if percent > 100 { + percent = 0 + } + case <-cancel: + ticker.Stop() + progressBar.Remove() + return + } + } + }() + return cancel +} + +func (c PackagePackCommand) getSource(context plugin.ExecutionContext) (string, error) { + source := c.getParameter("source", context.Parameters) + if source == "" { + return "", errors.New("source is not set") + } + source, _ = filepath.Abs(source) + fileInfo, err := os.Stat(source) + if err != nil { + return "", fmt.Errorf("%s not found", defaultProjectJson) + } + if fileInfo.IsDir() { + source = filepath.Join(source, defaultProjectJson) + } + return source, nil +} + +func (c PackagePackCommand) getDestination(context plugin.ExecutionContext) (string, error) { + destination := c.getParameter("destination", context.Parameters) + if destination == "" { + return "", errors.New("destination is not set") + } + destination, _ = filepath.Abs(destination) + return destination, nil +} + +func (c PackagePackCommand) readOutput(output io.Reader, logger log.Logger, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(output) + scanner.Split(bufio.ScanRunes) + for scanner.Scan() { + logger.Log(scanner.Text()) + } +} + +func (c PackagePackCommand) getParameter(name string, parameters []plugin.ExecutionParameter) string { + result := "" + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(string); ok { + result = data + break + } + } + } + return result +} + +func (c PackagePackCommand) getBoolParameter(name string, parameters []plugin.ExecutionParameter) bool { + result := false + for _, p := range parameters { + if p.Name == name { + if data, ok := p.Value.(bool); ok { + result = data + break + } + } + } + return result +} + +func NewPackagePackCommand() *PackagePackCommand { + return &PackagePackCommand{utils.NewExecProcess()} +} diff --git a/plugin/studio/package_pack_params.go b/plugin/studio/package_pack_params.go new file mode 100644 index 0000000..ebee3e7 --- /dev/null +++ b/plugin/studio/package_pack_params.go @@ -0,0 +1,22 @@ +package studio + +type packagePackParams struct { + Source string + Destination string + PackageVersion string + AutoVersion bool + OutputType string + SplitOutput bool + ReleaseNotes string +} + +func newPackagePackParams( + source string, + destination string, + packageVersion string, + autoVersion bool, + outputType string, + splitOutput bool, + releaseNotes string) *packagePackParams { + return &packagePackParams{source, destination, packageVersion, autoVersion, outputType, splitOutput, releaseNotes} +} diff --git a/plugin/studio/package_pack_result.go b/plugin/studio/package_pack_result.go new file mode 100644 index 0000000..2b89441 --- /dev/null +++ b/plugin/studio/package_pack_result.go @@ -0,0 +1,19 @@ +package studio + +type packagePackResult struct { + Status string `json:"status"` + Name *string `json:"name"` + Description *string `json:"description"` + ProjectId *string `json:"projectId"` + Version *string `json:"version"` + Output *string `json:"output"` + Error *string `json:"error"` +} + +func newSucceededPackagePackResult(output string, name string, description string, projectId string, version string) *packagePackResult { + return &packagePackResult{"Succeeded", &name, &description, &projectId, &version, &output, nil} +} + +func newFailedPackagePackResult(err string, name *string, description *string, projectId *string) *packagePackResult { + return &packagePackResult{"Failed", name, description, projectId, nil, nil, &err} +} diff --git a/plugin/studio/projects/.gitignore b/plugin/studio/projects/.gitignore new file mode 100644 index 0000000..57c19d8 --- /dev/null +++ b/plugin/studio/projects/.gitignore @@ -0,0 +1,6 @@ +crossplatform/* +!crossplatform/Main.xaml +!crossplatform/project.json +windows/* +!windows/Main.xaml +!windows/project.json \ No newline at end of file diff --git a/plugin/studio/projects/crossplatform/Main.xaml b/plugin/studio/projects/crossplatform/Main.xaml new file mode 100644 index 0000000..c7168e2 --- /dev/null +++ b/plugin/studio/projects/crossplatform/Main.xaml @@ -0,0 +1,87 @@ + + + + System.Activities + System.Activities.Statements + System.Activities.Expressions + System.Activities.Validation + System.Activities.XamlIntegration + Microsoft.VisualBasic + Microsoft.VisualBasic.Activities + System + System.Collections + System.Collections.Generic + System.Collections.ObjectModel + System.Data + System.Diagnostics + System.Drawing + System.IO + System.Linq + System.Net.Mail + System.Xml + System.Text + System.Xml.Linq + UiPath.Core + UiPath.Core.Activities + System.Windows.Markup + GlobalVariablesNamespace + GlobalConstantsNamespace + System.Linq.Expressions + System.Runtime.Serialization + + + + + Microsoft.CSharp + Microsoft.VisualBasic + mscorlib + System + System.Activities + System.ComponentModel.TypeConverter + System.Core + System.Data + System.Data.Common + System.Data.DataSetExtensions + System.Drawing + System.Drawing.Common + System.Drawing.Primitives + System.Linq + System.Net.Mail + System.ObjectModel + System.Private.CoreLib + System.Runtime.Serialization + System.ServiceModel + System.ServiceModel.Activities + System.Xaml + System.Xml + System.Xml.Linq + UiPath.System.Activities + UiPath.UiAutomation.Activities + UiPath.Studio.Constants + System.Configuration.ConfigurationManager + System.Security.Permissions + System.Console + System.ComponentModel + System.Memory + System.Private.Uri + System.Linq.Expressions + System.Runtime.Serialization.Formatters + System.Private.DataContractSerialization + System.Runtime.Serialization.Primitives + + + + + + True + + + + + + "Hello World" + + + + + \ No newline at end of file diff --git a/plugin/studio/projects/crossplatform/project.json b/plugin/studio/projects/crossplatform/project.json new file mode 100644 index 0000000..e6c5ed0 --- /dev/null +++ b/plugin/studio/projects/crossplatform/project.json @@ -0,0 +1,61 @@ +{ + "name": "MyProcess", + "projectId": "9011ee47-8dd4-4726-8850-299bd6ef057c", + "description": "Blank Process", + "main": "Main.xaml", + "dependencies": { + "UiPath.System.Activities": "[24.10.3]", + "UiPath.Testing.Activities": "[24.10.0]", + "UiPath.UIAutomation.Activities": "[24.10.0]", + "UiPath.WebAPI.Activities": "[1.21.0]" + }, + "webServices": [], + "entitiesStores": [], + "schemaVersion": "4.0", + "studioVersion": "24.10.1.0", + "projectVersion": "1.0.2", + "runtimeOptions": { + "autoDispose": false, + "netFrameworkLazyLoading": false, + "isPausable": true, + "isAttended": false, + "requiresUserInteraction": false, + "supportsPersistence": false, + "workflowSerialization": "DataContract", + "excludedLoggedData": [ + "Private:*", + "*password*" + ], + "executionType": "Workflow", + "readyForPiP": false, + "startsInPiP": false, + "mustRestoreAllDependencies": true, + "pipType": "ChildSession" + }, + "designOptions": { + "projectProfile": "Developement", + "outputType": "Process", + "libraryOptions": { + "includeOriginalXaml": false, + "privateWorkflows": [] + }, + "processOptions": { + "ignoredFiles": [] + }, + "fileInfoCollection": [], + "saveToCloud": false + }, + "expressionLanguage": "CSharp", + "entryPoints": [ + { + "filePath": "Main.xaml", + "uniqueId": "ac610120-f85b-4ed4-a014-05dca3380186", + "input": [], + "output": [] + } + ], + "isTemplate": false, + "templateProjectData": {}, + "publishData": {}, + "targetFramework": "Portable" +} \ No newline at end of file diff --git a/plugin/studio/projects/windows/Main.xaml b/plugin/studio/projects/windows/Main.xaml new file mode 100644 index 0000000..4c906dc --- /dev/null +++ b/plugin/studio/projects/windows/Main.xaml @@ -0,0 +1,94 @@ + + + + System.Activities + System.Activities.Statements + System.Activities.Expressions + System.Activities.Validation + System.Activities.XamlIntegration + Microsoft.VisualBasic + Microsoft.VisualBasic.Activities + System + System.Collections + System.Collections.Generic + System.Collections.ObjectModel + System.Data + System.Diagnostics + System.Drawing + System.IO + System.Linq + System.Net.Mail + System.Xml + System.Text + System.Xml.Linq + UiPath.Core + UiPath.Core.Activities + System.Windows.Markup + GlobalVariablesNamespace + GlobalConstantsNamespace + System.Linq.Expressions + System.Runtime.Serialization + + + + + Microsoft.CSharp + Microsoft.VisualBasic + mscorlib + PresentationCore + PresentationFramework + System + System.Activities + System.ComponentModel.TypeConverter + System.Core + System.Data + System.Data.Common + System.Data.DataSetExtensions + System.Drawing + System.Drawing.Common + System.Drawing.Primitives + System.Linq + System.Net.Mail + System.ObjectModel + System.Private.CoreLib + System.Runtime.Serialization + System.ServiceModel + System.ServiceModel.Activities + System.Xaml + System.Xml + System.Xml.Linq + UiPath.System.Activities + UiPath.UiAutomation.Activities + WindowsBase + UiPath.Studio.Constants + System.Memory.Data + UiPath.Excel.Activities.Design + NPOI + System.Console + System.Security.Permissions + System.Configuration.ConfigurationManager + System.ComponentModel + System.Memory + System.Private.Uri + System.Linq.Expressions + System.Private.ServiceModel + System.Runtime.Serialization.Formatters + System.Private.DataContractSerialization + System.Runtime.Serialization.Primitives + + + + + + True + + + + + + "Hello World" + + + + + \ No newline at end of file diff --git a/plugin/studio/projects/windows/project.json b/plugin/studio/projects/windows/project.json new file mode 100644 index 0000000..4798b88 --- /dev/null +++ b/plugin/studio/projects/windows/project.json @@ -0,0 +1,62 @@ +{ + "name": "MyWindowsProcess", + "projectId": "94c4c9c1-68c3-45d4-be14-d4427f17eddd", + "description": "Blank Process", + "main": "Main.xaml", + "dependencies": { + "UiPath.Excel.Activities": "[2.23.4]", + "UiPath.Mail.Activities": "[1.23.1]", + "UiPath.System.Activities": "[24.10.3]", + "UiPath.Testing.Activities": "[24.10.0]", + "UiPath.UIAutomation.Activities": "[24.10.0]" + }, + "webServices": [], + "entitiesStores": [], + "schemaVersion": "4.0", + "studioVersion": "24.10.1.0", + "projectVersion": "1.0.0", + "runtimeOptions": { + "autoDispose": false, + "netFrameworkLazyLoading": false, + "isPausable": true, + "isAttended": false, + "requiresUserInteraction": true, + "supportsPersistence": false, + "workflowSerialization": "DataContract", + "excludedLoggedData": [ + "Private:*", + "*password*" + ], + "executionType": "Workflow", + "readyForPiP": false, + "startsInPiP": false, + "mustRestoreAllDependencies": true, + "pipType": "ChildSession" + }, + "designOptions": { + "projectProfile": "Developement", + "outputType": "Process", + "libraryOptions": { + "includeOriginalXaml": false, + "privateWorkflows": [] + }, + "processOptions": { + "ignoredFiles": [] + }, + "fileInfoCollection": [], + "saveToCloud": false + }, + "expressionLanguage": "CSharp", + "entryPoints": [ + { + "filePath": "Main.xaml", + "uniqueId": "657507a4-4df3-47eb-810e-2548d3a59824", + "input": [], + "output": [] + } + ], + "isTemplate": false, + "templateProjectData": {}, + "publishData": {}, + "targetFramework": "Windows" +} \ No newline at end of file diff --git a/plugin/studio/studio_plugin_notwin_test.go b/plugin/studio/studio_plugin_notwin_test.go new file mode 100644 index 0000000..85b5a17 --- /dev/null +++ b/plugin/studio/studio_plugin_notwin_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package studio + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +func TestPackOnLinuxWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + if !strings.HasSuffix(commandName, "dotnet") { + t.Errorf("Expected command name to be dotnet, but got: %v", commandName) + } + if !strings.HasSuffix(commandArgs[0], "uipcli.dll") { + t.Errorf("Expected 1st argument to be the uipcli.dll, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[1]) + } + if commandArgs[2] != "pack" { + t.Errorf("Expected 3rd argument to be pack, but got: %v", commandArgs[2]) + } + if commandArgs[3] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[3]) + } + if commandArgs[4] != "--output" { + t.Errorf("Expected 5th argument to be output, but got: %v", commandArgs[4]) + } + if commandArgs[5] != destination { + t.Errorf("Expected 6th argument to be the output path, but got: %v", commandArgs[5]) + } +} + +func TestAnalyzeOnLinuxWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + if !strings.HasSuffix(commandName, "dotnet") { + t.Errorf("Expected command name to be dotnet, but got: %v", commandName) + } + if !strings.HasSuffix(commandArgs[0], "uipcli.dll") { + t.Errorf("Expected 1st argument to be the uipcli.dll, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[1]) + } + if commandArgs[2] != "analyze" { + t.Errorf("Expected 3rd argument to be analyze, but got: %v", commandArgs[2]) + } + if commandArgs[3] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[3]) + } +} diff --git a/plugin/studio/studio_plugin_test.go b/plugin/studio/studio_plugin_test.go new file mode 100644 index 0000000..712bf96 --- /dev/null +++ b/plugin/studio/studio_plugin_test.go @@ -0,0 +1,368 @@ +package studio + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +const studioDefinition = ` +openapi: 3.0.1 +info: + title: UiPath Studio + description: UiPath Studio + version: v1 +servers: + - url: https://cloud.uipath.com/{organization}/studio_/backend + description: The production url + variables: + organization: + description: The organization name (or id) + default: my-org +paths: + {} +` + +func TestPackWithoutSourceParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "pack", "--destination", "test.nupkg"}, context) + + if !strings.Contains(result.StdErr, "Argument --source is missing") { + t.Errorf("Expected stderr to show that source parameter is missing, but got: %v", result.StdErr) + } +} + +func TestPackWithoutDestinationParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "pack", "--source", source}, context) + + if !strings.Contains(result.StdErr, "Argument --destination is missing") { + t.Errorf("Expected stderr to show that destination parameter is missing, but got: %v", result.StdErr) + } +} + +func TestPackNonExistentProject(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "pack", "--source", "non-existent", "--destination", "test.nupkg"}, context) + + if !strings.Contains(result.StdErr, "project.json not found") { + t.Errorf("Expected stderr to show that project.json was not found, but got: %v", result.StdErr) + } +} + +func TestFailedPackagingReturnsFailureStatus(t *testing.T) { + exec := utils.NewExecCustomProcess(1, "Build output", "There was an error", func(name string, args []string) {}) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Failed" { + t.Errorf("Expected status to be Failed, but got: %v", result.StdOut) + } + if stdout["error"] != "There was an error" { + t.Errorf("Expected error to be set, but got: %v", result.StdOut) + } +} + +func TestPackCrossPlatformSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + if stdout["name"] != "MyProcess" { + t.Errorf("Expected name to be set, but got: %v", result.StdOut) + } + if stdout["description"] != "Blank Process" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + if stdout["projectId"] != "9011ee47-8dd4-4726-8850-299bd6ef057c" { + t.Errorf("Expected projectId to be set, but got: %v", result.StdOut) + } + if stdout["version"] != "1.0.2" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + outputFile := stdout["output"].(string) + if outputFile != filepath.Join(destination, "MyProcess.1.0.2.nupkg") { + t.Errorf("Expected output path to be set, but got: %v", result.StdOut) + } + if _, err := os.Stat(outputFile); err != nil { + t.Errorf("Expected output file %s to exists, but could not find it: %v", outputFile, err) + } +} + +func TestPackWithAutoVersionArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--auto-version", "true"}, context) + + if !slices.Contains(commandArgs, "--autoVersion") { + t.Errorf("Expected argument --autoVersion, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithOutputTypeArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--output-type", "Process"}, context) + + if !slices.Contains(commandArgs, "--outputType") { + t.Errorf("Expected argument --outputType, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithSplitOutputArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--split-output", "true"}, context) + + if !slices.Contains(commandArgs, "--splitOutput") { + t.Errorf("Expected argument --splitOutput, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestPackWithReleaseNotesArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination, "--release-notes", "These are release notes."}, context) + + index := slices.Index(commandArgs, "--releaseNotes") + if commandArgs[index] != "--releaseNotes" { + t.Errorf("Expected argument --releaseNotes, but got: %v", strings.Join(commandArgs, " ")) + } + if commandArgs[index+1] != "These are release notes." { + t.Errorf("Expected release notes argument, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestAnalyzeWithoutSourceParameterShowsValidationError(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + result := test.RunCli([]string{"studio", "package", "analyze"}, context) + + if !strings.Contains(result.StdErr, "Argument --source is missing") { + t.Errorf("Expected stderr to show that source parameter is missing, but got: %v", result.StdErr) + } +} + +func TestAnalyzeCrossPlatformSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + violations := stdout["violations"].([]interface{}) + if len(violations) == 0 { + t.Errorf("Expected violations not to be empty, but got: %v", result.StdOut) + } + violation := findViolation(violations, "TA-DBP-002") + if violation == nil { + t.Errorf("Could not find violation TA-DBP-002, got: %v", result.StdOut) + } + if violation["activityDisplayName"] != "" { + t.Errorf("Expected violation to have a activityDisplayName, but got: %v", result.StdOut) + } + if violation["description"] != "Workflow Main.xaml does not have any assigned Test Cases." { + t.Errorf("Expected violation to have a description, but got: %v", result.StdOut) + } + if violation["documentationLink"] != "https://docs.uipath.com/activities/lang-en/docs/ta-dbp-002" { + t.Errorf("Expected violation to have a documentationLink, but got: %v", result.StdOut) + } + if violation["errorSeverity"] != 1.0 { + t.Errorf("Expected violation to have a errorSeverity, but got: %v", result.StdOut) + } + if violation["filePath"] != "" { + t.Errorf("Expected violation to have a filePath, but got: %v", result.StdOut) + } + if violation["recommendation"] != "Creating Test Cases for your workflows allows you to run them frequently to discover potential issues early on before they are introduced in your production environment. [Learn more.](https://docs.uipath.com/activities/lang-en/docs/ta-dbp-002)" { + t.Errorf("Expected violation to have a recommendation, but got: %v", result.StdOut) + } + if violation["ruleName"] != "Untested Workflows" { + t.Errorf("Expected violation to have a ruleName, but got: %v", result.StdOut) + } + if violation["workflowDisplayName"] != "Main" { + t.Errorf("Expected violation to have a workflowDisplayName, but got: %v", result.StdOut) + } +} + +func TestFailedAnalyzeReturnsFailureStatus(t *testing.T) { + exec := utils.NewExecCustomProcess(1, "Analyze output", "There was an error", func(name string, args []string) {}) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Failed" { + t.Errorf("Expected status to be Failed, but got: %v", result.StdOut) + } + if stdout["error"] != "There was an error" { + t.Errorf("Expected error to be set, but got: %v", result.StdOut) + } +} + +func TestAnalyzeWithTreatWarningsAsErrorsArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source, "--treat-warnings-as-errors", "true"}, context) + + if !slices.Contains(commandArgs, "--treatWarningsAsErrors") { + t.Errorf("Expected argument --treatWarningsAsErrors, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func TestAnalyzeWithStopOnRuleViolationArgument(t *testing.T) { + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source, "--stop-on-rule-violation", "true"}, context) + + if !slices.Contains(commandArgs, "--stopOnRuleViolation") { + t.Errorf("Expected argument --stopOnRuleViolation, but got: %v", strings.Join(commandArgs, " ")) + } +} + +func findViolation(violations []interface{}, errorCode string) map[string]interface{} { + var violation map[string]interface{} + for _, v := range violations { + vMap := v.(map[string]interface{}) + if vMap["errorCode"] == errorCode { + violation = vMap + } + } + return violation +} + +func studioCrossPlatformProjectDirectory() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "projects", "crossplatform") +} + +func createDirectory(t *testing.T) string { + tmp, err := os.MkdirTemp("", "uipath-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(tmp) }) + return tmp +} diff --git a/plugin/studio/studio_plugin_windows_test.go b/plugin/studio/studio_plugin_windows_test.go new file mode 100644 index 0000000..f9dcdad --- /dev/null +++ b/plugin/studio/studio_plugin_windows_test.go @@ -0,0 +1,153 @@ +//go:build windows + +package studio + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/UiPath/uipathcli/test" + "github.com/UiPath/uipathcli/utils" +) + +func TestPackWindowsSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackagePackCommand()). + Build() + + source := studioWindowsProjectDirectory() + destination := createDirectory(t) + result := test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize pack command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + if stdout["name"] != "MyWindowsProcess" { + t.Errorf("Expected name to be set, but got: %v", result.StdOut) + } + if stdout["description"] != "Blank Process" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + if stdout["projectId"] != "94c4c9c1-68c3-45d4-be14-d4427f17eddd" { + t.Errorf("Expected projectId to be set, but got: %v", result.StdOut) + } + if stdout["version"] != "1.0.0" { + t.Errorf("Expected version to be set, but got: %v", result.StdOut) + } + outputFile := stdout["output"].(string) + if outputFile != filepath.Join(destination, "MyWindowsProcess.1.0.0.nupkg") { + t.Errorf("Expected output path to be set, but got: %v", result.StdOut) + } + if _, err := os.Stat(outputFile); err != nil { + t.Errorf("Expected output file %s to exists, but could not find it: %v", outputFile, err) + } +} + +func TestPackOnWindowsWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackagePackCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + destination := createDirectory(t) + test.RunCli([]string{"studio", "package", "pack", "--source", source, "--destination", destination}, context) + + if !strings.HasSuffix(commandName, "uipcli.exe") { + t.Errorf("Expected command name to be uipcli.exe, but got: %v", commandName) + } + if commandArgs[0] != "package" { + t.Errorf("Expected 1st argument to be package, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "pack" { + t.Errorf("Expected 2nd argument to be pack, but got: %v", commandArgs[1]) + } + if commandArgs[2] != filepath.Join(source, "project.json") { + t.Errorf("Expected 3rd argument to be the project.json, but got: %v", commandArgs[2]) + } + if commandArgs[3] != "--output" { + t.Errorf("Expected 4th argument to be output, but got: %v", commandArgs[3]) + } + if commandArgs[4] != destination { + t.Errorf("Expected 5th argument to be the output path, but got: %v", commandArgs[4]) + } +} + +func TestAnalyzeWindowsSuccessfully(t *testing.T) { + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(NewPackageAnalyzeCommand()). + Build() + + source := studioWindowsProjectDirectory() + result := test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + stdout := map[string]interface{}{} + err := json.Unmarshal([]byte(result.StdOut), &stdout) + if err != nil { + t.Errorf("Failed to deserialize analyze command result: %v", err) + } + if stdout["status"] != "Succeeded" { + t.Errorf("Expected status to be Succeeded, but got: %v", result.StdOut) + } + if stdout["error"] != nil { + t.Errorf("Expected error to be nil, but got: %v", result.StdOut) + } + violations := stdout["violations"].([]interface{}) + if len(violations) == 0 { + t.Errorf("Expected violations not to be empty, but got: %v", result.StdOut) + } +} + +func TestAnalyzeOnWindowsWithCorrectArguments(t *testing.T) { + commandName := "" + commandArgs := []string{} + exec := utils.NewExecCustomProcess(0, "", "", func(name string, args []string) { + commandName = name + commandArgs = args + }) + context := test.NewContextBuilder(). + WithDefinition("studio", studioDefinition). + WithCommandPlugin(PackageAnalyzeCommand{exec}). + Build() + + source := studioCrossPlatformProjectDirectory() + test.RunCli([]string{"studio", "package", "analyze", "--source", source}, context) + + if !strings.HasSuffix(commandName, "uipcli.exe") { + t.Errorf("Expected command name to be uipcli.exe, but got: %v", commandName) + } + if commandArgs[0] != "package" { + t.Errorf("Expected 2nd argument to be package, but got: %v", commandArgs[0]) + } + if commandArgs[1] != "analyze" { + t.Errorf("Expected 3rd argument to be analyze, but got: %v", commandArgs[1]) + } + if commandArgs[2] != filepath.Join(source, "project.json") { + t.Errorf("Expected 4th argument to be the project.json, but got: %v", commandArgs[2]) + } +} + +func studioWindowsProjectDirectory() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "projects", "windows") +} diff --git a/plugin/studio/studio_project_json.go b/plugin/studio/studio_project_json.go new file mode 100644 index 0000000..25b2db3 --- /dev/null +++ b/plugin/studio/studio_project_json.go @@ -0,0 +1,8 @@ +package studio + +type studioProjectJson struct { + Name string `json:"name"` + Description string `json:"description"` + ProjectId string `json:"projectId"` + TargetFramework string `json:"targetFramework"` +} diff --git a/plugin/studio/studio_project_reader.go b/plugin/studio/studio_project_reader.go new file mode 100644 index 0000000..e96be3b --- /dev/null +++ b/plugin/studio/studio_project_reader.go @@ -0,0 +1,44 @@ +package studio + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +type studioProjectReader struct { + Path string +} + +func (p studioProjectReader) GetTargetFramework() TargetFramework { + project, _ := p.ReadMetadata() + if strings.EqualFold(project.TargetFramework, "windows") { + return TargetFrameworkWindows + } + return TargetFrameworkCrossPlatform +} + +func (p studioProjectReader) ReadMetadata() (studioProjectJson, error) { + file, err := os.Open(p.Path) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + defer file.Close() + byteValue, err := io.ReadAll(file) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error reading %s file: %v", defaultProjectJson, err) + } + + var project studioProjectJson + err = json.Unmarshal(byteValue, &project) + if err != nil { + return studioProjectJson{}, fmt.Errorf("Error parsing %s file: %v", defaultProjectJson, err) + } + return project, nil +} + +func newStudioProjectReader(path string) *studioProjectReader { + return &studioProjectReader{path} +} diff --git a/plugin/studio/target_framework.go b/plugin/studio/target_framework.go new file mode 100644 index 0000000..969cc5d --- /dev/null +++ b/plugin/studio/target_framework.go @@ -0,0 +1,8 @@ +package studio + +type TargetFramework int + +const ( + TargetFrameworkCrossPlatform TargetFramework = iota + 1 + TargetFrameworkWindows +) diff --git a/plugin/studio/uipcli.go b/plugin/studio/uipcli.go new file mode 100644 index 0000000..04ddde9 --- /dev/null +++ b/plugin/studio/uipcli.go @@ -0,0 +1,66 @@ +package studio + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + + "github.com/UiPath/uipathcli/log" + "github.com/UiPath/uipathcli/plugin" + "github.com/UiPath/uipathcli/utils" +) + +const uipcliVersion = "24.12.9111.31003" +const uipcliUrl = "https://uipath.pkgs.visualstudio.com/Public.Feeds/_apis/packaging/feeds/1c781268-d43d-45ab-9dfc-0151a1c740b7/nuget/packages/UiPath.CLI/versions/" + uipcliVersion + "/content" + +const uipcliWindowsVersion = "24.12.9111.31003" +const uipcliWindowsUrl = "https://uipath.pkgs.visualstudio.com/Public.Feeds/_apis/packaging/feeds/1c781268-d43d-45ab-9dfc-0151a1c740b7/nuget/packages/UiPath.CLI.Windows/versions/" + uipcliWindowsVersion + "/content" + +type uipcli struct { + Exec utils.ExecProcess + Logger log.Logger +} + +func (c uipcli) Execute(targetFramework TargetFramework, args ...string) (utils.ExecCmd, error) { + if targetFramework == TargetFrameworkWindows { + return c.execute("uipcli-win", uipcliWindowsUrl, args) + } + return c.execute("uipcli", uipcliUrl, args) +} + +func (c uipcli) execute(name string, url string, args []string) (utils.ExecCmd, error) { + uipcliPath, err := c.getPath(name, url) + if err != nil { + return nil, err + } + + path := uipcliPath + if filepath.Ext(uipcliPath) == ".dll" { + path, err = exec.LookPath("dotnet") + if err != nil { + return nil, fmt.Errorf("Could not find dotnet runtime to run command: %v", err) + } + args = append([]string{uipcliPath}, args...) + } + + cmd := c.Exec.Command(path, args...) + return cmd, nil +} + +func (c uipcli) getPath(name string, url string) (string, error) { + externalPlugin := plugin.NewExternalPlugin(c.Logger) + executable := "tools/uipcli.dll" + if c.isWindows() { + executable = "tools/uipcli.exe" + } + return externalPlugin.GetTool(name, url, executable) +} + +func (c uipcli) isWindows() bool { + return runtime.GOOS == "windows" +} + +func newUipcli(exec utils.ExecProcess, logger log.Logger) *uipcli { + return &uipcli{exec, logger} +} diff --git a/plugin/zip_archive.go b/plugin/zip_archive.go new file mode 100644 index 0000000..aa6b64d --- /dev/null +++ b/plugin/zip_archive.go @@ -0,0 +1,75 @@ +package plugin + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const MaxArchiveSize = 1 * 1024 * 1024 * 1024 + +type zipArchive struct{} + +func (z zipArchive) Extract(zipArchive string, destinationFolder string, permissions os.FileMode) error { + archive, err := zip.OpenReader(zipArchive) + if err != nil { + return err + } + defer archive.Close() + + for _, file := range archive.File { + err := z.extractFile(file, destinationFolder, permissions) + if err != nil { + return err + } + } + return nil +} + +func (z zipArchive) extractFile(zipFile *zip.File, destinationFolder string, permissions os.FileMode) error { + path, err := z.sanitizeArchivePath(destinationFolder, zipFile.Name) + if err != nil { + return err + } + + if zipFile.FileInfo().IsDir() { + return os.MkdirAll(path, permissions) + } + err = os.MkdirAll(filepath.Dir(path), permissions) + if err != nil { + return err + } + + zipFileReader, err := zipFile.Open() + if err != nil { + return err + } + defer zipFileReader.Close() + + destinationFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) + if err != nil { + return err + } + defer destinationFile.Close() + + _, err = io.CopyN(destinationFile, zipFileReader, MaxArchiveSize) + if err != nil && err != io.EOF { + return err + } + return nil +} + +func (z zipArchive) sanitizeArchivePath(directory string, name string) (string, error) { + result := filepath.Join(directory, name) + if strings.HasPrefix(result, filepath.Clean(directory)) { + return result, nil + } + return "", fmt.Errorf("File path '%s' is not allowed", directory) +} + +func newZipArchive() *zipArchive { + return &zipArchive{} +} diff --git a/test/setup.go b/test/setup.go index 46809f0..2fdf2b8 100644 --- a/test/setup.go +++ b/test/setup.go @@ -187,7 +187,7 @@ func RunCli(args []string, context Context) Result { stderr := new(bytes.Buffer) authenticators := []auth.Authenticator{ auth.NewPatAuthenticator(), - auth.NewOAuthAuthenticator(cache.NewFileCache(), auth.NewExecBrowserLauncher()), + auth.NewOAuthAuthenticator(cache.NewFileCache(), *auth.NewBrowserLauncher()), auth.NewBearerAuthenticator(cache.NewFileCache()), } commandPlugins := []plugin.CommandPlugin{} diff --git a/utils/directories.go b/utils/directories.go new file mode 100644 index 0000000..b57caf6 --- /dev/null +++ b/utils/directories.go @@ -0,0 +1,43 @@ +package utils + +import ( + "os" + "path/filepath" +) + +const directoryPermissions = 0700 + +type Directories struct { +} + +func (d Directories) Temp() (string, error) { + return d.userDirectory("tmp") +} + +func (d Directories) Cache() (string, error) { + return d.userDirectory("cache") +} + +func (d Directories) Plugin() (string, error) { + return d.userDirectory("plugins") +} + +func (d Directories) userDirectory(name string) (string, error) { + userDirectory, err := d.baseUserDirectory() + if err != nil { + return "", err + } + directory := filepath.Join(userDirectory, name) + _ = os.MkdirAll(directory, directoryPermissions) + return directory, nil +} + +func (d Directories) baseUserDirectory() (string, error) { + userCacheDirectory, err := os.UserCacheDir() + if err != nil { + return "", err + } + userDirectory := filepath.Join(userCacheDirectory, "uipath", "uipathcli") + _ = os.MkdirAll(userDirectory, directoryPermissions) + return userDirectory, nil +} diff --git a/utils/exec_cmd.go b/utils/exec_cmd.go new file mode 100644 index 0000000..5fa7344 --- /dev/null +++ b/utils/exec_cmd.go @@ -0,0 +1,11 @@ +package utils + +import "io" + +type ExecCmd interface { + StdoutPipe() (io.ReadCloser, error) + StderrPipe() (io.ReadCloser, error) + Start() error + Wait() error + ExitCode() int +} diff --git a/utils/exec_custom_cmd.go b/utils/exec_custom_cmd.go new file mode 100644 index 0000000..35643b1 --- /dev/null +++ b/utils/exec_custom_cmd.go @@ -0,0 +1,35 @@ +package utils + +import ( + "io" +) + +type ExecCustomCmd struct { + Name string + Args []string + Exit int + StdOut io.ReadCloser + StdErr io.ReadCloser + OnStart func(name string, args []string) +} + +func (c ExecCustomCmd) StdoutPipe() (io.ReadCloser, error) { + return c.StdOut, nil +} + +func (c ExecCustomCmd) StderrPipe() (io.ReadCloser, error) { + return c.StdErr, nil +} + +func (c ExecCustomCmd) Start() error { + c.OnStart(c.Name, c.Args) + return nil +} + +func (c ExecCustomCmd) Wait() error { + return nil +} + +func (c ExecCustomCmd) ExitCode() int { + return c.Exit +} diff --git a/utils/exec_custom_process.go b/utils/exec_custom_process.go new file mode 100644 index 0000000..18a4c96 --- /dev/null +++ b/utils/exec_custom_process.go @@ -0,0 +1,33 @@ +package utils + +import ( + "io" + "strings" +) + +type ExecCustomProcess struct { + ExitCode int + Stdout string + Stderr string + OnStart func(name string, args []string) +} + +func (e ExecCustomProcess) Command(name string, args ...string) ExecCmd { + return ExecCustomCmd{ + Name: name, + Args: args, + StdOut: io.NopCloser(strings.NewReader(e.Stdout)), + StdErr: io.NopCloser(strings.NewReader(e.Stderr)), + Exit: e.ExitCode, + OnStart: e.OnStart, + } +} + +func NewExecCustomProcess(exitCode int, stdout string, stderr string, onStart func(name string, args []string)) *ExecCustomProcess { + return &ExecCustomProcess{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + OnStart: onStart, + } +} diff --git a/utils/exec_default_cmd.go b/utils/exec_default_cmd.go new file mode 100644 index 0000000..9aa6ffd --- /dev/null +++ b/utils/exec_default_cmd.go @@ -0,0 +1,30 @@ +package utils + +import ( + "io" + "os/exec" +) + +type ExecDefaultCmd struct { + Cmd *exec.Cmd +} + +func (c ExecDefaultCmd) StdoutPipe() (io.ReadCloser, error) { + return c.Cmd.StdoutPipe() +} + +func (c ExecDefaultCmd) StderrPipe() (io.ReadCloser, error) { + return c.Cmd.StderrPipe() +} + +func (c ExecDefaultCmd) Start() error { + return c.Cmd.Start() +} + +func (c ExecDefaultCmd) Wait() error { + return c.Cmd.Wait() +} + +func (c ExecDefaultCmd) ExitCode() int { + return c.Cmd.ProcessState.ExitCode() +} diff --git a/utils/exec_default_process.go b/utils/exec_default_process.go new file mode 100644 index 0000000..f8382e3 --- /dev/null +++ b/utils/exec_default_process.go @@ -0,0 +1,14 @@ +package utils + +import "os/exec" + +type ExecDefaultProcess struct { +} + +func (e ExecDefaultProcess) Command(name string, args ...string) ExecCmd { + return ExecDefaultCmd{exec.Command(name, args...)} +} + +func NewExecProcess() ExecProcess { + return &ExecDefaultProcess{} +} diff --git a/utils/exec_process.go b/utils/exec_process.go new file mode 100644 index 0000000..dc69d03 --- /dev/null +++ b/utils/exec_process.go @@ -0,0 +1,5 @@ +package utils + +type ExecProcess interface { + Command(name string, args ...string) ExecCmd +} diff --git a/utils/progress_bar.go b/utils/progress_bar.go index 9147021..eb81c82 100644 --- a/utils/progress_bar.go +++ b/utils/progress_bar.go @@ -16,9 +16,19 @@ type ProgressBar struct { renderedLength int } -func (b *ProgressBar) Update(text string, current int64, total int64, bytesPerSecond int64) { +func (b *ProgressBar) UpdatePercentage(text string, percent float64) { b.logger.LogError("\r") - length := b.render(text, current, total, bytesPerSecond) + length := b.renderTick(text, percent) + left := b.renderedLength - length + if left > 0 { + b.logger.LogError(strings.Repeat(" ", left)) + } + b.renderedLength = length +} + +func (b *ProgressBar) UpdateProgress(text string, current int64, total int64, bytesPerSecond int64) { + b.logger.LogError("\r") + length := b.renderProgress(text, current, total, bytesPerSecond) left := b.renderedLength - length if left > 0 { b.logger.LogError(strings.Repeat(" ", left)) @@ -33,10 +43,18 @@ func (b *ProgressBar) Remove() { } } -func (b ProgressBar) render(text string, currentBytes int64, totalBytes int64, bytesPerSecond int64) int { +func (b ProgressBar) renderTick(text string, percent float64) int { + bar := b.createBar(percent) + output := fmt.Sprintf("%s |%s|", + text, + bar) + b.logger.LogError(output) + return utf8.RuneCountInString(output) +} + +func (b ProgressBar) renderProgress(text string, currentBytes int64, totalBytes int64, bytesPerSecond int64) int { percent := math.Min(float64(currentBytes)/float64(totalBytes)*100.0, 100.0) - barCount := int(percent / 5.0) - bar := strings.Repeat("█", barCount) + strings.Repeat(" ", 20-barCount) + bar := b.createBar(percent) totalBytesFormatted, unit := b.formatBytes(totalBytes) currentBytesFormatted, unit := b.formatBytesInUnit(currentBytes, unit) bytesPerSecondFormatted, bytesPerSecondUnit := b.formatBytes(bytesPerSecond) @@ -53,6 +71,14 @@ func (b ProgressBar) render(text string, currentBytes int64, totalBytes int64, b return utf8.RuneCountInString(output) } +func (b ProgressBar) createBar(percent float64) string { + barCount := int(percent / 5.0) + if barCount > 20 { + barCount = 20 + } + return strings.Repeat("█", barCount) + strings.Repeat(" ", 20-barCount) +} + func (b ProgressBar) formatBytes(count int64) (string, string) { if count < 1000 { return b.formatBytesInUnit(count, "B") diff --git a/utils/progress_bar_test.go b/utils/progress_bar_test.go index 364361b..3f670d4 100644 --- a/utils/progress_bar_test.go +++ b/utils/progress_bar_test.go @@ -11,23 +11,23 @@ import ( "github.com/UiPath/uipathcli/log" ) -func TestProgressBarShowsZeroAtTheBeginning(t *testing.T) { +func TestProgressBarUpdateProgressShowsZeroAtTheBeginning(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) - progressBar.Update("downloading...", 0, 1000, 0) + progressBar.UpdateProgress("downloading...", 0, 1000, 0) if output.String() != "\rdownloading... 0% | | (0.0/1.0 kB, 0 B/s)" { t.Errorf("Should display progress of 10 percent, but got: %v", output.String()) } } -func TestProgressBarUpdatesMultipleTimes(t *testing.T) { +func TestProgressBarUpdateProgressMultipleTimes(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) - progressBar.Update("downloading...", 0, 1000, 1) - progressBar.Update("downloading...", 200, 1000, 1024) + progressBar.UpdateProgress("downloading...", 0, 1000, 1) + progressBar.UpdateProgress("downloading...", 200, 1000, 1024) if output.String() != "\rdownloading... 0% | | (0.0/1.0 kB, 1 B/s)"+ "\rdownloading... 20% |████ | (0.2/1.0 kB, 1.0 kB/s)" { @@ -39,7 +39,7 @@ func TestProgressBarShowsReadsFromProgressReader(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) progressReader := NewProgressReader(data(1000), func(progress Progress) { - progressBar.Update("download", progress.BytesRead, 1000, progress.BytesPerSecond) + progressBar.UpdateProgress("download", progress.BytesRead, 1000, progress.BytesPerSecond) }) _, _ = progressReader.Read(make([]byte, 100)) @@ -54,7 +54,7 @@ func TestProgressBarShowsMultipleReadsFromProgressReader(t *testing.T) { var output bytes.Buffer progressBar := NewProgressBar(log.NewDefaultLogger(&output)) progressReader := NewProgressReader(data(1000), func(progress Progress) { - progressBar.Update("download", progress.BytesRead, 1000, progress.BytesPerSecond) + progressBar.UpdateProgress("download", progress.BytesRead, 1000, progress.BytesPerSecond) }) _, _ = progressReader.Read(make([]byte, 500)) @@ -67,6 +67,44 @@ func TestProgressBarShowsMultipleReadsFromProgressReader(t *testing.T) { } } +func TestProgressBarUpdatePercentageShowsSimpleBar(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + + progressBar.UpdatePercentage("building...", 0) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... | |") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + +func TestProgressBarUpdatePercentageMovesBar(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + progressBar.UpdatePercentage("building...", 0) + + progressBar.UpdatePercentage("building...", 10) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... |██ |") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + +func TestProgressBarUpdatePercentageTo100(t *testing.T) { + var output bytes.Buffer + progressBar := NewProgressBar(log.NewDefaultLogger(&output)) + progressBar.UpdatePercentage("building...", 0) + progressBar.UpdatePercentage("building...", 50) + progressBar.UpdatePercentage("building...", 100) + + lastLine := lastLine(output) + if !strings.HasPrefix(lastLine, "building... |████████████████████|") { + t.Errorf("Should display simple bar, but got: %v", lastLine) + } +} + func lastLine(output bytes.Buffer) string { lines := strings.Split(output.String(), "\r") return lines[len(lines)-1]