Multiple fixes (#29)

* Multiple fixes

- add goreleaser to the build / release process
- add kubectl plugin for job graphs visualization
- add installation scripts
- update dependencies

* Update the release & CRD content.

* Next set of improvements.

  Code Quality

  - Label constants: Added LabelWorkflowName, LabelGroupName, LabelJobName, LabelJobID in controllers/definitions.go
  - Removed commented debug code: Cleaned up dead code from multiple files
  - Removed unused dependencyTree field: Cleaned connPackage struct
  - Fixed snake_case variables: Changed to camelCase (runGroup, groupDep, runJob, jobDep, k8sJob)

  Kubernetes Best Practices

  - Finalizers: Implemented handleDeletion() and deleteChildJobs() for proper cleanup
  - Status enum validation: Added +kubebuilder:validation:Enum=pending;running;succeeded;failed;aborted
  - ImagePullPolicy default: Created getImagePullPolicy() helper that defaults to IfNotPresent
  - Resource limits support: Added Resources *corev1.ResourceRequirements to ManagedJobParameters

  Observability

  - Prometheus metrics: Created controllers/metrics.go with counters (jobs created/succeeded/failed), histogram (reconciliation duration), and gauge (active jobs)
  - Structured logging: Added logger field to connPackage, used context-based logging throughout

  Configuration

  - Leader election ID: Made configurable via --leader-election-id flag
  - Development mode: Made configurable via --dev-mode flag and LOG_LEVEL env var

  Performance

  - Dependency lookup optimization: Changed from O(n*m) to O(1) using lookup maps (jobDepMap, groupDepMap)
  - Reconciliation backoff: Added RequeueAfter: 30*time.Second when workflow is running

  Documentation & Testing

  - Godoc documentation: Added comprehensive comments to API types and controller
  - Unit tests: Added helpers_test.go with tests for all helper functions
  - Integration tests: Added managedjob_controller_test.go with Ginkgo/Gomega tests

* Add the helm chart release.

* Add reasonable test coverage.
This commit is contained in:
2025-12-17 21:18:04 +00:00
parent b6ce5b7c98
commit 2b36071647
43 changed files with 16182 additions and 8179 deletions
+135
View File
@@ -0,0 +1,135 @@
package visualization
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/config"
jobsmanagerv1beta1 "raczylo.com/jobs-manager-operator/api/v1beta1"
)
// Client wraps the Kubernetes client for ManagedJob operations
type Client struct {
client client.Client
}
// NewClient creates a new Client for ManagedJob operations
func NewClient() (*Client, error) {
cfg, err := config.GetConfig()
if err != nil {
return nil, fmt.Errorf("failed to get kubeconfig: %w", err)
}
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("failed to add client-go scheme: %w", err)
}
if err := jobsmanagerv1beta1.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("failed to add jobsmanager scheme: %w", err)
}
cl, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}
return &Client{client: cl}, nil
}
// GetManagedJob retrieves a ManagedJob by name and namespace
func (c *Client) GetManagedJob(ctx context.Context, name, namespace string) (*jobsmanagerv1beta1.ManagedJob, error) {
mj := &jobsmanagerv1beta1.ManagedJob{}
err := c.client.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, mj)
if err != nil {
return nil, fmt.Errorf("failed to get ManagedJob %s/%s: %w", namespace, name, err)
}
return mj, nil
}
// ListManagedJobs lists all ManagedJobs in a namespace
func (c *Client) ListManagedJobs(ctx context.Context, namespace string) (*jobsmanagerv1beta1.ManagedJobList, error) {
mjList := &jobsmanagerv1beta1.ManagedJobList{}
opts := []client.ListOption{}
if namespace != "" {
opts = append(opts, client.InNamespace(namespace))
}
err := c.client.List(ctx, mjList, opts...)
if err != nil {
return nil, fmt.Errorf("failed to list ManagedJobs: %w", err)
}
return mjList, nil
}
// BuildTree builds a StatusTree from a ManagedJob
func BuildTree(mj *jobsmanagerv1beta1.ManagedJob) *StatusTree {
root := NewStatusTreeWithStatus(mj.Name, mj.Status)
for _, group := range mj.Spec.Groups {
groupNode := root.AddWithStatus(group.Name, group.Status)
// Add group dependencies
for _, dep := range group.Dependencies {
groupNode.Add(RenderDependency(dep.Name, true))
}
// Add jobs
for _, job := range group.Jobs {
jobNode := groupNode.AddWithStatus(job.Name, job.Status)
// Add job dependencies
for _, dep := range job.Dependencies {
jobNode.Add(RenderDependency(dep.Name, false))
}
}
}
return root
}
// GetStatusSummary returns a summary of job statuses
type StatusSummary struct {
Name string
Namespace string
Status string
Groups int
Jobs int
Pending int
Running int
Succeeded int
Failed int
Aborted int
}
// GetStatusSummary builds a summary of the ManagedJob status
func GetStatusSummary(mj *jobsmanagerv1beta1.ManagedJob) StatusSummary {
summary := StatusSummary{
Name: mj.Name,
Namespace: mj.Namespace,
Status: mj.Status,
Groups: len(mj.Spec.Groups),
}
for _, group := range mj.Spec.Groups {
for _, job := range group.Jobs {
summary.Jobs++
switch job.Status {
case StatusPending:
summary.Pending++
case StatusRunning:
summary.Running++
case StatusSucceeded:
summary.Succeeded++
case StatusFailed:
summary.Failed++
case StatusAborted:
summary.Aborted++
}
}
}
return summary
}
+150
View File
@@ -0,0 +1,150 @@
package visualization
import (
"strings"
"github.com/fatih/color"
)
// Box-drawing characters for tree rendering
const (
newLine = "\n"
emptySpace = " "
middleItem = "\u251c\u2500\u2500 " // ├──
continueItem = "\u2502 " // │
lastItem = "\u2514\u2500\u2500 " // └──
)
// Status constants
const (
StatusPending = "pending"
StatusRunning = "running"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusAborted = "aborted"
StatusUnknown = "unknown"
)
// Renderer handles tree rendering with optional color support
type Renderer struct {
useColor bool
green *color.Color
yellow *color.Color
red *color.Color
gray *color.Color
magenta *color.Color
cyan *color.Color
}
// NewRenderer creates a new Renderer
func NewRenderer(useColor bool) *Renderer {
return &Renderer{
useColor: useColor,
green: color.New(color.FgGreen),
yellow: color.New(color.FgYellow),
red: color.New(color.FgRed),
gray: color.New(color.FgHiBlack),
magenta: color.New(color.FgMagenta),
cyan: color.New(color.FgCyan),
}
}
// Render renders a StatusTree to a string
func (r *Renderer) Render(t *StatusTree) string {
var sb strings.Builder
r.renderNode(&sb, t, []bool{})
return sb.String()
}
// renderNode renders a single node and its children
func (r *Renderer) renderNode(sb *strings.Builder, t *StatusTree, spaces []bool) {
// Render current node
r.renderText(sb, t.Text(), t.Status(), spaces, true)
// Render children
items := t.Items()
for i, child := range items {
isLast := i == len(items)-1
r.renderChild(sb, child, spaces, isLast)
}
}
// renderChild renders a child node with proper indentation
func (r *Renderer) renderChild(sb *strings.Builder, t *StatusTree, spaces []bool, isLast bool) {
// Add prefix based on whether this is the last item
for _, space := range spaces {
if space {
sb.WriteString(emptySpace)
} else {
sb.WriteString(continueItem)
}
}
if isLast {
sb.WriteString(lastItem)
} else {
sb.WriteString(middleItem)
}
// Render the text with status
r.renderTextInline(sb, t.Text(), t.Status())
sb.WriteString(newLine)
// Render children with updated spaces
newSpaces := append(spaces, isLast)
items := t.Items()
for i, child := range items {
childIsLast := i == len(items)-1
r.renderChild(sb, child, newSpaces, childIsLast)
}
}
// renderText renders the root node text
func (r *Renderer) renderText(sb *strings.Builder, text, status string, spaces []bool, isRoot bool) {
if isRoot {
r.renderTextInline(sb, text, status)
sb.WriteString(newLine)
}
}
// renderTextInline renders text with status inline
func (r *Renderer) renderTextInline(sb *strings.Builder, text, status string) {
sb.WriteString(text)
if status != "" {
sb.WriteString(" ")
r.renderStatus(sb, status)
}
}
// renderStatus renders the status with appropriate color
func (r *Renderer) renderStatus(sb *strings.Builder, status string) {
statusText := "[" + status + "]"
if !r.useColor {
sb.WriteString(statusText)
return
}
switch status {
case StatusSucceeded:
sb.WriteString(r.green.Sprint(statusText))
case StatusRunning:
sb.WriteString(r.yellow.Sprint(statusText))
case StatusFailed:
sb.WriteString(r.red.Sprint(statusText))
case StatusPending:
sb.WriteString(r.gray.Sprint(statusText))
case StatusAborted:
sb.WriteString(r.magenta.Sprint(statusText))
default:
sb.WriteString(r.cyan.Sprint(statusText))
}
}
// RenderDependency formats a dependency reference
func RenderDependency(name string, isGroup bool) string {
if isGroup {
return "depends on group: " + name
}
return "depends on: " + name
}
+421
View File
@@ -0,0 +1,421 @@
package visualization
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type RendererTestSuite struct {
suite.Suite
}
func TestRendererSuite(t *testing.T) {
suite.Run(t, new(RendererTestSuite))
}
func (s *RendererTestSuite) TestNewRenderer() {
r := NewRenderer(true)
s.NotNil(r)
s.True(r.useColor)
r2 := NewRenderer(false)
s.NotNil(r2)
s.False(r2.useColor)
}
func (s *RendererTestSuite) TestRender_SimpleTree() {
r := NewRenderer(false)
tree := NewStatusTree("root")
output := r.Render(tree)
s.Contains(output, "root")
}
func (s *RendererTestSuite) TestRender_WithStatus() {
r := NewRenderer(false)
tree := NewStatusTreeWithStatus("workflow", StatusRunning)
output := r.Render(tree)
s.Contains(output, "workflow")
s.Contains(output, "[running]")
}
func (s *RendererTestSuite) TestRender_WithChildren() {
r := NewRenderer(false)
tree := NewStatusTree("root")
tree.Add("child1")
tree.Add("child2")
output := r.Render(tree)
s.Contains(output, "root")
s.Contains(output, "child1")
s.Contains(output, "child2")
// Check for tree characters
s.Contains(output, "├──")
s.Contains(output, "└──")
}
func (s *RendererTestSuite) TestRender_NestedChildren() {
r := NewRenderer(false)
tree := NewStatusTree("workflow")
group := tree.Add("group1")
group.Add("job1")
group.Add("job2")
output := r.Render(tree)
s.Contains(output, "workflow")
s.Contains(output, "group1")
s.Contains(output, "job1")
s.Contains(output, "job2")
}
func (s *RendererTestSuite) TestRender_AllStatusColors() {
r := NewRenderer(false)
tree := NewStatusTree("workflow")
tree.AddWithStatus("pending-job", StatusPending)
tree.AddWithStatus("running-job", StatusRunning)
tree.AddWithStatus("succeeded-job", StatusSucceeded)
tree.AddWithStatus("failed-job", StatusFailed)
tree.AddWithStatus("aborted-job", StatusAborted)
tree.AddWithStatus("unknown-job", StatusUnknown)
output := r.Render(tree)
s.Contains(output, "[pending]")
s.Contains(output, "[running]")
s.Contains(output, "[succeeded]")
s.Contains(output, "[failed]")
s.Contains(output, "[aborted]")
s.Contains(output, "[unknown]")
}
func (s *RendererTestSuite) TestRenderDependency_Job() {
result := RenderDependency("init-job", false)
s.Equal("depends on: init-job", result)
}
func (s *RendererTestSuite) TestRenderDependency_Group() {
result := RenderDependency("init-group", true)
s.Equal("depends on group: init-group", result)
}
// ==================== MATRIX TEST: RENDER SCENARIOS ====================
func TestRenderer_StatusRendering(t *testing.T) {
tests := []struct {
name string
status string
expected string
}{
{
name: "pending_status",
status: StatusPending,
expected: "[pending]",
},
{
name: "running_status",
status: StatusRunning,
expected: "[running]",
},
{
name: "succeeded_status",
status: StatusSucceeded,
expected: "[succeeded]",
},
{
name: "failed_status",
status: StatusFailed,
expected: "[failed]",
},
{
name: "aborted_status",
status: StatusAborted,
expected: "[aborted]",
},
{
name: "unknown_status",
status: StatusUnknown,
expected: "[unknown]",
},
{
name: "custom_status",
status: "custom",
expected: "[custom]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRenderer(false)
tree := NewStatusTreeWithStatus("node", tt.status)
output := r.Render(tree)
assert.Contains(t, output, tt.expected)
})
}
}
func TestRenderer_TreeStructure(t *testing.T) {
tests := []struct {
name string
buildTree func() *StatusTree
contains []string
}{
{
name: "single_node",
buildTree: func() *StatusTree {
return NewStatusTree("root")
},
contains: []string{"root"},
},
{
name: "parent_with_children",
buildTree: func() *StatusTree {
tree := NewStatusTree("parent")
tree.Add("child1")
tree.Add("child2")
return tree
},
contains: []string{"parent", "child1", "child2", "├──", "└──"},
},
{
name: "deep_nesting",
buildTree: func() *StatusTree {
tree := NewStatusTree("level1")
l2 := tree.Add("level2")
l3 := l2.Add("level3")
l3.Add("level4")
return tree
},
contains: []string{"level1", "level2", "level3", "level4"},
},
{
name: "workflow_structure",
buildTree: func() *StatusTree {
tree := NewStatusTreeWithStatus("my-workflow", StatusRunning)
g1 := tree.AddWithStatus("group1", StatusSucceeded)
g1.AddWithStatus("job1", StatusSucceeded)
g2 := tree.AddWithStatus("group2", StatusRunning)
g2.Add(RenderDependency("group1", true))
g2.AddWithStatus("job2", StatusRunning)
return tree
},
contains: []string{
"my-workflow", "[running]",
"group1", "[succeeded]",
"job1",
"group2",
"depends on group: group1",
"job2",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRenderer(false)
tree := tt.buildTree()
output := r.Render(tree)
for _, expected := range tt.contains {
assert.Contains(t, output, expected)
}
})
}
}
func TestRenderer_ColorMode(t *testing.T) {
tree := NewStatusTreeWithStatus("workflow", StatusSucceeded)
// Without color
rNoColor := NewRenderer(false)
outputNoColor := rNoColor.Render(tree)
assert.Contains(t, outputNoColor, "[succeeded]")
// With color - output should still contain the text (ANSI codes are transparent to Contains)
rColor := NewRenderer(true)
outputColor := rColor.Render(tree)
assert.NotEmpty(t, outputColor)
}
func TestRenderer_ComplexWorkflow(t *testing.T) {
r := NewRenderer(false)
// Build a complex workflow tree
workflow := NewStatusTreeWithStatus("production-deploy", StatusRunning)
// Setup phase
setup := workflow.AddWithStatus("setup", StatusSucceeded)
setup.AddWithStatus("configure-aws", StatusSucceeded)
setup.AddWithStatus("validate-config", StatusSucceeded)
// Build phase
build := workflow.AddWithStatus("build", StatusSucceeded)
build.Add(RenderDependency("setup", true))
build.AddWithStatus("compile-frontend", StatusSucceeded)
build.AddWithStatus("compile-backend", StatusSucceeded)
build.AddWithStatus("run-unit-tests", StatusSucceeded)
// Deploy phase
deploy := workflow.AddWithStatus("deploy", StatusRunning)
deploy.Add(RenderDependency("build", true))
staging := deploy.AddWithStatus("deploy-staging", StatusSucceeded)
staging.Add(RenderDependency("compile-frontend", false))
integration := deploy.AddWithStatus("run-integration-tests", StatusRunning)
integration.Add(RenderDependency("deploy-staging", false))
prod := deploy.AddWithStatus("deploy-production", StatusPending)
prod.Add(RenderDependency("run-integration-tests", false))
// Cleanup phase
cleanup := workflow.AddWithStatus("cleanup", StatusPending)
cleanup.Add(RenderDependency("deploy", true))
cleanup.AddWithStatus("remove-staging", StatusPending)
output := r.Render(workflow)
// Verify structure
expectedPhrases := []string{
"production-deploy",
"setup",
"configure-aws",
"build",
"compile-frontend",
"deploy",
"deploy-staging",
"run-integration-tests",
"deploy-production",
"cleanup",
"depends on group: setup",
"depends on group: build",
"depends on: deploy-staging",
}
for _, phrase := range expectedPhrases {
assert.Contains(t, output, phrase, "output should contain: %s", phrase)
}
// Verify line structure
lines := strings.Split(output, "\n")
assert.Greater(t, len(lines), 10, "should have multiple lines")
}
func TestRenderDependency_Matrix(t *testing.T) {
tests := []struct {
name string
depName string
isGroup bool
expected string
}{
{
name: "job_dependency",
depName: "init-job",
isGroup: false,
expected: "depends on: init-job",
},
{
name: "group_dependency",
depName: "setup-group",
isGroup: true,
expected: "depends on group: setup-group",
},
{
name: "long_job_name",
depName: "very-long-job-name-with-many-parts",
isGroup: false,
expected: "depends on: very-long-job-name-with-many-parts",
},
{
name: "empty_name",
depName: "",
isGroup: false,
expected: "depends on: ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RenderDependency(tt.depName, tt.isGroup)
assert.Equal(t, tt.expected, result)
})
}
}
// Test that tree rendering handles edge cases
func TestRenderer_EdgeCases(t *testing.T) {
tests := []struct {
name string
buildTree func() *StatusTree
verify func(*testing.T, string)
}{
{
name: "empty_text_node",
buildTree: func() *StatusTree {
return NewStatusTree("")
},
verify: func(t *testing.T, output string) {
assert.NotEmpty(t, output)
},
},
{
name: "special_characters_in_text",
buildTree: func() *StatusTree {
tree := NewStatusTree("workflow-v1.2.3")
tree.Add("job_with_underscore")
tree.Add("job.with.dots")
return tree
},
verify: func(t *testing.T, output string) {
assert.Contains(t, output, "workflow-v1.2.3")
assert.Contains(t, output, "job_with_underscore")
assert.Contains(t, output, "job.with.dots")
},
},
{
name: "many_siblings",
buildTree: func() *StatusTree {
tree := NewStatusTree("parent")
for i := 0; i < 20; i++ {
tree.Add("child")
}
return tree
},
verify: func(t *testing.T, output string) {
// Should have 19 middle items and 1 last item
assert.Equal(t, 19, strings.Count(output, "├──"))
assert.Equal(t, 1, strings.Count(output, "└──"))
},
},
{
name: "unicode_in_text",
buildTree: func() *StatusTree {
tree := NewStatusTree("🚀 deployment")
tree.Add("✅ verified")
tree.Add("⏳ pending")
return tree
},
verify: func(t *testing.T, output string) {
assert.Contains(t, output, "🚀")
assert.Contains(t, output, "✅")
assert.Contains(t, output, "⏳")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRenderer(false)
tree := tt.buildTree()
output := r.Render(tree)
tt.verify(t, output)
})
}
}
+54
View File
@@ -0,0 +1,54 @@
package visualization
// StatusTree represents a tree node with text and execution status
type StatusTree struct {
text string
status string
items []*StatusTree
}
// NewStatusTree creates a new StatusTree node
func NewStatusTree(text string) *StatusTree {
return &StatusTree{
text: text,
items: make([]*StatusTree, 0),
}
}
// NewStatusTreeWithStatus creates a new StatusTree node with a status
func NewStatusTreeWithStatus(text, status string) *StatusTree {
return &StatusTree{
text: text,
status: status,
items: make([]*StatusTree, 0),
}
}
// Add creates and appends a child node, returning it for chaining
func (t *StatusTree) Add(text string) *StatusTree {
child := NewStatusTree(text)
t.items = append(t.items, child)
return child
}
// AddWithStatus creates and appends a child node with status
func (t *StatusTree) AddWithStatus(text, status string) *StatusTree {
child := NewStatusTreeWithStatus(text, status)
t.items = append(t.items, child)
return child
}
// Items returns all child nodes
func (t *StatusTree) Items() []*StatusTree {
return t.items
}
// Text returns the node's text value
func (t *StatusTree) Text() string {
return t.text
}
// Status returns the node's status value
func (t *StatusTree) Status() string {
return t.status
}
+165
View File
@@ -0,0 +1,165 @@
package visualization
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type TreeTestSuite struct {
suite.Suite
}
func TestTreeSuite(t *testing.T) {
suite.Run(t, new(TreeTestSuite))
}
func (s *TreeTestSuite) TestNewStatusTree() {
tree := NewStatusTree("root")
s.Equal("root", tree.Text())
s.Equal("", tree.Status())
s.Empty(tree.Items())
}
func (s *TreeTestSuite) TestNewStatusTreeWithStatus() {
tree := NewStatusTreeWithStatus("workflow", StatusRunning)
s.Equal("workflow", tree.Text())
s.Equal(StatusRunning, tree.Status())
s.Empty(tree.Items())
}
func (s *TreeTestSuite) TestAdd() {
root := NewStatusTree("root")
child := root.Add("child")
s.Equal("child", child.Text())
s.Len(root.Items(), 1)
s.Equal(child, root.Items()[0])
}
func (s *TreeTestSuite) TestAddWithStatus() {
root := NewStatusTree("root")
child := root.AddWithStatus("job", StatusSucceeded)
s.Equal("job", child.Text())
s.Equal(StatusSucceeded, child.Status())
s.Len(root.Items(), 1)
}
func (s *TreeTestSuite) TestChaining() {
root := NewStatusTree("workflow")
group := root.Add("group1")
job := group.AddWithStatus("job1", StatusRunning)
job.Add("depends on: init-job")
s.Len(root.Items(), 1)
s.Len(root.Items()[0].Items(), 1)
s.Len(root.Items()[0].Items()[0].Items(), 1)
}
func (s *TreeTestSuite) TestItems() {
root := NewStatusTree("root")
root.Add("child1")
root.Add("child2")
root.Add("child3")
items := root.Items()
s.Len(items, 3)
s.Equal("child1", items[0].Text())
s.Equal("child2", items[1].Text())
s.Equal("child3", items[2].Text())
}
// ==================== MATRIX TEST: TREE BUILDING ====================
func TestStatusTree_StatusValues(t *testing.T) {
statuses := []string{
StatusPending,
StatusRunning,
StatusSucceeded,
StatusFailed,
StatusAborted,
StatusUnknown,
}
for _, status := range statuses {
t.Run(status, func(t *testing.T) {
tree := NewStatusTreeWithStatus("node", status)
assert.Equal(t, status, tree.Status())
})
}
}
func TestStatusTree_DeepNesting(t *testing.T) {
depth := 10
root := NewStatusTree("level-0")
current := root
for i := 1; i < depth; i++ {
current = current.Add("level-" + string(rune('0'+i)))
}
// Traverse back to verify
node := root
for i := 0; i < depth-1; i++ {
assert.Len(t, node.Items(), 1)
node = node.Items()[0]
}
assert.Empty(t, node.Items())
}
func TestStatusTree_MultipleChildren(t *testing.T) {
root := NewStatusTree("workflow")
// Add multiple groups
for i := 0; i < 5; i++ {
group := root.AddWithStatus("group"+string(rune('0'+i)), StatusPending)
// Add jobs to each group
for j := 0; j < 3; j++ {
group.AddWithStatus("job"+string(rune('0'+j)), StatusPending)
}
}
assert.Len(t, root.Items(), 5)
for _, group := range root.Items() {
assert.Len(t, group.Items(), 3)
}
}
func TestStatusTree_EmptyTree(t *testing.T) {
tree := NewStatusTree("")
assert.Equal(t, "", tree.Text())
assert.Equal(t, "", tree.Status())
assert.Empty(t, tree.Items())
}
func TestStatusTree_ComplexWorkflow(t *testing.T) {
// Simulate a real workflow structure
workflow := NewStatusTreeWithStatus("my-workflow", StatusRunning)
// First group - succeeded
group1 := workflow.AddWithStatus("init-group", StatusSucceeded)
group1.AddWithStatus("setup-database", StatusSucceeded)
group1.AddWithStatus("setup-cache", StatusSucceeded)
// Second group - running
group2 := workflow.AddWithStatus("build-group", StatusRunning)
group2.Add("depends on group: init-group")
build := group2.AddWithStatus("build-app", StatusRunning)
build.Add("depends on: setup-database")
group2.AddWithStatus("run-tests", StatusPending).Add("depends on: build-app")
// Third group - pending
group3 := workflow.AddWithStatus("deploy-group", StatusPending)
group3.Add("depends on group: build-group")
group3.AddWithStatus("deploy-staging", StatusPending)
group3.AddWithStatus("deploy-production", StatusPending).Add("depends on: deploy-staging")
assert.Len(t, workflow.Items(), 3)
assert.Equal(t, StatusSucceeded, workflow.Items()[0].Status())
assert.Equal(t, StatusRunning, workflow.Items()[1].Status())
assert.Equal(t, StatusPending, workflow.Items()[2].Status())
}