Skip to content
Draft
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
9 changes: 8 additions & 1 deletion cmd/workflow/simulate/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ import (
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink/v2/core/capabilities"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes/gateway"
)

// httpTriggerServerPort is the port on which the local HTTP server listens
// when no --http-payload flag is supplied and the user chooses to POST the payload.
const httpTriggerServerPort = 9090

// ManualTriggers holds chain-agnostic trigger services used in simulation.
type ManualTriggers struct {
ManualCronTrigger *fakes.ManualCronTriggerService
Expand All @@ -36,7 +41,9 @@ func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, regis
return nil, err
}

manualHTTPTrigger := fakes.NewManualHTTPTriggerService(lggr)
manualHTTPTrigger := fakes.NewManualHTTPTriggerService(lggr, gateway.Config{
Port: httpTriggerServerPort,
})
manualHTTPTriggerServer := httptrigger.NewHTTPServer(manualHTTPTrigger)
if err := registry.Add(ctx, manualHTTPTriggerServer); err != nil {
return nil, err
Expand Down
70 changes: 20 additions & 50 deletions cmd/workflow/simulate/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type Inputs struct {
// Non-interactive mode options
NonInteractive bool `validate:"-"`
TriggerIndex int `validate:"-"`
HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json
HTTPPayload string `validate:"-"` // JSON string or /path/to/file.json
ChainTypeInputs map[string]string `validate:"-"` // CLI-supplied chain-type-specific trigger inputs
// Limits enforcement
LimitsPath string `validate:"-"` // "default" or path to custom limits JSON
Expand Down Expand Up @@ -103,7 +103,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command {
simulateCmd.MarkFlagsMutuallyExclusive("config", "no-config", "default-config")
// Non-interactive trigger selection flags
simulateCmd.Flags().Int("trigger-index", -1, "Index of the trigger to run (0-based)")
simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)")
simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file")

// Register chain-type-specific CLI flags (e.g., --evm-tx-hash).
chain.RegisterAllCLIFlags(simulateCmd)
Expand Down Expand Up @@ -722,11 +722,21 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs
return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, skipWaitSignal)
}
case "http-trigger@1.0.0-alpha":
payload, err := getHTTPTriggerPayload(inputs.InvocationDir)
payload, err := getHTTPTriggerPayloadFromInput(inputs.InvocationDir, inputs.HTTPPayload)
if err != nil {
ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err))
os.Exit(1)
}
if payload == nil {
ui.Line()
ui.Step("No input detected for http-trigger. Supply the payload using one of:")
ui.Dim("1. POST JSON to the local trigger server:")
ui.Dim(fmt.Sprintf(` listening at http://localhost:%d`, httpTriggerServerPort))
ui.Dim("2. Re-run with --http-payload flag:")
ui.Dim(` --http-payload '{"key":"value"}' (inline JSON)`)
ui.Dim(` --http-payload ./payload.json (path to a JSON file)`)
ui.Line()
}
holder.TriggerFunc = func() error {
return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload)
}
Expand Down Expand Up @@ -808,7 +818,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, inputs.InvocationDir)
payload, err := getHTTPTriggerPayloadFromInput(inputs.InvocationDir, inputs.HTTPPayload)
if err != nil {
ui.Error(fmt.Sprintf("Failed to parse HTTP trigger payload: %v", err))
os.Exit(1)
Expand Down Expand Up @@ -882,22 +892,13 @@ func cleanupBeholder() error {
return nil
}

// getHTTPTriggerPayload prompts user for HTTP trigger data. Relative paths are
// getHTTPTriggerPayloadFromInput prompts user for HTTP trigger data. Relative paths are
// resolved against invocationDir so file references work from where the user ran
// the command even after SetExecutionContext switches cwd to the workflow dir.
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"),
ui.WithPlaceholder(`{"key": "value"} or ./payload.json`),
)
if err != nil {
return nil, fmt.Errorf("HTTP trigger input cancelled: %w", err)
}

func getHTTPTriggerPayloadFromInput(invocationDir, input string) (*httptypedapi.Payload, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, fmt.Errorf("empty input provided")
return nil, nil
}

var jsonData map[string]interface{}
Expand All @@ -916,12 +917,14 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error)
return nil, fmt.Errorf("failed to parse JSON from file %s: %w", resolvedPath, err)
}
ui.Success(fmt.Sprintf("Loaded JSON from file: %s", resolvedPath))
} else {
} else if strings.HasPrefix(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)
}
ui.Success("Parsed JSON input successfully")
} else {
return nil, fmt.Errorf("invalid JSON input: %s", input)
}

jsonDataBytes, err := json.Marshal(jsonData)
Expand Down Expand Up @@ -956,36 +959,3 @@ func resolvePathFromInvocation(path, invocationDir string) string {
}
return filepath.Join(invocationDir, path)
}

// 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 := 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 {
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", resolvedPath, err)
}
raw = data
} else {
raw = []byte(trimmed)
}
}

return &httptypedapi.Payload{Input: raw}, nil
}
87 changes: 41 additions & 46 deletions cmd/workflow/simulate/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package simulate

import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -342,79 +343,73 @@ func TestGetHTTPTriggerPayloadFromInput(t *testing.T) {
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.Run("empty input returns nil payload and no 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, "")
payload, err := getHTTPTriggerPayloadFromInput("", "")
require.NoError(t, err)
assert.Equal(t, []byte(payloadJSON), payload.Input)
require.Nil(t, payload)
})

t.Run("at-prefix with relative path resolved against invocationDir", func(t *testing.T) {
t.Run("whitespace-only input returns nil payload and no error", func(t *testing.T) {
t.Parallel()
payload, err := getHTTPTriggerPayloadFromInput("@payload.json", tmpDir)
payload, err := getHTTPTriggerPayloadFromInput(" ", " ")
require.NoError(t, err)
assert.Equal(t, []byte(payloadJSON), payload.Input)
require.Nil(t, payload)
})

t.Run("at-prefix with nonexistent file returns error", func(t *testing.T) {
t.Run("absolute file path reads and parses JSON", 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")
payload, err := getHTTPTriggerPayloadFromInput("", payloadFile)
require.NoError(t, err)
require.NotNil(t, payload)
var got map[string]interface{}
require.NoError(t, json.Unmarshal(payload.Input, &got))
assert.Equal(t, "GET", got["method"])
assert.Equal(t, "/hello", got["path"])
})

t.Run("absolute file path without at-prefix reads file", func(t *testing.T) {
t.Run("relative path resolved against invocationDir reads and parses JSON", func(t *testing.T) {
t.Parallel()
payload, err := getHTTPTriggerPayloadFromInput(payloadFile, "")
payload, err := getHTTPTriggerPayloadFromInput(tmpDir, "payload.json")
require.NoError(t, err)
assert.Equal(t, []byte(payloadJSON), payload.Input)
require.NotNil(t, payload)
var got map[string]interface{}
require.NoError(t, json.Unmarshal(payload.Input, &got))
assert.Equal(t, "GET", got["method"])
assert.Equal(t, "/hello", got["path"])
})

t.Run("relative file path resolved against invocationDir reads file", func(t *testing.T) {
t.Run("nonexistent file path returns invalid JSON error", func(t *testing.T) {
t.Parallel()
payload, err := getHTTPTriggerPayloadFromInput("payload.json", tmpDir)
require.NoError(t, err)
assert.Equal(t, []byte(payloadJSON), payload.Input)
_, err := getHTTPTriggerPayloadFromInput("", "/nonexistent/no-such-file.json")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON input")
})

t.Run("inline JSON string used as raw bytes", func(t *testing.T) {
t.Run("inline JSON string parsed as payload", func(t *testing.T) {
t.Parallel()
inlineJSON := `{"method":"POST","path":"/api"}`
payload, err := getHTTPTriggerPayloadFromInput(inlineJSON, "")
payload, err := getHTTPTriggerPayloadFromInput("", inlineJSON)
require.NoError(t, err)
assert.Equal(t, []byte(inlineJSON), payload.Input)
require.NotNil(t, payload)
var got map[string]interface{}
require.NoError(t, json.Unmarshal(payload.Input, &got))
assert.Equal(t, "POST", got["method"])
assert.Equal(t, "/api", got["path"])
})

t.Run("nonexistent relative path with empty invocationDir treated as raw bytes", func(t *testing.T) {
t.Run("non-JSON non-file input returns error", 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)
_, err := getHTTPTriggerPayloadFromInput("", "no-such-file-or-json")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON input")
})

t.Run("relative path not found in invocationDir treated as raw bytes", func(t *testing.T) {
t.Run("relative path not found in invocationDir returns error", 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)
_, err := getHTTPTriggerPayloadFromInput(tmpDir, "does-not-exist.json")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON input")
})
}

Expand Down
2 changes: 1 addition & 1 deletion docs/cre_workflow_simulate.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cre workflow simulate ./my-workflow
--evm-event-index int EVM trigger log index (0-based) (default -1)
--evm-tx-hash string EVM trigger transaction hash (0x...)
-h, --help help for simulate
--http-payload string HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)
--http-payload string HTTP trigger payload as JSON string or path to JSON file
--limits string Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable (default "default")
--no-config Simulate without a config file
--skip-type-checks Skip TypeScript project typecheck during compilation (passes --skip-type-checks to cre-compile)
Expand Down
Loading
Loading