From 3c6df3a1efcec288c776d76310e74a68cc4ad8e5 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Mon, 9 Sep 2024 14:53:56 -0400 Subject: [PATCH] Add unit test runner for workflows (#751) --- pkg/capabilities/cli/cmd/generator_test.go | 13 +- pkg/workflows/dependency_graph.go | 6 +- pkg/workflows/testutils/compute_capability.go | 35 +++ pkg/workflows/testutils/mocks.go | 113 +++++++--- pkg/workflows/testutils/runner.go | 205 +++++++++++++++++- pkg/workflows/testutils/runner_test.go | 174 +++++++++++---- 6 files changed, 445 insertions(+), 101 deletions(-) create mode 100644 pkg/workflows/testutils/compute_capability.go diff --git a/pkg/capabilities/cli/cmd/generator_test.go b/pkg/capabilities/cli/cmd/generator_test.go index cff97dd91..0f2e50ae0 100644 --- a/pkg/capabilities/cli/cmd/generator_test.go +++ b/pkg/capabilities/cli/cmd/generator_test.go @@ -19,6 +19,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/nestedaction" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/referenceaction" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/referenceaction/referenceactiontest" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink-common/pkg/workflows/testutils" ) @@ -243,7 +244,7 @@ func TestTypeGeneration(t *testing.T) { func TestMockGeneration(t *testing.T) { t.Run("Basic trigger", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) capMock := basictriggertest.Trigger(runner, func() (basictrigger.TriggerOutputs, error) { return basictrigger.TriggerOutputs{}, nil }) @@ -255,7 +256,7 @@ func TestMockGeneration(t *testing.T) { }) t.Run("Basic action", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) // nolint value is never used but it's assigned to mock to verify the type capMock := basicactiontest.Action(runner, func(_ basicaction.ActionInputs) (basicaction.ActionOutputs, error) { @@ -275,7 +276,7 @@ func TestMockGeneration(t *testing.T) { }) t.Run("Basic target", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) capMock := basictargettest.Target(runner, func(_ basictarget.TargetInputs) error { return nil }) @@ -287,7 +288,7 @@ func TestMockGeneration(t *testing.T) { }) t.Run("References", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) // nolint value is never used but it's assigned to mock to verify the type capMock := referenceactiontest.Action(runner, func(_ referenceaction.SomeInputs) (referenceaction.SomeOutputs, error) { @@ -307,7 +308,7 @@ func TestMockGeneration(t *testing.T) { }) t.Run("External references", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) // nolint value is never used but it's assigned to mock to verify the type capMock := externalreferenceactiontest.Action(runner, func(_ referenceaction.SomeInputs) (referenceaction.SomeOutputs, error) { @@ -330,7 +331,7 @@ func TestMockGeneration(t *testing.T) { // no need to test nesting, we don't generate anything different for the mock's t.Run("Array action", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) // nolint value is never used but it's assigned to mock to verify the type capMock := arrayactiontest.Action(runner, func(_ arrayaction.ActionInputs) ([]arrayaction.ActionOutputsElem, error) { return []arrayaction.ActionOutputsElem{}, nil diff --git a/pkg/workflows/dependency_graph.go b/pkg/workflows/dependency_graph.go index cd2456623..d0d2b3da2 100644 --- a/pkg/workflows/dependency_graph.go +++ b/pkg/workflows/dependency_graph.go @@ -95,6 +95,10 @@ func ParseDependencyGraph(yamlWorkflow string) (*DependencyGraph, error) { return nil, err } + return BuildDependencyGraph(spec) +} + +func BuildDependencyGraph(spec WorkflowSpec) (*DependencyGraph, error) { // Construct and validate the graph. We instantiate an // empty graph with just one starting entry: `trigger`. // This provides the starting point for our graph and @@ -110,7 +114,7 @@ func ParseDependencyGraph(yamlWorkflow string) (*DependencyGraph, error) { graph.PreventCycles(), graph.Directed(), ) - err = g.AddVertex(&Vertex{ + err := g.AddVertex(&Vertex{ StepDefinition: StepDefinition{Ref: KeywordTrigger}, }) if err != nil { diff --git a/pkg/workflows/testutils/compute_capability.go b/pkg/workflows/testutils/compute_capability.go new file mode 100644 index 000000000..8226fcc0f --- /dev/null +++ b/pkg/workflows/testutils/compute_capability.go @@ -0,0 +1,35 @@ +package testutils + +import ( + "context" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + "github.com/smartcontractkit/chainlink-common/pkg/workflows" +) + +type computeCapability struct { + sdk workflows.SDK + callback func(sdk workflows.SDK, request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) +} + +func (c *computeCapability) Info(ctx context.Context) (capabilities.CapabilityInfo, error) { + info := capabilities.MustNewCapabilityInfo( + "__internal__custom_compute@1.0.0", capabilities.CapabilityTypeAction, "Custom compute capability", + ) + info.IsLocal = true + return info, nil +} + +func (c *computeCapability) RegisterToWorkflow(ctx context.Context, request capabilities.RegisterToWorkflowRequest) error { + return nil +} + +func (c *computeCapability) UnregisterFromWorkflow(ctx context.Context, request capabilities.UnregisterFromWorkflowRequest) error { + return nil +} + +func (c *computeCapability) Execute(ctx context.Context, request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) { + return c.callback(c.sdk, request) +} + +var _ capabilities.ActionCapability = &computeCapability{} diff --git a/pkg/workflows/testutils/mocks.go b/pkg/workflows/testutils/mocks.go index ce303694a..0ea6ddbf0 100644 --- a/pkg/workflows/testutils/mocks.go +++ b/pkg/workflows/testutils/mocks.go @@ -1,20 +1,19 @@ package testutils import ( + "context" + "encoding/json" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/values" ) -// CapabilityMock allows for mocking of capabilities in a workflow -// they can be registered for a particular reference or entirely -// Note that registrations for a step are taken over registrations for a capability when there are both. -type CapabilityMock interface { - Run(request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) - ID() string +func MockCapability[I, O any](id string, fn func(I) (O, error)) *Mock[I, O] { + return &Mock[I, O]{mockBase: mockCapabilityBase[I, O](id, fn)} } -func MockCapability[I, O any](id string, fn func(I) (O, error)) *Mock[I, O] { - return &Mock[I, O]{ +func mockCapabilityBase[I, O any](id string, fn func(I) (O, error)) *mockBase[I, O] { + return &mockBase[I, O]{ id: id, inputs: map[string]I{}, outputs: map[string]O{}, @@ -23,7 +22,7 @@ func MockCapability[I, O any](id string, fn func(I) (O, error)) *Mock[I, O] { } } -type Mock[I, O any] struct { +type mockBase[I, O any] struct { id string inputs map[string]I outputs map[string]O @@ -31,10 +30,23 @@ type Mock[I, O any] struct { fn func(I) (O, error) } -var _ CapabilityMock = &Mock[any, any]{} +var _ capabilities.ExecutableCapability = &Mock[any, any]{} -func (m *Mock[I, O]) Run(request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) { +func (m *mockBase[I, O]) Info(ctx context.Context) (capabilities.CapabilityInfo, error) { + return capabilities.CapabilityInfo{ID: m.id, IsLocal: true}, nil +} + +func (m *mockBase[I, O]) RegisterToWorkflow(ctx context.Context, request capabilities.RegisterToWorkflowRequest) error { + return nil +} + +func (m *mockBase[I, O]) UnregisterFromWorkflow(ctx context.Context, request capabilities.UnregisterFromWorkflowRequest) error { + return nil +} + +func (m *mockBase[I, O]) Execute(ctx context.Context, request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) { var i I + if err := request.Inputs.UnwrapTo(&i); err != nil { m.errors[request.Metadata.ReferenceID] = err return capabilities.CapabilityResponse{}, err @@ -42,6 +54,19 @@ func (m *Mock[I, O]) Run(request capabilities.CapabilityRequest) (capabilities.C m.inputs[request.Metadata.ReferenceID] = i + // validate against schema + var tmp I + b, err := json.Marshal(i) + if err != nil { + m.errors[request.Metadata.ReferenceID] = err + return capabilities.CapabilityResponse{}, err + } + + if err = json.Unmarshal(b, &tmp); err != nil { + m.errors[request.Metadata.ReferenceID] = err + return capabilities.CapabilityResponse{}, err + } + result, err := m.fn(i) if err != nil { m.errors[request.Metadata.ReferenceID] = err @@ -59,17 +84,21 @@ func (m *Mock[I, O]) Run(request capabilities.CapabilityRequest) (capabilities.C return capabilities.CapabilityResponse{Value: wrapped}, nil } -func (m *Mock[I, O]) ID() string { +func (m *mockBase[I, O]) ID() string { return m.id } -func (m *Mock[I, O]) GetStep(ref string) StepResults[I, O] { +func (m *mockBase[I, O]) GetStep(ref string) StepResults[I, O] { input, ran := m.inputs[ref] output := m.outputs[ref] err := m.errors[ref] return StepResults[I, O]{WasRun: ran, Input: input, Output: output, Error: err} } +type Mock[I, O any] struct { + *mockBase[I, O] +} + type StepResults[I, O any] struct { WasRun bool Input I @@ -90,60 +119,72 @@ type TriggerResults[O any] struct { func MockTrigger[O any](id string, fn func() (O, error)) *TriggerMock[O] { return &TriggerMock[O]{ - mock: MockCapability[struct{}, O](id, func(struct{}) (O, error) { + mockBase: mockCapabilityBase[struct{}, O](id, func(struct{}) (O, error) { return fn() }), } } type TriggerMock[O any] struct { - mock *Mock[struct{}, O] + *mockBase[struct{}, O] } -func (t *TriggerMock[O]) Run(request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) { - return t.mock.Run(request) +var _ capabilities.TriggerCapability = &TriggerMock[any]{} + +func (t *TriggerMock[O]) RegisterTrigger(ctx context.Context, request capabilities.TriggerRegistrationRequest) (<-chan capabilities.TriggerResponse, error) { + result, err := t.mockBase.fn(struct{}{}) + + wrapped, wErr := values.CreateMapFromStruct(result) + if wErr != nil { + return nil, wErr + } + + response := capabilities.TriggerResponse{ + Event: capabilities.TriggerEvent{ + TriggerType: "Mock " + t.ID(), + ID: t.ID(), + Outputs: wrapped, + }, + Err: err, + } + ch := make(chan capabilities.TriggerResponse, 1) + ch <- response + close(ch) + return ch, nil } -func (t *TriggerMock[O]) ID() string { - return t.mock.ID() +func (t *TriggerMock[O]) UnregisterTrigger(ctx context.Context, request capabilities.TriggerRegistrationRequest) error { + return nil } func (t *TriggerMock[O]) GetStep() TriggerResults[O] { - step := t.mock.GetStep("trigger") + step := t.mockBase.GetStep("trigger") return TriggerResults[O]{Output: step.Output, Error: step.Error} } -var _ CapabilityMock = &TriggerMock[any]{} - type TargetMock[I any] struct { - mock *Mock[I, struct{}] + *mockBase[I, struct{}] } func MockTarget[I any](id string, fn func(I) error) *TargetMock[I] { return &TargetMock[I]{ - mock: MockCapability[I, struct{}](id, func(i I) (struct{}, error) { + mockBase: mockCapabilityBase[I, struct{}](id, func(i I) (struct{}, error) { return struct{}{}, fn(i) }), } } -func (t *TargetMock[I]) Run(request capabilities.CapabilityRequest) (capabilities.CapabilityResponse, error) { - return t.mock.Run(request) -} - -func (t *TargetMock[I]) ID() string { - return t.mock.ID() -} +var _ capabilities.TargetCapability = &TargetMock[any]{} func (t *TargetMock[I]) GetAllWrites() TargetResults[I] { targetResults := TargetResults[I]{} - for ref := range t.mock.inputs { + for ref := range t.mockBase.inputs { targetResults.NumRuns++ - step := t.mock.GetStep(ref) + step := t.mockBase.GetStep(ref) targetResults.Inputs = append(targetResults.Inputs, step.Input) - targetResults.Errors = append(targetResults.Errors, step.Error) + if step.Error != nil { + targetResults.Errors = append(targetResults.Errors, step.Error) + } } return targetResults } - -var _ CapabilityMock = &TargetMock[any]{} diff --git a/pkg/workflows/testutils/runner.go b/pkg/workflows/testutils/runner.go index a1be28f35..219d406e2 100644 --- a/pkg/workflows/testutils/runner.go +++ b/pkg/workflows/testutils/runner.go @@ -1,25 +1,47 @@ package testutils import ( + "context" "errors" "fmt" + "github.com/dominikbraun/graph" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/values" "github.com/smartcontractkit/chainlink-common/pkg/workflows" + "github.com/smartcontractkit/chainlink-common/pkg/workflows/exec" ) -func NewRunner() *Runner { - return &Runner{registry: map[string]CapabilityMock{}} +func NewRunner(ctx context.Context) *Runner { + return &Runner{ + ctx: ctx, + registry: map[string]capabilities.ExecutableCapability{}, + results: runnerResults{}, + idToStep: map[string]workflows.StepDefinition{}, + dependencies: map[string][]string{}, + sdk: &SDK{}, + } } type ConsensusMock interface { - CapabilityMock + capabilities.ConsensusCapability SingleToManyObservations(value values.Value) (*values.Map, error) } type Runner struct { - registry map[string]CapabilityMock - errors []error + // Context is held in this runner because it's for testing and capability calls are made by it. + // The real SDK implementation will be for the WASM guest and will make host calls, and callbacks to the program. + // nolint + ctx context.Context + trigger capabilities.TriggerCapability + registry map[string]capabilities.ExecutableCapability + am map[string]map[string]graph.Edge[string] + results runnerResults + idToStep map[string]workflows.StepDefinition + dependencies map[string][]string + sdk workflows.SDK + errors []error } var _ workflows.Runner = &Runner{} @@ -34,16 +56,69 @@ func (r *Runner) Run(factory *workflows.WorkflowSpecFactory) error { return err } - // TODO https://smartcontract-it.atlassian.net/browse/KS-442, implement this function - _ = spec - return errors.New("TODO https://smartcontract-it.atlassian.net/browse/KS-442") + if err = r.ensureGraph(spec); err != nil { + return err + } + + r.setupSteps(factory, spec) + + return r.walk(spec, workflows.KeywordTrigger) +} + +func (r *Runner) ensureGraph(spec workflows.WorkflowSpec) error { + g, err := workflows.BuildDependencyGraph(spec) + if err != nil { + return err + } + + if len(g.Triggers) != 1 { + return fmt.Errorf("expected exactly 1 trigger, got %d", len(g.Triggers)) + } + + edges, err := g.Edges() + if err != nil { + return err + } + + for _, edge := range edges { + r.dependencies[edge.Target] = append(r.dependencies[edge.Target], edge.Source) + } + + r.am, err = g.AdjacencyMap() + return err +} + +func (r *Runner) setupSteps(factory *workflows.WorkflowSpecFactory, spec workflows.WorkflowSpec) { + for _, step := range spec.Steps() { + if step.Ref == "" { + step.Ref = step.ID + } + + r.idToStep[step.Ref] = step + + // if the factory has a method, it's custom compute, we'll run that compute. + if run := factory.GetFn(step.Ref); run != nil { + compute := &computeCapability{ + sdk: r.sdk, + callback: run, + } + info, err := compute.Info(r.ctx) + if err != nil { + r.errors = append(r.errors, err) + continue + } + r.MockCapability(info.ID, &step.Ref, compute) + } + } + r.idToStep[workflows.KeywordTrigger] = spec.Triggers[0] } // MockCapability registers a new capability mock with the runner // if the step is not nil, the capability will be registered for that step // If a step is explicitly mocked, that will take priority over a mock of the entire capability. // This is best used with generated code to ensure correctness -func (r *Runner) MockCapability(name string, step *string, capability CapabilityMock) { +// Note that mocks of custom compute will not be used in place of the user's code +func (r *Runner) MockCapability(name string, step *string, capability capabilities.ExecutableCapability) { fullName := getFullName(name, step) if r.registry[fullName] != nil { forSuffix := "" @@ -56,7 +131,11 @@ func (r *Runner) MockCapability(name string, step *string, capability Capability r.registry[fullName] = capability } -func (r *Runner) GetRegisteredMock(name string, step string) CapabilityMock { +func (r *Runner) MockTrigger(trigger capabilities.TriggerCapability) { + r.trigger = trigger +} + +func (r *Runner) GetRegisteredMock(name string, step string) capabilities.ActionCapability { fullName := getFullName(name, &step) if c, ok := r.registry[fullName]; ok { return c @@ -65,6 +144,103 @@ func (r *Runner) GetRegisteredMock(name string, step string) CapabilityMock { return r.registry[name] } +func (r *Runner) walk(spec workflows.WorkflowSpec, ref string) error { + capability := r.idToStep[ref] + mock := r.GetRegisteredMock(capability.ID, ref) + if mock == nil { + return fmt.Errorf("no mock found for capability %s on step %s", capability.ID, ref) + } + + request, err := r.buildRequest(spec, capability) + if err != nil { + return err + } + + if c, ok := mock.(ConsensusMock); ok { + if request.Inputs, err = c.SingleToManyObservations(request.Inputs); err != nil { + return err + } + } + + results, err := mock.Execute(r.ctx, request) + if err != nil { + return err + } + + r.results[ref] = &exec.Result{ + Inputs: request.Inputs, + Outputs: results.Value, + } + + edges, ok := r.am[ref] + if !ok { + return nil + } + + return r.walkNext(spec, edges) +} + +func (r *Runner) buildRequest(spec workflows.WorkflowSpec, capability workflows.StepDefinition) (capabilities.CapabilityRequest, error) { + conf, err := values.NewMap(capability.Config) + if err != nil { + return capabilities.CapabilityRequest{}, err + } + + inputs, err := r.buildInput(capability) + if err != nil { + return capabilities.CapabilityRequest{}, err + } + + request := capabilities.CapabilityRequest{ + Metadata: capabilities.RequestMetadata{ + WorkflowOwner: spec.Owner, + WorkflowName: spec.Name, + ReferenceID: capability.Ref, + }, + Config: conf, + Inputs: inputs, + } + return request, nil +} + +func (r *Runner) walkNext(spec workflows.WorkflowSpec, edges map[string]graph.Edge[string]) error { + var errs []error + for edgeRef := range edges { + if r.isReady(edgeRef) { + if err := r.walk(spec, edgeRef); err != nil { + errs = append(errs, err) + } + } + } + + return errors.Join(errs...) +} + +func (r *Runner) buildInput(capability workflows.StepDefinition) (*values.Map, error) { + var input any + if capability.Inputs.OutputRef != "" { + input = capability.Inputs.OutputRef + } else { + input = capability.Inputs.Mapping + } + + val, err := exec.FindAndInterpolateAllKeys(input, r.results) + if err != nil { + return nil, err + } + return values.NewMap(val.(map[string]any)) +} + +func (r *Runner) isReady(ref string) bool { + for _, dep := range r.dependencies[ref] { + if _, ok := r.results[dep]; !ok { + return false + } + } + + return true +} + func getFullName(name string, step *string) string { fullName := name if step != nil { @@ -72,3 +248,12 @@ func getFullName(name string, step *string) string { } return fullName } + +type runnerResults map[string]*exec.Result + +func (f runnerResults) ResultForStep(s string) (*exec.Result, bool) { + r, ok := f[s] + return r, ok +} + +var _ exec.Results = runnerResults{} diff --git a/pkg/workflows/testutils/runner_test.go b/pkg/workflows/testutils/runner_test.go index 9c0cdea63..65df24c32 100644 --- a/pkg/workflows/testutils/runner_test.go +++ b/pkg/workflows/testutils/runner_test.go @@ -1,6 +1,7 @@ package testutils_test import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -16,6 +17,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/consensus/ocr3/ocr3test" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/targets/chainwriter" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/targets/chainwriter/chainwritertest" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" "github.com/smartcontractkit/chainlink-common/pkg/workflows" "github.com/smartcontractkit/chainlink-common/pkg/workflows/testutils" ) @@ -23,75 +25,132 @@ import ( func TestRunner(t *testing.T) { t.Parallel() t.Run("Run runs a workflow for a single execution", func(t *testing.T) { - t.Skip("TODO https://smartcontract-it.atlassian.net/browse/KS-442, written earlier for a more full look and feel") helper := &testHelper{t: t} - workflow := createTestWorkflow(helper.transformTrigger) + workflow := createBasicTestWorkflow(helper.transformTrigger) - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) - triggerMock := basictriggertest.Trigger(runner, func() (basictrigger.TriggerOutputs, error) { - return basictrigger.TriggerOutputs{CoolOutput: "cool"}, nil - }) - - actionMock := basicactiontest.Action(runner, func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { - assert.True(t, input.InputThing) - return basicaction.ActionOutputs{AdaptedThing: "it was true"}, nil - }) - - consensusMock := ocr3test.IdenticalConsensus[basicaction.ActionOutputs](runner) - - targetMock := chainwritertest.Target(runner, "chainwriter@1.0.0", func(input chainwriter.TargetInputs) error { - return nil - }) + triggerMock, actionMock, consensusMock, targetMock := setupAllRunnerMocks(t, runner) - err := runner.Run(workflow) - require.NoError(t, err) + require.NoError(t, runner.Run(workflow)) trigger := triggerMock.GetStep() - assert.NotNil(t, trigger) assert.NoError(t, trigger.Error) assert.Equal(t, "cool", trigger.Output.CoolOutput) action := actionMock.GetStep("action") - assert.NotNil(t, action) + assert.True(t, action.WasRun) assert.Equal(t, basicaction.ActionInputs{InputThing: true}, action.Input) assert.NoError(t, action.Error) assert.Equal(t, "it was true", action.Output.AdaptedThing) assert.True(t, helper.transformTriggerCalled) - consensus := consensusMock.GetStepDecoded("Consensus") + consensus := consensusMock.GetStepDecoded("consensus") assert.Equal(t, "it was true", consensus.Output.AdaptedThing) - require.Len(t, consensus.Input, 1) + require.Len(t, consensus.Input.Observations, 1) + rawConsensus := consensusMock.GetStep("consensus") target := targetMock.GetAllWrites() - require.NoError(t, err) - assert.Empty(t, target.Errors) + assert.Len(t, target.Errors, 0) assert.Len(t, target.Inputs, 1) - assert.Equal(t, consensus.Output, target.Inputs[0].SignedReport) + assert.Equal(t, rawConsensus.Output, target.Inputs[0].SignedReport) }) - t.Run("Captures errors", func(t *testing.T) { - t.Skip("TODO https://smartcontract-it.atlassian.net/browse/KS-442") - assert.Fail(t, "Not implemented") + t.Run("Run allows hard-coded values", func(t *testing.T) { + workflow := workflows.NewWorkflowSpecFactory(workflows.NewWorkflowParams{Name: "tester", Owner: "ryan"}) + trigger := basictrigger.TriggerConfig{Name: "trigger", Number: 100}.New(workflow) + hardCodedInput := basicaction.NewActionOutputsFromFields(workflows.ConstantDefinition("hard-coded")) + tTransform := workflows.Compute2[basictrigger.TriggerOutputs, basicaction.ActionOutputs, bool]( + workflow, + "transform", + workflows.Compute2Inputs[basictrigger.TriggerOutputs, basicaction.ActionOutputs]{Arg0: trigger, Arg1: hardCodedInput}, + func(SDK workflows.SDK, tr basictrigger.TriggerOutputs, hc basicaction.ActionOutputs) (bool, error) { + assert.Equal(t, "hard-coded", hc.AdaptedThing) + assert.NotNil(t, SDK) + assert.Equal(t, "cool", tr.CoolOutput) + return true, nil + }) + + action := basicaction.ActionConfig{CamelCaseInSchemaForTesting: "name", SnakeCaseInSchemaForTesting: 20}. + New(workflow, "action", basicaction.ActionInput{InputThing: tTransform.Value()}) + + consensus := ocr3.IdenticalConsensusConfig[basicaction.ActionOutputs]{ + Encoder: "Test", + EncoderConfig: ocr3.EncoderConfig{}, + }.New(workflow, "consensus", ocr3.IdenticalConsensusInput[basicaction.ActionOutputs]{Observations: action}) + + chainwriter.TargetConfig{ + Address: "0x123", + DeltaStage: "2m", + Schedule: "oneAtATime", + }.New(workflow, "chainwriter@1.0.0", chainwriter.TargetInput{SignedReport: consensus}) + + runner := testutils.NewRunner(tests.Context(t)) + _, _, _, targetMock := setupAllRunnerMocks(t, runner) + + require.NoError(t, runner.Run(workflow)) + target := targetMock.GetAllWrites() + assert.Len(t, target.Inputs, 1) }) - t.Run("Fails if MockCapability is not provided for a step that is run", func(t *testing.T) { - t.Skip("TODO https://smartcontract-it.atlassian.net/browse/KS-442") - assert.Fail(t, "Not implemented") + t.Run("Run returns errors if capabilities were registered multiple times", func(t *testing.T) { + helper := &testHelper{t: t} + workflow := createBasicTestWorkflow(helper.transformTrigger) + runner := testutils.NewRunner(tests.Context(t)) + setupAllRunnerMocks(t, runner) + setupAllRunnerMocks(t, runner) + + require.Error(t, runner.Run(workflow)) }) - t.Run("Fails build if workflow spec generation fails", func(t *testing.T) { - t.Skip("TODO https://smartcontract-it.atlassian.net/browse/KS-442") - assert.Fail(t, "Not implemented") + t.Run("Run captures errors", func(t *testing.T) { + expectedErr := errors.New("nope") + wf := createBasicTestWorkflow(func(SDK workflows.SDK, outputs basictrigger.TriggerOutputs) (bool, error) { + return false, expectedErr + }) + + runner := testutils.NewRunner(tests.Context(t)) + + basictriggertest.Trigger(runner, func() (basictrigger.TriggerOutputs, error) { + return basictrigger.TriggerOutputs{CoolOutput: "cool"}, nil + }) + + basicactiontest.Action(runner, func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { + return basicaction.ActionOutputs{AdaptedThing: "it was true"}, nil + }) + + consensusMock := ocr3test.IdenticalConsensus[basicaction.ActionOutputs](runner) + + err := runner.Run(wf) + assert.True(t, errors.Is(err, expectedErr)) + + consensus := consensusMock.GetStep("consensus") + assert.False(t, consensus.WasRun) }) - t.Run("Fails build if not all leafs are targets", func(t *testing.T) { - t.Skip("TODO https://smartcontract-it.atlassian.net/browse/KS-442") - assert.Fail(t, "Not implemented") + t.Run("Run fails if MockCapability is not provided for a step that is run", func(t *testing.T) { + helper := &testHelper{t: t} + workflow := createBasicTestWorkflow(helper.transformTrigger) + + runner := testutils.NewRunner(tests.Context(t)) + + basictriggertest.Trigger(runner, func() (basictrigger.TriggerOutputs, error) { + return basictrigger.TriggerOutputs{CoolOutput: "cool"}, nil + }) + + ocr3test.IdenticalConsensus[basicaction.ActionOutputs](runner) + + chainwritertest.Target(runner, "chainwriter@1.0.0", func(input chainwriter.TargetInputs) error { + return nil + }) + + err := runner.Run(workflow) + require.Error(t, err) + require.Equal(t, "no mock found for capability basic-test-action@1.0.0 on step action", err.Error()) }) t.Run("GetRegisteredMock returns the mock for a step", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) expected := basicactiontest.ActionForStep(runner, "action", func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { return basicaction.ActionOutputs{}, nil }) @@ -106,7 +165,7 @@ func TestRunner(t *testing.T) { }) t.Run("GetRegisteredMock returns a default mock if step wasn't specified", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) expected := basicactiontest.Action(runner, func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { return basicaction.ActionOutputs{}, nil }) @@ -115,7 +174,7 @@ func TestRunner(t *testing.T) { }) t.Run("GetRegisteredMock returns nil if no mock was registered", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) referenceactiontest.Action(runner, func(input referenceaction.SomeInputs) (referenceaction.SomeOutputs, error) { return referenceaction.SomeOutputs{}, nil }) @@ -123,7 +182,7 @@ func TestRunner(t *testing.T) { }) t.Run("GetRegisteredMock returns nil if no mock was registered for a step", func(t *testing.T) { - runner := testutils.NewRunner() + runner := testutils.NewRunner(tests.Context(t)) differentStep := basicactiontest.ActionForStep(runner, "step", func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { return basicaction.ActionOutputs{}, nil }) @@ -132,9 +191,27 @@ func TestRunner(t *testing.T) { }) } -type actionTransform func(sdk workflows.SDK, outputs basictrigger.TriggerOutputs) (bool, error) +func setupAllRunnerMocks(t *testing.T, runner *testutils.Runner) (*testutils.TriggerMock[basictrigger.TriggerOutputs], *testutils.Mock[basicaction.ActionInputs, basicaction.ActionOutputs], *ocr3test.IdenticalConsensusMock[basicaction.ActionOutputs], *testutils.TargetMock[chainwriter.TargetInputs]) { + triggerMock := basictriggertest.Trigger(runner, func() (basictrigger.TriggerOutputs, error) { + return basictrigger.TriggerOutputs{CoolOutput: "cool"}, nil + }) + + actionMock := basicactiontest.Action(runner, func(input basicaction.ActionInputs) (basicaction.ActionOutputs, error) { + assert.True(t, input.InputThing) + return basicaction.ActionOutputs{AdaptedThing: "it was true"}, nil + }) + + consensusMock := ocr3test.IdenticalConsensus[basicaction.ActionOutputs](runner) + + targetMock := chainwritertest.Target(runner, "chainwriter@1.0.0", func(input chainwriter.TargetInputs) error { + return nil + }) + return triggerMock, actionMock, consensusMock, targetMock +} + +type actionTransform func(SDK workflows.SDK, outputs basictrigger.TriggerOutputs) (bool, error) -func createTestWorkflow(actionTransform actionTransform) *workflows.WorkflowSpecFactory { +func createBasicTestWorkflow(actionTransform actionTransform) *workflows.WorkflowSpecFactory { workflow := workflows.NewWorkflowSpecFactory(workflows.NewWorkflowParams{Name: "tester", Owner: "ryan"}) trigger := basictrigger.TriggerConfig{Name: "trigger", Number: 100}.New(workflow) tTransform := workflows.Compute1[basictrigger.TriggerOutputs, bool]( @@ -143,8 +220,8 @@ func createTestWorkflow(actionTransform actionTransform) *workflows.WorkflowSpec workflows.Compute1Inputs[basictrigger.TriggerOutputs]{Arg0: trigger}, actionTransform) - action := basicaction.ActionConfig{CamelCaseInSchemaForTesting: "action", SnakeCaseInSchemaForTesting: 20}. - New(workflow, "basic action", basicaction.ActionInput{InputThing: tTransform.Value()}) + action := basicaction.ActionConfig{CamelCaseInSchemaForTesting: "name", SnakeCaseInSchemaForTesting: 20}. + New(workflow, "action", basicaction.ActionInput{InputThing: tTransform.Value()}) consensus := ocr3.IdenticalConsensusConfig[basicaction.ActionOutputs]{ Encoder: "Test", @@ -156,6 +233,7 @@ func createTestWorkflow(actionTransform actionTransform) *workflows.WorkflowSpec DeltaStage: "2m", Schedule: "oneAtATime", }.New(workflow, "chainwriter@1.0.0", chainwriter.TargetInput{SignedReport: consensus}) + return workflow } @@ -164,8 +242,8 @@ type testHelper struct { transformTriggerCalled bool } -func (helper *testHelper) transformTrigger(sdk workflows.SDK, outputs basictrigger.TriggerOutputs) (bool, error) { - assert.NotNil(helper.t, sdk) +func (helper *testHelper) transformTrigger(SDK workflows.SDK, outputs basictrigger.TriggerOutputs) (bool, error) { + assert.NotNil(helper.t, SDK) assert.Equal(helper.t, "cool", outputs.CoolOutput) assert.False(helper.t, helper.transformTriggerCalled) helper.transformTriggerCalled = true