Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 43 additions & 19 deletions cmd/workflow/simulate/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"math/big"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1054,26 +1075,29 @@ 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")
}

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 {
Expand Down
161 changes: 161 additions & 0 deletions cmd/workflow/simulate/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions internal/runtime/runtime_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading