diff --git a/.codecov.yml b/.codecov.yml index d3a06f1..4a10c77 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -4,6 +4,7 @@ # Exclude example tasks and test files from coverage ignore: - "tasks/example_*.go" + - "**/example_*.go" - "**/*_test.go" - "mocks/" - ".github/" diff --git a/action.go b/action.go index 2da1fc0..7b11419 100644 --- a/action.go +++ b/action.go @@ -279,7 +279,25 @@ func EntityValue(gc *GlobalContext, entityType, id, key string) (interface{}, er } return nil, fmt.Errorf("task '%s' not found in context", id) } - return TaskOutputFieldAs[interface{}](gc, id, key) + // Try TaskOutputs key first + if v, err := TaskOutputFieldAs[interface{}](gc, id, key); err == nil { + return v, nil + } + // Fallback to TaskResults if present and result is a map + gc.mu.RLock() + rp, exists := gc.TaskResults[id] + gc.mu.RUnlock() + if exists && rp != nil { + res := rp.GetResult() + if m, ok := res.(map[string]interface{}); ok { + if val, ok := m[key]; ok { + return val, nil + } + return nil, fmt.Errorf("EntityValue: result key '%s' not found in task '%s'", key, id) + } + return nil, fmt.Errorf("EntityValue: task '%s' result is not a map, cannot extract key '%s'", id, key) + } + return nil, fmt.Errorf("task '%s' not found in context", id) default: return nil, fmt.Errorf("invalid entity type '%s'", entityType) } diff --git a/action_test.go b/action_test.go index f30675b..23f7208 100644 --- a/action_test.go +++ b/action_test.go @@ -731,6 +731,21 @@ func TestGenerateIDFromName(t *testing.T) { } } +// New tests to cover SetID and GetName methods +func TestAction_SetIDAndGetName(t *testing.T) { + a := &Action[*MockAction]{ + Wrapped: &MockAction{}, + } + // SetID should update ID + a.SetID("manual-id") + assert.Equal(t, "manual-id", a.GetID()) + // With empty Name, GetName should fallback to ID + assert.Equal(t, "manual-id", a.GetName()) + // When Name is set, GetName should return it + a.Name = "Friendly Name" + assert.Equal(t, "Friendly Name", a.GetName()) +} + // Helper types for testing type testStringer struct{} diff --git a/parameters_resolve_test.go b/parameters_resolve_test.go new file mode 100644 index 0000000..780925f --- /dev/null +++ b/parameters_resolve_test.go @@ -0,0 +1,95 @@ +package task_engine_test + +import ( + "context" + "testing" + + engine "github.com/ndizazzo/task-engine" +) + +// minimal ResultProvider for tests +type rp struct{ v interface{} } + +func (p rp) GetResult() interface{} { return p.v } +func (p rp) GetError() error { return nil } + +func TestParameterResolvers_ResultProviders(t *testing.T) { + gc := engine.NewGlobalContext() + + // Prepare action result (map) and task result (map) + gc.StoreActionResult("actR", rp{v: map[string]interface{}{"sum": 10, "name": "demo"}}) + gc.StoreTaskResult("taskR", rp{v: map[string]interface{}{"ok": true, "n": 3}}) + + // ActionResultParameter full result + arp := engine.ActionResult("actR") + if v, err := arp.Resolve(context.Background(), gc); err != nil { + t.Fatalf("ActionResult Resolve err: %v", err) + } else if m, ok := v.(map[string]interface{}); !ok || m["sum"].(int) != 10 { + t.Fatalf("unexpected action result: %v", v) + } + // ActionResultParameter by key + arpk := engine.ActionResultField("actR", "name") + if v, err := arpk.Resolve(context.Background(), gc); err != nil || v.(string) != "demo" { + t.Fatalf("unexpected action result key: v=%v err=%v", v, err) + } + + // TaskResultParameter full result + trp := engine.TaskResult("taskR") + if v, err := trp.Resolve(context.Background(), gc); err != nil { + t.Fatalf("TaskResult Resolve err: %v", err) + } else if m, ok := v.(map[string]interface{}); !ok || m["ok"].(bool) != true { + t.Fatalf("unexpected task result: %v", v) + } + // TaskResultParameter by key + trpk := engine.TaskResultField("taskR", "n") + if v, err := trpk.Resolve(context.Background(), gc); err != nil || v.(int) != 3 { + t.Fatalf("unexpected task result key: v=%v err=%v", v, err) + } +} + +func TestEntityOutputParameter_FallbackToResults(t *testing.T) { + gc := engine.NewGlobalContext() + // Only a result is present (no output) + gc.StoreActionResult("A", rp{v: map[string]interface{}{"k": 1}}) + gc.StoreTaskResult("T", rp{v: map[string]interface{}{"s": "ok"}}) + + // Action entity, full result + p1 := engine.EntityOutput("action", "A") + if v, err := p1.Resolve(context.Background(), gc); err != nil { + t.Fatalf("EntityOutput(action) err: %v", err) + } else if m, ok := v.(map[string]interface{}); !ok || m["k"].(int) != 1 { + t.Fatalf("unexpected value: %v", v) + } + // Action entity by key + p1k := engine.EntityOutputField("action", "A", "k") + if v, err := p1k.Resolve(context.Background(), gc); err != nil || v.(int) != 1 { + t.Fatalf("unexpected key value: %v err=%v", v, err) + } + + // Task entity, full result + p2 := engine.EntityOutput("task", "T") + if v, err := p2.Resolve(context.Background(), gc); err != nil { + t.Fatalf("EntityOutput(task) err: %v", err) + } else if m, ok := v.(map[string]interface{}); !ok || m["s"].(string) != "ok" { + t.Fatalf("unexpected value: %v", v) + } + // Task entity by key + p2k := engine.EntityOutputField("task", "T", "s") + if v, err := p2k.Resolve(context.Background(), gc); err != nil || v.(string) != "ok" { + t.Fatalf("unexpected key value: %v err=%v", v, err) + } +} + +func TestResolveAs_GenericAdditional(t *testing.T) { + gc := engine.NewGlobalContext() + gc.StoreActionOutput("actX", map[string]interface{}{"flag": true, "nums": []string{"a", "b"}}) + + b, err := engine.ResolveAs[bool](context.Background(), engine.ActionOutputField("actX", "flag"), gc) + if err != nil || b != true { + t.Fatalf("expected true, got %v err=%v", b, err) + } + sl, err := engine.ResolveAs[[]string](context.Background(), engine.ActionOutputField("actX", "nums"), gc) + if err != nil || len(sl) != 2 || sl[0] != "a" { + t.Fatalf("unexpected slice: %v err=%v", sl, err) + } +} diff --git a/task_engine_test.go b/task_engine_test.go index 9fd2479..873d010 100644 --- a/task_engine_test.go +++ b/task_engine_test.go @@ -19,11 +19,7 @@ const ( LongActionTime = 500 * time.Millisecond ) -// NewDiscardLogger creates a new logger that discards all output -// This is useful for tests to prevent log output from cluttering test results -func NewDiscardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} +// duplicate NewDiscardLogger removed (defined earlier in file) type TestAction struct { task_engine.BaseAction @@ -86,11 +82,25 @@ type testResultProvider struct{ v interface{} } func (p testResultProvider) GetResult() interface{} { return p.v } func (p testResultProvider) GetError() error { return nil } -func (a *AfterExecuteFailingAction) AfterExecute(ctx context.Context) error { - if a.ShouldFailAfter { - return errors.New("simulated AfterExecute failure") +// CancelAwareAction returns context error if canceled, otherwise completes after Delay +type CancelAwareAction struct { + task_engine.BaseAction + Delay time.Duration +} + +func (a *CancelAwareAction) Execute(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(a.Delay): + return nil } - return nil +} + +// NewDiscardLogger creates a new logger that discards all output +// This is useful for tests to prevent log output from cluttering test results +func NewDiscardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) } var ( @@ -374,6 +384,179 @@ func TestResolveAsGeneric(t *testing.T) { } } +func TestEntityValueNegativePaths(t *testing.T) { + gc := task_engine.NewGlobalContext() + + if _, err := task_engine.EntityValue(gc, "invalid", "id", ""); err == nil { + t.Fatalf("expected error for invalid entity type") + } + if _, err := task_engine.EntityValue(gc, "action", "missing", ""); err == nil { + t.Fatalf("expected error for missing action") + } + gc.StoreActionOutput("a1", map[string]interface{}{"k": 1}) + if _, err := task_engine.ActionOutputFieldAs[string](gc, "a1", "k"); err == nil { + t.Fatalf("expected type error for wrong cast") + } +} + +func TestResolveAsNegative(t *testing.T) { + gc := task_engine.NewGlobalContext() + gc.StoreActionOutput("a", map[string]interface{}{"x": "str"}) + // wrong type + if _, err := task_engine.ResolveAs[int](context.Background(), task_engine.ActionOutputField("a", "x"), gc); err == nil { + t.Fatalf("expected type error for ResolveAs") + } +} + +func TestIDHelpers(t *testing.T) { + if out := task_engine.SanitizeIDPart(" Hello/World _! "); out == "" { + t.Fatalf("expected sanitized non-empty id") + } + id := task_engine.BuildActionID("prefix", " Part A ", "B/C") + if id == "" || id == "action-action" { + t.Fatalf("unexpected id: %s", id) + } +} + +// Task cancellation should still store task output and task result +func TestTaskCancellationStoresOutputAndResult(t *testing.T) { + logger := NewDiscardLogger() + gc := task_engine.NewGlobalContext() + + // Task with a quick action and a cancel-aware long-running action + task := &task_engine.Task{ + ID: "cancel-task", + Name: "Cancellation Test", + Actions: []task_engine.ActionWrapper{ + &task_engine.Action[*DelayAction]{ + ID: "quick", + Wrapped: &DelayAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 1 * time.Millisecond}, + Logger: logger, + }, + &task_engine.Action[*CancelAwareAction]{ + ID: "slow", + Wrapped: &CancelAwareAction{BaseAction: task_engine.BaseAction{Logger: logger}, Delay: 2 * time.Second}, + Logger: logger, + }, + }, + Logger: logger, + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + // cancel shortly after start + time.Sleep(5 * time.Millisecond) + cancel() + }() + _ = task.RunWithContext(ctx, gc) + + // Verify task output and result stored + if _, ok := gc.TaskOutputs[task.ID]; !ok { + t.Fatalf("expected TaskOutputs to contain task output on cancellation") + } + if _, ok := gc.TaskResults[task.ID]; !ok { + t.Fatalf("expected TaskResults to contain task result provider on cancellation") + } + // Check outputs map for success=false + out := gc.TaskOutputs[task.ID].(map[string]interface{}) + if out["success"].(bool) { + t.Fatalf("expected success=false on cancellation") + } +} + +// ResultBuilder error should set task error and mark success=false in outputs +func TestTaskResultBuilderErrorPath(t *testing.T) { + logger := NewDiscardLogger() + gc := task_engine.NewGlobalContext() + + errSentinel := errors.New("builder failed") + builderTask := &task_engine.Task{ + ID: "builder-error", + Name: "Builder Error", + Actions: []task_engine.ActionWrapper{ + &task_engine.Action[*DelayAction]{ID: "noop", Wrapped: &DelayAction{}, Logger: logger}, + }, + Logger: logger, + ResultBuilder: func(ctx *task_engine.TaskContext) (interface{}, error) { + return nil, errSentinel + }, + } + + _ = builderTask.RunWithContext(context.Background(), gc) + out, ok := gc.TaskOutputs[builderTask.ID] + if !ok { + t.Fatalf("expected TaskOutputs to contain output") + } + outMap := out.(map[string]interface{}) + if outMap["success"].(bool) { + t.Fatalf("expected success=false when builder fails") + } + // Result should be from task provider with error surfaced in GetResult map + res, ok := task_engine.TaskResultAs[map[string]interface{}](gc, builderTask.ID) + if !ok { + t.Fatalf("expected typed task result from task provider") + } + if res["success"].(bool) { + t.Fatalf("expected task result success=false when builder fails") + } +} + +// Typed helper does not fallback from outputs to results for tasks; verify error +func TestTypedHelperNoFallbackForTaskOutputFieldAs(t *testing.T) { + gc := task_engine.NewGlobalContext() + // Only set task result, no task output + gc.StoreTaskResult("t1", testResultProvider{v: map[string]interface{}{"v": 1}}) + if _, err := task_engine.TaskOutputFieldAs[int](gc, "t1", "v"); err == nil { + t.Fatalf("expected error since TaskOutputFieldAs should not fallback to results") + } + // But EntityValue should fallback to results and succeed (full result) + if v, err := task_engine.EntityValue(gc, "task", "t1", ""); err != nil { + t.Fatalf("expected EntityValue to return fallback result, err=%v", err) + } else { + if m, ok := v.(map[string]interface{}); !ok || m["v"].(int) != 1 { + t.Fatalf("unexpected result fallback: %v", v) + } + } + // And with a key, EntityValue should read from result map + if v, err := task_engine.EntityValue(gc, "task", "t1", "v"); err != nil || v.(int) != 1 { + t.Fatalf("expected EntityValue with key to read from result map, got %v, err=%v", v, err) + } +} + +// TaskManager timeout and ResetGlobalContext behavior +func TestTaskManagerTimeoutAndResetGlobalContext(t *testing.T) { + logger := NewDiscardLogger() + tm := task_engine.NewTaskManager(logger) + + // Long-running task + task := &task_engine.Task{ + ID: "timeout-task", + Name: "Timeout Task", + Actions: []task_engine.ActionWrapper{ + &task_engine.Action[*DelayAction]{ID: "slow", Wrapped: &DelayAction{Delay: 2 * time.Second}, Logger: logger}, + }, + Logger: logger, + } + _ = tm.AddTask(task) + _ = tm.RunTask("timeout-task") + // Expect timeout quickly + if err := tm.WaitForAllTasksToComplete(10 * time.Millisecond); err == nil { + t.Fatalf("expected timeout error") + } + + // Store something in current global context + gc := tm.GetGlobalContext() + gc.StoreActionOutput("a", "x") + // Reset and verify cleared + tm.ResetGlobalContext() + gc2 := tm.GetGlobalContext() + if gc2 == gc || len(gc2.ActionOutputs) != 0 || len(gc2.TaskOutputs) != 0 || len(gc2.ActionResults) != 0 || len(gc2.TaskResults) != 0 { + t.Fatalf("expected a fresh global context after reset") + } + // Stop to clean up + _ = tm.StopTask("timeout-task") +} + func TestTaskWithParameterPassing(t *testing.T) { t.Run("TaskExecutionWithGlobalContext", func(t *testing.T) { logger := NewDiscardLogger() diff --git a/task_manager.go b/task_manager.go index 6402532..a5212ff 100644 --- a/task_manager.go +++ b/task_manager.go @@ -59,16 +59,20 @@ func (tm *TaskManager) RunTask(taskID string) error { ctx, cancel := context.WithCancel(context.Background()) tm.runningTasks[taskID] = cancel + // Capture the current global context under lock to avoid races with ResetGlobalContext. + // Tasks will run against this snapshot even if the manager's global context is reset later. + gc := tm.globalContext + // Start every task in a goroutine - go func() { + go func(gcSnapshot *GlobalContext) { defer func() { tm.mu.Lock() delete(tm.runningTasks, taskID) tm.mu.Unlock() }() - // Run task with the global context for parameter resolution - err := task.RunWithContext(ctx, tm.globalContext) + // Run task with the captured global context for parameter resolution + err := task.RunWithContext(ctx, gcSnapshot) if err != nil { if ctx.Err() != nil { tm.Logger.Info("Task canceled", "taskID", taskID, "error", err) @@ -78,7 +82,7 @@ func (tm *TaskManager) RunTask(taskID string) error { } else { tm.Logger.Info("Task completed", "taskID", taskID) } - }() + }(gc) return nil }