Files
langship.sh/pkg/orchestrator/walk_test.go
T

148 lines
4.5 KiB
Go

package orchestrator
import (
"context"
"errors"
"sync/atomic"
"testing"
"github.com/lyzrai/flow/pkg/engine"
"github.com/lyzrai/flow/pkg/models"
)
func newTestWorkflow() *models.WorkflowDefinition {
return &models.WorkflowDefinition{
Name: "test",
Nodes: []models.NodeDef{
{ID: "1", Name: "Trigger", Type: "flow-nodes-base.trigger", Parameters: map[string]any{}},
{ID: "2", Name: "Noop", Type: "flow-nodes-base.noOp", Parameters: map[string]any{}},
},
Connections: []models.ConnectionDef{
{SourceNode: "Trigger", SourceOutputIndex: 0, TargetNode: "Noop", TargetInputIndex: 0},
},
}
}
// fakeRunner records each step name and forwards execution.
func fakeRunner(steps *[]string) NodeRunner {
return func(ctx context.Context, stepID string, fn func(context.Context) (map[int][]models.Item, error)) (map[int][]models.Item, error) {
*steps = append(*steps, stepID)
return fn(ctx)
}
}
func passThroughLookup() engine.ExecutorLookup {
return func(_ string) (engine.NodeExecutorFunc, error) {
return func(_ context.Context, _ models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
var items []models.Item
for _, in := range inputs {
items = append(items, in...)
}
if len(items) == 0 {
items = []models.Item{{}}
}
return map[int][]models.Item{0: items}, nil
}, nil
}
}
func TestWalkDurable_runsNodesViaNodeRunner(t *testing.T) {
wf := newTestWorkflow()
var steps []string
result, err := walkDurable(context.Background(), wf, []models.Item{{"in": "x"}}, passThroughLookup(), fakeRunner(&steps))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != "success" {
t.Fatalf("expected success, got %q (errors=%v)", result.Status, result.Errors)
}
if len(steps) != 2 {
t.Fatalf("expected 2 steps via NodeRunner, got %d: %v", len(steps), steps)
}
for _, want := range []string{"node:Trigger", "node:Noop"} {
found := false
for _, s := range steps {
if s == want {
found = true
break
}
}
if !found {
t.Errorf("missing expected step %q in %v", want, steps)
}
}
}
func TestWalkDurable_collectsTerminalOutputs(t *testing.T) {
wf := newTestWorkflow()
steps := []string{}
result, err := walkDurable(context.Background(), wf, []models.Item{{"hi": "world"}}, passThroughLookup(), fakeRunner(&steps))
if err != nil {
t.Fatalf("unexpected: %v", err)
}
// Noop has no outgoing edges → it's the terminal node.
if _, ok := result.Outputs["Noop"]; !ok {
t.Fatalf("expected terminal output for Noop, got %v", result.Outputs)
}
if _, ok := result.Outputs["Trigger"]; ok {
t.Fatalf("Trigger should not be a terminal output (has downstream edge)")
}
}
func TestWalkDurable_recordsExecErrors(t *testing.T) {
wf := newTestWorkflow()
calls := int32(0)
failingLookup := func(_ string) (engine.NodeExecutorFunc, error) {
return func(_ context.Context, node models.NodeDef, _ [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
n := atomic.AddInt32(&calls, 1)
if n == 2 {
return nil, errors.New("simulated failure")
}
return map[int][]models.Item{0: {{"ok": true}}}, nil
}, nil
}
steps := []string{}
result, err := walkDurable(context.Background(), wf, []models.Item{{}}, failingLookup, fakeRunner(&steps))
if err != nil {
t.Fatalf("walk should not return error for non-terminal failures, got %v", err)
}
if result.Status != "partial_error" {
t.Fatalf("expected partial_error, got %q", result.Status)
}
if len(result.Errors) == 0 {
t.Fatal("expected error message recorded")
}
}
func TestWalkDurable_failsOnCycle(t *testing.T) {
// Two non-trigger nodes pointing at each other = cycle.
wf := &models.WorkflowDefinition{
Name: "cycle",
Nodes: []models.NodeDef{
{ID: "1", Name: "T", Type: "flow-nodes-base.trigger"},
{ID: "2", Name: "A", Type: "flow-nodes-base.noOp"},
{ID: "3", Name: "B", Type: "flow-nodes-base.noOp"},
},
Connections: []models.ConnectionDef{
{SourceNode: "T", TargetNode: "A"},
{SourceNode: "A", TargetNode: "B"},
{SourceNode: "B", TargetNode: "A"},
},
}
steps := []string{}
_, err := walkDurable(context.Background(), wf, nil, passThroughLookup(), fakeRunner(&steps))
if err == nil {
t.Fatal("expected cycle detection error")
}
}
func TestRequestMeta_isSerializable(t *testing.T) {
// Smoke check: types compile and zero values work.
r := RunRequest{RequestMeta: RequestMeta{APIKey: "k", OrgID: "o"}}
if r.RequestMeta.APIKey != "k" || r.RequestMeta.OrgID != "o" {
t.Fatal("RequestMeta fields lost on assignment")
}
}