From 3780d8a505fd0bd8ef71bdea611fcb63c4089244 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Fri, 23 Jan 2026 09:23:51 -0500 Subject: [PATCH] Bring your own WASM --- cmd/common/compile.go | 97 ++++++ cmd/common/compile_test.go | 68 +++++ cmd/common/utils.go | 63 +++- cmd/creinit/creinit.go | 36 ++- .../workflow/wasmBlankTemplate/Makefile.tpl | 9 + .../workflow/wasmBlankTemplate/README.md | 32 ++ .../wasmBlankTemplate/config.production.json | 1 + .../wasmBlankTemplate/config.staging.json | 1 + .../workflow/wasmBlankTemplate/secrets.yaml | 1 + .../workflow/wasmBlankTemplate/wasm/README.md | 3 + cmd/workflow/convert/convert.go | 203 +++++++++++++ cmd/workflow/convert/convert_test.go | 199 +++++++++++++ cmd/workflow/deploy/compile.go | 55 +--- cmd/workflow/deploy/compile_test.go | 278 +++++------------- cmd/workflow/simulate/simulate.go | 49 +-- cmd/workflow/workflow.go | 2 + internal/constants/constants.go | 1 + internal/settings/workflow_settings.go | 67 +++++ internal/testutil/graphql_mock.go | 42 +++ test/convert_simulate_helper.go | 86 ++++++ test/graphql_mock.go | 14 + ...binding_generation_and_simulate_go_test.go | 39 +-- test/init_and_simulate_ts_test.go | 39 +-- test/init_and_simulate_wasm_test.go | 215 ++++++++++++++ test/init_convert_simulate_go_test.go | 113 +++++++ test/init_convert_simulate_ts_test.go | 113 +++++++ .../workflow_simulator_path.go | 33 +-- 27 files changed, 1436 insertions(+), 423 deletions(-) create mode 100644 cmd/common/compile.go create mode 100644 cmd/common/compile_test.go create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/README.md create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml create mode 100644 cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md create mode 100644 cmd/workflow/convert/convert.go create mode 100644 cmd/workflow/convert/convert_test.go create mode 100644 internal/testutil/graphql_mock.go create mode 100644 test/convert_simulate_helper.go create mode 100644 test/graphql_mock.go create mode 100644 test/init_and_simulate_wasm_test.go create mode 100644 test/init_convert_simulate_go_test.go create mode 100644 test/init_convert_simulate_ts_test.go diff --git a/cmd/common/compile.go b/cmd/common/compile.go new file mode 100644 index 00000000..9fada64c --- /dev/null +++ b/cmd/common/compile.go @@ -0,0 +1,97 @@ +package common + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +const makefileName = "Makefile" + +// CompileWorkflowToWasm compiles the workflow at workflowPath (value from workflow.yaml, e.g. ".", +// "main.go", "./wasm/workflow.wasm") and returns the WASM binary. For Go/TypeScript it runs +// go build or bun cre-compile; for WASM (path to .wasm file) it runs make build in the directory +// that contains the Makefile, then reads the WASM from the path specified in settings. +func CompileWorkflowToWasm(workflowPath string) ([]byte, error) { + workflowRootFolder, workflowMainFile, err := WorkflowPathRootAndMain(workflowPath) + if err != nil { + return nil, fmt.Errorf("workflow path: %w", err) + } + workflowAbsFile := filepath.Join(workflowRootFolder, workflowMainFile) + language := GetWorkflowLanguage(workflowMainFile) + + // For Go/TypeScript the source file must exist; for WASM the .wasm file is produced by make. + if language != constants.WorkflowLanguageWasm { + if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { + return nil, fmt.Errorf("workflow file not found: %s", workflowAbsFile) + } + } + + switch language { + case constants.WorkflowLanguageTypeScript: + if err := EnsureTool("bun"); err != nil { + return nil, errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") + } + case constants.WorkflowLanguageGolang: + if err := EnsureTool("go"); err != nil { + return nil, errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") + } + case constants.WorkflowLanguageWasm: + if err := EnsureTool("make"); err != nil { + return nil, errors.New("make is required for WASM workflows but was not found in PATH") + } + default: + return nil, fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) + } + + if language == constants.WorkflowLanguageWasm { + // workflow-path points to the .wasm file; run make in the directory that contains the Makefile. + makeRoot, err := findMakefileRoot(workflowRootFolder) + if err != nil { + return nil, err + } + buildCmd := exec.Command("make", "build") + buildCmd.Dir = makeRoot + buildOutput, err := buildCmd.CombinedOutput() + if err != nil { + out := strings.TrimSpace(string(buildOutput)) + return nil, fmt.Errorf("failed to build workflow: %w\nbuild output:\n%s", err, out) + } + return os.ReadFile(workflowAbsFile) + } + + // Go or TypeScript: build to a temp file, read bytes, remove temp file. + tmpWasm := filepath.Join(workflowRootFolder, ".cre_build_tmp.wasm") + defer os.Remove(tmpWasm) // best-effort cleanup + buildCmd := GetBuildCmd(workflowMainFile, tmpWasm, workflowRootFolder) + buildOutput, err := buildCmd.CombinedOutput() + if err != nil { + out := strings.TrimSpace(string(buildOutput)) + return nil, fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + } + _ = buildOutput + return os.ReadFile(tmpWasm) +} + +// findMakefileRoot walks up from dir and returns the first directory that contains a Makefile. +func findMakefileRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + for { + if _, err := os.Stat(filepath.Join(abs, makefileName)); err == nil { + return abs, nil + } + parent := filepath.Dir(abs) + if parent == abs { + return "", errors.New("no Makefile found in directory or any parent (required for WASM workflow build)") + } + abs = parent + } +} diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go new file mode 100644 index 00000000..61ff5eb0 --- /dev/null +++ b/cmd/common/compile_test.go @@ -0,0 +1,68 @@ +package common + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func deployTestdataPath(elem ...string) string { + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + return filepath.Join(append([]string{dir, "..", "workflow", "deploy", "testdata"}, elem...)...) +} + +func TestFindMakefileRoot(t *testing.T) { + dir := t.TempDir() + + _, err := findMakefileRoot(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "no Makefile found") + + require.NoError(t, os.WriteFile(filepath.Join(dir, makefileName), []byte("build:\n\techo ok\n"), 0600)) + root, err := findMakefileRoot(dir) + require.NoError(t, err) + absDir, _ := filepath.Abs(dir) + require.Equal(t, absDir, root) + + sub := filepath.Join(dir, "wasm") + require.NoError(t, os.MkdirAll(sub, 0755)) + root, err = findMakefileRoot(sub) + require.NoError(t, err) + require.Equal(t, absDir, root) +} + +func TestCompileWorkflowToWasm_Go_Success(t *testing.T) { + t.Run("basic_workflow", func(t *testing.T) { + path := deployTestdataPath("basic_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("configless_workflow", func(t *testing.T) { + path := deployTestdataPath("configless_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("missing_go_mod", func(t *testing.T) { + path := deployTestdataPath("missing_go_mod", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) +} + +func TestCompileWorkflowToWasm_Go_Malformed_Fails(t *testing.T) { + path := deployTestdataPath("malformed_workflow", "main.go") + _, err := CompileWorkflowToWasm(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to compile workflow") + assert.Contains(t, err.Error(), "undefined: sdk.RemovedFunctionThatFailsCompilation") +} diff --git a/cmd/common/utils.go b/cmd/common/utils.go index 98805798..3cef941f 100644 --- a/cmd/common/utils.go +++ b/cmd/common/utils.go @@ -166,15 +166,52 @@ func ToStringSlice(args []any) []string { } // GetWorkflowLanguage determines the workflow language based on the file extension -// Note: inputFile can be a file path (e.g., "main.ts" or "main.go") or a directory (for Go workflows, e.g., ".") -// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageGolang otherwise +// Note: inputFile can be a file path (e.g., "main.ts", "main.go", or "workflow.wasm") or a directory (for Go workflows, e.g., ".") +// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageWasm for .wasm files, constants.WorkflowLanguageGolang otherwise func GetWorkflowLanguage(inputFile string) string { if strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") { return constants.WorkflowLanguageTypeScript } + if strings.HasSuffix(inputFile, ".wasm") { + return constants.WorkflowLanguageWasm + } return constants.WorkflowLanguageGolang } +// ResolveWorkflowPath turns a workflow-path value from YAML (e.g. "." or "main.ts") into an +// absolute path to the main file. When pathFromYAML is "." or "", looks for main.go then main.ts +// under workflowDir. Callers can use GetWorkflowLanguage on the result to get the language. +func ResolveWorkflowPath(workflowDir, pathFromYAML string) (absPath string, err error) { + workflowDir, err = filepath.Abs(workflowDir) + if err != nil { + return "", fmt.Errorf("workflow directory: %w", err) + } + if pathFromYAML == "" || pathFromYAML == "." { + mainGo := filepath.Join(workflowDir, "main.go") + mainTS := filepath.Join(workflowDir, "main.ts") + if _, err := os.Stat(mainGo); err == nil { + return mainGo, nil + } + if _, err := os.Stat(mainTS); err == nil { + return mainTS, nil + } + return "", fmt.Errorf("no main.go or main.ts in %s", workflowDir) + } + joined := filepath.Join(workflowDir, pathFromYAML) + return filepath.Abs(joined) +} + +// WorkflowPathRootAndMain returns the absolute root directory and main file name for a workflow +// path (e.g. "workflowName/main.go" -> rootDir, "main.go"). Use with GetWorkflowLanguage(mainFile) +// for consistent language detection. +func WorkflowPathRootAndMain(workflowPath string) (rootDir, mainFile string, err error) { + abs, err := filepath.Abs(workflowPath) + if err != nil { + return "", "", fmt.Errorf("workflow path: %w", err) + } + return filepath.Dir(abs), filepath.Base(abs), nil +} + // EnsureTool checks that the binary exists on PATH func EnsureTool(bin string) error { if _, err := exec.LookPath(bin); err != nil { @@ -183,19 +220,23 @@ func EnsureTool(bin string) error { return nil } -// Gets a build command for either Golang or Typescript based on the filename +// Gets a build command for Golang, TypeScript, or WASM based on the workflow language func GetBuildCmd(inputFile string, outputFile string, rootFolder string) *exec.Cmd { - isTypescriptWorkflow := strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") + language := GetWorkflowLanguage(inputFile) var buildCmd *exec.Cmd - if isTypescriptWorkflow { + switch language { + case constants.WorkflowLanguageTypeScript: buildCmd = exec.Command( "bun", "cre-compile", inputFile, outputFile, ) - } else { + case constants.WorkflowLanguageWasm: + // For WASM workflows, use make build + buildCmd = exec.Command("make", "build") + case constants.WorkflowLanguageGolang: // The build command for reproducible and trimmed binaries. // -trimpath removes all file system paths from the compiled binary. // -ldflags="-buildid= -w -s" further reduces the binary size: @@ -211,6 +252,16 @@ func GetBuildCmd(inputFile string, outputFile string, rootFolder string) *exec.C inputFile, ) buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") + default: + buildCmd = exec.Command( + "go", + "build", + "-o", outputFile, + "-trimpath", + "-ldflags=-buildid= -w -s", + inputFile, + ) + buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") } buildCmd.Dir = rootFolder diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index ea8d3480..d335e609 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -30,8 +30,9 @@ const SecretsFileName = "secrets.yaml" type TemplateLanguage string const ( - TemplateLangGo TemplateLanguage = "go" - TemplateLangTS TemplateLanguage = "typescript" + TemplateLangGo TemplateLanguage = "go" + TemplateLangTS TemplateLanguage = "typescript" + TemplateLangWasm TemplateLanguage = "wasm" ) const ( @@ -75,6 +76,14 @@ var languageTemplates = []LanguageTemplate{ {Folder: "typescriptConfHTTP", Title: "Confidential Http: Typescript example using the confidential http capability", ID: 5, Name: ConfHTTPTemplate, Hidden: true}, }, }, + { + Title: "Self-compiled WASM (advanced)", + Lang: TemplateLangWasm, + EntryPoint: "./wasm/workflow.wasm", + Workflows: []WorkflowTemplate{ + {Folder: "wasmBlankTemplate", Title: "Blank: Self-compiled WASM workflow template", ID: 6, Name: HelloWorldTemplate}, + }, + }, } type Inputs struct { @@ -365,6 +374,8 @@ func (h *handler) Execute(inputs Inputs) error { h.runtimeContext.Workflow.Language = constants.WorkflowLanguageGolang case TemplateLangTS: h.runtimeContext.Workflow.Language = constants.WorkflowLanguageTypeScript + case TemplateLangWasm: + h.runtimeContext.Workflow.Language = constants.WorkflowLanguageWasm } } @@ -372,7 +383,8 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Println("") fmt.Println("Next steps:") - if selectedLanguageTemplate.Lang == TemplateLangGo { + switch selectedLanguageTemplate.Lang { + case TemplateLangGo: fmt.Println(" 1. Navigate to your project directory:") fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) fmt.Println("") @@ -382,7 +394,7 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Printf(" 3. (Optional) Consult %s to learn more about this template:\n\n", filepath.Join(filepath.Base(workflowDirectory), "README.md")) fmt.Println("") - } else { + case TemplateLangTS: fmt.Println(" 1. Navigate to your project directory:") fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) fmt.Println("") @@ -398,6 +410,22 @@ func (h *handler) Execute(inputs Inputs) error { fmt.Printf(" 5. (Optional) Consult %s to learn more about this template:\n\n", filepath.Join(filepath.Base(workflowDirectory), "README.md")) fmt.Println("") + case TemplateLangWasm: + fmt.Println(" 1. Navigate to your project directory:") + fmt.Printf(" cd %s\n", filepath.Base(projectRoot)) + fmt.Println("") + fmt.Println(" 2. Add your build logic to the Makefile:") + fmt.Printf(" Edit %s/Makefile and implement the 'build' target\n", filepath.Base(workflowDirectory)) + fmt.Println("") + fmt.Println(" 3. Build your workflow:") + fmt.Printf(" cd %s && make build\n", filepath.Base(workflowDirectory)) + fmt.Println("") + fmt.Println(" 4. Run the workflow on your machine:") + fmt.Printf(" cre workflow simulate %s\n", workflowName) + fmt.Println("") + fmt.Printf(" 5. (Optional) Consult %s to learn more about this template:\n\n", + filepath.Join(filepath.Base(workflowDirectory), "README.md")) + fmt.Println("") } return nil } diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl b/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl new file mode 100644 index 00000000..4d0f01be --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/Makefile.tpl @@ -0,0 +1,9 @@ +.PHONY: build + +build: + # TODO: Add your build logic here + # This target should compile your workflow to wasm/workflow.wasm + # Example for Go: + # GOOS=wasip1 GOARCH=wasm go build -o wasm/workflow.wasm . + @echo "Please implement the build target in the Makefile" + @exit 1 diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/README.md b/cmd/creinit/template/workflow/wasmBlankTemplate/README.md new file mode 100644 index 00000000..94056ad3 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/README.md @@ -0,0 +1,32 @@ +# Self-compiled WASM Workflow Template + +This template provides a blank workflow template for self-compiled WASM workflows. It includes the necessary files for a workflow, excluding workflow code. + +## Structure + +- `Makefile`: Contains a TODO on the `build` target where you should add your build logic +- `workflow.yaml`: Workflow settings file with the wasm directory configured +- `config.staging.json` and `config.production.json`: Configuration files for different environments +- `secrets.yaml`: Secrets file (if needed) + +## Steps to use + +1. **Add your build logic**: Edit the `Makefile` and implement the `build` target. This should compile your workflow to `wasm/workflow.wasm`. + +2. **Build your workflow**: Run `make build` from the workflow directory. + +3. **Simulate the workflow**: From the project root, run: + ```bash + cre workflow simulate --target=staging-settings + ``` + +## Example Makefile build target + +```makefile +build: + # TODO: Add your build logic here + # Example for Go: + # GOOS=wasip1 GOARCH=wasm go build -o wasm/workflow.wasm . + @echo "Please implement the build target in the Makefile" + exit 1 +``` diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json b/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/config.production.json @@ -0,0 +1 @@ +{} diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json b/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/config.staging.json @@ -0,0 +1 @@ +{} diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml b/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml new file mode 100644 index 00000000..7b85d864 --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/secrets.yaml @@ -0,0 +1 @@ +secretsNames: diff --git a/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md b/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md new file mode 100644 index 00000000..5a3491cd --- /dev/null +++ b/cmd/creinit/template/workflow/wasmBlankTemplate/wasm/README.md @@ -0,0 +1,3 @@ +# WASM Directory + +This directory should contain your compiled WASM file (`workflow.wasm`) after running `make build`. diff --git a/cmd/workflow/convert/convert.go b/cmd/workflow/convert/convert.go new file mode 100644 index 00000000..d226b310 --- /dev/null +++ b/cmd/workflow/convert/convert.go @@ -0,0 +1,203 @@ +package convert + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/prompt" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +const ( + wasmWorkflowPath = "./wasm/workflow.wasm" + convertWarning = "This will convert your workflow to a custom (self-compiled) build. This cannot be undone by the CLI. Continue?" +) + +type Inputs struct { + WorkflowFolder string + Force bool +} + +func New(runtimeContext *runtime.Context) *cobra.Command { + var force bool + convertCmd := &cobra.Command{ + Use: "convert-to-custom-build ", + Short: "Converts an existing workflow to a custom (self-compiled) build", + Long: `Converts a Go or TypeScript workflow to use a custom build via Makefile, producing wasm/workflow.wasm. The workflow-path in workflow.yaml is updated to ./wasm/workflow.wasm. This cannot be undone.`, + Args: cobra.ExactArgs(1), + Example: `cre workflow convert-to-custom-build ./my-workflow`, + RunE: func(cmd *cobra.Command, args []string) error { + handler := newHandler(runtimeContext, cmd.InOrStdin()) + inputs := Inputs{ + WorkflowFolder: args[0], + Force: force, + } + return handler.Execute(inputs) + }, + } + convertCmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt and convert immediately") + return convertCmd +} + +type handler struct { + log *zerolog.Logger + stdin io.Reader + runtimeContext *runtime.Context +} + +func newHandler(runtimeContext *runtime.Context, stdin io.Reader) *handler { + h := &handler{stdin: stdin, runtimeContext: runtimeContext} + if runtimeContext != nil { + h.log = runtimeContext.Logger + } + return h +} + +func (h *handler) Execute(inputs Inputs) error { + workflowDir, err := filepath.Abs(inputs.WorkflowFolder) + if err != nil { + return fmt.Errorf("workflow folder path: %w", err) + } + workflowYAML := filepath.Join(workflowDir, constants.DefaultWorkflowSettingsFileName) + currentPath, err := settings.GetWorkflowPathFromFile(workflowYAML) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("workflow folder does not contain %s: %w", constants.DefaultWorkflowSettingsFileName, err) + } + return err + } + workflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, currentPath) + if err != nil { + return fmt.Errorf("cannot detect workflow language: %w", err) + } + lang := cmdcommon.GetWorkflowLanguage(workflowPath) + if lang == constants.WorkflowLanguageWasm { + return fmt.Errorf("workflow is already a custom build (workflow-path is %s)", currentPath) + } + + if !inputs.Force { + confirmed, err := prompt.YesNoPrompt(h.stdin, convertWarning) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Convert cancelled.") + return nil + } + } + + if err := settings.SetWorkflowPathInFile(workflowYAML, wasmWorkflowPath); err != nil { + return err + } + + wasmDir := filepath.Join(workflowDir, "wasm") + if err := os.MkdirAll(wasmDir, 0755); err != nil { + return fmt.Errorf("create wasm directory: %w", err) + } + + makefilePath := filepath.Join(workflowDir, "Makefile") + makefile, err := makefileContent(workflowDir, lang) + if err != nil { + return err + } + if err := os.WriteFile(makefilePath, []byte(makefile), 0600); err != nil { + return fmt.Errorf("write Makefile: %w", err) + } + + fmt.Println("Workflow converted to custom build. workflow-path is now", wasmWorkflowPath) + fmt.Println("Implement the build target in the Makefile, then run: make build") + return nil +} + +const ( + creSdkNodeModulesPath = "node_modules/@chainlink/cre-sdk" + creScriptsDir = "scripts/src" + creDotCreDir = ".cre" +) + +func goMakefile() string { + return `.PHONY: build + +build: + GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o wasm/workflow.wasm -trimpath -ldflags="-buildid= -w -s" . +` +} + +func makefileContent(workflowDir, lang string) (string, error) { + switch lang { + case constants.WorkflowLanguageGolang: + return goMakefile(), nil + case constants.WorkflowLanguageTypeScript: + return makefileContentTS(workflowDir) + default: + return "", fmt.Errorf("unsupported workflow language") + } +} + +func makefileContentTS(workflowDir string) (string, error) { + sdkRoot := filepath.Join(workflowDir, creSdkNodeModulesPath) + compileToJS := filepath.Join(sdkRoot, creScriptsDir, "compile-to-js.ts") + wrapper := filepath.Join(sdkRoot, creScriptsDir, "workflow-wrapper.ts") + if _, err := os.Stat(compileToJS); err != nil { + if os.IsNotExist(err) { + if _, errDir := os.Stat(filepath.Join(workflowDir, "node_modules")); os.IsNotExist(errDir) { + cmd := exec.Command("bun", "install") + cmd.Dir = workflowDir + if out, runErr := cmd.CombinedOutput(); runErr != nil { + return "", fmt.Errorf("TypeScript workflow requires dependencies; run 'bun install' in the workflow directory: %w\n%s", runErr, out) + } + } + } + if _, err = os.Stat(compileToJS); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("TypeScript workflow requires %s; run 'bun install' in the workflow directory first", creSdkNodeModulesPath) + } + return "", fmt.Errorf("check %s: %w", compileToJS, err) + } + } + if _, err := os.Stat(wrapper); err != nil { + return "", fmt.Errorf("workflow-wrapper not found: %w", err) + } + + dotCre := filepath.Join(workflowDir, creDotCreDir) + if err := os.MkdirAll(dotCre, 0755); err != nil { + return "", fmt.Errorf("create %s: %w", creDotCreDir, err) + } + + compileToJSBytes, err := os.ReadFile(compileToJS) + if err != nil { + return "", err + } + compileToJSSrc := string(compileToJSBytes) + compileToJSSrc = strings.Replace(compileToJSSrc, "process.argv.slice(3)", "process.argv.slice(2)", 1) + compileToJSSrc = strings.TrimSuffix(compileToJSSrc, "\n") + "\n\nmain().catch((err: unknown) => { console.error(err); process.exit(1); })\n" + if err := os.WriteFile(filepath.Join(dotCre, "compile-to-js.ts"), []byte(compileToJSSrc), 0600); err != nil { + return "", err + } + + wrapperBytes, err := os.ReadFile(wrapper) + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(dotCre, "workflow-wrapper.ts"), wrapperBytes, 0600); err != nil { + return "", err + } + + return `.PHONY: build + +build: + bun .cre/compile-to-js.ts main.ts $(CURDIR)/wasm/workflow.js + bunx cre-compile-workflow $(CURDIR)/wasm/workflow.js $(CURDIR)/wasm/workflow.wasm +`, nil +} diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go new file mode 100644 index 00000000..03ccbb1b --- /dev/null +++ b/cmd/workflow/convert/convert_test.go @@ -0,0 +1,199 @@ +package convert + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +const selectDownEnter = "\033[B\n" // down arrow + Enter (selects "No" in YesNoPrompt) + +func TestConvert_AlreadyWasm_ReturnsError(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + yamlContent := `staging-settings: + user-workflow: + workflow-name: "foo-staging" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "foo-production" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "already a custom build") +} + +func TestConvert_Force_UpdatesYAMLAndCreatesMakefile(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + + require.DirExists(t, filepath.Join(dir, "wasm")) + makefile := filepath.Join(dir, "Makefile") + require.FileExists(t, makefile) + content, _ := os.ReadFile(makefile) + require.Contains(t, string(content), "build") +} + +func TestConvert_PromptNo_Cancels(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt: ["Yes", "No"]; down+Enter selects No + h := newHandler(nil, strings.NewReader(selectDownEnter)) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), "workflow-path: \".\"") + require.NotContains(t, string(data), wasmWorkflowPath) + require.NoFileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_PromptYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt: default is Yes; Enter proceeds + h := newHandler(nil, strings.NewReader("\n")) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) + require.DirExists(t, filepath.Join(dir, "wasm")) +} + +func TestConvert_PromptEmpty_DefaultsYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + // YesNoPrompt defaults to Yes; Enter proceeds + h := newHandler(nil, strings.NewReader("\n")) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_TS_InstallsDepsIfNoNodeModules(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainTS := filepath.Join(dir, "main.ts") + packageJSON := filepath.Join(dir, "package.json") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainTS, []byte("export default function run() { return Promise.resolve({ result: \"ok\" }); }\n"), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.0.3"}}`), 0600)) + + h := newHandler(nil, nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "Makefile")) + require.FileExists(t, filepath.Join(dir, ".cre", "compile-to-js.ts")) + require.DirExists(t, filepath.Join(dir, "node_modules", "@chainlink", "cre-sdk")) +} diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index 51387569..e7280dc4 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -3,16 +3,13 @@ package deploy import ( "bytes" "encoding/base64" - "errors" "fmt" "os" - "path/filepath" "strings" "github.com/andybalholm/brotli" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" ) func (h *handler) Compile() error { @@ -34,60 +31,20 @@ func (h *handler) Compile() error { h.inputs.OutputPath += ".b64" // Append ".b64" if it doesn't already end with ".b64" } - workflowAbsFile, err := filepath.Abs(h.inputs.WorkflowPath) + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(h.inputs.WorkflowPath) if err != nil { - return fmt.Errorf("failed to get absolute path for the workflow file: %w", err) + return fmt.Errorf("failed to resolve workflow path: %w", err) } - - if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { - return fmt.Errorf("workflow file not found: %s", workflowAbsFile) - } - - workflowRootFolder := filepath.Dir(h.inputs.WorkflowPath) - - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(h.inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension if h.runtimeContext != nil { h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) - } } - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - buildOutput, err := buildCmd.CombinedOutput() + wasmFile, err := cmdcommon.CompileWorkflowToWasm(h.inputs.WorkflowPath) if err != nil { - fmt.Println(string(buildOutput)) - - out := strings.TrimSpace(string(buildOutput)) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + return fmt.Errorf("failed to compile workflow: %w", err) } - h.log.Debug().Msgf("Build output: %s", buildOutput) fmt.Println("Workflow compiled successfully") - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFile, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - compressedFile, err := applyBrotliCompressionV2(&wasmFile) if err != nil { return fmt.Errorf("failed to compress WASM binary: %w", err) @@ -99,10 +56,6 @@ func (h *handler) Compile() error { } h.log.Debug().Msg("WASM binary encoded") - if err = os.Remove(tmpWasmLocation); err != nil { - return fmt.Errorf("failed to remove the temporary file: %w", err) - } - return nil } diff --git a/cmd/workflow/deploy/compile_test.go b/cmd/workflow/deploy/compile_test.go index d0ebadd8..dea76f10 100644 --- a/cmd/workflow/deploy/compile_test.go +++ b/cmd/workflow/deploy/compile_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" @@ -165,245 +166,61 @@ func TestCompileCmd(t *testing.T) { httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) - tests := []struct { - inputs Inputs - wantErr string - compilationErr string - WorkflowOwnerType string - }{ - { - inputs: Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, - WorkflowOwnerType: constants.WorkflowOwnerTypeEOA, - wantErr: "failed to compile workflow: exit status 1", - compilationErr: "undefined: sdk.RemovedFunctionThatFailsCompilation", - }, - } - - for _, tt := range tests { - t.Run(tt.wantErr, func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - ctx, buf := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - handler := newHandler(ctx, buf) - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - tt.WorkflowOwnerType, - "test_workflow", - tt.inputs.WorkflowPath, - tt.inputs.ConfigPath, - ) - handler.settings = ctx.Settings - handler.inputs = tt.inputs - err := handler.ValidateInputs() - require.NoError(t, err) - - err = handler.Execute() - - w.Close() - os.Stdout = oldStdout - var output strings.Builder - _, _ = io.Copy(&output, r) - - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - - if tt.compilationErr != "" { - assert.Contains(t, output.String(), tt.compilationErr) - } - }) - } - }) - - t.Run("no config", func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - ctx, _ := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - constants.WorkflowOwnerTypeEOA, - "test_workflow", - "testdata/configless_workflow/main.go", - "", - ) - - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "configless_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - - t.Run("with config", func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - OutputPath: outputPath, - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - - t.Run("compiles even without go.mod", func(t *testing.T) { - // it auto falls back to using the go.mod in the root directory (/cre-cli) simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) defer simulatedEnvironment.Close() - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - err := runCompile(simulatedEnvironment, Inputs{ WorkflowName: "test_workflow", WorkflowOwner: chainsim.TestAddress, DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "missing_go_mod", "main.go"), + WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), OutputPath: outputPath, WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to compile workflow") + assert.ErrorContains(t, err, "undefined: sdk.RemovedFunctionThatFailsCompilation") }) - }) } -func TestCompileCreatesBase64EncodedFile(t *testing.T) { +func TestCompileOutputMatchesUnderlying(t *testing.T) { simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) + baseInputs := Inputs{ + WorkflowName: "test_workflow", + WorkflowOwner: chainsim.TestAddress, + DonFamily: "test_label", + WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), + ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), + WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", + WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", + } - t.Run("default output file is binary.wasm.br", func(t *testing.T) { - expectedOutputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(expectedOutputPath) - - require.NoError(t, err) - assert.FileExists(t, expectedOutputPath) + t.Run("default output path", func(t *testing.T) { + inputs := baseInputs + inputs.OutputPath = "./binary.wasm.br.b64" + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) - t.Run("ensures output file has .wasm.br.b64 extension", func(t *testing.T) { + t.Run("output path extension variants", func(t *testing.T) { tests := []struct { - name string - outputPath string - expectedOutput string + name string + outputPath string }{ - { - name: "no extension", - outputPath: "./my-binary", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .br and .b64", - outputPath: "./my-binary.wasm", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .b64", - outputPath: "./my-binary.wasm.br", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions", - outputPath: "./my-binary.wasm.br.b64", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions - same as default", - outputPath: "./binary.wasm.br.b64", - expectedOutput: "./binary.wasm.br.b64", - }, + {"no extension", "./my-binary"}, + {"missing .br and .b64", "./my-binary.wasm"}, + {"missing .b64", "./my-binary.wasm.br"}, + {"all extensions", "./my-binary.wasm.br.b64"}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: tt.outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(tt.expectedOutput) - - require.NoError(t, err) - assert.FileExists(t, tt.expectedOutput) + inputs := baseInputs + inputs.OutputPath = tt.outputPath + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) } }) - - t.Run("output file is base64 encoded", func(t *testing.T) { - outputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - assert.FileExists(t, outputPath) - - // Read the output file content - content, err := os.ReadFile(outputPath) - require.NoError(t, err) - - // Check if the content is valid base64 - _, err = base64.StdEncoding.DecodeString(string(content)) - assert.NoError(t, err, "Output file content should be valid base64 encoded data") - }) } // createTestSettings is a helper function to construct settings for tests @@ -458,3 +275,40 @@ func runCompile(simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inpu return handler.Compile() } + +// outputPathWithExtensions returns the path with .wasm.br.b64 appended as in Compile(). +func outputPathWithExtensions(path string) string { + if path == "" { + path = defaultOutputPath + } + if !strings.HasSuffix(path, ".b64") { + if !strings.HasSuffix(path, ".br") { + if !strings.HasSuffix(path, ".wasm") { + path += ".wasm" + } + path += ".br" + } + path += ".b64" + } + return path +} + +// assertCompileOutputMatchesUnderlying compiles via handler.Compile(), then verifies the output +// file content equals CompileWorkflowToWasm(workflowPath) + brotli + base64. +func assertCompileOutputMatchesUnderlying(t *testing.T, simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inputs, ownerType string) { + t.Helper() + wasm, err := cmdcommon.CompileWorkflowToWasm(inputs.WorkflowPath) + require.NoError(t, err) + compressed, err := applyBrotliCompressionV2(&wasm) + require.NoError(t, err) + expected := base64.StdEncoding.EncodeToString(compressed) + + err = runCompile(simulatedEnvironment, inputs, ownerType) + require.NoError(t, err) + + actualPath := outputPathWithExtensions(inputs.OutputPath) + t.Cleanup(func() { _ = os.Remove(actualPath) }) + actual, err := os.ReadFile(actualPath) + require.NoError(t, err) + assert.Equal(t, expected, string(actual), "handler.Compile() output should match CompileWorkflowToWasm + brotli + base64") +} diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1623235d..97657955 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -5,13 +5,11 @@ import ( "context" "crypto/ecdsa" "encoding/json" - "errors" "fmt" "math" "math/big" "os" "os/signal" - "path/filepath" "strconv" "strings" "syscall" @@ -40,7 +38,6 @@ import ( v2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/v2" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/validation" @@ -194,54 +191,20 @@ func (h *handler) ValidateInputs(inputs Inputs) error { } func (h *handler) Execute(inputs Inputs) error { - // Compile the workflow - // terminal command: GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags="-buildid= -w -s" -o - workflowRootFolder := filepath.Dir(inputs.WorkflowPath) - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(inputs.WorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } if h.runtimeContext != nil { h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) - } } - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - // Execute the build command - buildOutput, err := buildCmd.CombinedOutput() + wasmFileBinary, err := cmdcommon.CompileWorkflowToWasm(inputs.WorkflowPath) if err != nil { - out := strings.TrimSpace(string(buildOutput)) - h.log.Info().Msg(out) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + return fmt.Errorf("failed to compile workflow: %w", err) } - h.log.Debug().Msgf("Build output: %s", buildOutput) fmt.Println("Workflow compiled") - // Read the compiled workflow binary - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFileBinary, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - // Read the config file var config []byte if inputs.ConfigPath != "" { diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 72e5b699..bc4298c2 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/workflow/activate" + "github.com/smartcontractkit/cre-cli/cmd/workflow/convert" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" @@ -20,6 +21,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { } workflowCmd.AddCommand(activate.New(runtimeContext)) + workflowCmd.AddCommand(convert.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) workflowCmd.AddCommand(pause.New(runtimeContext)) workflowCmd.AddCommand(test.New(runtimeContext)) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 7c0c854c..c213add7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -53,6 +53,7 @@ const ( WorkflowLanguageGolang = "golang" WorkflowLanguageTypeScript = "typescript" + WorkflowLanguageWasm = "wasm" TestAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" TestAddress2 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index cf62e3d9..5d7bf476 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -3,14 +3,81 @@ package settings import ( "fmt" "net/url" + "os" "strings" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" + "sigs.k8s.io/yaml" ) +// GetWorkflowPathFromFile reads workflow-path from a workflow.yaml file (same value deploy/simulate get from Settings). +func GetWorkflowPathFromFile(workflowYAMLPath string) (string, error) { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return "", fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return "", fmt.Errorf("parse workflow settings: %w", err) + } + return workflowPathFromRaw(raw) +} + +// SetWorkflowPathInFile sets workflow-path in workflow.yaml (both staging-settings and production-settings) and writes the file. +func SetWorkflowPathInFile(workflowYAMLPath, newPath string) error { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse workflow settings: %w", err) + } + setWorkflowPathInRaw(raw, newPath) + out, err := yaml.Marshal(raw) + if err != nil { + return fmt.Errorf("marshal workflow settings: %w", err) + } + if err := os.WriteFile(workflowYAMLPath, out, 0600); err != nil { + return fmt.Errorf("write workflow settings: %w", err) + } + return nil +} + +func workflowPathFromRaw(raw map[string]interface{}) (string, error) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + if p, ok := artifacts["workflow-path"].(string); ok && p != "" { + return p, nil + } + } + return "", fmt.Errorf("workflow-path not found in workflow settings") +} + +func setWorkflowPathInRaw(raw map[string]interface{}, path string) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + artifacts["workflow-path"] = path + } +} + type WorkflowSettings struct { UserWorkflowSettings struct { WorkflowOwnerAddress string `mapstructure:"workflow-owner-address" yaml:"workflow-owner-address"` diff --git a/internal/testutil/graphql_mock.go b/internal/testutil/graphql_mock.go new file mode 100644 index 00000000..98bbd188 --- /dev/null +++ b/internal/testutil/graphql_mock.go @@ -0,0 +1,42 @@ +package testutil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +// NewGraphQLMockServerGetOrganization starts an httptest.Server that responds to +// getOrganization with a fixed organizationId. It sets EnvVarGraphQLURL so CLI +// commands use this server. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { + var req struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(req.Query, "getOrganization") { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "getOrganization": map[string]any{"organizationId": "test-org-id"}, + }, + }) + return + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, + }) + } + })) + t.Setenv(environments.EnvVarGraphQLURL, srv.URL+"/graphql") + return srv +} diff --git a/test/convert_simulate_helper.go b/test/convert_simulate_helper.go new file mode 100644 index 00000000..61d186b8 --- /dev/null +++ b/test/convert_simulate_helper.go @@ -0,0 +1,86 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func convertSimulateCaptureOutput(t *testing.T, projectRoot, workflowName string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (before convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + return stdout.String() +} + +func convertSimulateRequireOutputContains(t *testing.T, projectRoot, workflowName, expectedSubstring string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (after convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), expectedSubstring, + "simulate output after convert should contain %q", expectedSubstring) +} + +// ConvertSimulateBeforeAfter runs simulate (capture output), convert, make build, +// then simulate again and verifies output contains the same expectedSubstring. +func ConvertSimulateBeforeAfter(t *testing.T, projectRoot, workflowDir, workflowName, expectedSubstring string) { + t.Helper() + beforeOutput := convertSimulateCaptureOutput(t, projectRoot, workflowName) + require.Contains(t, beforeOutput, expectedSubstring, + "baseline simulate output should contain %q", expectedSubstring) + convertRunConvert(t, projectRoot, workflowDir) + convertRunMakeBuild(t, workflowDir) + convertRequireWasmExists(t, workflowDir) + convertSimulateRequireOutputContains(t, projectRoot, workflowName, expectedSubstring) +} + +func convertRunConvert(t *testing.T, projectRoot, workflowDir string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "convert-to-custom-build", workflowDir, "-f") + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "convert failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} + +func convertRunMakeBuild(t *testing.T, workflowDir string, env ...string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command("make", "build") + cmd.Dir = workflowDir + cmd.Env = append(os.Environ(), env...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "make build failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} + +func convertRequireWasmExists(t *testing.T, workflowDir string) { + t.Helper() + require.FileExists(t, filepath.Join(workflowDir, "wasm", "workflow.wasm"), + "wasm/workflow.wasm should exist after make build") +} diff --git a/test/graphql_mock.go b/test/graphql_mock.go new file mode 100644 index 00000000..8298fe02 --- /dev/null +++ b/test/graphql_mock.go @@ -0,0 +1,14 @@ +package test + +import ( + "net/http/httptest" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +// NewGraphQLMockServerGetOrganization starts a mock GraphQL server that responds to +// getOrganization and sets EnvVarGraphQLURL. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + return testutil.NewGraphQLMockServerGetOrganization(t) +} diff --git a/test/init_and_binding_generation_and_simulate_go_test.go b/test/init_and_binding_generation_and_simulate_go_test.go index c12d9d1c..190d1922 100644 --- a/test/init_and_binding_generation_and_simulate_go_test.go +++ b/test/init_and_binding_generation_and_simulate_go_test.go @@ -2,20 +2,15 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -33,41 +28,9 @@ func TestE2EInit_DevPoRTemplate(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_and_simulate_ts_test.go b/test/init_and_simulate_ts_test.go index b4265c54..563ba5a9 100644 --- a/test/init_and_simulate_ts_test.go +++ b/test/init_and_simulate_ts_test.go @@ -2,19 +2,14 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -32,41 +27,9 @@ func TestE2EInit_DevPoRTemplateTS(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_and_simulate_wasm_test.go b/test/init_and_simulate_wasm_test.go new file mode 100644 index 00000000..ae691bee --- /dev/null +++ b/test/init_and_simulate_wasm_test.go @@ -0,0 +1,215 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestE2EInit_WasmBlankTemplate(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-init-wasm-test" + workflowName := "wasmWorkflow" + templateID := "6" + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + ethKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + t.Setenv(settings.EthPrivateKeyEnvVar, ethKey) + + // Set dummy API key + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with WASM template --- + initArgs := []string{ + "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + } + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, initArgs...) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + + require.NoError( + t, + initCmd.Run(), + "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultEnvFileName)) + require.DirExists(t, workflowDirectory) + + expectedFiles := []string{"README.md", "Makefile", "workflow.yaml", "config.staging.json", "config.production.json", "secrets.yaml"} + for _, f := range expectedFiles { + require.FileExists(t, filepath.Join(workflowDirectory, f), "missing workflow file %q", f) + } + + // Create wasm directory + wasmDir := filepath.Join(workflowDirectory, "wasm") + require.NoError(t, os.MkdirAll(wasmDir, 0755)) + + // Create a simple Go workflow file similar to blankTemplate but with custom build tag + mainGoContent := `//go:build wasip1 && customwasm + +package main + +import ( + "fmt" + "log/slog" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" +) + +type ExecutionResult struct { + Result string +} + +type Config struct{} + +func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) { + cronTrigger := cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}) + return cre.Workflow[*Config]{ + cre.Handler(cronTrigger, onCronTrigger), + }, nil +} + +func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (*ExecutionResult, error) { + logger := runtime.Logger() + scheduledTime := trigger.ScheduledExecutionTime.AsTime() + logger.Info("Cron trigger fired", "scheduledTime", scheduledTime) + return &ExecutionResult{Result: fmt.Sprintf("Fired at %s", scheduledTime)}, nil +} + +func main() { + wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow) +} +` + mainGoPath := filepath.Join(workflowDirectory, "main.go") + require.NoError(t, os.WriteFile(mainGoPath, []byte(mainGoContent), 0600)) + + // Create go.mod file - will be updated by go mod tidy + goModContent := `module wasm-workflow + +go 1.25.3 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.1.3 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.0.0-beta.0 +) +` + goModPath := filepath.Join(workflowDirectory, "go.mod") + require.NoError(t, os.WriteFile(goModPath, []byte(goModContent), 0600)) + + // Update Makefile to include build command with custom build tag + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefileContent := `.PHONY: build + +build: + GOOS=wasip1 GOARCH=wasm go build -tags customwasm -o wasm/workflow.wasm . +` + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileContent), 0600)) + + // Run go mod tidy to resolve dependencies + stdout.Reset() + stderr.Reset() + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = workflowDirectory + tidyCmd.Stdout = &stdout + tidyCmd.Stderr = &stderr + + require.NoError( + t, + tidyCmd.Run(), + "go mod tidy failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Build the workflow using make build + stdout.Reset() + stderr.Reset() + buildCmd := exec.Command("make", "build") + buildCmd.Dir = workflowDirectory + buildCmd.Stdout = &stdout + buildCmd.Stderr = &stderr + + require.NoError( + t, + buildCmd.Run(), + "make build failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Verify WASM file was created + wasmFilePath := filepath.Join(wasmDir, "workflow.wasm") + require.FileExists(t, wasmFilePath, "WASM file should be created by make build") + + // --- cre workflow simulate wasmWorkflow --- + stdout.Reset() + stderr.Reset() + simulateArgs := []string{ + "workflow", "simulate", + workflowName, + "--project-root", projectRoot, + "--non-interactive", + "--trigger-index=0", + } + simulateCmd := exec.Command(CLIPath, simulateArgs...) + simulateCmd.Dir = projectRoot + simulateCmd.Stdout = &stdout + simulateCmd.Stderr = &stderr + + require.NoError( + t, + simulateCmd.Run(), + "cre workflow simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // --- cre workflow compile wasmWorkflow --- + stdout.Reset() + stderr.Reset() + compileArgs := []string{ + "workflow", "compile", + filepath.Join(workflowDirectory, "workflow.yaml"), + "--project-root", projectRoot, + } + compileCmd := exec.Command(CLIPath, compileArgs...) + compileCmd.Dir = projectRoot + compileCmd.Stdout = &stdout + compileCmd.Stderr = &stderr + + require.NoError( + t, + compileCmd.Run(), + "cre workflow compile failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), + stderr.String(), + ) + + // Verify compiled output exists + outputPath := filepath.Join(workflowDirectory, "binary.wasm.br.b64") + require.FileExists(t, outputPath, "compiled output should exist") +} diff --git a/test/init_convert_simulate_go_test.go b/test/init_convert_simulate_go_test.go new file mode 100644 index 00000000..595072b9 --- /dev/null +++ b/test/init_convert_simulate_go_test.go @@ -0,0 +1,113 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_Go: init (blank Go), simulate (capture), convert, make build, simulate (require match), +// then add FlagProof/constA/constB/Makefile FLAG, make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_Go(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-go" + workflowName := "goWorkflow" + templateID := "2" // blank Go template + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with blank Go template --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.go")) + + // go mod tidy so simulate can build + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = workflowDirectory + tidyCmd.Stdout = &stdout + tidyCmd.Stderr = &stderr + require.NoError(t, tidyCmd.Run(), "go mod tidy failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + // Before/after: simulate (capture), convert, make build, simulate (verify same key content) + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Fired at") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + + // Now make test-specific changes: FlagProof, constA/constB, Makefile FLAG + mainPath := filepath.Join(workflowDirectory, "main.go") + mainBytes, err := os.ReadFile(mainPath) + require.NoError(t, err) + mainStr := string(mainBytes) + mainStr = strings.Replace(mainStr, "type ExecutionResult struct {\n\tResult string\n}", "type ExecutionResult struct {\n\tResult string\n\tFlagProof string\n}", 1) + mainStr = strings.Replace(mainStr, "\t// Your logic here...\n\n\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime)}, nil", + "\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime), FlagProof: FlagProof}, nil", 1) + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + constA := `//go:build customFlag + +package main + +const FlagProof = "set" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constA.go"), []byte(constA), 0600)) + + constB := `//go:build !customFlag + +package main + +const FlagProof = "unset" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constB.go"), []byte(constB), 0600)) + + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefile, err := os.ReadFile(makefilePath) + require.NoError(t, err) + makefileStr := strings.Replace(string(makefile), "go build -o", "go build -tags $(FLAG) -o", 1) + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileStr), 0600)) + + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "set", "FlagProof") + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "unset", "FlagProof") +} + +func convertGoBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr, wantSubstr2 string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) + require.Contains(t, stdout.String(), wantSubstr2) +} diff --git a/test/init_convert_simulate_ts_test.go b/test/init_convert_simulate_ts_test.go new file mode 100644 index 00000000..a84b238e --- /dev/null +++ b/test/init_convert_simulate_ts_test.go @@ -0,0 +1,113 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_TS: init (typescriptSimpleExample), bun install, simulate (capture), +// convert (copies script from node_modules), make build, simulate (require match), then patch .cre/compile-to-js +// for declare/inject and main.ts for BUILD_FLAG, make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_TS(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-ts" + workflowName := "tsWorkflow" + templateID := "3" // typescriptSimpleExample + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with typescriptSimpleExample --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.ts")) + + // bun install so simulate can build and convert can find node_modules + installCmd := exec.Command("bun", "install") + installCmd.Dir = workflowDirectory + installCmd.Stdout = &stdout + installCmd.Stderr = &stderr + require.NoError(t, installCmd.Run(), "bun install failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + // Before/after: simulate (capture), convert, make build, simulate (verify same key content) + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Hello world!") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + require.FileExists(t, filepath.Join(workflowDirectory, ".cre", "compile-to-js.ts")) + require.FileExists(t, filepath.Join(workflowDirectory, ".cre", "workflow-wrapper.ts")) + + // Patch .cre/compile-to-js.ts to inject BUILD_FLAG from env FLAG at build time (declare + inject) + compileToJSPath := filepath.Join(workflowDirectory, ".cre", "compile-to-js.ts") + compileToJSBytes, err := os.ReadFile(compileToJSPath) + require.NoError(t, err) + compileToJSSrc := string(compileToJSBytes) + definePatch := "define: process.env.FLAG !== undefined ? { BUILD_FLAG: JSON.stringify(process.env.FLAG) } : {}," + compileToJSSrc = strings.Replace(compileToJSSrc, + "naming: path.basename(resolvedOutput),\n\t })", + "naming: path.basename(resolvedOutput),\n\t\t"+definePatch+"\n\t })", + 1) + require.NoError(t, os.WriteFile(compileToJSPath, []byte(compileToJSSrc), 0600)) + + // Patch main.ts: declare const BUILD_FLAG and return message based on it + mainPath := filepath.Join(workflowDirectory, "main.ts") + mainBytes, err := os.ReadFile(mainPath) + require.NoError(t, err) + mainStr := string(mainBytes) + mainStr = "declare const BUILD_FLAG: string;\n" + mainStr + mainStr = strings.Replace(mainStr, "return \"Hello world!\";", + "return BUILD_FLAG === 'customFlag' ? \"Hello World (custom)\" : \"Hello World (default)\";", 1) + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + // Patch Makefile to pass FLAG so the script sees process.env.FLAG + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefileBytes, err := os.ReadFile(makefilePath) + require.NoError(t, err) + makefileStr := string(makefileBytes) + makefileStr = strings.Replace(makefileStr, "bun .cre/compile-to-js.ts", "FLAG=$(FLAG) bun .cre/compile-to-js.ts", 1) + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileStr), 0600)) + + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "Hello World (custom)") + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "Hello World (default)") +} + +func convertTSBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) +} diff --git a/test/multi_command_flows/workflow_simulator_path.go b/test/multi_command_flows/workflow_simulator_path.go index e99fcba4..66cc3c6a 100644 --- a/test/multi_command_flows/workflow_simulator_path.go +++ b/test/multi_command_flows/workflow_simulator_path.go @@ -8,13 +8,12 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "time" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/testutil" ) type testEVMConfig struct { @@ -80,37 +79,9 @@ func RunSimulationHappyPath(t *testing.T, tc TestConfig, projectDir string) { t.Helper() t.Run("Simulate", func(t *testing.T) { - // Set up GraphQL mock server for authentication validation - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req graphQLRequest - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := testutil.NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - srv := startMockPORServer(t) patchWorkflowConfigURL(t, projectDir, "por_workflow", srv.URL)