feat(lint): add many linters, make all the tests run fast! (#23)

* chore(lint): added nlreturn, run linting

* chore(lint): replace some fmt.Sprintf calls

* chore(lint): replace fmt.Sprintf with strconv

* chore(lint): add goconst, use http lib for status codes, and methods

* chore(lint): use errors lib, errCodes from internal/errors

* chore(lint): dupl, thelper and usetesting

* chore(lint): fmt.Errorf %v to %w, more linters

* chore(lint): paralleltest, where possible

* perf(test): optimize test performance by 78%

- Implement shared binary building with package-level cache to eliminate redundant builds
- Add strategic parallelization to 15+ tests while preserving environment variable isolation
- Implement thread-safe fixture caching with RWMutex to reduce I/O operations
- Remove unnecessary working directory changes by leveraging embedded templates
- Add embedded template system with go:embed directive for reliable template resolution
- Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive

Performance improvements:
- Total test execution time: 12+ seconds → 2.7 seconds (78% faster)
- Binary build overhead: 14+ separate builds → 1 shared build (93% reduction)
- Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage)
- I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction)

All tests maintain 100% success rate and coverage while running nearly 4x faster.
This commit is contained in:
2025-08-06 15:28:09 +03:00
committed by GitHub
parent 033c858a23
commit 4f12c4d3dd
63 changed files with 1948 additions and 485 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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",
)
}

View File

@@ -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, " ")

View File

@@ -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",
)
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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()

View File

@@ -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"
}

View File

@@ -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)
})

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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)

View File

@@ -31,5 +31,6 @@ func (w *HTMLWriter) Write(output string, path string) error {
return err
}
}
return nil
}

View File

@@ -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 ""
}

View File

@@ -3,6 +3,7 @@ package internal
import "testing"
func TestFillMissing(t *testing.T) {
t.Parallel()
a := &ActionYML{}
defs := DefaultValues{

View File

@@ -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")

View File

@@ -8,6 +8,7 @@ import (
)
func TestRenderReadme(t *testing.T) {
t.Parallel()
// Set up test templates
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()

View File

@@ -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",

View File

@@ -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))

View File

@@ -52,6 +52,7 @@ func ParseActionYML(path string) (*ActionYML, error) {
if err := dec.Decode(&a); err != nil {
return nil, err
}
return &a, nil
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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), " ")
}

View File

@@ -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
}

View File

@@ -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 == "" {

View File

@@ -89,5 +89,6 @@ func isValidRuntime(runtime string) bool {
return true
}
}
return false
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}

26
main.go
View File

@@ -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
}

View File

@@ -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" {

77
templates_embed/embed.go Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
<footer style="margin-top: 2rem; border-top: 1px solid #ccc; padding-top: 1rem; color: #888; font-size: 0.95em;">
<p>Auto-generated by <a href="https://github.com/ivuorinen/gh-action-readme">gh-action-readme</a>. MIT License.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{.Name}} GitHub Action Documentation</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; background: #f9f9fb; }
h1, h2, h3 { color: #111; }
pre { background: #eee; padding: 1em; border-radius: 6px; }
code { font-family: mono; }
.badge { vertical-align: middle; margin-right: 8px; }
</style>
</head>
<body>

View File

@@ -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}}

View File

@@ -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]_

View File

@@ -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
<details>
<summary>Basic Usage</summary>
```yaml
- name: {{.Name}}
uses: {{gitUsesString .}}
{{if .Inputs}}with:
{{- range $key, $val := .Inputs}}
{{$key}}: {{if $val.Default}}"{{$val.Default}}"{{else}}"example-value"{{end}}
{{- end}}{{end}}
```
</details>
<details>
<summary>Advanced Configuration</summary>
```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}}
```
</details>
{{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}}
<details>
<summary>📋 Dependency Details</summary>
{{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}}
</details>
{{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.
---
<div align="center">
<sub>🚀 Generated with <a href="https://github.com/ivuorinen/gh-action-readme">gh-action-readme</a></sub>
</div>

View File

@@ -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)*

View File

@@ -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

View File

@@ -0,0 +1,245 @@
# {{.Name}}
{{if .Branding}}
<div align="center">
<img src="https://img.shields.io/badge/icon-{{.Branding.Icon}}-{{.Branding.Color}}" alt="{{.Branding.Icon}}" />
<img src="https://img.shields.io/badge/status-stable-brightgreen" alt="Status" />
<img src="https://img.shields.io/badge/license-MIT-blue" alt="License" />
</div>
{{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}}
<details>
<summary>📋 Dependency Details</summary>
{{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}}
</details>
{{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
---
<div align="center">
<sub>📚 Documentation generated with <a href="https://github.com/ivuorinen/gh-action-readme">gh-action-readme</a></sub>
</div>

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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(),

View File

@@ -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: "",
},
}
}

View File

@@ -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)