From c77dbbed4431d024df4c16ef4ca2edc036fee051 Mon Sep 17 00:00:00 2001 From: Tarcisio Ferraz Date: Thu, 2 Apr 2026 10:19:29 -0300 Subject: [PATCH 1/2] Resolve simulation file paths relative to invocation directory --- cmd/root.go | 5 +++ cmd/workflow/simulate/simulate.go | 62 ++++++++++++++++++++--------- internal/runtime/runtime_context.go | 3 ++ 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index d70b735a..9734c412 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -242,6 +242,11 @@ func newRootCommand() *cobra.Command { if showSpinner { spinner.Update("Loading settings...") } + // Capture the invocation directory before SetExecutionContext changes it. + if invocationDir, err := os.Getwd(); err == nil { + runtimeContext.InvocationDir = invocationDir + } + // Set execution context (project root + workflow directory if applicable) projectRootFlag := runtimeContext.Viper.GetString(settings.Flags.ProjectRoot.Name) if err := context.SetExecutionContext(cmd, args, projectRootFlag, rootLogger); err != nil { diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 771eef49..c832e87e 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -9,6 +9,7 @@ import ( "math/big" "os" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -65,6 +66,10 @@ type Inputs struct { ExperimentalForwarders map[uint64]common.Address `validate:"-"` // forwarders keyed by chain ID // Limits enforcement LimitsPath string `validate:"-"` // "default" or path to custom limits JSON + // InvocationDir is the working directory at the time the CLI was invoked, before + // SetExecutionContext changes it to the workflow directory. Used to resolve file + // paths entered interactively or via flags relative to where the user ran the command. + InvocationDir string `validate:"-"` } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -240,6 +245,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) EVMEventIndex: v.GetInt("evm-event-index"), ExperimentalForwarders: experimentalForwarders, LimitsPath: v.GetString("limits"), + InvocationDir: h.runtimeContext.InvocationDir, }, nil } @@ -737,7 +743,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } case trigger == "http-trigger@1.0.0-alpha": - payload, err := getHTTPTriggerPayload() + payload, err := getHTTPTriggerPayload(inputs.InvocationDir) if err != nil { ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) os.Exit(1) @@ -816,7 +822,7 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp ui.Error("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") os.Exit(1) } - payload, err := getHTTPTriggerPayloadFromInput(inputs.HTTPPayload) + payload, err := getHTTPTriggerPayloadFromInput(inputs.HTTPPayload, inputs.InvocationDir) if err != nil { ui.Error(fmt.Sprintf("Failed to parse HTTP trigger payload: %v", err)) os.Exit(1) @@ -893,8 +899,11 @@ func cleanupBeholder() error { return nil } -// getHTTPTriggerPayload prompts user for HTTP trigger data -func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { +// getHTTPTriggerPayload prompts user for HTTP trigger data. +// invocationDir is the working directory at the time the CLI was invoked; relative +// paths entered by the user are resolved against it rather than the current working +// directory (which may have been changed to the workflow folder by SetExecutionContext). +func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) { ui.Line() input, err := ui.Input("HTTP Trigger Configuration", ui.WithInputDescription("Enter a file path or JSON directly for the HTTP trigger"), @@ -911,19 +920,22 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { var jsonData map[string]interface{} - // Check if input is a file path - if _, err := os.Stat(input); err == nil { - // It's a file path - data, err := os.ReadFile(input) + // Resolve the path against the invocation directory so that relative paths + // like ./production.json work from where the user ran the command, even though + // the process cwd has been changed to the workflow subdirectory. + resolvedPath := resolvePathFromInvocation(input, invocationDir) + + if _, err := os.Stat(resolvedPath); err == nil { + data, err := os.ReadFile(resolvedPath) if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", input, err) + return nil, fmt.Errorf("failed to read file %s: %w", resolvedPath, err) } if err := json.Unmarshal(data, &jsonData); err != nil { - return nil, fmt.Errorf("failed to parse JSON from file %s: %w", input, err) + return nil, fmt.Errorf("failed to parse JSON from file %s: %w", resolvedPath, err) } - ui.Success(fmt.Sprintf("Loaded JSON from file: %s", input)) + ui.Success(fmt.Sprintf("Loaded JSON from file: %s", resolvedPath)) } else { - // It's direct JSON input + // Treat as direct JSON input if err := json.Unmarshal([]byte(input), &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } @@ -934,7 +946,6 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { if err != nil { return nil, fmt.Errorf("failed to marshal JSON: %w", err) } - // Create the payload payload := &httptypedapi.Payload{ Input: jsonDataBytes, // Key is optional for simulation @@ -944,6 +955,16 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { return payload, nil } +// resolvePathFromInvocation converts a (potentially relative) path to an absolute +// path anchored at invocationDir. Absolute paths and paths that are already +// reachable from the current working directory are returned unchanged. +func resolvePathFromInvocation(path, invocationDir string) string { + if filepath.IsAbs(path) || invocationDir == "" { + return path + } + return filepath.Join(invocationDir, path) +} + // getEVMTriggerLog prompts user for EVM trigger data and fetches the log func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Log, error) { var txHashInput string @@ -1054,8 +1075,10 @@ func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Lo return pbLog, nil } -// getHTTPTriggerPayloadFromInput builds an HTTP trigger payload from a JSON string or a file path (optionally prefixed with '@') -func getHTTPTriggerPayloadFromInput(input string) (*httptypedapi.Payload, error) { +// getHTTPTriggerPayloadFromInput builds an HTTP trigger payload from a JSON string or a file path +// (optionally prefixed with '@'). invocationDir is used to resolve relative paths against the +// directory where the user invoked the CLI rather than the current working directory. +func getHTTPTriggerPayloadFromInput(input, invocationDir string) (*httptypedapi.Payload, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return nil, fmt.Errorf("empty http payload input") @@ -1063,17 +1086,18 @@ func getHTTPTriggerPayloadFromInput(input string) (*httptypedapi.Payload, error) var raw []byte if strings.HasPrefix(trimmed, "@") { - path := strings.TrimPrefix(trimmed, "@") + path := resolvePathFromInvocation(strings.TrimPrefix(trimmed, "@"), invocationDir) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", path, err) } raw = data } else { - if _, err := os.Stat(trimmed); err == nil { - data, err := os.ReadFile(trimmed) + resolvedPath := resolvePathFromInvocation(trimmed, invocationDir) + if _, err := os.Stat(resolvedPath); err == nil { + data, err := os.ReadFile(resolvedPath) if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", trimmed, err) + return nil, fmt.Errorf("failed to read file %s: %w", resolvedPath, err) } raw = data } else { diff --git a/internal/runtime/runtime_context.go b/internal/runtime/runtime_context.go index af367838..13cb0d53 100644 --- a/internal/runtime/runtime_context.go +++ b/internal/runtime/runtime_context.go @@ -31,6 +31,9 @@ type Context struct { EnvironmentSet *environments.EnvironmentSet TenantContext *tenantctx.EnvironmentContext Workflow WorkflowRuntime + // InvocationDir is the working directory at the time the CLI was invoked, + // before any os.Chdir calls made by SetExecutionContext. + InvocationDir string } type WorkflowRuntime struct { From 81cc1fe92f7ad06943fdf46cdbf4970f7d12084a Mon Sep 17 00:00:00 2001 From: Tarcisio Ferraz Date: Thu, 2 Apr 2026 11:11:01 -0300 Subject: [PATCH 2/2] add test --- cmd/workflow/simulate/simulate_test.go | 161 +++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index b29d3f52..576f1315 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -276,6 +276,167 @@ func TestSimulateWasmFormatHandling(t *testing.T) { }) } +func TestResolvePathFromInvocation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + invocationDir string + want string + }{ + { + name: "absolute path returned unchanged regardless of invocationDir", + path: "/absolute/path/file.json", + invocationDir: "/some/other/dir", + want: "/absolute/path/file.json", + }, + { + name: "relative path with empty invocationDir returned unchanged", + path: "relative/file.json", + invocationDir: "", + want: "relative/file.json", + }, + { + name: "relative path joined with invocationDir", + path: "file.json", + invocationDir: "/invocation/dir", + want: "/invocation/dir/file.json", + }, + { + name: "relative path with subdirs joined with invocationDir", + path: "sub/dir/file.json", + invocationDir: "/invocation/dir", + want: "/invocation/dir/sub/dir/file.json", + }, + { + name: "dot-slash relative path joined with invocationDir", + path: "./file.json", + invocationDir: "/invocation/dir", + want: "/invocation/dir/file.json", + }, + { + name: "absolute path with empty invocationDir returned unchanged", + path: "/abs/path.json", + invocationDir: "", + want: "/abs/path.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := resolvePathFromInvocation(tt.path, tt.invocationDir) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetHTTPTriggerPayloadFromInput(t *testing.T) { + t.Parallel() + + // Create a temp dir with a payload file for file-based tests. + tmpDir := t.TempDir() + payloadJSON := `{"method":"GET","path":"/hello"}` + payloadFile := filepath.Join(tmpDir, "payload.json") + require.NoError(t, os.WriteFile(payloadFile, []byte(payloadJSON), 0600)) + + t.Run("empty input returns error", func(t *testing.T) { + t.Parallel() + _, err := getHTTPTriggerPayloadFromInput("", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty http payload input") + }) + + t.Run("whitespace-only input returns error", func(t *testing.T) { + t.Parallel() + _, err := getHTTPTriggerPayloadFromInput(" ", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty http payload input") + }) + + t.Run("at-prefix with absolute file path reads file", func(t *testing.T) { + t.Parallel() + payload, err := getHTTPTriggerPayloadFromInput("@"+payloadFile, "") + require.NoError(t, err) + assert.Equal(t, []byte(payloadJSON), payload.Input) + }) + + t.Run("at-prefix with relative path resolved against invocationDir", func(t *testing.T) { + t.Parallel() + payload, err := getHTTPTriggerPayloadFromInput("@payload.json", tmpDir) + require.NoError(t, err) + assert.Equal(t, []byte(payloadJSON), payload.Input) + }) + + t.Run("at-prefix with nonexistent file returns error", func(t *testing.T) { + t.Parallel() + _, err := getHTTPTriggerPayloadFromInput("@/nonexistent/no-such-file.json", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read file") + }) + + t.Run("absolute file path without at-prefix reads file", func(t *testing.T) { + t.Parallel() + payload, err := getHTTPTriggerPayloadFromInput(payloadFile, "") + require.NoError(t, err) + assert.Equal(t, []byte(payloadJSON), payload.Input) + }) + + t.Run("relative file path resolved against invocationDir reads file", func(t *testing.T) { + t.Parallel() + payload, err := getHTTPTriggerPayloadFromInput("payload.json", tmpDir) + require.NoError(t, err) + assert.Equal(t, []byte(payloadJSON), payload.Input) + }) + + t.Run("inline JSON string used as raw bytes", func(t *testing.T) { + t.Parallel() + inlineJSON := `{"method":"POST","path":"/api"}` + payload, err := getHTTPTriggerPayloadFromInput(inlineJSON, "") + require.NoError(t, err) + assert.Equal(t, []byte(inlineJSON), payload.Input) + }) + + t.Run("nonexistent relative path with empty invocationDir treated as raw bytes", func(t *testing.T) { + t.Parallel() + // A path that doesn't exist is treated as raw bytes (no error). + input := "no-such-file-or-json" + payload, err := getHTTPTriggerPayloadFromInput(input, "") + require.NoError(t, err) + assert.Equal(t, []byte(input), payload.Input) + }) + + t.Run("relative path not found in invocationDir treated as raw bytes", func(t *testing.T) { + t.Parallel() + // A relative path that resolves to a nonexistent file is used as raw bytes. + input := "does-not-exist.json" + payload, err := getHTTPTriggerPayloadFromInput(input, tmpDir) + require.NoError(t, err) + assert.Equal(t, []byte(input), payload.Input) + }) +} + +func TestSimulateResolveInputs_InvocationDir(t *testing.T) { + t.Parallel() + + invocationDir := "/some/invocation/dir" + v := createSimulateTestViper(t) + creSettings := createSimulateTestSettings("test-workflow", "main.go", "config.json") + + runtimeCtx := &runtime.Context{ + Logger: testutil.NewTestLogger(), + Viper: v, + Settings: creSettings, + InvocationDir: invocationDir, + } + h := newHandler(runtimeCtx) + + inputs, err := h.ResolveInputs(v, creSettings) + require.NoError(t, err) + assert.Equal(t, invocationDir, inputs.InvocationDir) +} + func TestSimulateConfigFlagsMutuallyExclusive(t *testing.T) { t.Parallel()