diff --git a/pkg/workflow/compiler_jobs_test.go b/pkg/workflow/compiler_jobs_test.go index 78f4032115c..758980f9258 100644 --- a/pkg/workflow/compiler_jobs_test.go +++ b/pkg/workflow/compiler_jobs_test.go @@ -66,6 +66,321 @@ func TestExtractJobsFromFrontmatter(t *testing.T) { } } +// ======================================== +// Helper Function Tests +// ======================================== + +// TestReferencesCustomJobOutputsAdditional tests additional edge cases for referencesCustomJobOutputs method +func TestReferencesCustomJobOutputsAdditional(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + condition string + customJobs map[string]any + expected bool + }{ + { + name: "references non-existent job", + condition: "needs.job2.outputs.value", + customJobs: map[string]any{"job1": map[string]any{}}, + expected: false, + }, + { + name: "multiple custom jobs with reference", + condition: "needs.producer.outputs.result", + customJobs: map[string]any{"producer": map[string]any{}, "consumer": map[string]any{}}, + expected: true, + }, + { + name: "complex condition with output reference", + condition: "needs.test.outputs.status == 'pass' && github.ref == 'refs/heads/main'", + customJobs: map[string]any{"test": map[string]any{}}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.referencesCustomJobOutputs(tt.condition, tt.customJobs) + if result != tt.expected { + t.Errorf("referencesCustomJobOutputs() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestJobDependsOnPreActivationEdgeCases tests edge cases for jobDependsOnPreActivation function +func TestJobDependsOnPreActivationEdgeCases(t *testing.T) { + tests := []struct { + name string + jobConfig map[string]any + expected bool + }{ + { + name: "needs is invalid type", + jobConfig: map[string]any{ + "needs": 123, + }, + expected: false, + }, + { + name: "array with non-string element", + jobConfig: map[string]any{ + "needs": []any{123, "pre_activation"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := jobDependsOnPreActivation(tt.jobConfig) + if result != tt.expected { + t.Errorf("jobDependsOnPreActivation() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestJobDependsOnAgentEdgeCases tests edge cases for jobDependsOnAgent function +func TestJobDependsOnAgentEdgeCases(t *testing.T) { + tests := []struct { + name string + jobConfig map[string]any + expected bool + }{ + { + name: "array with mixed types including agent", + jobConfig: map[string]any{ + "needs": []any{123, "agent", "job2"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := jobDependsOnAgent(tt.jobConfig) + if result != tt.expected { + t.Errorf("jobDependsOnAgent() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestGetCustomJobsDependingOnPreActivationEdgeCases tests edge cases for getCustomJobsDependingOnPreActivation method +func TestGetCustomJobsDependingOnPreActivationEdgeCases(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + customJobs map[string]any + expectedCount int + expectedJobIDs []string + }{ + { + name: "job with invalid config type", + customJobs: map[string]any{ + "job1": "invalid", + "job2": map[string]any{"needs": "pre_activation"}, + }, + expectedCount: 1, + expectedJobIDs: []string{"job2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.getCustomJobsDependingOnPreActivation(tt.customJobs) + if len(result) != tt.expectedCount { + t.Errorf("getCustomJobsDependingOnPreActivation() returned %d jobs, want %d", len(result), tt.expectedCount) + } + // Check that expected job IDs are present + for _, expectedID := range tt.expectedJobIDs { + found := false + for _, job := range result { + if job == expectedID { + found = true + break + } + } + if !found { + t.Errorf("Expected job %q not found in result", expectedID) + } + } + }) + } +} + +// TestGetReferencedCustomJobs tests the getReferencedCustomJobs method +func TestGetReferencedCustomJobs(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + content string + customJobs map[string]any + expectedCount int + expectedJobIDs []string + }{ + { + name: "empty content", + content: "", + customJobs: map[string]any{"job1": map[string]any{}}, + expectedCount: 0, + expectedJobIDs: []string{}, + }, + { + name: "nil custom jobs", + content: "needs.job1.outputs.value", + customJobs: nil, + expectedCount: 0, + expectedJobIDs: []string{}, + }, + { + name: "references one job output", + content: "needs.producer.outputs.value", + customJobs: map[string]any{"producer": map[string]any{}, "consumer": map[string]any{}}, + expectedCount: 1, + expectedJobIDs: []string{"producer"}, + }, + { + name: "references job result", + content: "needs.test_job.result == 'success'", + customJobs: map[string]any{"test_job": map[string]any{}}, + expectedCount: 1, + expectedJobIDs: []string{"test_job"}, + }, + { + name: "references multiple jobs", + content: "needs.job1.outputs.a && needs.job2.outputs.b", + customJobs: map[string]any{"job1": map[string]any{}, "job2": map[string]any{}}, + expectedCount: 2, + expectedJobIDs: []string{"job1", "job2"}, + }, + { + name: "no job references", + content: "github.event_name == 'push'", + customJobs: map[string]any{"job1": map[string]any{}}, + expectedCount: 0, + expectedJobIDs: []string{}, + }, + { + name: "references non-existent job", + content: "needs.unknown.outputs.value", + customJobs: map[string]any{"job1": map[string]any{}}, + expectedCount: 0, + expectedJobIDs: []string{}, + }, + { + name: "github expression format", + content: "${{ needs.check.outputs.status }}", + customJobs: map[string]any{"check": map[string]any{}}, + expectedCount: 1, + expectedJobIDs: []string{"check"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.getReferencedCustomJobs(tt.content, tt.customJobs) + if len(result) != tt.expectedCount { + t.Errorf("getReferencedCustomJobs() returned %d jobs, want %d", len(result), tt.expectedCount) + } + // Check that expected job IDs are present + for _, expectedID := range tt.expectedJobIDs { + found := false + for _, job := range result { + if job == expectedID { + found = true + break + } + } + if !found { + t.Errorf("Expected job %q not found in result", expectedID) + } + } + }) + } +} + +// TestShouldAddCheckoutStep tests the shouldAddCheckoutStep method +func TestShouldAddCheckoutStep(t *testing.T) { + tests := []struct { + name string + data *WorkflowData + actionMode ActionMode + expected bool + }{ + { + name: "custom steps with checkout", + data: &WorkflowData{ + CustomSteps: "- uses: actions/checkout@v4", + }, + actionMode: ActionModeDev, + expected: false, + }, + { + name: "custom steps without checkout", + data: &WorkflowData{ + CustomSteps: "- run: echo 'test'", + }, + actionMode: ActionModeDev, + expected: true, + }, + { + name: "agent file specified", + data: &WorkflowData{ + AgentFile: ".github/agents/custom.md", + }, + actionMode: ActionModeRelease, + expected: true, + }, + { + name: "release mode without agent file", + data: &WorkflowData{ + CustomSteps: "", + }, + actionMode: ActionModeRelease, + expected: false, + }, + { + name: "dev mode without agent file", + data: &WorkflowData{ + CustomSteps: "", + }, + actionMode: ActionModeDev, + expected: true, + }, + { + name: "script mode without agent file", + data: &WorkflowData{ + CustomSteps: "", + }, + actionMode: ActionModeScript, + expected: true, + }, + { + name: "uninitialized mode", + data: &WorkflowData{}, + actionMode: ActionMode(""), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + compiler.actionMode = tt.actionMode + result := compiler.shouldAddCheckoutStep(tt.data) + if result != tt.expected { + t.Errorf("shouldAddCheckoutStep() = %v, want %v (actionMode=%v)", result, tt.expected, tt.actionMode) + } + }) + } +} + // ======================================== // Integration Tests // ======================================== @@ -1239,3 +1554,415 @@ Test content` t.Error("Expected custom2 to depend on custom1") } } + +// ======================================== +// Additional Edge Case Tests +// ======================================== + +// TestBuildCustomJobsWithMultipleDependencies tests custom jobs with complex dependency chains +func TestBuildCustomJobsWithMultipleDependencies(t *testing.T) { + tmpDir := testutil.TempDir(t, "multi-dep-test") + + frontmatter := `--- +on: push +permissions: + contents: read +engine: copilot +strict: false +jobs: + job_a: + runs-on: ubuntu-latest + steps: + - run: echo "job_a" + job_b: + runs-on: ubuntu-latest + needs: job_a + steps: + - run: echo "job_b" + job_c: + runs-on: ubuntu-latest + needs: [job_a, job_b] + steps: + - run: echo "job_c" +--- + +# Test Workflow + +Test content` + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("CompileWorkflow() error: %v", err) + } + + // Read compiled output + lockFile := filepath.Join(tmpDir, "test.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(content) + + // Verify all custom jobs exist + if !containsInNonCommentLines(yamlStr, "job_a:") { + t.Error("Expected job_a") + } + if !containsInNonCommentLines(yamlStr, "job_b:") { + t.Error("Expected job_b") + } + if !containsInNonCommentLines(yamlStr, "job_c:") { + t.Error("Expected job_c") + } + + // Verify job_c has multiple dependencies + if !strings.Contains(yamlStr, "job_a") || !strings.Contains(yamlStr, "job_b") { + t.Error("Expected job_c to depend on both job_a and job_b") + } +} + +// TestBuildCustomJobsWithCircularDetection tests handling of circular dependencies +func TestBuildCustomJobsWithCircularDetection(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + // Create workflow data with potential circular dependency + // Note: This tests that the compiler handles the case without crashing + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "job_a": map[string]any{ + "runs-on": "ubuntu-latest", + "needs": "job_b", + "steps": []any{ + map[string]any{"run": "echo 'job_a'"}, + }, + }, + "job_b": map[string]any{ + "runs-on": "ubuntu-latest", + "needs": "job_a", + "steps": []any{ + map[string]any{"run": "echo 'job_b'"}, + }, + }, + }, + } + + // Build custom jobs - this should not crash even with circular deps + // GitHub Actions itself will catch circular dependencies at runtime + err := compiler.buildCustomJobs(data, false) + if err != nil { + t.Fatalf("buildCustomJobs() returned error: %v", err) + } + + // Verify both jobs were added + if _, exists := compiler.jobManager.GetJob("job_a"); !exists { + t.Error("Expected job_a to be added") + } + if _, exists := compiler.jobManager.GetJob("job_b"); !exists { + t.Error("Expected job_b to be added") + } +} + +// TestBuildCustomJobsWithPermissions tests custom jobs with various permission configurations +func TestBuildCustomJobsWithPermissions(t *testing.T) { + tmpDir := testutil.TempDir(t, "permissions-test") + + frontmatter := `--- +on: push +permissions: + contents: read +engine: copilot +strict: false +jobs: + job_with_perms: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - run: echo "has permissions" + job_without_perms: + runs-on: ubuntu-latest + steps: + - run: echo "no permissions" +--- + +# Test Workflow + +Test content` + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("CompileWorkflow() error: %v", err) + } + + // Read compiled output + lockFile := filepath.Join(tmpDir, "test.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(content) + + // Verify job_with_perms has permissions + if !strings.Contains(yamlStr, "job_with_perms:") { + t.Error("Expected job_with_perms") + } + if !strings.Contains(yamlStr, "contents: write") { + t.Error("Expected contents: write permission") + } + + // Verify job_without_perms exists + if !strings.Contains(yamlStr, "job_without_perms:") { + t.Error("Expected job_without_perms") + } +} + +// TestBuildCustomJobsWithConditionals tests custom jobs with if conditions +func TestBuildCustomJobsWithConditionals(t *testing.T) { + tmpDir := testutil.TempDir(t, "conditionals-test") + + frontmatter := `--- +on: push +permissions: + contents: read +engine: copilot +strict: false +jobs: + conditional_job: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - run: echo "only on main" + always_job: + runs-on: ubuntu-latest + steps: + - run: echo "always runs" +--- + +# Test Workflow + +Test content` + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("CompileWorkflow() error: %v", err) + } + + // Read compiled output + lockFile := filepath.Join(tmpDir, "test.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(content) + + // Verify conditional_job has if condition + if !strings.Contains(yamlStr, "conditional_job:") { + t.Error("Expected conditional_job") + } + if !strings.Contains(yamlStr, "github.ref == 'refs/heads/main'") { + t.Error("Expected if condition to be preserved") + } + + // Verify always_job exists without conditions + if !strings.Contains(yamlStr, "always_job:") { + t.Error("Expected always_job") + } +} + +// TestBuildCustomJobsWithReusableWorkflowAndWith tests reusable workflow with parameters +func TestBuildCustomJobsWithReusableWorkflowAndWith(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + // Create workflow data with reusable workflow and with parameters + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "reusable_job": map[string]any{ + "uses": "owner/repo/.github/workflows/reusable.yml@main", + "with": map[string]any{ + "param1": "value1", + "param2": 42, + }, + }, + }, + } + + err := compiler.buildCustomJobs(data, false) + if err != nil { + t.Fatalf("buildCustomJobs() returned error: %v", err) + } + + // Verify job was added + job, exists := compiler.jobManager.GetJob("reusable_job") + if !exists { + t.Fatal("Expected reusable_job to be added") + } + + // Verify uses field is set + if job.Uses == "" { + t.Error("Expected uses field to be set") + } + + // Verify with parameters are set + if job.With == nil { + t.Fatal("Expected with parameters to be set") + } + if job.With["param1"] != "value1" { + t.Errorf("Expected param1=value1, got %v", job.With["param1"]) + } +} + +// TestBuildCustomJobsWithInvalidSecrets tests secret validation +func TestBuildCustomJobsWithInvalidSecrets(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + // Create workflow data with invalid secrets (not a GitHub Actions expression) + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "reusable_job": map[string]any{ + "uses": "owner/repo/.github/workflows/reusable.yml@main", + "secrets": map[string]any{ + "token": "hardcoded_secret", // Invalid - not an expression + }, + }, + }, + } + + err := compiler.buildCustomJobs(data, false) + if err == nil { + t.Error("Expected error for invalid secret, got nil") + } +} + +// TestBuildCustomJobsAutomaticActivationDependency tests automatic activation dependency +func TestBuildCustomJobsAutomaticActivationDependency(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + // Add activation job to manager + activationJob := &Job{ + Name: string(constants.ActivationJobName), + } + if err := compiler.jobManager.AddJob(activationJob); err != nil { + t.Fatal(err) + } + + // Create workflow data with custom job that has no explicit needs + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "custom_job": map[string]any{ + "runs-on": "ubuntu-latest", + "steps": []any{ + map[string]any{"run": "echo 'test'"}, + }, + }, + }, + } + + // Build custom jobs with activation created + err := compiler.buildCustomJobs(data, true) + if err != nil { + t.Fatalf("buildCustomJobs() returned error: %v", err) + } + + // Verify custom job has automatic dependency on activation + job, exists := compiler.jobManager.GetJob("custom_job") + if !exists { + t.Fatal("Expected custom_job to be added") + } + + // Check that activation is in the needs array + found := false + for _, need := range job.Needs { + if need == string(constants.ActivationJobName) { + found = true + break + } + } + if !found { + t.Error("Expected automatic dependency on activation job") + } +} + +// TestBuildCustomJobsSkipsPreActivationJob tests that pre_activation jobs are skipped +func TestBuildCustomJobsSkipsPreActivationJob(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + // Create workflow data with pre_activation job (should be skipped) + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "pre_activation": map[string]any{ + "runs-on": "ubuntu-latest", + "steps": []any{ + map[string]any{"run": "echo 'should be skipped'"}, + }, + }, + "pre-activation": map[string]any{ + "runs-on": "ubuntu-latest", + "steps": []any{ + map[string]any{"run": "echo 'should also be skipped'"}, + }, + }, + "normal_job": map[string]any{ + "runs-on": "ubuntu-latest", + "steps": []any{ + map[string]any{"run": "echo 'should be added'"}, + }, + }, + }, + } + + err := compiler.buildCustomJobs(data, false) + if err != nil { + t.Fatalf("buildCustomJobs() returned error: %v", err) + } + + // Verify pre_activation jobs were skipped + if _, exists := compiler.jobManager.GetJob("pre_activation"); exists { + t.Error("Expected pre_activation job to be skipped") + } + if _, exists := compiler.jobManager.GetJob("pre-activation"); exists { + t.Error("Expected pre-activation job to be skipped") + } + + // Verify normal job was added + if _, exists := compiler.jobManager.GetJob("normal_job"); !exists { + t.Error("Expected normal_job to be added") + } +}