mirror of
https://github.com/lukaszraczylo/jobs-manager-operator.git
synced 2026-06-10 22:59:18 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user