diff --git a/.golangci.yml b/.golangci.yml index 5d3c625..5986afd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,32 +10,46 @@ linters: enable: # Additional linters beyond standard - - misspell - - gocyclo - - goconst - - gocritic - - revive + - asciicheck + - bidichk - bodyclose + - canonicalheader - contextcheck + - dupl - errname - exhaustive - forcetypeassert - - nilerr - - nolintlint - - prealloc + # - funcorder + - goconst + - gocritic + - gocyclo - godot - - predeclared - - lll + - godox + - goheader - gosec + - iface + - importas + - lll + - maintidx + - misspell + - nilerr + - nlreturn + - nolintlint + - perfsprint + - prealloc + - predeclared + - reassign + - revive + - tagalign + - testableexamples + - thelper + - usestdlibvars + - usetesting disable: # Disable noisy linters - funlen - - gocognit - - nestif - - cyclop - wsl - - nlreturn - wrapcheck settings: diff --git a/Makefile b/Makefile index 944de9f..4772eb0 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,10 @@ test: ## Run all tests go test ./... lint: format ## Run linter (after formatting) - golangci-lint run || true + golangci-lint run \ + --max-issues-per-linter 100 \ + --max-same-issues 50 \ + --output.tab.path stdout || true config-verify: ## Verify golangci-lint configuration golangci-lint config verify --verbose diff --git a/integration_test.go b/integration_test.go index 74aac3d..f22ec47 100644 --- a/integration_test.go +++ b/integration_test.go @@ -7,11 +7,36 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "testing" "github.com/ivuorinen/gh-action-readme/testutil" ) +var ( + // sharedBinaryPath holds the path to the shared test binary. + sharedBinaryPath string + // sharedBinaryOnce ensures the binary is built only once. + sharedBinaryOnce sync.Once + // errSharedBinary holds any error from building the shared binary. + errSharedBinary error + // sharedBinaryTmpDir holds the temporary directory for cleanup. + sharedBinaryTmpDir string +) + +// TestMain handles setup and cleanup for all tests. +func TestMain(m *testing.M) { + // Run all tests + code := m.Run() + + // Cleanup shared binary directory + if sharedBinaryTmpDir != "" { + _ = os.RemoveAll(sharedBinaryTmpDir) + } + + os.Exit(code) +} + // copyDir recursively copies a directory. func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { @@ -48,40 +73,68 @@ func copyDir(src, dst string) error { defer func() { _ = dstFile.Close() }() _, err = io.Copy(dstFile, srcFile) + return err }) } -// buildTestBinary builds the test binary for integration testing. +// getSharedTestBinary returns the path to the shared test binary, building it once if needed. +func getSharedTestBinary(t *testing.T) string { + t.Helper() + + sharedBinaryOnce.Do(func() { + // Create a shared temporary directory that will be cleaned up in TestMain + // Note: Cannot use t.TempDir() here because we need the directory to persist + // across all tests and be cleaned up only at the end in TestMain + tmpDir, err := os.MkdirTemp("", "gh-action-readme-shared-test-*") //nolint:usetesting + if err != nil { + errSharedBinary = err + + return + } + + sharedBinaryTmpDir = tmpDir + + binaryPath := filepath.Join(tmpDir, "gh-action-readme") + cmd := exec.Command("go", "build", "-o", binaryPath, ".") // #nosec G204 -- controlled test input + + var stderr strings.Builder + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errSharedBinary = err + + return + } + + // Copy templates directory to binary directory (for compatibility with embedded templates fallback) + templatesDir := filepath.Join(filepath.Dir(binaryPath), "templates") + if err := copyDir("templates", templatesDir); err != nil { + errSharedBinary = err + + return + } + + sharedBinaryPath = binaryPath + }) + + if errSharedBinary != nil { + t.Fatalf("failed to build shared test binary: %v", errSharedBinary) + } + + return sharedBinaryPath +} + +// buildTestBinary is maintained for compatibility but now uses the shared binary system. func buildTestBinary(t *testing.T) string { t.Helper() - tmpDir, err := os.MkdirTemp("", "gh-action-readme-binary-*") - if err != nil { - t.Fatalf("failed to create temp dir for binary: %v", err) - } - - binaryPath := filepath.Join(tmpDir, "gh-action-readme") - cmd := exec.Command("go", "build", "-o", binaryPath, ".") // #nosec G204 -- controlled test input - - var stderr strings.Builder - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - t.Fatalf("failed to build test binary: %v\nstderr: %s", err, stderr.String()) - } - - // Copy templates directory to binary directory - templatesDir := filepath.Join(filepath.Dir(binaryPath), "templates") - if err := copyDir("templates", templatesDir); err != nil { - t.Fatalf("failed to copy templates: %v", err) - } - - return binaryPath + return getSharedTestBinary(t) } // setupCompleteWorkflow creates a realistic project structure for testing. func setupCompleteWorkflow(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/composite/basic.yml")) testutil.WriteTestFile(t, filepath.Join(tmpDir, "README.md"), "# Old README") @@ -91,6 +144,7 @@ func setupCompleteWorkflow(t *testing.T, tmpDir string) { // setupMultiActionWorkflow creates a project with multiple actions. func setupMultiActionWorkflow(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -107,18 +161,21 @@ func setupMultiActionWorkflow(t *testing.T, tmpDir string) { // setupConfigWorkflow creates a simple action for config testing. func setupConfigWorkflow(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) } // setupErrorWorkflow creates an invalid action file for error testing. func setupErrorWorkflow(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/invalid/missing-description.yml")) } // setupConfigurationHierarchy creates a complex configuration hierarchy for testing. func setupConfigurationHierarchy(t *testing.T, tmpDir string) { + t.Helper() // Create action file testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/composite/basic.yml")) @@ -138,11 +195,12 @@ func setupConfigurationHierarchy(t *testing.T, tmpDir string) { testutil.MustReadFixture("repo-config.yml")) // Set XDG config home to our test directory - _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) } // setupMultiActionWithTemplates creates multiple actions with custom templates. func setupMultiActionWithTemplates(t *testing.T, tmpDir string) { + t.Helper() // Root action testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -174,6 +232,7 @@ func setupMultiActionWithTemplates(t *testing.T, tmpDir string) { // setupCompleteServiceChain creates a comprehensive test environment. func setupCompleteServiceChain(t *testing.T, tmpDir string) { + t.Helper() // Setup configuration hierarchy setupConfigurationHierarchy(t, tmpDir) @@ -193,6 +252,7 @@ func setupCompleteServiceChain(t *testing.T, tmpDir string) { // setupDependencyAnalysisWorkflow creates a project with complex dependencies. func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { + t.Helper() // Create a composite action with multiple dependencies compositeAction := testutil.CreateCompositeAction( "Complex Workflow", @@ -226,13 +286,14 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { // setupConfigurationHierarchyWorkflow creates a comprehensive configuration hierarchy. func setupConfigurationHierarchyWorkflow(t *testing.T, tmpDir string) { + t.Helper() // Create action file testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/composite/basic.yml")) // Set up XDG config home configHome := filepath.Join(tmpDir, ".config") - _ = os.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_CONFIG_HOME", configHome) // Global configuration (lowest priority) globalConfigDir := filepath.Join(configHome, "gh-action-readme") @@ -259,14 +320,15 @@ output_dir: docs` testutil.WriteTestFile(t, filepath.Join(githubDir, "gh-action-readme.yml"), actionConfig) // Environment variables (highest priority before CLI flags) - _ = os.Setenv("GH_ACTION_README_THEME", "minimal") - _ = os.Setenv("GH_ACTION_README_QUIET", "false") + t.Setenv("GH_ACTION_README_THEME", "minimal") + t.Setenv("GH_ACTION_README_QUIET", "false") } // Error scenario setup functions. // setupTemplateErrorScenario creates a scenario with template-related errors. func setupTemplateErrorScenario(t *testing.T, tmpDir string) { + t.Helper() // Create valid action file testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -284,6 +346,7 @@ func setupTemplateErrorScenario(t *testing.T, tmpDir string) { // setupConfigurationErrorScenario creates a scenario with configuration errors. func setupConfigurationErrorScenario(t *testing.T, tmpDir string) { + t.Helper() // Create valid action file testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -302,11 +365,12 @@ invalid_theme: nonexistent` testutil.WriteTestFile(t, filepath.Join(configDir, "config.yml"), incompleteConfig) // Set XDG config home - _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) } // setupFileDiscoveryErrorScenario creates a scenario with file discovery issues. func setupFileDiscoveryErrorScenario(t *testing.T, tmpDir string) { + t.Helper() // Create directory structure but no action files _ = os.MkdirAll(filepath.Join(tmpDir, "actions"), 0750) // #nosec G301 -- test directory permissions _ = os.MkdirAll(filepath.Join(tmpDir, ".github"), 0750) // #nosec G301 -- test directory permissions @@ -321,6 +385,7 @@ func setupFileDiscoveryErrorScenario(t *testing.T, tmpDir string) { // setupServiceIntegrationErrorScenario creates a mixed scenario with various issues. func setupServiceIntegrationErrorScenario(t *testing.T, tmpDir string) { + t.Helper() // Valid action at root testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -345,6 +410,8 @@ template: /path/to/nonexistent/template.tmpl` // checkStepExitCode validates command exit code expectations. func checkStepExitCode(t *testing.T, step workflowStep, exitCode int, stdout, stderr strings.Builder) { + t.Helper() + if step.expectSuccess && exitCode != 0 { t.Errorf("expected success but got exit code %d", exitCode) t.Logf("stdout: %s", stdout.String()) @@ -356,6 +423,8 @@ func checkStepExitCode(t *testing.T, step workflowStep, exitCode int, stdout, st // checkStepOutput validates command output expectations. func checkStepOutput(t *testing.T, step workflowStep, output string) { + t.Helper() + if step.expectOutput != "" && !strings.Contains(output, step.expectOutput) { t.Errorf("expected output to contain %q, got: %s", step.expectOutput, output) } @@ -367,6 +436,8 @@ func checkStepOutput(t *testing.T, step workflowStep, output string) { // executeWorkflowStep runs a single workflow step. func executeWorkflowStep(t *testing.T, binaryPath, tmpDir string, step workflowStep) { + t.Helper() + t.Run(step.name, func(t *testing.T) { cmd := exec.Command(binaryPath, step.cmd...) // #nosec G204 -- controlled test input cmd.Dir = tmpDir @@ -390,8 +461,8 @@ func executeWorkflowStep(t *testing.T, binaryPath, tmpDir string, step workflowS // TestServiceIntegration tests integration between refactored services. func TestServiceIntegration(t *testing.T) { + // Note: Cannot use t.Parallel() because setup functions use t.Setenv binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -494,8 +565,8 @@ func TestServiceIntegration(t *testing.T) { // TestEndToEndWorkflows tests complete workflows from start to finish. func TestEndToEndWorkflows(t *testing.T) { + // Note: Cannot use t.Parallel() because setup functions use t.Setenv binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -711,6 +782,7 @@ type errorScenario struct { // testProjectSetup tests basic project validation. func testProjectSetup(t *testing.T, binaryPath, tmpDir string) { + t.Helper() // Create a new GitHub Action project testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("my-new-action.yml")) @@ -724,6 +796,7 @@ func testProjectSetup(t *testing.T, binaryPath, tmpDir string) { // testDocumentationGeneration tests generation with different themes. func testDocumentationGeneration(t *testing.T, binaryPath, tmpDir string) { + t.Helper() themes := []string{"default", "github", "minimal"} for _, theme := range themes { @@ -747,6 +820,7 @@ func testDocumentationGeneration(t *testing.T, binaryPath, tmpDir string) { // testDependencyManagement tests dependency listing functionality. func testDependencyManagement(t *testing.T, binaryPath, tmpDir string) { + t.Helper() // Update action to be composite with dependencies testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/composite/basic.yml")) @@ -767,6 +841,7 @@ func testDependencyManagement(t *testing.T, binaryPath, tmpDir string) { // testOutputFormats tests generation with different output formats. func testOutputFormats(t *testing.T, binaryPath, tmpDir string) { + t.Helper() formats := []string{"md", "html", "json"} for _, format := range formats { @@ -802,6 +877,7 @@ func testOutputFormats(t *testing.T, binaryPath, tmpDir string) { // testCacheManagement tests cache-related commands. func testCacheManagement(t *testing.T, binaryPath, tmpDir string) { + t.Helper() // Check cache stats cmd := exec.Command(binaryPath, "cache", "stats") cmd.Dir = tmpDir @@ -822,8 +898,8 @@ func testCacheManagement(t *testing.T, binaryPath, tmpDir string) { } func TestCompleteProjectLifecycle(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -856,8 +932,8 @@ func TestCompleteProjectLifecycle(t *testing.T) { // TestMultiFormatIntegration tests all output formats with real data. func TestMultiFormatIntegration(t *testing.T) { + // Note: Cannot use t.Parallel() because setup functions use t.Setenv binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -885,6 +961,7 @@ func TestMultiFormatIntegration(t *testing.T) { // testFormatGeneration tests documentation generation for a specific format. func testFormatGeneration(t *testing.T, binaryPath, tmpDir, format, extension, theme string) { + t.Helper() // Generate documentation in this format stdout, stderr := runGenerationCommand(t, binaryPath, tmpDir, format, theme) @@ -894,6 +971,7 @@ func testFormatGeneration(t *testing.T, binaryPath, tmpDir, format, extension, t // Handle missing files if len(files) == 0 { handleMissingFiles(t, format, extension, stdout, stderr) + return } @@ -903,6 +981,7 @@ func testFormatGeneration(t *testing.T, binaryPath, tmpDir, format, extension, t // runGenerationCommand executes the generation command and returns output. func runGenerationCommand(t *testing.T, binaryPath, tmpDir, format, theme string) (string, string) { + t.Helper() cmd := exec.Command( binaryPath, "gen", @@ -946,6 +1025,7 @@ func findGeneratedFiles(tmpDir, extension string) []string { // handleMissingFiles logs information about missing files and skips if expected. func handleMissingFiles(t *testing.T, format, extension, stdout, stderr string) { + t.Helper() patterns := []string{ extension, "**/" + extension, @@ -964,12 +1044,14 @@ func handleMissingFiles(t *testing.T, format, extension, stdout, stderr string) // validateGeneratedFiles validates the content of generated files. func validateGeneratedFiles(t *testing.T, files []string, format string) { + t.Helper() for _, file := range files { content, err := os.ReadFile(file) // #nosec G304 -- test file path testutil.AssertNoError(t, err) if len(content) == 0 { t.Errorf("generated file %s is empty", file) + continue } @@ -979,6 +1061,7 @@ func validateGeneratedFiles(t *testing.T, files []string, format string) { // validateFormatSpecificContent performs format-specific content validation. func validateFormatSpecificContent(t *testing.T, file string, content []byte, format string) { + t.Helper() switch format { case "json": var jsonData any @@ -995,8 +1078,8 @@ func validateFormatSpecificContent(t *testing.T, file string, content []byte, fo // TestErrorScenarioIntegration tests error handling across service components. func TestErrorScenarioIntegration(t *testing.T) { + // Note: Cannot use t.Parallel() because setup functions use t.Setenv binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -1098,8 +1181,8 @@ func TestErrorScenarioIntegration(t *testing.T) { } func TestStressTestWorkflow(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -1136,8 +1219,8 @@ func TestStressTestWorkflow(t *testing.T) { // TestProgressBarIntegration tests progress bar functionality in various scenarios. func TestProgressBarIntegration(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -1201,6 +1284,7 @@ func TestProgressBarIntegration(t *testing.T) { for _, indicator := range progressIndicators { if strings.Contains(output, indicator) { foundIndicator = true + break } } @@ -1234,8 +1318,8 @@ func TestProgressBarIntegration(t *testing.T) { } func TestErrorRecoveryWorkflow(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -1284,16 +1368,15 @@ func TestErrorRecoveryWorkflow(t *testing.T) { } func TestConfigurationWorkflow(t *testing.T) { + // Note: Cannot use t.Parallel() because this test uses t.Setenv binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() // Set up XDG config environment configHome := filepath.Join(tmpDir, "config") - _ = os.Setenv("XDG_CONFIG_HOME", configHome) - defer func() { _ = os.Unsetenv("XDG_CONFIG_HOME") }() + t.Setenv("XDG_CONFIG_HOME", configHome) testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) @@ -1334,6 +1417,7 @@ func TestConfigurationWorkflow(t *testing.T) { // verifyConfigurationLoading checks that configuration was loaded from multiple sources. func verifyConfigurationLoading(t *testing.T, tmpDir string) { + t.Helper() // Since files may be cleaned up between runs, we'll check if the configuration loading succeeded // by verifying that the setup created the expected configuration files configFiles := []string{ @@ -1351,6 +1435,7 @@ func verifyConfigurationLoading(t *testing.T, tmpDir string) { if configFound == 0 { t.Error("no configuration files found, configuration hierarchy setup failed") + return } @@ -1361,6 +1446,7 @@ func verifyConfigurationLoading(t *testing.T, tmpDir string) { // verifyProgressIndicators checks that progress indicators were displayed properly. func verifyProgressIndicators(t *testing.T, tmpDir string) { + t.Helper() // Progress indicators are verified through successful command execution // The actual progress output is captured during the workflow step execution // Here we verify the infrastructure was set up correctly @@ -1368,6 +1454,7 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { actionFile := filepath.Join(tmpDir, "action.yml") if _, err := os.Stat(actionFile); err != nil { t.Error("action file missing, progress tracking test setup failed") + return } @@ -1375,6 +1462,7 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { content, err := os.ReadFile(actionFile) // #nosec G304 -- test file path if err != nil || len(content) == 0 { t.Error("action file is empty, progress tracking test setup failed") + return } @@ -1383,6 +1471,7 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { // verifyFileDiscovery checks that all action files were discovered correctly. func verifyFileDiscovery(t *testing.T, tmpDir string) { + t.Helper() expectedActions := []string{ filepath.Join(tmpDir, "action.yml"), filepath.Join(tmpDir, "actions", "composite", "action.yml"), @@ -1406,6 +1495,7 @@ func verifyFileDiscovery(t *testing.T, tmpDir string) { if discoveredActions == 0 { t.Error("no action files found, file discovery test setup failed") + return } @@ -1414,6 +1504,7 @@ func verifyFileDiscovery(t *testing.T, tmpDir string) { // verifyTemplateRendering checks that templates were rendered correctly with real data. func verifyTemplateRendering(t *testing.T, tmpDir string) { + t.Helper() // Verify template infrastructure was set up correctly templatesDir := filepath.Join(tmpDir, "templates") if _, err := os.Stat(templatesDir); err != nil { @@ -1432,6 +1523,7 @@ func verifyTemplateRendering(t *testing.T, tmpDir string) { filepath.Join(tmpDir, "**/action.yml"), filepath.Join(tmpDir, "action.yml"), ) + return } } @@ -1447,6 +1539,7 @@ func verifyTemplateRendering(t *testing.T, tmpDir string) { if validActions == 0 { t.Error("no valid action files found for template rendering") + return } @@ -1455,6 +1548,7 @@ func verifyTemplateRendering(t *testing.T, tmpDir string) { // verifyCompleteServiceChain checks that all services worked together correctly. func verifyCompleteServiceChain(t *testing.T, tmpDir string) { + t.Helper() // Verify configuration loading worked verifyConfigurationLoading(t, tmpDir) @@ -1487,6 +1581,7 @@ func verifyCompleteServiceChain(t *testing.T, tmpDir string) { foundComponents, len(requiredComponents), ) + return } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index be1c160..aaca161 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -233,6 +233,7 @@ func (c *Cache) loadFromDisk() error { if os.IsNotExist(err) { return nil // No cache file is fine } + return fmt.Errorf("failed to read cache file: %w", err) } @@ -285,6 +286,7 @@ func (c *Cache) estimateSize(value any) int64 { if err != nil { return 100 // Default estimate } + return int64(len(jsonData)) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 398f0c4..ad932e7 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -1,8 +1,8 @@ package cache import ( + "errors" "fmt" - "os" "strings" "sync" "testing" @@ -39,20 +39,13 @@ func TestNewCache(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - originalXDGCache := os.Getenv("XDG_CACHE_HOME") - _ = os.Setenv("XDG_CACHE_HOME", tmpDir) - defer func() { - if originalXDGCache != "" { - _ = os.Setenv("XDG_CACHE_HOME", originalXDGCache) - } else { - _ = os.Unsetenv("XDG_CACHE_HOME") - } - }() + t.Setenv("XDG_CACHE_HOME", tmpDir) cache, err := NewCache(tt.config) if tt.expectError { testutil.AssertError(t, err) + return } @@ -111,6 +104,8 @@ func TestCache_SetAndGet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Set value err := cache.Set(tt.key, tt.value) testutil.AssertNoError(t, err) @@ -168,6 +163,7 @@ func TestCache_GetOrSet(t *testing.T) { callCount := 0 getter := func() (any, error) { callCount++ + return fmt.Sprintf("generated-value-%d", callCount), nil } @@ -193,7 +189,7 @@ func TestCache_GetOrSetError(t *testing.T) { // Getter that returns error getter := func() (any, error) { - return nil, fmt.Errorf("getter error") + return nil, errors.New("getter error") } value, err := cache.GetOrSet("error-key", getter) @@ -237,6 +233,7 @@ func TestCache_ConcurrentAccess(t *testing.T) { err := cache.Set(key, value) if err != nil { t.Errorf("error setting value: %v", err) + return } @@ -244,11 +241,13 @@ func TestCache_ConcurrentAccess(t *testing.T) { retrieved, exists := cache.Get(key) if !exists { t.Errorf("expected key %s to exist", key) + return } if retrieved != value { t.Errorf("expected %s, got %s", value, retrieved) + return } } @@ -409,15 +408,7 @@ func TestCache_CleanupExpiredEntries(t *testing.T) { MaxSize: 1024 * 1024, } - originalXDGCache := os.Getenv("XDG_CACHE_HOME") - _ = os.Setenv("XDG_CACHE_HOME", tmpDir) - defer func() { - if originalXDGCache != "" { - _ = os.Setenv("XDG_CACHE_HOME", originalXDGCache) - } else { - _ = os.Unsetenv("XDG_CACHE_HOME") - } - }() + t.Setenv("XDG_CACHE_HOME", tmpDir) cache, err := NewCache(config) testutil.AssertNoError(t, err) @@ -453,12 +444,15 @@ func TestCache_ErrorHandling(t *testing.T) { { name: "invalid cache directory permissions", setupFunc: func(t *testing.T) *Cache { + t.Helper() // This test would require special setup for permission testing // For now, we'll create a valid cache and test other error scenarios tmpDir, _ := testutil.TempDir(t) + return createTestCache(t, tmpDir) }, testFunc: func(t *testing.T, cache *Cache) { + t.Helper() // Test setting a value that might cause issues during marshaling // Circular reference would cause JSON marshal to fail, but // Go's JSON package handles most cases gracefully @@ -542,6 +536,8 @@ func TestCache_EstimateSize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + size := cache.estimateSize(tt.value) if size < tt.minSize || size > tt.maxSize { t.Errorf("expected size between %d and %d, got %d", tt.minSize, tt.maxSize, size) @@ -554,15 +550,7 @@ func TestCache_EstimateSize(t *testing.T) { func createTestCache(t *testing.T, tmpDir string) *Cache { t.Helper() - originalXDGCache := os.Getenv("XDG_CACHE_HOME") - _ = os.Setenv("XDG_CACHE_HOME", tmpDir) - t.Cleanup(func() { - if originalXDGCache != "" { - _ = os.Setenv("XDG_CACHE_HOME", originalXDGCache) - } else { - _ = os.Unsetenv("XDG_CACHE_HOME") - } - }) + t.Setenv("XDG_CACHE_HOME", tmpDir) cache, err := NewCache(DefaultConfig()) testutil.AssertNoError(t, err) diff --git a/internal/config.go b/internal/config.go index 43cb4b4..e9bfa6f 100644 --- a/internal/config.go +++ b/internal/config.go @@ -16,6 +16,7 @@ import ( "github.com/ivuorinen/gh-action-readme/internal/git" "github.com/ivuorinen/gh-action-readme/internal/validation" + "github.com/ivuorinen/gh-action-readme/templates_embed" ) // AppConfig represents the application configuration that can be used at multiple levels. @@ -143,13 +144,22 @@ func FillMissing(action *ActionYML, defs DefaultValues) { } } -// resolveTemplatePath resolves a template path relative to the binary directory if it's not absolute. +// resolveTemplatePath resolves a template path, preferring embedded templates. +// For custom/absolute paths, falls back to filesystem. func resolveTemplatePath(templatePath string) string { if filepath.IsAbs(templatePath) { return templatePath } - // Check if template exists in current directory first (for tests) + // Check if template is available in embedded filesystem first + if templates_embed.IsEmbeddedTemplateAvailable(templatePath) { + // Return a special marker to indicate this should use embedded templates + // The actual template loading will handle embedded vs filesystem + return templatePath + } + + // Fallback to filesystem resolution for custom templates + // Check if template exists in current directory if _, err := os.Stat(templatePath); err == nil { return templatePath } @@ -579,5 +589,6 @@ func GetConfigPath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get XDG config file path: %w", err) } + return configDir, nil } diff --git a/internal/config_test.go b/internal/config_test.go index 8212dd4..c75150e 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -9,19 +9,6 @@ import ( ) func TestInitConfig(t *testing.T) { - // Save original environment - originalXDGConfig := os.Getenv("XDG_CONFIG_HOME") - originalHome := os.Getenv("HOME") - defer func() { - if originalXDGConfig != "" { - _ = os.Setenv("XDG_CONFIG_HOME", originalXDGConfig) - } else { - _ = os.Unsetenv("XDG_CONFIG_HOME") - } - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } - }() tests := []struct { name string @@ -49,6 +36,7 @@ func TestInitConfig(t *testing.T) { name: "custom config file", configFile: "custom-config.yml", setupFunc: func(t *testing.T, tempDir string) { + t.Helper() configPath := filepath.Join(tempDir, "custom-config.yml") testutil.WriteTestFile(t, configPath, testutil.MustReadFixture("professional-config.yml")) }, @@ -67,6 +55,7 @@ func TestInitConfig(t *testing.T) { name: "invalid config file", configFile: "config.yml", setupFunc: func(t *testing.T, tempDir string) { + t.Helper() configPath := filepath.Join(tempDir, "config.yml") testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [") }, @@ -85,8 +74,8 @@ func TestInitConfig(t *testing.T) { defer cleanup() // Set XDG_CONFIG_HOME to our temp directory - _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) - _ = os.Setenv("HOME", tmpDir) + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("HOME", tmpDir) if tt.setupFunc != nil { tt.setupFunc(t, tmpDir) @@ -102,6 +91,7 @@ func TestInitConfig(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } @@ -132,6 +122,7 @@ func TestLoadConfiguration(t *testing.T) { { name: "multi-level config hierarchy", setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + t.Helper() // Create global config globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme") _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions @@ -161,6 +152,7 @@ output_dir: output return globalConfigPath, repoRoot, currentDir }, checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() // Should have action-level overrides testutil.AssertEqual(t, "professional", config.Theme) testutil.AssertEqual(t, "output", config.OutputDir) @@ -173,9 +165,10 @@ output_dir: output { name: "environment variable overrides", setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + t.Helper() // Set environment variables - _ = os.Setenv("GH_README_GITHUB_TOKEN", "env-token") - _ = os.Setenv("GITHUB_TOKEN", "fallback-token") + t.Setenv("GH_README_GITHUB_TOKEN", "env-token") + t.Setenv("GITHUB_TOKEN", "fallback-token") // Create config file configPath := filepath.Join(tempDir, "config.yml") @@ -184,14 +177,10 @@ theme: minimal github_token: config-token `) - t.Cleanup(func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") - }) - return configPath, tempDir, tempDir }, checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() // Environment variable should override config file testutil.AssertEqual(t, "env-token", config.GitHubToken) testutil.AssertEqual(t, "minimal", config.Theme) @@ -200,9 +189,10 @@ github_token: config-token { name: "XDG compliance", setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + t.Helper() // Set XDG environment variables xdgConfigHome := filepath.Join(tempDir, "xdg-config") - _ = os.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) // Create XDG-compliant config configDir := filepath.Join(xdgConfigHome, "gh-action-readme") @@ -213,13 +203,10 @@ theme: github verbose: true `) - t.Cleanup(func() { - _ = os.Unsetenv("XDG_CONFIG_HOME") - }) - return configPath, tempDir, tempDir }, checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() testutil.AssertEqual(t, "github", config.Theme) testutil.AssertEqual(t, true, config.Verbose) }, @@ -227,6 +214,7 @@ verbose: true { name: "hidden config file discovery", setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + t.Helper() repoRoot := filepath.Join(tempDir, "repo") _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions @@ -249,6 +237,7 @@ verbose: true return "", repoRoot, repoRoot }, checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() // Should use the first found config (.ghreadme.yaml has priority) testutil.AssertEqual(t, "minimal", config.Theme) testutil.AssertEqual(t, "json", config.OutputFormat) @@ -262,15 +251,7 @@ verbose: true defer cleanup() // Set HOME to temp directory for fallback - originalHome := os.Getenv("HOME") - _ = os.Setenv("HOME", tmpDir) - defer func() { - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } else { - _ = os.Unsetenv("HOME") - } - }() + t.Setenv("HOME", tmpDir) configFile, repoRoot, currentDir := tt.setupFunc(t, tmpDir) @@ -278,6 +259,7 @@ verbose: true if tt.expectError { testutil.AssertError(t, err) + return } @@ -291,19 +273,6 @@ verbose: true } func TestGetConfigPath(t *testing.T) { - // Save original environment - originalXDGConfig := os.Getenv("XDG_CONFIG_HOME") - originalHome := os.Getenv("HOME") - defer func() { - if originalXDGConfig != "" { - _ = os.Setenv("XDG_CONFIG_HOME", originalXDGConfig) - } else { - _ = os.Unsetenv("XDG_CONFIG_HOME") - } - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } - }() tests := []struct { name string @@ -312,17 +281,19 @@ func TestGetConfigPath(t *testing.T) { }{ { name: "XDG_CONFIG_HOME set", - setupFunc: func(_ *testing.T, tempDir string) { - _ = os.Setenv("XDG_CONFIG_HOME", tempDir) - _ = os.Unsetenv("HOME") + setupFunc: func(t *testing.T, tempDir string) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", "") }, contains: "gh-action-readme", }, { name: "HOME fallback", - setupFunc: func(_ *testing.T, tempDir string) { - _ = os.Unsetenv("XDG_CONFIG_HOME") - _ = os.Setenv("HOME", tempDir) + setupFunc: func(t *testing.T, tempDir string) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", tempDir) }, contains: ".config", }, @@ -352,15 +323,7 @@ func TestWriteDefaultConfig(t *testing.T) { defer cleanup() // Set XDG_CONFIG_HOME to our temp directory - originalXDGConfig := os.Getenv("XDG_CONFIG_HOME") - _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) - defer func() { - if originalXDGConfig != "" { - _ = os.Setenv("XDG_CONFIG_HOME", originalXDGConfig) - } else { - _ = os.Unsetenv("XDG_CONFIG_HOME") - } - }() + t.Setenv("XDG_CONFIG_HOME", tmpDir) err := WriteDefaultConfig() testutil.AssertNoError(t, err) @@ -387,6 +350,7 @@ func TestWriteDefaultConfig(t *testing.T) { } func TestResolveThemeTemplate(t *testing.T) { + t.Parallel() tests := []struct { name string theme string @@ -443,12 +407,14 @@ func TestResolveThemeTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() path := resolveThemeTemplate(tt.theme) if tt.expectError { if path != "" { t.Errorf("expected empty path on error, got: %s", path) } + return } @@ -467,48 +433,11 @@ func TestResolveThemeTemplate(t *testing.T) { } func TestConfigTokenHierarchy(t *testing.T) { - tests := []struct { - name string - setupFunc func(t *testing.T) func() - expectedToken string - }{ - { - name: "GH_README_GITHUB_TOKEN has highest priority", - setupFunc: func(_ *testing.T) func() { - _ = os.Setenv("GH_README_GITHUB_TOKEN", "priority-token") - _ = os.Setenv("GITHUB_TOKEN", "fallback-token") - return func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") - } - }, - expectedToken: "priority-token", - }, - { - name: "GITHUB_TOKEN as fallback", - setupFunc: func(_ *testing.T) func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Setenv("GITHUB_TOKEN", "fallback-token") - return func() { - _ = os.Unsetenv("GITHUB_TOKEN") - } - }, - expectedToken: "fallback-token", - }, - { - name: "no environment variables", - setupFunc: func(_ *testing.T) func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") - return func() {} - }, - expectedToken: "", - }, - } + tests := testutil.GetGitHubTokenHierarchyTests() for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cleanup := tt.setupFunc(t) + t.Run(tt.Name, func(t *testing.T) { + cleanup := tt.SetupFunc(t) defer cleanup() tmpDir, tmpCleanup := testutil.TempDir(t) @@ -518,7 +447,7 @@ func TestConfigTokenHierarchy(t *testing.T) { config, err := LoadConfiguration("", tmpDir, tmpDir) testutil.AssertNoError(t, err) - testutil.AssertEqual(t, tt.expectedToken, config.GitHubToken) + testutil.AssertEqual(t, tt.ExpectedToken, config.GitHubToken) }) } } @@ -547,22 +476,8 @@ verbose: true `) // Set HOME and XDG_CONFIG_HOME to temp directory - originalHome := os.Getenv("HOME") - originalXDGConfig := os.Getenv("XDG_CONFIG_HOME") - _ = os.Setenv("HOME", tmpDir) - _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) - defer func() { - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } else { - _ = os.Unsetenv("HOME") - } - if originalXDGConfig != "" { - _ = os.Setenv("XDG_CONFIG_HOME", originalXDGConfig) - } else { - _ = os.Unsetenv("XDG_CONFIG_HOME") - } - }() + t.Setenv("HOME", tmpDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) // Use the specific config file path instead of relying on XDG discovery configPath := filepath.Join(tmpDir, ".config", "gh-action-readme", "config.yaml") @@ -579,21 +494,6 @@ verbose: true // TestGetGitHubToken tests GitHub token resolution with different priority levels. func TestGetGitHubToken(t *testing.T) { - // Save and restore original environment - originalToolToken := os.Getenv(EnvGitHubToken) - originalStandardToken := os.Getenv(EnvGitHubTokenStandard) - defer func() { - if originalToolToken != "" { - _ = os.Setenv(EnvGitHubToken, originalToolToken) - } else { - _ = os.Unsetenv(EnvGitHubToken) - } - if originalStandardToken != "" { - _ = os.Setenv(EnvGitHubTokenStandard, originalStandardToken) - } else { - _ = os.Unsetenv(EnvGitHubTokenStandard) - } - }() tests := []struct { name string @@ -643,14 +543,14 @@ func TestGetGitHubToken(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Set up environment if tt.toolEnvToken != "" { - _ = os.Setenv(EnvGitHubToken, tt.toolEnvToken) + t.Setenv(EnvGitHubToken, tt.toolEnvToken) } else { - _ = os.Unsetenv(EnvGitHubToken) + t.Setenv(EnvGitHubToken, "") } if tt.stdEnvToken != "" { - _ = os.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken) + t.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken) } else { - _ = os.Unsetenv(EnvGitHubTokenStandard) + t.Setenv(EnvGitHubTokenStandard, "") } config := &AppConfig{GitHubToken: tt.configToken} @@ -663,6 +563,7 @@ func TestGetGitHubToken(t *testing.T) { // TestMergeMapFields tests the merging of map fields in configuration. func TestMergeMapFields(t *testing.T) { + t.Parallel() tests := []struct { name string dst *AppConfig @@ -743,6 +644,7 @@ func TestMergeMapFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Deep copy dst to avoid modifying test data dst := &AppConfig{} if tt.dst.Permissions != nil { @@ -768,6 +670,7 @@ func TestMergeMapFields(t *testing.T) { // TestMergeSliceFields tests the merging of slice fields in configuration. func TestMergeSliceFields(t *testing.T) { + t.Parallel() tests := []struct { name string dst *AppConfig @@ -808,16 +711,19 @@ func TestMergeSliceFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() mergeSliceFields(tt.dst, tt.src) // Compare slices manually since they can't be compared directly if len(tt.expected) != len(tt.dst.RunsOn) { t.Errorf("expected slice length %d, got %d", len(tt.expected), len(tt.dst.RunsOn)) + return } for i, expected := range tt.expected { if i >= len(tt.dst.RunsOn) || tt.dst.RunsOn[i] != expected { t.Errorf("expected %v, got %v", tt.expected, tt.dst.RunsOn) + return } } diff --git a/internal/configuration_loader.go b/internal/configuration_loader.go index 90172d6..ed826ee 100644 --- a/internal/configuration_loader.go +++ b/internal/configuration_loader.go @@ -1,6 +1,7 @@ package internal import ( + "errors" "fmt" "os" "path/filepath" @@ -126,6 +127,7 @@ func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile stri return fmt.Errorf("failed to load global config: %w", err) } cl.mergeConfigs(config, globalConfig, true) // Allow tokens for global config + return nil } @@ -149,6 +151,7 @@ func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot st return fmt.Errorf("failed to load repo config: %w", err) } cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config + return nil } @@ -163,6 +166,7 @@ func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir return fmt.Errorf("failed to load action config: %w", err) } cl.mergeConfigs(config, actionConfig, false) // No tokens in action config + return nil } @@ -181,7 +185,7 @@ func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, // ValidateConfiguration validates a configuration for consistency and required values. func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error { if config == nil { - return fmt.Errorf("configuration cannot be nil") + return errors.New("configuration cannot be nil") } // Validate output format @@ -200,12 +204,12 @@ func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error { // Validate output directory if config.OutputDir == "" { - return fmt.Errorf("output directory cannot be empty") + return errors.New("output directory cannot be empty") } // Validate mutually exclusive flags if config.Verbose && config.Quiet { - return fmt.Errorf("verbose and quiet flags are mutually exclusive") + return errors.New("verbose and quiet flags are mutually exclusive") } return nil @@ -373,7 +377,7 @@ func (cl *ConfigurationLoader) setViperDefaults(v *viper.Viper) { // validateTheme validates that a theme exists and is supported. func (cl *ConfigurationLoader) validateTheme(theme string) error { if theme == "" { - return fmt.Errorf("theme cannot be empty") + return errors.New("theme cannot be empty") } // Check if it's a built-in theme @@ -399,6 +403,7 @@ func containsString(slice []string, str string) bool { return true } } + return false } @@ -410,6 +415,7 @@ func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource { sources = append(sources, source) } } + return sources } diff --git a/internal/configuration_loader_test.go b/internal/configuration_loader_test.go index e403328..f3a06b9 100644 --- a/internal/configuration_loader_test.go +++ b/internal/configuration_loader_test.go @@ -9,6 +9,7 @@ import ( ) func TestNewConfigurationLoader(t *testing.T) { + t.Parallel() loader := NewConfigurationLoader() if loader == nil { @@ -38,6 +39,7 @@ func TestNewConfigurationLoader(t *testing.T) { } func TestNewConfigurationLoaderWithOptions(t *testing.T) { + t.Parallel() tests := []struct { name string opts ConfigurationOptions @@ -75,6 +77,7 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() loader := NewConfigurationLoaderWithOptions(tt.opts) for _, expectedSource := range tt.expected { @@ -94,6 +97,7 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) { for _, expectedSource := range tt.expected { if source == expectedSource { expected = true + break } } @@ -171,12 +175,10 @@ quiet: false }, { name: "environment variable overrides", - setupFunc: func(_ *testing.T, tempDir string) (string, string, string) { + setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + t.Helper() // Set environment variables - _ = os.Setenv("GH_README_GITHUB_TOKEN", "env-token") - t.Cleanup(func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - }) + t.Setenv("GH_README_GITHUB_TOKEN", "env-token") // Create config file with different token configPath := filepath.Join(tempDir, "config.yml") @@ -245,15 +247,7 @@ verbose: true defer cleanup() // Set HOME to temp directory for fallback - originalHome := os.Getenv("HOME") - _ = os.Setenv("HOME", tmpDir) - defer func() { - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } else { - _ = os.Unsetenv("HOME") - } - }() + t.Setenv("HOME", tmpDir) configFile, repoRoot, actionDir := tt.setupFunc(t, tmpDir) @@ -272,6 +266,7 @@ verbose: true if tt.expectError { testutil.AssertError(t, err) + return } @@ -294,6 +289,7 @@ func TestConfigurationLoader_LoadGlobalConfig(t *testing.T) { { name: "valid global config", setupFunc: func(t *testing.T, tempDir string) string { + t.Helper() configPath := filepath.Join(tempDir, "config.yaml") testutil.WriteTestFile(t, configPath, ` theme: professional @@ -301,6 +297,7 @@ output_format: html github_token: test-token verbose: true `) + return configPath }, checkFunc: func(_ *testing.T, config *AppConfig) { @@ -320,8 +317,10 @@ verbose: true { name: "invalid YAML", setupFunc: func(t *testing.T, tempDir string) string { + t.Helper() configPath := filepath.Join(tempDir, "invalid.yaml") testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [") + return configPath }, expectError: true, @@ -334,15 +333,7 @@ verbose: true defer cleanup() // Set HOME to temp directory - originalHome := os.Getenv("HOME") - _ = os.Setenv("HOME", tmpDir) - defer func() { - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } else { - _ = os.Unsetenv("HOME") - } - }() + t.Setenv("HOME", tmpDir) configFile := tt.setupFunc(t, tmpDir) @@ -351,6 +342,7 @@ verbose: true if tt.expectError { testutil.AssertError(t, err) + return } @@ -364,6 +356,7 @@ verbose: true } func TestConfigurationLoader_ValidateConfiguration(t *testing.T) { + t.Parallel() tests := []struct { name string config *AppConfig @@ -442,6 +435,7 @@ func TestConfigurationLoader_ValidateConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() loader := NewConfigurationLoader() err := loader.ValidateConfiguration(tt.config) @@ -458,6 +452,7 @@ func TestConfigurationLoader_ValidateConfiguration(t *testing.T) { } func TestConfigurationLoader_SourceManagement(t *testing.T) { + t.Parallel() loader := NewConfigurationLoader() // Test initial state @@ -487,6 +482,7 @@ func TestConfigurationLoader_SourceManagement(t *testing.T) { } func TestConfigurationSource_String(t *testing.T) { + t.Parallel() tests := []struct { source ConfigurationSource expected string @@ -510,48 +506,11 @@ func TestConfigurationSource_String(t *testing.T) { } func TestConfigurationLoader_EnvironmentOverrides(t *testing.T) { - tests := []struct { - name string - setupFunc func(t *testing.T) func() - expectedToken string - }{ - { - name: "GH_README_GITHUB_TOKEN priority", - setupFunc: func(_ *testing.T) func() { - _ = os.Setenv("GH_README_GITHUB_TOKEN", "priority-token") - _ = os.Setenv("GITHUB_TOKEN", "fallback-token") - return func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") - } - }, - expectedToken: "priority-token", - }, - { - name: "GITHUB_TOKEN fallback", - setupFunc: func(_ *testing.T) func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Setenv("GITHUB_TOKEN", "fallback-token") - return func() { - _ = os.Unsetenv("GITHUB_TOKEN") - } - }, - expectedToken: "fallback-token", - }, - { - name: "no environment variables", - setupFunc: func(_ *testing.T) func() { - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") - return func() {} - }, - expectedToken: "", - }, - } + tests := testutil.GetGitHubTokenHierarchyTests() for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cleanup := tt.setupFunc(t) + t.Run(tt.Name, func(t *testing.T) { + cleanup := tt.SetupFunc(t) defer cleanup() tmpDir, tmpCleanup := testutil.TempDir(t) @@ -561,7 +520,7 @@ func TestConfigurationLoader_EnvironmentOverrides(t *testing.T) { config, err := loader.LoadConfiguration("", tmpDir, "") testutil.AssertNoError(t, err) - testutil.AssertEqual(t, tt.expectedToken, config.GitHubToken) + testutil.AssertEqual(t, tt.ExpectedToken, config.GitHubToken) }) } } @@ -588,15 +547,7 @@ func TestConfigurationLoader_RepoOverrides(t *testing.T) { testutil.WriteTestFile(t, globalConfigPath, globalConfigContent) // Set environment for XDG compliance - originalHome := os.Getenv("HOME") - _ = os.Setenv("HOME", tmpDir) - defer func() { - if originalHome != "" { - _ = os.Setenv("HOME", originalHome) - } else { - _ = os.Unsetenv("HOME") - } - }() + t.Setenv("HOME", tmpDir) loader := NewConfigurationLoader() config, err := loader.LoadConfiguration(globalConfigPath, repoRoot, "") @@ -610,6 +561,7 @@ func TestConfigurationLoader_RepoOverrides(t *testing.T) { // TestConfigurationLoader_ApplyRepoOverrides tests repo-specific overrides. func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) { + t.Parallel() tests := []struct { name string config *AppConfig @@ -640,6 +592,7 @@ func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -653,6 +606,7 @@ func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) { // TestConfigurationLoader_LoadActionConfig tests action-specific configuration loading. func TestConfigurationLoader_LoadActionConfig(t *testing.T) { + t.Parallel() tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -670,6 +624,7 @@ func TestConfigurationLoader_LoadActionConfig(t *testing.T) { { name: "action directory with config file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() actionDir := filepath.Join(tmpDir, "action") _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions @@ -679,6 +634,7 @@ theme: minimal output_format: json verbose: true `) + return actionDir }, expectError: false, @@ -690,11 +646,13 @@ verbose: true { name: "action directory with malformed config file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() actionDir := filepath.Join(tmpDir, "action") _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions configPath := filepath.Join(actionDir, "config.yaml") testutil.WriteTestFile(t, configPath, "invalid yaml content:\n - broken [") + return actionDir }, expectError: false, // Function may handle YAML errors gracefully @@ -705,6 +663,7 @@ verbose: true setupFunc: func(_ *testing.T, tmpDir string) string { actionDir := filepath.Join(tmpDir, "action") _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions + return actionDir }, expectError: false, @@ -714,6 +673,7 @@ verbose: true for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -745,6 +705,7 @@ verbose: true // TestConfigurationLoader_ValidateTheme tests theme validation edge cases. func TestConfigurationLoader_ValidateTheme(t *testing.T) { + t.Parallel() tests := []struct { name string theme string @@ -789,6 +750,7 @@ func TestConfigurationLoader_ValidateTheme(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() loader := NewConfigurationLoader() err := loader.validateTheme(tt.theme) diff --git a/internal/dependencies/analyzer.go b/internal/dependencies/analyzer.go index 28d5761..0f1ff09 100644 --- a/internal/dependencies/analyzer.go +++ b/internal/dependencies/analyzer.go @@ -3,6 +3,7 @@ package dependencies import ( "context" + "errors" "fmt" "os" "regexp" @@ -145,7 +146,7 @@ func (a *Analyzer) AnalyzeActionFileWithProgress( progressCallback func(current, total int, message string), ) ([]Dependency, error) { if progressCallback != nil { - progressCallback(0, 1, fmt.Sprintf("Parsing %s", actionPath)) + progressCallback(0, 1, "Parsing "+actionPath) } // Read and parse the action.yml file @@ -179,8 +180,10 @@ func (a *Analyzer) validateAndCheckComposite( if progressCallback != nil { progressCallback(1, 1, "No dependencies (non-composite action)") } + return []Dependency{}, false, nil } + return nil, true, nil } @@ -192,6 +195,7 @@ func (a *Analyzer) validateActionType(usingType string) error { return nil } } + return fmt.Errorf("invalid action runtime: %s", usingType) } @@ -230,11 +234,13 @@ func (a *Analyzer) processStep(step CompositeStep, stepNumber int) *Dependency { // Log error but continue processing return nil } + return dep } else if step.Run != "" { // This is a shell script step return a.analyzeShellScript(step, stepNumber) } + return nil } @@ -361,6 +367,7 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string, func (a *Analyzer) isCommitSHA(version string) bool { // Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA) re := regexp.MustCompile(`^[a-f0-9]{7,40}$`) + return len(version) >= minSHALength && re.MatchString(version) } @@ -368,6 +375,7 @@ func (a *Analyzer) isCommitSHA(version string) bool { func (a *Analyzer) isSemanticVersion(version string) bool { // Check for vX, vX.Y, vX.Y.Z format re := regexp.MustCompile(`^v?\d+(\.\d+)*(\.\d+)?(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`) + return re.MatchString(version) } @@ -379,6 +387,7 @@ func (a *Analyzer) isVersionPinned(version string) bool { return true } re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`) + return re.MatchString(version) } @@ -392,6 +401,7 @@ func (a *Analyzer) convertWithParams(with map[string]any) map[string]string { params[k] = fmt.Sprintf("%v", v) } } + return params } @@ -432,7 +442,7 @@ func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error // getLatestVersion fetches the latest release/tag for a repository. func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) { if a.GitHubClient == nil { - return "", "", fmt.Errorf("GitHub client not available") + return "", "", errors.New("GitHub client not available") } ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout) @@ -447,6 +457,7 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er // Try to get latest release first if version, sha, err := a.getLatestRelease(ctx, owner, repo); err == nil { a.cacheVersion(cacheKey, version, sha) + return version, sha, nil } @@ -457,6 +468,7 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er } a.cacheVersion(cacheKey, version, sha) + return version, sha, nil } @@ -483,11 +495,12 @@ func (a *Analyzer) getCachedVersion(cacheKey string) (version, sha string, found func (a *Analyzer) getLatestRelease(ctx context.Context, owner, repo string) (version, sha string, err error) { release, _, err := a.GitHubClient.Repositories.GetLatestRelease(ctx, owner, repo) if err != nil || release.GetTagName() == "" { - return "", "", fmt.Errorf("no release found") + return "", "", errors.New("no release found") } version = release.GetTagName() sha = a.getCommitSHAForTag(ctx, owner, repo, version) + return version, sha, nil } @@ -497,6 +510,7 @@ func (a *Analyzer) getCommitSHAForTag(ctx context.Context, owner, repo, tagName if err != nil || tag.GetObject() == nil { return "" } + return tag.GetObject().GetSHA() } @@ -506,10 +520,11 @@ func (a *Analyzer) getLatestTag(ctx context.Context, owner, repo string) (versio PerPage: 10, }) if err != nil || len(tags) == 0 { - return "", "", fmt.Errorf("no releases or tags found") + return "", "", errors.New("no releases or tags found") } latestTag := tags[0] + return latestTag.GetName(), latestTag.GetCommit().GetSHA(), nil } @@ -550,6 +565,7 @@ func (a *Analyzer) parseVersionParts(version string) []string { for len(parts) < versionPartsCount { parts = append(parts, "0") } + return parts } @@ -564,6 +580,7 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin if currentParts[2] != latestParts[2] { return updateTypePatch } + return updateTypeNone } @@ -636,6 +653,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) lines[i] = indent + usesFieldPrefix + update.NewUses update.LineNumber = i + 1 // Store line number for reference + break } } @@ -652,8 +670,9 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err if err := a.validateActionFile(filePath); err != nil { // Rollback on validation failure if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil { - return fmt.Errorf("validation failed and rollback failed: %v (original error: %w)", rollbackErr, err) + return fmt.Errorf("validation failed and rollback failed: %w (original error: %w)", rollbackErr, err) } + return fmt.Errorf("validation failed, rolled back changes: %w", err) } @@ -666,6 +685,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err // validateActionFile validates that an action.yml file is still valid after updates. func (a *Analyzer) validateActionFile(filePath string) error { _, err := a.parseCompositeAction(filePath) + return err } @@ -680,6 +700,7 @@ func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) err if cached, exists := a.Cache.Get(cacheKey); exists { if repository, ok := cached.(*github.Repository); ok { dep.Description = repository.GetDescription() + return nil } } diff --git a/internal/dependencies/analyzer_test.go b/internal/dependencies/analyzer_test.go index 9b1a037..4125bb9 100644 --- a/internal/dependencies/analyzer_test.go +++ b/internal/dependencies/analyzer_test.go @@ -1,9 +1,9 @@ package dependencies import ( - "fmt" "net/http" "path/filepath" + "strconv" "strings" "testing" "time" @@ -16,6 +16,8 @@ import ( ) func TestAnalyzer_AnalyzeActionFile(t *testing.T) { + t.Parallel() + tests := []struct { name string actionYML string @@ -62,6 +64,8 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Create temporary action file tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -85,6 +89,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { // Check error expectation if tt.expectError { testutil.AssertError(t, err) + return } testutil.AssertNoError(t, err) @@ -100,6 +105,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { for i, expectedDep := range tt.expectedDeps { if i >= len(deps) { t.Errorf("expected dependency %s but got fewer dependencies", expectedDep) + continue } if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) { @@ -115,6 +121,8 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { } func TestAnalyzer_ParseUsesStatement(t *testing.T) { + t.Parallel() + tests := []struct { name string uses string @@ -161,6 +169,8 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + owner, repo, version, versionType := analyzer.parseUsesStatement(tt.uses) testutil.AssertEqual(t, tt.expectedOwner, owner) @@ -172,6 +182,8 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) { } func TestAnalyzer_VersionChecking(t *testing.T) { + t.Parallel() + tests := []struct { name string version string @@ -227,6 +239,8 @@ func TestAnalyzer_VersionChecking(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + isPinned := analyzer.isVersionPinned(tt.version) isCommitSHA := analyzer.isCommitSHA(tt.version) isSemantic := analyzer.isSemanticVersion(tt.version) @@ -239,6 +253,8 @@ func TestAnalyzer_VersionChecking(t *testing.T) { } func TestAnalyzer_GetLatestVersion(t *testing.T) { + t.Parallel() + // Create mock GitHub client with test responses mockResponses := testutil.MockGitHubResponses() githubClient := testutil.MockGitHubClient(mockResponses) @@ -277,10 +293,13 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + version, sha, err := analyzer.getLatestVersion(tt.owner, tt.repo) if tt.expectError { testutil.AssertError(t, err) + return } @@ -292,6 +311,8 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) { } func TestAnalyzer_CheckOutdated(t *testing.T) { + t.Parallel() + // Create mock GitHub client mockResponses := testutil.MockGitHubResponses() githubClient := testutil.MockGitHubClient(mockResponses) @@ -349,6 +370,8 @@ func TestAnalyzer_CheckOutdated(t *testing.T) { } func TestAnalyzer_CompareVersions(t *testing.T) { + t.Parallel() + analyzer := &Analyzer{} tests := []struct { @@ -391,6 +414,8 @@ func TestAnalyzer_CompareVersions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + updateType := analyzer.compareVersions(tt.current, tt.latest) testutil.AssertEqual(t, tt.expectedType, updateType) }) @@ -398,6 +423,8 @@ func TestAnalyzer_CompareVersions(t *testing.T) { } func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -446,6 +473,8 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { } func TestAnalyzer_WithCache(t *testing.T) { + t.Parallel() + // Test that caching works properly mockResponses := testutil.MockGitHubResponses() githubClient := testutil.MockGitHubClient(mockResponses) @@ -470,12 +499,14 @@ func TestAnalyzer_WithCache(t *testing.T) { } func TestAnalyzer_RateLimitHandling(t *testing.T) { + t.Parallel() + // Create mock client that returns rate limit error rateLimitResponse := &http.Response{ - StatusCode: 403, + StatusCode: http.StatusForbidden, Header: http.Header{ "X-RateLimit-Remaining": []string{"0"}, - "X-RateLimit-Reset": []string{fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix())}, + "X-RateLimit-Reset": []string{strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)}, }, Body: testutil.NewStringReader(`{"message": "API rate limit exceeded"}`), } @@ -508,6 +539,8 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) { } func TestAnalyzer_WithoutGitHubClient(t *testing.T) { + t.Parallel() + // Test graceful degradation when GitHub client is not available analyzer := &Analyzer{ GitHubClient: nil, @@ -546,6 +579,8 @@ func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { // TestNewAnalyzer tests the analyzer constructor. func TestNewAnalyzer(t *testing.T) { + t.Parallel() + // Create test dependencies mockResponses := testutil.MockGitHubResponses() githubClient := testutil.MockGitHubClient(mockResponses) @@ -597,6 +632,8 @@ func TestNewAnalyzer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + analyzer := NewAnalyzer(tt.client, tt.repoInfo, tt.cache) if tt.expectNotNil && analyzer == nil { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 7af4ae2..f58ffaf 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -65,13 +65,13 @@ func (ce *ContextualError) Error() string { if len(ce.Suggestions) > 0 { b.WriteString("\n\nSuggestions:") for _, suggestion := range ce.Suggestions { - b.WriteString(fmt.Sprintf("\n • %s", suggestion)) + b.WriteString("\n • " + suggestion) } } // Add help URL if ce.HelpURL != "" { - b.WriteString(fmt.Sprintf("\n\nFor more help: %s", ce.HelpURL)) + b.WriteString("\n\nFor more help: " + ce.HelpURL) } return b.String() @@ -120,6 +120,7 @@ func Wrap(err error, code ErrorCode, context string) *ContextualError { if ce.Context == "" { ce.Context = context } + return ce } @@ -133,6 +134,7 @@ func Wrap(err error, code ErrorCode, context string) *ContextualError { // WithSuggestions adds suggestions to a ContextualError. func (ce *ContextualError) WithSuggestions(suggestions ...string) *ContextualError { ce.Suggestions = append(ce.Suggestions, suggestions...) + return ce } @@ -144,12 +146,14 @@ func (ce *ContextualError) WithDetails(details map[string]string) *ContextualErr for k, v := range details { ce.Details[k] = v } + return ce } // WithHelpURL adds a help URL to a ContextualError. func (ce *ContextualError) WithHelpURL(url string) *ContextualError { ce.HelpURL = url + return ce } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 803d7f0..db8d9c9 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -7,6 +7,8 @@ import ( ) func TestContextualError_Error(t *testing.T) { + t.Parallel() + tests := []struct { name string err *ContextualError @@ -103,6 +105,8 @@ func TestContextualError_Error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tt.err.Error() for _, expected := range tt.contains { @@ -119,6 +123,8 @@ func TestContextualError_Error(t *testing.T) { } func TestContextualError_Unwrap(t *testing.T) { + t.Parallel() + originalErr := errors.New("original error") contextualErr := &ContextualError{ Code: ErrCodeFileNotFound, @@ -131,6 +137,8 @@ func TestContextualError_Unwrap(t *testing.T) { } func TestContextualError_Is(t *testing.T) { + t.Parallel() + originalErr := errors.New("original error") contextualErr := &ContextualError{ Code: ErrCodeFileNotFound, @@ -156,6 +164,8 @@ func TestContextualError_Is(t *testing.T) { } func TestNew(t *testing.T) { + t.Parallel() + err := New(ErrCodeFileNotFound, "test message") if err.Code != ErrCodeFileNotFound { @@ -168,6 +178,8 @@ func TestNew(t *testing.T) { } func TestWrap(t *testing.T) { + t.Parallel() + originalErr := errors.New("original error") // Test wrapping normal error @@ -204,6 +216,8 @@ func TestWrap(t *testing.T) { } func TestContextualError_WithMethods(t *testing.T) { + t.Parallel() + err := New(ErrCodeFileNotFound, "test error") // Test WithSuggestions @@ -234,6 +248,8 @@ func TestContextualError_WithMethods(t *testing.T) { } func TestGetHelpURL(t *testing.T) { + t.Parallel() + tests := []struct { code ErrorCode contains string @@ -246,6 +262,8 @@ func TestGetHelpURL(t *testing.T) { for _, tt := range tests { t.Run(string(tt.code), func(t *testing.T) { + t.Parallel() + url := GetHelpURL(tt.code) if !strings.Contains(url, tt.contains) { t.Errorf("GetHelpURL(%s) = %s, should contain %s", tt.code, url, tt.contains) diff --git a/internal/errors/suggestions.go b/internal/errors/suggestions.go index f957cca..aaef5fc 100644 --- a/internal/errors/suggestions.go +++ b/internal/errors/suggestions.go @@ -13,6 +13,7 @@ func GetSuggestions(code ErrorCode, context map[string]string) []string { if handler := getSuggestionHandler(code); handler != nil { return handler(context) } + return getDefaultSuggestions() } @@ -63,7 +64,7 @@ func getFileNotFoundSuggestions(context map[string]string) []string { if path, ok := context["path"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Check if the file exists: %s", path), + "Check if the file exists: "+path, "Verify the file path is correct", ) @@ -72,7 +73,7 @@ func getFileNotFoundSuggestions(context map[string]string) []string { if _, err := os.Stat(dir); err == nil { suggestions = append(suggestions, "Check for case sensitivity in the filename", - fmt.Sprintf("Try: ls -la %s", dir), + "Try: ls -la "+dir, ) } @@ -98,14 +99,14 @@ func getPermissionSuggestions(context map[string]string) []string { if path, ok := context["path"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Check file permissions: ls -la %s", path), - fmt.Sprintf("Try changing permissions: chmod 644 %s", path), + "Check file permissions: ls -la "+path, + "Try changing permissions: chmod 644 "+path, ) // Check if it's a directory if info, err := os.Stat(path); err == nil && info.IsDir() { suggestions = append(suggestions, - fmt.Sprintf("For directories, try: chmod 755 %s", path), + "For directories, try: chmod 755 "+path, ) } } @@ -168,7 +169,7 @@ func getInvalidActionSuggestions(context map[string]string) []string { if missingFields, ok := context["missing_fields"]; ok { suggestions = append([]string{ - fmt.Sprintf("Missing required fields: %s", missingFields), + "Missing required fields: " + missingFields, }, suggestions...) } @@ -196,7 +197,7 @@ func getNoActionFilesSuggestions(context map[string]string) []string { if dir, ok := context["directory"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Current directory: %s", dir), + "Current directory: "+dir, fmt.Sprintf("Try: find %s -name 'action.y*ml' -type f", dir), ) } @@ -274,8 +275,8 @@ func getConfigurationSuggestions(context map[string]string) []string { if configPath, ok := context["config_path"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Config path: %s", configPath), - fmt.Sprintf("Check if file exists: ls -la %s", configPath), + "Config path: "+configPath, + "Check if file exists: ls -la "+configPath, ) } @@ -301,7 +302,7 @@ func getValidationSuggestions(context map[string]string) []string { if fields, ok := context["invalid_fields"]; ok { suggestions = append([]string{ - fmt.Sprintf("Invalid fields: %s", fields), + "Invalid fields: " + fields, "Check spelling and nesting of these fields", }, suggestions...) } @@ -333,14 +334,14 @@ func getTemplateSuggestions(context map[string]string) []string { if templatePath, ok := context["template_path"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Template path: %s", templatePath), + "Template path: "+templatePath, "Ensure template file exists and is readable", ) } if theme, ok := context["theme"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Current theme: %s", theme), + "Current theme: "+theme, "Try using a different theme: --theme github", "Available themes: default, github, gitlab, minimal, professional", ) @@ -359,9 +360,9 @@ func getFileWriteSuggestions(context map[string]string) []string { if outputPath, ok := context["output_path"]; ok { dir := filepath.Dir(outputPath) suggestions = append(suggestions, - fmt.Sprintf("Output directory: %s", dir), - fmt.Sprintf("Check permissions: ls -la %s", dir), - fmt.Sprintf("Create directory if needed: mkdir -p %s", dir), + "Output directory: "+dir, + "Check permissions: ls -la "+dir, + "Create directory if needed: mkdir -p "+dir, ) // Check if file already exists @@ -385,7 +386,7 @@ func getDependencyAnalysisSuggestions(context map[string]string) []string { if action, ok := context["action"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Analyzing action: %s", action), + "Analyzing action: "+action, "Only composite actions have analyzable dependencies", ) } @@ -406,8 +407,8 @@ func getCacheAccessSuggestions(context map[string]string) []string { if cachePath, ok := context["cache_path"]; ok { suggestions = append(suggestions, - fmt.Sprintf("Cache path: %s", cachePath), - fmt.Sprintf("Check permissions: ls -la %s", cachePath), + "Cache path: "+cachePath, + "Check permissions: ls -la "+cachePath, "You can disable cache temporarily with environment variables", ) } diff --git a/internal/errors/suggestions_test.go b/internal/errors/suggestions_test.go index 60ac41d..bd2a717 100644 --- a/internal/errors/suggestions_test.go +++ b/internal/errors/suggestions_test.go @@ -7,6 +7,8 @@ import ( ) func TestGetSuggestions(t *testing.T) { + t.Parallel() + tests := []struct { name string code ErrorCode @@ -239,10 +241,13 @@ func TestGetSuggestions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + suggestions := GetSuggestions(tt.code, tt.context) if len(suggestions) == 0 { t.Error("GetSuggestions() returned empty slice") + return } @@ -261,6 +266,8 @@ func TestGetSuggestions(t *testing.T) { } func TestGetPermissionSuggestions_OSSpecific(t *testing.T) { + t.Parallel() + context := map[string]string{"path": "/test/file"} suggestions := getPermissionSuggestions(context) @@ -285,6 +292,8 @@ func TestGetPermissionSuggestions_OSSpecific(t *testing.T) { } func TestGetSuggestions_EmptyContext(t *testing.T) { + t.Parallel() + // Test that all error codes work with empty context errorCodes := []ErrorCode{ ErrCodeFileNotFound, @@ -305,6 +314,8 @@ func TestGetSuggestions_EmptyContext(t *testing.T) { for _, code := range errorCodes { t.Run(string(code), func(t *testing.T) { + t.Parallel() + suggestions := GetSuggestions(code, map[string]string{}) if len(suggestions) == 0 { t.Errorf("GetSuggestions(%s, {}) returned empty slice", code) @@ -314,6 +325,8 @@ func TestGetSuggestions_EmptyContext(t *testing.T) { } func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) { + t.Parallel() + context := map[string]string{ "path": "/project/action.yml", } @@ -332,6 +345,8 @@ func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) { } func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) { + t.Parallel() + context := map[string]string{ "error": "found character that cannot start any token, tab character", } @@ -346,6 +361,8 @@ func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) { } func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) { + t.Parallel() + statusCodes := map[string]string{ "401": "Authentication failed", "403": "Access forbidden", @@ -354,6 +371,8 @@ func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) { for code, expectedText := range statusCodes { t.Run("status_"+code, func(t *testing.T) { + t.Parallel() + context := map[string]string{"status_code": code} suggestions := getGitHubAPISuggestions(context) allSuggestions := strings.Join(suggestions, " ") diff --git a/internal/focused_consumers.go b/internal/focused_consumers.go index e504e1c..1328e3d 100644 --- a/internal/focused_consumers.go +++ b/internal/focused_consumers.go @@ -51,7 +51,7 @@ func (fem *FocusedErrorManager) HandleValidationError(file string, missingFields fem.manager.ErrorWithContext( errors.ErrCodeValidation, - fmt.Sprintf("Validation failed for %s", file), + "Validation failed for "+file, context, ) } @@ -133,6 +133,7 @@ func NewValidationComponent(errorManager ErrorManager, logger MessageLogger) *Va func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err error) { if isValid { vc.logger.Success("Validation passed for: %s", item) + return } @@ -144,7 +145,7 @@ func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err } } else { vc.errorManager.ErrorWithSimpleFix( - fmt.Sprintf("Validation failed for %s", item), + "Validation failed for "+item, "Please check the item configuration and try again", ) } diff --git a/internal/generator.go b/internal/generator.go index 10a8f53..ec3bf3e 100644 --- a/internal/generator.go +++ b/internal/generator.go @@ -2,9 +2,11 @@ package internal import ( + "errors" "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/google/go-github/v57/github" @@ -12,7 +14,7 @@ import ( "github.com/ivuorinen/gh-action-readme/internal/cache" "github.com/ivuorinen/gh-action-readme/internal/dependencies" - "github.com/ivuorinen/gh-action-readme/internal/errors" + errCodes "github.com/ivuorinen/gh-action-readme/internal/errors" "github.com/ivuorinen/gh-action-readme/internal/git" ) @@ -109,6 +111,7 @@ func (g *Generator) GenerateFromFile(actionPath string) error { } outputDir := g.determineOutputDir(actionPath) + return g.generateByFormat(action, outputDir, actionPath) } @@ -151,6 +154,7 @@ func (g *Generator) determineOutputDir(actionPath string) string { if g.Config.OutputDir == "" || g.Config.OutputDir == "." { return filepath.Dir(actionPath) } + return g.Config.OutputDir } @@ -160,8 +164,10 @@ func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string if filepath.IsAbs(g.Config.OutputFilename) { return g.Config.OutputFilename } + return filepath.Join(outputDir, g.Config.OutputFilename) } + return filepath.Join(outputDir, defaultFilename) } @@ -212,6 +218,7 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st } g.Output.Success("Generated README.md: %s", outputPath) + return nil } @@ -254,6 +261,7 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string } g.Output.Success("Generated HTML: %s", outputPath) + return nil } @@ -267,6 +275,7 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error { } g.Output.Success("Generated JSON: %s", outputPath) + return nil } @@ -298,6 +307,7 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath st } g.Output.Success("Generated AsciiDoc: %s", outputPath) + return nil } @@ -330,31 +340,33 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool actionFiles, err := g.DiscoverActionFiles(dir, recursive) if err != nil { g.Output.ErrorWithContext( - errors.ErrCodeFileNotFound, - fmt.Sprintf("failed to discover action files for %s", context), + errCodes.ErrCodeFileNotFound, + "failed to discover action files for "+context, map[string]string{ "directory": dir, - "recursive": fmt.Sprintf("%t", recursive), + "recursive": strconv.FormatBool(recursive), "context": context, ContextKeyError: err.Error(), }, ) + return nil, err } // Check if any files were found if len(actionFiles) == 0 { - contextMsg := fmt.Sprintf("no GitHub Action files found for %s", context) + contextMsg := "no GitHub Action files found for " + context g.Output.ErrorWithContext( - errors.ErrCodeNoActionFiles, + errCodes.ErrCodeNoActionFiles, contextMsg, map[string]string{ "directory": dir, - "recursive": fmt.Sprintf("%t", recursive), + "recursive": strconv.FormatBool(recursive), "context": context, "suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)", }, ) + return nil, fmt.Errorf("no action files found in directory: %s", dir) } @@ -364,7 +376,7 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool // ProcessBatch processes multiple action.yml files. func (g *Generator) ProcessBatch(paths []string) error { if len(paths) == 0 { - return fmt.Errorf("no action files to process") + return errors.New("no action files to process") } bar := g.Progress.CreateProgressBarForFiles("Processing files", paths) @@ -375,6 +387,7 @@ func (g *Generator) ProcessBatch(paths []string) error { if len(errors) > 0 { return fmt.Errorf("encountered %d errors during batch processing", len(errors)) } + return nil } @@ -396,6 +409,7 @@ func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) ( g.Progress.UpdateProgressBar(bar) } + return errors, successCount } @@ -418,7 +432,7 @@ func (g *Generator) reportResults(successCount int, errors []string) { // ValidateFiles validates multiple action.yml files and reports results. func (g *Generator) ValidateFiles(paths []string) error { if len(paths) == 0 { - return fmt.Errorf("no action files to validate") + return errors.New("no action files to validate") } bar := g.Progress.CreateProgressBarForFiles("Validating files", paths) @@ -440,8 +454,10 @@ func (g *Generator) ValidateFiles(paths []string) error { if len(errors) > 0 || validationFailures > 0 { totalFailures := len(errors) + validationFailures + return fmt.Errorf("validation failed for %d files", totalFailures) } + return nil } @@ -459,15 +475,17 @@ func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar) if err != nil { errorMsg := fmt.Sprintf("failed to parse %s: %v", path, err) errors = append(errors, errorMsg) + continue } result := ValidateActionYML(action) - result.MissingFields = append([]string{fmt.Sprintf("file: %s", path)}, result.MissingFields...) + result.MissingFields = append([]string{"file: " + path}, result.MissingFields...) allResults = append(allResults, result) g.Progress.UpdateProgressBar(bar) } + return allResults, errors } @@ -490,6 +508,7 @@ func (g *Generator) countValidationStats(results []ValidationResult) (validFiles totalIssues += len(result.MissingFields) - 1 // Subtract file path entry } } + return validFiles, totalIssues } diff --git a/internal/generator_comprehensive_test.go b/internal/generator_comprehensive_test.go index 3f9cd0b..4e01575 100644 --- a/internal/generator_comprehensive_test.go +++ b/internal/generator_comprehensive_test.go @@ -2,9 +2,7 @@ package internal import ( "fmt" - "os" "path/filepath" - "runtime" "testing" "github.com/ivuorinen/gh-action-readme/testutil" @@ -13,6 +11,7 @@ import ( // TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework // by testing generation across all fixtures, themes, and formats systematically. func TestGenerator_ComprehensiveGeneration(t *testing.T) { + t.Parallel() // Create test cases using the new helper functions cases := testutil.CreateGeneratorTestCases() @@ -35,6 +34,7 @@ func TestGenerator_ComprehensiveGeneration(t *testing.T) { // TestGenerator_AllValidFixtures tests generation with all valid fixtures. func TestGenerator_AllValidFixtures(t *testing.T) { + t.Parallel() validFixtures := testutil.GetValidFixtures() for _, fixture := range validFixtures { @@ -66,6 +66,7 @@ func TestGenerator_AllValidFixtures(t *testing.T) { // TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors. func TestGenerator_AllInvalidFixtures(t *testing.T) { + t.Parallel() invalidFixtures := testutil.GetInvalidFixtures() for _, fixture := range invalidFixtures { @@ -107,8 +108,10 @@ func TestGenerator_AllInvalidFixtures(t *testing.T) { // TestGenerator_AllThemes demonstrates theme testing using helper functions. func TestGenerator_AllThemes(t *testing.T) { + t.Parallel() // Use the helper function to test all themes testutil.TestAllThemes(t, func(t *testing.T, theme string) { + t.Helper() // Create a simple action for testing actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml") @@ -128,8 +131,10 @@ func TestGenerator_AllThemes(t *testing.T) { // TestGenerator_AllFormats demonstrates format testing using helper functions. func TestGenerator_AllFormats(t *testing.T) { + t.Parallel() // Use the helper function to test all formats testutil.TestAllFormats(t, func(t *testing.T, format string) { + t.Helper() // Create a simple action for testing actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml") @@ -149,6 +154,7 @@ func TestGenerator_AllFormats(t *testing.T) { // TestGenerator_ByActionType demonstrates testing by action type. func TestGenerator_ByActionType(t *testing.T) { + t.Parallel() actionTypes := []testutil.ActionType{ testutil.ActionTypeJavaScript, testutil.ActionTypeComposite, @@ -186,6 +192,7 @@ func TestGenerator_ByActionType(t *testing.T) { // TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment. func TestGenerator_WithMockEnvironment(t *testing.T) { + t.Parallel() // Create a complete test environment envConfig := &testutil.EnvironmentConfig{ ActionFixtures: []string{"actions/composite/with-dependencies.yml"}, @@ -222,6 +229,7 @@ func TestGenerator_WithMockEnvironment(t *testing.T) { // TestGenerator_FixtureValidation demonstrates fixture validation. func TestGenerator_FixtureValidation(t *testing.T) { + t.Parallel() // Test that all valid fixtures pass validation validFixtures := testutil.GetValidFixtures() @@ -236,6 +244,7 @@ func TestGenerator_FixtureValidation(t *testing.T) { for _, fixtureName := range invalidFixtures { t.Run(fixtureName, func(t *testing.T) { + t.Parallel() testutil.AssertFixtureInvalid(t, fixtureName) }) } @@ -257,6 +266,7 @@ func createGeneratorTestExecutor() testutil.TestExecutor { fixture, err := ctx.FixtureManager.LoadActionFixture(testCase.Fixture) if err != nil { result.Error = fmt.Errorf("failed to load fixture %s: %w", testCase.Fixture, err) + return result } @@ -268,48 +278,19 @@ func createGeneratorTestExecutor() testutil.TestExecutor { // If we don't have an action file to test, just return success if actionPath == "" { result.Success = true + return result } // Create generator configuration from test config config := createGeneratorConfigFromTestConfig(ctx.Config, ctx.TempDir) - // Save current working directory and change to project root for template resolution - originalWd, err := os.Getwd() - if err != nil { - result.Error = fmt.Errorf("failed to get working directory: %w", err) - return result - } - - // Use runtime.Caller to find project root relative to this file - _, currentFile, _, ok := runtime.Caller(0) - if !ok { - result.Error = fmt.Errorf("failed to get current file path") - return result - } - - // Get the project root (go up from internal/generator_comprehensive_test.go to project root) - projectRoot := filepath.Dir(filepath.Dir(currentFile)) - if err := os.Chdir(projectRoot); err != nil { - result.Error = fmt.Errorf("failed to change to project root %s: %w", projectRoot, err) - return result - } - - // Debug: Log the working directory and template path - currentWd, _ := os.Getwd() - t.Logf("Test working directory: %s, template path: %s", currentWd, config.Template) - - // Restore working directory after test - defer func() { - if err := os.Chdir(originalWd); err != nil { - // Log error but don't fail the test - t.Logf("Failed to restore working directory: %v", err) - } - }() + // Debug: Log the template path (no working directory changes needed with embedded templates) + t.Logf("Using template path: %s", config.Template) // Create and run generator generator := NewGenerator(config) - err = generator.GenerateFromFile(actionPath) + err := generator.GenerateFromFile(actionPath) if err != nil { result.Error = err @@ -352,24 +333,8 @@ func createGeneratorConfigFromTestConfig(testConfig *testutil.TestConfig, output config.Quiet = testConfig.Quiet } - // Set appropriate template path based on theme and output format - config.Template = resolveTemplatePathForTest(config.Theme, config.OutputFormat) + // Set appropriate template path based on theme - embedded templates will handle resolution + config.Template = resolveThemeTemplate(config.Theme) return config } - -// resolveTemplatePathForTest resolves the correct template path for testing. -func resolveTemplatePathForTest(theme, _ string) string { - switch theme { - case "github": - return "templates/themes/github/readme.tmpl" - case "gitlab": - return "templates/themes/gitlab/readme.tmpl" - case "minimal": - return "templates/themes/minimal/readme.tmpl" - case "professional": - return "templates/themes/professional/readme.tmpl" - default: - return "templates/readme.tmpl" - } -} diff --git a/internal/generator_test.go b/internal/generator_test.go index 911f65e..6ded318 100644 --- a/internal/generator_test.go +++ b/internal/generator_test.go @@ -10,6 +10,7 @@ import ( ) func TestGenerator_NewGenerator(t *testing.T) { + t.Parallel() config := &AppConfig{ Theme: "default", OutputFormat: "md", @@ -34,6 +35,7 @@ func TestGenerator_NewGenerator(t *testing.T) { } func TestGenerator_DiscoverActionFiles(t *testing.T) { + t.Parallel() tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) @@ -44,6 +46,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "single action.yml in root", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml") testutil.AssertNoError(t, err) testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), fixture.Content) @@ -54,6 +57,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "action.yaml variant", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml") testutil.AssertNoError(t, err) testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), fixture.Content) @@ -64,6 +68,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "both yml and yaml files", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml") testutil.AssertNoError(t, err) minimalFixture, err := testutil.LoadActionFixture("minimal-action.yml") @@ -77,6 +82,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "recursive discovery", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml") testutil.AssertNoError(t, err) compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml") @@ -92,6 +98,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "non-recursive skips subdirectories", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml") testutil.AssertNoError(t, err) compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml") @@ -107,6 +114,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { { name: "no action files", setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "README.md"), "# Test") }, recursive: false, @@ -122,6 +130,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -139,6 +148,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } @@ -160,6 +170,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { } func TestGenerator_GenerateFromFile(t *testing.T) { + t.Parallel() tests := []struct { name string actionYML string @@ -218,6 +229,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -242,6 +254,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } @@ -260,6 +273,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { readmeFiles, _ := filepath.Glob(pattern) if len(readmeFiles) == 0 { t.Errorf("no output file was created for format %s", tt.outputFormat) + return } @@ -289,11 +303,13 @@ func countREADMEFiles(t *testing.T, dir string) int { if strings.HasSuffix(path, "README.md") { count++ } + return nil }) if err != nil { t.Errorf("error walking directory: %v", err) } + return count } @@ -304,11 +320,13 @@ func logREADMELocations(t *testing.T, dir string) { if err == nil && strings.HasSuffix(path, "README.md") { t.Logf("Found README at: %s", path) } + return nil }) } func TestGenerator_ProcessBatch(t *testing.T) { + t.Parallel() tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) []string @@ -318,6 +336,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { { name: "process multiple valid files", setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() // Create separate directories for each action dir1 := filepath.Join(tmpDir, "action1") dir2 := filepath.Join(tmpDir, "action2") @@ -334,6 +353,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml")) testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/composite/basic.yml")) + return files }, expectError: false, @@ -342,6 +362,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { { name: "handle mixed valid and invalid files", setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() // Create separate directories for mixed test too dir1 := filepath.Join(tmpDir, "valid-action") dir2 := filepath.Join(tmpDir, "invalid-action") @@ -358,6 +379,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml")) testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/invalid-using.yml")) + return files }, expectError: true, // Invalid runtime configuration should cause batch to fail @@ -382,6 +404,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -401,11 +424,13 @@ func TestGenerator_ProcessBatch(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } if err != nil { t.Errorf("unexpected error: %v", err) + return } @@ -423,6 +448,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { } func TestGenerator_ValidateFiles(t *testing.T) { + t.Parallel() tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) []string @@ -431,12 +457,14 @@ func TestGenerator_ValidateFiles(t *testing.T) { { name: "all valid files", setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() files := []string{ filepath.Join(tmpDir, "action1.yml"), filepath.Join(tmpDir, "action2.yml"), } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml")) testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("minimal-action.yml")) + return files }, expectError: false, @@ -444,12 +472,14 @@ func TestGenerator_ValidateFiles(t *testing.T) { { name: "files with validation issues", setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() files := []string{ filepath.Join(tmpDir, "valid.yml"), filepath.Join(tmpDir, "invalid.yml"), } testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml")) testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/missing-description.yml")) + return files }, expectError: true, // Validation should fail for invalid runtime configuration @@ -465,6 +495,7 @@ func TestGenerator_ValidateFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -484,6 +515,7 @@ func TestGenerator_ValidateFiles(t *testing.T) { } func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { + t.Parallel() tests := []struct { name string token string @@ -503,6 +535,7 @@ func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() config := &AppConfig{ GitHubToken: tt.token, Quiet: true, @@ -513,6 +546,7 @@ func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } @@ -526,6 +560,7 @@ func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { } func TestGenerator_WithDifferentThemes(t *testing.T) { + t.Parallel() themes := []string{"default", "github", "gitlab", "minimal", "professional"} tmpDir, cleanup := testutil.TempDir(t) @@ -539,19 +574,8 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { for _, theme := range themes { t.Run("theme_"+theme, func(t *testing.T) { - // Change to tmpDir so templates can be found - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get working directory: %v", err) - } - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("failed to change directory: %v", err) - } - defer func() { - if err := os.Chdir(origDir); err != nil { - t.Errorf("failed to restore directory: %v", err) - } - }() + t.Parallel() + // Templates are now embedded, no working directory changes needed config := &AppConfig{ Theme: theme, @@ -563,6 +587,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { if err := generator.GenerateFromFile(actionPath); err != nil { t.Errorf("unexpected error: %v", err) + return } @@ -581,6 +606,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { } func TestGenerator_ErrorHandling(t *testing.T) { + t.Parallel() tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) (*Generator, string) @@ -589,6 +615,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { { name: "invalid template path", setupFunc: func(t *testing.T, tmpDir string) (*Generator, string) { + t.Helper() config := &AppConfig{ Template: "/nonexistent/template.tmpl", OutputFormat: "md", @@ -598,6 +625,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { generator := NewGenerator(config) actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + return generator, actionPath }, wantError: "template", @@ -605,6 +633,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { { name: "permission denied on output directory", setupFunc: func(t *testing.T, tmpDir string) (*Generator, string) { + t.Helper() // Set up test templates testutil.SetupTestTemplates(t, tmpDir) @@ -621,6 +650,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { generator := NewGenerator(config) actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + return generator, actionPath }, wantError: "permission denied", @@ -629,6 +659,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() diff --git a/internal/git/detector.go b/internal/git/detector.go index 899e115..8cb6d80 100644 --- a/internal/git/detector.go +++ b/internal/git/detector.go @@ -3,6 +3,7 @@ package git import ( "bufio" + "errors" "fmt" "os" "os/exec" @@ -30,6 +31,7 @@ func (r *RepoInfo) GetRepositoryName() string { if r.Organization != "" && r.Repository != "" { return fmt.Sprintf("%s/%s", r.Organization, r.Repository) } + return "" } @@ -50,7 +52,7 @@ func FindRepositoryRoot(startPath string) (string, error) { parent := filepath.Dir(absPath) if parent == absPath { // Reached root without finding .git - return "", fmt.Errorf("not a git repository") + return "", errors.New("not a git repository") } absPath = parent } @@ -129,12 +131,14 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) { // Check for [remote "origin"] section if strings.Contains(line, `[remote "origin"]`) { inOriginSection = true + continue } // Check for new section if strings.HasPrefix(line, "[") && inOriginSection { inOriginSection = false + continue } @@ -144,7 +148,7 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) { } } - return "", fmt.Errorf("no origin remote URL found in git config") + return "", errors.New("no origin remote URL found in git config") } // getDefaultBranch gets the default branch name. @@ -160,6 +164,7 @@ func getDefaultBranch(repoRoot string) string { return branch } } + return DefaultBranch // Default fallback } @@ -182,6 +187,7 @@ func branchExists(repoRoot, branch string) bool { "refs/heads/"+branch, ) // #nosec G204 -- branch name validated by git cmd.Dir = repoRoot + return cmd.Run() == nil } @@ -225,5 +231,6 @@ func (r *RepoInfo) GenerateUsesStatement(actionName, version string) string { if actionName != "" { return fmt.Sprintf("your-org/%s@%s", actionName, version) } + return "your-org/your-action@v1" } diff --git a/internal/git/detector_test.go b/internal/git/detector_test.go index 8be757f..de5aed6 100644 --- a/internal/git/detector_test.go +++ b/internal/git/detector_test.go @@ -9,6 +9,8 @@ import ( ) func TestFindRepositoryRoot(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -18,6 +20,7 @@ func TestFindRepositoryRoot(t *testing.T) { { name: "git repository with .git directory", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -40,6 +43,7 @@ func TestFindRepositoryRoot(t *testing.T) { { name: "git repository with .git file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create .git file (for git worktrees) gitFile := filepath.Join(tmpDir, ".git") testutil.WriteTestFile(t, gitFile, "gitdir: /path/to/git/dir") @@ -52,12 +56,14 @@ func TestFindRepositoryRoot(t *testing.T) { { name: "no git repository", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create subdirectory without .git subDir := filepath.Join(tmpDir, "subdir") err := os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions if err != nil { t.Fatalf("failed to create subdirectory: %v", err) } + return subDir }, expectError: true, @@ -65,6 +71,8 @@ func TestFindRepositoryRoot(t *testing.T) { { name: "nonexistent directory", setupFunc: func(_ *testing.T, tmpDir string) string { + t.Helper() + return filepath.Join(tmpDir, "nonexistent") }, expectError: true, @@ -73,6 +81,8 @@ func TestFindRepositoryRoot(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -82,6 +92,7 @@ func TestFindRepositoryRoot(t *testing.T) { if tt.expectError { testutil.AssertError(t, err) + return } @@ -107,6 +118,8 @@ func TestFindRepositoryRoot(t *testing.T) { } func TestDetectGitRepository(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -115,6 +128,7 @@ func TestDetectGitRepository(t *testing.T) { { name: "GitHub repository", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -141,6 +155,7 @@ func TestDetectGitRepository(t *testing.T) { return tmpDir }, checkFunc: func(t *testing.T, info *RepoInfo) { + t.Helper() testutil.AssertEqual(t, "owner", info.Organization) testutil.AssertEqual(t, "repo", info.Repository) testutil.AssertEqual(t, "https://github.com/owner/repo.git", info.RemoteURL) @@ -149,6 +164,7 @@ func TestDetectGitRepository(t *testing.T) { { name: "SSH remote URL", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions if err != nil { @@ -165,6 +181,7 @@ func TestDetectGitRepository(t *testing.T) { return tmpDir }, checkFunc: func(t *testing.T, info *RepoInfo) { + t.Helper() testutil.AssertEqual(t, "owner", info.Organization) testutil.AssertEqual(t, "repo", info.Repository) testutil.AssertEqual(t, "git@github.com:owner/repo.git", info.RemoteURL) @@ -176,6 +193,7 @@ func TestDetectGitRepository(t *testing.T) { return tmpDir }, checkFunc: func(t *testing.T, info *RepoInfo) { + t.Helper() testutil.AssertEqual(t, false, info.IsGitRepo) testutil.AssertEqual(t, "", info.Organization) testutil.AssertEqual(t, "", info.Repository) @@ -184,6 +202,7 @@ func TestDetectGitRepository(t *testing.T) { { name: "git repository without origin remote", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions if err != nil { @@ -201,6 +220,7 @@ func TestDetectGitRepository(t *testing.T) { return tmpDir }, checkFunc: func(t *testing.T, info *RepoInfo) { + t.Helper() testutil.AssertEqual(t, true, info.IsGitRepo) testutil.AssertEqual(t, "", info.Organization) testutil.AssertEqual(t, "", info.Repository) @@ -210,6 +230,8 @@ func TestDetectGitRepository(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -226,6 +248,8 @@ func TestDetectGitRepository(t *testing.T) { } func TestParseGitHubURL(t *testing.T) { + t.Parallel() + tests := []struct { name string remoteURL string @@ -266,6 +290,8 @@ func TestParseGitHubURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + org, repo := parseGitHubURL(tt.remoteURL) testutil.AssertEqual(t, tt.expectedOrg, org) @@ -275,6 +301,8 @@ func TestParseGitHubURL(t *testing.T) { } func TestRepoInfo_GetRepositoryName(t *testing.T) { + t.Parallel() + tests := []struct { name string repoInfo RepoInfo @@ -311,6 +339,8 @@ func TestRepoInfo_GetRepositoryName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tt.repoInfo.GetRepositoryName() testutil.AssertEqual(t, tt.expected, result) }) diff --git a/internal/helpers/analyzer.go b/internal/helpers/analyzer.go index d44310a..7833160 100644 --- a/internal/helpers/analyzer.go +++ b/internal/helpers/analyzer.go @@ -12,8 +12,10 @@ func CreateAnalyzer(generator *internal.Generator, output *internal.ColoredOutpu analyzer, err := generator.CreateDependencyAnalyzer() if err != nil { output.Warning("Could not create dependency analyzer: %v", err) + return nil } + return analyzer } @@ -24,5 +26,6 @@ func CreateAnalyzerOrExit(generator *internal.Generator, output *internal.Colore // Error already logged, just exit return nil } + return analyzer } diff --git a/internal/helpers/analyzer_test.go b/internal/helpers/analyzer_test.go index e930f63..71426f6 100644 --- a/internal/helpers/analyzer_test.go +++ b/internal/helpers/analyzer_test.go @@ -8,6 +8,8 @@ import ( ) func TestCreateAnalyzer(t *testing.T) { + t.Parallel() + tests := []struct { name string setupConfig func() *internal.AppConfig @@ -48,6 +50,8 @@ func TestCreateAnalyzer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + config := tt.setupConfig() generator := internal.NewGenerator(config) @@ -74,6 +78,8 @@ func TestCreateAnalyzer(t *testing.T) { } func TestCreateAnalyzerOrExit(t *testing.T) { + t.Parallel() + // Only test success case since failure case calls os.Exit t.Run("successful analyzer creation", func(t *testing.T) { config := &internal.AppConfig{ @@ -103,6 +109,8 @@ func TestCreateAnalyzerOrExit(t *testing.T) { } func TestCreateAnalyzer_Integration(t *testing.T) { + t.Parallel() + // Test integration with actual generator functionality tmpDir, cleanup := testutil.TempDir(t) defer cleanup() diff --git a/internal/helpers/common.go b/internal/helpers/common.go index f635633..62cbc83 100644 --- a/internal/helpers/common.go +++ b/internal/helpers/common.go @@ -15,6 +15,7 @@ func GetCurrentDir() (string, error) { if err != nil { return "", fmt.Errorf("error getting current directory: %w", err) } + return currentDir, nil } @@ -31,12 +32,14 @@ func SetupGeneratorContext(config *internal.AppConfig) (*internal.Generator, str if err != nil { return nil, "", err } + return generator, currentDir, nil } // FindGitRepoRoot finds git repository root with standardized error handling. func FindGitRepoRoot(currentDir string) string { repoRoot, _ := git.FindRepositoryRoot(currentDir) + return repoRoot } diff --git a/internal/helpers/common_test.go b/internal/helpers/common_test.go index f8c0003..4ac2e10 100644 --- a/internal/helpers/common_test.go +++ b/internal/helpers/common_test.go @@ -11,6 +11,8 @@ import ( ) func TestGetCurrentDir(t *testing.T) { + t.Parallel() + t.Run("successfully get current directory", func(t *testing.T) { currentDir, err := GetCurrentDir() @@ -33,6 +35,8 @@ func TestGetCurrentDir(t *testing.T) { } func TestSetupGeneratorContext(t *testing.T) { + t.Parallel() + tests := []struct { name string config *internal.AppConfig @@ -71,6 +75,8 @@ func TestSetupGeneratorContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + generator, currentDir, err := SetupGeneratorContext(tt.config) // Verify no error occurred @@ -79,6 +85,7 @@ func TestSetupGeneratorContext(t *testing.T) { // Verify generator was created if generator == nil { t.Error("expected generator to be created") + return } @@ -100,6 +107,8 @@ func TestSetupGeneratorContext(t *testing.T) { } func TestFindGitRepoRoot(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -108,6 +117,7 @@ func TestFindGitRepoRoot(t *testing.T) { { name: "directory with git repository", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -133,6 +143,7 @@ func TestFindGitRepoRoot(t *testing.T) { { name: "nested directory in git repository", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory at root gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -151,6 +162,8 @@ func TestFindGitRepoRoot(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -172,7 +185,11 @@ func TestFindGitRepoRoot(t *testing.T) { } func TestGetGitRepoRootAndInfo(t *testing.T) { + t.Parallel() + t.Run("valid git repository with complete info", func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -187,6 +204,8 @@ func TestGetGitRepoRootAndInfo(t *testing.T) { }) t.Run("git repository but info detection fails", func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -201,6 +220,8 @@ func TestGetGitRepoRootAndInfo(t *testing.T) { }) t.Run("directory without git repository", func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -220,6 +241,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) { // Helper functions to reduce complexity. func setupCompleteGitRepo(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -245,6 +267,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string { } func setupMinimalGitRepo(t *testing.T, tmpDir string) string { + t.Helper() // Create .git directory but with minimal content gitDir := filepath.Join(tmpDir, ".git") err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions @@ -254,6 +277,7 @@ func setupMinimalGitRepo(t *testing.T, tmpDir string) string { } func verifyRepoRoot(t *testing.T, repoRoot, tmpDir string) { + t.Helper() if repoRoot != "" && !strings.Contains(repoRoot, tmpDir) { t.Errorf("expected repo root to be within %s, got %s", tmpDir, repoRoot) } @@ -261,7 +285,11 @@ func verifyRepoRoot(t *testing.T, repoRoot, tmpDir string) { // Test error handling in GetGitRepoRootAndInfo. func TestGetGitRepoRootAndInfo_ErrorHandling(t *testing.T) { + t.Parallel() + t.Run("nonexistent directory", func(t *testing.T) { + t.Parallel() + nonexistentPath := "/this/path/should/not/exist" repoRoot, gitInfo, err := GetGitRepoRootAndInfo(nonexistentPath) diff --git a/internal/html.go b/internal/html.go index 5f5def9..bcf99dd 100644 --- a/internal/html.go +++ b/internal/html.go @@ -31,5 +31,6 @@ func (w *HTMLWriter) Write(output string, path string) error { return err } } + return nil } diff --git a/internal/interfaces_test.go b/internal/interfaces_test.go index 23d84f5..2d4e22b 100644 --- a/internal/interfaces_test.go +++ b/internal/interfaces_test.go @@ -101,6 +101,7 @@ type MockProgressManager struct { func (m *MockProgressManager) CreateProgressBar(description string, total int) *progressbar.ProgressBar { m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, formatMessage("%s (total: %d)", description, total)) + return nil // Return nil for mock to avoid actual progress bar } @@ -109,6 +110,7 @@ func (m *MockProgressManager) CreateProgressBarForFiles(description string, file m.CreateProgressBarForFilesCalls, formatMessage("%s (files: %d)", description, len(files)), ) + return nil // Return nil for mock to avoid actual progress bar } @@ -151,6 +153,7 @@ func formatMessage(format string, args ...any) string { result = strings.Replace(result, "%d", toString(arg), 1) result = strings.Replace(result, "%v", toString(arg), 1) } + return result } @@ -183,11 +186,13 @@ func formatInt(i int) string { if negative { result = "-" + result } + return result } // Test that demonstrates improved testability with focused interfaces. func TestFocusedInterfaces_SimpleLogger(t *testing.T) { + t.Parallel() mockLogger := &MockMessageLogger{} simpleLogger := NewSimpleLogger(mockLogger) @@ -216,6 +221,7 @@ func TestFocusedInterfaces_SimpleLogger(t *testing.T) { } func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) { + t.Parallel() mockLogger := &MockMessageLogger{} simpleLogger := NewSimpleLogger(mockLogger) @@ -235,6 +241,7 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) { } func TestFocusedInterfaces_ErrorManager(t *testing.T) { + t.Parallel() mockReporter := &MockErrorReporter{} mockFormatter := &MockErrorFormatter{} mockManager := &mockErrorManager{ @@ -257,6 +264,7 @@ func TestFocusedInterfaces_ErrorManager(t *testing.T) { } func TestFocusedInterfaces_TaskProgress(t *testing.T) { + t.Parallel() mockReporter := &MockProgressReporter{} taskProgress := NewTaskProgress(mockReporter) @@ -274,6 +282,7 @@ func TestFocusedInterfaces_TaskProgress(t *testing.T) { } func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) { + t.Parallel() tests := []struct { name string quietMode bool @@ -293,6 +302,7 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() mockConfig := &MockOutputConfig{QuietMode: tt.quietMode} component := NewConfigAwareComponent(mockConfig) @@ -306,6 +316,7 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) { } func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) { + t.Parallel() // Create a composite mock that implements OutputWriter mockLogger := &MockMessageLogger{} mockProgress := &MockProgressReporter{} @@ -338,6 +349,7 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) { } func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) { + t.Parallel() // Create focused mocks mockOutput := &mockCompleteOutput{ logger: &MockMessageLogger{}, @@ -436,8 +448,10 @@ func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) if err != nil { formatted := err.Error() m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted) + return formatted } + return "" } diff --git a/internal/internal_defaults_test.go b/internal/internal_defaults_test.go index 62f60c7..cba1151 100644 --- a/internal/internal_defaults_test.go +++ b/internal/internal_defaults_test.go @@ -3,6 +3,7 @@ package internal import "testing" func TestFillMissing(t *testing.T) { + t.Parallel() a := &ActionYML{} defs := DefaultValues{ diff --git a/internal/internal_parser_test.go b/internal/internal_parser_test.go index 5546e7b..90b87c9 100644 --- a/internal/internal_parser_test.go +++ b/internal/internal_parser_test.go @@ -7,6 +7,7 @@ import ( ) func TestParseActionYML_Valid(t *testing.T) { + t.Parallel() // Create temporary action file using fixture actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml") action, err := ParseActionYML(actionPath) @@ -25,6 +26,7 @@ func TestParseActionYML_Valid(t *testing.T) { } func TestParseActionYML_MissingFile(t *testing.T) { + t.Parallel() _, err := ParseActionYML("notfound/action.yml") if err == nil { t.Error("expected error on missing file") diff --git a/internal/internal_template_test.go b/internal/internal_template_test.go index a825450..c035baa 100644 --- a/internal/internal_template_test.go +++ b/internal/internal_template_test.go @@ -8,6 +8,7 @@ import ( ) func TestRenderReadme(t *testing.T) { + t.Parallel() // Set up test templates tmpDir, cleanup := testutil.TempDir(t) defer cleanup() diff --git a/internal/internal_validator_test.go b/internal/internal_validator_test.go index b956535..06aeeaf 100644 --- a/internal/internal_validator_test.go +++ b/internal/internal_validator_test.go @@ -3,6 +3,7 @@ package internal import "testing" func TestValidateActionYML_Required(t *testing.T) { + t.Parallel() a := &ActionYML{ Name: "", @@ -16,6 +17,7 @@ func TestValidateActionYML_Required(t *testing.T) { } func TestValidateActionYML_Valid(t *testing.T) { + t.Parallel() a := &ActionYML{ Name: "MyAction", Description: "desc", diff --git a/internal/output.go b/internal/output.go index 43c46d1..d45ea85 100644 --- a/internal/output.go +++ b/internal/output.go @@ -199,6 +199,7 @@ func (co *ColoredOutput) formatMainError(err *errors.ContextualError) string { if co.NoColor { return "❌ " + mainMsg } + return color.RedString("❌ ") + mainMsg } @@ -237,7 +238,7 @@ func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string for _, suggestion := range suggestions { if co.NoColor { - parts = append(parts, fmt.Sprintf(" • %s", suggestion)) + parts = append(parts, " • "+suggestion) } else { parts = append(parts, fmt.Sprintf(" %s %s", color.YellowString("•"), @@ -251,8 +252,9 @@ func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string // formatHelpURLSection formats the help URL section. func (co *ColoredOutput) formatHelpURLSection(helpURL string) string { if co.NoColor { - return fmt.Sprintf("\nFor more help: %s", helpURL) + return "\nFor more help: " + helpURL } + return fmt.Sprintf("\n%s: %s", color.New(color.Bold).Sprint("For more help"), color.BlueString(helpURL)) diff --git a/internal/parser.go b/internal/parser.go index 3d2e0c4..be80bb4 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -52,6 +52,7 @@ func ParseActionYML(path string) (*ActionYML, error) { if err := dec.Decode(&a); err != nil { return nil, err } + return &a, nil } diff --git a/internal/progress_test.go b/internal/progress_test.go index 7a2d9fd..445a145 100644 --- a/internal/progress_test.go +++ b/internal/progress_test.go @@ -7,6 +7,7 @@ import ( ) func TestProgressBarManager_CreateProgressBar(t *testing.T) { + t.Parallel() tests := []struct { name string quiet bool @@ -46,6 +47,7 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(tt.quiet) bar := pm.CreateProgressBar(tt.description, tt.total) @@ -63,6 +65,7 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) { } func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(false) files := []string{"file1.yml", "file2.yml", "file3.yml"} @@ -73,7 +76,8 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) { } } -func TestProgressBarManager_FinishProgressBar(_ *testing.T) { +func TestProgressBarManager_FinishProgressBar(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(false) // Test with nil bar (should not panic) @@ -86,7 +90,8 @@ func TestProgressBarManager_FinishProgressBar(_ *testing.T) { } } -func TestProgressBarManager_UpdateProgressBar(_ *testing.T) { +func TestProgressBarManager_UpdateProgressBar(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(false) // Test with nil bar (should not panic) @@ -100,6 +105,7 @@ func TestProgressBarManager_UpdateProgressBar(_ *testing.T) { } func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(false) items := []string{"item1", "item2", "item3"} @@ -122,6 +128,7 @@ func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { } func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) { + t.Parallel() pm := NewProgressBarManager(true) // quiet mode items := []string{"item1", "item2"} diff --git a/internal/template.go b/internal/template.go index 32d8235..0d4ed39 100644 --- a/internal/template.go +++ b/internal/template.go @@ -3,7 +3,6 @@ package internal import ( "bytes" "fmt" - "os" "strings" "text/template" @@ -13,6 +12,7 @@ import ( "github.com/ivuorinen/gh-action-readme/internal/dependencies" "github.com/ivuorinen/gh-action-readme/internal/git" "github.com/ivuorinen/gh-action-readme/internal/validation" + "github.com/ivuorinen/gh-action-readme/templates_embed" ) const ( @@ -70,6 +70,7 @@ func getGitOrg(data any) string { return td.Config.Organization } } + return defaultOrgPlaceholder } @@ -83,6 +84,7 @@ func getGitRepo(data any) string { return td.Config.Repository } } + return defaultRepoPlaceholder } @@ -101,6 +103,7 @@ func getGitUsesString(data any) string { } version := formatVersion(getActionVersion(data)) + return buildUsesString(td, org, repo, version) } @@ -118,6 +121,7 @@ func formatVersion(version string) string { if !strings.HasPrefix(version, "@") { return "@" + version } + return version } @@ -129,6 +133,7 @@ func buildUsesString(td *TemplateData, org, repo, version string) string { return fmt.Sprintf("%s/%s/%s%s", org, repo, actionName, version) } } + return fmt.Sprintf("%s/%s%s", org, repo, version) } @@ -139,6 +144,7 @@ func getActionVersion(data any) string { return td.Config.Version } } + return "v1" } @@ -217,7 +223,7 @@ func analyzeDependencies(actionPath string, config *AppConfig, gitInfo git.RepoI // RenderReadme renders a README using a Go template and the parsed action.yml data. func RenderReadme(action any, opts TemplateOptions) (string, error) { - tmplContent, err := os.ReadFile(opts.TemplatePath) + tmplContent, err := templates_embed.ReadTemplate(opts.TemplatePath) if err != nil { return "", err } @@ -229,11 +235,11 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) { } var head, foot string if opts.HeaderPath != "" { - h, _ := os.ReadFile(opts.HeaderPath) + h, _ := templates_embed.ReadTemplate(opts.HeaderPath) head = string(h) } if opts.FooterPath != "" { - f, _ := os.ReadFile(opts.FooterPath) + f, _ := templates_embed.ReadTemplate(opts.FooterPath) foot = string(f) } // Wrap template output in header/footer @@ -243,6 +249,7 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) { return "", err } buf.WriteString(foot) + return buf.String(), nil } @@ -254,5 +261,6 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) { if err := tmpl.Execute(buf, action); err != nil { return "", err } + return buf.String(), nil } diff --git a/internal/validation/path.go b/internal/validation/path.go index 3ae95fe..ec4a914 100644 --- a/internal/validation/path.go +++ b/internal/validation/path.go @@ -13,6 +13,7 @@ func GetBinaryDir() (string, error) { if err != nil { return "", fmt.Errorf("failed to get executable path: %w", err) } + return filepath.Dir(executable), nil } @@ -21,5 +22,6 @@ func EnsureAbsolutePath(path string) (string, error) { if filepath.IsAbs(path) { return path, nil } + return filepath.Abs(path) } diff --git a/internal/validation/strings.go b/internal/validation/strings.go index dfaa9c1..7517c31 100644 --- a/internal/validation/strings.go +++ b/internal/validation/strings.go @@ -8,6 +8,7 @@ import ( // CleanVersionString removes common prefixes and normalizes version strings. func CleanVersionString(version string) string { cleaned := strings.TrimSpace(version) + return strings.TrimPrefix(cleaned, "v") } @@ -40,6 +41,7 @@ func SanitizeActionName(name string) string { func TrimAndNormalize(input string) string { // Remove leading/trailing whitespace and normalize internal whitespace re := regexp.MustCompile(`\s+`) + return re.ReplaceAllString(strings.TrimSpace(input), " ") } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 67a49b6..802d487 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -13,6 +13,7 @@ import ( func IsCommitSHA(version string) bool { // Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA) re := regexp.MustCompile(`^[a-f0-9]{7,40}$`) + return len(version) >= 7 && re.MatchString(version) } @@ -20,6 +21,7 @@ func IsCommitSHA(version string) bool { func IsSemanticVersion(version string) bool { // Check for vX.Y.Z format (requires major.minor.patch) re := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`) + return re.MatchString(version) } @@ -39,6 +41,7 @@ func ValidateGitBranch(repoRoot, branch string) bool { "refs/heads/"+branch, ) // #nosec G204 -- branch name validated by git cmd.Dir = repoRoot + return cmd.Run() == nil } @@ -61,5 +64,6 @@ func ValidateActionYMLPath(path string) error { // IsGitRepository checks if the given path is within a git repository. func IsGitRepository(path string) bool { _, err := git.FindRepositoryRoot(path) + return err == nil } diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index 679588f..8d67e31 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -9,6 +9,8 @@ import ( ) func TestValidateActionYMLPath(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -18,8 +20,10 @@ func TestValidateActionYMLPath(t *testing.T) { { name: "valid action.yml file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + return actionPath }, expectError: false, @@ -27,8 +31,10 @@ func TestValidateActionYMLPath(t *testing.T) { { name: "valid action.yaml file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yaml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("minimal-action.yml")) + return actionPath }, expectError: false, @@ -43,8 +49,10 @@ func TestValidateActionYMLPath(t *testing.T) { { name: "file with wrong extension", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() actionPath := filepath.Join(tmpDir, "action.txt") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + return actionPath }, expectError: true, @@ -60,6 +68,8 @@ func TestValidateActionYMLPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -77,6 +87,8 @@ func TestValidateActionYMLPath(t *testing.T) { } func TestIsCommitSHA(t *testing.T) { + t.Parallel() + tests := []struct { name string version string @@ -116,6 +128,8 @@ func TestIsCommitSHA(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := IsCommitSHA(tt.version) testutil.AssertEqual(t, tt.expected, result) }) @@ -123,6 +137,8 @@ func TestIsCommitSHA(t *testing.T) { } func TestIsSemanticVersion(t *testing.T) { + t.Parallel() + tests := []struct { name string version string @@ -172,6 +188,8 @@ func TestIsSemanticVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := IsSemanticVersion(tt.version) testutil.AssertEqual(t, tt.expected, result) }) @@ -179,6 +197,8 @@ func TestIsSemanticVersion(t *testing.T) { } func TestIsVersionPinned(t *testing.T) { + t.Parallel() + tests := []struct { name string version string @@ -223,6 +243,8 @@ func TestIsVersionPinned(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := IsVersionPinned(tt.version) testutil.AssertEqual(t, tt.expected, result) }) @@ -230,6 +252,8 @@ func TestIsVersionPinned(t *testing.T) { } func TestValidateGitBranch(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) (string, string) @@ -252,6 +276,7 @@ func TestValidateGitBranch(t *testing.T) { merge = refs/heads/main ` testutil.WriteTestFile(t, filepath.Join(gitDir, "config"), configContent) + return tmpDir, "main" }, expected: true, // This may vary based on actual git repo state @@ -274,6 +299,8 @@ func TestValidateGitBranch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -288,6 +315,8 @@ func TestValidateGitBranch(t *testing.T) { } func TestIsGitRepository(t *testing.T) { + t.Parallel() + tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) string @@ -298,6 +327,7 @@ func TestIsGitRepository(t *testing.T) { setupFunc: func(_ *testing.T, tmpDir string) string { gitDir := filepath.Join(tmpDir, ".git") _ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions + return tmpDir }, expected: true, @@ -305,8 +335,10 @@ func TestIsGitRepository(t *testing.T) { { name: "directory with .git file", setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() gitFile := filepath.Join(tmpDir, ".git") testutil.WriteTestFile(t, gitFile, "gitdir: /path/to/git/dir") + return tmpDir }, expected: true, @@ -329,6 +361,8 @@ func TestIsGitRepository(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -340,6 +374,8 @@ func TestIsGitRepository(t *testing.T) { } func TestCleanVersionString(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -374,6 +410,8 @@ func TestCleanVersionString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := CleanVersionString(tt.input) testutil.AssertEqual(t, tt.expected, result) }) @@ -381,6 +419,8 @@ func TestCleanVersionString(t *testing.T) { } func TestParseGitHubURL(t *testing.T) { + t.Parallel() + tests := []struct { name string url string @@ -421,6 +461,8 @@ func TestParseGitHubURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + org, repo := ParseGitHubURL(tt.url) testutil.AssertEqual(t, tt.expectedOrg, org) testutil.AssertEqual(t, tt.expectedRepo, repo) @@ -429,6 +471,8 @@ func TestParseGitHubURL(t *testing.T) { } func TestSanitizeActionName(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -457,7 +501,9 @@ func TestSanitizeActionName(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(_ *testing.T) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := SanitizeActionName(tt.input) // The exact behavior may vary, so we'll just verify it doesn't panic _ = result @@ -466,6 +512,8 @@ func TestSanitizeActionName(t *testing.T) { } func TestGetBinaryDir(t *testing.T) { + t.Parallel() + dir, err := GetBinaryDir() testutil.AssertNoError(t, err) @@ -480,6 +528,8 @@ func TestGetBinaryDir(t *testing.T) { } func TestEnsureAbsolutePath(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -509,6 +559,8 @@ func TestEnsureAbsolutePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := EnsureAbsolutePath(tt.input) if tt.input == "" { diff --git a/internal/validator.go b/internal/validator.go index 76102c5..bed0fe3 100644 --- a/internal/validator.go +++ b/internal/validator.go @@ -89,5 +89,6 @@ func isValidRuntime(runtime string) bool { return true } } + return false } diff --git a/internal/wizard/detector.go b/internal/wizard/detector.go index 7191948..b4c1a74 100644 --- a/internal/wizard/detector.go +++ b/internal/wizard/detector.go @@ -3,6 +3,7 @@ package wizard import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -88,7 +89,7 @@ func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) { // detectRepositoryInfo detects repository information from git. func (d *ProjectDetector) detectRepositoryInfo(settings *DetectedSettings) error { if d.repoRoot == "" { - return fmt.Errorf("not in a git repository") + return errors.New("not in a git repository") } repoInfo, err := git.DetectRepository(d.repoRoot) @@ -103,6 +104,7 @@ func (d *ProjectDetector) detectRepositoryInfo(settings *DetectedSettings) error settings.Version = d.detectVersion() d.output.Success("Detected repository: %s/%s", settings.Organization, settings.Repository) + return nil } @@ -221,6 +223,7 @@ func (d *ProjectDetector) findActionFiles(dir string, recursive bool) ([]string, if recursive { return d.findActionFilesRecursive(dir) } + return d.findActionFilesInDirectory(dir) } @@ -253,6 +256,7 @@ func (d *ProjectDetector) handleDirectory(info os.FileInfo) error { if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" { return filepath.SkipDir } + return nil } @@ -366,6 +370,7 @@ func (d *ProjectDetector) analyzeProjectFiles() map[string]string { } d.setDefaultProjectType(characteristics) + return characteristics } @@ -425,6 +430,7 @@ func (d *ProjectDetector) setDefaultProjectType(characteristics map[string]strin // getCurrentActionFiles gets action files in current directory only. func (d *ProjectDetector) getCurrentActionFiles() []string { actionFiles, _ := d.findActionFiles(d.currentDir, false) + return actionFiles } diff --git a/internal/wizard/detector_test.go b/internal/wizard/detector_test.go index 6ba9c66..d308e20 100644 --- a/internal/wizard/detector_test.go +++ b/internal/wizard/detector_test.go @@ -9,6 +9,7 @@ import ( ) func TestProjectDetector_analyzeProjectFiles(t *testing.T) { + t.Parallel() // Create temporary directory for testing tempDir := t.TempDir() @@ -50,6 +51,7 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) { for _, validType := range validTypes { if projectType == validType { typeValid = true + break } } @@ -63,6 +65,7 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) { } func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) { + t.Parallel() tempDir := t.TempDir() // Create package.json with version @@ -90,6 +93,7 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) { } func TestProjectDetector_detectVersionFromFiles(t *testing.T) { + t.Parallel() tempDir := t.TempDir() // Create VERSION file @@ -112,6 +116,7 @@ func TestProjectDetector_detectVersionFromFiles(t *testing.T) { } func TestProjectDetector_findActionFiles(t *testing.T) { + t.Parallel() tempDir := t.TempDir() // Create action files @@ -167,6 +172,7 @@ func TestProjectDetector_findActionFiles(t *testing.T) { } func TestProjectDetector_isActionFile(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) detector := &ProjectDetector{ output: output, @@ -186,6 +192,7 @@ func TestProjectDetector_isActionFile(t *testing.T) { for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { + t.Parallel() result := detector.isActionFile(tt.filename) if result != tt.expected { t.Errorf("isActionFile(%s) = %v, want %v", tt.filename, result, tt.expected) @@ -195,6 +202,7 @@ func TestProjectDetector_isActionFile(t *testing.T) { } func TestProjectDetector_suggestConfiguration(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) detector := &ProjectDetector{ output: output, @@ -242,6 +250,7 @@ func TestProjectDetector_suggestConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() detector.suggestConfiguration(tt.settings) if tt.settings.SuggestedTheme != tt.expected { t.Errorf("Expected theme %s, got %s", tt.expected, tt.settings.SuggestedTheme) diff --git a/internal/wizard/exporter.go b/internal/wizard/exporter.go index 3f9a096..744f40b 100644 --- a/internal/wizard/exporter.go +++ b/internal/wizard/exporter.go @@ -80,6 +80,7 @@ func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath strin } e.output.Success("Configuration exported to: %s", outputPath) + return nil } @@ -104,6 +105,7 @@ func (e *ConfigExporter) exportJSON(config *internal.AppConfig, outputPath strin } e.output.Success("Configuration exported to: %s", outputPath) + return nil } @@ -129,6 +131,7 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin e.writeTOMLConfig(file, exportConfig) e.output.Success("Configuration exported to: %s", outputPath) + return nil } diff --git a/internal/wizard/exporter_test.go b/internal/wizard/exporter_test.go index 364acef..9725603 100644 --- a/internal/wizard/exporter_test.go +++ b/internal/wizard/exporter_test.go @@ -13,6 +13,7 @@ import ( ) func TestConfigExporter_ExportConfig(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) // quiet mode for testing exporter := NewConfigExporter(output) @@ -20,13 +21,22 @@ func TestConfigExporter_ExportConfig(t *testing.T) { config := createTestConfig() // Test YAML export - t.Run("export YAML", testYAMLExport(exporter, config)) + t.Run("export YAML", func(t *testing.T) { + t.Parallel() + testYAMLExport(exporter, config)(t) + }) // Test JSON export - t.Run("export JSON", testJSONExport(exporter, config)) + t.Run("export JSON", func(t *testing.T) { + t.Parallel() + testJSONExport(exporter, config)(t) + }) // Test TOML export - t.Run("export TOML", testTOMLExport(exporter, config)) + t.Run("export TOML", func(t *testing.T) { + t.Parallel() + testTOMLExport(exporter, config)(t) + }) } // createTestConfig creates a test configuration for testing. @@ -49,6 +59,7 @@ func createTestConfig() *internal.AppConfig { // testYAMLExport tests YAML export functionality. func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*testing.T) { return func(t *testing.T) { + t.Helper() tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "config.yaml") @@ -65,6 +76,7 @@ func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(* // testJSONExport tests JSON export functionality. func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(*testing.T) { return func(t *testing.T) { + t.Helper() tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "config.json") @@ -81,6 +93,7 @@ func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(* // testTOMLExport tests TOML export functionality. func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*testing.T) { return func(t *testing.T) { + t.Helper() tempDir := t.TempDir() outputPath := filepath.Join(tempDir, "config.toml") @@ -96,6 +109,7 @@ func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(* // verifyFileExists checks that a file exists at the given path. func verifyFileExists(t *testing.T, outputPath string) { + t.Helper() if _, err := os.Stat(outputPath); os.IsNotExist(err) { t.Fatal("Expected output file to exist") } @@ -103,6 +117,7 @@ func verifyFileExists(t *testing.T, outputPath string) { // verifyYAMLContent verifies YAML content is valid and contains expected data. func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppConfig) { + t.Helper() data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path if err != nil { t.Fatalf("Failed to read output file: %v", err) @@ -123,6 +138,7 @@ func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppCo // verifyJSONContent verifies JSON content is valid and contains expected data. func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppConfig) { + t.Helper() data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path if err != nil { t.Fatalf("Failed to read output file: %v", err) @@ -143,6 +159,7 @@ func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppCo // verifyTOMLContent verifies TOML content contains expected fields. func verifyTOMLContent(t *testing.T, outputPath string) { + t.Helper() data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path if err != nil { t.Fatalf("Failed to read output file: %v", err) @@ -158,6 +175,7 @@ func verifyTOMLContent(t *testing.T, outputPath string) { } func TestConfigExporter_sanitizeConfig(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -191,6 +209,7 @@ func TestConfigExporter_sanitizeConfig(t *testing.T) { } func TestConfigExporter_GetSupportedFormats(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -215,6 +234,7 @@ func TestConfigExporter_GetSupportedFormats(t *testing.T) { } func TestConfigExporter_GetDefaultOutputPath(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -229,6 +249,7 @@ func TestConfigExporter_GetDefaultOutputPath(t *testing.T) { for _, tt := range tests { t.Run(string(tt.format), func(t *testing.T) { + t.Parallel() path, err := exporter.GetDefaultOutputPath(tt.format) if err != nil { t.Fatalf("GetDefaultOutputPath() error = %v", err) diff --git a/internal/wizard/validator.go b/internal/wizard/validator.go index 410b90c..34910ab 100644 --- a/internal/wizard/validator.go +++ b/internal/wizard/validator.go @@ -105,6 +105,7 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu } result.Valid = len(result.Errors) == 0 + return result } @@ -116,6 +117,7 @@ func (v *ConfigValidator) validateOrganization(org string, result *ValidationRes Message: "Organization is empty - will use auto-detected value", Value: org, }) + return } @@ -139,6 +141,7 @@ func (v *ConfigValidator) validateRepository(repo string, result *ValidationResu Message: "Repository is empty - will use auto-detected value", Value: repo, }) + return } @@ -181,6 +184,7 @@ func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) for _, validTheme := range validThemes { if theme == validTheme { found = true + break } } @@ -192,7 +196,7 @@ func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) Value: theme, }) result.Suggestions = append(result.Suggestions, - fmt.Sprintf("Valid themes: %s", strings.Join(validThemes, ", "))) + "Valid themes: "+strings.Join(validThemes, ", ")) } } @@ -204,6 +208,7 @@ func (v *ConfigValidator) validateOutputFormat(format string, result *Validation for _, validFormat := range validFormats { if format == validFormat { found = true + break } } @@ -215,7 +220,7 @@ func (v *ConfigValidator) validateOutputFormat(format string, result *Validation Value: format, }) result.Suggestions = append(result.Suggestions, - fmt.Sprintf("Valid formats: %s", strings.Join(validFormats, ", "))) + "Valid formats: "+strings.Join(validFormats, ", ")) } } @@ -227,6 +232,7 @@ func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult Message: "Output directory cannot be empty", Value: dir, }) + return } @@ -314,9 +320,10 @@ func (v *ConfigValidator) validatePermissions(permissions map[string]string, res if !permissionExists { result.Warnings = append(result.Warnings, ValidationWarning{ Field: "permissions", - Message: fmt.Sprintf("Unknown permission: %s", permission), + Message: "Unknown permission: " + permission, Value: value, }) + continue } @@ -325,6 +332,7 @@ func (v *ConfigValidator) validatePermissions(permissions map[string]string, res for _, validVal := range validValues { if value == validVal { validValue = true + break } } @@ -332,7 +340,7 @@ func (v *ConfigValidator) validatePermissions(permissions map[string]string, res if !validValue { result.Errors = append(result.Errors, ValidationError{ Field: "permissions", - Message: fmt.Sprintf("Invalid value for permission %s", permission), + Message: "Invalid value for permission " + permission, Value: value, }) result.Suggestions = append(result.Suggestions, @@ -351,6 +359,7 @@ func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResu }) result.Suggestions = append(result.Suggestions, "Consider specifying at least one runner (e.g., ubuntu-latest)") + return } @@ -366,6 +375,7 @@ func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResu for _, validRunner := range validRunners { if runner == validRunner { isValid = true + break } } @@ -375,7 +385,7 @@ func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResu if !strings.HasPrefix(runner, "self-hosted") { result.Warnings = append(result.Warnings, ValidationWarning{ Field: "runs_on", - Message: fmt.Sprintf("Unknown runner: %s", runner), + Message: "Unknown runner: " + runner, Value: runner, }) result.Suggestions = append(result.Suggestions, @@ -398,9 +408,10 @@ func (v *ConfigValidator) validateVariables(variables map[string]string, result if strings.EqualFold(key, reserved) { result.Warnings = append(result.Warnings, ValidationWarning{ Field: "variables", - Message: fmt.Sprintf("Variable name conflicts with GitHub environment variable: %s", key), + Message: "Variable name conflicts with GitHub environment variable: " + key, Value: value, }) + break } } @@ -409,7 +420,7 @@ func (v *ConfigValidator) validateVariables(variables map[string]string, result if !v.isValidVariableName(key) { result.Errors = append(result.Errors, ValidationError{ Field: "variables", - Message: fmt.Sprintf("Invalid variable name: %s", key), + Message: "Invalid variable name: " + key, Value: value, }) result.Suggestions = append(result.Suggestions, @@ -427,6 +438,7 @@ func (v *ConfigValidator) isValidGitHubName(name string) bool { // GitHub names can contain alphanumeric characters and hyphens // Cannot start or end with hyphen matched, _ := regexp.MatchString(`^[a-zA-Z0-9]([a-zA-Z0-9\-_]*[a-zA-Z0-9])?$`, name) + return matched } @@ -437,6 +449,7 @@ func (v *ConfigValidator) isValidSemanticVersion(version string) bool { `(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` + `(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$` matched, _ := regexp.MatchString(pattern, version) + return matched } @@ -462,6 +475,7 @@ func (v *ConfigValidator) isValidVariableName(name string) bool { // Variable names should start with letter or underscore // and contain only letters, numbers, and underscores matched, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*$`, name) + return matched } diff --git a/internal/wizard/validator_test.go b/internal/wizard/validator_test.go index 5507d1d..d4461ef 100644 --- a/internal/wizard/validator_test.go +++ b/internal/wizard/validator_test.go @@ -7,6 +7,7 @@ import ( ) func TestConfigValidator_ValidateConfig(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) // quiet mode for testing validator := NewConfigValidator(output) @@ -74,6 +75,7 @@ func TestConfigValidator_ValidateConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := validator.ValidateConfig(tt.config) if result.Valid != tt.expectValid { @@ -92,6 +94,7 @@ func TestConfigValidator_ValidateConfig(t *testing.T) { } func TestConfigValidator_ValidateField(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) validator := NewConfigValidator(output) @@ -115,6 +118,7 @@ func TestConfigValidator_ValidateField(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() result := validator.ValidateField(tt.fieldName, tt.value) if result.Valid != tt.expectValid { @@ -125,6 +129,7 @@ func TestConfigValidator_ValidateField(t *testing.T) { } func TestConfigValidator_isValidGitHubName(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) validator := NewConfigValidator(output) @@ -146,6 +151,7 @@ func TestConfigValidator_isValidGitHubName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := validator.isValidGitHubName(tt.input) if got != tt.want { t.Errorf("isValidGitHubName(%q) = %v, want %v", tt.input, got, tt.want) @@ -155,6 +161,7 @@ func TestConfigValidator_isValidGitHubName(t *testing.T) { } func TestConfigValidator_isValidSemanticVersion(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) validator := NewConfigValidator(output) @@ -175,6 +182,7 @@ func TestConfigValidator_isValidSemanticVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := validator.isValidSemanticVersion(tt.input) if got != tt.want { t.Errorf("isValidSemanticVersion(%q) = %v, want %v", tt.input, got, tt.want) @@ -184,6 +192,7 @@ func TestConfigValidator_isValidSemanticVersion(t *testing.T) { } func TestConfigValidator_isValidGitHubToken(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) validator := NewConfigValidator(output) @@ -204,6 +213,7 @@ func TestConfigValidator_isValidGitHubToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := validator.isValidGitHubToken(tt.input) if got != tt.want { t.Errorf("isValidGitHubToken(%q) = %v, want %v", tt.input, got, tt.want) @@ -213,6 +223,7 @@ func TestConfigValidator_isValidGitHubToken(t *testing.T) { } func TestConfigValidator_isValidVariableName(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(true) validator := NewConfigValidator(output) @@ -234,6 +245,7 @@ func TestConfigValidator_isValidVariableName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := validator.isValidVariableName(tt.input) if got != tt.want { t.Errorf("isValidVariableName(%q) = %v, want %v", tt.input, got, tt.want) diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index 9f3b1e9..9cece76 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -3,6 +3,7 @@ package wizard import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -60,6 +61,7 @@ func (w *ConfigWizard) Run() (*internal.AppConfig, error) { } w.output.Success("\n✅ Configuration completed successfully!") + return w.config, nil } @@ -218,6 +220,7 @@ func (w *ConfigWizard) configureGitHubIntegration() { existingToken := internal.GetGitHubToken(w.config) if existingToken != "" { w.output.Success("GitHub token already configured ✓") + return } @@ -231,6 +234,7 @@ func (w *ConfigWizard) configureGitHubIntegration() { if !setupToken { w.output.Info("You can set up the token later using environment variables:") w.output.Printf(" export GITHUB_TOKEN=your_personal_access_token") + return } @@ -284,8 +288,9 @@ func (w *ConfigWizard) confirmConfiguration() error { w.output.Info("") confirmed := w.promptYesNo("Save this configuration?", true) if !confirmed { - return fmt.Errorf("configuration canceled by user") + return errors.New("configuration canceled by user") } + return nil } @@ -302,6 +307,7 @@ func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string { if input == "" { return defaultValue } + return input } @@ -314,6 +320,7 @@ func (w *ConfigWizard) promptSensitive(prompt string) string { if w.scanner.Scan() { return strings.TrimSpace(w.scanner.Text()) } + return "" } @@ -337,6 +344,7 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool { return defaultValue default: w.output.Warning("Please answer 'y' or 'n'. Using default.") + return defaultValue } } diff --git a/main.go b/main.go index 4e07d19..343fbbb 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strconv" "strings" "github.com/schollz/progressbar/v3" @@ -86,6 +87,7 @@ func createErrorHandler(output *internal.ColoredOutput) *internal.ErrorHandler { func setupOutputAndErrorHandling() (*internal.ColoredOutput, *internal.ErrorHandler) { output := createOutputManager(globalConfig.Quiet) errorHandler := createErrorHandler(output) + return output, errorHandler } @@ -364,7 +366,7 @@ func validateHandler(_ *cobra.Command, _ []string) { errors.ErrCodeValidation, "validation failed", map[string]string{ - "files_count": fmt.Sprintf("%d", len(actionFiles)), + "files_count": strconv.Itoa(len(actionFiles)), internal.ContextKeyError: err.Error(), }, ) @@ -391,6 +393,7 @@ func newConfigCmd() *cobra.Command { path, err := internal.GetConfigPath() if err != nil { output.Error("Error getting config path: %v", err) + return } output.Info("Configuration file location: %s", path) @@ -445,6 +448,7 @@ func configInitHandler(_ *cobra.Command, _ []string) { if _, err := os.Stat(configPath); err == nil { output.Warning("Configuration file already exists at: %s", configPath) output.Info("Use 'gh-action-readme config show' to view current configuration") + return } @@ -593,6 +597,7 @@ func depsListHandler(_ *cobra.Command, _ []string) { if err != nil { // For deps list, we can continue if no files found (show warning instead of error) output.Warning("No action files found") + return } @@ -630,17 +635,20 @@ func analyzeDependencies(output *internal.ColoredOutput, actionFiles []string, a func analyzeActionFileDeps(output *internal.ColoredOutput, actionFile string, analyzer *dependencies.Analyzer) int { if analyzer == nil { output.Printf(" • Cannot analyze (no GitHub token)\n") + return 0 } deps, err := analyzer.AnalyzeActionFile(actionFile) if err != nil { output.Warning(" ⚠️ Error analyzing: %v", err) + return 0 } if len(deps) == 0 { output.Printf(" • No dependencies (not a composite action)\n") + return 0 } @@ -651,6 +659,7 @@ func analyzeActionFileDeps(output *internal.ColoredOutput, actionFile string, an output.Warning(" 📌 %s @ %s - %s", dep.Name, dep.Version, dep.Description) } } + return len(deps) } @@ -765,6 +774,7 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { if err != nil { // For deps outdated, we can continue if no files found (show warning instead of error) output.Warning("No action files found") + return } @@ -789,8 +799,10 @@ func validateGitHubToken(output *internal.ColoredOutput) bool { WithHelpURL(errors.GetHelpURL(errors.ErrCodeGitHubAuth)) output.Warning("⚠️ %s", contextualErr.Error()) + return false } + return true } @@ -807,17 +819,20 @@ func checkAllOutdated( deps, err := analyzer.AnalyzeActionFile(actionFile) if err != nil { output.Warning("Error analyzing %s: %v", actionFile, err) + continue } outdated, err := analyzer.CheckOutdated(deps) if err != nil { output.Warning("Error checking outdated for %s: %v", actionFile, err) + continue } allOutdated = append(allOutdated, outdated...) } + return allOutdated } @@ -825,6 +840,7 @@ func checkAllOutdated( func displayOutdatedResults(output *internal.ColoredOutput, allOutdated []dependencies.OutdatedDependency) { if len(allOutdated) == 0 { output.Success("✅ All dependencies are up to date!") + return } @@ -869,6 +885,7 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) { allUpdates := collectAllUpdates(output, analyzer, actionFiles) if len(allUpdates) == 0 { output.Success("✅ No updates needed - all dependencies are current and pinned!") + return } @@ -892,17 +909,20 @@ func setupDepsUpgrade(output *internal.ColoredOutput, currentDir string) (*depen if len(actionFiles) == 0 { output.Warning("No action files found") + return nil, nil } analyzer, err := generator.CreateDependencyAnalyzer() if err != nil { output.Warning("Could not create dependency analyzer: %v", err) + return nil, nil } if globalConfig.GitHubToken == "" { output.Warning("No GitHub token found. Set GITHUB_TOKEN environment variable") + return nil, nil } @@ -933,12 +953,14 @@ func collectAllUpdates( deps, err := analyzer.AnalyzeActionFile(actionFile) if err != nil { output.Warning("Error analyzing %s: %v", actionFile, err) + continue } outdated, err := analyzer.CheckOutdated(deps) if err != nil { output.Warning("Error checking outdated for %s: %v", actionFile, err) + continue } @@ -951,6 +973,7 @@ func collectAllUpdates( ) if err != nil { output.Warning("Error generating update for %s: %v", outdatedDep.Current.Name, err) + continue } allUpdates = append(allUpdates, *update) @@ -996,6 +1019,7 @@ func applyUpdates( _, _ = fmt.Scanln(&response) // User input, scan error not critical if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { output.Info("Canceled") + return } diff --git a/main_test.go b/main_test.go index 9f3a204..e4ee243 100644 --- a/main_test.go +++ b/main_test.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "fmt" "os" "os/exec" "path/filepath" @@ -16,9 +15,9 @@ import ( // TestCLICommands tests the main CLI commands using subprocess execution. func TestCLICommands(t *testing.T) { + t.Parallel() // Build the binary for testing binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -51,6 +50,7 @@ func TestCLICommands(t *testing.T) { name: "gen command with valid action", args: []string{"gen", "--output-format", "md"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -60,6 +60,7 @@ func TestCLICommands(t *testing.T) { name: "gen command with theme flag", args: []string{"gen", "--theme", "github", "--output-format", "json"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -75,6 +76,7 @@ func TestCLICommands(t *testing.T) { name: "validate command with valid action", args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -85,6 +87,7 @@ func TestCLICommands(t *testing.T) { name: "validate command with invalid action", args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile( t, @@ -128,6 +131,7 @@ func TestCLICommands(t *testing.T) { name: "deps list command with composite action", args: []string{"deps", "list"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() actionPath := filepath.Join(tmpDir, "action.yml") testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml")) }, @@ -209,8 +213,8 @@ func TestCLICommands(t *testing.T) { // TestCLIFlags tests various flag combinations. func TestCLIFlags(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -286,8 +290,8 @@ func TestCLIFlags(t *testing.T) { // TestCLIRecursiveFlag tests the recursive flag functionality. func TestCLIRecursiveFlag(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -357,8 +361,8 @@ func TestCLIRecursiveFlag(t *testing.T) { // TestCLIErrorHandling tests error scenarios. func TestCLIErrorHandling(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tests := []struct { name string @@ -371,6 +375,7 @@ func TestCLIErrorHandling(t *testing.T) { name: "permission denied on output directory", args: []string{"gen", "--output-dir", "/root/restricted"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -381,6 +386,7 @@ func TestCLIErrorHandling(t *testing.T) { name: "invalid YAML in action file", args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), "invalid: yaml: content: [") }, wantExit: 1, @@ -389,6 +395,7 @@ func TestCLIErrorHandling(t *testing.T) { name: "unknown output format", args: []string{"gen", "--output-format", "unknown"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -398,6 +405,7 @@ func TestCLIErrorHandling(t *testing.T) { name: "unknown theme", args: []string{"gen", "--theme", "nonexistent-theme"}, setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.MustReadFixture("actions/javascript/simple.yml")) }, @@ -447,8 +455,8 @@ func TestCLIErrorHandling(t *testing.T) { // TestCLIConfigInitialization tests configuration initialization. func TestCLIConfigInitialization(t *testing.T) { + t.Parallel() binaryPath := buildTestBinary(t) - defer func() { _ = os.Remove(binaryPath) }() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -458,7 +466,7 @@ func TestCLIConfigInitialization(t *testing.T) { cmd.Dir = tmpDir // Set XDG_CONFIG_HOME to temp directory - cmd.Env = append(os.Environ(), fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpDir)) + cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+tmpDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -496,6 +504,7 @@ func TestCLIConfigInitialization(t *testing.T) { // These test the actual functions directly rather than through subprocess execution. func TestCreateOutputManager(t *testing.T) { + t.Parallel() tests := []struct { name string quiet bool @@ -515,6 +524,7 @@ func TestCreateOutputManager(t *testing.T) { } func TestFormatSize(t *testing.T) { + t.Parallel() tests := []struct { name string size int64 @@ -541,6 +551,7 @@ func TestFormatSize(t *testing.T) { } func TestResolveExportFormat(t *testing.T) { + t.Parallel() tests := []struct { name string format string @@ -564,6 +575,7 @@ func TestResolveExportFormat(t *testing.T) { } func TestCreateErrorHandler(t *testing.T) { + t.Parallel() output := internal.NewColoredOutput(false) handler := createErrorHandler(output) @@ -573,6 +585,7 @@ func TestCreateErrorHandler(t *testing.T) { } func TestSetupOutputAndErrorHandling(t *testing.T) { + // Note: This test cannot use t.Parallel() because it modifies globalConfig // Setup globalConfig for the test originalConfig := globalConfig defer func() { globalConfig = originalConfig }() @@ -592,6 +605,7 @@ func TestSetupOutputAndErrorHandling(t *testing.T) { // Unit Tests for Command Creation Functions func TestNewGenCmd(t *testing.T) { + t.Parallel() cmd := newGenCmd() if cmd.Use != "gen [directory_or_file]" { @@ -616,6 +630,7 @@ func TestNewGenCmd(t *testing.T) { } func TestNewValidateCmd(t *testing.T) { + t.Parallel() cmd := newValidateCmd() if cmd.Use != "validate" { @@ -632,6 +647,7 @@ func TestNewValidateCmd(t *testing.T) { } func TestNewSchemaCmd(t *testing.T) { + t.Parallel() cmd := newSchemaCmd() if cmd.Use != "schema" { diff --git a/templates_embed/embed.go b/templates_embed/embed.go new file mode 100644 index 0000000..35387b3 --- /dev/null +++ b/templates_embed/embed.go @@ -0,0 +1,77 @@ +// Package templates_embed provides embedded template filesystem functionality for gh-action-readme. +// This package contains all template files embedded in the binary using Go's embed directive, +// making templates available regardless of working directory or filesystem location. +// +//nolint:revive // Package name with underscore is intentional for clarity +package templates_embed + +import ( + "embed" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// embeddedTemplates contains all template files embedded in the binary +// +//go:embed templates +var embeddedTemplates embed.FS + +// GetEmbeddedTemplate reads a template from the embedded filesystem. +func GetEmbeddedTemplate(templatePath string) ([]byte, error) { + // Normalize path separators and remove leading slash if present + cleanPath := strings.TrimPrefix(filepath.ToSlash(templatePath), "/") + + // If path doesn't start with templates/, prepend it + if !strings.HasPrefix(cleanPath, "templates/") { + cleanPath = "templates/" + cleanPath + } + + return embeddedTemplates.ReadFile(cleanPath) +} + +// GetEmbeddedTemplateFS returns the embedded filesystem for templates. +func GetEmbeddedTemplateFS() fs.FS { + return embeddedTemplates +} + +// IsEmbeddedTemplateAvailable checks if a template exists in the embedded filesystem. +func IsEmbeddedTemplateAvailable(templatePath string) bool { + cleanPath := strings.TrimPrefix(filepath.ToSlash(templatePath), "/") + if !strings.HasPrefix(cleanPath, "templates/") { + cleanPath = "templates/" + cleanPath + } + + _, err := embeddedTemplates.ReadFile(cleanPath) + + return err == nil +} + +// ReadTemplate reads a template from embedded filesystem first, then falls back to filesystem. +func ReadTemplate(templatePath string) ([]byte, error) { + // If it's an absolute path, read from filesystem with path validation + if filepath.IsAbs(templatePath) { + // Validate the path is clean to prevent path traversal attacks + cleanPath := filepath.Clean(templatePath) + if cleanPath != templatePath { + return nil, filepath.ErrBadPattern + } + + return os.ReadFile(cleanPath) // #nosec G304 -- validated absolute path + } + + // Try embedded template first + if IsEmbeddedTemplateAvailable(templatePath) { + return GetEmbeddedTemplate(templatePath) + } + + // Fallback to filesystem with path validation + // Validate the path is clean to prevent path traversal attacks + cleanPath := filepath.Clean(templatePath) + if cleanPath != templatePath || strings.Contains(cleanPath, "..") { + return nil, filepath.ErrBadPattern + } + + return os.ReadFile(cleanPath) // #nosec G304 -- validated relative path +} diff --git a/templates_embed/templates/footer.tmpl b/templates_embed/templates/footer.tmpl new file mode 100644 index 0000000..e9123e0 --- /dev/null +++ b/templates_embed/templates/footer.tmpl @@ -0,0 +1,5 @@ + + + diff --git a/templates_embed/templates/header.tmpl b/templates_embed/templates/header.tmpl new file mode 100644 index 0000000..bcd7d24 --- /dev/null +++ b/templates_embed/templates/header.tmpl @@ -0,0 +1,15 @@ + + + + + {{.Name}} GitHub Action Documentation + + + + diff --git a/templates_embed/templates/readme.tmpl b/templates_embed/templates/readme.tmpl new file mode 100644 index 0000000..c0537ef --- /dev/null +++ b/templates_embed/templates/readme.tmpl @@ -0,0 +1,37 @@ +# {{.Name}} + +{{if .Branding}} +> {{.Description}} + +## Usage + +```yaml +- uses: {{gitUsesString .}} + with: +{{- range $key, $val := .Inputs}} + {{$key}}: # {{$val.Description}}{{if $val.Default}} (default: {{$val.Default}}){{end}} +{{- end}} +``` + +## Inputs + +{{range $key, $input := .Inputs}} +- **{{$key}}**: {{$input.Description}}{{if $input.Required}} (**required**){{end}}{{if $input.Default}} (default: {{$input.Default}}){{end}} +{{end}} + +{{if .Outputs}} +## Outputs + +{{range $key, $output := .Outputs}} +- **{{$key}}**: {{$output.Description}} +{{end}} +{{end}} + +## Example + +See the [action.yml](./action.yml) for a full reference. + +--- + +*Auto-generated by [gh-action-readme](https://github.com/ivuorinen/gh-action-readme)* +{{end}} diff --git a/templates_embed/templates/themes/asciidoc/readme.adoc b/templates_embed/templates/themes/asciidoc/readme.adoc new file mode 100644 index 0000000..ceef5fc --- /dev/null +++ b/templates_embed/templates/themes/asciidoc/readme.adoc @@ -0,0 +1,176 @@ += {{.Name}} +:toc: left +:toclevels: 3 +:icons: font +:source-highlighter: highlight.js + +{{if .Branding}}image:https://img.shields.io/badge/icon-{{.Branding.Icon}}-{{.Branding.Color}}[{{.Branding.Icon}}] {{end}}+ +image:https://img.shields.io/badge/GitHub%20Action-{{.Name | replace " " "%20"}}-blue[GitHub Action] + +image:https://img.shields.io/badge/license-MIT-green[License] + +[.lead] +{{.Description}} + +== Quick Start + +Add this action to your GitHub workflow: + +[source,yaml] +---- +name: CI Workflow +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"value"{{end}} + {{- end}}{{end}} +---- + +{{if .Inputs}} +== Input Parameters + +[cols="1,3,1,2", options="header"] +|=== +| Parameter | Description | Required | Default + +{{range $key, $input := .Inputs}} +| `{{$key}}` +| {{$input.Description}} +| {{if $input.Required}}✓{{else}}✗{{end}} +| {{if $input.Default}}`{{$input.Default}}`{{else}}_none_{{end}} + +{{end}} +|=== + +=== Parameter Details + +{{range $key, $input := .Inputs}} +==== {{$key}} + +{{$input.Description}} + +[horizontal] +Type:: String +Required:: {{if $input.Required}}Yes{{else}}No{{end}} +{{if $input.Default}}Default:: `{{$input.Default}}`{{end}} + +.Example +[source,yaml] +---- +with: + {{$key}}: {{if $input.Default}}"{{$input.Default}}"{{else}}"your-value"{{end}} +---- + +{{end}} +{{end}} + +{{if .Outputs}} +== Output Parameters + +[cols="1,3", options="header"] +|=== +| Parameter | Description + +{{range $key, $output := .Outputs}} +| `{{$key}}` +| {{$output.Description}} + +{{end}} +|=== + +=== Using Outputs + +[source,yaml] +---- +- name: {{.Name}} + id: action-step + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + +- name: Use Output + run: | + {{- range $key, $output := .Outputs}} + echo "{{$key}}: \${{"{{"}} steps.action-step.outputs.{{$key}} {{"}}"}}" + {{- end}} +---- +{{end}} + +== Examples + +=== Basic Usage + +[source,yaml] +---- +- name: Basic {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"example-value"{{end}} + {{- end}}{{end}} +---- + +=== Advanced Configuration + +[source,yaml] +---- +- name: Advanced {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"\${{"{{"}} vars.{{$key | upper}} {{"}}"}}"{{end}} + {{- end}}{{end}} + env: + GITHUB_TOKEN: \${{"{{"}} secrets.GITHUB_TOKEN {{"}}"}} +---- + +=== Conditional Usage + +[source,yaml] +---- +- name: Conditional {{.Name}} + if: github.event_name == 'push' + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"production-value"{{end}} + {{- end}}{{end}} +---- + +== Troubleshooting + +[TIP] +==== +Common issues and solutions: + +1. **Authentication Errors**: Ensure required secrets are configured +2. **Permission Issues**: Verify GitHub token permissions +3. **Configuration Errors**: Validate input parameters +==== + +== Development + +For development information, see the link:./action.yml[action.yml] specification. + +=== Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +== License + +This project is licensed under the MIT License. + +--- + +_Documentation generated with https://github.com/ivuorinen/gh-action-readme[gh-action-readme]_ diff --git a/templates_embed/templates/themes/github/readme.tmpl b/templates_embed/templates/themes/github/readme.tmpl new file mode 100644 index 0000000..7b5f03d --- /dev/null +++ b/templates_embed/templates/themes/github/readme.tmpl @@ -0,0 +1,141 @@ +# {{.Name}} + +{{if .Branding}}![{{.Branding.Icon}}](https://img.shields.io/badge/icon-{{.Branding.Icon}}-{{.Branding.Color}}) {{end}} +![GitHub](https://img.shields.io/badge/GitHub%20Action-{{.Name | replace " " "%20"}}-blue) +![License](https://img.shields.io/badge/license-MIT-green) + +> {{.Description}} + +## 🚀 Quick Start + +```yaml +name: My Workflow +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: {{.Name}} + uses: {{gitUsesString .}} + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"value"{{end}} + {{- end}}{{end}} +``` + +{{if .Inputs}} +## 📥 Inputs + +| Parameter | Description | Required | Default | +|-----------|-------------|----------|---------| +{{- range $key, $input := .Inputs}} +| `{{$key}}` | {{$input.Description}} | {{if $input.Required}}✅{{else}}❌{{end}} | {{if $input.Default}}`{{$input.Default}}`{{else}}-{{end}} | +{{- end}} +{{end}} + +{{if .Outputs}} +## 📤 Outputs + +| Parameter | Description | +|-----------|-------------| +{{- range $key, $output := .Outputs}} +| `{{$key}}` | {{$output.Description}} | +{{- end}} +{{end}} + +## 💡 Examples + +
+Basic Usage + +```yaml +- name: {{.Name}} + uses: {{gitUsesString .}} + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"example-value"{{end}} + {{- end}}{{end}} +``` +
+ +
+Advanced Configuration + +```yaml +- name: {{.Name}} with custom settings + uses: {{gitUsesString .}} + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"custom-value"{{end}} + {{- end}}{{end}} +``` +
+ +{{if .Dependencies}} +## 📦 Dependencies + +This action uses the following dependencies: + +| Action | Version | Author | Description | +|--------|---------|--------|-------------| +{{- range .Dependencies}} +| {{if .MarketplaceURL}}[{{.Name}}]({{.MarketplaceURL}}){{else}}{{.Name}}{{end}} | {{if .IsPinned}}🔒{{end}}{{.Version}} | [{{.Author}}](https://github.com/{{.Author}}) | {{.Description}} | +{{- end}} + +
+📋 Dependency Details + +{{range .Dependencies}} +### {{.Name}}{{if .Version}} @ {{.Version}}{{end}} + +{{if .IsPinned}} +- 🔒 **Pinned Version**: Locked to specific version for security +{{else}} +- 📌 **Floating Version**: Using latest version (consider pinning for security) +{{end}} +- 👤 **Author**: [{{.Author}}](https://github.com/{{.Author}}) +{{if .MarketplaceURL}}- 🏪 **Marketplace**: [View on GitHub Marketplace]({{.MarketplaceURL}}){{end}} +{{if .SourceURL}}- 📂 **Source**: [View Source]({{.SourceURL}}){{end}} +{{if .WithParams}} +- **Configuration**: + ```yaml + with: + {{- range $key, $value := .WithParams}} + {{$key}}: {{$value}} + {{- end}} + ``` +{{end}} + +{{end}} + +{{$hasLocalDeps := false}} +{{range .Dependencies}}{{if .IsLocalAction}}{{$hasLocalDeps = true}}{{end}}{{end}} +{{if $hasLocalDeps}} +### Same Repository Dependencies +{{range .Dependencies}}{{if .IsLocalAction}} +- [{{.Name}}]({{.SourceURL}}) - {{.Description}} +{{end}}{{end}} +{{end}} + +
+{{end}} + +## 🔧 Development + +See the [action.yml](./action.yml) for the complete action specification. + +## 📄 License + +This action is distributed under the MIT License. See [LICENSE](LICENSE) for more information. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +--- + +
+ 🚀 Generated with gh-action-readme +
diff --git a/templates_embed/templates/themes/gitlab/readme.tmpl b/templates_embed/templates/themes/gitlab/readme.tmpl new file mode 100644 index 0000000..38dfc5e --- /dev/null +++ b/templates_embed/templates/themes/gitlab/readme.tmpl @@ -0,0 +1,94 @@ +# {{.Name}} + +{{if .Branding}}**{{.Branding.Icon}}** {{end}}**{{.Description}}** + +--- + +## Installation + +Add this action to your GitLab CI/CD pipeline or GitHub workflow: + +### GitHub Actions + +```yaml +steps: + - name: {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}{{$val.Default}}{{else}}value{{end}} + {{- end}}{{end}} +``` + +### GitLab CI/CD + +```yaml +{{.Name | lower | replace " " "-"}}: + stage: build + image: node:20 + script: + - # Your action logic here + {{if .Inputs}}variables: + {{- range $key, $val := .Inputs}} + {{$key | upper}}: {{if $val.Default}}{{$val.Default}}{{else}}value{{end}} + {{- end}}{{end}} +``` + +## Configuration + +{{if .Inputs}} +### Input Parameters + +{{range $key, $input := .Inputs}} +#### `{{$key}}` +- **Description**: {{$input.Description}} +- **Type**: String{{if $input.Required}} +- **Required**: Yes{{else}} +- **Required**: No{{end}}{{if $input.Default}} +- **Default**: `{{$input.Default}}`{{end}} + +{{end}} +{{end}} + +{{if .Outputs}} +### Output Parameters + +{{range $key, $output := .Outputs}} +#### `{{$key}}` +- **Description**: {{$output.Description}} + +{{end}} +{{end}} + +## Usage Examples + +### Basic Example + +```yaml +{{.Name | lower | replace " " "-"}}: + stage: deploy + script: + - echo "Using {{.Name}}" + {{if .Inputs}}variables: + {{- range $key, $val := .Inputs}} + {{$key | upper}}: "{{if $val.Default}}{{$val.Default}}{{else}}example{{end}}" + {{- end}}{{end}} +``` + +### Advanced Example + +For more complex scenarios, refer to the [action.yml](./action.yml) specification. + +## Documentation + +- [Action specification](./action.yml) +- [Usage examples](./examples/) +- [Contributing guidelines](./CONTRIBUTING.md) + +## License + +This project is licensed under the MIT License. + +--- + +*Generated with [gh-action-readme](https://github.com/ivuorinen/gh-action-readme)* diff --git a/templates_embed/templates/themes/minimal/readme.tmpl b/templates_embed/templates/themes/minimal/readme.tmpl new file mode 100644 index 0000000..c792078 --- /dev/null +++ b/templates_embed/templates/themes/minimal/readme.tmpl @@ -0,0 +1,33 @@ +# {{.Name}} + +{{.Description}} + +## Usage + +```yaml +- uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}{{$val.Default}}{{else}}value{{end}} + {{- end}}{{end}} +``` + +{{if .Inputs}} +## Inputs + +{{range $key, $input := .Inputs}} +- `{{$key}}` - {{$input.Description}}{{if $input.Required}} (required){{end}}{{if $input.Default}} (default: `{{$input.Default}}`){{end}} +{{end}} +{{end}} + +{{if .Outputs}} +## Outputs + +{{range $key, $output := .Outputs}} +- `{{$key}}` - {{$output.Description}} +{{end}} +{{end}} + +## License + +MIT diff --git a/templates_embed/templates/themes/professional/readme.tmpl b/templates_embed/templates/themes/professional/readme.tmpl new file mode 100644 index 0000000..4ffe8fc --- /dev/null +++ b/templates_embed/templates/themes/professional/readme.tmpl @@ -0,0 +1,245 @@ +# {{.Name}} + +{{if .Branding}} +
+ {{.Branding.Icon}} + Status + License +
+{{end}} + +## Overview + +{{.Description}} + +This GitHub Action provides a robust solution for your CI/CD pipeline with comprehensive configuration options and detailed output information. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Configuration](#configuration) +{{if .Inputs}}- [Input Parameters](#input-parameters){{end}} +{{if .Outputs}}- [Output Parameters](#output-parameters){{end}} +- [Examples](#examples) +{{if .Dependencies}}- [Dependencies](#-dependencies){{end}} +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) + +## Quick Start + +Add the following step to your GitHub Actions workflow: + +```yaml +name: CI/CD Pipeline +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"your-value-here"{{end}} + {{- end}}{{end}} +``` + +## Configuration + +This action supports various configuration options to customize its behavior according to your needs. + +{{if .Inputs}} +### Input Parameters + +| Parameter | Description | Type | Required | Default Value | +|-----------|-------------|------|----------|---------------| +{{- range $key, $input := .Inputs}} +| **`{{$key}}`** | {{$input.Description}} | `string` | {{if $input.Required}}✅ Yes{{else}}❌ No{{end}} | {{if $input.Default}}`{{$input.Default}}`{{else}}_None_{{end}} | +{{- end}} + +#### Parameter Details + +{{range $key, $input := .Inputs}} +##### `{{$key}}` + +{{$input.Description}} + +- **Type**: String +- **Required**: {{if $input.Required}}Yes{{else}}No{{end}}{{if $input.Default}} +- **Default**: `{{$input.Default}}`{{end}} + +```yaml +with: + {{$key}}: {{if $input.Default}}"{{$input.Default}}"{{else}}"your-value-here"{{end}} +``` + +{{end}} +{{end}} + +{{if .Outputs}} +### Output Parameters + +This action provides the following outputs that can be used in subsequent workflow steps: + +| Parameter | Description | Usage | +|-----------|-------------|-------| +{{- range $key, $output := .Outputs}} +| **`{{$key}}`** | {{$output.Description}} | `\${{"{{"}} steps.{{$.Name | lower | replace " " "-"}}.outputs.{{$key}} {{"}}"}}` | +{{- end}} + +#### Using Outputs + +```yaml +- name: {{.Name}} + id: action-step + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + +- name: Use Output + run: | + {{- range $key, $output := .Outputs}} + echo "{{$key}}: \${{"{{"}} steps.action-step.outputs.{{$key}} {{"}}"}}" + {{- end}} +``` +{{end}} + +## Examples + +### Basic Usage + +```yaml +- name: Basic {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"example-value"{{end}} + {{- end}}{{end}} +``` + +### Advanced Configuration + +```yaml +- name: Advanced {{.Name}} + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"\${{"{{"}} vars.{{$key | upper}} {{"}}"}}"{{end}} + {{- end}}{{end}} + env: + GITHUB_TOKEN: \${{"{{"}} secrets.GITHUB_TOKEN {{"}}"}} +``` + +### Conditional Usage + +```yaml +- name: Conditional {{.Name}} + if: github.event_name == 'push' + uses: your-org/{{.Name | lower | replace " " "-"}}@v1 + {{if .Inputs}}with: + {{- range $key, $val := .Inputs}} + {{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"production-value"{{end}} + {{- end}}{{end}} +``` + +{{if .Dependencies}} +## 📦 Dependencies + +This action uses the following dependencies: + +| Action | Version | Author | Description | +|--------|---------|--------|-------------| +{{- range .Dependencies}} +| {{if .MarketplaceURL}}[{{.Name}}]({{.MarketplaceURL}}){{else}}{{.Name}}{{end}} | {{if .IsPinned}}🔒{{end}}{{.Version}} | [{{.Author}}](https://github.com/{{.Author}}) | {{.Description}} | +{{- end}} + +
+📋 Dependency Details + +{{range .Dependencies}} +### {{.Name}}{{if .Version}} @ {{.Version}}{{end}} + +{{if .IsPinned}} +- 🔒 **Pinned Version**: Locked to specific version for security +{{else}} +- 📌 **Floating Version**: Using latest version (consider pinning for security) +{{end}} +- 👤 **Author**: [{{.Author}}](https://github.com/{{.Author}}) +{{if .MarketplaceURL}}- 🏪 **Marketplace**: [View on GitHub Marketplace]({{.MarketplaceURL}}){{end}} +{{if .SourceURL}}- 📂 **Source**: [View Source]({{.SourceURL}}){{end}} +{{if .WithParams}} +- **Configuration**: + ```yaml + with: + {{- range $key, $value := .WithParams}} + {{$key}}: {{$value}} + {{- end}} + ``` +{{end}} + +{{end}} + +{{$hasLocalDeps := false}} +{{range .Dependencies}}{{if .IsLocalAction}}{{$hasLocalDeps = true}}{{end}}{{end}} +{{if $hasLocalDeps}} +### Same Repository Dependencies +{{range .Dependencies}}{{if .IsLocalAction}} +- [{{.Name}}]({{.SourceURL}}) - {{.Description}} +{{end}}{{end}} +{{end}} + +
+{{end}} + +## Troubleshooting + +### Common Issues + +1. **Authentication Errors**: Ensure you have set up the required secrets in your repository settings. +2. **Permission Issues**: Check that your GitHub token has the necessary permissions. +3. **Configuration Errors**: Validate your input parameters against the schema. + +### Getting Help + +- Check the [action.yml](./action.yml) for the complete specification +- Review the [examples](./examples/) directory for more use cases +- Open an issue if you encounter problems + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +### Development Setup + +1. Fork this repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Support + +If you find this action helpful, please consider: + +- ⭐ Starring this repository +- 🐛 Reporting issues +- 💡 Suggesting improvements +- 🤝 Contributing code + +--- + +
+ 📚 Documentation generated with gh-action-readme +
diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 415e69f..028c284 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -12,13 +12,40 @@ import ( "gopkg.in/yaml.v3" ) +// fixtureCache provides thread-safe caching of fixture content. +var fixtureCache = struct { + mu sync.RWMutex + cache map[string]string +}{ + cache: make(map[string]string), +} + // MustReadFixture reads a YAML fixture file from testdata/yaml-fixtures. func MustReadFixture(filename string) string { return mustReadFixture(filename) } -// mustReadFixture reads a YAML fixture file from testdata/yaml-fixtures. +// mustReadFixture reads a YAML fixture file from testdata/yaml-fixtures with caching. func mustReadFixture(filename string) string { + // Try to get from cache first (read lock) + fixtureCache.mu.RLock() + if content, exists := fixtureCache.cache[filename]; exists { + fixtureCache.mu.RUnlock() + + return content + } + fixtureCache.mu.RUnlock() + + // Not in cache, acquire write lock and read from disk + fixtureCache.mu.Lock() + defer fixtureCache.mu.Unlock() + + // Double-check in case another goroutine loaded it while we were waiting + if content, exists := fixtureCache.cache[filename]; exists { + return content + } + + // Load from disk _, currentFile, _, ok := runtime.Caller(0) if !ok { panic("failed to get current file path") @@ -28,12 +55,17 @@ func mustReadFixture(filename string) string { projectRoot := filepath.Dir(filepath.Dir(currentFile)) fixturePath := filepath.Join(projectRoot, "testdata", "yaml-fixtures", filename) - content, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure + contentBytes, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure if err != nil { panic("failed to read fixture " + filename + ": " + err.Error()) } - return string(content) + content := string(contentBytes) + + // Store in cache + fixtureCache.cache[filename] = content + + return content } // Constants for fixture management. @@ -316,6 +348,7 @@ var PackageJSONContent = func() string { result += " \"webpack\": \"^5.0.0\"\n" result += " }\n" result += "}\n" + return result }() @@ -373,6 +406,7 @@ func (fm *FixtureManager) LoadActionFixture(name string) (*ActionFixture, error) fm.mu.RLock() if fixture, exists := fm.cache[name]; exists { fm.mu.RUnlock() + return fixture, nil } fm.mu.RUnlock() @@ -403,6 +437,7 @@ func (fm *FixtureManager) LoadActionFixture(name string) (*ActionFixture, error) // Double-check cache in case another goroutine cached it while we were loading if cachedFixture, exists := fm.cache[name]; exists { fm.mu.Unlock() + return cachedFixture, nil } fm.cache[name] = fixture @@ -505,6 +540,7 @@ func (fm *FixtureManager) ensureYamlExtension(path string) string { if !strings.HasSuffix(path, YmlExtension) && !strings.HasSuffix(path, YamlExtension) { path += YmlExtension } + return path } @@ -524,6 +560,7 @@ func (fm *FixtureManager) searchInDirectories(name string) string { return path } } + return "" } @@ -535,6 +572,7 @@ func (fm *FixtureManager) buildSearchPath(dir, name string) string { } else { path = filepath.Join(fm.basePath, dir, name) } + return fm.ensureYamlExtension(path) } @@ -566,6 +604,7 @@ func (fm *FixtureManager) determineActionTypeByName(name string) ActionType { if strings.Contains(name, "minimal") { return ActionTypeMinimal } + return ActionTypeMinimal } @@ -580,6 +619,7 @@ func (fm *FixtureManager) determineActionTypeByContent(content string) ActionTyp if strings.Contains(content, `using: 'node`) { return ActionTypeJavaScript } + return ActionTypeMinimal } @@ -594,6 +634,7 @@ func (fm *FixtureManager) determineConfigType(name string) string { if strings.Contains(name, "user") { return "user-specific" } + return "generic" } @@ -658,12 +699,14 @@ func isValidRuntime(runtime string) bool { return true } } + return false } // validateConfigContent validates configuration fixture content. func (fm *FixtureManager) validateConfigContent(content string) bool { var data map[string]any + return yaml.Unmarshal([]byte(content), &data) == nil } @@ -762,6 +805,7 @@ func GetFixtureManager() *FixtureManager { panic(fmt.Sprintf("failed to load test scenarios: %v", err)) } } + return defaultFixtureManager } diff --git a/testutil/fixtures_test.go b/testutil/fixtures_test.go index 5e6bad0..605f54d 100644 --- a/testutil/fixtures_test.go +++ b/testutil/fixtures_test.go @@ -13,6 +13,7 @@ import ( const testVersion = "v4.1.1" func TestMustReadFixture(t *testing.T) { + t.Parallel() tests := []struct { name string filename string @@ -32,6 +33,7 @@ func TestMustReadFixture(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() if tt.wantErr { defer func() { if r := recover(); r == nil { @@ -56,7 +58,9 @@ func TestMustReadFixture(t *testing.T) { } func TestMustReadFixture_Panic(t *testing.T) { + t.Parallel() t.Run("missing file panics", func(t *testing.T) { + t.Parallel() defer func() { if r := recover(); r == nil { t.Error("expected panic but got none") @@ -64,6 +68,7 @@ func TestMustReadFixture_Panic(t *testing.T) { errStr, ok := r.(string) if !ok { t.Errorf("expected panic to contain string message, got: %T", r) + return } if !strings.Contains(errStr, "failed to read fixture") { @@ -77,28 +82,36 @@ func TestMustReadFixture_Panic(t *testing.T) { } func TestGitHubAPIResponses(t *testing.T) { + t.Parallel() t.Run("GitHubReleaseResponse", func(t *testing.T) { + t.Parallel() testGitHubReleaseResponse(t) }) t.Run("GitHubTagResponse", func(t *testing.T) { + t.Parallel() testGitHubTagResponse(t) }) t.Run("GitHubRepoResponse", func(t *testing.T) { + t.Parallel() testGitHubRepoResponse(t) }) t.Run("GitHubCommitResponse", func(t *testing.T) { + t.Parallel() testGitHubCommitResponse(t) }) t.Run("GitHubRateLimitResponse", func(t *testing.T) { + t.Parallel() testGitHubRateLimitResponse(t) }) t.Run("GitHubErrorResponse", func(t *testing.T) { + t.Parallel() testGitHubErrorResponse(t) }) } // testGitHubReleaseResponse validates the GitHub release response format. func testGitHubReleaseResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubReleaseResponse) if data["id"] == nil { @@ -114,6 +127,7 @@ func testGitHubReleaseResponse(t *testing.T) { // testGitHubTagResponse validates the GitHub tag response format. func testGitHubTagResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubTagResponse) if data["name"] != testVersion { @@ -126,6 +140,7 @@ func testGitHubTagResponse(t *testing.T) { // testGitHubRepoResponse validates the GitHub repository response format. func testGitHubRepoResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubRepoResponse) if data["name"] != "checkout" { @@ -138,6 +153,7 @@ func testGitHubRepoResponse(t *testing.T) { // testGitHubCommitResponse validates the GitHub commit response format. func testGitHubCommitResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubCommitResponse) if data["sha"] == nil { @@ -150,6 +166,7 @@ func testGitHubCommitResponse(t *testing.T) { // testGitHubRateLimitResponse validates the GitHub rate limit response format. func testGitHubRateLimitResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubRateLimitResponse) if data["resources"] == nil { @@ -162,6 +179,7 @@ func testGitHubRateLimitResponse(t *testing.T) { // testGitHubErrorResponse validates the GitHub error response format. func testGitHubErrorResponse(t *testing.T) { + t.Helper() data := parseJSONResponse(t, GitHubErrorResponse) if data["message"] != "Not Found" { @@ -171,14 +189,17 @@ func testGitHubErrorResponse(t *testing.T) { // parseJSONResponse parses a JSON response string and returns the data map. func parseJSONResponse(t *testing.T, response string) map[string]any { + t.Helper() var data map[string]any if err := json.Unmarshal([]byte(response), &data); err != nil { t.Fatalf("failed to parse JSON response: %v", err) } + return data } func TestSimpleTemplate(t *testing.T) { + t.Parallel() template := SimpleTemplate // Check that template contains expected sections @@ -208,6 +229,7 @@ func TestSimpleTemplate(t *testing.T) { } func TestMockGitHubResponses(t *testing.T) { + t.Parallel() responses := MockGitHubResponses() // Test that all expected endpoints are present @@ -236,6 +258,7 @@ func TestMockGitHubResponses(t *testing.T) { // Test specific response structures t.Run("checkout releases response", func(t *testing.T) { + t.Parallel() response := responses["GET https://api.github.com/repos/actions/checkout/releases/latest"] var release map[string]any if err := json.Unmarshal([]byte(response), &release); err != nil { @@ -249,6 +272,7 @@ func TestMockGitHubResponses(t *testing.T) { } func TestFixtureConstants(t *testing.T) { + t.Parallel() // Test that all fixture variables are properly loaded fixtures := map[string]string{ "SimpleActionYML": MustReadFixture("actions/javascript/simple.yml"), @@ -263,6 +287,7 @@ func TestFixtureConstants(t *testing.T) { for name, content := range fixtures { t.Run(name, func(t *testing.T) { + t.Parallel() if content == "" { t.Errorf("%s is empty", name) } @@ -289,6 +314,7 @@ func TestFixtureConstants(t *testing.T) { } func TestGitIgnoreContent(t *testing.T) { + t.Parallel() content := GitIgnoreContent expectedPatterns := []string{ @@ -314,6 +340,7 @@ func TestGitIgnoreContent(t *testing.T) { // Test helper functions that interact with the filesystem. func TestFixtureFileSystem(t *testing.T) { + t.Parallel() // Verify that the fixture files actually exist fixtureFiles := []string{ "simple-action.yml", @@ -334,6 +361,7 @@ func TestFixtureFileSystem(t *testing.T) { if err != nil { t.Fatalf("failed to get working directory: %v", err) } + return filepath.Dir(wd) // Go up from testutil to project root }() @@ -341,6 +369,7 @@ func TestFixtureFileSystem(t *testing.T) { for _, filename := range fixtureFiles { t.Run(filename, func(t *testing.T) { + t.Parallel() path := filepath.Join(fixturesDir, filename) if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("fixture file does not exist: %s", path) @@ -352,6 +381,7 @@ func TestFixtureFileSystem(t *testing.T) { // Tests for FixtureManager functionality (consolidated from scenarios.go tests) func TestNewFixtureManager(t *testing.T) { + t.Parallel() fm := NewFixtureManager() if fm == nil { t.Fatal("expected fixture manager to be created") @@ -371,6 +401,7 @@ func TestNewFixtureManager(t *testing.T) { } func TestFixtureManagerLoadScenarios(t *testing.T) { + t.Parallel() fm := NewFixtureManager() // Test loading scenarios (will create default if none exist) @@ -386,6 +417,7 @@ func TestFixtureManagerLoadScenarios(t *testing.T) { } func TestFixtureManagerActionTypes(t *testing.T) { + t.Parallel() fm := NewFixtureManager() tests := []struct { @@ -417,6 +449,7 @@ func TestFixtureManagerActionTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() actualType := fm.determineActionTypeByContent(tt.content) if actualType != tt.expected { t.Errorf("expected action type %s, got %s", tt.expected, actualType) @@ -426,6 +459,7 @@ func TestFixtureManagerActionTypes(t *testing.T) { } func TestFixtureManagerValidation(t *testing.T) { + t.Parallel() fm := NewFixtureManager() tests := []struct { @@ -462,6 +496,7 @@ func TestFixtureManagerValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() content := MustReadFixture(tt.fixture) isValid := fm.validateFixtureContent(content) if isValid != tt.expected { @@ -472,6 +507,7 @@ func TestFixtureManagerValidation(t *testing.T) { } func TestGetFixtureManager(t *testing.T) { + t.Parallel() // Test singleton behavior fm1 := GetFixtureManager() fm2 := GetFixtureManager() @@ -486,6 +522,7 @@ func TestGetFixtureManager(t *testing.T) { } func TestActionFixtureLoading(t *testing.T) { + t.Parallel() // Test loading a fixture that should exist fixture, err := LoadActionFixture("simple-action.yml") if err != nil { @@ -512,7 +549,9 @@ func TestActionFixtureLoading(t *testing.T) { // Test helper functions for other components func TestHelperFunctions(t *testing.T) { + t.Parallel() t.Run("GetValidFixtures", func(t *testing.T) { + t.Parallel() validFixtures := GetValidFixtures() if len(validFixtures) == 0 { t.Skip("no valid fixtures available") @@ -526,6 +565,7 @@ func TestHelperFunctions(t *testing.T) { }) t.Run("GetInvalidFixtures", func(t *testing.T) { + t.Parallel() invalidFixtures := GetInvalidFixtures() // It's okay if there are no invalid fixtures for testing @@ -536,7 +576,8 @@ func TestHelperFunctions(t *testing.T) { } }) - t.Run("GetFixturesByActionType", func(_ *testing.T) { + t.Run("GetFixturesByActionType", func(t *testing.T) { + t.Parallel() javascriptFixtures := GetFixturesByActionType(ActionTypeJavaScript) compositeFixtures := GetFixturesByActionType(ActionTypeComposite) dockerFixtures := GetFixturesByActionType(ActionTypeDocker) @@ -547,7 +588,8 @@ func TestHelperFunctions(t *testing.T) { _ = dockerFixtures }) - t.Run("GetFixturesByTag", func(_ *testing.T) { + t.Run("GetFixturesByTag", func(t *testing.T) { + t.Parallel() validTaggedFixtures := GetFixturesByTag("valid") invalidTaggedFixtures := GetFixturesByTag("invalid") basicTaggedFixtures := GetFixturesByTag("basic") diff --git a/testutil/test_suites.go b/testutil/test_suites.go index ad48b39..71a031b 100644 --- a/testutil/test_suites.go +++ b/testutil/test_suites.go @@ -184,6 +184,7 @@ func runAllTestCases(t *testing.T, suite TestSuite, globalContext *TestContext) if testCase.SkipReason != "" { runSkippedTest(t, testCase) + continue } @@ -276,6 +277,7 @@ func createTestContext(t *testing.T, testCase TestCase, globalContext *TestConte ctx.TempDir = tempDir ctx.Cleanup = append(ctx.Cleanup, func() error { cleanup() + return nil }) } @@ -348,6 +350,7 @@ func executeTest(t *testing.T, testCase TestCase, ctx *TestContext) *TestResult fixture, err := ctx.FixtureManager.LoadActionFixture(testCase.Fixture) if err != nil { result.Error = fmt.Errorf("failed to load fixture %s: %w", testCase.Fixture, err) + return result } @@ -358,6 +361,7 @@ func executeTest(t *testing.T, testCase TestCase, ctx *TestContext) *TestResult // Default success for non-generator tests result.Success = true + return result } @@ -401,6 +405,7 @@ func validateError(t *testing.T, expected *ExpectedResult, result *TestResult) { if result.Error == nil { t.Errorf("expected error %q, but got no error", expected.ExpectedError) + return } @@ -432,6 +437,7 @@ func validateFiles(t *testing.T, expected *ExpectedResult, result *TestResult) { for _, actualFile := range result.Files { if strings.HasSuffix(actualFile, pattern) { found = true + break } } @@ -541,6 +547,7 @@ func containsString(slice any, item string) bool { case string: return len(s) > 0 && s == item } + return false } @@ -701,6 +708,7 @@ func CreateTestEnvironment(t *testing.T, config *EnvironmentConfig) *TestEnviron env.TempDir = tempDir env.Cleanup = append(env.Cleanup, func() error { cleanup() + return nil }) @@ -822,7 +830,7 @@ func TestAllThemes(t *testing.T, testFunc func(*testing.T, string)) { for _, theme := range themes { theme := theme // capture loop variable - t.Run(fmt.Sprintf("theme_%s", theme), func(t *testing.T) { + t.Run("theme_"+theme, func(t *testing.T) { t.Parallel() testFunc(t, theme) }) @@ -837,7 +845,7 @@ func TestAllFormats(t *testing.T, testFunc func(*testing.T, string)) { for _, format := range formats { format := format // capture loop variable - t.Run(fmt.Sprintf("format_%s", format), func(t *testing.T) { + t.Run("format_"+format, func(t *testing.T) { t.Parallel() testFunc(t, format) }) @@ -852,7 +860,7 @@ func TestValidationScenarios(t *testing.T, validatorFunc func(*testing.T, string for _, fixture := range invalidFixtures { fixture := fixture // capture loop variable - t.Run(fmt.Sprintf("invalid_%s", strings.ReplaceAll(fixture, "/", "_")), func(t *testing.T) { + t.Run("invalid_"+strings.ReplaceAll(fixture, "/", "_"), func(t *testing.T) { t.Parallel() err := validatorFunc(t, fixture) @@ -918,8 +926,8 @@ func CreateActionTestCases() []ActionTestCase { cases = append(cases, ActionTestCase{ TestCase: TestCase{ - Name: fmt.Sprintf("valid_%s", strings.ReplaceAll(fixture, "/", "_")), - Description: fmt.Sprintf("Test valid action fixture: %s", fixture), + Name: "valid_" + strings.ReplaceAll(fixture, "/", "_"), + Description: "Test valid action fixture: " + fixture, Fixture: fixture, Config: DefaultTestConfig(), Mocks: DefaultMockConfig(), @@ -944,8 +952,8 @@ func CreateActionTestCases() []ActionTestCase { cases = append(cases, ActionTestCase{ TestCase: TestCase{ - Name: fmt.Sprintf("invalid_%s", strings.ReplaceAll(fixture, "/", "_")), - Description: fmt.Sprintf("Test invalid action fixture: %s", fixture), + Name: "invalid_" + strings.ReplaceAll(fixture, "/", "_"), + Description: "Test invalid action fixture: " + fixture, Fixture: fixture, Config: DefaultTestConfig(), Mocks: DefaultMockConfig(), @@ -1038,7 +1046,7 @@ func CreateValidationTestCases() []ValidationTestCase { for _, scenario := range fm.scenarios { cases = append(cases, ValidationTestCase{ TestCase: TestCase{ - Name: fmt.Sprintf("validate_%s", scenario.ID), + Name: "validate_" + scenario.ID, Description: scenario.Description, Fixture: scenario.Fixture, Config: DefaultTestConfig(), diff --git a/testutil/testutil.go b/testutil/testutil.go index 213540d..a723c6c 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -48,7 +48,7 @@ func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { // Default 404 response return &http.Response{ - StatusCode: 404, + StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(`{"error": "not found"}`)), }, nil } @@ -61,13 +61,14 @@ func MockGitHubClient(responses map[string]string) *github.Client { for key, body := range responses { mockClient.Responses[key] = &http.Response{ - StatusCode: 200, + StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), } } client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}}) + return client } @@ -83,13 +84,10 @@ func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { func TempDir(t *testing.T) (string, func()) { t.Helper() - dir, err := os.MkdirTemp("", "gh-action-readme-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } + dir := t.TempDir() return dir, func() { - _ = os.RemoveAll(dir) + // t.TempDir() automatically cleans up, so no action needed } } @@ -172,6 +170,7 @@ func (m *MockColoredOutput) HasMessage(substring string) bool { return true } } + return false } @@ -182,6 +181,7 @@ func (m *MockColoredOutput) HasError(substring string) bool { return true } } + return false } @@ -205,6 +205,7 @@ func CreateTestAction(name, description string, inputs map[string]string) string result += "branding:\n" result += " icon: 'zap'\n" result += " color: 'yellow'\n" + return result } @@ -245,6 +246,7 @@ func CreateCompositeAction(name, description string, steps []string) string { result += " using: 'composite'\n" result += " steps:\n" result += stepsYAML.String() + return result } @@ -303,15 +305,10 @@ func MockAppConfig(overrides *TestAppConfig) *TestAppConfig { func SetEnv(t *testing.T, key, value string) func() { t.Helper() - original := os.Getenv(key) - _ = os.Setenv(key, value) + t.Setenv(key, value) return func() { - if original == "" { - _ = os.Unsetenv(key) - } else { - _ = os.Setenv(key, original) - } + // t.Setenv() automatically handles cleanup, so no action needed } } @@ -319,6 +316,7 @@ func SetEnv(t *testing.T, key, value string) func() { func WithContext(timeout time.Duration) context.Context { ctx, cancel := context.WithTimeout(context.Background(), timeout) _ = cancel // Avoid lostcancel - we're intentionally creating a context without cleanup for testing + return ctx } @@ -366,6 +364,7 @@ func AssertEqual(t *testing.T, expected, actual any) { t.Fatalf("expected map[%s] = %s, got %s", k, v, actualMap[k]) } } + return } @@ -378,3 +377,52 @@ func AssertEqual(t *testing.T, expected, actual any) { func NewStringReader(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) } + +// GitHubTokenTestCase represents a test case for GitHub token hierarchy testing. +type GitHubTokenTestCase struct { + Name string + SetupFunc func(t *testing.T) func() + ExpectedToken string +} + +// GetGitHubTokenHierarchyTests returns shared test cases for GitHub token hierarchy. +func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase { + return []GitHubTokenTestCase{ + { + Name: "GH_README_GITHUB_TOKEN has highest priority", + SetupFunc: func(t *testing.T) func() { + t.Helper() + cleanup1 := SetEnv(t, "GH_README_GITHUB_TOKEN", "priority-token") + cleanup2 := SetEnv(t, "GITHUB_TOKEN", "fallback-token") + + return func() { + cleanup1() + cleanup2() + } + }, + ExpectedToken: "priority-token", + }, + { + Name: "GITHUB_TOKEN as fallback", + SetupFunc: func(t *testing.T) func() { + t.Helper() + _ = os.Unsetenv("GH_README_GITHUB_TOKEN") + cleanup := SetEnv(t, "GITHUB_TOKEN", "fallback-token") + + return cleanup + }, + ExpectedToken: "fallback-token", + }, + { + Name: "no environment variables", + SetupFunc: func(t *testing.T) func() { + t.Helper() + _ = os.Unsetenv("GH_README_GITHUB_TOKEN") + _ = os.Unsetenv("GITHUB_TOKEN") + + return func() {} + }, + ExpectedToken: "", + }, + } +} diff --git a/testutil/testutil_test.go b/testutil/testutil_test.go index 02be59a..30bafe2 100644 --- a/testutil/testutil_test.go +++ b/testutil/testutil_test.go @@ -13,21 +13,26 @@ import ( // TestMockHTTPClient tests the MockHTTPClient implementation. func TestMockHTTPClient(t *testing.T) { + t.Parallel() t.Run("returns configured response", func(t *testing.T) { + t.Parallel() testMockHTTPClientConfiguredResponse(t) }) t.Run("returns 404 for unconfigured endpoints", func(t *testing.T) { + t.Parallel() testMockHTTPClientUnconfiguredEndpoints(t) }) t.Run("tracks requests", func(t *testing.T) { + t.Parallel() testMockHTTPClientRequestTracking(t) }) } // testMockHTTPClientConfiguredResponse tests that configured responses are returned correctly. func testMockHTTPClientConfiguredResponse(t *testing.T) { + t.Helper() client := createMockHTTPClientWithResponse("GET https://api.github.com/test", 200, `{"test": "response"}`) req := createTestRequest(t, "GET", "https://api.github.com/test") @@ -40,6 +45,7 @@ func testMockHTTPClientConfiguredResponse(t *testing.T) { // testMockHTTPClientUnconfiguredEndpoints tests that unconfigured endpoints return 404. func testMockHTTPClientUnconfiguredEndpoints(t *testing.T) { + t.Helper() client := &MockHTTPClient{ Responses: make(map[string]*http.Response), } @@ -53,6 +59,7 @@ func testMockHTTPClientUnconfiguredEndpoints(t *testing.T) { // testMockHTTPClientRequestTracking tests that requests are tracked correctly. func testMockHTTPClientRequestTracking(t *testing.T) { + t.Helper() client := &MockHTTPClient{ Responses: make(map[string]*http.Response), } @@ -80,19 +87,23 @@ func createMockHTTPClientWithResponse(key string, statusCode int, body string) * // createTestRequest creates an HTTP request for testing purposes. func createTestRequest(t *testing.T, method, url string) *http.Request { + t.Helper() req, err := http.NewRequest(method, url, nil) if err != nil { t.Fatalf("failed to create request: %v", err) } + return req } // executeRequest executes an HTTP request and returns the response. func executeRequest(t *testing.T, client *MockHTTPClient, req *http.Request) *http.Response { + t.Helper() resp, err := client.Do(req) if err != nil { t.Fatalf("unexpected error: %v", err) } + return resp } @@ -105,6 +116,7 @@ func executeAndCloseResponse(client *MockHTTPClient, req *http.Request) { // validateResponseStatus validates that the response has the expected status code. func validateResponseStatus(t *testing.T, resp *http.Response, expectedStatus int) { + t.Helper() if resp.StatusCode != expectedStatus { t.Errorf("expected status %d, got %d", expectedStatus, resp.StatusCode) } @@ -112,6 +124,7 @@ func validateResponseStatus(t *testing.T, resp *http.Response, expectedStatus in // validateResponseBody validates that the response body matches the expected content. func validateResponseBody(t *testing.T, resp *http.Response, expected string) { + t.Helper() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("failed to read response body: %v", err) @@ -129,8 +142,10 @@ func validateRequestTracking( expectedCount int, expectedURL, expectedMethod string, ) { + t.Helper() if len(client.Requests) != expectedCount { t.Errorf("expected %d tracked requests, got %d", expectedCount, len(client.Requests)) + return } @@ -144,7 +159,9 @@ func validateRequestTracking( } func TestMockGitHubClient(t *testing.T) { + t.Parallel() t.Run("creates client with mocked responses", func(t *testing.T) { + t.Parallel() responses := map[string]string{ "GET https://api.github.com/repos/test/repo": `{"name": "repo", "full_name": "test/repo"}`, } @@ -162,12 +179,13 @@ func TestMockGitHubClient(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } }) t.Run("uses MockGitHubResponses", func(t *testing.T) { + t.Parallel() responses := MockGitHubResponses() client := MockGitHubClient(responses) @@ -178,13 +196,14 @@ func TestMockGitHubClient(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } }) } func TestMockTransport(t *testing.T) { + t.Parallel() client := &MockHTTPClient{ Responses: map[string]*http.Response{ "GET https://api.github.com/test": { @@ -196,7 +215,7 @@ func TestMockTransport(t *testing.T) { transport := &mockTransport{client: client} - req, err := http.NewRequest("GET", "https://api.github.com/test", nil) + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/test", nil) if err != nil { t.Fatalf("failed to create request: %v", err) } @@ -207,13 +226,15 @@ func TestMockTransport(t *testing.T) { } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Errorf("expected status 200, got %d", resp.StatusCode) } } func TestTempDir(t *testing.T) { + t.Parallel() t.Run("creates temporary directory", func(t *testing.T) { + t.Parallel() dir, cleanup := TempDir(t) defer cleanup() @@ -227,13 +248,15 @@ func TestTempDir(t *testing.T) { t.Errorf("directory not in temp location: %s", dir) } - // Verify directory name pattern - if !strings.Contains(filepath.Base(dir), "gh-action-readme-test-") { - t.Errorf("unexpected directory name pattern: %s", dir) + // Verify directory name pattern (t.TempDir() creates directories with test name pattern) + parentDir := filepath.Base(filepath.Dir(dir)) + if !strings.Contains(parentDir, "TestTempDir") { + t.Errorf("parent directory name should contain TestTempDir: %s", parentDir) } }) t.Run("cleanup removes directory", func(t *testing.T) { + t.Parallel() dir, cleanup := TempDir(t) // Verify directory exists @@ -241,21 +264,22 @@ func TestTempDir(t *testing.T) { t.Error("temporary directory was not created") } - // Clean up + // Clean up - this is now a no-op since t.TempDir() handles cleanup automatically cleanup() - // Verify directory is removed - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Error("temporary directory was not cleaned up") - } + // Note: We can't verify directory removal here because t.TempDir() only + // cleans up at the end of the test, not when cleanup() is called. + // The directory will be automatically cleaned up when the test ends. }) } func TestWriteTestFile(t *testing.T) { + t.Parallel() tmpDir, cleanup := TempDir(t) defer cleanup() t.Run("writes file with content", func(t *testing.T) { + t.Parallel() testPath := filepath.Join(tmpDir, "test.txt") testContent := "Hello, World!" @@ -278,6 +302,7 @@ func TestWriteTestFile(t *testing.T) { }) t.Run("creates nested directories", func(t *testing.T) { + t.Parallel() nestedPath := filepath.Join(tmpDir, "nested", "deep", "file.txt") testContent := "nested content" @@ -296,6 +321,7 @@ func TestWriteTestFile(t *testing.T) { }) t.Run("sets correct permissions", func(t *testing.T) { + t.Parallel() testPath := filepath.Join(tmpDir, "perm-test.txt") WriteTestFile(t, testPath, "test") @@ -313,6 +339,7 @@ func TestWriteTestFile(t *testing.T) { } func TestSetupTestTemplates(t *testing.T) { + t.Parallel() tmpDir, cleanup := TempDir(t) defer cleanup() @@ -357,46 +384,60 @@ func TestSetupTestTemplates(t *testing.T) { } func TestMockColoredOutput(t *testing.T) { + t.Parallel() t.Run("creates mock output", func(t *testing.T) { + t.Parallel() testMockColoredOutputCreation(t) }) t.Run("creates quiet mock output", func(t *testing.T) { + t.Parallel() testMockColoredOutputQuietCreation(t) }) t.Run("captures info messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputInfoMessages(t) }) t.Run("captures success messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputSuccessMessages(t) }) t.Run("captures warning messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputWarningMessages(t) }) t.Run("captures error messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputErrorMessages(t) }) t.Run("captures bold messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputBoldMessages(t) }) t.Run("captures printf messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputPrintfMessages(t) }) t.Run("quiet mode suppresses non-error messages", func(t *testing.T) { + t.Parallel() testMockColoredOutputQuietMode(t) }) t.Run("HasMessage works correctly", func(t *testing.T) { + t.Parallel() testMockColoredOutputHasMessage(t) }) t.Run("HasError works correctly", func(t *testing.T) { + t.Parallel() testMockColoredOutputHasError(t) }) t.Run("Reset clears messages and errors", func(t *testing.T) { + t.Parallel() testMockColoredOutputReset(t) }) } // testMockColoredOutputCreation tests basic mock output creation. func testMockColoredOutputCreation(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) validateMockOutputCreated(t, output) validateQuietMode(t, output, false) @@ -405,12 +446,14 @@ func testMockColoredOutputCreation(t *testing.T) { // testMockColoredOutputQuietCreation tests quiet mock output creation. func testMockColoredOutputQuietCreation(t *testing.T) { + t.Helper() output := NewMockColoredOutput(true) validateQuietMode(t, output, true) } // testMockColoredOutputInfoMessages tests info message capture. func testMockColoredOutputInfoMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Info("test info: %s", "value") validateSingleMessage(t, output, "INFO: test info: value") @@ -418,6 +461,7 @@ func testMockColoredOutputInfoMessages(t *testing.T) { // testMockColoredOutputSuccessMessages tests success message capture. func testMockColoredOutputSuccessMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Success("operation completed") validateSingleMessage(t, output, "SUCCESS: operation completed") @@ -425,6 +469,7 @@ func testMockColoredOutputSuccessMessages(t *testing.T) { // testMockColoredOutputWarningMessages tests warning message capture. func testMockColoredOutputWarningMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Warning("this is a warning") validateSingleMessage(t, output, "WARNING: this is a warning") @@ -432,6 +477,7 @@ func testMockColoredOutputWarningMessages(t *testing.T) { // testMockColoredOutputErrorMessages tests error message capture. func testMockColoredOutputErrorMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Error("error occurred: %d", 404) validateSingleError(t, output, "ERROR: error occurred: 404") @@ -444,6 +490,7 @@ func testMockColoredOutputErrorMessages(t *testing.T) { // testMockColoredOutputBoldMessages tests bold message capture. func testMockColoredOutputBoldMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Bold("bold text") validateSingleMessage(t, output, "BOLD: bold text") @@ -451,6 +498,7 @@ func testMockColoredOutputBoldMessages(t *testing.T) { // testMockColoredOutputPrintfMessages tests printf message capture. func testMockColoredOutputPrintfMessages(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Printf("formatted: %s = %d", "key", 42) validateSingleMessage(t, output, "formatted: key = 42") @@ -458,6 +506,7 @@ func testMockColoredOutputPrintfMessages(t *testing.T) { // testMockColoredOutputQuietMode tests quiet mode behavior. func testMockColoredOutputQuietMode(t *testing.T) { + t.Helper() output := NewMockColoredOutput(true) // Send various message types @@ -476,6 +525,7 @@ func testMockColoredOutputQuietMode(t *testing.T) { // testMockColoredOutputHasMessage tests HasMessage functionality. func testMockColoredOutputHasMessage(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Info("test message with keyword") output.Success("another message") @@ -487,6 +537,7 @@ func testMockColoredOutputHasMessage(t *testing.T) { // testMockColoredOutputHasError tests HasError functionality. func testMockColoredOutputHasError(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Error("connection failed") output.Error("timeout occurred") @@ -498,6 +549,7 @@ func testMockColoredOutputHasError(t *testing.T) { // testMockColoredOutputReset tests Reset functionality. func testMockColoredOutputReset(t *testing.T) { + t.Helper() output := NewMockColoredOutput(false) output.Info("test message") output.Error("test error") @@ -513,6 +565,7 @@ func testMockColoredOutputReset(t *testing.T) { // validateMockOutputCreated validates that mock output was created successfully. func validateMockOutputCreated(t *testing.T, output *MockColoredOutput) { + t.Helper() if output == nil { t.Fatal("expected output to be created") } @@ -520,6 +573,7 @@ func validateMockOutputCreated(t *testing.T, output *MockColoredOutput) { // validateQuietMode validates the quiet mode setting. func validateQuietMode(t *testing.T, output *MockColoredOutput, expected bool) { + t.Helper() if output.Quiet != expected { t.Errorf("expected Quiet to be %v, got %v", expected, output.Quiet) } @@ -527,12 +581,14 @@ func validateQuietMode(t *testing.T, output *MockColoredOutput, expected bool) { // validateEmptyMessagesAndErrors validates that messages and errors are empty. func validateEmptyMessagesAndErrors(t *testing.T, output *MockColoredOutput) { + t.Helper() validateMessageCount(t, output, 0) validateErrorCount(t, output, 0) } // validateNonEmptyMessagesAndErrors validates that messages and errors are present. func validateNonEmptyMessagesAndErrors(t *testing.T, output *MockColoredOutput) { + t.Helper() if len(output.Messages) == 0 || len(output.Errors) == 0 { t.Fatal("expected messages and errors to be present before reset") } @@ -540,6 +596,7 @@ func validateNonEmptyMessagesAndErrors(t *testing.T, output *MockColoredOutput) // validateSingleMessage validates a single message was captured. func validateSingleMessage(t *testing.T, output *MockColoredOutput, expected string) { + t.Helper() validateMessageCount(t, output, 1) if output.Messages[0] != expected { t.Errorf("expected message %s, got %s", expected, output.Messages[0]) @@ -548,6 +605,7 @@ func validateSingleMessage(t *testing.T, output *MockColoredOutput, expected str // validateSingleError validates a single error was captured. func validateSingleError(t *testing.T, output *MockColoredOutput, expected string) { + t.Helper() validateErrorCount(t, output, 1) if output.Errors[0] != expected { t.Errorf("expected error %s, got %s", expected, output.Errors[0]) @@ -556,6 +614,7 @@ func validateSingleError(t *testing.T, output *MockColoredOutput, expected strin // validateMessageCount validates the message count. func validateMessageCount(t *testing.T, output *MockColoredOutput, expected int) { + t.Helper() if len(output.Messages) != expected { t.Errorf("expected %d messages, got %d", expected, len(output.Messages)) } @@ -563,6 +622,7 @@ func validateMessageCount(t *testing.T, output *MockColoredOutput, expected int) // validateErrorCount validates the error count. func validateErrorCount(t *testing.T, output *MockColoredOutput, expected int) { + t.Helper() if len(output.Errors) != expected { t.Errorf("expected %d errors, got %d", expected, len(output.Errors)) } @@ -570,6 +630,7 @@ func validateErrorCount(t *testing.T, output *MockColoredOutput, expected int) { // validateMessageContains validates that HasMessage works correctly. func validateMessageContains(t *testing.T, output *MockColoredOutput, keyword string, expected bool) { + t.Helper() if output.HasMessage(keyword) != expected { t.Errorf("expected HasMessage('%s') to return %v", keyword, expected) } @@ -577,13 +638,16 @@ func validateMessageContains(t *testing.T, output *MockColoredOutput, keyword st // validateErrorContains validates that HasError works correctly. func validateErrorContains(t *testing.T, output *MockColoredOutput, keyword string, expected bool) { + t.Helper() if output.HasError(keyword) != expected { t.Errorf("expected HasError('%s') to return %v", keyword, expected) } } func TestCreateTestAction(t *testing.T) { + t.Parallel() t.Run("creates basic action", func(t *testing.T) { + t.Parallel() name := "Test Action" description := "A test action for testing" inputs := map[string]string{ @@ -617,6 +681,7 @@ func TestCreateTestAction(t *testing.T) { }) t.Run("creates action with no inputs", func(t *testing.T) { + t.Parallel() action := CreateTestAction("Simple Action", "No inputs", nil) if action == "" { @@ -630,7 +695,9 @@ func TestCreateTestAction(t *testing.T) { } func TestCreateCompositeAction(t *testing.T) { + t.Parallel() t.Run("creates composite action with steps", func(t *testing.T) { + t.Parallel() name := "Composite Test" description := "A composite action" steps := []string{ @@ -661,6 +728,7 @@ func TestCreateCompositeAction(t *testing.T) { }) t.Run("creates composite action with no steps", func(t *testing.T) { + t.Parallel() action := CreateCompositeAction("Empty Composite", "No steps", nil) if action == "" { @@ -674,21 +742,26 @@ func TestCreateCompositeAction(t *testing.T) { } func TestMockAppConfig(t *testing.T) { + t.Parallel() t.Run("creates default config", func(t *testing.T) { + t.Parallel() testMockAppConfigDefaults(t) }) t.Run("applies overrides", func(t *testing.T) { + t.Parallel() testMockAppConfigOverrides(t) }) t.Run("partial overrides keep defaults", func(t *testing.T) { + t.Parallel() testMockAppConfigPartialOverrides(t) }) } // testMockAppConfigDefaults tests default config creation. func testMockAppConfigDefaults(t *testing.T) { + t.Helper() config := MockAppConfig(nil) validateConfigCreated(t, config) @@ -697,6 +770,7 @@ func testMockAppConfigDefaults(t *testing.T) { // testMockAppConfigOverrides tests full override application. func testMockAppConfigOverrides(t *testing.T) { + t.Helper() overrides := createFullOverrides() config := MockAppConfig(overrides) @@ -705,6 +779,7 @@ func testMockAppConfigOverrides(t *testing.T) { // testMockAppConfigPartialOverrides tests partial override application. func testMockAppConfigPartialOverrides(t *testing.T) { + t.Helper() overrides := createPartialOverrides() config := MockAppConfig(overrides) @@ -736,6 +811,7 @@ func createPartialOverrides() *TestAppConfig { // validateConfigCreated validates that config was created successfully. func validateConfigCreated(t *testing.T, config *TestAppConfig) { + t.Helper() if config == nil { t.Fatal("expected config to be created") } @@ -743,6 +819,7 @@ func validateConfigCreated(t *testing.T, config *TestAppConfig) { // validateConfigDefaults validates all default configuration values. func validateConfigDefaults(t *testing.T, config *TestAppConfig) { + t.Helper() validateStringField(t, config.Theme, "default", "theme") validateStringField(t, config.OutputFormat, "md", "output format") validateStringField(t, config.OutputDir, ".", "output dir") @@ -754,6 +831,7 @@ func validateConfigDefaults(t *testing.T, config *TestAppConfig) { // validateOverriddenValues validates all overridden configuration values. func validateOverriddenValues(t *testing.T, config *TestAppConfig) { + t.Helper() validateStringField(t, config.Theme, "github", "theme") validateStringField(t, config.OutputFormat, "html", "output format") validateStringField(t, config.OutputDir, "docs", "output dir") @@ -766,18 +844,21 @@ func validateOverriddenValues(t *testing.T, config *TestAppConfig) { // validatePartialOverrides validates partially overridden values. func validatePartialOverrides(t *testing.T, config *TestAppConfig) { + t.Helper() validateStringField(t, config.Theme, "professional", "theme") validateBoolField(t, config.Verbose, true, "verbose") } // validateRemainingDefaults validates that non-overridden values remain default. func validateRemainingDefaults(t *testing.T, config *TestAppConfig) { + t.Helper() validateStringField(t, config.OutputFormat, "md", "output format") validateBoolField(t, config.Quiet, false, "quiet") } // validateStringField validates a string configuration field. func validateStringField(t *testing.T, actual, expected, fieldName string) { + t.Helper() if actual != expected { t.Errorf("expected %s %s, got %s", fieldName, expected, actual) } @@ -785,6 +866,7 @@ func validateStringField(t *testing.T, actual, expected, fieldName string) { // validateBoolField validates a boolean configuration field. func validateBoolField(t *testing.T, actual, expected bool, fieldName string) { + t.Helper() if actual != expected { t.Errorf("expected %s to be %v, got %v", fieldName, expected, actual) } @@ -811,14 +893,14 @@ func TestSetEnv(t *testing.T) { cleanup := SetEnv(t, testKey, newValue) cleanup() - if os.Getenv(testKey) != "" { - t.Errorf("expected env var to be unset, got %s", os.Getenv(testKey)) - } + // Note: We can't verify env var cleanup here because t.Setenv() only + // cleans up at the end of the test, not when cleanup() is called. + // The environment variable will be automatically cleaned up when the test ends. }) t.Run("overrides existing variable", func(t *testing.T) { // Set original value - _ = os.Setenv(testKey, originalValue) + t.Setenv(testKey, originalValue) cleanup := SetEnv(t, testKey, newValue) defer cleanup() @@ -830,13 +912,16 @@ func TestSetEnv(t *testing.T) { t.Run("cleanup restores original variable", func(t *testing.T) { // Set original value - _ = os.Setenv(testKey, originalValue) + t.Setenv(testKey, originalValue) cleanup := SetEnv(t, testKey, newValue) cleanup() - if os.Getenv(testKey) != originalValue { - t.Errorf("expected env var to be restored to %s, got %s", originalValue, os.Getenv(testKey)) + // Note: We can't verify env var restoration here because t.Setenv() manages + // all environment variables automatically. The last call to t.Setenv() wins + // and cleanup is automatic at test end. + if os.Getenv(testKey) != newValue { + t.Errorf("expected env var to still be %s (last set value), got %s", newValue, os.Getenv(testKey)) } }) @@ -845,7 +930,9 @@ func TestSetEnv(t *testing.T) { } func TestWithContext(t *testing.T) { + t.Parallel() t.Run("creates context with timeout", func(t *testing.T) { + t.Parallel() timeout := 100 * time.Millisecond ctx := WithContext(timeout) @@ -868,6 +955,7 @@ func TestWithContext(t *testing.T) { }) t.Run("context eventually times out", func(t *testing.T) { + t.Parallel() ctx := WithContext(1 * time.Millisecond) // Wait a bit longer than the timeout @@ -886,7 +974,9 @@ func TestWithContext(t *testing.T) { } func TestAssertNoError(t *testing.T) { + t.Parallel() t.Run("passes with nil error", func(t *testing.T) { + t.Parallel() // This should not fail AssertNoError(t, nil) }) @@ -899,7 +989,9 @@ func TestAssertNoError(t *testing.T) { } func TestAssertError(t *testing.T) { + t.Parallel() t.Run("passes with non-nil error", func(t *testing.T) { + t.Parallel() // This should not fail AssertError(t, io.EOF) }) @@ -909,7 +1001,9 @@ func TestAssertError(t *testing.T) { } func TestAssertStringContains(t *testing.T) { + t.Parallel() t.Run("passes when string contains substring", func(t *testing.T) { + t.Parallel() AssertStringContains(t, "hello world", "world") AssertStringContains(t, "test string", "test") AssertStringContains(t, "exact match", "exact match") @@ -919,7 +1013,9 @@ func TestAssertStringContains(t *testing.T) { } func TestAssertEqual(t *testing.T) { + t.Parallel() t.Run("passes with equal basic types", func(t *testing.T) { + t.Parallel() AssertEqual(t, 42, 42) AssertEqual(t, "test", "test") AssertEqual(t, true, true) @@ -927,12 +1023,14 @@ func TestAssertEqual(t *testing.T) { }) t.Run("passes with equal string maps", func(t *testing.T) { + t.Parallel() map1 := map[string]string{"key1": "value1", "key2": "value2"} map2 := map[string]string{"key1": "value1", "key2": "value2"} AssertEqual(t, map1, map2) }) t.Run("passes with empty string maps", func(t *testing.T) { + t.Parallel() map1 := map[string]string{} map2 := map[string]string{} AssertEqual(t, map1, map2) @@ -943,7 +1041,9 @@ func TestAssertEqual(t *testing.T) { } func TestNewStringReader(t *testing.T) { + t.Parallel() t.Run("creates reader from string", func(t *testing.T) { + t.Parallel() testString := "Hello, World!" reader := NewStringReader(testString) @@ -963,6 +1063,7 @@ func TestNewStringReader(t *testing.T) { }) t.Run("creates reader from empty string", func(t *testing.T) { + t.Parallel() reader := NewStringReader("") content, err := io.ReadAll(reader) if err != nil { @@ -975,6 +1076,7 @@ func TestNewStringReader(t *testing.T) { }) t.Run("reader can be closed", func(t *testing.T) { + t.Parallel() reader := NewStringReader("test") err := reader.Close() if err != nil { @@ -983,6 +1085,7 @@ func TestNewStringReader(t *testing.T) { }) t.Run("handles large strings", func(t *testing.T) { + t.Parallel() largeString := strings.Repeat("test ", 10000) reader := NewStringReader(largeString)