diff --git a/.gitignore b/.gitignore index 70bba6c..ec67bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ coverage.* # Other /megalinter-reports/ cr.txt +pr.txt diff --git a/.golangci.yml b/.golangci.yml index 194539e..364bb2f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,20 @@ # yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json version: "2" +# golangci-lint configuration +# Aligned with SonarCloud "Sonar way" quality gate +# https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/ +# +# Key alignments: +# - gosec: Aligns with Security Rating A requirement (no vulnerabilities) +# - gocyclo (min: 10): Stricter than SonarCloud (not enforced) +# - dupl: Aligns with duplicated lines density <= 3% +# - lll (120 chars): Stricter than SonarCloud (not enforced) +# - Code coverage: See Makefile target 'test-coverage-check' (>= 60%, goal: 80% for new code) +# +# SonarCloud focuses on new code (last 30 days), local linting checks entire codebase +# Local standards are intentionally stricter in some areas (complexity, line length) + run: timeout: 5m go: "1.24" diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..54fee00 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,85 @@ +--- +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp csharp_omnisharp +# dart elixir elm erlang fortran go +# haskell java julia kotlin lua markdown +# nix perl php python python_jedi r +# rego ruby ruby_solargraph rust scala swift +# terraform typescript typescript_vts yaml zig +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: + - go + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "gh-action-readme" +included_optional_tools: [] diff --git a/CLAUDE.md b/CLAUDE.md index 36462d2..6e96117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,195 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **gh-action-readme** - CLI tool for GitHub Actions documentation generation +## ⚠️ Code Quality Anti-Patterns - DO NOT REPEAT + +**CRITICAL:** The following patterns have caused quality issues in the past. These mistakes must not be repeated: + +### 🚫 High Cognitive Complexity + +#### Never write functions with cognitive complexity > 15 + +**Bad - Repeated Mistakes:** + +- Nested conditionals in test assertions +- Complex error checking logic duplicated across tests +- Deep nesting in validation functions + +**Always:** + +- Extract complex logic into helper functions +- Create test helper functions for repeated assertion patterns +- Keep functions focused on a single responsibility +- Break down complex conditions into smaller, testable pieces + +**Example:** +Instead of 19 lines of nested error checking, create a helper: + +```go +// ❌ BAD - High complexity +func TestValidation(t *testing.T) { + if result.HasErrors { + found := false + for _, err := range result.Errors { + if strings.Contains(err.Message, expected) { + found = true + break + } + } + if !found { + t.Errorf("error not found") + } + } else { + // more nesting... + } +} + +// ✅ GOOD - Use helper +func TestValidation(t *testing.T) { + assertValidationError(t, result, "field", true, "expected message") +} +``` + +### 🚫 Duplicate String Literals + +#### Never repeat string literals across test files + +**Bad - Repeated Mistakes:** + +- File paths like `"/tmp/action.yml"` repeated 22 times +- Action references like `"actions/checkout@v3"` duplicated +- Error messages and test scenarios hardcoded everywhere + +**Always:** + +- Use constants from `appconstants/` for production strings +- Use constants from `testutil/test_constants.go` for test-only strings +- Add new constants when you see duplication (>2 uses) + +**Red Flag Patterns:** + +- Same string literal in multiple test files +- Same file path repeated in different tests +- Same error message in multiple assertions + +### 🚫 Inline YAML and Config Data in Tests + +#### Never embed YAML or config data directly in test code + +**Bad - Repeated Mistakes:** + +- Inline YAML strings with backticks in test functions +- Config data hardcoded in test setup +- Template content embedded in test files + +**Always:** + +- Create fixture files in `testdata/yaml-fixtures/` +- Use `testutil.MustReadFixture()` to load fixtures +- Add constants to `testutil/test_constants.go` for fixture paths +- Reuse fixtures across multiple tests + +**Example:** + +```go +// ❌ BAD - Inline YAML +testConfig := ` +theme: default +output_format: md +` + +// ✅ GOOD - Use fixture +testConfig := string(testutil.MustReadFixture(testutil.TestConfigDefault)) +``` + +**Fixture Organization:** + +- `testdata/yaml-fixtures/configs/` - Config files +- `testdata/yaml-fixtures/actions/` - Action files +- `testdata/yaml-fixtures/template-fixtures/` - Template files + +### 🚫 Co-Authored-By Lines in Commits + +#### Never add Co-Authored-By or similar bylines to commit messages + +**Bad - Repeated Mistakes:** + +- Adding `Co-Authored-By: Claude Sonnet 4.5 ` to commits +- Including attribution lines at end of commit messages +- Adding signature or generated-by lines + +**Always:** + +- Write clean commit messages following conventional commits format +- Omit any co-author, attribution, or signature lines +- Focus commit message on what changed and why + +**Example:** + +```text +❌ BAD: +refactor: move inline YAML to fixtures + +Benefits: +- Improved maintainability +- Better separation + +Co-Authored-By: Claude Sonnet 4.5 + +✅ GOOD: +refactor: move inline YAML to fixtures for better test maintainability + +- Created 16 new config fixtures +- Replaced 19 inline YAML instances +- All tests passing with no regressions +``` + +**When user says "no bylines":** + +- This means: Remove ALL attribution/co-author lines +- Do NOT argue or explain why they might be useful +- Just comply immediately and recommit without bylines + +### ✅ Prevention Mechanisms + +**Before writing ANY code:** + +1. Check `testutil/test_constants.go` for existing constants +2. Check `testdata/yaml-fixtures/` for existing fixtures +3. Consider if your function will exceed complexity limits +4. Plan helper functions for complex logic upfront + +**Before committing:** + +1. Run `make lint` - catches complexity and duplication +2. Pre-commit hooks will catch most issues +3. SonarCloud will flag remaining issues in PR + +**Remember:** It's easier to write clean code initially than to refactor after quality issues are raised. + +## 🛡️ Quality Standards + +This project enforces strict quality gates aligned with [SonarCloud "Sonar way"](https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/): + +| Metric | Threshold | Check Command | +| ------ | --------- | ------------- | +| Code Coverage | ≥ 80% (new code) | `make test-coverage-check` | +| Duplicated Lines | ≤ 3% (new code) | `make lint` (via dupl) | +| Security Rating | A (no issues) | `make security` | +| Reliability Rating | A (no bugs) | `make lint` | +| Maintainability | A (tech debt ≤ 5%) | `make lint` | +| Cyclomatic Complexity | ≤ 10 per function | `make lint` (via gocyclo) | +| Line Length | ≤ 120 characters | `make lint` (via lll) | + +**Current Coverage:** 72.8% overall (target: 80%) +**Coverage Threshold:** Set in `Makefile` as `COVERAGE_THRESHOLD := 80.0` + +**Pre-commit Quality Checks:** + +- All linters run automatically via pre-commit hooks +- EditorConfig compliance enforced +- Security scans (gitleaks) prevent secret commits + ## 📝 Template Updates **Templates are embedded from:** `templates_embed/templates/` @@ -41,6 +230,96 @@ gh-action-readme gen testdata/ --output /tmp/test-output.md ## 🏗️ Architecture Overview +### Command Handler Pattern + +**All Cobra command handlers return errors** instead of calling `os.Exit()` directly. This enables comprehensive unit testing. + +**Pattern:** + +```go +// Handler function signature - returns error +func myHandler(cmd *cobra.Command, args []string) error { + if err := someOperation(); err != nil { + return fmt.Errorf("operation failed: %w", err) + } + return nil +} + +// Wrapped in command definition for Cobra compatibility +var myCmd = &cobra.Command{ + Use: "my-command", + Short: "Description", + Run: wrapHandlerWithErrorHandling(myHandler), +} +``` + +The `wrapHandlerWithErrorHandling()` wrapper (in `main.go`): + +- Initializes `globalConfig` if nil (important for testing) +- Calls the handler and captures the error +- Displays error via `ColoredOutput` and exits with code 1 if error occurs + +**Testing handlers:** + +```go +func TestMyHandler(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("some-flag", "default", "") + + err := myHandler(cmd, []string{}) + + // Can now test error conditions without os.Exit() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} +``` + +### Dependency Injection for Testing + +Functions that interact with I/O or global state use **nil-default parameter pattern** for testability: + +```go +// Production signature with optional injectable dependencies +func myFunction(output *ColoredOutput, config *AppConfig, reader InputReader) error { + // Default to real implementations if not provided + if config == nil { + config = globalConfig + } + if reader == nil { + reader = &StdinReader{} // Real stdin + } + // ... function logic +} + +// Production usage (pass nil for defaults) +err := myFunction(output, nil, nil) + +// Test usage (inject mocks) +mockConfig := internal.DefaultAppConfig() +mockReader := &TestInputReader{responses: []string{"y"}} +err := myFunction(output, mockConfig, mockReader) +``` + +**Examples in codebase:** + +- `applyUpdates()` - accepts `InputReader` for stdin mocking (main.go:1094) +- `setupDepsUpgrade()` - accepts `*AppConfig` for config injection (main.go:1001) + +**Test interfaces:** + +```go +// InputReader for mocking user input +type InputReader interface { + ReadLine() (string, error) +} + +type TestInputReader struct { + responses []string + index int +} +``` + ### Template Rendering Pipeline 1. **Parser** (`internal/parser.go`): @@ -395,9 +674,48 @@ When adding fields to `ActionYML`: ### Test File Locations - Unit tests: `internal/*_test.go` alongside source files -- Test fixtures: `testdata/example-action/`, `testdata/composite-action/` +- Test fixtures: `testdata/yaml-fixtures/` (organized by type) - Integration tests: Manual CLI testing with testdata +### Test Fixture Organization + +**CRITICAL:** Always use fixtures, never inline YAML in tests. + +**Fixture Structure:** + +```text +testdata/yaml-fixtures/ +├── actions/ +│ ├── composite/ # Composite actions +│ ├── javascript/ # JavaScript actions +│ ├── docker/ # Docker actions +│ └── invalid/ # Invalid actions for error testing +├── dependencies/ # Actions with specific dependencies +├── configs/ # Configuration files +└── error-scenarios/ # Edge cases and error conditions +``` + +**Using Fixtures in Tests:** + +```go +// Use fixture constants from testutil/test_constants.go +testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureCompositeBasic) + +// Available fixture constants: +// - TestFixtureJavaScriptSimple +// - TestFixtureCompositeBasic +// - TestFixtureCompositeWithDeps +// - TestFixtureCompositeMultipleNamedSteps +// - TestFixtureActionWithCheckoutV3/V4 +// See testutil/test_constants.go for complete list +``` + +**Adding New Fixtures:** + +1. Create YAML file in appropriate subdirectory: `testdata/yaml-fixtures/actions/composite/my-new-fixture.yml` +2. Add constant to `testutil/test_constants.go`: `TestFixtureMyNewFixture = "actions/composite/my-new-fixture.yml"` +3. Use in tests: `testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureMyNewFixture)` + ### Running Specific Tests ```bash diff --git a/Makefile b/Makefile index adf3499..161ea9e 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,15 @@ -.PHONY: help test test-coverage test-coverage-html lint build run example \ +.PHONY: help test test-coverage test-coverage-html test-coverage-check lint build run example \ clean readme config-verify security vulncheck audit trivy gitleaks \ editorconfig editorconfig-fix format devtools pre-commit-install pre-commit-update \ deps-check deps-update deps-update-all all: help +# Coverage threshold (align with SonarCloud) +# Note: SonarCloud checks NEW code coverage (≥80%), this checks overall coverage +# Current overall coverage: 72.9% - working towards 80% target +COVERAGE_THRESHOLD := 72.0 + help: ## Show this help message @echo "GitHub Action README Generator - Available Make Targets:" @echo "" @@ -54,6 +59,21 @@ test-coverage-html: test-coverage ## Generate HTML coverage report and open in b echo "Open coverage.html in your browser to view detailed coverage"; \ fi +test-coverage-check: ## Run tests with coverage check (overall >= 72%) + @command -v bc >/dev/null 2>&1 || { \ + echo "❌ bc command not found. Please install bc (e.g., apt-get install bc, brew install bc)"; \ + exit 1; \ + } + @echo "Running tests with coverage check..." + @go test -cover -coverprofile=coverage.out ./... + @total=$$(go tool cover -func=coverage.out | grep total | awk '{print $$3}' | sed 's/%//'); \ + if [ $$(echo "$$total < $(COVERAGE_THRESHOLD)" | bc) -eq 1 ]; then \ + echo "❌ Coverage $$total% is below threshold $(COVERAGE_THRESHOLD)%"; \ + exit 1; \ + else \ + echo "✅ Coverage $$total% meets threshold $(COVERAGE_THRESHOLD)%"; \ + fi + lint: editorconfig ## Run all linters via pre-commit @echo "Running all linters via pre-commit..." @command -v pre-commit >/dev/null 2>&1 || \ diff --git a/README.md b/README.md index cdd0a11..e28dddc 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ [![Go Vulnerability Check](https://github.com/ivuorinen/gh-action-readme/actions/workflows/security.yml/badge.svg)](https://github.com/ivuorinen/gh-action-readme/actions/workflows/security.yml) [![CodeQL](https://github.com/ivuorinen/gh-action-readme/actions/workflows/codeql.yml/badge.svg)](https://github.com/ivuorinen/gh-action-readme/actions/workflows/codeql.yml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ivuorinen_gh-action-readme&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ivuorinen_gh-action-readme&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme) +[![Maintainability](https://sonarcloud.io/api/project_badges/measure?project=ivuorinen_gh-action-readme&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme) + +[![Reliability](https://sonarcloud.io/api/project_badges/measure?project=ivuorinen_gh-action-readme&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ivuorinen_gh-action-readme&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme) + > **The definitive CLI tool for generating beautiful documentation from GitHub Actions `action.yml` files** @@ -28,6 +35,28 @@ Transform your GitHub Actions into professional documentation with multiple them - 📁 **Flexible Targeting** - Directory/file arguments, custom output filenames - 🛡️ **Thread Safe** - Race condition protection, concurrent processing ready +## 🛡️ Quality Gates + +This project enforces quality standards aligned with [SonarCloud "Sonar way"](https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/): + +| Metric | Threshold | +| ---------------------- | ------------------- | +| Code Coverage | ≥ 80% (new code) | +| Duplicated Lines | ≤ 3% (new code) | +| Security Rating | A (no issues) | +| Reliability Rating | A (no bugs) | +| Maintainability Rating | A (tech debt ≤ 5%) | + +**Local Development Checks:** + +```bash +make lint # Run all linters (gosec, dupl, gocyclo, etc.) +make test-coverage-check # Verify coverage threshold +make security # Security scans (gosec, trivy, gitleaks) +``` + +Local linting enforces additional standards including cyclomatic complexity ≤ 10 and line length ≤ 120 characters. + ## 🚀 Quick Start ### Installation diff --git a/appconstants/constants.go b/appconstants/constants.go index 0f01e80..873ab5f 100644 --- a/appconstants/constants.go +++ b/appconstants/constants.go @@ -123,6 +123,23 @@ func GetSupportedThemes() []string { return themes } +// supportedOutputFormats lists all available output format names (unexported to prevent modification). +var supportedOutputFormats = []string{ + OutputFormatMarkdown, + OutputFormatHTML, + OutputFormatJSON, + OutputFormatASCIIDoc, +} + +// GetSupportedOutputFormats returns a copy of the supported output format names. +// Returns a new slice to prevent external modification of the internal list. +func GetSupportedOutputFormats() []string { + formats := make([]string, len(supportedOutputFormats)) + copy(formats, supportedOutputFormats) + + return formats +} + // Template placeholder constants for Git repository information. const ( // DefaultOrgPlaceholder is the default organization placeholder. @@ -409,6 +426,16 @@ const ( ActionTypeMinimal = "minimal" ) +// GitHub Actions runner constants. +const ( + // RunnerUbuntuLatest is the latest Ubuntu runner. + RunnerUbuntuLatest = "ubuntu-latest" + // RunnerWindowsLatest is the latest Windows runner. + RunnerWindowsLatest = "windows-latest" + // RunnerMacosLatest is the latest macOS runner. + RunnerMacosLatest = "macos-latest" +) + // Programming language identifier constants. const ( // LangJavaScriptTypeScript is the JavaScript/TypeScript language identifier. @@ -549,6 +576,14 @@ const ( FlagRecursive = "recursive" // FlagIgnoreDirs is the ignore-dirs flag name. FlagIgnoreDirs = "ignore-dirs" + // FlagCI is the CI mode flag name. + FlagCI = "ci" + + // CommandPin is the pin command name. + CommandPin = "pin" + + // CacheStatsKeyDir is the cache stats key for directory. + CacheStatsKeyDir = "cache_dir" ) // Field names for validation. @@ -636,11 +671,21 @@ const ( // ErrFailedToAccessCache is the failed to access cache error. ErrFailedToAccessCache = "Failed to access cache: %v" // ErrNoActionFilesFound is the no action files found error. - ErrNoActionFilesFound = "No action files found" + ErrNoActionFilesFound = "no action files found" // ErrFailedToGetCurrentFilePath is the failed to get current file path error. ErrFailedToGetCurrentFilePath = "failed to get current file path" // ErrFailedToLoadActionFixture is the failed to load action fixture error. ErrFailedToLoadActionFixture = "failed to load action fixture %s: %v" + // ErrFailedToApplyUpdatesWrapped is the failed to apply updates error with wrapping. + ErrFailedToApplyUpdatesWrapped = "failed to apply updates: %w" + // ErrFailedToDiscoverActionFiles is the failed to discover action files error with wrapping. + ErrFailedToDiscoverActionFiles = "failed to discover action files: %w" + // ErrPathTraversal is the path traversal attempt error. + ErrPathTraversal = "path traversal detected: output path '%s' attempts to escape output directory '%s'" + // ErrInvalidOutputPath is the invalid output path error. + ErrInvalidOutputPath = "invalid output path: %w" + // ErrFailedToResolveOutputPath is the failed to resolve output path error with wrapping. + ErrFailedToResolveOutputPath = "failed to resolve output path: %w" ) // Common message templates. @@ -653,6 +698,120 @@ const ( MsgConfigurationExportedTo = "Configuration exported to: %s" ) +// Test command names - used across multiple test files. +const ( + TestCmdGen = "gen" + TestCmdConfig = "config" + TestCmdValidate = "validate" + TestCmdDeps = "deps" + TestCmdShow = "show" + TestCmdList = "list" +) + +// Test file paths and names - used across multiple test files. +const ( + TestTmpDir = "/tmp" + TestTmpActionFile = "/tmp/action.yml" + TestErrorScenarioOldDeps = "error-scenarios/action-with-old-deps.yml" + TestErrorScenarioMissing = "error-scenarios/missing-required-fields.yml" + TestErrorScenarioInvalid = "error-scenarios/invalid-yaml-syntax.yml" +) + +// TestMinimalAction is the minimal action YAML content for testing. +const TestMinimalAction = "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []" + +// TestScenarioNoDeps is the common test scenario description for actions with no dependencies. +const TestScenarioNoDeps = "handles action with no dependencies" + +// Test messages and error strings - used in output tests. +const ( + TestMsgFileNotFound = "File not found" + TestMsgInvalidYAML = "Invalid YAML" + TestMsgQuietSuppressOutput = "quiet mode suppresses output" + TestMsgNoOutputInQuiet = "Expected no output in quiet mode, got %q" + TestMsgVerifyPermissions = "Verify permissions" + TestMsgSuggestions = "Suggestions" + TestMsgDetails = "Details" + TestMsgCheckFilePath = "Check the file path" + TestMsgTryAgain = "Try again" + TestMsgProcessingStarted = "Processing started" + TestMsgOperationCompleted = "Operation completed" + TestMsgOutputMissingEmoji = "Output missing error emoji: %q" +) + +// Test scenario names - used in output tests. +const ( + TestScenarioColorEnabled = "with color enabled" + TestScenarioColorDisabled = "with color disabled" + TestScenarioQuietEnabled = "quiet mode enabled" + TestScenarioQuietDisabled = "quiet mode disabled" +) + +// Test URLs and paths - used in output tests. +const ( + TestURLHelp = "https://example.com/help" + TestKeyFile = "file" + TestKeyPath = "path" +) + +// Test wizard inputs and prompts - used in wizard tests. +const ( + TestWizardInputYes = "y\n" + TestWizardInputNo = "n\n" + TestWizardInputYesYes = "y\ny\n" + TestWizardInputTwo = "2\n" + TestWizardInputTripleNL = "\n\n\n" + TestWizardInputDoubleNL = "\n\n" + TestWizardPromptContinue = "Continue?" + TestWizardPromptEnter = "Enter value" +) + +// Test repository and organization names - used in wizard tests. +const ( + TestOrgName = "testorg" + TestRepoName = "testrepo" + TestValue = "test" + TestVersion = "v1.0.0" + TestDocsPath = "./docs" +) + +// Test assertion messages - used in wizard tests. +const ( + TestAssertTheme = "Theme = %q, want %q" +) + +// Test dependency actions - used in updater tests. +const ( + TestActionCheckoutV4 = "actions/checkout@v4" + TestActionCheckoutPinned = "actions/checkout@abc123 # v4.1.1" + TestActionCheckoutFullSHA = "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7" + TestActionCheckoutSHA = "692973e3d937129bcbf40652eb9f2f61becf3332" + TestActionCheckoutVersion = "v4.1.7" + TestCacheKey = "test-key" + TestUpdateTypePatch = "patch" + TestDepsSimpleCheckoutFile = "dependencies/simple-test-checkout.yml" +) + +// Test paths and output - used in generator tests. +const ( + TestOutputPath = "/tmp/output" +) + +// Test HTML content - used in html tests. +const ( + TestHTMLNewContent = "New content" + TestHTMLClosingTag = "\n" + TestMsgFailedToReadOutput = "Failed to read output file: %v" +) + +// Test detector messages - used in detector tests. +const ( + TestMsgFailedToCreateAction = "Failed to create action.yml: %v" + TestPermRead = "read" + TestPermWrite = "write" + TestPermContents = "contents" +) + // File permissions (additional). const ( // FilePermDir is the directory permission. diff --git a/appconstants/constants_test.go b/appconstants/constants_test.go new file mode 100644 index 0000000..a901740 --- /dev/null +++ b/appconstants/constants_test.go @@ -0,0 +1,212 @@ +package appconstants + +import ( + "path/filepath" + "strings" + "testing" +) + +const testModifiedValue = "modified" + +// TestGetSupportedThemes tests the GetSupportedThemes function. +func TestGetSupportedThemes(t *testing.T) { + t.Parallel() + + themes := GetSupportedThemes() + + // Check that we get a non-empty slice + if len(themes) == 0 { + t.Error("GetSupportedThemes() returned empty slice") + } + + // Check that known themes are included + expectedThemes := []string{ThemeDefault, ThemeGitHub, ThemeMinimal, ThemeProfessional} + for _, expected := range expectedThemes { + found := false + for _, theme := range themes { + if theme == expected { + found = true + + break + } + } + if !found { + t.Errorf("GetSupportedThemes() missing expected theme: %s", expected) + } + } + + // Verify it returns a copy (modifying returned slice shouldn't affect original) + themes1 := GetSupportedThemes() + themes2 := GetSupportedThemes() + if len(themes1) != len(themes2) { + t.Error("GetSupportedThemes() not returning consistent results") + } + + // Modify the returned slice + if len(themes1) > 0 { + themes1[0] = testModifiedValue + // Get a fresh copy + themes3 := GetSupportedThemes() + // Should not be modified + if themes3[0] == testModifiedValue { + t.Error("GetSupportedThemes() not returning a copy - original was modified") + } + } +} + +// TestGetConfigSearchPaths tests the GetConfigSearchPaths function. +func TestGetConfigSearchPaths(t *testing.T) { + t.Parallel() + + paths := GetConfigSearchPaths() + + // Check that we get a non-empty slice + if len(paths) == 0 { + t.Error("GetConfigSearchPaths() returned empty slice") + } + + // Check that it contains path-like strings + for _, path := range paths { + if path == "" { + t.Error("GetConfigSearchPaths() contains empty string") + } + + // Validate path doesn't contain traversal components + if strings.Contains(path, "..") { + t.Errorf("GetConfigSearchPaths() path %q contains unsafe .. component", path) + } + + // Validate path is already cleaned + cleanPath := filepath.Clean(path) + if path != cleanPath { + t.Errorf("GetConfigSearchPaths() path %q is not cleaned (should be %q)", path, cleanPath) + } + } + + // Verify it returns a copy (modifying returned slice shouldn't affect original) + paths1 := GetConfigSearchPaths() + paths2 := GetConfigSearchPaths() + if len(paths1) != len(paths2) { + t.Error("GetConfigSearchPaths() not returning consistent results") + } + + // Modify the returned slice + if len(paths1) > 0 { + paths1[0] = testModifiedValue + // Get a fresh copy + paths3 := GetConfigSearchPaths() + // Should not be modified + if paths3[0] == testModifiedValue { + t.Error("GetConfigSearchPaths() not returning a copy - original was modified") + } + } +} + +// TestGetDefaultIgnoredDirectories tests the GetDefaultIgnoredDirectories function. +func TestGetDefaultIgnoredDirectories(t *testing.T) { + t.Parallel() + + dirs := GetDefaultIgnoredDirectories() + + // Check that we get a non-empty slice + if len(dirs) == 0 { + t.Error("GetDefaultIgnoredDirectories() returned empty slice") + } + + // Check that known ignored directories are included + expectedDirs := []string{DirGit, DirNodeModules, DirVendor, DirDist} + for _, expected := range expectedDirs { + found := false + for _, dir := range dirs { + if dir == expected { + found = true + + break + } + } + if !found { + t.Errorf("GetDefaultIgnoredDirectories() missing expected directory: %s", expected) + } + } + + // Verify it returns a copy (modifying returned slice shouldn't affect original) + dirs1 := GetDefaultIgnoredDirectories() + dirs2 := GetDefaultIgnoredDirectories() + if len(dirs1) != len(dirs2) { + t.Error("GetDefaultIgnoredDirectories() not returning consistent results") + } + + // Modify the returned slice + if len(dirs1) > 0 { + dirs1[0] = testModifiedValue + // Get a fresh copy + dirs3 := GetDefaultIgnoredDirectories() + // Should not be modified + if dirs3[0] == testModifiedValue { + t.Error("GetDefaultIgnoredDirectories() not returning a copy - original was modified") + } + } +} + +// TestConfigurationSourceString tests the String method for ConfigurationSource. +func TestConfigurationSourceString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source ConfigurationSource + want string + }{ + { + name: "defaults source", + source: SourceDefaults, + want: ConfigKeyDefaults, + }, + { + name: "global source", + source: SourceGlobal, + want: ScopeGlobal, + }, + { + name: "repo override source", + source: SourceRepoOverride, + want: "repo-override", + }, + { + name: "repo config source", + source: SourceRepoConfig, + want: "repo-config", + }, + { + name: "action config source", + source: SourceActionConfig, + want: "action-config", + }, + { + name: "environment source", + source: SourceEnvironment, + want: "environment", + }, + { + name: "CLI flags source", + source: SourceCLIFlags, + want: "cli-flags", + }, + { + name: "unknown source", + source: ConfigurationSource(999), + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.source.String() + if got != tt.want { + t.Errorf("ConfigurationSource.String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/appconstants/test_constants.go b/appconstants/test_constants.go deleted file mode 100644 index 3db0cf5..0000000 --- a/appconstants/test_constants.go +++ /dev/null @@ -1,102 +0,0 @@ -package appconstants - -// This file contains constants used exclusively for testing. -// These are separated from production constants to: -// - Reduce API surface pollution in the main constants file -// - Make it clear which constants are test-only -// - Improve code organization and maintainability -// -// Note: These constants must remain exported so they can be used by -// test files in other packages (e.g., internal/*_test.go, main_test.go). - -// Test assertion message format templates. -const ( - // TestMsgExitCode is the format for exit code mismatch assertions. - TestMsgExitCode = "expected exit code %d, got %d" - - // TestMsgStdout is the format for standard output logging. - TestMsgStdout = "stdout: %s" - - // TestMsgStderr is the format for standard error logging. - TestMsgStderr = "stderr: %s" -) - -// Test fixture path constants. -const ( - // JavaScript action fixtures. - TestFixtureJavaScriptSimple = "actions/javascript/simple.yml" - - // Composite action fixtures. - TestFixtureCompositeBasic = "actions/composite/basic.yml" - TestFixtureCompositeWithDeps = "actions/composite/with-dependencies.yml" - - // Docker action fixtures. - TestFixtureDockerBasic = "actions/docker/basic.yml" - - // Invalid action fixtures. - TestFixtureInvalidMissingDescription = "actions/invalid/missing-description.yml" - TestFixtureInvalidInvalidUsing = "actions/invalid/invalid-using.yml" - - // Minimal/other fixtures. - TestFixtureMinimalAction = "minimal-action.yml" - TestFixtureProfessionalConfig = "professional-config.yml" - TestFixtureTestCompositeAction = "test-composite-action.yml" - TestFixtureMyNewAction = "my-new-action.yml" -) - -// Test file path constants. -const ( - TestPathConfigYML = "config.yml" - TestPathCustomConfigYML = "custom-config.yml" - TestPathNonexistentYML = "nonexistent.yml" -) - -// Test directory path constants. -const ( - TestDirSubdir = "subdir" - TestDirActions = "actions" - TestDirActionsDeploy = "actions/deploy" - TestDirActionsTest = "actions/test" - TestDirActionsComposite = "actions/composite" - TestDirActionsDocker = "actions/docker" - TestDirNested = "nested" - TestDirNestedDeep = "nested/deep" - - // Config directories. - TestDirConfigGhActionReadme = ".config/gh-action-readme" - TestDirDotConfig = ".config" - TestDirCacheGhActionReadme = ".cache/gh-action-readme" -) - -// (Test file permission constants removed - use production constants from appconstants/constants.go) - -// Test YAML content for parser tests. -const ( - TestYAMLRoot = "name: root" - TestYAMLNodeModules = "name: node_modules" - TestYAMLVendor = "name: vendor" - TestYAMLGit = "name: git" - TestYAMLSrc = "name: src" - TestYAMLNested = "name: nested" - TestYAMLSub = "name: sub" -) - -// Test YAML template strings for parser tests. -const ( - TestActionFilePattern = "action-*.yml" - TestPermissionsHeader = "# permissions:\n" - TestActionNameLine = "name: Test Action\n" - TestDescriptionLine = "description: Test\n" - TestRunsLine = "runs:\n" - TestCompositeUsing = " using: composite\n" - TestStepsEmpty = " steps: []\n" - TestErrorFormat = "ParseActionYML() error = %v" - TestContentsRead = "# contents: read\n" -) - -// Test path constants for template tests. -const ( - TestRepoActionPath = "/repo/action.yml" - TestRepoBuildActionPath = "/repo/build/action.yml" - TestVersionV123 = "@v1.2.3" -) diff --git a/go.sum b/go.sum index 6eea1ba..d44469e 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= -github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0= diff --git a/integration_test.go b/integration_test.go index 6eca191..12e4e7f 100644 --- a/integration_test.go +++ b/integration_test.go @@ -37,6 +37,38 @@ func TestMain(m *testing.M) { os.Exit(code) } +// findFilesRecursive recursively searches for files matching the given pattern. +// It uses filepath.WalkDir for recursive search and filepath.Match for pattern matching. +// The pattern is matched against the basename of each file. +func findFilesRecursive(rootDir, pattern string) ([]string, error) { + var matches []string + + err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Match pattern against basename + matched, err := filepath.Match(pattern, filepath.Base(path)) + if err != nil { + return err + } + + if matched { + matches = append(matches, path) + } + + return nil + }) + + return matches, err +} + // getSharedTestBinary returns the path to the shared test binary, building it once if needed. func getSharedTestBinary(t *testing.T) string { t.Helper() @@ -45,7 +77,7 @@ func getSharedTestBinary(t *testing.T) string { // 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 + tmpDir, err := os.MkdirTemp("", testutil.TestBinaryName+"-shared-test-*") //nolint:usetesting if err != nil { errSharedBinary = err @@ -54,7 +86,7 @@ func getSharedTestBinary(t *testing.T) string { sharedBinaryTmpDir = tmpDir - binaryPath := filepath.Join(tmpDir, "gh-action-readme") + binaryPath := filepath.Join(tmpDir, testutil.TestBinaryName) cmd := exec.Command("go", "build", "-o", binaryPath, ".") // #nosec G204 -- controlled test input var stderr strings.Builder @@ -87,9 +119,9 @@ func buildTestBinary(t *testing.T) string { func setupCompleteWorkflow(t *testing.T, tmpDir string) { t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) testutil.WriteTestFile(t, filepath.Join(tmpDir, "README.md"), "# Old README") - testutil.WriteTestFile(t, filepath.Join(tmpDir, ".gitignore"), testutil.GitIgnoreContent) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGitIgnore), testutil.GitIgnoreContent) testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) } @@ -97,24 +129,24 @@ func setupCompleteWorkflow(t *testing.T, tmpDir string) { func setupMultiActionWorkflow(t *testing.T, tmpDir string) { t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) - testutil.CreateActionSubdir(t, tmpDir, "actions/deploy", appconstants.TestFixtureDockerBasic) - testutil.CreateActionSubdir(t, tmpDir, "actions/test", appconstants.TestFixtureCompositeBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/deploy", testutil.TestFixtureDockerBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/test", testutil.TestFixtureCompositeBasic) } // setupConfigWorkflow creates a simple action for config testing. func setupConfigWorkflow(t *testing.T, tmpDir string) { t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) } // setupErrorWorkflow creates an invalid action file for error testing. func setupErrorWorkflow(t *testing.T, tmpDir string) { t.Helper() testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription)) + testutil.MustReadFixture(testutil.TestFixtureInvalidMissingDescription)) } // setupConfigurationHierarchy creates a complex configuration hierarchy for testing. @@ -122,21 +154,21 @@ func setupConfigurationHierarchy(t *testing.T, tmpDir string) { t.Helper() // Create action file testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) // Create global config - testutil.WriteConfigFile(t, tmpDir, testutil.MustReadFixture("configs/global/default.yml")) + testutil.WriteConfigFile(t, tmpDir, testutil.MustReadFixture(testutil.TestFixtureGlobalConfig)) // Create repo-specific config override - testutil.WriteTestFile(t, filepath.Join(tmpDir, "gh-action-readme.yml"), - testutil.MustReadFixture("professional-config.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGHActionReadme), + testutil.MustReadFixture(testutil.TestFixtureProfessionalConfig)) // Create action-specific config - testutil.WriteTestFile(t, filepath.Join(tmpDir, ".github", "gh-action-readme.yml"), - testutil.MustReadFixture("repo-config.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestDirDotGitHub, testutil.TestFileGHActionReadme), + testutil.MustReadFixture(testutil.TestFixtureRepoConfig)) // Set XDG config home to our test directory - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, appconstants.TestDirDotConfig)) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, testutil.TestDirDotConfig)) } // setupMultiActionWithTemplates creates multiple actions with custom templates. @@ -144,12 +176,12 @@ func setupMultiActionWithTemplates(t *testing.T, tmpDir string) { t.Helper() // Root action testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) // Nested actions with different types - testutil.CreateActionSubdir(t, tmpDir, "actions/composite", appconstants.TestFixtureCompositeBasic) - testutil.CreateActionSubdir(t, tmpDir, "actions/docker", appconstants.TestFixtureDockerBasic) - testutil.CreateActionSubdir(t, tmpDir, "actions/minimal", appconstants.TestFixtureMinimalAction) + testutil.CreateActionSubdir(t, tmpDir, "actions/composite", testutil.TestFixtureCompositeBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/docker", testutil.TestFixtureDockerBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/minimal", testutil.TestFixtureMinimalAction) // Setup templates testutil.SetupTestTemplates(t, tmpDir) @@ -167,12 +199,11 @@ func setupCompleteServiceChain(t *testing.T, tmpDir string) { // Add package.json for dependency analysis testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) - // Add .gitignore - testutil.WriteTestFile(t, filepath.Join(tmpDir, ".gitignore"), testutil.GitIgnoreContent) + // Add testutil.TestFileGitIgnore + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGitIgnore), testutil.GitIgnoreContent) // Create cache directory structure - cacheDir := filepath.Join(tmpDir, ".cache", "gh-action-readme") - _ = os.MkdirAll(cacheDir, 0750) // #nosec G301 -- test directory permissions + testutil.CreateTestSubdir(t, tmpDir, ".cache", testutil.TestBinaryName) } // setupDependencyAnalysisWorkflow creates a project with complex dependencies. @@ -183,7 +214,7 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { "Complex Workflow", "A composite action with multiple dependencies for testing", []string{ - "actions/checkout@v4", + testutil.TestActionCheckoutV4, "actions/setup-node@v4", "actions/cache@v3", "actions/upload-artifact@v3", @@ -195,8 +226,7 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) // Add a nested action with different dependencies - nestedDir := filepath.Join(tmpDir, "actions", "deploy") - _ = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions + nestedDir := testutil.CreateTestSubdir(t, tmpDir, "actions", "deploy") nestedAction := testutil.CreateCompositeAction( "Deploy Action", @@ -214,35 +244,25 @@ func setupConfigurationHierarchyWorkflow(t *testing.T, tmpDir string) { t.Helper() // Create action file testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) // Set up XDG config home - configHome := filepath.Join(tmpDir, appconstants.TestDirDotConfig) + configHome := filepath.Join(tmpDir, testutil.TestDirDotConfig) t.Setenv("XDG_CONFIG_HOME", configHome) // Global configuration (lowest priority) - globalConfigDir := filepath.Join(configHome, "gh-action-readme") - _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions - globalConfig := `theme: default -output_format: md -verbose: false -github_token: ghp_test1234567890abcdefghijklmnopqrstuvwxyz` - testutil.WriteTestFile(t, filepath.Join(globalConfigDir, appconstants.TestPathConfigYML), globalConfig) + globalConfigDir := testutil.CreateTestSubdir(t, configHome, testutil.TestBinaryName) + globalConfig := string(testutil.MustReadFixture(testutil.TestConfigGlobalDefault)) + testutil.WriteTestFile(t, filepath.Join(globalConfigDir, testutil.TestPathConfigYML), globalConfig) // Repository configuration (medium priority) - repoConfig := `theme: github -output_format: html -verbose: true -schema: custom-schema.json` - testutil.WriteTestFile(t, filepath.Join(tmpDir, "gh-action-readme.yml"), repoConfig) + repoConfig := string(testutil.MustReadFixture(testutil.TestConfigRepoGitHub)) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGHActionReadme), repoConfig) // Action-specific configuration (higher priority) - githubDir := filepath.Join(tmpDir, ".github") - _ = os.MkdirAll(githubDir, 0750) // #nosec G301 -- test directory permissions - actionConfig := `theme: professional -template: custom-template.tmpl -output_dir: docs` - testutil.WriteTestFile(t, filepath.Join(githubDir, "gh-action-readme.yml"), actionConfig) + githubDir := testutil.CreateTestSubdir(t, tmpDir, testutil.TestDirDotGitHub) + actionConfig := string(testutil.MustReadFixture(testutil.TestConfigActionProfessional)) + testutil.WriteTestFile(t, filepath.Join(githubDir, testutil.TestFileGHActionReadme), actionConfig) // Environment variables (highest priority before CLI flags) t.Setenv("GH_ACTION_README_THEME", "minimal") @@ -256,16 +276,13 @@ func setupTemplateErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) // Create a broken template directory structure - templatesDir := filepath.Join(tmpDir, "templates") - _ = os.MkdirAll(templatesDir, 0750) // #nosec G301 -- test directory permissions + templatesDir := testutil.CreateTestSubdir(t, tmpDir, "templates") // Create invalid template - brokenTemplate := `# {{ .Name } -{{ .InvalidField }} -{{ range .NonExistentField }}` + brokenTemplate := string(testutil.MustReadFixture(testutil.TestTemplateBroken)) testutil.WriteTestFile(t, filepath.Join(templatesDir, "broken.tmpl"), brokenTemplate) } @@ -274,38 +291,34 @@ func setupConfigurationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) // Create invalid configuration files - invalidConfig := `theme: [invalid yaml structure -output_format: "missing quote -verbose: not_a_boolean` - testutil.WriteTestFile(t, filepath.Join(tmpDir, "gh-action-readme.yml"), invalidConfig) + invalidConfig := string(testutil.MustReadFixture(testutil.TestConfigInvalidMalformed)) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGHActionReadme), invalidConfig) // Create configuration with missing required fields - incompleteConfig := `unknown_field: value -invalid_theme: nonexistent` - configDir := filepath.Join(tmpDir, appconstants.TestDirDotConfig, "gh-action-readme") - _ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(configDir, appconstants.TestPathConfigYML), incompleteConfig) + incompleteConfig := string(testutil.MustReadFixture(testutil.TestConfigInvalidIncomplete)) + configDir := testutil.CreateTestSubdir(t, tmpDir, testutil.TestDirDotConfig, testutil.TestBinaryName) + testutil.WriteTestFile(t, filepath.Join(configDir, testutil.TestPathConfigYML), incompleteConfig) // Set XDG config home - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, appconstants.TestDirDotConfig)) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, testutil.TestDirDotConfig)) } // 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 + testutil.CreateTestSubdir(t, tmpDir, "actions") + testutil.CreateTestSubdir(t, tmpDir, testutil.TestDirDotGitHub) // Create files with similar names but not action files testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.txt"), "not an action") testutil.WriteTestFile(t, filepath.Join(tmpDir, "workflow.yml"), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) testutil.WriteTestFile(t, filepath.Join(tmpDir, "actions", "action.bak"), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) } // setupServiceIntegrationErrorScenario creates a mixed scenario with various issues. @@ -313,18 +326,17 @@ func setupServiceIntegrationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Valid action at root testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) // Invalid action in subdirectory - testutil.CreateActionSubdir(t, tmpDir, "actions/broken", appconstants.TestFixtureInvalidMissingDescription) + testutil.CreateActionSubdir(t, tmpDir, "actions/broken", testutil.TestFixtureInvalidMissingDescription) // Valid action in another subdirectory - testutil.CreateActionSubdir(t, tmpDir, "actions/valid", appconstants.TestFixtureCompositeBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/valid", testutil.TestFixtureCompositeBasic) // Broken configuration - brokenConfig := `theme: nonexistent_theme -template: /path/to/nonexistent/template.tmpl` - testutil.WriteTestFile(t, filepath.Join(tmpDir, "gh-action-readme.yml"), brokenConfig) + brokenConfig := string(testutil.MustReadFixture(testutil.TestConfigInvalidTheme)) + testutil.WriteTestFile(t, filepath.Join(tmpDir, testutil.TestFileGHActionReadme), brokenConfig) } // checkStepExitCode validates command exit code expectations. @@ -333,8 +345,8 @@ func checkStepExitCode(t *testing.T, step workflowStep, exitCode int, stdout, st if step.expectSuccess && exitCode != 0 { t.Errorf("expected success but got exit code %d", exitCode) - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) + t.Logf(testutil.TestMsgStdout, stdout.String()) + t.Logf(testutil.TestMsgStderr, stderr.String()) } else if !step.expectSuccess && exitCode == 0 { t.Error("expected failure but command succeeded") } @@ -395,7 +407,7 @@ func TestServiceIntegration(t *testing.T) { workflow: []workflowStep{ { name: "generate with verbose progress indicators", - cmd: []string{"gen", "--verbose", "--theme", "github"}, + cmd: []string{"gen", testutil.TestFlagVerbose, testutil.TestFlagTheme, "github"}, expectSuccess: true, expectOutput: "Processing file:", }, @@ -416,8 +428,14 @@ func TestServiceIntegration(t *testing.T) { setupFunc: setupMultiActionWithTemplates, workflow: []workflowStep{ { - name: "discover and process multiple actions recursively", - cmd: []string{"gen", "--recursive", "--theme", "professional", "--verbose"}, + name: "discover and process multiple actions recursively", + cmd: []string{ + "gen", + testutil.TestFlagRecursive, + testutil.TestFlagTheme, + "professional", + testutil.TestFlagVerbose, + }, expectSuccess: true, }, }, @@ -440,11 +458,11 @@ func TestServiceIntegration(t *testing.T) { name: "full workflow with all services", cmd: []string{ "gen", - "--recursive", - "--verbose", - "--theme", + testutil.TestFlagRecursive, + testutil.TestFlagVerbose, + testutil.TestFlagTheme, "github", - "--output-format", + testutil.TestFlagOutputFormat, "html", }, expectSuccess: true, @@ -504,12 +522,18 @@ func TestEndToEndWorkflows(t *testing.T) { }, { name: "generate with default theme", - cmd: []string{"gen", "--theme", "default"}, + cmd: []string{"gen", testutil.TestFlagTheme, "default"}, expectSuccess: true, }, { - name: "generate with github theme", - cmd: []string{"gen", "--theme", "github", "--output-format", "html"}, + name: "generate with github theme", + cmd: []string{ + "gen", + testutil.TestFlagTheme, + "github", + testutil.TestFlagOutputFormat, + "html", + }, expectSuccess: true, }, { @@ -535,8 +559,13 @@ func TestEndToEndWorkflows(t *testing.T) { expectSuccess: true, }, { - name: "generate docs for all actions", - cmd: []string{"gen", "--recursive", "--theme", "professional"}, + name: "generate docs for all actions", + cmd: []string{ + "gen", + testutil.TestFlagRecursive, + testutil.TestFlagTheme, + "professional", + }, expectSuccess: true, }, { @@ -554,7 +583,7 @@ func TestEndToEndWorkflows(t *testing.T) { name: "show current config", cmd: []string{"config", "show"}, expectSuccess: true, - expectOutput: "Current Configuration", + expectOutput: testutil.TestMsgCurrentConfig, }, { name: "list available themes", @@ -564,7 +593,7 @@ func TestEndToEndWorkflows(t *testing.T) { }, { name: "generate with custom theme", - cmd: []string{"gen", "--theme", "minimal"}, + cmd: []string{"gen", testutil.TestFlagTheme, "minimal"}, expectSuccess: true, }, }, @@ -574,23 +603,41 @@ func TestEndToEndWorkflows(t *testing.T) { setupFunc: setupCompleteWorkflow, workflow: []workflowStep{ { - name: "generate markdown documentation", - cmd: []string{"gen", "--output-format", "md", "--theme", "github"}, + name: "generate markdown documentation", + cmd: []string{ + "gen", + testutil.TestFlagOutputFormat, + "md", + testutil.TestFlagTheme, + "github", + }, expectSuccess: true, }, { - name: "generate HTML documentation", - cmd: []string{"gen", "--output-format", "html", "--theme", "professional"}, + name: "generate HTML documentation", + cmd: []string{ + "gen", + testutil.TestFlagOutputFormat, + "html", + testutil.TestFlagTheme, + "professional", + }, expectSuccess: true, }, { name: "generate JSON documentation", - cmd: []string{"gen", "--output-format", "json"}, + cmd: []string{"gen", testutil.TestFlagOutputFormat, "json"}, expectSuccess: true, }, { - name: "generate AsciiDoc documentation", - cmd: []string{"gen", "--output-format", "asciidoc", "--theme", "minimal"}, + name: "generate AsciiDoc documentation", + cmd: []string{ + "gen", + testutil.TestFlagOutputFormat, + "asciidoc", + testutil.TestFlagTheme, + "minimal", + }, expectSuccess: true, }, }, @@ -601,9 +648,9 @@ func TestEndToEndWorkflows(t *testing.T) { workflow: []workflowStep{ { name: "analyze composite action dependencies", - cmd: []string{"deps", "list", "--verbose"}, + cmd: []string{"deps", "list", testutil.TestFlagVerbose}, expectSuccess: true, - expectOutput: "Dependencies found", + expectOutput: testutil.TestMsgDependenciesFound, }, { name: "check for dependency updates", @@ -612,7 +659,7 @@ func TestEndToEndWorkflows(t *testing.T) { }, { name: "generate documentation with dependency info", - cmd: []string{"gen", "--theme", "github", "--verbose"}, + cmd: []string{"gen", testutil.TestFlagTheme, "github", testutil.TestFlagVerbose}, expectSuccess: true, }, }, @@ -623,18 +670,25 @@ func TestEndToEndWorkflows(t *testing.T) { workflow: []workflowStep{ { name: "show merged configuration", - cmd: []string{"config", "show", "--verbose"}, + cmd: []string{"config", "show", testutil.TestFlagVerbose}, expectSuccess: true, - expectOutput: "Current Configuration", + expectOutput: testutil.TestMsgCurrentConfig, }, { name: "generate with hierarchical config", - cmd: []string{"gen", "--verbose"}, + cmd: []string{"gen", testutil.TestFlagVerbose}, expectSuccess: true, }, { - name: "override with CLI flags", - cmd: []string{"gen", "--theme", "minimal", "--output-format", "html", "--verbose"}, + name: "override with CLI flags", + cmd: []string{ + "gen", + testutil.TestFlagTheme, + "minimal", + testutil.TestFlagOutputFormat, + "html", + testutil.TestFlagVerbose, + }, expectSuccess: true, }, }, @@ -704,12 +758,10 @@ func testProjectSetup(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Create a new GitHub Action project testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureMyNewAction)) + testutil.MustReadFixture(testutil.TestFixtureMyNewAction)) // Validate the action - cmd := exec.Command(binaryPath, "validate") // #nosec G204 -- controlled test input - cmd.Dir = tmpDir - err := cmd.Run() + _, err := testutil.RunBinaryCommand(t, binaryPath, tmpDir, "validate") testutil.AssertNoError(t, err) } @@ -719,13 +771,18 @@ func testDocumentationGeneration(t *testing.T, binaryPath, tmpDir string) { themes := []string{"default", "github", "minimal"} for _, theme := range themes { - cmd := exec.Command(binaryPath, "gen", "--theme", theme) // #nosec G204 -- controlled test input + cmd := exec.Command( + binaryPath, + "gen", + testutil.TestFlagTheme, + theme, + ) // #nosec G204 -- controlled test input cmd.Dir = tmpDir err := cmd.Run() testutil.AssertNoError(t, err) // Verify README was created - readmeFiles, _ := filepath.Glob(filepath.Join(tmpDir, "README*.md")) + readmeFiles, _ := findFilesRecursive(tmpDir, testutil.TestPatternREADME) if len(readmeFiles) == 0 { t.Errorf("no README generated for theme %s", theme) } @@ -742,18 +799,12 @@ func testDependencyManagement(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Update action to be composite with dependencies testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) // List dependencies - cmd := exec.Command(binaryPath, "deps", "list") - cmd.Dir = tmpDir - var stdout strings.Builder - cmd.Stdout = &stdout - err := cmd.Run() + output, err := testutil.RunBinaryCommand(t, binaryPath, tmpDir, "deps", "list") testutil.AssertNoError(t, err) - - output := stdout.String() - if !strings.Contains(output, "Dependencies found") { + if !strings.Contains(output, testutil.TestMsgDependenciesFound) { t.Error("expected dependency listing output") } } @@ -764,7 +815,12 @@ func testOutputFormats(t *testing.T, binaryPath, tmpDir string) { formats := []string{"md", "html", "json"} for _, format := range formats { - cmd := exec.Command(binaryPath, "gen", "--output-format", format) // #nosec G204 -- controlled test input + cmd := exec.Command( + binaryPath, + "gen", + testutil.TestFlagOutputFormat, + format, + ) // #nosec G204 -- controlled test input cmd.Dir = tmpDir err := cmd.Run() testutil.AssertNoError(t, err) @@ -773,10 +829,10 @@ func testOutputFormats(t *testing.T, binaryPath, tmpDir string) { var pattern string switch format { case "md": - pattern = "README*.md" + pattern = testutil.TestPatternREADME case "html": // HTML files are named after the action name (e.g., "Example Action.html") - pattern = "*.html" + pattern = testutil.TestPatternHTML case "json": // JSON files have a fixed name pattern = "action-docs.json" @@ -798,21 +854,15 @@ func testOutputFormats(t *testing.T, binaryPath, tmpDir string) { func testCacheManagement(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Check cache stats - cmd := exec.Command(binaryPath, "cache", "stats") - cmd.Dir = tmpDir - err := cmd.Run() + _, err := testutil.RunBinaryCommand(t, binaryPath, tmpDir, "cache", "stats") testutil.AssertNoError(t, err) // Clear cache - cmd = exec.Command(binaryPath, "cache", "clear") - cmd.Dir = tmpDir - err = cmd.Run() + _, err = testutil.RunBinaryCommand(t, binaryPath, tmpDir, "cache", "clear") testutil.AssertNoError(t, err) // Check path - cmd = exec.Command(binaryPath, "cache", "path") - cmd.Dir = tmpDir - err = cmd.Run() + _, err = testutil.RunBinaryCommand(t, binaryPath, tmpDir, "cache", "path") testutil.AssertNoError(t, err) } @@ -865,8 +915,8 @@ func TestMultiFormatIntegration(t *testing.T) { extension string theme string }{ - {"md", "README*.md", "github"}, - {"html", "*.html", "professional"}, + {"md", testutil.TestPatternREADME, "github"}, + {"html", testutil.TestPatternHTML, "professional"}, {"json", "action-docs.json", "default"}, {"asciidoc", "*.adoc", "minimal"}, } @@ -904,11 +954,11 @@ func runGenerationCommand(t *testing.T, binaryPath, tmpDir, format, theme string cmd := exec.Command( binaryPath, "gen", - "--output-format", + testutil.TestFlagOutputFormat, format, - "--theme", + testutil.TestFlagTheme, theme, - "--verbose", + testutil.TestFlagVerbose, ) // #nosec G204 -- controlled test input cmd.Dir = tmpDir var stdout, stderr strings.Builder @@ -917,8 +967,8 @@ func runGenerationCommand(t *testing.T, binaryPath, tmpDir, format, theme string err := cmd.Run() if err != nil { - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) + t.Logf(testutil.TestMsgStdout, stdout.String()) + t.Logf(testutil.TestMsgStderr, stderr.String()) } testutil.AssertNoError(t, err) @@ -1010,7 +1060,7 @@ func TestErrorScenarioIntegration(t *testing.T) { setupFunc: setupTemplateErrorScenario, scenarios: []errorScenario{ { - cmd: []string{"gen", "--theme", "nonexistent"}, + cmd: []string{"gen", testutil.TestFlagTheme, "nonexistent"}, expectFailure: true, expectError: "batch processing", }, @@ -1031,7 +1081,7 @@ func TestErrorScenarioIntegration(t *testing.T) { expectError: "", }, { - cmd: []string{"gen", "--verbose"}, + cmd: []string{"gen", testutil.TestFlagVerbose}, expectFailure: false, // Should use defaults expectError: "", }, @@ -1058,7 +1108,7 @@ func TestErrorScenarioIntegration(t *testing.T) { setupFunc: setupServiceIntegrationErrorScenario, scenarios: []errorScenario{ { - cmd: []string{"gen", "--recursive", "--verbose"}, + cmd: []string{"gen", testutil.TestFlagRecursive, testutil.TestFlagVerbose}, expectFailure: true, // Mixed valid/invalid files expectError: "", // May partially succeed }, @@ -1109,22 +1159,27 @@ func TestStressTestWorkflow(t *testing.T) { // Create many action files to test performance const numActions = 20 for i := 0; i < numActions; i++ { - actionDir := filepath.Join(tmpDir, "action"+string(rune('A'+i))) - _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions + actionDir := testutil.CreateTestSubdir(t, tmpDir, "action"+string(rune('A'+i))) - actionContent := strings.ReplaceAll(testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), + actionContent := strings.ReplaceAll(testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), "Simple Action", "Action "+string(rune('A'+i))) testutil.WriteTestFile(t, filepath.Join(actionDir, appconstants.ActionFileNameYML), actionContent) } // Test recursive processing - cmd := exec.Command(binaryPath, "gen", "--recursive", "--theme", "github") // #nosec G204 -- controlled test input + cmd := exec.Command( + binaryPath, + "gen", + testutil.TestFlagRecursive, + testutil.TestFlagTheme, + "github", + ) // #nosec G204 -- controlled test input cmd.Dir = tmpDir err := cmd.Run() testutil.AssertNoError(t, err) // Verify all READMEs were generated - readmeFiles, _ := filepath.Glob(filepath.Join(tmpDir, "**/README*.md")) + readmeFiles, _ := findFilesRecursive(tmpDir, testutil.TestPatternREADME) if len(readmeFiles) < numActions { t.Errorf("expected at least %d README files, got %d", numActions, len(readmeFiles)) } @@ -1149,22 +1204,28 @@ func TestProgressBarIntegration(t *testing.T) { { name: "Single action progress", setupFunc: setupCompleteWorkflow, - cmd: []string{"gen", "--verbose", "--theme", "github"}, + cmd: []string{"gen", testutil.TestFlagVerbose, testutil.TestFlagTheme, "github"}, }, { name: "Multiple actions progress", setupFunc: setupMultiActionWithTemplates, - cmd: []string{"gen", "--recursive", "--verbose", "--theme", "professional"}, + cmd: []string{ + "gen", + testutil.TestFlagRecursive, + testutil.TestFlagVerbose, + testutil.TestFlagTheme, + "professional", + }, }, { name: "Dependency analysis progress", setupFunc: setupDependencyAnalysisWorkflow, - cmd: []string{"deps", "list", "--verbose"}, + cmd: []string{"deps", "list", testutil.TestFlagVerbose}, }, { name: "Multi-format generation progress", setupFunc: setupCompleteWorkflow, - cmd: []string{"gen", "--output-format", "html", "--verbose"}, + cmd: []string{"gen", testutil.TestFlagOutputFormat, "html", testutil.TestFlagVerbose}, }, } @@ -1183,8 +1244,8 @@ func TestProgressBarIntegration(t *testing.T) { err := cmd.Run() if err != nil { - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) + t.Logf(testutil.TestMsgStdout, stdout.String()) + t.Logf(testutil.TestMsgStderr, stderr.String()) } testutil.AssertNoError(t, err) @@ -1195,7 +1256,7 @@ func TestProgressBarIntegration(t *testing.T) { "Processing file:", "Generated README", "Discovered action file:", - "Dependencies found", + testutil.TestMsgDependenciesFound, "Analyzing dependencies", } @@ -1215,17 +1276,14 @@ func TestProgressBarIntegration(t *testing.T) { // Verify operation completed successfully (files were generated) if strings.Contains(tt.cmd[0], "gen") { - patterns := []string{ - filepath.Join(tmpDir, "README*.md"), - filepath.Join(tmpDir, "**/README*.md"), - filepath.Join(tmpDir, "*.html"), - } - var foundFiles []string - for _, pattern := range patterns { - files, _ := filepath.Glob(pattern) - foundFiles = append(foundFiles, files...) - } + + // Use findFilesRecursive for recursive patterns + readmeFiles, _ := findFilesRecursive(tmpDir, testutil.TestPatternREADME) + foundFiles = append(foundFiles, readmeFiles...) + + htmlFiles, _ := findFilesRecursive(tmpDir, testutil.TestPatternHTML) + foundFiles = append(foundFiles, htmlFiles...) if len(foundFiles) == 0 { t.Logf("No documentation files found, but progress indicators were present") @@ -1246,37 +1304,27 @@ func TestErrorRecoveryWorkflow(t *testing.T) { // Create a project with mixed valid and invalid files // Note: validation looks for files named exactly "action.yml" or "action.yaml" testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) - testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, - appconstants.TestFixtureInvalidMissingDescription) + testutil.CreateActionSubdir(t, tmpDir, testutil.TestDirSubdir, + testutil.TestFixtureInvalidMissingDescription) // Test that validation reports issues but doesn't crash - cmd := exec.Command(binaryPath, "validate") // #nosec G204 -- controlled test input - cmd.Dir = tmpDir - var stderr strings.Builder - cmd.Stderr = &stderr - - err := cmd.Run() + output, err := testutil.RunBinaryCommand(t, binaryPath, tmpDir, "validate") // Validation should fail due to invalid file if err == nil { t.Error("expected validation to fail with invalid files") } // But it should still report on valid files with validation errors - output := stderr.String() if !strings.Contains(output, "Missing required field:") && !strings.Contains(output, "validation failed") { t.Errorf("expected validation error message, got: %s", output) } // Test generation with mixed files - should generate docs for valid ones - cmd = exec.Command(binaryPath, "gen", "--recursive") // #nosec G204 -- controlled test input - cmd.Dir = tmpDir - cmd.Stderr = &stderr - - _ = cmd.Run() + _, _ = testutil.RunBinaryCommand(t, binaryPath, tmpDir, "gen", testutil.TestFlagRecursive) // Generation might fail due to invalid files, but check what was generated - readmeFiles, _ := filepath.Glob(filepath.Join(tmpDir, "**/README*.md")) + readmeFiles, _ := findFilesRecursive(tmpDir, testutil.TestPatternREADME) // Should have generated at least some READMEs for valid files if len(readmeFiles) == 0 { @@ -1296,7 +1344,7 @@ func TestConfigurationWorkflow(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", configHome) testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) var err error @@ -1314,12 +1362,12 @@ func TestConfigurationWorkflow(t *testing.T) { err = cmd.Run() testutil.AssertNoError(t, err) - if !strings.Contains(stdout.String(), "Current Configuration") { + if !strings.Contains(stdout.String(), testutil.TestMsgCurrentConfig) { t.Error("expected configuration output") } // Test with different configuration options - cmd = exec.Command(binaryPath, "--verbose", "gen") // #nosec G204 -- controlled test input + cmd = exec.Command(binaryPath, testutil.TestFlagVerbose, "gen") // #nosec G204 -- controlled test input cmd.Dir = tmpDir err = cmd.Run() testutil.AssertNoError(t, err) @@ -1338,9 +1386,9 @@ func verifyConfigurationLoading(t *testing.T, tmpDir string) { // 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{ - filepath.Join(tmpDir, appconstants.TestDirDotConfig, "gh-action-readme", appconstants.TestPathConfigYML), - filepath.Join(tmpDir, "gh-action-readme.yml"), - filepath.Join(tmpDir, ".github", "gh-action-readme.yml"), + filepath.Join(tmpDir, testutil.TestDirDotConfig, testutil.TestBinaryName, testutil.TestPathConfigYML), + filepath.Join(tmpDir, testutil.TestFileGHActionReadme), + filepath.Join(tmpDir, testutil.TestDirDotGitHub, testutil.TestFileGHActionReadme), } configFound := 0 @@ -1482,7 +1530,7 @@ func verifyCompleteServiceChain(t *testing.T, tmpDir string) { requiredComponents := []string{ filepath.Join(tmpDir, appconstants.ActionFileNameYML), filepath.Join(tmpDir, "package.json"), - filepath.Join(tmpDir, ".gitignore"), + filepath.Join(tmpDir, testutil.TestFileGitIgnore), } foundComponents := 0 diff --git a/internal/apperrors/errors_test.go b/internal/apperrors/errors_test.go index 9fac79c..5bb1675 100644 --- a/internal/apperrors/errors_test.go +++ b/internal/apperrors/errors_test.go @@ -92,7 +92,7 @@ func TestContextualErrorError(t *testing.T) { Code: appconstants.ErrCodeValidation, Err: errors.New("validation failed"), Context: "validating action.yml", - Details: map[string]string{"file": "action.yml"}, + Details: map[string]string{"file": appconstants.ActionFileNameYML}, Suggestions: []string{ "Check required fields", "Validate YAML syntax", diff --git a/internal/apperrors/suggestions_test.go b/internal/apperrors/suggestions_test.go index 0ea65df..17c4303 100644 --- a/internal/apperrors/suggestions_test.go +++ b/internal/apperrors/suggestions_test.go @@ -8,24 +8,6 @@ import ( "github.com/ivuorinen/gh-action-readme/testutil" ) -// Test helper factories for creating context maps - -func ctxPath(path string) map[string]string { - return map[string]string{"path": path} -} - -func ctxError(err string) map[string]string { - return map[string]string{"error": err} -} - -func ctxStatusCode(code string) map[string]string { - return map[string]string{"status_code": code} -} - -func ctxEmpty() map[string]string { - return map[string]string{} -} - func TestGetSuggestions(t *testing.T) { t.Parallel() @@ -38,7 +20,7 @@ func TestGetSuggestions(t *testing.T) { { name: "file not found with path", code: appconstants.ErrCodeFileNotFound, - context: ctxPath("/path/to/action.yml"), + context: testutil.ContextWithPath("/path/to/action.yml"), contains: []string{ "Check if the file exists: /path/to/action.yml", "Verify the file path is correct", @@ -48,7 +30,7 @@ func TestGetSuggestions(t *testing.T) { { name: "file not found action file", code: appconstants.ErrCodeFileNotFound, - context: ctxPath("/project/action.yml"), + context: testutil.ContextWithPath("/project/action.yml"), contains: []string{ "Common action file names: action.yml, action.yaml", "Check if the file is in a subdirectory", @@ -57,18 +39,16 @@ func TestGetSuggestions(t *testing.T) { { name: "permission denied", code: appconstants.ErrCodePermission, - context: ctxPath("/restricted/file.txt"), + context: testutil.ContextWithPath("/restricted/file.txt"), contains: []string{ "Check file permissions: ls -la /restricted/file.txt", "chmod 644 /restricted/file.txt", }, }, { - name: "invalid YAML with line number", - code: appconstants.ErrCodeInvalidYAML, - context: map[string]string{ - "line": "25", - }, + name: "invalid YAML with line number", + code: appconstants.ErrCodeInvalidYAML, + context: testutil.ContextWithLine("25"), contains: []string{ "Error near line 25", "Check YAML indentation", @@ -79,18 +59,16 @@ func TestGetSuggestions(t *testing.T) { { name: "invalid YAML with tab error", code: appconstants.ErrCodeInvalidYAML, - context: ctxError("found character that cannot start any token (tab)"), + context: testutil.ContextWithError("found character that cannot start any token (tab)"), contains: []string{ "YAML files must use spaces for indentation, not tabs", "Replace all tabs with spaces", }, }, { - name: "invalid action with missing fields", - code: appconstants.ErrCodeInvalidAction, - context: map[string]string{ - "missing_fields": "name, description", - }, + name: "invalid action with missing fields", + code: appconstants.ErrCodeInvalidAction, + context: testutil.ContextWithMissingFields("name, description"), contains: []string{ "Missing required fields: name, description", "required fields: name, description", @@ -98,11 +76,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "no action files", - code: appconstants.ErrCodeNoActionFiles, - context: map[string]string{ - "directory": "/project", - }, + name: "no action files", + code: appconstants.ErrCodeNoActionFiles, + context: testutil.ContextWithDirectory("/project"), contains: []string{ "Current directory: /project", "find /project -name 'action.y*ml'", @@ -113,7 +89,7 @@ func TestGetSuggestions(t *testing.T) { { name: "GitHub API 401 error", code: appconstants.ErrCodeGitHubAPI, - context: ctxStatusCode("401"), + context: testutil.ContextWithStatusCode("401"), contains: []string{ "Authentication failed", "check your GitHub token", @@ -123,7 +99,7 @@ func TestGetSuggestions(t *testing.T) { { name: "GitHub API 403 error", code: appconstants.ErrCodeGitHubAPI, - context: ctxStatusCode("403"), + context: testutil.ContextWithStatusCode("403"), contains: []string{ "Access forbidden", "check token permissions", @@ -133,7 +109,7 @@ func TestGetSuggestions(t *testing.T) { { name: "GitHub API 404 error", code: appconstants.ErrCodeGitHubAPI, - context: ctxStatusCode("404"), + context: testutil.ContextWithStatusCode("404"), contains: []string{ "Repository or resource not found", "repository is private", @@ -142,7 +118,7 @@ func TestGetSuggestions(t *testing.T) { { name: "GitHub rate limit", code: appconstants.ErrCodeGitHubRateLimit, - context: ctxEmpty(), + context: testutil.EmptyContext(), contains: []string{ "rate limit exceeded", "GITHUB_TOKEN", @@ -153,7 +129,7 @@ func TestGetSuggestions(t *testing.T) { { name: "GitHub auth", code: appconstants.ErrCodeGitHubAuth, - context: ctxEmpty(), + context: testutil.EmptyContext(), contains: []string{ "export GITHUB_TOKEN", "gh auth login", @@ -162,11 +138,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "configuration error with path", - code: appconstants.ErrCodeConfiguration, - context: map[string]string{ - "config_path": "~/.config/gh-action-readme/config.yaml", - }, + name: "configuration error with path", + code: appconstants.ErrCodeConfiguration, + context: testutil.ContextWithConfigPath("~/.config/gh-action-readme/config.yaml"), contains: []string{ "Config path: ~/.config/gh-action-readme/config.yaml", "ls -la ~/.config/gh-action-readme/config.yaml", @@ -174,11 +148,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "validation error with invalid fields", - code: appconstants.ErrCodeValidation, - context: map[string]string{ - "invalid_fields": "runs.using, inputs.test", - }, + name: "validation error with invalid fields", + code: appconstants.ErrCodeValidation, + context: testutil.ContextWithField("invalid_fields", "runs.using, inputs.test"), contains: []string{ "Invalid fields: runs.using, inputs.test", "Check spelling and nesting", @@ -186,11 +158,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "template error with theme", - code: appconstants.ErrCodeTemplateRender, - context: map[string]string{ - "theme": "custom", - }, + name: "template error with theme", + code: appconstants.ErrCodeTemplateRender, + context: testutil.ContextWithField("theme", "custom"), contains: []string{ "Current theme: custom", "Try using a different theme", @@ -198,11 +168,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "file write error with output path", - code: appconstants.ErrCodeFileWrite, - context: map[string]string{ - "output_path": "/output/README.md", - }, + name: "file write error with output path", + code: appconstants.ErrCodeFileWrite, + context: testutil.ContextWithField("output_path", "/output/README.md"), contains: []string{ "Output directory: /output", "Check permissions: ls -la /output", @@ -210,11 +178,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "dependency analysis error", - code: appconstants.ErrCodeDependencyAnalysis, - context: map[string]string{ - "action": "my-action", - }, + name: "dependency analysis error", + code: appconstants.ErrCodeDependencyAnalysis, + context: testutil.ContextWithField("action", "my-action"), contains: []string{ "Analyzing action: my-action", "GitHub token is set", @@ -222,11 +188,9 @@ func TestGetSuggestions(t *testing.T) { }, }, { - name: "cache access error", - code: appconstants.ErrCodeCacheAccess, - context: map[string]string{ - "cache_path": "~/.cache/gh-action-readme", - }, + name: "cache access error", + code: appconstants.ErrCodeCacheAccess, + context: testutil.ContextWithField("cache_path", "~/.cache/gh-action-readme"), contains: []string{ "Cache path: ~/.cache/gh-action-readme", "gh-action-readme cache clear", @@ -236,7 +200,7 @@ func TestGetSuggestions(t *testing.T) { { name: "unknown error code", code: "UNKNOWN_TEST_CODE", - context: ctxEmpty(), + context: testutil.EmptyContext(), contains: []string{ "Check the error message", "--verbose flag", @@ -258,7 +222,7 @@ func TestGetSuggestions(t *testing.T) { func TestGetPermissionSuggestionsOSSpecific(t *testing.T) { t.Parallel() - context := map[string]string{"path": "/test/file"} + context := testutil.ContextWithPath("/test/file") suggestions := getPermissionSuggestions(context) switch runtime.GOOS { @@ -294,7 +258,7 @@ func TestGetSuggestionsEmptyContext(t *testing.T) { t.Run(string(code), func(t *testing.T) { t.Parallel() - suggestions := GetSuggestions(code, map[string]string{}) + suggestions := GetSuggestions(code, testutil.EmptyContext()) if len(suggestions) == 0 { t.Errorf("GetSuggestions(%s, {}) returned empty slice", code) } @@ -305,9 +269,7 @@ func TestGetSuggestionsEmptyContext(t *testing.T) { func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) { t.Parallel() - context := map[string]string{ - "path": "/project/action.yml", - } + context := testutil.ContextWithPath("/project/action.yml") suggestions := getFileNotFoundSuggestions(context) testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"}) @@ -316,9 +278,7 @@ func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) { func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) { t.Parallel() - context := map[string]string{ - "error": "found character that cannot start any token, tab character", - } + context := testutil.ContextWithError("found character that cannot start any token, tab character") suggestions := getInvalidYAMLSuggestions(context) testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"}) @@ -337,9 +297,205 @@ func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) { t.Run("status_"+code, func(t *testing.T) { t.Parallel() - context := map[string]string{"status_code": code} + context := testutil.ContextWithStatusCode(code) suggestions := getGitHubAPISuggestions(context) testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText}) }) } } + +// TestGetValidationSuggestions tests the getValidationSuggestions function. +func TestGetValidationSuggestions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + context map[string]string + expectedContains []string + }{ + { + name: "basic validation suggestions", + context: map[string]string{}, + expectedContains: []string{ + "Review validation errors", + "Check required fields", + "Use 'gh-action-readme schema' to see valid structure", + }, + }, + { + name: "with invalid_fields context", + context: testutil.ContextWithField("invalid_fields", "runs.using, description"), + expectedContains: []string{ + "Invalid fields: runs.using, description", + "Check spelling and nesting", + }, + }, + { + name: "with validation_type required", + context: testutil.ContextWithField("validation_type", "required"), + expectedContains: []string{ + "Add missing required fields", + "name, description, runs", + }, + }, + { + name: "with validation_type type", + context: testutil.ContextWithField("validation_type", "type"), + expectedContains: []string{ + "Ensure field values match expected types", + "Strings should be quoted", + }, + }, + { + name: "with both invalid_fields and validation_type", + context: testutil.MergeContexts( + testutil.ContextWithField("invalid_fields", "name"), + testutil.ContextWithField("validation_type", "required"), + ), + expectedContains: []string{ + "Invalid fields: name", + "Add missing required fields", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + suggestions := getValidationSuggestions(tt.context) + testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains) + }) + } +} + +// TestGetConfigurationSuggestions tests the getConfigurationSuggestions function. +func TestGetConfigurationSuggestions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + context map[string]string + expectedContains []string + }{ + { + name: "basic configuration suggestions", + context: map[string]string{}, + expectedContains: []string{ + "Check configuration file syntax", + "Ensure configuration file exists", + "Use 'gh-action-readme config init'", + }, + }, + { + name: "with config_path context", + context: testutil.ContextWithConfigPath("/path/to/config.yaml"), + expectedContains: []string{ + "Config path: /path/to/config.yaml", + "Check if file exists: ls -la /path/to/config.yaml", + }, + }, + { + name: "with permission error in context", + context: testutil.ContextWithError("permission denied"), + expectedContains: []string{ + "Check file permissions for config file", + "Ensure parent directory is writable", + }, + }, + { + name: "with both config_path and permission error", + context: testutil.MergeContexts( + testutil.ContextWithConfigPath("/restricted/config.yaml"), + testutil.ContextWithError("permission denied while reading"), + ), + expectedContains: []string{ + "Config path: /restricted/config.yaml", + "Check file permissions for config file", + }, + }, + { + name: "with path traversal attempt", + context: testutil.ContextWithConfigPath("../../../etc/passwd"), + expectedContains: []string{ + "Check configuration file syntax", + "Ensure configuration file exists", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + suggestions := getConfigurationSuggestions(tt.context) + testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains) + }) + } +} + +// TestGetTemplateSuggestions tests the getTemplateSuggestions function. +func TestGetTemplateSuggestions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + context map[string]string + expectedContains []string + }{ + { + name: "basic template suggestions", + context: map[string]string{}, + expectedContains: []string{ + "Check template syntax", + "Ensure all template variables are defined", + "Verify custom template path is correct", + }, + }, + { + name: "with template_path context", + context: testutil.ContextWithField("template_path", "/path/to/custom-template.tmpl"), + expectedContains: []string{ + "Template path: /path/to/custom-template.tmpl", + "Ensure template file exists and is readable", + }, + }, + { + name: "with theme context", + context: testutil.ContextWithField("theme", "custom-theme"), + expectedContains: []string{ + "Current theme: custom-theme", + "Try using a different theme: --theme github", + "Available themes: default, github, gitlab, minimal, professional", + }, + }, + { + name: "with both template_path and theme", + context: testutil.MergeContexts( + testutil.ContextWithField("template_path", "/custom/template.tmpl"), + testutil.ContextWithField("theme", "github"), + ), + expectedContains: []string{ + "Template path: /custom/template.tmpl", + "Current theme: github", + }, + }, + { + name: "with path traversal attempt", + context: testutil.ContextWithField("template_path", "../../../../../../etc/passwd"), + expectedContains: []string{ + "Check template syntax", + "Ensure all template variables are defined", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + suggestions := getTemplateSuggestions(tt.context) + testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains) + }) + } +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index a606ec3..23235f6 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -69,7 +69,7 @@ func TestNewCache(t *testing.T) { } } -func TestCache_SetAndGet(t *testing.T) { +func TestCacheSetAndGet(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -84,9 +84,9 @@ func TestCache_SetAndGet(t *testing.T) { }{ { name: "string value", - key: "test-key", - value: "test-value", - expected: "test-value", + key: testutil.CacheTestKey, + value: testutil.CacheTestValue, + expected: testutil.CacheTestValue, }, { name: "struct value", @@ -121,7 +121,7 @@ func TestCache_SetAndGet(t *testing.T) { } } -func TestCache_TTL(t *testing.T) { +func TestCacheTTL(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -150,7 +150,7 @@ func TestCache_TTL(t *testing.T) { } } -func TestCache_GetOrSet(t *testing.T) { +func TestCacheGetOrSet(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -180,7 +180,7 @@ func TestCache_GetOrSet(t *testing.T) { testutil.AssertEqual(t, 1, callCount) // Getter not called again } -func TestCache_GetOrSetError(t *testing.T) { +func TestCacheGetOrSetError(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -207,7 +207,7 @@ func TestCache_GetOrSetError(t *testing.T) { } } -func TestCache_ConcurrentAccess(t *testing.T) { +func TestCacheConcurrentAccess(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -257,7 +257,7 @@ func TestCache_ConcurrentAccess(t *testing.T) { wg.Wait() } -func TestCache_Persistence(t *testing.T) { +func TestCachePersistence(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -282,7 +282,7 @@ func TestCache_Persistence(t *testing.T) { testutil.AssertEqual(t, "persistent-value", value) } -func TestCache_Clear(t *testing.T) { +func TestCacheClear(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -290,12 +290,12 @@ func TestCache_Clear(t *testing.T) { defer testutil.CleanupCache(t, cache)() // Add some data - _ = cache.Set("key1", "value1") - _ = cache.Set("key2", "value2") + _ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1) + _ = cache.Set(testutil.CacheTestKey2, "value2") // Verify data exists - _, exists1 := cache.Get("key1") - _, exists2 := cache.Get("key2") + _, exists1 := cache.Get(testutil.CacheTestKey1) + _, exists2 := cache.Get(testutil.CacheTestKey2) if !exists1 || !exists2 { t.Fatal("expected test data to exist before clear") } @@ -305,14 +305,14 @@ func TestCache_Clear(t *testing.T) { testutil.AssertNoError(t, err) // Verify data is gone - _, exists1 = cache.Get("key1") - _, exists2 = cache.Get("key2") + _, exists1 = cache.Get(testutil.CacheTestKey1) + _, exists2 = cache.Get(testutil.CacheTestKey2) if exists1 || exists2 { t.Error("expected data to be cleared") } } -func TestCache_Delete(t *testing.T) { +func TestCacheDelete(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -320,22 +320,22 @@ func TestCache_Delete(t *testing.T) { defer testutil.CleanupCache(t, cache)() // Add some data - _ = cache.Set("key1", "value1") - _ = cache.Set("key2", "value2") + _ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1) + _ = cache.Set(testutil.CacheTestKey2, "value2") _ = cache.Set("key3", "value3") // Verify data exists - _, exists := cache.Get("key1") + _, exists := cache.Get(testutil.CacheTestKey1) if !exists { t.Fatal("expected key1 to exist before delete") } // Delete specific key - cache.Delete("key1") + cache.Delete(testutil.CacheTestKey1) // Verify deleted key is gone but others remain - _, exists1 := cache.Get("key1") - _, exists2 := cache.Get("key2") + _, exists1 := cache.Get(testutil.CacheTestKey1) + _, exists2 := cache.Get(testutil.CacheTestKey2) _, exists3 := cache.Get("key3") if exists1 { @@ -349,7 +349,7 @@ func TestCache_Delete(t *testing.T) { cache.Delete("nonexistent") } -func TestCache_Stats(t *testing.T) { +func TestCacheStats(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -360,8 +360,8 @@ func TestCache_Stats(t *testing.T) { _ = cache.Clear() // Add some data - _ = cache.Set("key1", "value1") - _ = cache.Set("key2", "larger-value-with-more-content") + _ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1) + _ = cache.Set(testutil.CacheTestKey2, "larger-value-with-more-content") stats := cache.Stats() @@ -397,7 +397,7 @@ func TestCache_Stats(t *testing.T) { } } -func TestCache_CleanupExpiredEntries(t *testing.T) { +func TestCacheCleanupExpiredEntries(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -434,7 +434,7 @@ func TestCache_CleanupExpiredEntries(t *testing.T) { } } -func TestCache_ErrorHandling(t *testing.T) { +func TestCacheErrorHandling(t *testing.T) { tests := []struct { name string setupFunc func(t *testing.T) *Cache @@ -472,7 +472,7 @@ func TestCache_ErrorHandling(t *testing.T) { } } -func TestCache_AsyncSaveErrorHandling(t *testing.T) { +func TestCacheAsyncSaveErrorHandling(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -481,7 +481,7 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) { // This tests our new saveToDiskAsync error handling // Set a value to trigger async save - err := cache.Set("test-key", "test-value") + err := cache.Set(testutil.CacheTestKey, testutil.CacheTestValue) testutil.AssertNoError(t, err) // Give some time for async save to complete @@ -490,14 +490,14 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) { // The async save should have completed without panicking // We can't easily test the error logging without capturing logs, // but we can verify the cache still works - value, exists := cache.Get("test-key") + value, exists := cache.Get(testutil.CacheTestKey) if !exists { t.Error("expected value to exist after async save") } - testutil.AssertEqual(t, "test-value", value) + testutil.AssertEqual(t, testutil.CacheTestValue, value) } -func TestCache_EstimateSize(t *testing.T) { +func TestCacheEstimateSize(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() @@ -525,9 +525,9 @@ func TestCache_EstimateSize(t *testing.T) { { name: "struct", value: map[string]any{ - "key1": "value1", - "key2": 42, - "key3": []string{"a", "b", "c"}, + testutil.CacheTestKey1: testutil.CacheTestValue1, + testutil.CacheTestKey2: 42, + "key3": []string{"a", "b", "c"}, }, minSize: 30, maxSize: 200, diff --git a/internal/config.go b/internal/config.go index a5b116c..32bcc4a 100644 --- a/internal/config.go +++ b/internal/config.go @@ -16,7 +16,7 @@ import ( "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal/git" "github.com/ivuorinen/gh-action-readme/internal/validation" - "github.com/ivuorinen/gh-action-readme/templates_embed" + templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed" ) // AppConfig represents the application configuration that can be used at multiple levels. @@ -149,7 +149,7 @@ func resolveTemplatePath(templatePath string) string { } // Check if template is available in embedded filesystem first - if templates_embed.IsEmbeddedTemplateAvailable(templatePath) { + if templatesembed.IsEmbeddedTemplateAvailable(templatePath) { // Return a special marker to indicate this should use embedded templates // The actual template loading will handle embedded vs filesystem return templatePath @@ -233,7 +233,7 @@ func DefaultAppConfig() *AppConfig { // Workflow Requirements Permissions: map[string]string{}, - RunsOn: []string{"ubuntu-latest"}, + RunsOn: []string{appconstants.RunnerUbuntuLatest}, // Features AnalyzeDependencies: false, @@ -317,15 +317,17 @@ func mergeMapFields(dst *AppConfig, src *AppConfig) { } // mergeSliceFields merges slice fields from src to dst if non-empty. +// copySliceIfNotEmpty copies src slice to dst if src is not empty. +func copySliceIfNotEmpty(dst *[]string, src []string) { + if len(src) > 0 { + *dst = make([]string, len(src)) + copy(*dst, src) + } +} + func mergeSliceFields(dst *AppConfig, src *AppConfig) { - if len(src.RunsOn) > 0 { - dst.RunsOn = make([]string, len(src.RunsOn)) - copy(dst.RunsOn, src.RunsOn) - } - if len(src.IgnoredDirectories) > 0 { - dst.IgnoredDirectories = make([]string, len(src.IgnoredDirectories)) - copy(dst.IgnoredDirectories, src.IgnoredDirectories) - } + copySliceIfNotEmpty(&dst.RunsOn, src.RunsOn) + copySliceIfNotEmpty(&dst.IgnoredDirectories, src.IgnoredDirectories) } // mergeBooleanFields merges boolean fields from src to dst if true. @@ -407,6 +409,29 @@ func DetectRepositoryName(repoRoot string) string { return info.GetRepositoryName() } +// loadAndMergeConfig is a helper that loads config from a directory and merges it. +// Returns nil if dir is empty (no-op). Returns error if loading fails. +func loadAndMergeConfig( + config *AppConfig, + dir string, + loadFunc func(string) (*AppConfig, error), + errorFormat string, + allowTokens bool, +) error { + if dir == "" { + return nil + } + + loadedConfig, err := loadFunc(dir) + if err != nil { + return fmt.Errorf(errorFormat, err) + } + + MergeConfigs(config, loadedConfig, allowTokens) + + return nil +} + // LoadConfiguration loads configuration with multi-level hierarchy. func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) { // 1. Start with defaults @@ -428,21 +453,15 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro } // 4. Load repository root ghreadme.yaml - if repoRoot != "" { - repoConfig, err := LoadRepoConfig(repoRoot) - if err != nil { - return nil, fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err) - } - MergeConfigs(config, repoConfig, false) // No tokens in repo config + if err := loadAndMergeConfig(config, repoRoot, LoadRepoConfig, + appconstants.ErrFailedToLoadRepoConfig, false); err != nil { + return nil, err } // 5. Load action-specific config.yaml - if actionDir != "" { - actionConfig, err := LoadActionConfig(actionDir) - if err != nil { - return nil, fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err) - } - MergeConfigs(config, actionConfig, false) // No tokens in action config + if err := loadAndMergeConfig(config, actionDir, LoadActionConfig, + appconstants.ErrFailedToLoadActionConfig, false); err != nil { + return nil, err } // 6. Apply environment variable overrides for GitHub token diff --git a/internal/config_helper_test.go b/internal/config_helper_test.go new file mode 100644 index 0000000..67a3268 --- /dev/null +++ b/internal/config_helper_test.go @@ -0,0 +1,180 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-github/v74/github" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// TestAssertBooleanConfigFields_Helper tests the assertBooleanConfigFields helper. +func TestAssertBooleanConfigFieldsHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got *AppConfig + want *AppConfig + }{ + { + name: "all fields match", + got: &AppConfig{ + AnalyzeDependencies: true, + ShowSecurityInfo: false, + Verbose: true, + Quiet: false, + UseDefaultBranch: true, + }, + want: &AppConfig{ + AnalyzeDependencies: true, + ShowSecurityInfo: false, + Verbose: true, + Quiet: false, + UseDefaultBranch: true, + }, + }, + { + name: "all fields false", + got: &AppConfig{ + AnalyzeDependencies: false, + ShowSecurityInfo: false, + Verbose: false, + Quiet: false, + UseDefaultBranch: false, + }, + want: &AppConfig{ + AnalyzeDependencies: false, + ShowSecurityInfo: false, + Verbose: false, + Quiet: false, + UseDefaultBranch: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Call the helper - it will call t.Error if fields don't match + // For matching cases, it should not error + assertBooleanConfigFields(t, tt.got, tt.want) + }) + } +} + +// TestAssertGitHubClientValid_Helper tests the assertGitHubClientValid helper. +func TestAssertGitHubClientValidHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + client *GitHubClient + expectedToken string + }{ + { + name: "valid client with token", + client: &GitHubClient{ + Client: github.NewClient(nil), + Token: "test-token-123", + }, + expectedToken: "test-token-123", + }, + { + name: "valid client with empty token", + client: &GitHubClient{ + Client: github.NewClient(nil), + Token: "", + }, + expectedToken: "", + }, + { + name: "valid client with github PAT", + client: &GitHubClient{ + Client: github.NewClient(nil), + Token: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD", + }, + expectedToken: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Call the helper - it will verify the client is valid + // For valid clients, it should not error + assertGitHubClientValid(t, tt.client, tt.expectedToken) + }) + } +} + +// TestRunTemplatePathTest_Helper tests the runTemplatePathTest helper. +func TestRunTemplatePathTestHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(*testing.T) (string, func()) + checkFunc func(*testing.T, string) + expectResult string + }{ + { + name: "absolute path setup", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "test.tmpl") + + err := os.WriteFile(templatePath, []byte("test template"), appconstants.FilePermDefault) + if err != nil { + t.Fatalf("failed to write template: %v", err) + } + + return templatePath, func() { /* Cleanup handled by t.TempDir() */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result == "" { + t.Error(testutil.TestMsgExpectedNonEmpty) + } + }, + }, + { + name: "relative path setup", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return "templates/readme.tmpl", func() { /* No cleanup needed for relative path test */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result == "" { + t.Error(testutil.TestMsgExpectedNonEmpty) + } + }, + }, + { + name: "nil checkFunc (just runs setup)", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return "test/path.tmpl", func() { /* No cleanup needed for nil checkFunc test */ } + }, + checkFunc: nil, // No validation + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Call the helper - it runs setup, calls resolveTemplatePath, and validates + runTemplatePathTest(t, tt.setupFunc, tt.checkFunc) + }) + } +} diff --git a/internal/config_test.go b/internal/config_test.go index d7df701..6069945 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -1,7 +1,6 @@ package internal import ( - "os" "path/filepath" "testing" @@ -23,10 +22,10 @@ func TestInitConfig(t *testing.T) { configFile: "", setupFunc: nil, expected: &AppConfig{ - Theme: "default", + Theme: testutil.TestThemeDefault, OutputFormat: "md", OutputDir: ".", - Template: "templates/readme.tmpl", + Template: testutil.TestTemplateWithPrefix, Schema: "schemas/schema.json", Verbose: false, Quiet: false, @@ -35,14 +34,14 @@ func TestInitConfig(t *testing.T) { }, { name: "custom config file", - configFile: "custom-config.yml", + configFile: testutil.TestFileCustomConfig, 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")) + configPath := filepath.Join(tempDir, testutil.TestFileCustomConfig) + testutil.WriteTestFile(t, configPath, testutil.MustReadFixture(testutil.TestFixtureProfessionalConfig)) }, expected: &AppConfig{ - Theme: "professional", + Theme: testutil.TestThemeProfessional, OutputFormat: "html", OutputDir: "docs", Template: "custom-template.tmpl", @@ -54,10 +53,10 @@ func TestInitConfig(t *testing.T) { }, { name: "invalid config file", - configFile: "config.yml", + configFile: testutil.TestPathConfigYML, setupFunc: func(t *testing.T, tempDir string) { t.Helper() - configPath := filepath.Join(tempDir, "config.yml") + configPath := filepath.Join(tempDir, testutil.TestPathConfigYML) testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [") }, expectError: true, @@ -129,42 +128,31 @@ func TestLoadConfiguration(t *testing.T) { t.Setenv(appconstants.EnvGitHubToken, "") // Create global config - globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme") - _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions - globalConfigPath := filepath.Join(globalConfigDir, "config.yaml") - testutil.WriteTestFile(t, globalConfigPath, ` -theme: default -output_format: md -github_token: ghp_test1234567890abcdefghijklmnopqrstuvwxyz -`) + globalConfigDir := filepath.Join(tempDir, testutil.TestDirDotConfig, testutil.TestBinaryName) + globalConfigPath := testutil.WriteFileInDir(t, globalConfigDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigGlobalDefault))) // Create repo root with repo-specific config repoRoot := filepath.Join(tempDir, "repo") - _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), ` -theme: github -output_format: html -`) + testutil.WriteFileInDir(t, repoRoot, testutil.TestFileGHReadmeYAML, + string(testutil.MustReadFixture(testutil.TestConfigRepoSimple))) // Create current directory with action-specific config currentDir := filepath.Join(repoRoot, "action") - _ = os.MkdirAll(currentDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(currentDir, "config.yaml"), ` -theme: professional -output_dir: output -`) + testutil.WriteFileInDir(t, currentDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigActionSimple))) 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, testutil.TestThemeProfessional, config.Theme) testutil.AssertEqual(t, "output", config.OutputDir) // Should inherit from repo level testutil.AssertEqual(t, "html", config.OutputFormat) // Should inherit GitHub token from global config - testutil.AssertEqual(t, "ghp_test1234567890abcdefghijklmnopqrstuvwxyz", config.GitHubToken) + testutil.AssertEqual(t, testutil.TestTokenStd, config.GitHubToken) }, }, { @@ -176,7 +164,7 @@ output_dir: output t.Setenv("GITHUB_TOKEN", "fallback-token") // Create config file - configPath := filepath.Join(tempDir, "config.yml") + configPath := filepath.Join(tempDir, testutil.TestPathConfigYML) testutil.WriteTestFile(t, configPath, ` theme: minimal github_token: config-token @@ -188,7 +176,7 @@ github_token: config-token t.Helper() // Environment variable should override config file testutil.AssertEqual(t, "env-token", config.GitHubToken) - testutil.AssertEqual(t, "minimal", config.Theme) + testutil.AssertEqual(t, testutil.TestThemeMinimal, config.Theme) }, }, { @@ -200,19 +188,15 @@ github_token: config-token t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) // Create XDG-compliant config - configDir := filepath.Join(xdgConfigHome, "gh-action-readme") - _ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions - configPath := filepath.Join(configDir, "config.yaml") - testutil.WriteTestFile(t, configPath, ` -theme: github -verbose: true -`) + configDir := filepath.Join(xdgConfigHome, testutil.TestBinaryName) + configPath := testutil.WriteFileInDir(t, configDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigGitHubVerbose))) return configPath, tempDir, tempDir }, checkFunc: func(t *testing.T, config *AppConfig) { t.Helper() - testutil.AssertEqual(t, "github", config.Theme) + testutil.AssertEqual(t, testutil.TestThemeGitHub, config.Theme) testutil.AssertEqual(t, true, config.Verbose) }, }, @@ -221,30 +205,23 @@ verbose: true 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 // Create multiple hidden config files - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), ` -theme: minimal -output_format: json -`) + testutil.WriteFileInDir(t, repoRoot, testutil.TestFileGHReadmeYAML, + string(testutil.MustReadFixture(testutil.TestConfigMinimalTheme))) - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".config", "ghreadme.yaml"), ` -theme: professional -quiet: true -`) + testutil.WriteTestFile(t, filepath.Join(repoRoot, testutil.TestDirDotConfig, "ghreadme.yaml"), + string(testutil.MustReadFixture(testutil.TestConfigProfessionalQuiet))) - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".github", "ghreadme.yaml"), ` -theme: github -verbose: true -`) + testutil.WriteTestFile(t, filepath.Join(repoRoot, ".github", "ghreadme.yaml"), + string(testutil.MustReadFixture(testutil.TestConfigGitHubVerbose))) 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, testutil.TestThemeMinimal, config.Theme) testutil.AssertEqual(t, "json", config.OutputFormat) }, }, @@ -291,7 +268,7 @@ func TestGetConfigPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tempDir) t.Setenv("HOME", "") }, - contains: "gh-action-readme", + contains: testutil.TestBinaryName, }, { name: "HOME fallback", @@ -300,7 +277,7 @@ func TestGetConfigPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("HOME", tempDir) }, - contains: ".config", + contains: testutil.TestDirDotConfig, }, } @@ -343,7 +320,7 @@ func TestWriteDefaultConfig(t *testing.T) { testutil.AssertNoError(t, err) // Should have default values - testutil.AssertEqual(t, "default", config.Theme) + testutil.AssertEqual(t, testutil.TestThemeDefault, config.Theme) testutil.AssertEqual(t, "md", config.OutputFormat) testutil.AssertEqual(t, ".", config.OutputDir) } @@ -359,35 +336,35 @@ func TestResolveThemeTemplate(t *testing.T) { }{ { name: "default theme", - theme: "default", + theme: testutil.TestThemeDefault, expectError: false, shouldExist: true, - expectedPath: "templates/readme.tmpl", + expectedPath: testutil.TestTemplateWithPrefix, }, { name: "github theme", - theme: "github", + theme: testutil.TestThemeGitHub, expectError: false, shouldExist: true, expectedPath: "templates/themes/github/readme.tmpl", }, { name: "gitlab theme", - theme: "gitlab", + theme: testutil.TestThemeGitLab, expectError: false, shouldExist: true, expectedPath: "templates/themes/gitlab/readme.tmpl", }, { name: "minimal theme", - theme: "minimal", + theme: testutil.TestThemeMinimal, expectError: false, shouldExist: true, expectedPath: "templates/themes/minimal/readme.tmpl", }, { name: "professional theme", - theme: "professional", + theme: testutil.TestThemeProfessional, expectError: false, shouldExist: true, expectedPath: "templates/themes/professional/readme.tmpl", @@ -457,38 +434,33 @@ func TestConfigMerging(t *testing.T) { // Test config merging by creating config files and seeing the result - globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme") - _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(globalConfigDir, "config.yaml"), ` -theme: default -output_format: md -github_token: base-token -verbose: false -`) + globalConfigDir := filepath.Join(tmpDir, testutil.TestDirDotConfig, testutil.TestBinaryName) + testutil.WriteFileInDir(t, globalConfigDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigGlobalBaseToken))) repoRoot := filepath.Join(tmpDir, "repo") - _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), ` -theme: github -output_format: html -verbose: true -`) + testutil.WriteFileInDir(t, repoRoot, testutil.TestFileGHReadmeYAML, + string(testutil.MustReadFixture(testutil.TestConfigRepoVerbose))) // Set HOME and XDG_CONFIG_HOME to temp directory - t.Setenv("HOME", tmpDir) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + testutil.SetupConfigEnvironment(t, tmpDir) // Use the specific config file path instead of relying on XDG discovery - configPath := filepath.Join(tmpDir, ".config", "gh-action-readme", "config.yaml") + configPath := filepath.Join( + tmpDir, + testutil.TestDirDotConfig, + testutil.TestBinaryName, + testutil.TestFileConfigYAML, + ) config, err := LoadConfiguration(configPath, repoRoot, repoRoot) testutil.AssertNoError(t, err) // Should have merged values - testutil.AssertEqual(t, "github", config.Theme) // from repo config - testutil.AssertEqual(t, "html", config.OutputFormat) // from repo config - testutil.AssertEqual(t, true, config.Verbose) // from repo config - testutil.AssertEqual(t, "base-token", config.GitHubToken) // from global config - testutil.AssertEqual(t, "schemas/schema.json", config.Schema) // default value + testutil.AssertEqual(t, testutil.TestThemeGitHub, config.Theme) // from repo config + testutil.AssertEqual(t, "html", config.OutputFormat) // from repo config + testutil.AssertEqual(t, true, config.Verbose) // from repo config + testutil.AssertEqual(t, "base-token", config.GitHubToken) // from global config + testutil.AssertEqual(t, "schemas/schema.json", config.Schema) // default value } // TestGetGitHubToken tests GitHub token resolution with different priority levels. @@ -504,23 +476,23 @@ func TestGetGitHubToken(t *testing.T) { { name: "tool-specific env var has highest priority", toolEnvToken: "tool-token", - stdEnvToken: "std-token", - configToken: "config-token", + stdEnvToken: testutil.TestTokenStd, + configToken: testutil.TestTokenConfig, expectedToken: "tool-token", }, { name: "standard env var when tool env not set", toolEnvToken: "", - stdEnvToken: "std-token", - configToken: "config-token", - expectedToken: "std-token", + stdEnvToken: testutil.TestTokenStd, + configToken: testutil.TestTokenConfig, + expectedToken: testutil.TestTokenStd, }, { name: "config token when env vars not set", toolEnvToken: "", stdEnvToken: "", - configToken: "config-token", - expectedToken: "config-token", + configToken: testutil.TestTokenConfig, + expectedToken: testutil.TestTokenConfig, }, { name: "empty string when nothing set", @@ -533,8 +505,8 @@ func TestGetGitHubToken(t *testing.T) { name: "empty env var does not override config", toolEnvToken: "", stdEnvToken: "", - configToken: "config-token", - expectedToken: "config-token", + configToken: testutil.TestTokenConfig, + expectedToken: testutil.TestTokenConfig, }, } @@ -569,50 +541,34 @@ func TestMergeMapFields(t *testing.T) { src *AppConfig expected *AppConfig }{ - { - name: "merge permissions into empty dst", - dst: &AppConfig{}, - src: &AppConfig{ - Permissions: map[string]string{"read": "read", "write": "write"}, - }, - expected: &AppConfig{ - Permissions: map[string]string{"read": "read", "write": "write"}, - }, - }, - { - name: "merge permissions into existing dst", - dst: &AppConfig{ - Permissions: map[string]string{"read": "existing"}, - }, - src: &AppConfig{ - Permissions: map[string]string{"read": "new", "write": "write"}, - }, - expected: &AppConfig{ - Permissions: map[string]string{"read": "new", "write": "write"}, - }, - }, - { - name: "merge variables into empty dst", - dst: &AppConfig{}, - src: &AppConfig{ - Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"}, - }, - expected: &AppConfig{ - Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"}, - }, - }, - { - name: "merge variables into existing dst", - dst: &AppConfig{ - Variables: map[string]string{"VAR1": "existing"}, - }, - src: &AppConfig{ - Variables: map[string]string{"VAR1": "new", "VAR2": "value2"}, - }, - expected: &AppConfig{ - Variables: map[string]string{"VAR1": "new", "VAR2": "value2"}, - }, - }, + createMapMergeTest( + "merge permissions into empty dst", + nil, + map[string]string{"read": "read", "write": "write"}, + map[string]string{"read": "read", "write": "write"}, + true, + ), + createMapMergeTest( + "merge permissions into existing dst", + map[string]string{"read": "existing"}, + map[string]string{"read": "new", "write": "write"}, + map[string]string{"read": "new", "write": "write"}, + true, + ), + createMapMergeTest( + "merge variables into empty dst", + nil, + map[string]string{"VAR1": "value1", "VAR2": "value2"}, + map[string]string{"VAR1": "value1", "VAR2": "value2"}, + false, + ), + createMapMergeTest( + "merge variables into existing dst", + map[string]string{"VAR1": "existing"}, + map[string]string{"VAR1": "new", "VAR2": "value2"}, + map[string]string{"VAR1": "new", "VAR2": "value2"}, + false, + ), { name: "merge both permissions and variables", dst: &AppConfig{ @@ -679,26 +635,26 @@ func TestMergeSliceFields(t *testing.T) { { name: "merge runsOn into empty dst", dst: &AppConfig{}, - src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}}, - expected: []string{"ubuntu-latest", "windows-latest"}, + src: &AppConfig{RunsOn: []string{testutil.RunnerUbuntuLatest, testutil.RunnerWindowsLatest}}, + expected: []string{testutil.RunnerUbuntuLatest, testutil.RunnerWindowsLatest}, }, { name: "merge runsOn replaces existing dst", dst: &AppConfig{RunsOn: []string{"macos-latest"}}, - src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}}, - expected: []string{"ubuntu-latest", "windows-latest"}, + src: &AppConfig{RunsOn: []string{testutil.RunnerUbuntuLatest, testutil.RunnerWindowsLatest}}, + expected: []string{testutil.RunnerUbuntuLatest, testutil.RunnerWindowsLatest}, }, { name: "empty src does not affect dst", - dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}}, + dst: &AppConfig{RunsOn: []string{testutil.RunnerUbuntuLatest}}, src: &AppConfig{}, - expected: []string{"ubuntu-latest"}, + expected: []string{testutil.RunnerUbuntuLatest}, }, { name: "empty src slice does not affect dst", - dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}}, + dst: &AppConfig{RunsOn: []string{testutil.RunnerUbuntuLatest}}, src: &AppConfig{RunsOn: []string{}}, - expected: []string{"ubuntu-latest"}, + expected: []string{testutil.RunnerUbuntuLatest}, }, { name: "single item slice", @@ -729,3 +685,702 @@ func TestMergeSliceFields(t *testing.T) { }) } } + +// assertBooleanConfigFields is a helper that checks all boolean fields in AppConfig. +func assertBooleanConfigFields(t *testing.T, got, want *AppConfig) { + t.Helper() + + fields := []struct { + name string + gotVal bool + wantVal bool + }{ + {"AnalyzeDependencies", got.AnalyzeDependencies, want.AnalyzeDependencies}, + {"ShowSecurityInfo", got.ShowSecurityInfo, want.ShowSecurityInfo}, + {"Verbose", got.Verbose, want.Verbose}, + {"Quiet", got.Quiet, want.Quiet}, + {"UseDefaultBranch", got.UseDefaultBranch, want.UseDefaultBranch}, + } + + for _, field := range fields { + if field.gotVal != field.wantVal { + t.Errorf("%s = %v, want %v", field.name, field.gotVal, field.wantVal) + } + } +} + +// TestMergeBooleanFields tests merging boolean configuration fields. +func TestMergeBooleanFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dst *AppConfig + src *AppConfig + want *AppConfig + }{ + createBoolFieldMergeTest( + "merge all true values", + boolFields{false, false, false, false, false}, + boolFields{true, true, true, true, true}, + boolFields{true, true, true, true, true}, + ), + createBoolFieldMergeTest( + "merge only some true values", + boolFields{false, true, false, true, false}, + boolFields{true, false, true, false, false}, + boolFields{true, true, true, true, false}, + ), + createBoolFieldMergeTest( + "merge with all source false", + boolFields{true, true, true, true, true}, + boolFields{false, false, false, false, false}, + boolFields{true, true, true, true, true}, + ), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mergeBooleanFields(tt.dst, tt.src) + + assertBooleanConfigFields(t, tt.dst, tt.want) + }) + } +} + +// TestMergeSecurityFields tests merging security-sensitive configuration fields. +func TestMergeSecurityFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dst *AppConfig + src *AppConfig + allowTokens bool + want *AppConfig + }{ + createTokenMergeTest("allow tokens - merge token", "", "ghp_test_token", "ghp_test_token", true), + createTokenMergeTest("disallow tokens - do not merge token", "", "ghp_test_token", "", false), + createTokenMergeTest( + "allow tokens - do not overwrite with empty", + "ghp_existing_token", + "", + "ghp_existing_token", + true, + ), + createTokenMergeTest( + "allow tokens - overwrite existing token", + "ghp_old_token", + "ghp_new_token", + "ghp_new_token", + true, + ), + { + name: "allow tokens - merge repo overrides into nil dst", + dst: &AppConfig{ + RepoOverrides: nil, + }, + src: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.OrgRepo: {Organization: testutil.OrgName, Repository: testutil.RepoName}, + }, + }, + allowTokens: true, + want: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.OrgRepo: {Organization: testutil.OrgName, Repository: testutil.RepoName}, + }, + }, + }, + { + name: "allow tokens - merge repo overrides into existing dst", + dst: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.ExistingRepo: {Organization: testutil.ExistingOrgName, Repository: testutil.RepoName}, + }, + }, + src: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.NewRepo: {Organization: testutil.NewOrgName, Repository: testutil.RepoName}, + }, + }, + allowTokens: true, + want: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.ExistingRepo: {Organization: testutil.ExistingOrgName, Repository: testutil.RepoName}, + testutil.NewRepo: {Organization: testutil.NewOrgName, Repository: testutil.RepoName}, + }, + }, + }, + { + name: "disallow tokens - do not merge repo overrides", + dst: &AppConfig{ + RepoOverrides: nil, + }, + src: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.OrgRepo: {Organization: testutil.OrgName, Repository: testutil.RepoName}, + }, + }, + allowTokens: false, + want: &AppConfig{ + RepoOverrides: nil, + }, + }, + { + name: "allow tokens - empty source repo overrides", + dst: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.ExistingRepo: {Organization: testutil.ExistingOrgName, Repository: testutil.RepoName}, + }, + }, + src: &AppConfig{ + RepoOverrides: map[string]AppConfig{}, + }, + allowTokens: true, + want: &AppConfig{ + RepoOverrides: map[string]AppConfig{ + testutil.ExistingRepo: {Organization: testutil.ExistingOrgName, Repository: testutil.RepoName}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mergeSecurityFields(tt.dst, tt.src, tt.allowTokens) + + if tt.dst.GitHubToken != tt.want.GitHubToken { + t.Errorf("GitHubToken = %q, want %q", + tt.dst.GitHubToken, tt.want.GitHubToken) + } + + assertRepoOverrides(t, tt.dst.RepoOverrides, tt.want.RepoOverrides) + }) + } +} + +// assertRepoOverrides validates that RepoOverrides match expectations. +func assertRepoOverrides(t *testing.T, got, want map[string]AppConfig) { + t.Helper() + + if want == nil { + if got != nil { + t.Errorf("RepoOverrides = %v, want nil", got) + } + + return + } + + if got == nil { + t.Error("RepoOverrides is nil, want non-nil") + + return + } + + for key, wantVal := range want { + gotVal, exists := got[key] + if !exists { + t.Errorf("RepoOverrides missing key %q", key) + } else if gotVal.Organization != wantVal.Organization || + gotVal.Repository != wantVal.Repository { + t.Errorf("RepoOverrides[%q] = %+v, want %+v", + key, gotVal, wantVal) + } + } + + if len(got) != len(want) { + t.Errorf("RepoOverrides length = %d, want %d", len(got), len(want)) + } +} + +// assertGitHubClientValid checks that a GitHub client is properly initialized. +func assertGitHubClientValid(t *testing.T, client *GitHubClient, expectedToken string) { + t.Helper() + if client == nil { + t.Error("expected non-nil client") + + return + } + if client.Client == nil { + t.Error("expected non-nil GitHub client") + } + if client.Token != expectedToken { + t.Errorf("expected token %q, got %q", expectedToken, client.Token) + } +} + +// TestNewGitHubClient_EdgeCases tests GitHub client initialization edge cases. +func TestNewGitHubClientEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + expectError bool + description string + }{ + { + name: "valid classic GitHub token", + token: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD", + expectError: false, + description: "Should create client with valid classic token", + }, + { + name: "valid fine-grained PAT", + token: "github_pat_11AAAAAA0AAAAaAaaAaaaAaa_AaAAaAAaAAAaAAAAAaAAaAAaAaAAaAAAAaAAAAAAAAaAAaAAaAaaAA", + expectError: false, + description: "Should create client with fine-grained token", + }, + { + name: "empty token", + token: "", + expectError: false, + description: "Should create client without authentication", + }, + { + name: "short token", + token: "ghp_short", + expectError: false, + description: "Should create client even with unusual token format", + }, + { + name: "token with special characters", + token: "test-token_123", + expectError: false, + description: "Should handle tokens with various characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := NewGitHubClient(tt.token) + + if tt.expectError { + testutil.AssertError(t, err) + + return + } + + testutil.AssertNoError(t, err) + assertGitHubClientValid(t, client, tt.token) + }) + } +} + +// runTemplatePathTest runs a template path test with setup and validation. +func runTemplatePathTest( + t *testing.T, + setupFunc func(*testing.T) (string, func()), + checkFunc func(*testing.T, string), +) { + t.Helper() + templatePath, cleanup := setupFunc(t) + defer cleanup() + result := resolveTemplatePath(templatePath) + if checkFunc != nil { + checkFunc(t, result) + } +} + +// TestResolveTemplatePath_EdgeCases tests template path resolution edge cases. +func TestResolveTemplatePathEdgeCases(t *testing.T) { + // Note: Cannot use t.Parallel() because one subtest uses t.Chdir() + + tests := []struct { + name string + setupFunc func(t *testing.T) (templatePath string, cleanup func()) + checkFunc func(t *testing.T, result string) + description string + }{ + { + name: "absolute path - return as-is", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + tmpDir, cleanup := testutil.TempDir(t) + absPath := filepath.Join(tmpDir, "template.tmpl") + testutil.WriteTestFile(t, absPath, "test template") + + return absPath, cleanup + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if !filepath.IsAbs(result) { + t.Errorf("expected absolute path, got: %s", result) + } + }, + description: "Absolute paths should be returned unchanged", + }, + { + name: "embedded template - available", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + // Use a path we know is embedded + return testutil.TestTemplateReadme, func() { /* No cleanup needed for embedded templates */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result != testutil.TestTemplateReadme { + t.Errorf("expected %q, got: %s", testutil.TestTemplateReadme, result) + } + }, + description: "Embedded templates should return original path", + }, + { + name: "embedded template with templates/ prefix", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return testutil.TestTemplateWithPrefix, func() { /* No cleanup needed for embedded templates */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result != testutil.TestTemplateWithPrefix { + t.Errorf("expected %q, got: %s", testutil.TestTemplateWithPrefix, result) + } + }, + description: "Embedded templates with prefix should return original path", + }, + { + name: "filesystem template - exists in current dir", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + tmpDir, cleanup := testutil.TempDir(t) + // Create template in current directory + templateName := "custom-template.tmpl" + templatePath := filepath.Join(tmpDir, templateName) + testutil.WriteTestFile(t, templatePath, "custom template") + + // Change to tmpDir + t.Chdir(tmpDir) + + return templateName, cleanup + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result == "" { + t.Error(testutil.TestMsgExpectedNonEmpty) + } + }, + description: "Templates in current directory should be found", + }, + { + name: "non-existent template - fallback to original path", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return "nonexistent-template.tmpl", func() { /* No cleanup needed for non-existent template test */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + if result != "nonexistent-template.tmpl" { + t.Errorf("expected original path, got: %s", result) + } + }, + description: "Non-existent templates should return original path", + }, + { + name: "empty path", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return "", func() { /* No cleanup needed for empty path test */ } + }, + checkFunc: func(t *testing.T, _ string) { + t.Helper() + // Empty path may return binary directory or empty string + // depending on whether GetBinaryDir succeeds + // Just verify it doesn't crash + }, + description: "Empty path should not crash", + }, + { + name: "relative path with subdirectory", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + + return "themes/github/readme.tmpl", func() { /* No cleanup needed for relative path test */ } + }, + checkFunc: func(t *testing.T, result string) { + t.Helper() + // Should return the path (either embedded or fallback) + if result == "" { + t.Error(testutil.TestMsgExpectedNonEmpty) + } + }, + description: "Relative paths with subdirectories should be resolved", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Cannot use t.Parallel() because one subtest uses t.Chdir() + runTemplatePathTest(t, tt.setupFunc, tt.checkFunc) + }) + } +} + +// TestDetectRepositoryName_EdgeCases tests repository name detection edge cases. +func TestDetectRepositoryNameEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T) string + expectedResult string + description string + }{ + { + name: "empty repo root", + setupFunc: func(t *testing.T) string { + t.Helper() + + return "" + }, + expectedResult: "", + description: "Empty repo root should return empty string", + }, + { + name: "non-existent directory", + setupFunc: func(t *testing.T) string { + t.Helper() + + return "/nonexistent/path/to/repo" + }, + expectedResult: "", + description: "Non-existent directory should return empty string", + }, + { + name: "directory without git", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + + return tmpDir + }, + expectedResult: "", + description: "Directory without .git should return empty string", + }, + createGitRemoteTestCase( + "valid git repository with GitHub remote", + `[remote "origin"] + url = https://github.com/testorg/testrepo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + "testorg/testrepo", + "Valid GitHub repo should return org/repo", + ), + createGitRemoteTestCase( + "git repository with SSH remote", + `[remote "origin"] + url = git@github.com:sshorg/sshrepo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + "sshorg/sshrepo", + "SSH remote should be parsed correctly", + ), + createGitRemoteTestCase( + "git repository without remote", + "", // No config content + "", + "Repository without remote should return empty string", + ), + createGitRemoteTestCase( + "git repository with non-GitHub remote", + `[remote "origin"] + url = https://gitlab.com/glorg/glrepo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + "", + "Non-GitHub remote should return empty string", + ), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repoRoot := tt.setupFunc(t) + result := DetectRepositoryName(repoRoot) + + if result != tt.expectedResult { + t.Errorf("DetectRepositoryName() = %q, want %q (test: %s)", + result, tt.expectedResult, tt.description) + } + }) + } +} + +// TestLoadConfiguration_EdgeCases tests configuration loading edge cases. +func TestLoadConfigurationEdgeCases(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) (configFile, repoRoot, currentDir string) + expectError bool + checkFunc func(t *testing.T, config *AppConfig) + description string + }{ + { + name: "empty config file path with defaults", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + testutil.SetupConfigEnvironment(t, tmpDir) + + return "", tmpDir, tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + if config == nil { + t.Fatal(testutil.TestMsgExpectedNonNilConfig) + } + // Should have default values + if config.Theme == "" { + t.Error("expected non-empty theme (default)") + } + }, + description: "Empty config file should load defaults", + }, + { + name: "all paths empty", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + t.Setenv("HOME", tmpDir) + + return "", "", "" + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + if config == nil { + t.Fatal(testutil.TestMsgExpectedNonNilConfig) + } + }, + description: "All empty paths should still return config", + }, + { + name: "config file with minimal values", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, testutil.TestFileConfigYAML) + testutil.WriteTestFile(t, configPath, "theme: minimal\n") + + return configPath, tmpDir, tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestThemeMinimal, config.Theme) + }, + description: "Minimal config should merge with defaults", + }, + { + name: "invalid config file path", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + + return filepath.Join(tmpDir, "nonexistent.yaml"), tmpDir, tmpDir + }, + expectError: true, + description: "Invalid config file path should error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configFile, repoRoot, currentDir := tt.setupFunc(t) + + config, err := LoadConfiguration(configFile, repoRoot, currentDir) + + if tt.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + + if tt.checkFunc != nil { + tt.checkFunc(t, config) + } + } + }) + } +} + +// TestInitConfig_EdgeCases tests config initialization edge cases. +func TestInitConfigEdgeCases(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) string + expectError bool + checkFunc func(t *testing.T, config *AppConfig) + description string + }{ + { + name: "empty config file path - use default", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + testutil.SetupConfigEnvironment(t, tmpDir) + + return "" + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + if config == nil { + t.Fatal(testutil.TestMsgExpectedNonNilConfig) + } + // Should have default values + testutil.AssertEqual(t, testutil.TestThemeDefault, config.Theme) + }, + description: "Empty path should use default config", + }, + { + name: "config file with empty values", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, "empty.yaml") + testutil.WriteTestFile(t, configPath, "---\n") + + return configPath + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + // Should still have default values filled in + if config.Theme == "" { + t.Error("expected non-empty theme from defaults") + } + }, + description: "Empty config should be filled with defaults", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := tt.setupFunc(t) + + config, err := InitConfig(configPath) + + if tt.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + + if tt.checkFunc != nil { + tt.checkFunc(t, config) + } + } + }) + } +} diff --git a/internal/config_test_helper.go b/internal/config_test_helper.go new file mode 100644 index 0000000..e73036e --- /dev/null +++ b/internal/config_test_helper.go @@ -0,0 +1,157 @@ +package internal + +import ( + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// boolFields represents the boolean configuration fields used in merge tests. +type boolFields struct { + AnalyzeDependencies bool + ShowSecurityInfo bool + Verbose bool + Quiet bool + UseDefaultBranch bool +} + +// createBoolFieldMergeTest creates a test table entry for testing boolean field merging. +// This helper reduces duplication by standardizing the creation of AppConfig test structures +// with boolean fields. +func createBoolFieldMergeTest(name string, dst, src, want boolFields) struct { + name string + dst *AppConfig + src *AppConfig + want *AppConfig +} { + return struct { + name string + dst *AppConfig + src *AppConfig + want *AppConfig + }{ + name: name, + dst: &AppConfig{ + AnalyzeDependencies: dst.AnalyzeDependencies, + ShowSecurityInfo: dst.ShowSecurityInfo, + Verbose: dst.Verbose, + Quiet: dst.Quiet, + UseDefaultBranch: dst.UseDefaultBranch, + }, + src: &AppConfig{ + AnalyzeDependencies: src.AnalyzeDependencies, + ShowSecurityInfo: src.ShowSecurityInfo, + Verbose: src.Verbose, + Quiet: src.Quiet, + UseDefaultBranch: src.UseDefaultBranch, + }, + want: &AppConfig{ + AnalyzeDependencies: want.AnalyzeDependencies, + ShowSecurityInfo: want.ShowSecurityInfo, + Verbose: want.Verbose, + Quiet: want.Quiet, + UseDefaultBranch: want.UseDefaultBranch, + }, + } +} + +// createGitRemoteTestCase creates a test table entry for git remote detection tests. +// This helper reduces duplication for tests that set up a git repo with a remote config. +func createGitRemoteTestCase( + name, configContent, expectedResult, description string, +) struct { + name string + setupFunc func(t *testing.T) string + expectedResult string + description string +} { + return struct { + name string + setupFunc func(t *testing.T) string + expectedResult string + description string + }{ + name: name, + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + testutil.InitGitRepo(t, tmpDir) + + if configContent != "" { + configPath := filepath.Join(tmpDir, ".git", "config") + testutil.WriteTestFile(t, configPath, configContent) + } + + return tmpDir + }, + expectedResult: expectedResult, + description: description, + } +} + +// createTokenMergeTest creates a test table entry for testing token merging behavior. +// This helper reduces duplication for the 4 token merge test cases. +func createTokenMergeTest( + name, dstToken, srcToken, wantToken string, + allowTokens bool, +) struct { + name string + dst *AppConfig + src *AppConfig + allowTokens bool + want *AppConfig +} { + return struct { + name string + dst *AppConfig + src *AppConfig + allowTokens bool + want *AppConfig + }{ + name: name, + dst: &AppConfig{GitHubToken: dstToken}, + src: &AppConfig{GitHubToken: srcToken}, + allowTokens: allowTokens, + want: &AppConfig{GitHubToken: wantToken}, + } +} + +// createMapMergeTest creates a test table entry for testing map field merging (permissions/variables). +// This helper reduces duplication for tests that merge map[string]string fields. +func createMapMergeTest( + name string, + dstMap, srcMap, expectedMap map[string]string, + isPermissions bool, +) struct { + name string + dst *AppConfig + src *AppConfig + expected *AppConfig +} { + dst := &AppConfig{} + src := &AppConfig{} + expected := &AppConfig{} + + if isPermissions { + dst.Permissions = dstMap + src.Permissions = srcMap + expected.Permissions = expectedMap + } else { + dst.Variables = dstMap + src.Variables = srcMap + expected.Variables = expectedMap + } + + return struct { + name string + dst *AppConfig + src *AppConfig + expected *AppConfig + }{ + name: name, + dst: dst, + src: src, + expected: expected, + } +} diff --git a/internal/config_test_helpers.go b/internal/config_test_helpers.go new file mode 100644 index 0000000..deaaee4 --- /dev/null +++ b/internal/config_test_helpers.go @@ -0,0 +1,37 @@ +package internal + +import ( + "testing" + + "github.com/google/go-github/v74/github" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// assertGitHubClient validates GitHub client creation results. +// This helper reduces cognitive complexity in config tests by centralizing +// the client validation logic that was repeated across test cases. +// +//nolint:unused // Prepared for future use in config tests +func assertGitHubClient(t *testing.T, client *github.Client, err error, expectError bool) { + t.Helper() + + if expectError { + if err == nil { + t.Error(testutil.TestErrNoErrorGotNone) + } + if client != nil { + t.Error("expected nil client on error") + } + + return + } + + // Success case + if err != nil { + t.Errorf(testutil.TestErrUnexpected, err) + } + if client == nil { + t.Error("expected non-nil client") + } +} diff --git a/internal/configuration_loader.go b/internal/configuration_loader.go index 69e9add..adcf818 100644 --- a/internal/configuration_loader.go +++ b/internal/configuration_loader.go @@ -105,7 +105,7 @@ func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error { } // Validate output format - validFormats := []string{"md", "html", "json", "asciidoc"} + validFormats := appconstants.GetSupportedOutputFormats() if !containsString(validFormats, config.OutputFormat) { return fmt.Errorf("invalid output format '%s', must be one of: %s", config.OutputFormat, strings.Join(validFormats, ", ")) @@ -196,34 +196,50 @@ func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot cl.applyRepoOverrides(config, repoRoot) } -// loadRepoConfigStep loads repository root configuration. -func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error { - if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" { +// loadConfigStep is a generic helper for loading and merging configuration from a specific source. +func (cl *ConfigurationLoader) loadConfigStep( + config *AppConfig, + sourceName appconstants.ConfigurationSource, + dirPath string, + loadFunc func(string) (*AppConfig, error), + errorFormat string, + mergeTokens bool, +) error { + if !cl.sources[sourceName] || dirPath == "" { return nil } - repoConfig, err := cl.loadRepoConfig(repoRoot) + loadedConfig, err := loadFunc(dirPath) if err != nil { - return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err) + return fmt.Errorf(errorFormat, err) } - cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config + cl.mergeConfigs(config, loadedConfig, mergeTokens) return nil } +// loadRepoConfigStep loads repository root configuration. +func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error { + return cl.loadConfigStep( + config, + appconstants.SourceRepoConfig, + repoRoot, + cl.loadRepoConfig, + appconstants.ErrFailedToLoadRepoConfig, + false, // No tokens in repo config + ) +} + // loadActionConfigStep loads action-specific configuration. func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error { - if !cl.sources[appconstants.SourceActionConfig] || actionDir == "" { - return nil - } - - actionConfig, err := cl.loadActionConfig(actionDir) - if err != nil { - return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err) - } - cl.mergeConfigs(config, actionConfig, false) // No tokens in action config - - return nil + return cl.loadConfigStep( + config, + appconstants.SourceActionConfig, + actionDir, + cl.loadActionConfig, + appconstants.ErrFailedToLoadActionConfig, + false, // No tokens in action config + ) } // loadEnvironmentStep applies environment variable overrides. diff --git a/internal/configuration_loader_test.go b/internal/configuration_loader_test.go index 60bddf0..f9bfcd5 100644 --- a/internal/configuration_loader_test.go +++ b/internal/configuration_loader_test.go @@ -1,7 +1,6 @@ package internal import ( - "os" "path/filepath" "testing" @@ -11,74 +10,90 @@ import ( func TestNewConfigurationLoader(t *testing.T) { t.Parallel() + loader := NewConfigurationLoader() if loader == nil { t.Fatal("expected non-nil loader") } - if loader.viper == nil { - t.Fatal("expected viper instance to be initialized") + sources := loader.GetConfigurationSources() + if len(sources) == 0 { + t.Error("expected non-empty configuration sources") } - // Check default sources are enabled + // Verify all sources are enabled by default expectedSources := []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride, - appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment, + appconstants.SourceDefaults, + appconstants.SourceGlobal, + appconstants.SourceRepoOverride, + appconstants.SourceRepoConfig, + appconstants.SourceActionConfig, + appconstants.SourceEnvironment, } for _, source := range expectedSources { - if !loader.sources[source] { - t.Errorf("expected source %s to be enabled by default", source.String()) - } - } + found := false + for _, s := range sources { + if s == source { + found = true - // CLI flags should be disabled by default - if loader.sources[appconstants.SourceCLIFlags] { - t.Error("expected CLI flags source to be disabled by default") + break + } + } + if !found { + t.Errorf("expected source %s to be enabled by default", source) + } } } func TestNewConfigurationLoaderWithOptions(t *testing.T) { t.Parallel() + tests := []struct { - name string - opts ConfigurationOptions - expected []appconstants.ConfigurationSource + name string + opts ConfigurationOptions + check func(t *testing.T, loader *ConfigurationLoader) }{ { - name: "default options", - opts: ConfigurationOptions{}, - expected: []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride, - appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment, + name: "custom config file", + opts: ConfigurationOptions{ + ConfigFile: "/tmp/custom-config.yaml", + AllowTokens: true, + }, + check: func(t *testing.T, loader *ConfigurationLoader) { + t.Helper() + if loader == nil { + t.Fatal("expected non-nil loader") + } }, }, { - name: "custom enabled sources", + name: "disabled sources", opts: ConfigurationOptions{ EnabledSources: []appconstants.ConfigurationSource{ appconstants.SourceDefaults, - appconstants.SourceGlobal, }, }, - expected: []appconstants.ConfigurationSource{appconstants.SourceDefaults, appconstants.SourceGlobal}, + check: func(t *testing.T, loader *ConfigurationLoader) { + t.Helper() + sources := loader.GetConfigurationSources() + if len(sources) != 1 { + t.Errorf("expected 1 source, got %d", len(sources)) + } + }, }, { - name: "all sources enabled", + name: "empty enabled sources - all enabled", opts: ConfigurationOptions{ - EnabledSources: []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, appconstants.SourceGlobal, - appconstants.SourceRepoOverride, appconstants.SourceRepoConfig, - appconstants.SourceActionConfig, appconstants.SourceEnvironment, - appconstants.SourceCLIFlags, - }, + EnabledSources: []appconstants.ConfigurationSource{}, }, - expected: []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, appconstants.SourceGlobal, - appconstants.SourceRepoOverride, appconstants.SourceRepoConfig, - appconstants.SourceActionConfig, appconstants.SourceEnvironment, - appconstants.SourceCLIFlags, + check: func(t *testing.T, loader *ConfigurationLoader) { + t.Helper() + sources := loader.GetConfigurationSources() + if len(sources) < 2 { + t.Errorf("expected all sources enabled, got %d", len(sources)) + } }, }, } @@ -86,377 +101,295 @@ 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 { - if !loader.sources[expectedSource] { - t.Errorf("expected source %s to be enabled", expectedSource.String()) - } - } - - // Check that non-expected sources are disabled - allSources := []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, appconstants.SourceGlobal, - appconstants.SourceRepoOverride, appconstants.SourceRepoConfig, - appconstants.SourceActionConfig, appconstants.SourceEnvironment, - appconstants.SourceCLIFlags, - } - - for _, source := range allSources { - expected := false - for _, expectedSource := range tt.expected { - if source == expectedSource { - expected = true - - break - } - } - - if loader.sources[source] != expected { - t.Errorf("source %s enabled=%v, expected=%v", source.String(), loader.sources[source], expected) - } + if tt.check != nil { + tt.check(t, loader) } }) } } -func TestConfigurationLoader_LoadConfiguration(t *testing.T) { +func TestConfigurationLoaderLoadConfiguration(t *testing.T) { + // Note: Cannot use t.Parallel() because subtests use t.Setenv() + tests := []struct { name string - setupFunc func(t *testing.T, tempDir string) (configFile, repoRoot, actionDir string) + setupFunc func(t *testing.T) (configFile, repoRoot, actionDir string) expectError bool checkFunc func(t *testing.T, config *AppConfig) + description string }{ { - name: "defaults only", - setupFunc: func(_ *testing.T, _ string) (string, string, string) { + name: "all empty paths - use defaults", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + testutil.SetupConfigEnvironment(t, tmpDir) + return "", "", "" }, - checkFunc: func(_ *testing.T, config *AppConfig) { - testutil.AssertEqual(t, "default", config.Theme) - testutil.AssertEqual(t, "md", config.OutputFormat) - testutil.AssertEqual(t, ".", config.OutputDir) + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + if config == nil { + t.Fatal(testutil.TestMsgExpectedNonNilConfig) + } + if config.Theme == "" { + t.Error("expected default theme") + } }, + description: "Should load defaults when all paths empty", }, { - name: "multi-level configuration hierarchy", - setupFunc: func(_ *testing.T, tempDir string) (string, string, string) { - // Create global config - globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme") - _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions - globalConfigPath := filepath.Join(globalConfigDir, "config.yaml") - testutil.WriteTestFile(t, globalConfigPath, ` -theme: default -output_format: md -github_token: ghp_test1234567890abcdefghijklmnopqrstuvwxyz -verbose: false -`) - - // Create repo root with repo-specific config - repoRoot := filepath.Join(tempDir, "repo") - _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), ` + name: "global config only", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, testutil.TestFileConfigYAML) + testutil.WriteTestFile(t, configPath, ` theme: github output_format: html -verbose: true `) - // Create action directory with action-specific config - actionDir := filepath.Join(repoRoot, "action") - _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(actionDir, "config.yaml"), ` -theme: professional -output_dir: output -quiet: false -`) - - return globalConfigPath, repoRoot, actionDir - }, - checkFunc: func(_ *testing.T, config *AppConfig) { - // Should have action-level overrides - testutil.AssertEqual(t, "professional", config.Theme) - testutil.AssertEqual(t, "output", config.OutputDir) - // Should inherit from repo level - testutil.AssertEqual(t, "html", config.OutputFormat) - testutil.AssertEqual(t, true, config.Verbose) - // Should inherit GitHub token from global config - testutil.AssertEqual(t, "ghp_test1234567890abcdefghijklmnopqrstuvwxyz", config.GitHubToken) + return configPath, "", "" }, + expectError: false, + checkFunc: checkThemeAndFormat(testutil.TestThemeGitHub, "html"), + description: "Should load global config only", }, { - name: "environment variable overrides", - setupFunc: func(t *testing.T, tempDir string) (string, string, string) { + name: "repo config overrides global", + setupFunc: func(t *testing.T) (string, string, string) { t.Helper() - // Set environment variables - t.Setenv("GH_README_GITHUB_TOKEN", "env-token") + tmpDir, _ := testutil.TempDir(t) - // Create config file with different token - configPath := filepath.Join(tempDir, "config.yml") - testutil.WriteTestFile(t, configPath, ` -theme: minimal -github_token: config-token + // Global config + globalPath := filepath.Join(tmpDir, "global.yaml") + testutil.WriteTestFile(t, globalPath, ` +theme: default +output_format: md `) - return configPath, tempDir, "" - }, - checkFunc: func(_ *testing.T, config *AppConfig) { - // Environment variable should override config file - testutil.AssertEqual(t, "env-token", config.GitHubToken) - testutil.AssertEqual(t, "minimal", config.Theme) + // Repo config + repoRoot := filepath.Join(tmpDir, "repo") + testutil.WriteFileInDir(t, repoRoot, ".ghreadme.yaml", + string(testutil.MustReadFixture(testutil.TestConfigMinimalSimple))) + + return globalPath, repoRoot, "" }, + expectError: false, + checkFunc: checkThemeAndFormat(testutil.TestThemeMinimal, "md"), + description: "Repo config should override global", }, { - name: "hidden config file priority", - setupFunc: func(_ *testing.T, tempDir string) (string, string, string) { - repoRoot := filepath.Join(tempDir, "repo") - _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions + name: "action config has highest priority", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) - // Create multiple hidden config files - first one should win - testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), ` -theme: minimal -output_format: json + // Global config + globalPath := filepath.Join(tmpDir, "global.yaml") + testutil.WriteTestFile(t, globalPath, ` +theme: default +output_format: md `) - configDir := filepath.Join(repoRoot, ".config") - _ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(configDir, "ghreadme.yaml"), ` -theme: professional -quiet: true -`) + // Repo config + repoRoot := filepath.Join(tmpDir, "repo") + testutil.WriteFileInDir(t, repoRoot, ".ghreadme.yaml", + string(testutil.MustReadFixture(testutil.TestConfigMinimalSimple))) - githubDir := filepath.Join(repoRoot, ".github") - _ = os.MkdirAll(githubDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(githubDir, "ghreadme.yaml"), ` -theme: github -verbose: true -`) + // Action config + actionDir := filepath.Join(repoRoot, "action") + testutil.WriteFileInDir(t, actionDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigProfessionalSimple))) - return "", repoRoot, "" + return globalPath, repoRoot, actionDir }, - checkFunc: func(_ *testing.T, config *AppConfig) { - // Should use the first found config (.ghreadme.yaml has priority) - testutil.AssertEqual(t, "minimal", config.Theme) - testutil.AssertEqual(t, "json", config.OutputFormat) + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestThemeProfessional, config.Theme) }, + description: "Action config should have highest priority", }, { - name: "selective source loading", - setupFunc: func(_ *testing.T, _ string) (string, string, string) { - // This test uses a loader with specific sources enabled - return "", "", "" - }, - checkFunc: func(_ *testing.T, _ *AppConfig) { - // This will be tested with a custom loader + name: "invalid global config file", + setupFunc: func(t *testing.T) (string, string, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, "bad.yaml") + testutil.WriteTestFile(t, configPath, `{invalid yaml: [[`) + + return configPath, "", "" }, + expectError: true, + description: "Should error on invalid global config", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - - // Set HOME to temp directory for fallback - t.Setenv("HOME", tmpDir) - - configFile, repoRoot, actionDir := tt.setupFunc(t, tmpDir) - - // Special handling for selective source loading test - var loader *ConfigurationLoader - if tt.name == "selective source loading" { - // Create loader with only defaults and global sources - loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{ - EnabledSources: []appconstants.ConfigurationSource{ - appconstants.SourceDefaults, - appconstants.SourceGlobal, - }, - }) - } else { - loader = NewConfigurationLoader() - } + configFile, repoRoot, actionDir := tt.setupFunc(t) + loader := NewConfigurationLoader() config, err := loader.LoadConfiguration(configFile, repoRoot, actionDir) if tt.expectError { testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) - return - } - - testutil.AssertNoError(t, err) - - if tt.checkFunc != nil { - tt.checkFunc(t, config) + if tt.checkFunc != nil { + tt.checkFunc(t, config) + } } }) } } -func TestConfigurationLoader_LoadGlobalConfig(t *testing.T) { +func TestConfigurationLoaderLoadGlobalConfig(t *testing.T) { + t.Parallel() + tests := []struct { name string - setupFunc func(t *testing.T, tempDir string) string + setupFunc func(t *testing.T) string expectError bool checkFunc func(t *testing.T, config *AppConfig) + description string }{ { name: "valid global config", - setupFunc: func(t *testing.T, tempDir string) string { + setupFunc: func(t *testing.T) string { t.Helper() - configPath := filepath.Join(tempDir, "config.yaml") + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, testutil.TestFileConfigYAML) testutil.WriteTestFile(t, configPath, ` -theme: professional +theme: github output_format: html -github_token: test-token verbose: true `) return configPath }, - checkFunc: func(_ *testing.T, config *AppConfig) { - testutil.AssertEqual(t, "professional", config.Theme) + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestThemeGitHub, config.Theme) testutil.AssertEqual(t, "html", config.OutputFormat) - testutil.AssertEqual(t, "test-token", config.GitHubToken) testutil.AssertEqual(t, true, config.Verbose) }, + description: "Should load valid global config", }, { - name: "nonexistent config file", - setupFunc: func(_ *testing.T, tempDir string) string { - return filepath.Join(tempDir, "nonexistent.yaml") + name: "empty config file", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, "empty.yaml") + testutil.WriteTestFile(t, configPath, "---\n") + + return configPath + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + if config == nil { + t.Fatal(testutil.TestMsgExpectedNonNilConfig) + } + }, + description: "Empty config should not error", + }, + { + name: "config file does not exist", + setupFunc: func(t *testing.T) string { + t.Helper() + + return "/nonexistent/config.yaml" }, expectError: true, + description: "Non-existent config should error", }, { - name: "invalid YAML", - setupFunc: func(t *testing.T, tempDir string) string { + name: "malformed YAML", + setupFunc: func(t *testing.T) string { t.Helper() - configPath := filepath.Join(tempDir, "invalid.yaml") - testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [") + tmpDir, _ := testutil.TempDir(t) + configPath := filepath.Join(tmpDir, "bad.yaml") + testutil.WriteTestFile(t, configPath, `{{{invalid}}}`) return configPath }, expectError: true, + description: "Malformed YAML should error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - - // Set HOME to temp directory - t.Setenv("HOME", tmpDir) - - configFile := tt.setupFunc(t, tmpDir) - - loader := NewConfigurationLoader() - config, err := loader.LoadGlobalConfig(configFile) - - if tt.expectError { - testutil.AssertError(t, err) - - return - } - - testutil.AssertNoError(t, err) - - if tt.checkFunc != nil { - tt.checkFunc(t, config) - } + runConfigLoaderTest(t, configLoaderTestCase{ + name: tt.name, + setupFunc: tt.setupFunc, + expectError: tt.expectError, + checkFunc: tt.checkFunc, + description: tt.description, + }, func(loader *ConfigurationLoader, path string) (*AppConfig, error) { + return loader.LoadGlobalConfig(path) + }) }) } } -func TestConfigurationLoader_ValidateConfiguration(t *testing.T) { +func TestConfigurationLoaderValidateConfiguration(t *testing.T) { t.Parallel() + tests := []struct { name string config *AppConfig expectError bool - errorMsg string + description string }{ { - name: "nil config", - config: nil, - expectError: true, - errorMsg: "configuration cannot be nil", - }, - { - name: "valid config", + name: "valid configuration", config: &AppConfig{ - Theme: "default", + Theme: testutil.TestThemeDefault, OutputFormat: "md", OutputDir: ".", - Verbose: false, - Quiet: false, }, expectError: false, - }, - { - name: "invalid output format", - config: &AppConfig{ - Theme: "default", - OutputFormat: "invalid", - OutputDir: ".", - }, - expectError: true, - errorMsg: "invalid output format", - }, - { - name: "empty output directory", - config: &AppConfig{ - Theme: "default", - OutputFormat: "md", - OutputDir: "", - }, - expectError: true, - errorMsg: "output directory cannot be empty", - }, - { - name: "verbose and quiet both true", - config: &AppConfig{ - Theme: "default", - OutputFormat: "md", - OutputDir: ".", - Verbose: true, - Quiet: true, - }, - expectError: true, - errorMsg: "verbose and quiet flags are mutually exclusive", + description: "Valid config should pass", }, { name: "invalid theme", config: &AppConfig{ - Theme: "nonexistent", + Theme: "invalid-theme", OutputFormat: "md", - OutputDir: ".", }, expectError: true, - errorMsg: "invalid theme", + description: "Invalid theme should error", }, { - name: "valid built-in themes", + name: "empty theme", config: &AppConfig{ - Theme: "github", - OutputFormat: "html", - OutputDir: "docs", + Theme: "", + OutputFormat: "md", }, - expectError: false, + expectError: true, + description: "Empty theme should error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + loader := NewConfigurationLoader() err := loader.ValidateConfiguration(tt.config) if tt.expectError { testutil.AssertError(t, err) - if tt.errorMsg != "" { - testutil.AssertStringContains(t, err.Error(), tt.errorMsg) - } } else { testutil.AssertNoError(t, err) } @@ -464,38 +397,49 @@ func TestConfigurationLoader_ValidateConfiguration(t *testing.T) { } } -func TestConfigurationLoader_SourceManagement(t *testing.T) { +func TestConfigurationLoaderSourceManagement(t *testing.T) { t.Parallel() + loader := NewConfigurationLoader() - // Test initial state + // Initially, all sources should be enabled sources := loader.GetConfigurationSources() - if len(sources) != 6 { // All except CLI flags - t.Errorf("expected 6 enabled sources, got %d", len(sources)) + if len(sources) < 4 { + t.Errorf("expected at least 4 sources initially, got %d", len(sources)) } - // Test disabling a source - loader.DisableSource(appconstants.SourceGlobal) - if loader.sources[appconstants.SourceGlobal] { - t.Error("expected appconstants.SourceGlobal to be disabled") - } + // Disable a source + loader.DisableSource(appconstants.SourceRepoConfig) - // Test enabling a source - loader.EnableSource(appconstants.SourceCLIFlags) - if !loader.sources[appconstants.SourceCLIFlags] { - t.Error("expected appconstants.SourceCLIFlags to be enabled") - } - - // Test updated sources list + // Verify it's disabled sources = loader.GetConfigurationSources() - expectedCount := 6 // 5 original + CLI flags - Global - if len(sources) != expectedCount { - t.Errorf("expected %d enabled sources, got %d", expectedCount, len(sources)) + for _, source := range sources { + if source == appconstants.SourceRepoConfig { + t.Error("expected SourceRepoConfig to be disabled") + } + } + + // Re-enable the source + loader.EnableSource(appconstants.SourceRepoConfig) + + // Verify it's enabled again + sources = loader.GetConfigurationSources() + found := false + for _, source := range sources { + if source == appconstants.SourceRepoConfig { + found = true + + break + } + } + if !found { + t.Error("expected SourceRepoConfig to be re-enabled") } } -func TestConfigurationSource_String(t *testing.T) { +func TestConfigurationSourceString(t *testing.T) { t.Parallel() + tests := []struct { source appconstants.ConfigurationSource expected string @@ -506,266 +450,261 @@ func TestConfigurationSource_String(t *testing.T) { {appconstants.SourceRepoConfig, "repo-config"}, {appconstants.SourceActionConfig, "action-config"}, {appconstants.SourceEnvironment, "environment"}, - {appconstants.SourceCLIFlags, "cli-flags"}, - {appconstants.ConfigurationSource(999), "unknown"}, } for _, tt := range tests { - result := tt.source.String() - if result != tt.expected { - t.Errorf("source %d String() = %s, expected %s", int(tt.source), result, tt.expected) - } - } -} - -func TestConfigurationLoader_EnvironmentOverrides(t *testing.T) { - tests := testutil.GetGitHubTokenHierarchyTests() - - for _, tt := range tests { - t.Run(tt.Name, func(t *testing.T) { - cleanup := tt.SetupFunc(t) - defer cleanup() - - tmpDir, tmpCleanup := testutil.TempDir(t) - defer tmpCleanup() - - loader := NewConfigurationLoader() - config, err := loader.LoadConfiguration("", tmpDir, "") - testutil.AssertNoError(t, err) - - testutil.AssertEqual(t, tt.ExpectedToken, config.GitHubToken) - }) - } -} - -func TestConfigurationLoader_RepoOverrides(t *testing.T) { - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - - // Create a mock git repository structure for testing - repoRoot := filepath.Join(tmpDir, "test-repo") - _ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions - - // Create global config with repo overrides - globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme") - _ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions - globalConfigPath := filepath.Join(globalConfigDir, "config.yaml") - globalConfigContent := "theme: default\n" - globalConfigContent += "output_format: md\n" - globalConfigContent += "repo_overrides:\n" - globalConfigContent += " test-repo:\n" - globalConfigContent += " theme: github\n" - globalConfigContent += " output_format: html\n" - globalConfigContent += " verbose: true\n" - testutil.WriteTestFile(t, globalConfigPath, globalConfigContent) - - // Set environment for XDG compliance - t.Setenv("HOME", tmpDir) - - loader := NewConfigurationLoader() - config, err := loader.LoadConfiguration(globalConfigPath, repoRoot, "") - testutil.AssertNoError(t, err) - - // Note: Since we don't have actual git repository detection in this test, - // repo overrides won't be applied. This test validates the structure works. - testutil.AssertEqual(t, "default", config.Theme) - testutil.AssertEqual(t, "md", config.OutputFormat) -} - -// TestConfigurationLoader_ApplyRepoOverrides tests repo-specific overrides. -func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) { - t.Parallel() - tests := []struct { - name string - config *AppConfig - expectedTheme string - expectedFormat string - }{ - { - name: "no repo overrides configured", - config: &AppConfig{ - Theme: "default", - OutputFormat: "md", - RepoOverrides: nil, - }, - expectedTheme: "default", - expectedFormat: "md", - }, - { - name: "empty repo overrides map", - config: &AppConfig{ - Theme: "default", - OutputFormat: "md", - RepoOverrides: map[string]AppConfig{}, - }, - expectedTheme: "default", - expectedFormat: "md", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.expected, func(t *testing.T) { t.Parallel() - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - loader := NewConfigurationLoader() - loader.applyRepoOverrides(tt.config, tmpDir) - testutil.AssertEqual(t, tt.expectedTheme, tt.config.Theme) - testutil.AssertEqual(t, tt.expectedFormat, tt.config.OutputFormat) - }) - } -} - -// 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 - expectError bool - expectedVals map[string]string - }{ - { - name: "no action directory provided", - setupFunc: func(_ *testing.T, _ string) string { - return "" - }, - expectError: false, - expectedVals: map[string]string{}, - }, - { - 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 - - configPath := filepath.Join(actionDir, "config.yaml") - testutil.WriteTestFile(t, configPath, ` -theme: minimal -output_format: json -verbose: true -`) - - return actionDir - }, - expectError: false, - expectedVals: map[string]string{ - "theme": "minimal", - "output_format": "json", - }, - }, - { - 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 - expectedVals: map[string]string{}, - }, - { - name: "action directory without config file", - 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, - expectedVals: map[string]string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - - actionDir := tt.setupFunc(t, tmpDir) - - loader := NewConfigurationLoader() - config, err := loader.loadActionConfig(actionDir) - - if tt.expectError { - testutil.AssertError(t, err) - } else { - testutil.AssertNoError(t, err) - - // Check expected values if no error - if config != nil { - for key, expected := range tt.expectedVals { - switch key { - case "theme": - testutil.AssertEqual(t, expected, config.Theme) - case "output_format": - testutil.AssertEqual(t, expected, config.OutputFormat) - } - } - } + result := tt.source.String() + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) } }) } } -// TestConfigurationLoader_ValidateTheme tests theme validation edge cases. -func TestConfigurationLoader_ValidateTheme(t *testing.T) { +func TestConfigurationLoaderEnvironmentOverrides(t *testing.T) { + tests := []struct { + name string + setupEnv func(t *testing.T) + setupConfig func(t *testing.T) *AppConfig + checkFunc func(t *testing.T, config *AppConfig) + description string + }{ + { + name: "GH_README_GITHUB_TOKEN overrides config", + setupEnv: func(t *testing.T) { + t.Helper() + t.Setenv(appconstants.EnvGitHubToken, testutil.TestTokenEnv) + }, + setupConfig: func(t *testing.T) *AppConfig { + t.Helper() + + return &AppConfig{ + GitHubToken: testutil.TestTokenConfig, + } + }, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestTokenEnv, config.GitHubToken) + }, + description: "Environment variable should override config token", + }, + { + name: "GITHUB_TOKEN fallback", + setupEnv: func(t *testing.T) { + t.Helper() + t.Setenv(appconstants.EnvGitHubToken, "") + t.Setenv(appconstants.EnvGitHubTokenStandard, "standard-token") + }, + setupConfig: func(t *testing.T) *AppConfig { + t.Helper() + + return &AppConfig{} + }, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, "standard-token", config.GitHubToken) + }, + description: "Should use GITHUB_TOKEN when GH_README_GITHUB_TOKEN not set", + }, + { + name: "config token used when no env vars", + setupEnv: func(t *testing.T) { + t.Helper() + t.Setenv(appconstants.EnvGitHubToken, "") + t.Setenv(appconstants.EnvGitHubTokenStandard, "") + }, + setupConfig: func(t *testing.T) *AppConfig { + t.Helper() + + return &AppConfig{ + GitHubToken: testutil.TestTokenConfig, + } + }, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestTokenConfig, config.GitHubToken) + }, + description: "Should preserve config token when no env vars", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupEnv(t) + config := tt.setupConfig(t) + + loader := NewConfigurationLoader() + loader.loadEnvironmentStep(config) + + if tt.checkFunc != nil { + tt.checkFunc(t, config) + } + }) + } +} + +func TestConfigurationLoaderApplyRepoOverrides(t *testing.T) { + tests := []repoOverrideTestCase{ + createRepoOverrideTestCase(repoOverrideTestParams{ + name: "matching repo override applied", + remoteURL: "https://github.com/test/repo.git", + overrideKey: testutil.TestRepoTestRepo, + overrideTheme: testutil.TestThemeProfessional, + overrideFormat: "html", + expectedTheme: testutil.TestThemeProfessional, + expectedFormat: "html", + description: "Matching repo override should be applied", + }), + createRepoOverrideTestCase(repoOverrideTestParams{ + name: "no override when repo doesn't match", + remoteURL: "https://github.com/different/repo.git", + overrideKey: testutil.TestRepoTestRepo, + overrideTheme: testutil.TestThemeProfessional, + overrideFormat: "html", + expectedTheme: testutil.TestThemeDefault, + expectedFormat: "md", + description: "No override when repo doesn't match", + }), + { + name: "no override when no git repository", + setupFunc: func(t *testing.T) (*AppConfig, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + + config := &AppConfig{ + Theme: testutil.TestThemeDefault, + OutputFormat: "md", + RepoOverrides: map[string]AppConfig{ + testutil.TestRepoTestRepo: { + Theme: testutil.TestThemeProfessional, + OutputFormat: "html", + }, + }, + } + + return config, tmpDir + }, + expectedTheme: testutil.TestThemeDefault, + expectedFormat: "md", + description: "No override when not a git repository", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runRepoOverrideTest(t, tt) + }) + } +} + +func TestConfigurationLoaderLoadActionConfig(t *testing.T) { t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T) string + expectError bool + checkFunc func(t *testing.T, config *AppConfig) + description string + }{ + { + name: "valid action config", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + testutil.WriteFileInDir(t, tmpDir, testutil.TestFileConfigYAML, + string(testutil.MustReadFixture(testutil.TestConfigMinimalDist))) + + return tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, testutil.TestThemeMinimal, config.Theme) + testutil.AssertEqual(t, "dist", config.OutputDir) + }, + description: "Should load action config", + }, + { + name: "no action config file", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + + return tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, _ *AppConfig) { + t.Helper() + // Empty config is okay + }, + description: "Missing action config should not error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runConfigLoaderTest(t, configLoaderTestCase{ + name: tt.name, + setupFunc: tt.setupFunc, + expectError: tt.expectError, + checkFunc: tt.checkFunc, + description: tt.description, + }, func(loader *ConfigurationLoader, path string) (*AppConfig, error) { + return loader.loadActionConfig(path) + }) + }) + } +} + +func TestConfigurationLoaderValidateTheme(t *testing.T) { + t.Parallel() + tests := []struct { name string theme string expectError bool }{ { - name: "valid built-in theme", - theme: "github", + name: "valid theme - default", + theme: testutil.TestThemeDefault, expectError: false, }, { - name: "valid default theme", - theme: "default", + name: "valid theme - github", + theme: testutil.TestThemeGitHub, expectError: false, }, { - name: "empty theme returns error", - theme: "", - expectError: true, + name: "valid theme - minimal", + theme: testutil.TestThemeMinimal, + expectError: false, }, { name: "invalid theme", - theme: "nonexistent-theme", + theme: "nonexistent", expectError: true, }, { - name: "case sensitive theme", - theme: "GitHub", + name: "empty theme", + theme: "", expectError: true, }, - { - name: "custom theme path", - theme: "/custom/theme/path.tmpl", - expectError: false, - }, - { - name: "relative theme path", - theme: "custom/theme.tmpl", - expectError: false, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + loader := NewConfigurationLoader() - err := loader.validateTheme(tt.theme) + config := &AppConfig{ + Theme: tt.theme, + } + + err := loader.validateTheme(config.Theme) if tt.expectError { testutil.AssertError(t, err) @@ -775,3 +714,46 @@ func TestConfigurationLoader_ValidateTheme(t *testing.T) { }) } } + +func TestConfigurationLoaderApplyRepoOverridesWithRepoRoot(t *testing.T) { + tests := []repoOverrideTestCase{ + createRepoOverrideTestCase(repoOverrideTestParams{ + name: "override applied with valid repo root", + remoteURL: "https://github.com/myorg/myrepo.git", + overrideKey: "myorg/myrepo", + overrideTheme: testutil.TestThemeGitHub, + overrideFormat: "json", + expectedTheme: "github", + expectedFormat: "json", + description: "Should apply repo override for detected repository", + }), + { + name: "no override with empty repo root", + setupFunc: func(t *testing.T) (*AppConfig, string) { + t.Helper() + + config := &AppConfig{ + Theme: testutil.TestThemeDefault, + OutputFormat: "md", + RepoOverrides: map[string]AppConfig{ + "myorg/myrepo": { + Theme: testutil.TestThemeGitHub, + OutputFormat: "json", + }, + }, + } + + return config, "" + }, + expectedTheme: testutil.TestThemeDefault, + expectedFormat: "md", + description: "Should not apply override when repo root is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runRepoOverrideTest(t, tt) + }) + } +} diff --git a/internal/configuration_loader_test_helper.go b/internal/configuration_loader_test_helper.go new file mode 100644 index 0000000..5370a31 --- /dev/null +++ b/internal/configuration_loader_test_helper.go @@ -0,0 +1,116 @@ +package internal + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// repoOverrideTestCase defines the structure for repository override test cases. +type repoOverrideTestCase struct { + name string + setupFunc func(t *testing.T) (config *AppConfig, repoRoot string) + expectedTheme string + expectedFormat string + description string +} + +// runRepoOverrideTest executes a test case for repository override functionality. +// This helper reduces duplication in TestConfigurationLoaderApplyRepoOverrides tests. +func runRepoOverrideTest(t *testing.T, tc repoOverrideTestCase) { + t.Helper() + + config, repoRoot := tc.setupFunc(t) + + loader := NewConfigurationLoader() + loader.applyRepoOverrides(config, repoRoot) + + // Verify expected values + testutil.AssertEqual(t, tc.expectedTheme, config.Theme) + testutil.AssertEqual(t, tc.expectedFormat, config.OutputFormat) +} + +// repoOverrideTestParams holds parameters for creating repo override test cases. +type repoOverrideTestParams struct { + name, remoteURL, overrideKey string + overrideTheme, overrideFormat string + expectedTheme, expectedFormat string + description string +} + +// createRepoOverrideTestCase creates a repo override test case with git repo setup. +// This helper reduces duplication when creating test cases that need git repositories. +func createRepoOverrideTestCase(params repoOverrideTestParams) repoOverrideTestCase { + return repoOverrideTestCase{ + name: params.name, + setupFunc: func(t *testing.T) (*AppConfig, string) { + t.Helper() + tmpDir, _ := testutil.TempDir(t) + + if params.remoteURL != "" { + testutil.CreateGitRepoWithRemote(t, tmpDir, params.remoteURL) + } + + config := &AppConfig{ + Theme: testutil.TestThemeDefault, + OutputFormat: "md", + RepoOverrides: map[string]AppConfig{ + params.overrideKey: { + Theme: params.overrideTheme, + OutputFormat: params.overrideFormat, + }, + }, + } + + return config, tmpDir + }, + expectedTheme: params.expectedTheme, + expectedFormat: params.expectedFormat, + description: params.description, + } +} + +// configLoaderTestCase defines the structure for configuration loader test cases. +type configLoaderTestCase struct { + name string + setupFunc func(t *testing.T) string + expectError bool + checkFunc func(t *testing.T, config *AppConfig) + description string +} + +// runConfigLoaderTest executes a test case for configuration loading functionality. +// This helper reduces duplication between LoadGlobalConfig and loadActionConfig tests. +func runConfigLoaderTest( + t *testing.T, + tc configLoaderTestCase, + loadFunc func(loader *ConfigurationLoader, path string) (*AppConfig, error), +) { + t.Helper() + t.Parallel() + + path := tc.setupFunc(t) + + loader := NewConfigurationLoader() + config, err := loadFunc(loader, path) + + if tc.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + + if tc.checkFunc != nil { + tc.checkFunc(t, config) + } + } +} + +// checkThemeAndFormat is a helper that creates a checkFunc for verifying theme and output format. +// This reduces duplication in test cases that only need to verify these two fields. +func checkThemeAndFormat(expectedTheme, expectedFormat string) func(t *testing.T, config *AppConfig) { + return func(t *testing.T, config *AppConfig) { + t.Helper() + testutil.AssertEqual(t, expectedTheme, config.Theme) + testutil.AssertEqual(t, expectedFormat, config.OutputFormat) + } +} diff --git a/internal/dependencies/analyzer.go b/internal/dependencies/analyzer.go index 7eda8d3..429ee71 100644 --- a/internal/dependencies/analyzer.go +++ b/internal/dependencies/analyzer.go @@ -605,19 +605,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err // Apply updates to content lines := strings.Split(string(content), "\n") - for _, update := range updates { - // Find and replace the uses line - for i, line := range lines { - if strings.Contains(line, update.OldUses) { - // Replace the uses statement while preserving indentation - indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) - lines[i] = indent + appconstants.UsesFieldPrefix + update.NewUses - update.LineNumber = i + 1 // Store line number for reference - - break - } - } - } + applyUpdatesToLines(lines, updates) // Write updated content updatedContent := strings.Join(lines, "\n") @@ -625,7 +613,44 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err return fmt.Errorf("failed to write updated file: %w", err) } - // Validate the updated file by trying to parse it + // Validate and rollback on failure + if err := a.validateAndRollbackOnFailure(filePath, backupPath); err != nil { + return err + } + + // Remove backup on success + _ = os.Remove(backupPath) + + return nil +} + +// applyUpdatesToLines applies all updates to the file lines in place. +// Preserves indentation and YAML list markers. +func applyUpdatesToLines(lines []string, updates []PinnedUpdate) { + for _, update := range updates { + for i, line := range lines { + if !strings.Contains(line, update.OldUses) { + continue + } + + // Preserve both indentation AND list markers + trimmed := strings.TrimLeft(line, " \t") + indent := strings.Repeat(" ", len(line)-len(trimmed)) + + // Check if this is a list item (starts with "- ") + listMarker := "" + if strings.HasPrefix(trimmed, "- ") { + listMarker = "- " + } + + // Reconstruct: indent + list marker + uses field + lines[i] = indent + listMarker + appconstants.UsesFieldPrefix + update.NewUses + } + } +} + +// validateAndRollbackOnFailure validates the action file and rolls back changes on failure. +func (a *Analyzer) validateAndRollbackOnFailure(filePath, backupPath string) error { if err := a.validateActionFile(filePath); err != nil { // Rollback on validation failure if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil { @@ -635,17 +660,60 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err return fmt.Errorf("validation failed, rolled back changes: %w", err) } - // Remove backup on success - _ = os.Remove(backupPath) - return nil } -// validateActionFile validates that an action.yml file is still valid after updates. +// validateActionFile validates that an action.yml file conforms to GitHub Actions schema. +// Schema reference: https://www.schemastore.org/github-action.json func (a *Analyzer) validateActionFile(filePath string) error { - _, err := a.parseCompositeAction(filePath) + // Parse to check YAML syntax + action, err := a.parseCompositeAction(filePath) + if err != nil { + return err + } - return err + // Validate required fields per GitHub Actions schema + if action.Name == "" { + return errors.New("validation failed: missing required field 'name'") + } + if action.Description == "" { + return errors.New("validation failed: missing required field 'description'") + } + if action.Runs.Using == "" { + return errors.New("validation failed: missing required field 'runs.using'") + } + + // Validate 'using' field value against GitHub Actions specification + // Valid runtimes: node12, node16, node20, node24, docker, composite + // Reference: https://docs.github.com/en/actions/creating-actions + validRuntimes := []string{ + "node12", + "node16", + "node20", + "node24", + "docker", + "composite", + } + + validUsing := false + runtime := strings.TrimSpace(strings.ToLower(action.Runs.Using)) + for _, valid := range validRuntimes { + if runtime == valid { + validUsing = true + + break + } + } + + if !validUsing { + return fmt.Errorf( + "validation failed: invalid value for 'runs.using': %s (valid: %s)", + action.Runs.Using, + strings.Join(validRuntimes, ", "), + ) + } + + return nil } // enrichWithGitHubData fetches additional information from GitHub API. diff --git a/internal/dependencies/analyzer_test.go b/internal/dependencies/analyzer_test.go index 2ee62ff..b0ac903 100644 --- a/internal/dependencies/analyzer_test.go +++ b/internal/dependencies/analyzer_test.go @@ -16,7 +16,7 @@ import ( "github.com/ivuorinen/gh-action-readme/testutil" ) -func TestAnalyzer_AnalyzeActionFile(t *testing.T) { +func TestAnalyzerAnalyzeActionFile(t *testing.T) { t.Parallel() tests := []struct { @@ -29,34 +29,34 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { }{ { name: "simple action - no dependencies", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), + actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), expectError: false, expectDeps: false, expectedLen: 0, }, { name: "composite action with dependencies", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeWithDeps), + actionYML: testutil.MustReadFixture(testutil.TestFixtureCompositeWithDeps), expectError: false, expectDeps: true, expectedLen: 5, // 3 action dependencies + 2 shell script dependencies - expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"}, + expectedDeps: []string{testutil.TestActionCheckoutV4, "actions/setup-node@v4", "actions/setup-python@v4"}, }, { name: "docker action - no step dependencies", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureDockerBasic), + actionYML: testutil.MustReadFixture(testutil.TestFixtureDockerBasic), expectError: false, expectDeps: false, expectedLen: 0, }, { name: "invalid action file", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing), + actionYML: testutil.MustReadFixture(testutil.TestFixtureInvalidInvalidUsing), expectError: true, }, { name: "minimal action - no dependencies", - actionYML: testutil.MustReadFixture("minimal-action.yml"), + actionYML: testutil.MustReadFixture(testutil.TestFixtureMinimalAction), expectError: false, expectDeps: false, expectedLen: 0, @@ -121,7 +121,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) { } } -func TestAnalyzer_ParseUsesStatement(t *testing.T) { +func TestAnalyzerParseUsesStatement(t *testing.T) { t.Parallel() tests := []struct { @@ -134,7 +134,7 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) { }{ { name: "semantic version", - uses: "actions/checkout@v4", + uses: testutil.TestActionCheckoutV4, expectedOwner: "actions", expectedRepo: "checkout", expectedVersion: "v4", @@ -153,7 +153,7 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) { uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", expectedOwner: "actions", expectedRepo: "checkout", - expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + expectedVersion: testutil.TestSHAForTesting, expectedType: CommitSHA, }, { @@ -182,7 +182,7 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) { } } -func TestAnalyzer_VersionChecking(t *testing.T) { +func TestAnalyzerVersionChecking(t *testing.T) { t.Parallel() tests := []struct { @@ -208,7 +208,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) { }, { name: "commit SHA full", - version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + version: testutil.TestSHAForTesting, isPinned: true, isCommitSHA: true, isSemantic: false, @@ -253,7 +253,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) { } } -func TestAnalyzer_GetLatestVersion(t *testing.T) { +func TestAnalyzerGetLatestVersion(t *testing.T) { t.Parallel() // Create mock GitHub client with test responses @@ -278,15 +278,15 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) { name: "valid repository", owner: "actions", repo: "checkout", - expectedVersion: "v4.1.1", - expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + expectedVersion: testutil.TestVersionV4_1_1, + expectedSHA: testutil.TestSHAForTesting, expectError: false, }, { name: "another valid repository", owner: "actions", repo: "setup-node", - expectedVersion: "v4.0.0", + expectedVersion: testutil.TestVersionV4_0_0, expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b", expectError: false, }, @@ -311,7 +311,7 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) { } } -func TestAnalyzer_CheckOutdated(t *testing.T) { +func TestAnalyzerCheckOutdated(t *testing.T) { t.Parallel() // Create mock GitHub client @@ -327,8 +327,8 @@ func TestAnalyzer_CheckOutdated(t *testing.T) { // Create test dependencies dependencies := []Dependency{ { - Name: "actions/checkout", - Uses: "actions/checkout@v3", + Name: testutil.TestActionCheckout, + Uses: testutil.TestActionCheckoutV3, Version: "v3", IsPinned: false, VersionType: SemanticVersion, @@ -337,7 +337,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) { { Name: "actions/setup-node", Uses: "actions/setup-node@v4.0.0", - Version: "v4.0.0", + Version: testutil.TestVersionV4_0_0, IsPinned: true, VersionType: SemanticVersion, Description: "Setup Node.js", @@ -354,9 +354,9 @@ func TestAnalyzer_CheckOutdated(t *testing.T) { found := false for _, dep := range outdated { - if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" { + if dep.Current.Name == testutil.TestActionCheckout && dep.Current.Version == "v3" { found = true - if dep.LatestVersion != "v4.1.1" { + if dep.LatestVersion != testutil.TestVersionV4_1_1 { t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion) } if dep.UpdateType != "major" { @@ -370,7 +370,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) { } } -func TestAnalyzer_CompareVersions(t *testing.T) { +func TestAnalyzerCompareVersions(t *testing.T) { t.Parallel() analyzer := &Analyzer{} @@ -384,31 +384,31 @@ func TestAnalyzer_CompareVersions(t *testing.T) { { name: "major version difference", current: "v3.0.0", - latest: "v4.0.0", + latest: testutil.TestVersionV4_0_0, expectedType: "major", }, { name: "minor version difference", - current: "v4.0.0", + current: testutil.TestVersionV4_0_0, latest: "v4.1.0", expectedType: "minor", }, { name: "patch version difference", current: "v4.1.0", - latest: "v4.1.1", + latest: testutil.TestVersionV4_1_1, expectedType: "patch", }, { name: "no difference", - current: "v4.1.1", - latest: "v4.1.1", + current: testutil.TestVersionV4_1_1, + latest: testutil.TestVersionV4_1_1, expectedType: "none", }, { name: "floating to specific", current: "v4", - latest: "v4.1.1", + latest: testutil.TestVersionV4_1_1, expectedType: "patch", }, } @@ -423,14 +423,14 @@ func TestAnalyzer_CompareVersions(t *testing.T) { } } -func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { +func TestAnalyzerGeneratePinnedUpdate(t *testing.T) { t.Parallel() tmpDir, cleanup := testutil.TempDir(t) defer cleanup() // Create a test action file with composite steps - actionContent := testutil.MustReadFixture(appconstants.TestFixtureTestCompositeAction) + actionContent := testutil.MustReadFixture(testutil.TestFixtureTestCompositeAction) actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, actionContent) @@ -447,8 +447,8 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { // Create test dependency dep := Dependency{ - Name: "actions/checkout", - Uses: "actions/checkout@v3", + Name: testutil.TestActionCheckout, + Uses: testutil.TestActionCheckoutV3, Version: "v3", IsPinned: false, VersionType: SemanticVersion, @@ -459,21 +459,21 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) { update, err := analyzer.GeneratePinnedUpdate( actionPath, dep, - "v4.1.1", - "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + testutil.TestVersionV4_1_1, + testutil.TestSHAForTesting, ) testutil.AssertNoError(t, err) // Verify update details testutil.AssertEqual(t, actionPath, update.FilePath) - testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses) + testutil.AssertEqual(t, testutil.TestActionCheckoutV3, update.OldUses) testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e") testutil.AssertStringContains(t, update.NewUses, "# v4.1.1") testutil.AssertEqual(t, "major", update.UpdateType) } -func TestAnalyzer_WithCache(t *testing.T) { +func TestAnalyzerWithCache(t *testing.T) { t.Parallel() // Test that caching works properly @@ -499,7 +499,7 @@ func TestAnalyzer_WithCache(t *testing.T) { testutil.AssertEqual(t, sha1, sha2) } -func TestAnalyzer_RateLimitHandling(t *testing.T) { +func TestAnalyzerRateLimitHandling(t *testing.T) { t.Parallel() // Create mock client that returns rate limit error @@ -518,7 +518,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) { }, } - client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}}) + client := github.NewClient(&http.Client{Transport: &testutil.MockTransport{Client: mockClient}}) cacheInstance, _ := cache.NewCache(cache.DefaultConfig()) analyzer := &Analyzer{ @@ -539,7 +539,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) { } } -func TestAnalyzer_WithoutGitHubClient(t *testing.T) { +func TestAnalyzerWithoutGitHubClient(t *testing.T) { t.Parallel() // Test graceful degradation when GitHub client is not available @@ -552,7 +552,7 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) { defer cleanup() actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) deps, err := analyzer.AnalyzeActionFile(actionPath) @@ -569,15 +569,6 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) { } } -// mockTransport wraps our mock HTTP client for GitHub client. -type mockTransport struct { - client *testutil.MockHTTPClient -} - -func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return t.client.Do(req) -} - // TestNewAnalyzer tests the analyzer constructor. func TestNewAnalyzer(t *testing.T) { t.Parallel() @@ -654,3 +645,125 @@ func TestNewAnalyzer(t *testing.T) { }) } } + +// TestNoOpCache tests the no-op cache implementation. +func TestNoOpCache(t *testing.T) { + t.Parallel() + + noc := NewNoOpCache() + if noc == nil { + t.Fatal("NewNoOpCache() returned nil") + } + + // Test Get - should always return false + val, ok := noc.Get(testutil.CacheTestKey) + if ok { + t.Error("NoOpCache.Get() should return false") + } + if val != nil { + t.Error("NoOpCache.Get() should return nil value") + } + + // Test Set - should not error + err := noc.Set(testutil.CacheTestKey, testutil.CacheTestValue) + if err != nil { + t.Errorf("NoOpCache.Set() returned error: %v", err) + } + + // Test SetWithTTL - should not error + err = noc.SetWithTTL(testutil.CacheTestKey, testutil.CacheTestValue, time.Hour) + if err != nil { + t.Errorf("NoOpCache.SetWithTTL() returned error: %v", err) + } +} + +// TestCacheAdapterSet tests the cache adapter Set method. +func TestCacheAdapterSet(t *testing.T) { + t.Parallel() + + c, err := cache.NewCache(cache.DefaultConfig()) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + defer testutil.CleanupCache(t, c)() + + adapter := NewCacheAdapter(c) + + // Test Set + err = adapter.Set(testutil.CacheTestKey, testutil.CacheTestValue) + if err != nil { + t.Errorf("CacheAdapter.Set() returned error: %v", err) + } + + // Verify value was set + val, ok := adapter.Get(testutil.CacheTestKey) + if !ok { + t.Error("CacheAdapter.Get() should return true after Set") + } + if val != testutil.CacheTestValue { + t.Errorf("CacheAdapter.Get() = %v, want %q", val, testutil.CacheTestValue) + } +} + +// TestIsCompositeAction tests composite action detection. +func TestIsCompositeAction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixture string + want bool + wantErr bool + }{ + { + name: "composite action", + fixture: "composite-action.yml", + want: true, + wantErr: false, + }, + { + name: "docker action", + fixture: "docker-action.yml", + want: false, + wantErr: false, + }, + { + name: "javascript action", + fixture: "javascript-action.yml", + want: false, + wantErr: false, + }, + { + name: "invalid yaml", + fixture: "invalid.yml", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Read fixture content using safe helper + yamlContent := testutil.MustReadAnalyzerFixture(tt.fixture) + + // Create temp file with action YAML + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, actionPath, yamlContent) + + got, err := IsCompositeAction(actionPath) + if (err != nil) != tt.wantErr { + t.Errorf("IsCompositeAction() error = %v, wantErr %v", err, tt.wantErr) + + return + } + if got != tt.want { + t.Errorf("IsCompositeAction() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/dependencies/parser.go b/internal/dependencies/parser.go index 1e5ccf7..66704e9 100644 --- a/internal/dependencies/parser.go +++ b/internal/dependencies/parser.go @@ -3,16 +3,38 @@ package dependencies import ( "fmt" "os" + "path/filepath" + "strings" "github.com/goccy/go-yaml" "github.com/ivuorinen/gh-action-readme/appconstants" ) +// validateFilePath ensures a file path is safe to read. +// Returns an error if the path contains traversal attempts. +func validateFilePath(path string) error { + cleanPath := filepath.Clean(path) + + // Check for ".." components in cleaned path + for _, component := range strings.Split(filepath.ToSlash(cleanPath), "/") { + if component == ".." { + return fmt.Errorf("invalid file path: traversal detected in %q", path) + } + } + + return nil +} + // parseCompositeActionFromFile reads and parses a composite action file. func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) { + // Validate path before reading + if err := validateFilePath(actionPath); err != nil { + return nil, err + } + // Read the file - data, err := os.ReadFile(actionPath) // #nosec G304 -- action path from function parameter + data, err := os.ReadFile(actionPath) // #nosec G304 -- path validated above if err != nil { return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err) } diff --git a/internal/dependencies/parser_test.go b/internal/dependencies/parser_test.go new file mode 100644 index 0000000..b75472f --- /dev/null +++ b/internal/dependencies/parser_test.go @@ -0,0 +1,62 @@ +package dependencies + +import ( + "testing" +) + +func TestValidateFilePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr bool + }{ + { + name: "valid relative path", + path: "testdata/action.yml", + wantErr: false, + }, + { + name: "valid absolute path", + path: "/tmp/action.yml", + wantErr: false, + }, + { + name: "traversal with double dots", + path: "../../../etc/passwd", + wantErr: true, + }, + { + name: "traversal in middle of path", + path: "foo/../../../etc/passwd", + wantErr: true, + }, + { + name: "clean path with dot slash", + path: "./foo/bar", + wantErr: false, + }, + { + name: "valid nested path", + path: "internal/testdata/fixtures/action.yml", + wantErr: false, + }, + { + name: "path with trailing slash", + path: "testdata/action.yml/", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateFilePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("validateFilePath() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/dependencies/updater_test.go b/internal/dependencies/updater_test.go new file mode 100644 index 0000000..e534ab1 --- /dev/null +++ b/internal/dependencies/updater_test.go @@ -0,0 +1,749 @@ +package dependencies + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/cache" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// newTestAnalyzer creates an Analyzer with cache for testing. +// Returns the analyzer and a cleanup function. +// Pattern used 7+ times in updater_test.go. +func newTestAnalyzer(t *testing.T) (*Analyzer, func()) { + t.Helper() + + cacheInstance, err := cache.NewCache(cache.DefaultConfig()) + testutil.AssertNoError(t, err) + + analyzer := &Analyzer{ + Cache: NewCacheAdapter(cacheInstance), + } + + return analyzer, testutil.CleanupCache(t, cacheInstance) +} + +// validatePinnedUpdateSuccess validates that the update succeeded and backup was cleaned up. +func validatePinnedUpdateSuccess(t *testing.T, actionPath string, validateBackup bool, analyzer *Analyzer) { + t.Helper() + + if validateBackup { + testutil.AssertBackupNotExists(t, actionPath) + } + + // Verify file is still valid YAML + err := analyzer.validateActionFile(actionPath) + testutil.AssertNoError(t, err) +} + +// validatePinnedUpdateRollback validates that the rollback succeeded and file is unchanged. +func validatePinnedUpdateRollback(t *testing.T, actionPath, originalContent string) { + t.Helper() + + testutil.ValidateRollback(t, actionPath, originalContent) + + // Backup should be removed after rollback + testutil.AssertBackupNotExists(t, actionPath) +} + +// TestApplyPinnedUpdates tests the ApplyPinnedUpdates method. +// Note: These tests identify a bug where the `- ` list marker is not preserved +// when updating YAML. The current implementation replaces entire lines with +// just "uses: " prefix, losing the list marker. Tests are written to document +// current behavior while validating the logic works. +func TestApplyPinnedUpdates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actionContent string + updates []PinnedUpdate + wantErr bool + validateBackup bool + checkRollback bool + }{ + createSingleUpdateTestCase(singleUpdateParams{ + name: "list format updates now work correctly (bug fixed)", + fixturePath: "dependencies/simple-list-step.yml", + oldUses: testutil.TestCheckoutV4OldUses, + newUses: testutil.TestCheckoutPinnedV417, + commitSHA: testutil.TestActionCheckoutSHA, + version: testutil.TestVersionV417, + updateType: "patch", + wantErr: false, + validateBackup: true, + checkRollback: false, + }), + createSingleUpdateTestCase(singleUpdateParams{ + name: "updates work when uses is not in list format", + fixturePath: "dependencies/named-step.yml", + oldUses: testutil.TestCheckoutV4OldUses, + newUses: testutil.TestCheckoutPinnedV417, + commitSHA: testutil.TestActionCheckoutSHA, + version: testutil.TestVersionV417, + updateType: "patch", + wantErr: false, + validateBackup: true, + checkRollback: false, + }), + { + name: "multiple updates in non-list format", + actionContent: testutil.MustReadFixture("dependencies/multiple-steps.yml"), + updates: []PinnedUpdate{ + { + FilePath: "", // Will be set by test + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV417, + CommitSHA: testutil.TestActionCheckoutSHA, + Version: testutil.TestVersionV417, + UpdateType: "patch", + LineNumber: 0, + }, + { + FilePath: "", // Will be set by test + OldUses: testutil.TestActionSetupNodeV3, + NewUses: "actions/setup-node@1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b # v4.0.0", + CommitSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b", + Version: "v4.0.0", + UpdateType: "major", + LineNumber: 0, + }, + }, + wantErr: false, + validateBackup: true, + checkRollback: false, + }, + createSingleUpdateTestCase(singleUpdateParams{ + name: "preserves indentation in non-list format", + fixturePath: "dependencies/step-with-parameters.yml", + oldUses: testutil.TestCheckoutV4OldUses, + newUses: testutil.TestCheckoutPinnedV417, + commitSHA: testutil.TestActionCheckoutSHA, + version: testutil.TestVersionV417, + updateType: "patch", + wantErr: false, + validateBackup: true, + checkRollback: false, + }), + createSingleUpdateTestCase(singleUpdateParams{ + name: "handles already pinned dependencies", + fixturePath: "dependencies/already-pinned.yml", + oldUses: testutil.TestCheckoutPinnedV417, + newUses: testutil.TestCheckoutPinnedV417, + commitSHA: testutil.TestActionCheckoutSHA, + version: testutil.TestVersionV417, + updateType: "none", + wantErr: false, + validateBackup: true, + checkRollback: false, + }), + { + name: "invalid YAML triggers rollback", + actionContent: testutil.MustReadFixture("dependencies/simple-test-step.yml"), + updates: []PinnedUpdate{ + { + FilePath: "", // Will be set by test + OldUses: "name: Test Action", + NewUses: "invalid:::yaml", + CommitSHA: "", + Version: "", + UpdateType: "none", + LineNumber: 0, + }, + }, + wantErr: true, + validateBackup: false, + checkRollback: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temporary directory and action file + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionPath := testutil.WriteActionFile(t, dir, tt.actionContent) + + // Store original content for rollback check + originalContent, _ := os.ReadFile(actionPath) // #nosec G304 -- test file path + + // Set file path in updates + for i := range tt.updates { + tt.updates[i].FilePath = actionPath + } + + // Create analyzer + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + // Apply updates + err := analyzer.ApplyPinnedUpdates(tt.updates) + + // Check error expectation + if (err != nil) != tt.wantErr { + t.Errorf("ApplyPinnedUpdates() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !tt.wantErr { + validatePinnedUpdateSuccess(t, actionPath, tt.validateBackup, analyzer) + } + + if tt.checkRollback { + validatePinnedUpdateRollback(t, actionPath, string(originalContent)) + } + }) + } +} + +// validateUpdateFileSuccess validates that the file was updated correctly and backup was cleaned up. +func validateUpdateFileSuccess(t *testing.T, actionPath, expectedYAML string, checkBackup bool) { + t.Helper() + + testutil.AssertFileContentEquals(t, actionPath, expectedYAML) + + if checkBackup { + testutil.AssertBackupNotExists(t, actionPath) + } +} + +// validateUpdateFileRollback validates that the rollback succeeded and file is unchanged. +func validateUpdateFileRollback(t *testing.T, actionPath, initialYAML string) { + t.Helper() + + testutil.AssertFileContentEquals(t, actionPath, initialYAML) +} + +// TestUpdateActionFile tests the updateActionFile method directly. +func TestUpdateActionFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + initialYAML string + updates []PinnedUpdate + expectedYAML string + expectError bool + checkBackup bool + rollbackCheck bool + }{ + { + name: "finds and replaces uses statement in non-list format", + initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4.yml"), + updates: []PinnedUpdate{ + { + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV411, + }, + }, + expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"), + expectError: false, + checkBackup: true, + }, + { + name: "handles different version formats", + initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4-1-0.yml"), + updates: []PinnedUpdate{ + { + OldUses: "actions/checkout@v4.1.0", + NewUses: testutil.TestCheckoutPinnedV411, + }, + }, + expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"), + expectError: false, + checkBackup: true, + }, + { + name: "handles multiple references to same action", + initialYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout.yml"), + updates: []PinnedUpdate{ + { + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV411, + }, + }, + expectedYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout-pinned.yml"), + expectError: false, + checkBackup: true, + }, + { + name: "preserves whitespace and comments", + initialYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment.yml"), + updates: []PinnedUpdate{ + { + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV411, + }, + }, + expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment-pinned.yml"), + expectError: false, + checkBackup: true, + }, + { + name: "invalid YAML triggers rollback", + initialYAML: testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout), + updates: []PinnedUpdate{ + { + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: "\"unclosed string that breaks YAML parsing", // Unclosed quote breaks YAML + }, + }, + expectedYAML: "", // Should rollback to original + expectError: true, + checkBackup: false, + rollbackCheck: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temp directory and file + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionPath := testutil.WriteActionFile(t, dir, tt.initialYAML) + + // Create analyzer + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + // Apply update + err := analyzer.updateActionFile(actionPath, tt.updates) + + // Check error expectation + if (err != nil) != tt.expectError { + t.Errorf("updateActionFile() error = %v, expectError %v", err, tt.expectError) + + return + } + + if !tt.expectError { + validateUpdateFileSuccess(t, actionPath, tt.expectedYAML, tt.checkBackup) + } + + if tt.rollbackCheck { + validateUpdateFileRollback(t, actionPath, tt.initialYAML) + } + }) + } +} + +// TestValidateActionFile tests the validateActionFile method. +func TestValidateActionFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yamlContent string + expectValid bool + }{ + { + name: "valid composite action", + yamlContent: testutil.MustReadFixture("dependencies/simple-list-step.yml"), + expectValid: true, + }, + { + name: "valid JavaScript action", + yamlContent: testutil.MustReadFixture("dependencies/valid-javascript-action.yml"), + expectValid: true, + }, + { + name: "valid Docker action", + yamlContent: testutil.MustReadFixture("dependencies/valid-docker-action.yml"), + expectValid: true, + }, + { + name: "missing name field", + yamlContent: testutil.MustReadFixture("dependencies/missing-name.yml"), + expectValid: false, + }, + { + name: "missing description field", + yamlContent: testutil.MustReadFixture("dependencies/missing-description.yml"), + expectValid: false, + }, + { + name: "missing runs field", + yamlContent: testutil.MustReadFixture("dependencies/missing-runs.yml"), + expectValid: false, + }, + { + name: "invalid YAML syntax", + yamlContent: testutil.MustReadFixture("dependencies/invalid-syntax.yml"), + expectValid: false, + }, + { + name: "invalid using field", + yamlContent: testutil.MustReadFixture("dependencies/invalid-using.yml"), + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temp file + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionPath := testutil.WriteActionFile(t, dir, tt.yamlContent) + + // Create analyzer + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + // Validate + err := analyzer.validateActionFile(actionPath) + + if tt.expectValid && err != nil { + t.Errorf("validateActionFile() expected valid but got error: %v", err) + } + + if !tt.expectValid && err == nil { + t.Errorf("validateActionFile() expected invalid but got nil error") + } + }) + } +} + +// TestGetLatestTagEdgeCases tests edge cases for getLatestTag. +func TestGetLatestTagEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mockSetup func() *Analyzer + owner string + repo string + expectError bool + }{ + { + name: "no tags available", + mockSetup: func() *Analyzer { + mockClient := testutil.MockGitHubClient(map[string]string{ + "GET https://api.github.com/repos/test/repo/tags": "[]", + }) + cacheInstance, _ := cache.NewCache(cache.DefaultConfig()) + + return &Analyzer{ + GitHubClient: mockClient, + Cache: NewCacheAdapter(cacheInstance), + } + }, + owner: "test", + repo: "repo", + expectError: true, + }, + { + name: "GitHub client nil", + mockSetup: func() *Analyzer { + return &Analyzer{ + GitHubClient: nil, + Cache: nil, + } + }, + owner: "test", + repo: "repo", + expectError: true, + }, + { + name: "malformed tag response", + mockSetup: func() *Analyzer { + mockClient := testutil.MockGitHubClient(map[string]string{ + "GET https://api.github.com/repos/test/repo/tags": "invalid json", + }) + cacheInstance, _ := cache.NewCache(cache.DefaultConfig()) + + return &Analyzer{ + GitHubClient: mockClient, + Cache: NewCacheAdapter(cacheInstance), + } + }, + owner: "test", + repo: "repo", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + analyzer := tt.mockSetup() + if analyzer.Cache != nil { + // Clean up cache if it exists + defer func() { + if ca, ok := analyzer.Cache.(*CacheAdapter); ok { + _ = ca.cache.Close() + } + }() + } + + _, _, err := analyzer.getLatestVersion(tt.owner, tt.repo) + + if (err != nil) != tt.expectError { + t.Errorf("getLatestVersion() error = %v, expectError %v", err, tt.expectError) + } + }) + } +} + +// assertCacheVersionNotFound validates that no version was found in the cache. +func assertCacheVersionNotFound(t *testing.T, version, sha string, found bool) { + t.Helper() + + if found { + t.Error("getCachedVersion() should return false") + } + if version != "" { + t.Errorf("version = %q, want empty", version) + } + if sha != "" { + t.Errorf("sha = %q, want empty", sha) + } +} + +// TestCacheVersionEdgeCases tests edge cases for cacheVersion and getCachedVersion. +func TestCacheVersionEdgeCases(t *testing.T) { + t.Parallel() + + // Parametrized tests for getCachedVersion edge cases + notFoundCases := []struct { + name string + setupFn func(*testing.T) (*Analyzer, func()) + cacheKey string + }{ + { + name: "nil cache", + setupFn: func(_ *testing.T) (*Analyzer, func()) { + return &Analyzer{Cache: nil}, func() { + // No cleanup needed for nil cache + } + }, + cacheKey: testutil.CacheTestKey, + }, + { + name: "invalid data type", + setupFn: func(t *testing.T) (*Analyzer, func()) { + t.Helper() + c, err := cache.NewCache(cache.DefaultConfig()) + testutil.AssertNoError(t, err) + _ = c.Set(testutil.CacheTestKey, "invalid-string") + + return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c) + }, + cacheKey: testutil.CacheTestKey, + }, + { + name: "empty cache entry", + setupFn: func(t *testing.T) (*Analyzer, func()) { + t.Helper() + c, err := cache.NewCache(cache.DefaultConfig()) + testutil.AssertNoError(t, err) + + return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c) + }, + cacheKey: "nonexistent-key", + }, + } + + for _, tc := range notFoundCases { + t.Run("getCachedVersion with "+tc.name, func(t *testing.T) { + t.Parallel() + analyzer, cleanup := tc.setupFn(t) + defer cleanup() + version, sha, found := analyzer.getCachedVersion(tc.cacheKey) + assertCacheVersionNotFound(t, version, sha, found) + }) + } + + t.Run("cacheVersion with nil cache", func(t *testing.T) { + t.Parallel() + + analyzer := &Analyzer{Cache: nil} + // Should not panic + analyzer.cacheVersion(testutil.CacheTestKey, "v1.0.0", "abc123") + }) + + t.Run("cacheVersion stores and retrieves correctly", func(t *testing.T) { + t.Parallel() + + cacheInstance, err := cache.NewCache(cache.DefaultConfig()) + testutil.AssertNoError(t, err) + defer testutil.CleanupCache(t, cacheInstance)() + + analyzer := &Analyzer{Cache: NewCacheAdapter(cacheInstance)} + + // Cache a version + analyzer.cacheVersion(testutil.CacheTestKey, "v1.2.3", "def456") + + // Retrieve it + version, sha, found := analyzer.getCachedVersion(testutil.CacheTestKey) + + if !found { + t.Error("getCachedVersion() should return true after cacheVersion()") + } + if version != "v1.2.3" { + t.Errorf("getCachedVersion() version = %s, want v1.2.3", version) + } + if sha != "def456" { + t.Errorf("getCachedVersion() sha = %s, want def456", sha) + } + }) +} + +// TestUpdateActionFileBackupAndRollback tests backup creation and rollback functionality. +func TestUpdateActionFileBackupAndRollback(t *testing.T) { + t.Parallel() + + t.Run("backup created before modification", func(t *testing.T) { + t.Parallel() + + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout) + actionPath := testutil.WriteActionFile(t, dir, originalContent) + + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + updates := []PinnedUpdate{ + { + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV411, + }, + } + + err := analyzer.updateActionFile(actionPath, updates) + testutil.AssertNoError(t, err) + + // Backup should be removed after successful update + testutil.AssertBackupNotExists(t, actionPath) + }) + + t.Run("rollback on validation failure", func(t *testing.T) { + t.Parallel() + + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout) + actionPath := testutil.WriteActionFile(t, dir, originalContent) + + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + // Create an update that breaks YAML + updates := []PinnedUpdate{ + { + OldUses: "name: Test", + NewUses: "invalid::yaml::syntax:", + }, + } + + err := analyzer.updateActionFile(actionPath, updates) + if err == nil { + t.Error("updateActionFile() should return error for invalid YAML") + } + + // File should be rolled back to original + testutil.AssertFileContentEquals(t, actionPath, originalContent) + + // Backup should be removed after rollback + testutil.AssertBackupNotExists(t, actionPath) + }) + + t.Run("file permission errors", func(t *testing.T) { + // Skip on Windows as permission handling is different + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionPath := filepath.Join(dir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, actionPath, "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []") + + // Make file read-only + err := os.Chmod(actionPath, 0444) // #nosec G302 -- intentionally read-only for test + testutil.AssertNoError(t, err) + + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + updates := []PinnedUpdate{ + { + OldUses: "anything", + NewUses: "something", + }, + } + + err = analyzer.updateActionFile(actionPath, updates) + if err == nil { + t.Error("updateActionFile() should return error for read-only file") + } + }) +} + +// TestApplyPinnedUpdatesGroupedByFile tests updates to multiple files. +func TestApplyPinnedUpdatesGroupedByFile(t *testing.T) { + t.Parallel() + + dir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Create two action files in non-list format (to avoid YAML bug) + action1Path := filepath.Join(dir, "action1.yml") + action2Path := filepath.Join(dir, "action2.yml") + + action1Content := testutil.MustReadFixture("dependencies/action1-checkout.yml") + action2Content := testutil.MustReadFixture("dependencies/action2-setup-node.yml") + + testutil.WriteTestFile(t, action1Path, action1Content) + testutil.WriteTestFile(t, action2Path, action2Content) + + analyzer, cleanupAnalyzer := newTestAnalyzer(t) + defer cleanupAnalyzer() + + // Create updates for both files + updates := []PinnedUpdate{ + { + FilePath: action1Path, + OldUses: testutil.TestCheckoutV4OldUses, + NewUses: testutil.TestCheckoutPinnedV411, + }, + { + FilePath: action2Path, + OldUses: testutil.TestActionSetupNodeV3, + NewUses: "actions/setup-node@def456 # v4.0.0", + }, + } + + err := analyzer.ApplyPinnedUpdates(updates) + testutil.AssertNoError(t, err) + + // Verify both files were updated + content1 := testutil.SafeReadFile(t, action1Path, dir) + if !strings.Contains(string(content1), testutil.TestCheckoutPinnedV411) { + t.Errorf("action1.yml was not updated correctly, got:\n%s", string(content1)) + } + + content2 := testutil.SafeReadFile(t, action2Path, dir) + if !strings.Contains(string(content2), "actions/setup-node@def456 # v4.0.0") { + t.Errorf("action2.yml was not updated correctly, got:\n%s", string(content2)) + } +} diff --git a/internal/dependencies/updater_test_helper.go b/internal/dependencies/updater_test_helper.go new file mode 100644 index 0000000..b109069 --- /dev/null +++ b/internal/dependencies/updater_test_helper.go @@ -0,0 +1,48 @@ +package dependencies + +import "github.com/ivuorinen/gh-action-readme/testutil" + +// singleUpdateParams holds parameters for creating a test case with a single update. +type singleUpdateParams struct { + name string + fixturePath string + oldUses, newUses, commitSHA, version, updateType string + wantErr, validateBackup, checkRollback bool +} + +// createSingleUpdateTestCase creates a test case with a single PinnedUpdate. +// This helper reduces duplication for test cases that update a single dependency. +func createSingleUpdateTestCase(params singleUpdateParams) struct { + name string + actionContent string + updates []PinnedUpdate + wantErr bool + validateBackup bool + checkRollback bool +} { + return struct { + name string + actionContent string + updates []PinnedUpdate + wantErr bool + validateBackup bool + checkRollback bool + }{ + name: params.name, + actionContent: testutil.MustReadFixture(params.fixturePath), + updates: []PinnedUpdate{ + { + FilePath: "", // Will be set by test + OldUses: params.oldUses, + NewUses: params.newUses, + CommitSHA: params.commitSHA, + Version: params.version, + UpdateType: params.updateType, + LineNumber: 0, + }, + }, + wantErr: params.wantErr, + validateBackup: params.validateBackup, + checkRollback: params.checkRollback, + } +} diff --git a/internal/errorhandler_integration_test.go b/internal/errorhandler_integration_test.go new file mode 100644 index 0000000..b9a6b56 --- /dev/null +++ b/internal/errorhandler_integration_test.go @@ -0,0 +1,361 @@ +package internal_test + +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +const ( + envGoTestSubprocess = "GO_TEST_SUBPROCESS" + envTestType = "TEST_TYPE" +) + +// verifyExitCode checks that the command exited with the expected exit code. +func verifyExitCode(t *testing.T, err error, expectedExit int) { + t.Helper() + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != expectedExit { + t.Errorf("expected exit code %d, got %d", expectedExit, exitErr.ExitCode()) + } + + return + } + if err != nil { + t.Fatalf(testutil.TestErrUnexpected, err) + } + if expectedExit != 0 { + t.Errorf("expected exit code %d, but process exited successfully", expectedExit) + } +} + +// execSubprocessTest spawns a subprocess and returns its stderr output and error. +func execSubprocessTest(t *testing.T, testType string) (string, error) { + t.Helper() + //nolint:gosec // Controlled test arguments + cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerIntegration$") + cmd.Env = append(os.Environ(), + envGoTestSubprocess+"=1", + envTestType+"="+testType, + ) + + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("failed to get stderr pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start subprocess: %v", err) + } + + stderrOutput := make([]byte, 4096) + n, _ := stderr.Read(stderrOutput) + stderrStr := string(stderrOutput[:n]) + + return stderrStr, cmd.Wait() +} + +// runSubprocessErrorTest executes a subprocess test and verifies exit code and stderr. +// Consolidates 15 duplicated test loops. +func runSubprocessErrorTest(t *testing.T, testType string, expectedExit int, expectedStderr string) { + t.Helper() + + stderrStr, err := execSubprocessTest(t, testType) + verifyExitCode(t, err, expectedExit) + + if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(expectedStderr)) { + t.Errorf("stderr missing expected text %q, got: %s", expectedStderr, stderrStr) + } +} + +// TestErrorHandlerIntegration tests error handler methods that call os.Exit() +// using subprocess pattern. +func TestErrorHandlerIntegration(t *testing.T) { + t.Parallel() + + // Check if this is the subprocess + if os.Getenv(envGoTestSubprocess) == "1" { + runSubprocessTest() + + return + } + + tests := []struct { + name string + testType string + expectedExit int + expectedStderr string + }{ + { + name: "HandleError with file not found", + testType: "handle_error_file_not_found", + expectedExit: appconstants.ExitCodeError, + expectedStderr: testutil.TestErrFileNotFound, + }, + { + name: "HandleError with validation error", + testType: "handle_error_validation", + expectedExit: appconstants.ExitCodeError, + expectedStderr: "validation failed", + }, + { + name: "HandleError with context", + testType: "handle_error_with_context", + expectedExit: appconstants.ExitCodeError, + expectedStderr: "config file", + }, + { + name: "HandleError with suggestions", + testType: "handle_error_with_suggestions", + expectedExit: appconstants.ExitCodeError, + expectedStderr: testutil.TestErrFileError, + }, + { + name: "HandleFatalError with permission denied", + testType: "handle_fatal_error_permission", + expectedExit: appconstants.ExitCodeError, + expectedStderr: testutil.TestErrPermissionDenied, + }, + { + name: "HandleFatalError with config error", + testType: "handle_fatal_error_config", + expectedExit: appconstants.ExitCodeError, + expectedStderr: "configuration error", + }, + { + name: "HandleSimpleError with generic error", + testType: "handle_simple_error_generic", + expectedExit: appconstants.ExitCodeError, + expectedStderr: "operation failed", + }, + { + name: "HandleSimpleError with file not found pattern", + testType: "handle_simple_error_not_found", + expectedExit: appconstants.ExitCodeError, + expectedStderr: testutil.TestErrFileError, + }, + { + name: "HandleSimpleError with permission pattern", + testType: "handle_simple_error_permission", + expectedExit: appconstants.ExitCodeError, + expectedStderr: "access error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + runSubprocessErrorTest(t, tt.testType, tt.expectedExit, tt.expectedStderr) + }) + } +} + +// runSubprocessTest executes the actual error handler call based on TEST_TYPE. +func runSubprocessTest() { + testType := os.Getenv(envTestType) + output := internal.NewColoredOutput(false) // quiet=false + handler := internal.NewErrorHandler(output) + + switch testType { + case "handle_error_file_not_found": + err := apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound) + handler.HandleError(err) + + case "handle_error_validation": + err := apperrors.New(appconstants.ErrCodeValidation, "validation failed") + handler.HandleError(err) + + case "handle_error_with_context": + err := apperrors.New(appconstants.ErrCodeConfiguration, "config file missing") + err = err.WithDetails(map[string]string{ + "path": "/invalid/path/config.yaml", + "type": "application", + }) + handler.HandleError(err) + + case "handle_error_with_suggestions": + err := apperrors.New(appconstants.ErrCodeFileNotFound, "file error occurred") + err = err.WithSuggestions("Check that the file exists", "Verify file permissions") + handler.HandleError(err) + + case "handle_fatal_error_permission": + handler.HandleFatalError( + appconstants.ErrCodePermission, + "permission denied accessing file", + map[string]string{"file": "/etc/passwd"}, + ) + + case "handle_fatal_error_config": + handler.HandleFatalError( + appconstants.ErrCodeConfiguration, + "configuration error in settings", + map[string]string{ + "section": "github", + "key": "token", + }, + ) + + case "handle_simple_error_generic": + handler.HandleSimpleError("operation failed", errors.New("generic error occurred")) + + case "handle_simple_error_not_found": + handler.HandleSimpleError(testutil.TestErrFileError, errors.New("no such file or directory")) + + case "handle_simple_error_permission": + handler.HandleSimpleError("access error", errors.New(testutil.TestErrPermissionDenied)) + + default: + os.Exit(99) // Unexpected test type + } +} + +// TestErrorHandlerAllErrorCodes tests that all error codes produce correct exit codes. +func TestErrorHandlerAllErrorCodes(t *testing.T) { + t.Parallel() + + // Check if this is the subprocess + if os.Getenv(envGoTestSubprocess) == "1" { + runErrorCodeTest() + + return + } + + errorCodes := []struct { + code appconstants.ErrorCode + description string + }{ + {appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound}, + {appconstants.ErrCodePermission, testutil.TestErrPermissionDenied}, + {appconstants.ErrCodeInvalidYAML, "invalid yaml"}, + {appconstants.ErrCodeInvalidAction, "invalid action"}, + {appconstants.ErrCodeNoActionFiles, "no action files"}, + {appconstants.ErrCodeGitHubAPI, "github api error"}, + {appconstants.ErrCodeGitHubRateLimit, "rate limit"}, + {appconstants.ErrCodeGitHubAuth, "auth error"}, + {appconstants.ErrCodeConfiguration, "configuration error"}, + {appconstants.ErrCodeValidation, "validation error"}, + {appconstants.ErrCodeTemplateRender, "template error"}, + {appconstants.ErrCodeFileWrite, "file write error"}, + {appconstants.ErrCodeDependencyAnalysis, "dependency error"}, + {appconstants.ErrCodeCacheAccess, "cache error"}, + {appconstants.ErrCodeUnknown, "unknown error"}, + } + + for _, tc := range errorCodes { + t.Run(string(tc.code), func(t *testing.T) { + t.Parallel() + + //nolint:gosec // Controlled test arguments + cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerAllErrorCodes$/^"+string(tc.code)+"$") + cmd.Env = append(os.Environ(), + "GO_TEST_SUBPROCESS=1", + "ERROR_CODE="+string(tc.code), + "ERROR_DESC="+tc.description, + ) + + stderr, _ := cmd.StderrPipe() + _ = cmd.Start() + + stderrOutput := make([]byte, 4096) + n, _ := stderr.Read(stderrOutput) + stderrStr := string(stderrOutput[:n]) + + err := cmd.Wait() + + // All errors should exit with ExitCodeError (1) + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() != appconstants.ExitCodeError { + t.Errorf("expected exit code %d, got %d", appconstants.ExitCodeError, exitErr.ExitCode()) + } + } else if err != nil { + t.Fatalf(testutil.TestErrUnexpected, err) + } else { + t.Error("expected non-zero exit code") + } + + // Verify error message appears in output + if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(tc.description)) { + t.Errorf("stderr missing expected error description %q, got: %s", tc.description, stderrStr) + } + }) + } +} + +// runErrorCodeTest handles subprocess execution for error code tests. +func runErrorCodeTest() { + code := appconstants.ErrorCode(os.Getenv("ERROR_CODE")) + desc := os.Getenv("ERROR_DESC") + + output := internal.NewColoredOutput(false) + handler := internal.NewErrorHandler(output) + + err := apperrors.New(code, desc) + handler.HandleError(err) +} + +// TestErrorHandlerWithComplexContext tests error handler with multiple context values and suggestions. +func TestErrorHandlerWithComplexContext(t *testing.T) { + t.Parallel() + + if os.Getenv(envGoTestSubprocess) == "1" { + runComplexContextTest() + + return + } + + //nolint:gosec // Controlled test arguments + cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerWithComplexContext$") + cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1") + + stderr, _ := cmd.StderrPipe() + _ = cmd.Start() + + stderrOutput := make([]byte, 8192) + n, _ := stderr.Read(stderrOutput) + stderrStr := string(stderrOutput[:n]) + + _ = cmd.Wait() + + // Verify all context keys are displayed + contextKeys := []string{"path", "action", "reason"} + for _, key := range contextKeys { + if !strings.Contains(stderrStr, key) { + t.Errorf("stderr missing context key %q", key) + } + } + + // Verify suggestions are displayed + suggestions := []string{"Check the file path", "Verify YAML syntax", "Consult documentation"} + for _, suggestion := range suggestions { + if !strings.Contains(stderrStr, suggestion) { + t.Errorf("stderr missing suggestion %q", suggestion) + } + } +} + +// runComplexContextTest handles subprocess execution for complex context test. +func runComplexContextTest() { + output := internal.NewColoredOutput(false) + handler := internal.NewErrorHandler(output) + + err := apperrors.New(appconstants.ErrCodeInvalidYAML, "YAML parsing failed") + err = err.WithDetails(map[string]string{ + "path": "/path/to/action.yml", + "action": "parse-workflow", + "reason": "invalid syntax at line 42", + }) + err = err.WithSuggestions( + "Check the file path is correct", + "Verify YAML syntax is valid", + "Consult documentation for proper format", + ) + + handler.HandleError(err) +} diff --git a/internal/errorhandler_integration_test_helpers.go b/internal/errorhandler_integration_test_helpers.go new file mode 100644 index 0000000..b47bda6 --- /dev/null +++ b/internal/errorhandler_integration_test_helpers.go @@ -0,0 +1,62 @@ +package internal + +import ( + "io" + "os" + "os/exec" + "strings" + "testing" +) + +// spawnTestSubprocess creates and configures a test subprocess. +// This helper reduces cognitive complexity in integration tests by centralizing +// the subprocess creation logic. +// +//nolint:unused // Prepared for future use in errorhandler integration tests +func spawnTestSubprocess(t *testing.T, testType string) *exec.Cmd { + t.Helper() + + //nolint:gosec // G204: Controlled test arguments, not user input + cmd := exec.Command(os.Args[0], "-test.run=TestErrorHandlerIntegration") + cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1", "TEST_TYPE="+testType) + + return cmd +} + +// assertSubprocessExit validates subprocess exit code and stderr. +// This helper reduces cognitive complexity in integration tests by centralizing +// the subprocess validation logic that was repeated across test cases. +// +//nolint:unused // Prepared for future use in errorhandler integration tests +func assertSubprocessExit(t *testing.T, cmd *exec.Cmd, expectedExitCode int, stderrPattern string) { + t.Helper() + + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("failed to create stderr pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start subprocess: %v", err) + } + + stderrBytes, _ := io.ReadAll(stderr) + stderrStr := string(stderrBytes) + + err = cmd.Wait() + + // Validate exit code + exitCode := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + + if exitCode != expectedExitCode { + t.Errorf("exit code = %d, want %d", exitCode, expectedExitCode) + } + + // Validate stderr contains pattern + if stderrPattern != "" && !strings.Contains(stderrStr, stderrPattern) { + t.Errorf("stderr does not contain %q, got: %s", stderrPattern, stderrStr) + } +} diff --git a/internal/errorhandler_test.go b/internal/errorhandler_test.go new file mode 100644 index 0000000..4d499c8 --- /dev/null +++ b/internal/errorhandler_test.go @@ -0,0 +1,321 @@ +package internal + +import ( + "errors" + "os" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// newTestErrorHandler creates an ErrorHandler for testing with quiet output. +// Reduces duplication across error handler tests. +func newTestErrorHandler() *ErrorHandler { + return NewErrorHandler(&ColoredOutput{NoColor: true, Quiet: true}) +} + +// TestNewErrorHandler tests error handler creation. +func TestNewErrorHandler(t *testing.T) { + output := &ColoredOutput{NoColor: true, Quiet: true} + handler := NewErrorHandler(output) + + if handler == nil { + t.Fatal("NewErrorHandler() returned nil") + } + + if handler.output != output { + t.Error("NewErrorHandler() did not set output correctly") + } +} + +// TestDetermineErrorCode tests error code determination. +// + +func TestDetermineErrorCode(t *testing.T) { + handler := newTestErrorHandler() + + tests := []struct { + name string + err error + wantCode appconstants.ErrorCode + }{ + { + name: "file not found - typed error", + err: apperrors.ErrFileNotFound, + wantCode: appconstants.ErrCodeFileNotFound, + }, + { + name: "file not found - os.ErrNotExist", + err: os.ErrNotExist, + wantCode: appconstants.ErrCodeFileNotFound, + }, + { + name: "permission denied - typed error", + err: apperrors.ErrPermissionDenied, + wantCode: appconstants.ErrCodePermission, + }, + { + name: "permission denied - os.ErrPermission", + err: os.ErrPermission, + wantCode: appconstants.ErrCodePermission, + }, + { + name: "invalid YAML", + err: apperrors.ErrInvalidYAML, + wantCode: appconstants.ErrCodeInvalidYAML, + }, + { + name: "GitHub API error", + err: apperrors.ErrGitHubAPI, + wantCode: appconstants.ErrCodeGitHubAPI, + }, + { + name: "configuration error", + err: apperrors.ErrConfiguration, + wantCode: appconstants.ErrCodeConfiguration, + }, + { + name: "unknown error", + err: errors.New("some random error"), + wantCode: appconstants.ErrCodeUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handler.determineErrorCode(tt.err) + if got != tt.wantCode { + t.Errorf("determineErrorCode() = %v, want %v", got, tt.wantCode) + } + }) + } +} + +// TestCheckTypedError tests typed error checking. +// + +func TestCheckTypedError(t *testing.T) { + handler := newTestErrorHandler() + + tests := []struct { + name string + err error + wantCode appconstants.ErrorCode + }{ + { + name: "ErrFileNotFound", + err: apperrors.ErrFileNotFound, + wantCode: appconstants.ErrCodeFileNotFound, + }, + { + name: "os.ErrNotExist", + err: os.ErrNotExist, + wantCode: appconstants.ErrCodeFileNotFound, + }, + { + name: "ErrPermissionDenied", + err: apperrors.ErrPermissionDenied, + wantCode: appconstants.ErrCodePermission, + }, + { + name: "os.ErrPermission", + err: os.ErrPermission, + wantCode: appconstants.ErrCodePermission, + }, + { + name: "ErrInvalidYAML", + err: apperrors.ErrInvalidYAML, + wantCode: appconstants.ErrCodeInvalidYAML, + }, + { + name: "ErrGitHubAPI", + err: apperrors.ErrGitHubAPI, + wantCode: appconstants.ErrCodeGitHubAPI, + }, + { + name: "ErrConfiguration", + err: apperrors.ErrConfiguration, + wantCode: appconstants.ErrCodeConfiguration, + }, + { + name: "unknown error", + err: errors.New(testutil.UnknownErrorMsg), + wantCode: appconstants.ErrCodeUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handler.checkTypedError(tt.err) + if got != tt.wantCode { + t.Errorf("checkTypedError() = %v, want %v", got, tt.wantCode) + } + }) + } +} + +// TestCheckStringPatterns tests string pattern matching. +func TestCheckStringPatterns(t *testing.T) { + handler := newTestErrorHandler() + + tests := []struct { + name string + errStr string + wantCode appconstants.ErrorCode + }{ + { + name: "file not found pattern", + errStr: "no such file or directory", + wantCode: appconstants.ErrCodeFileNotFound, + }, + { + name: "permission denied pattern", + errStr: "permission denied", + wantCode: appconstants.ErrCodePermission, + }, + { + name: "YAML error pattern", + errStr: "yaml: unmarshal error", + wantCode: appconstants.ErrCodeInvalidYAML, + }, + { + name: "GitHub API pattern", + errStr: "GitHub API error", + wantCode: appconstants.ErrCodeGitHubAPI, + }, + { + name: "configuration pattern", + errStr: "configuration error", + wantCode: appconstants.ErrCodeConfiguration, + }, + { + name: "unknown pattern", + errStr: "some random error message", + wantCode: appconstants.ErrCodeUnknown, + }, + { + name: "case insensitive matching", + errStr: "PERMISSION DENIED", + wantCode: appconstants.ErrCodePermission, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := handler.checkStringPatterns(tt.errStr) + if got != tt.wantCode { + t.Errorf("checkStringPatterns(%q) = %v, want %v", tt.errStr, got, tt.wantCode) + } + }) + } +} + +// TestContains tests the contains helper function. +func TestContains(t *testing.T) { + tests := []struct { + name string + s string + substr string + want bool + }{ + { + name: "exact match", + s: testutil.HelloWorldStr, + substr: "hello", + want: true, + }, + { + name: "case insensitive match", + s: "Hello World", + substr: "hello", + want: true, + }, + { + name: "no match", + s: testutil.HelloWorldStr, + substr: "goodbye", + want: false, + }, + { + name: "empty substring", + s: testutil.HelloWorldStr, + substr: "", + want: true, + }, + { + name: "empty string", + s: "", + substr: "hello", + want: false, + }, + { + name: "substring in middle", + s: "the quick brown fox", + substr: "quick", + want: true, + }, + { + name: "case insensitive - uppercase string", + s: "ERROR: PERMISSION DENIED", + substr: "permission", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contains(tt.s, tt.substr) + if got != tt.want { + t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want) + } + }) + } +} + +// NOTE: HandleSimpleError testing is covered by TestDetermineErrorCode +// since HandleSimpleError calls determineErrorCode and then os.Exit(). +// Testing os.Exit() directly is not practical in unit tests. + +// TestFatalErrorComponents tests the components used in fatal error handling. +// NOTE: We cannot test HandleFatalError directly as it calls os.Exit(). +// This test verifies that error construction components work correctly. +func TestFatalErrorComponents(t *testing.T) { + // Test the logic that HandleFatalError uses before calling os.Exit + + handler := newTestErrorHandler() + + // Test that HandleFatalError correctly constructs contextual errors + code := appconstants.ErrCodeFileNotFound + message := "test error message" + context := map[string]string{"file": "test.yml"} + + // Verify suggestions and help URL are retrieved + suggestions := apperrors.GetSuggestions(code, context) + helpURL := apperrors.GetHelpURL(code) + + // ErrCodeFileNotFound should have suggestions and help URL + if len(suggestions) == 0 { + t.Errorf("GetSuggestions(%v) returned empty, expected non-empty for ErrCodeFileNotFound", code) + } + + if helpURL == "" { + t.Errorf("GetHelpURL(%v) returned empty string, expected URL for ErrCodeFileNotFound", code) + } + + // Verify error construction (without calling HandleFatalError which exits) + contextualErr := apperrors.New(code, message). + WithSuggestions(suggestions...). + WithHelpURL(helpURL). + WithDetails(context) + + if contextualErr == nil { + t.Error("failed to construct contextual error") + } + + // Verify handler is properly initialized + if handler.output == nil { + t.Error("handler output is nil") + } +} diff --git a/internal/focused_consumers_test.go b/internal/focused_consumers_test.go new file mode 100644 index 0000000..357fd7a --- /dev/null +++ b/internal/focused_consumers_test.go @@ -0,0 +1,284 @@ +package internal + +import ( + "errors" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// compositeOutputWriterForTest wraps testutil mocks to satisfy OutputWriter interface. +type compositeOutputWriterForTest struct { + *testutil.MessageLoggerMock + *testutil.ProgressReporterMock + *testutil.OutputConfigMock +} + +// errorManagerForTest wraps testutil mocks to satisfy ErrorManager interface. +type errorManagerForTest struct { + *testutil.ErrorReporterMock + *testutil.ErrorFormatterMock +} + +// FormatContextualError implements ErrorManager interface. +func (e *errorManagerForTest) FormatContextualError(err *apperrors.ContextualError) string { + if err != nil { + return e.ErrorFormatterMock.FormatContextualError(err) + } + + return "" +} + +// ErrorWithSuggestions implements ErrorManager interface. +func (e *errorManagerForTest) ErrorWithSuggestions(err *apperrors.ContextualError) { + e.ErrorReporterMock.ErrorWithSuggestions(err) +} + +// TestNewCompositeOutputWriter tests the composite output writer constructor. +func TestNewCompositeOutputWriter(t *testing.T) { + t.Parallel() + + writer := &compositeOutputWriterForTest{ + MessageLoggerMock: &testutil.MessageLoggerMock{}, + ProgressReporterMock: &testutil.ProgressReporterMock{}, + OutputConfigMock: &testutil.OutputConfigMock{}, + } + cow := NewCompositeOutputWriter(writer) + + if cow == nil { + t.Fatal("NewCompositeOutputWriter() returned nil") + } + + if cow.writer != writer { + t.Error("NewCompositeOutputWriter() did not set writer correctly") + } +} + +// TestCompositeOutputWriterProcessWithOutput tests processing with output. +func TestCompositeOutputWriterProcessWithOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isQuiet bool + items []string + wantMessages int + wantInfo bool + wantProgress bool + wantSuccess bool + }{ + { + name: "with items not quiet", + isQuiet: false, + items: []string{"item1", "item2", "item3"}, + wantMessages: 5, // 1 info + 3 progress + 1 success + wantInfo: true, + wantProgress: true, + wantSuccess: true, + }, + { + name: "with quiet mode", + isQuiet: true, + items: []string{"item1", "item2"}, + wantMessages: 0, + wantInfo: false, + wantProgress: false, + wantSuccess: false, + }, + { + name: "with empty items", + isQuiet: false, + items: []string{}, + wantMessages: 2, // 1 info + 1 success + wantInfo: true, + wantProgress: false, + wantSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + logger := &testutil.MessageLoggerMock{} + progress := &testutil.ProgressReporterMock{} + writer := &compositeOutputWriterForTest{ + MessageLoggerMock: logger, + ProgressReporterMock: progress, + OutputConfigMock: &testutil.OutputConfigMock{QuietMode: tt.isQuiet}, + } + cow := NewCompositeOutputWriter(writer) + + cow.ProcessWithOutput(tt.items) + + totalMessages := len(logger.InfoCalls) + len(progress.ProgressCalls) + len(logger.SuccessCalls) + if totalMessages != tt.wantMessages { + t.Errorf("ProcessWithOutput() produced %d messages, want %d", + totalMessages, tt.wantMessages) + } + + hasInfo := len(logger.InfoCalls) > 0 + hasProgress := len(progress.ProgressCalls) > 0 + hasSuccess := len(logger.SuccessCalls) > 0 + + if hasInfo != tt.wantInfo { + t.Errorf("ProcessWithOutput() hasInfo = %v, want %v", hasInfo, tt.wantInfo) + } + if hasProgress != tt.wantProgress { + t.Errorf("ProcessWithOutput() hasProgress = %v, want %v", hasProgress, tt.wantProgress) + } + if hasSuccess != tt.wantSuccess { + t.Errorf("ProcessWithOutput() hasSuccess = %v, want %v", hasSuccess, tt.wantSuccess) + } + }) + } +} + +// TestNewValidationComponent tests the validation component constructor. +func TestNewValidationComponent(t *testing.T) { + t.Parallel() + + errorManager := &errorManagerForTest{ + ErrorReporterMock: &testutil.ErrorReporterMock{}, + ErrorFormatterMock: &testutil.ErrorFormatterMock{}, + } + logger := &testutil.MessageLoggerMock{} + + vc := NewValidationComponent(errorManager, logger) + + if vc == nil { + t.Fatal("NewValidationComponent() returned nil") + } + + if vc.errorManager != errorManager { + t.Error("NewValidationComponent() did not set errorManager correctly") + } + + if vc.logger != logger { + t.Error("NewValidationComponent() did not set logger correctly") + } +} + +// getErrorCallType returns the type of error call that was made. +func getErrorCallType(reporter *testutil.ErrorReporterMock) string { + switch { + case len(reporter.ErrorWithSuggestionsCalls) > 0: + return "ErrorWithSuggestions" + case len(reporter.ErrorCalls) > 0: + return "Error" + case len(reporter.ErrorWithSimpleFixCalls) > 0: + return "ErrorWithSimpleFix" + case len(reporter.ErrorWithContextCalls) > 0: + return "ErrorWithContext" + default: + return "" + } +} + +// TestValidationComponentValidateAndReport tests validation reporting. +func TestValidationComponentValidateAndReport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + item string + isValid bool + err error + wantLoggerCalls int + wantErrorCalls int + wantErrorCallType string + }{ + { + name: "valid item", + item: testutil.TestItemName, + isValid: true, + err: nil, + wantLoggerCalls: 1, + wantErrorCalls: 0, + wantErrorCallType: "", + }, + { + name: "invalid with contextual error", + item: testutil.TestItemName, + isValid: false, + err: apperrors.New(appconstants.ErrCodeValidation, "validation failed"), + wantLoggerCalls: 0, + wantErrorCalls: 1, + wantErrorCallType: "ErrorWithSuggestions", + }, + { + name: "invalid with regular error", + item: testutil.TestItemName, + isValid: false, + err: errors.New("regular error"), + wantLoggerCalls: 0, + wantErrorCalls: 1, + wantErrorCallType: "Error", + }, + { + name: "invalid without error", + item: testutil.TestItemName, + isValid: false, + err: nil, + wantLoggerCalls: 0, + wantErrorCalls: 1, + wantErrorCallType: "ErrorWithSimpleFix", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + errorReporter := &testutil.ErrorReporterMock{} + errorManager := &errorManagerForTest{ + ErrorReporterMock: errorReporter, + ErrorFormatterMock: &testutil.ErrorFormatterMock{}, + } + logger := &testutil.MessageLoggerMock{} + vc := NewValidationComponent(errorManager, logger) + + vc.ValidateAndReport(tt.item, tt.isValid, tt.err) + + totalLoggerCalls := len( + logger.InfoCalls, + ) + len( + logger.SuccessCalls, + ) + len( + logger.WarningCalls, + ) + len( + logger.BoldCalls, + ) + len( + logger.PrintfCalls, + ) + if totalLoggerCalls != tt.wantLoggerCalls { + t.Errorf("ValidateAndReport() logger calls = %d, want %d", + totalLoggerCalls, tt.wantLoggerCalls) + } + + totalErrorCalls := len( + errorReporter.ErrorCalls, + ) + len( + errorReporter.ErrorWithSuggestionsCalls, + ) + len( + errorReporter.ErrorWithContextCalls, + ) + len( + errorReporter.ErrorWithSimpleFixCalls, + ) + if totalErrorCalls != tt.wantErrorCalls { + t.Errorf("ValidateAndReport() error calls = %d, want %d", + totalErrorCalls, tt.wantErrorCalls) + } + + if tt.wantErrorCallType != "" { + actualCallType := getErrorCallType(errorReporter) + if actualCallType != tt.wantErrorCallType { + t.Errorf("ValidateAndReport() error call type = %s, want %s", + actualCallType, tt.wantErrorCallType) + } + } + }) + } +} diff --git a/internal/generator.go b/internal/generator.go index b283a39..692dc85 100644 --- a/internal/generator.go +++ b/internal/generator.go @@ -48,7 +48,13 @@ func isUnitTestEnvironment() bool { // NewGenerator creates a new generator instance with the provided configuration. // This constructor maintains backward compatibility by using concrete implementations. // In unit test environments, it automatically uses NullOutput to suppress output. +// If config is nil, it uses DefaultAppConfig() to prevent panics. func NewGenerator(config *AppConfig) *Generator { + // Handle nil config gracefully + if config == nil { + config = DefaultAppConfig() + } + // Use null output in unit test environments to keep tests clean // Integration tests need real output to verify CLI behavior if isUnitTestEnvironment() { @@ -289,31 +295,47 @@ func (g *Generator) renderTemplateForAction( return content, nil } -// generateMarkdown creates a README.md file using the template. -func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error { +// generateSimpleFormat is a helper for generating simple text-based formats (Markdown, AsciiDoc). +// It consolidates the common pattern of template rendering, file writing, and success messaging. +func (g *Generator) generateSimpleFormat( + action *ActionYML, + outputDir, actionPath string, + format, defaultFilename, successMsg string, +) error { templatePath := g.resolveTemplatePathForFormat() opts := TemplateOptions{ TemplatePath: templatePath, - Format: "md", + Format: format, } content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts) if err != nil { - return fmt.Errorf("failed to render markdown template: %w", err) + return fmt.Errorf("failed to render %s template: %w", format, err) } - outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeMarkdown) + outputPath, err := g.resolveOutputPath(outputDir, defaultFilename) + if err != nil { + return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err) + } if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil { // #nosec G306 -- output file permissions - return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err) + return fmt.Errorf("failed to write %s to %s: %w", format, outputPath, err) } - g.Output.Success("Generated README.md: %s", outputPath) + g.Output.Success("%s: %s", successMsg, outputPath) return nil } +// generateMarkdown creates a README.md file using the template. +func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error { + return g.generateSimpleFormat( + action, outputDir, actionPath, + "md", appconstants.ReadmeMarkdown, "Generated README.md", + ) +} + // generateHTML creates an HTML file using the template and optional header/footer. func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error { templatePath := g.resolveTemplatePathForFormat() @@ -337,7 +359,10 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string } defaultFilename := action.Name + ".html" - outputPath := g.resolveOutputPath(outputDir, defaultFilename) + outputPath, err := g.resolveOutputPath(outputDir, defaultFilename) + if err != nil { + return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err) + } if err := writer.Write(content, outputPath); err != nil { return fmt.Errorf("failed to write HTML to %s: %w", outputPath, err) } @@ -351,7 +376,10 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string func (g *Generator) generateJSON(action *ActionYML, outputDir string) error { writer := NewJSONWriter(g.Config) - outputPath := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON) + outputPath, err := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON) + if err != nil { + return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err) + } if err := writer.Write(action, outputPath); err != nil { return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err) } @@ -363,27 +391,10 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error { // generateASCIIDoc creates an AsciiDoc file using the template. func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error { - templatePath := g.resolveTemplatePathForFormat() - - opts := TemplateOptions{ - TemplatePath: templatePath, - Format: "asciidoc", - } - - content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts) - if err != nil { - return fmt.Errorf("failed to render AsciiDoc template: %w", err) - } - - outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeASCIIDoc) - if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil { - // #nosec G306 -- output file permissions - return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err) - } - - g.Output.Success("Generated AsciiDoc: %s", outputPath) - - return nil + return g.generateSimpleFormat( + action, outputDir, actionPath, + "asciidoc", appconstants.ReadmeASCIIDoc, "Generated AsciiDoc", + ) } // processFiles processes each file and tracks results. @@ -468,17 +479,56 @@ func (g *Generator) determineOutputDir(actionPath string) string { return g.Config.OutputDir } -// resolveOutputPath resolves the final output path, considering custom filename. -func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string { +// resolveOutputPath resolves the final output path and validates it prevents path traversal. +// Returns an error if the resolved path would escape the outputDir. +func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) (string, error) { + // Determine the filename to use + filename := defaultFilename if g.Config.OutputFilename != "" { - if filepath.IsAbs(g.Config.OutputFilename) { - return g.Config.OutputFilename - } - - return filepath.Join(outputDir, g.Config.OutputFilename) + filename = g.Config.OutputFilename } - return filepath.Join(outputDir, defaultFilename) + // Reject paths containing .. components (path traversal attempt) + if strings.Contains(filename, "..") { + return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir) + } + + // Handle absolute paths - allow them as-is (user's explicit choice) + if filepath.IsAbs(filename) { + cleaned := filepath.Clean(filename) + if cleaned != filename { + return "", fmt.Errorf("absolute path contains extraneous components: %s", filename) + } + + return cleaned, nil + } + + // For relative paths, join with output directory + finalPath := filepath.Join(outputDir, filename) + + // Validate the final path stays within outputDir + absOutputDir, err := filepath.Abs(outputDir) + if err != nil { + return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err) + } + + absFinalPath, err := filepath.Abs(finalPath) + if err != nil { + return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err) + } + + // Check if final path is within output directory using filepath.Rel + relPath, err := filepath.Rel(absOutputDir, absFinalPath) + if err != nil { + return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err) + } + + // If relative path starts with "..", it's outside the output directory + if strings.HasPrefix(relPath, "..") { + return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir) + } + + return absFinalPath, nil } // generateByFormat generates documentation in the specified format. diff --git a/internal/generator_comprehensive_test.go b/internal/generator_comprehensive_test.go index 4e01575..a1a1fd4 100644 --- a/internal/generator_comprehensive_test.go +++ b/internal/generator_comprehensive_test.go @@ -5,12 +5,13 @@ import ( "path/filepath" "testing" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/testutil" ) -// TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework +// TestGeneratorComprehensiveGeneration demonstrates the new table-driven testing framework // by testing generation across all fixtures, themes, and formats systematically. -func TestGenerator_ComprehensiveGeneration(t *testing.T) { +func TestGeneratorComprehensiveGeneration(t *testing.T) { t.Parallel() // Create test cases using the new helper functions cases := testutil.CreateGeneratorTestCases() @@ -32,8 +33,8 @@ func TestGenerator_ComprehensiveGeneration(t *testing.T) { testutil.RunGeneratorTests(t, filteredCases) } -// TestGenerator_AllValidFixtures tests generation with all valid fixtures. -func TestGenerator_AllValidFixtures(t *testing.T) { +// TestGeneratorAllValidFixtures tests generation with all valid fixtures. +func TestGeneratorAllValidFixtures(t *testing.T) { t.Parallel() validFixtures := testutil.GetValidFixtures() @@ -64,8 +65,8 @@ func TestGenerator_AllValidFixtures(t *testing.T) { } } -// TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors. -func TestGenerator_AllInvalidFixtures(t *testing.T) { +// TestGeneratorAllInvalidFixtures tests that invalid fixtures produce expected errors. +func TestGeneratorAllInvalidFixtures(t *testing.T) { t.Parallel() invalidFixtures := testutil.GetInvalidFixtures() @@ -106,8 +107,8 @@ func TestGenerator_AllInvalidFixtures(t *testing.T) { } } -// TestGenerator_AllThemes demonstrates theme testing using helper functions. -func TestGenerator_AllThemes(t *testing.T) { +// TestGeneratorAllThemes demonstrates theme testing using helper functions. +func TestGeneratorAllThemes(t *testing.T) { t.Parallel() // Use the helper function to test all themes testutil.TestAllThemes(t, func(t *testing.T, theme string) { @@ -129,8 +130,8 @@ func TestGenerator_AllThemes(t *testing.T) { }) } -// TestGenerator_AllFormats demonstrates format testing using helper functions. -func TestGenerator_AllFormats(t *testing.T) { +// TestGeneratorAllFormats demonstrates format testing using helper functions. +func TestGeneratorAllFormats(t *testing.T) { t.Parallel() // Use the helper function to test all formats testutil.TestAllFormats(t, func(t *testing.T, format string) { @@ -152,8 +153,8 @@ func TestGenerator_AllFormats(t *testing.T) { }) } -// TestGenerator_ByActionType demonstrates testing by action type. -func TestGenerator_ByActionType(t *testing.T) { +// TestGeneratorByActionType demonstrates testing by action type. +func TestGeneratorByActionType(t *testing.T) { t.Parallel() actionTypes := []testutil.ActionType{ testutil.ActionTypeJavaScript, @@ -190,8 +191,8 @@ func TestGenerator_ByActionType(t *testing.T) { } } -// TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment. -func TestGenerator_WithMockEnvironment(t *testing.T) { +// TestGeneratorWithMockEnvironment demonstrates testing with a complete mock environment. +func TestGeneratorWithMockEnvironment(t *testing.T) { t.Parallel() // Create a complete test environment envConfig := &testutil.EnvironmentConfig{ @@ -227,8 +228,8 @@ func TestGenerator_WithMockEnvironment(t *testing.T) { testutil.AssertNoError(t, err) } -// TestGenerator_FixtureValidation demonstrates fixture validation. -func TestGenerator_FixtureValidation(t *testing.T) { +// TestGeneratorFixtureValidation demonstrates fixture validation. +func TestGeneratorFixtureValidation(t *testing.T) { t.Parallel() // Test that all valid fixtures pass validation validFixtures := testutil.GetValidFixtures() @@ -271,7 +272,7 @@ func createGeneratorTestExecutor() testutil.TestExecutor { } // Create temporary action file - actionPath = filepath.Join(ctx.TempDir, "action.yml") + actionPath = filepath.Join(ctx.TempDir, appconstants.ActionFileNameYML) testutil.WriteTestFile(t, actionPath, fixture.Content) } diff --git a/internal/generator_helper_test.go b/internal/generator_helper_test.go new file mode 100644 index 0000000..8d21796 --- /dev/null +++ b/internal/generator_helper_test.go @@ -0,0 +1,139 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" +) + +// TestDefaultTestConfig_Helper tests the defaultTestConfig helper function. +func TestDefaultTestConfigHelper(t *testing.T) { + t.Parallel() + + // Call the helper multiple times to verify consistency + cfg1 := defaultTestConfig() + cfg2 := defaultTestConfig() + + // Verify expected defaults + if cfg1.Quiet != true { + t.Error("expected Quiet=true for test config") + } + if cfg1.Theme != appconstants.ThemeDefault { + t.Errorf("expected default theme, got %s", cfg1.Theme) + } + if cfg1.OutputFormat != appconstants.OutputFormatMarkdown { + t.Errorf("expected markdown format, got %s", cfg1.OutputFormat) + } + if cfg1.OutputDir != "." { + t.Errorf("expected OutputDir='.', got %s", cfg1.OutputDir) + } + + // Verify immutability - modifying one shouldn't affect others + cfg1.Quiet = false + cfg1.Theme = "custom" + + if cfg2.Quiet != true { + t.Error("defaultTestConfig should return independent configs") + } + if cfg2.Theme != appconstants.ThemeDefault { + t.Error("defaultTestConfig should return independent configs") + } + + // Verify getting a fresh config after modification + cfg3 := defaultTestConfig() + if cfg3.Quiet != true { + t.Error("defaultTestConfig should always return Quiet=true") + } +} + +// TestAssertActionFiles_Helper tests the assertActionFiles helper function. +func TestAssertActionFilesHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files []string + setup func(*testing.T) []string + wantErr bool + }{ + { + name: "empty file list", + setup: func(t *testing.T) []string { + t.Helper() + + return []string{} + }, + }, + { + name: "valid action.yml files", + setup: func(t *testing.T) []string { + t.Helper() + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML) + file2 := filepath.Join(tmpDir2, appconstants.ActionFileNameYML) + + err := os.WriteFile(file1, []byte("name: test"), appconstants.FilePermDefault) + if err != nil { + t.Fatalf("failed to write file1: %v", err) + } + + err = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault) + if err != nil { + t.Fatalf("failed to write file2: %v", err) + } + + return []string{file1, file2} + }, + }, + { + name: "valid action.yaml files", + setup: func(t *testing.T) []string { + t.Helper() + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "action.yaml") + + err := os.WriteFile(file, []byte("name: test"), appconstants.FilePermDefault) + if err != nil { + t.Fatalf("failed to write file: %v", err) + } + + return []string{file} + }, + }, + { + name: "mixed yml and yaml extensions", + setup: func(t *testing.T) []string { + t.Helper() + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML) + file2 := filepath.Join(tmpDir2, "action.yaml") + + _ = os.WriteFile(file1, []byte("name: test1"), appconstants.FilePermDefault) + + _ = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault) + + return []string{file1, file2} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + files := tt.setup(t) + + // Call the helper - it will verify files exist and have correct extensions + // For invalid files, it will call t.Error (which is expected) + assertActionFiles(t, files) + }) + } +} + +// Note: Invalid test cases (wrong extensions, nonexistent files) are not included +// because testing error paths would require mocking testing.T, which is complex. +// The helper is already well-tested through the main test suite for error cases. diff --git a/internal/generator_test.go b/internal/generator_test.go index c89e846..1c9fee2 100644 --- a/internal/generator_test.go +++ b/internal/generator_test.go @@ -7,18 +7,56 @@ import ( "testing" "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" "github.com/ivuorinen/gh-action-readme/testutil" ) -func TestGenerator_NewGenerator(t *testing.T) { - t.Parallel() - config := &AppConfig{ - Theme: "default", - OutputFormat: "md", +// defaultTestConfig returns an AppConfig with sensible test defaults. +// Sets Quiet: true to suppress output during tests. +func defaultTestConfig() *AppConfig { + return &AppConfig{ + Theme: appconstants.ThemeDefault, + OutputFormat: appconstants.OutputFormatMarkdown, OutputDir: ".", - Verbose: false, - Quiet: false, + Quiet: true, } +} + +// assertActionFiles verifies that all files are valid action files. +func assertActionFiles(t *testing.T, files []string) { + t.Helper() + for _, file := range files { + testutil.AssertFileExists(t, file) + if !strings.HasSuffix(file, appconstants.ActionFileNameYML) && + !strings.HasSuffix(file, appconstants.ActionFileNameYAML) { + t.Errorf("discovered file is not an action file: %s", file) + } + } +} + +// createMultipleFixtureFiles writes multiple fixtures to files and returns their paths. +// This helper reduces duplication for tests that set up multiple action files. +func createMultipleFixtureFiles( + t *testing.T, + tmpDir string, + filesAndFixtures map[string]string, +) []string { + t.Helper() + + files := make([]string, 0, len(filesAndFixtures)) + for filename, fixturePath := range filesAndFixtures { + filePath := filepath.Join(tmpDir, filename) + testutil.WriteTestFile(t, filePath, testutil.MustReadFixture(fixturePath)) + files = append(files, filePath) + } + + return files +} + +func TestGeneratorNewGenerator(t *testing.T) { + t.Parallel() + config := defaultTestConfig() + config.Quiet = false // Override for this test generator := NewGenerator(config) @@ -35,7 +73,7 @@ func TestGenerator_NewGenerator(t *testing.T) { } } -func TestGenerator_DiscoverActionFiles(t *testing.T) { +func TestGeneratorDiscoverActionFiles(t *testing.T) { t.Parallel() tests := []struct { name string @@ -48,7 +86,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { name: "single action.yml in root", setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, recursive: false, expectedLen: 1, @@ -61,7 +99,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { t, tmpDir, appconstants.ActionFileNameYAML, - appconstants.TestFixtureJavaScriptSimple, + testutil.TestFixtureJavaScriptSimple, ) }, recursive: false, @@ -71,12 +109,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { name: "both yml and yaml files", setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) testutil.WriteActionFixtureAs( t, tmpDir, appconstants.ActionFileNameYAML, - appconstants.TestFixtureMinimalAction, + testutil.TestFixtureMinimalAction, ) }, recursive: false, @@ -86,12 +124,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { name: "recursive discovery", setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) testutil.CreateActionSubdir( t, tmpDir, - appconstants.TestDirSubdir, - appconstants.TestFixtureCompositeBasic, + testutil.TestDirSubdir, + testutil.TestFixtureCompositeBasic, ) }, recursive: true, @@ -101,12 +139,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { name: "non-recursive skips subdirectories", setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) testutil.CreateActionSubdir( t, tmpDir, - appconstants.TestDirSubdir, - appconstants.TestFixtureCompositeBasic, + testutil.TestDirSubdir, + testutil.TestFixtureCompositeBasic, ) }, recursive: false, @@ -116,7 +154,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") + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ReadmeMarkdown), "# Test") }, recursive: false, expectedLen: 0, @@ -135,7 +173,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - config := &AppConfig{Quiet: true} + config := defaultTestConfig() generator := NewGenerator(config) testDir := tmpDir @@ -156,20 +194,84 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) { testutil.AssertNoError(t, err) testutil.AssertEqual(t, tt.expectedLen, len(files)) - // Verify all returned files exist and are action files - for _, file := range files { - testutil.AssertFileExists(t, file) + assertActionFiles(t, files) + }) + } +} - if !strings.HasSuffix(file, appconstants.ActionFileNameYML) && - !strings.HasSuffix(file, appconstants.ActionFileNameYAML) { - t.Errorf("discovered file is not an action file: %s", file) - } +func TestGeneratorDiscoverActionFilesVerbose(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + recursive bool + }{ + { + name: "verbose non-recursive", + recursive: false, + }, + { + name: "verbose recursive", + recursive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Create test action file + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + if tt.recursive { + testutil.CreateActionSubdir(t, tmpDir, "subdir", testutil.TestFixtureCompositeBasic) + } + + // Create generator with verbose mode enabled + config := defaultTestConfig() + config.Verbose = true + generator := NewGenerator(config) + + files, err := generator.DiscoverActionFiles(tmpDir, tt.recursive, []string{}) + + testutil.AssertNoError(t, err) + if tt.recursive { + testutil.AssertEqual(t, 2, len(files)) + } else { + testutil.AssertEqual(t, 1, len(files)) } }) } } -func TestGenerator_GenerateFromFile(t *testing.T) { +// getOutputPattern returns the expected output filename pattern for the given format. +func getOutputPattern(format string) string { + switch format { + case appconstants.OutputFormatHTML: + return "*.html" + case appconstants.OutputFormatJSON: + return "*.json" + case appconstants.OutputFormatASCIIDoc: + return "*.adoc" + default: + return "README*.md" + } +} + +// validateGeneratedContent validates that the generated content contains expected strings. +func validateGeneratedContent(t *testing.T, content []byte, expectedStrings []string) { + t.Helper() + + for _, expected := range expectedStrings { + if !strings.Contains(string(content), expected) { + t.Errorf("Output missing expected string: %q", expected) + } + } +} + +func TestGeneratorGenerateFromFile(t *testing.T) { t.Parallel() tests := []struct { name string @@ -180,22 +282,22 @@ func TestGenerator_GenerateFromFile(t *testing.T) { }{ { name: "simple action to markdown", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), - outputFormat: "md", + actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), + outputFormat: appconstants.OutputFormatMarkdown, expectError: false, contains: []string{"# Simple JavaScript Action", "A simple JavaScript action for testing"}, }, { name: "composite action to markdown", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic), - outputFormat: "md", + actionYML: testutil.MustReadFixture(testutil.TestFixtureCompositeBasic), + outputFormat: appconstants.OutputFormatMarkdown, expectError: false, contains: []string{"# Basic Composite Action", "A simple composite action with basic steps"}, }, { name: "action to HTML", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), - outputFormat: "html", + actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), + outputFormat: appconstants.OutputFormatHTML, expectError: false, contains: []string{ "Simple JavaScript Action", @@ -204,8 +306,8 @@ func TestGenerator_GenerateFromFile(t *testing.T) { }, { name: "action to JSON", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), - outputFormat: "json", + actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), + outputFormat: appconstants.OutputFormatJSON, expectError: false, contains: []string{ `"name": "Simple JavaScript Action"`, @@ -214,14 +316,14 @@ func TestGenerator_GenerateFromFile(t *testing.T) { }, { name: "invalid action file", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing), - outputFormat: "md", + actionYML: testutil.MustReadFixture(testutil.TestFixtureInvalidInvalidUsing), + outputFormat: appconstants.OutputFormatMarkdown, expectError: true, // Invalid runtime configuration should cause failure contains: []string{}, }, { name: "unknown output format", - actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), + actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), outputFormat: "unknown", expectError: true, }, @@ -245,7 +347,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { OutputFormat: tt.outputFormat, OutputDir: tmpDir, Quiet: true, - Template: filepath.Join(tmpDir, "templates", "readme.tmpl"), + Template: filepath.Join(tmpDir, "templates", appconstants.TemplateReadme), } generator := NewGenerator(config) @@ -261,15 +363,8 @@ func TestGenerator_GenerateFromFile(t *testing.T) { testutil.AssertNoError(t, err) // Find the generated output file based on format - var pattern string - switch tt.outputFormat { - case "html": - pattern = filepath.Join(tmpDir, "*.html") - case "json": - pattern = filepath.Join(tmpDir, "*.json") - default: - pattern = filepath.Join(tmpDir, "README*.md") - } + filename := getOutputPattern(tt.outputFormat) + pattern := filepath.Join(tmpDir, filename) readmeFiles, _ := filepath.Glob(pattern) if len(readmeFiles) == 0 { t.Errorf("no output file was created for format %s", tt.outputFormat) @@ -280,14 +375,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) { // Read and verify output content content, err := os.ReadFile(readmeFiles[0]) testutil.AssertNoError(t, err) - - contentStr := string(content) - for _, expectedStr := range tt.contains { - if !strings.Contains(contentStr, expectedStr) { - t.Errorf("output does not contain expected string %q", expectedStr) - t.Logf("Output content: %s", contentStr) - } - } + validateGeneratedContent(t, content, tt.contains) }) } } @@ -300,7 +388,7 @@ func countREADMEFiles(t *testing.T, dir string) int { if err != nil { return err } - if strings.HasSuffix(path, "README.md") { + if strings.HasSuffix(path, appconstants.ReadmeMarkdown) { count++ } @@ -317,7 +405,7 @@ func countREADMEFiles(t *testing.T, dir string) int { func logREADMELocations(t *testing.T, dir string) { t.Helper() _ = filepath.Walk(dir, func(path string, _ os.FileInfo, err error) error { - if err == nil && strings.HasSuffix(path, "README.md") { + if err == nil && strings.HasSuffix(path, appconstants.ReadmeMarkdown) { t.Logf("Found README at: %s", path) } @@ -325,7 +413,7 @@ func logREADMELocations(t *testing.T, dir string) { }) } -func TestGenerator_ProcessBatch(t *testing.T) { +func TestGeneratorProcessBatch(t *testing.T) { t.Parallel() tests := []struct { name string @@ -335,43 +423,19 @@ 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 - dirs := createTestDirs(t, tmpDir, "action1", "action2") - - files := []string{ - filepath.Join(dirs[0], appconstants.ActionFileNameYML), - filepath.Join(dirs[1], appconstants.ActionFileNameYML), - } - testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) - testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) - - return files - }, + setupFunc: createMultiActionSetup( + []string{"action1", "action2"}, + []string{testutil.TestFixtureJavaScriptSimple, testutil.TestFixtureCompositeBasic}, + ), expectError: false, expectFiles: 2, }, { name: "handle mixed valid and invalid files", - setupFunc: func(t *testing.T, tmpDir string) []string { - t.Helper() - // Create separate directories for mixed test too - dirs := createTestDirs(t, tmpDir, "valid-action", "invalid-action") - - files := []string{ - filepath.Join(dirs[0], appconstants.ActionFileNameYML), - filepath.Join(dirs[1], appconstants.ActionFileNameYML), - } - testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) - testutil.WriteTestFile( - t, - files[1], - testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing), - ) - - return files - }, + setupFunc: createMultiActionSetup( + []string{"valid-action", "invalid-action"}, + []string{testutil.TestFixtureJavaScriptSimple, testutil.TestFixtureInvalidInvalidUsing}, + ), expectError: true, // Invalid runtime configuration should cause batch to fail expectFiles: 0, // No files should be expected when batch fails }, @@ -384,10 +448,8 @@ func TestGenerator_ProcessBatch(t *testing.T) { expectFiles: 0, }, { - name: "nonexistent files", - setupFunc: func(_ *testing.T, tmpDir string) []string { - return []string{filepath.Join(tmpDir, "nonexistent.yml")} - }, + name: "nonexistent files", + setupFunc: setupNonexistentFiles("nonexistent.yml"), expectError: true, }, } @@ -401,12 +463,12 @@ func TestGenerator_ProcessBatch(t *testing.T) { // Set up test templates testutil.SetupTestTemplates(t, tmpDir) - config := &AppConfig{ - OutputFormat: "md", - // Don't set OutputDir so each action generates README in its own directory - Verbose: true, // Enable verbose to see what's happening - Template: filepath.Join(tmpDir, "templates", "readme.tmpl"), - } + config := defaultTestConfig() + config.OutputFormat = appconstants.OutputFormatMarkdown + // Don't set OutputDir so each action generates README in its own directory + config.OutputDir = "" + config.Verbose = true // Enable verbose to see what's happening + config.Template = filepath.Join(tmpDir, "templates", appconstants.TemplateReadme) generator := NewGenerator(config) files := tt.setupFunc(t, tmpDir) @@ -419,7 +481,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { } if err != nil { - t.Errorf("unexpected error: %v", err) + t.Errorf(testutil.TestErrUnexpected, err) return } @@ -437,7 +499,7 @@ func TestGenerator_ProcessBatch(t *testing.T) { } } -func TestGenerator_ValidateFiles(t *testing.T) { +func TestGeneratorValidateFiles(t *testing.T) { t.Parallel() tests := []struct { name string @@ -448,14 +510,11 @@ 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(appconstants.TestFixtureJavaScriptSimple)) - testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureMinimalAction)) - return files + return createMultipleFixtureFiles(t, tmpDir, map[string]string{ + "action1.yml": testutil.TestFixtureJavaScriptSimple, + "action2.yml": testutil.TestFixtureMinimalAction, + }) }, expectError: false, }, @@ -463,26 +522,17 @@ 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(appconstants.TestFixtureJavaScriptSimple)) - testutil.WriteTestFile( - t, - files[1], - testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription), - ) - return files + return createMultipleFixtureFiles(t, tmpDir, map[string]string{ + "valid.yml": testutil.TestFixtureJavaScriptSimple, + "invalid.yml": testutil.TestFixtureInvalidMissingDescription, + }) }, expectError: true, // Validation should fail for invalid runtime configuration }, { - name: "nonexistent files", - setupFunc: func(_ *testing.T, tmpDir string) []string { - return []string{filepath.Join(tmpDir, "nonexistent.yml")} - }, + name: "nonexistent files", + setupFunc: setupNonexistentFiles("nonexistent.yml"), expectError: true, }, } @@ -493,7 +543,7 @@ func TestGenerator_ValidateFiles(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - config := &AppConfig{Quiet: true} + config := defaultTestConfig() generator := NewGenerator(config) files := tt.setupFunc(t, tmpDir) @@ -508,7 +558,7 @@ func TestGenerator_ValidateFiles(t *testing.T) { } } -func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { +func TestGeneratorCreateDependencyAnalyzer(t *testing.T) { t.Parallel() tests := []struct { name string @@ -530,10 +580,8 @@ 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, - } + config := defaultTestConfig() + config.GitHubToken = tt.token generator := NewGenerator(config) analyzer, err := generator.CreateDependencyAnalyzer() @@ -553,9 +601,15 @@ func TestGenerator_CreateDependencyAnalyzer(t *testing.T) { } } -func TestGenerator_WithDifferentThemes(t *testing.T) { +func TestGeneratorWithDifferentThemes(t *testing.T) { t.Parallel() - themes := []string{"default", "github", "gitlab", "minimal", "professional"} + themes := []string{ + appconstants.ThemeDefault, + appconstants.ThemeGitHub, + appconstants.ThemeGitLab, + appconstants.ThemeMinimal, + appconstants.ThemeProfessional, + } for _, theme := range themes { t.Run("theme_"+theme, func(t *testing.T) { @@ -568,18 +622,15 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { testutil.SetupTestTemplates(t, tmpDir) actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) + testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple)) - config := &AppConfig{ - Theme: theme, - OutputFormat: "md", - OutputDir: tmpDir, - Quiet: true, - } + config := defaultTestConfig() + config.Theme = theme + config.OutputDir = tmpDir generator := NewGenerator(config) if err := generator.GenerateFromFile(actionPath); err != nil { - t.Errorf("unexpected error: %v", err) + t.Errorf(testutil.TestErrUnexpected, err) return } @@ -593,7 +644,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { } } -func TestGenerator_ErrorHandling(t *testing.T) { +func TestGeneratorErrorHandling(t *testing.T) { t.Parallel() tests := []struct { name string @@ -606,8 +657,8 @@ func TestGenerator_ErrorHandling(t *testing.T) { t.Helper() config := &AppConfig{ Template: "/nonexistent/template.tmpl", - OutputFormat: "md", OutputDir: tmpDir, + OutputFormat: appconstants.OutputFormatMarkdown, Quiet: true, } generator := NewGenerator(config) @@ -615,7 +666,7 @@ func TestGenerator_ErrorHandling(t *testing.T) { testutil.WriteTestFile( t, actionPath, - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), ) return generator, actionPath @@ -633,18 +684,15 @@ func TestGenerator_ErrorHandling(t *testing.T) { restrictedDir := filepath.Join(tmpDir, "restricted") _ = os.MkdirAll(restrictedDir, 0444) // #nosec G301 -- intentionally read-only for test - config := &AppConfig{ - OutputFormat: "md", - OutputDir: restrictedDir, - Quiet: true, - Template: filepath.Join(tmpDir, "templates", "readme.tmpl"), - } + config := defaultTestConfig() + config.OutputDir = restrictedDir + config.Template = filepath.Join(tmpDir, "templates", appconstants.TemplateReadme) generator := NewGenerator(config) actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) testutil.WriteTestFile( t, actionPath, - testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), + testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple), ) return generator, actionPath @@ -670,18 +718,563 @@ func TestGenerator_ErrorHandling(t *testing.T) { } } -// createTestDirs is a helper that creates multiple directories within tmpDir for testing. -// Returns the full paths of all created directories. -func createTestDirs(t *testing.T, tmpDir string, names ...string) []string { +// TestGeneratorDiscoverActionFilesWithValidation tests the validation wrapper. +// validateDiscoveryResult validates the result of action file discovery. +func validateDiscoveryResult(t *testing.T, files []string, err error, wantErr bool) { t.Helper() - dirs := make([]string, len(names)) - for i, name := range names { - dirPath := filepath.Join(tmpDir, name) - if err := os.MkdirAll(dirPath, 0750); err != nil { // #nosec G301 -- test directory permissions - t.Fatalf("failed to create directory %s: %v", name, err) - } - dirs[i] = dirPath + + if (err != nil) != wantErr { + t.Errorf("DiscoverActionFilesWithValidation() error = %v, wantErr %v", err, wantErr) + + return } - return dirs + if !wantErr && len(files) == 0 { + t.Error("Expected files but got none") + } +} + +func TestGeneratorDiscoverActionFilesWithValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dir string + recursive bool + context string + wantErr bool + setupFunc func(t *testing.T) string + }{ + { + name: "nonexistent directory", + dir: "/nonexistent/path/does/not/exist", + recursive: false, + context: "test context", + wantErr: true, + }, + { + name: "empty directory", + recursive: false, + context: "empty dir test", + wantErr: true, + setupFunc: func(t *testing.T) string { + t.Helper() + + return t.TempDir() + }, + }, + { + name: "valid directory with action file", + recursive: false, + context: "valid test", + wantErr: false, + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + actionPath := filepath.Clean(filepath.Join(tmpDir, appconstants.ActionFileNameYML)) + if actionPath != filepath.Join(tmpDir, appconstants.ActionFileNameYML) || + strings.Contains(actionPath, "..") { + t.Fatalf("invalid path: %q", actionPath) + } + content := "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []" + testutil.WriteTestFile(t, actionPath, content) + + return tmpDir + }, + }, + { + name: "path with parent traversal - .. component", + dir: "../outside", + recursive: false, + context: "path traversal test", + wantErr: true, + }, + { + name: "path with .. in middle", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + // Return path with .. that would escape + return filepath.Join(tmpDir, "..", "escape") + }, + recursive: false, + context: "path traversal test", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + config := DefaultAppConfig() + config.Quiet = true + gen := NewGenerator(config) + + dir := tt.dir + if tt.setupFunc != nil { + dir = tt.setupFunc(t) + } + + files, err := gen.DiscoverActionFilesWithValidation(dir, tt.recursive, []string{}, tt.context) + validateDiscoveryResult(t, files, err, tt.wantErr) + }) + } +} + +// TestGeneratorResolveOutputPath tests output path resolution. +// validateResolveOutputPathResult validates the result of resolveOutputPath call. +func validateResolveOutputPathResult( + t *testing.T, + gotPath string, + err error, + wantPath string, + wantErr bool, + errContains string, +) { + t.Helper() + + if wantErr { + if err == nil { + t.Errorf("resolveOutputPath() expected error but got nil") + + return + } + if errContains != "" && !strings.Contains(err.Error(), errContains) { + t.Errorf("error message %q does not contain %q", err.Error(), errContains) + } + } else { + if err != nil { + t.Errorf("resolveOutputPath() unexpected error: %v", err) + + return + } + if gotPath != wantPath { + t.Errorf("resolveOutputPath() = %q, want %q", gotPath, wantPath) + } + } +} + +func TestGeneratorResolveOutputPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + outputFilename string + outputDir string + defaultFilename string + wantPath string // Expected path (if no error) + wantErr bool // Whether error is expected + errContains string // Error message substring (if wantErr) + }{ + // LEGITIMATE PATHS - Should succeed + { + name: "no custom filename", + outputFilename: "", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantPath: "/tmp/output/README.md", + wantErr: false, + }, + { + name: "relative custom filename", + outputFilename: "custom.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantPath: "/tmp/output/custom.md", + wantErr: false, + }, + { + name: "absolute custom filename", + outputFilename: "/absolute/path/output.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantPath: "/absolute/path/output.md", + wantErr: false, + }, + { + name: "custom filename with subdirectory", + outputFilename: "docs/output.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantPath: "/tmp/output/docs/output.md", + wantErr: false, + }, + { + name: "outputDir with .. component (filename is clean)", + outputFilename: "file.md", + outputDir: "/tmp/output/../escape", + defaultFilename: appconstants.ReadmeMarkdown, + wantPath: "/tmp/escape/file.md", + wantErr: false, + }, + + // PATH TRAVERSAL ATTEMPTS - Should error + { + name: "path traversal attempt with ../", + outputFilename: "../escape.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantErr: true, + errContains: testutil.TestErrPathTraversal, + }, + { + name: "path traversal with ../ in middle", + outputFilename: "sub/../escape.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantErr: true, + errContains: testutil.TestErrPathTraversal, + }, + { + name: "multiple ../ escaping directory", + outputFilename: "../../escape.md", + outputDir: testutil.TestOutputPath, + defaultFilename: appconstants.ReadmeMarkdown, + wantErr: true, + errContains: testutil.TestErrPathTraversal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + config := DefaultAppConfig() + config.OutputFilename = tt.outputFilename + config.Quiet = true + gen := NewGenerator(config) + + gotPath, err := gen.resolveOutputPath(tt.outputDir, tt.defaultFilename) + + validateResolveOutputPathResult(t, gotPath, err, tt.wantPath, tt.wantErr, tt.errContains) + }) + } +} + +// TestGeneratorDiscoverActionFilesErrorPaths tests error handling in file discovery. +func TestGeneratorDiscoverActionFilesErrorPaths(t *testing.T) { + t.Parallel() + + config := DefaultAppConfig() + config.Quiet = true + gen := NewGenerator(config) + + // Test with non-existent directory + _, err := gen.DiscoverActionFiles("/nonexistent/directory", false, []string{}) + if err == nil { + t.Error("Expected error for non-existent directory, got nil") + } + + // Test with unreadable directory (if we can create one) + tmpDir := t.TempDir() + unreadableDir := filepath.Join(tmpDir, "unreadable") + err = os.Mkdir(unreadableDir, 0000) + if err != nil { + t.Skip("Cannot create unreadable directory for testing") + } + defer func() { _ = os.Chmod(unreadableDir, 0700) }() //nolint:gosec // Test cleanup needs to restore permissions + + _, _ = gen.DiscoverActionFiles(unreadableDir, true, []string{}) + // May succeed or fail depending on platform permissions + // Just ensure it doesn't panic +} + +// TestGeneratorParseAndValidateActionErrorPaths tests validation error scenarios. +func TestGeneratorParseAndValidateActionErrorPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr bool + wantValid bool + }{ + { + name: "valid action", + content: "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []", + wantErr: false, + wantValid: true, + }, + { + name: "missing name", + content: "description: Test\nruns:\n using: composite\n steps: []", + wantErr: true, + wantValid: false, + }, + { + name: "missing description", + content: "name: Test\nruns:\n using: composite\n steps: []", + wantErr: true, + wantValid: false, + }, + { + name: "missing runs", + content: "name: Test\ndescription: Test", + wantErr: true, + wantValid: false, + }, + { + name: "invalid yaml", + content: "name: Test\ninvalid: [\n - item", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpPath := testutil.CreateTempActionFile(t, tt.content) + + config := DefaultAppConfig() + config.Quiet = true + gen := NewGenerator(config) + + action, err := gen.parseAndValidateAction(tmpPath) + + if (err != nil) != tt.wantErr { + t.Errorf("parseAndValidateAction() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && action == nil { + t.Error("Expected action to be non-nil when no error") + } + }) + } +} + +// TestGeneratorGenerateHTMLErrorPaths tests HTML generation error handling. +func TestGeneratorGenerateHTMLErrorPaths(t *testing.T) { + testHTMLGeneration(t) +} + +// TestGeneratorGenerateJSONErrorPaths tests JSON generation error handling. +func TestGeneratorGenerateJSONErrorPaths(t *testing.T) { + testJSONGeneration(t) +} + +// TestGeneratorGenerateASCIIDocErrorPaths tests AsciiDoc generation error handling. +func TestGeneratorGenerateASCIIDocErrorPaths(t *testing.T) { + testASCIIDocGeneration(t) +} + +// TestGeneratorReportResultsEdgeCases tests result reporting edge cases. +func TestGeneratorReportResultsEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + successCount int + errors []string + wantPanic bool + }{ + { + name: "all successful", + successCount: 5, + errors: []string{}, + wantPanic: false, + }, + { + name: "all failed", + successCount: 0, + errors: []string{"error1", "error2"}, + wantPanic: false, + }, + { + name: "mixed results", + successCount: 3, + errors: []string{"error1"}, + wantPanic: false, + }, + { + name: "zero files", + successCount: 0, + errors: []string{}, + wantPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + config := DefaultAppConfig() + config.Quiet = true + gen := NewGenerator(config) + + defer func() { + if r := recover(); r != nil && !tt.wantPanic { + t.Errorf("reportResults() panicked unexpectedly: %v", r) + } + }() + + gen.reportResults(tt.successCount, tt.errors) + }) + } +} + +// testCapturedOutput wraps testutil.CapturedOutput for reportResults testing. +type testCapturedOutput struct { + *testutil.CapturedOutput +} + +// ErrorWithSuggestions wraps the testutil version to match interface signature. +func (c *testCapturedOutput) ErrorWithSuggestions(err *apperrors.ContextualError) { + if err != nil { + c.ErrorMessages = append(c.ErrorMessages, err.Error()) + } +} + +// FormatContextualError wraps the testutil version to match interface signature. +func (c *testCapturedOutput) FormatContextualError(err *apperrors.ContextualError) string { + if err != nil { + return err.Error() + } + + return "" +} + +// verifyReportResultsOutput checks expected vs actual output message counts. +func verifyReportResultsOutput(t *testing.T, output *testCapturedOutput, wantBold, wantError bool) { + t.Helper() + + // Verify Bold message + gotBold := len(output.BoldMessages) > 0 + if wantBold && !gotBold { + t.Error("expected Bold message, got none") + } else if !wantBold && gotBold { + t.Errorf("expected no Bold messages, got %d", len(output.BoldMessages)) + } + + // Verify Error messages + gotError := len(output.ErrorMessages) > 0 + if wantError && !gotError { + t.Error("expected Error messages, got none") + } else if !wantError && gotError { + t.Errorf("expected no Error messages, got %d", len(output.ErrorMessages)) + } +} + +// TestGeneratorReportResultsOutput tests reportResults output in non-quiet mode. +func TestGeneratorReportResultsOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + quiet bool + verbose bool + successCount int + errors []string + wantBold bool + wantError bool + }{ + { + name: "quiet mode - no output", + quiet: true, + verbose: false, + successCount: 5, + errors: []string{"error1"}, + wantBold: false, + wantError: false, + }, + { + name: "non-quiet, no errors", + quiet: false, + verbose: false, + successCount: 5, + errors: []string{}, + wantBold: true, + wantError: false, + }, + { + name: "non-quiet, verbose, with errors", + quiet: false, + verbose: true, + successCount: 3, + errors: []string{"error1", "error2"}, + wantBold: true, + wantError: true, + }, + { + name: "non-quiet, non-verbose, with errors", + quiet: false, + verbose: false, + successCount: 2, + errors: []string{"error1"}, + wantBold: true, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output := &testCapturedOutput{ + CapturedOutput: &testutil.CapturedOutput{}, + } + config := DefaultAppConfig() + config.Quiet = tt.quiet + config.Verbose = tt.verbose + + gen := NewGeneratorWithDependencies(config, output, nil) + gen.reportResults(tt.successCount, tt.errors) + + verifyReportResultsOutput(t, output, tt.wantBold, tt.wantError) + }) + } +} + +// TestGeneratorIsUnitTestEnvironment tests unit test detection. +func TestGeneratorIsUnitTestEnvironment(t *testing.T) { + // This test runs in a test environment, so should return true + if !isUnitTestEnvironment() { + t.Error("Expected isUnitTestEnvironment() to return true in test context") + } +} + +// TestGeneratorNewGeneratorEdgeCases tests generator initialization edge cases. +func TestGeneratorNewGeneratorEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *AppConfig + }{ + { + name: "nil config", + config: nil, + }, + { + name: "default config", + config: DefaultAppConfig(), + }, + { + name: "custom config", + config: &AppConfig{ + Theme: appconstants.ThemeGitHub, + OutputFormat: appconstants.OutputFormatHTML, + Quiet: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + defer func() { + if r := recover(); r != nil { + t.Errorf("NewGenerator() panicked with config %v: %v", tt.config, r) + } + }() + + gen := NewGenerator(tt.config) + + if gen == nil { + t.Error("NewGenerator() returned nil") + } + }) + } } diff --git a/internal/generator_test_helper.go b/internal/generator_test_helper.go new file mode 100644 index 0000000..8cd3d73 --- /dev/null +++ b/internal/generator_test_helper.go @@ -0,0 +1,153 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// testFormatGeneration is a generic helper for testing format generation methods. +// It consolidates the common pattern across HTML, JSON, and AsciiDoc generation tests. +func testFormatGeneration( + t *testing.T, + generateFunc func(*Generator, *ActionYML, string, string) error, + expectedFile, formatName string, + needsActionPath bool, +) { + t.Helper() + t.Parallel() + + tmpDir := t.TempDir() + action := createTestAction() + gen := createQuietGenerator() + + var err error + if needsActionPath { + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + err = generateFunc(gen, action, tmpDir, actionPath) + } else { + // For JSON which doesn't need actionPath + err = generateFunc(gen, action, tmpDir, "") + } + + if err != nil { + t.Errorf("%s generation unexpected error = %v", formatName, err) + } + + verifyFileExists(t, filepath.Join(tmpDir, expectedFile), expectedFile) +} + +// testHTMLGeneration tests HTML generation creates the expected output file. +func testHTMLGeneration(t *testing.T) { + t.Helper() + + testFormatGeneration( + t, + func(g *Generator, a *ActionYML, out, path string) error { + return g.generateHTML(a, out, path) + }, + "Test Action.html", + "HTML", + true, // needs actionPath + ) +} + +// testJSONGeneration tests JSON generation creates the expected output file. +func testJSONGeneration(t *testing.T) { + t.Helper() + + testFormatGeneration( + t, + func(g *Generator, a *ActionYML, out, _ string) error { + return g.generateJSON(a, out) + }, + "action-docs.json", + "JSON", + false, // doesn't need actionPath + ) +} + +// testASCIIDocGeneration tests AsciiDoc generation creates the expected output file. +func testASCIIDocGeneration(t *testing.T) { + t.Helper() + + testFormatGeneration( + t, + func(g *Generator, a *ActionYML, out, path string) error { + return g.generateASCIIDoc(a, out, path) + }, + "README.adoc", + "AsciiDoc", + true, // needs actionPath + ) +} + +// createTestAction creates a basic test action for generator tests. +func createTestAction() *ActionYML { + return &ActionYML{ + Name: testutil.TestActionName, + Description: testutil.TestActionDesc, + Runs: map[string]any{"using": "composite"}, + } +} + +// createQuietGenerator creates a generator with quiet output for testing. +func createQuietGenerator() *Generator { + config := DefaultAppConfig() + config.Quiet = true + + return NewGenerator(config) +} + +// verifyFileExists checks that a file was created at the expected path. +func verifyFileExists(t *testing.T, fullPath, expectedFileName string) { + t.Helper() + + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + t.Errorf("Expected %s to be created", expectedFileName) + } +} + +// createTestDirs creates multiple test directories with given names. +func createTestDirs(t *testing.T, tmpDir string, names ...string) []string { + t.Helper() + dirs := make([]string, len(names)) + for i, name := range names { + dirPath := filepath.Join(tmpDir, name) + testutil.CreateTestDir(t, dirPath) + dirs[i] = dirPath + } + + return dirs +} + +// createMultiActionSetup creates a setupFunc for batch processing tests with multiple actions. +// It generates separate directories for each action and writes the specified fixtures. +func createMultiActionSetup(dirNames, fixtures []string) func(t *testing.T, tmpDir string) []string { + return func(t *testing.T, tmpDir string) []string { + t.Helper() + + // Create separate directories for each action + dirs := createTestDirs(t, tmpDir, dirNames...) + + // Build file paths and write fixtures + files := make([]string, len(dirs)) + for i, dir := range dirs { + files[i] = filepath.Join(dir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, files[i], testutil.MustReadFixture(fixtures[i])) + } + + return files + } +} + +// setupNonexistentFiles returns a setupFunc that creates paths to nonexistent files. +// This is used in multiple tests to verify error handling for missing files. +func setupNonexistentFiles(filename string) func(*testing.T, string) []string { + return func(_ *testing.T, tmpDir string) []string { + return []string{filepath.Join(tmpDir, filename)} + } +} diff --git a/internal/generator_validation_helper_test.go b/internal/generator_validation_helper_test.go new file mode 100644 index 0000000..34ffa13 --- /dev/null +++ b/internal/generator_validation_helper_test.go @@ -0,0 +1,85 @@ +package internal + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// TestAssertMessageCounts_Helper tests the assertMessageCounts helper function. +func TestAssertMessageCountsHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output *capturedOutput + want messageCountExpectations + }{ + { + name: "all counts zero", + output: &capturedOutput{ + CapturedOutput: &testutil.CapturedOutput{ + BoldMessages: []string{}, + SuccessMessages: []string{}, + WarningMessages: []string{}, + ErrorMessages: []string{}, + InfoMessages: []string{}, + }, + }, + want: messageCountExpectations{ + bold: 0, + success: 0, + warning: 0, + error: 0, + info: 0, + }, + }, + { + name: "some messages", + output: &capturedOutput{ + CapturedOutput: &testutil.CapturedOutput{ + BoldMessages: []string{"bold1", "bold2"}, + SuccessMessages: []string{"success1"}, + WarningMessages: []string{}, + ErrorMessages: []string{"error1", "error2", "error3"}, + InfoMessages: []string{"info1"}, + }, + }, + want: messageCountExpectations{ + bold: 2, + success: 1, + warning: 0, + error: 3, + info: 1, + }, + }, + { + name: "only bold and success", + output: &capturedOutput{ + CapturedOutput: &testutil.CapturedOutput{ + BoldMessages: []string{"bold"}, + SuccessMessages: []string{"success"}, + WarningMessages: []string{}, + ErrorMessages: []string{}, + InfoMessages: []string{}, + }, + }, + want: messageCountExpectations{ + bold: 1, + success: 1, + warning: 0, + error: 0, + info: 0, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Call the helper - it validates message counts + assertMessageCounts(t, tt.output, tt.want) + }) + } +} diff --git a/internal/generator_validation_test.go b/internal/generator_validation_test.go new file mode 100644 index 0000000..b70a606 --- /dev/null +++ b/internal/generator_validation_test.go @@ -0,0 +1,551 @@ +package internal + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// capturedOutput wraps testutil.CapturedOutput to satisfy CompleteOutput interface. +type capturedOutput struct { + *testutil.CapturedOutput +} + +// ErrorWithSuggestions wraps the testutil version to match interface signature. +func (c *capturedOutput) ErrorWithSuggestions(err *apperrors.ContextualError) { + c.CapturedOutput.ErrorWithSuggestions(err) +} + +// FormatContextualError wraps the testutil version to match interface signature. +func (c *capturedOutput) FormatContextualError(err *apperrors.ContextualError) string { + return c.CapturedOutput.FormatContextualError(err) +} + +// newCapturedOutput creates a new capturedOutput instance. +func newCapturedOutput() *capturedOutput { + return &capturedOutput{ + CapturedOutput: &testutil.CapturedOutput{}, + } +} + +// TestCountValidationStats tests the validation statistics counting function. +func TestCountValidationStats(t *testing.T) { + tests := []struct { + name string + results []ValidationResult + wantValidFiles int + wantTotalIssues int + }{ + { + name: "all valid files", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1}}, + {MissingFields: []string{testutil.ValidationTestFile2}}, + }, + wantValidFiles: 2, + wantTotalIssues: 0, + }, + { + name: "all invalid files", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}}, + {MissingFields: []string{testutil.ValidationTestFile2, "runs"}}, + }, + wantValidFiles: 0, + wantTotalIssues: 3, // 2 issues in first file + 1 in second + }, + { + name: "mixed valid and invalid", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1}}, // Valid + {MissingFields: []string{testutil.ValidationTestFile2, "name", "description"}}, // 2 issues + {MissingFields: []string{"file: action3.yml"}}, // Valid + {MissingFields: []string{"file: action4.yml", "runs"}}, // 1 issue + }, + wantValidFiles: 2, + wantTotalIssues: 3, + }, + { + name: "empty results", + results: []ValidationResult{}, + wantValidFiles: 0, + wantTotalIssues: 0, + }, + { + name: "single valid file", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile3}}, + }, + wantValidFiles: 1, + wantTotalIssues: 0, + }, + { + name: "single invalid file with multiple issues", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile3, "name", "description", "runs"}}, + }, + wantValidFiles: 0, + wantTotalIssues: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := &Generator{} + gotValid, gotIssues := gen.countValidationStats(tt.results) + + if gotValid != tt.wantValidFiles { + t.Errorf("countValidationStats() validFiles = %d, want %d", gotValid, tt.wantValidFiles) + } + if gotIssues != tt.wantTotalIssues { + t.Errorf("countValidationStats() totalIssues = %d, want %d", gotIssues, tt.wantTotalIssues) + } + }) + } +} + +// messageCountExpectations defines expected message counts for validation tests. +type messageCountExpectations struct { + bold int + success int + warning int + error int + info int +} + +// assertMessageCounts checks that message counts match expectations. +func assertMessageCounts(t *testing.T, output *capturedOutput, want messageCountExpectations) { + t.Helper() + + checks := []struct { + name string + got int + expected int + }{ + {"bold messages", len(output.BoldMessages), want.bold}, + {"success messages", len(output.SuccessMessages), want.success}, + {"warning messages", len(output.WarningMessages), want.warning}, + {"error messages", len(output.ErrorMessages), want.error}, + {"info messages", len(output.InfoMessages), want.info}, + } + + for _, check := range checks { + if check.got != check.expected { + t.Errorf("showValidationSummary() %s = %d, want %d", check.name, check.got, check.expected) + } + } +} + +// TestShowValidationSummary tests the validation summary display function. +func TestShowValidationSummary(t *testing.T) { + tests := []validationSummaryTestCase{ + createValidationSummaryTest(validationSummaryParams{ + name: "all valid files", + totalFiles: 3, + validFiles: 3, + totalIssues: 0, + resultCount: 3, + errorCount: 0, + wantWarning: 0, + wantError: 0, + wantInfo: 0, + }), + createValidationSummaryTest(validationSummaryParams{ + name: "some files with issues", + totalFiles: 3, + validFiles: 1, + totalIssues: 5, + resultCount: 3, + errorCount: 0, + wantWarning: 1, + wantError: 0, + wantInfo: 1, + }), + createValidationSummaryTest(validationSummaryParams{ + name: "parse errors present", + totalFiles: 5, + validFiles: 2, + totalIssues: 3, + resultCount: 3, + errorCount: 2, + wantWarning: 1, + wantError: 1, + wantInfo: 1, + }), + createValidationSummaryTest(validationSummaryParams{ + name: "only parse errors", + totalFiles: 2, + validFiles: 0, + totalIssues: 0, + resultCount: 0, + errorCount: 2, + wantWarning: 0, + wantError: 1, + wantInfo: 0, + }), + createValidationSummaryTest(validationSummaryParams{ + name: "zero files", + totalFiles: 0, + validFiles: 0, + totalIssues: 0, + resultCount: 0, + errorCount: 0, + wantWarning: 0, + wantError: 0, + wantInfo: 0, + }), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := newCapturedOutput() + gen := &Generator{Output: output} + + gen.showValidationSummary(tt.totalFiles, tt.validFiles, tt.totalIssues, tt.resultCount, tt.errorCount) + + assertMessageCounts(t, output, messageCountExpectations{ + bold: tt.wantBold, + success: tt.wantSuccess, + warning: tt.wantWarning, + error: tt.wantError, + info: tt.wantInfo, + }) + }) + } +} + +// TestShowParseErrors tests the parse error display function. +func TestShowParseErrors(t *testing.T) { + tests := []struct { + name string + errors []string + wantBold int + wantError int + wantContains string + }{ + { + name: "no parse errors", + errors: []string{}, + wantBold: 0, + wantError: 0, + wantContains: "", + }, + { + name: "single parse error", + errors: []string{"Failed to parse action.yml: invalid YAML"}, + wantBold: 1, + wantError: 1, + wantContains: "Failed to parse", + }, + { + name: "multiple parse errors", + errors: []string{ + "Failed to parse action1.yml: invalid YAML", + "Failed to parse action2.yml: file not found", + "Failed to parse action3.yml: permission denied", + }, + wantBold: 1, + wantError: 3, + wantContains: "Failed to parse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := newCapturedOutput() + gen := &Generator{Output: output} + + gen.showParseErrors(tt.errors) + + testutil.AssertMessageCounts(t, tt.name, output.CapturedOutput, 0, tt.wantError, 0, tt.wantBold) + + if tt.wantContains != "" && !output.ContainsError(tt.wantContains) { + t.Errorf( + "showParseErrors() error messages should contain %q, got %v", + tt.wantContains, + output.ErrorMessages, + ) + } + }) + } +} + +// TestShowFileIssues tests the file-specific issue display function. +func TestShowFileIssues(t *testing.T) { + tests := []struct { + name string + result ValidationResult + wantInfo int + wantError int + wantWarning int + wantContains string + }{ + { + name: "file with missing fields only", + result: ValidationResult{ + MissingFields: []string{testutil.ValidationTestFile3, "name", "description"}, + }, + wantInfo: 1, // File name only (no suggestions) + wantError: 2, // 2 missing fields + wantWarning: 0, + wantContains: "name", + }, + { + name: "file with warnings only", + result: ValidationResult{ + MissingFields: []string{testutil.ValidationTestFile3}, + Warnings: []string{"author field is recommended", "icon field is recommended"}, + }, + wantInfo: 1, // File name + wantError: 0, + wantWarning: 2, + wantContains: "author", + }, + { + name: "file with missing fields and warnings", + result: ValidationResult{ + MissingFields: []string{testutil.ValidationTestFile3, "name"}, + Warnings: []string{"author field is recommended"}, + }, + wantInfo: 1, + wantError: 1, + wantWarning: 1, + wantContains: "name", + }, + { + name: "file with suggestions", + result: ValidationResult{ + MissingFields: []string{testutil.ValidationTestFile3, "name"}, + Suggestions: []string{"Add a descriptive name field", "See documentation for examples"}, + }, + wantInfo: 2, // File name + Suggestions header + wantError: 1, + wantWarning: 0, + wantContains: "descriptive name", + }, + { + name: "valid file (no issues)", + result: ValidationResult{ + MissingFields: []string{testutil.ValidationTestFile3}, + }, + wantInfo: 1, // Just file name + wantError: 0, + wantWarning: 0, + wantContains: appconstants.ActionFileNameYML, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := newCapturedOutput() + gen := &Generator{Output: output} + + gen.showFileIssues(tt.result) + + if len(output.InfoMessages) < tt.wantInfo { + t.Errorf("showFileIssues() info messages = %d, want at least %d", len(output.InfoMessages), tt.wantInfo) + } + if len(output.ErrorMessages) != tt.wantError { + t.Errorf("showFileIssues() error messages = %d, want %d", len(output.ErrorMessages), tt.wantError) + } + if len(output.WarningMessages) != tt.wantWarning { + t.Errorf("showFileIssues() warning messages = %d, want %d", len(output.WarningMessages), tt.wantWarning) + } + + // Check if expected content appears somewhere in the output + if tt.wantContains != "" && !output.ContainsMessage(tt.wantContains) { + t.Errorf("showFileIssues() output should contain %q, got info=%v, error=%v, warning=%v", + tt.wantContains, output.InfoMessages, output.ErrorMessages, output.WarningMessages) + } + }) + } +} + +// TestShowDetailedIssues tests the detailed issues display function. +func TestShowDetailedIssues(t *testing.T) { + tests := []struct { + name string + results []ValidationResult + totalIssues int + verbose bool + wantBold int // Expected number of bold messages + }{ + { + name: "no issues, not verbose", + results: []ValidationResult{ + {MissingFields: []string{"file: action1.yml"}}, + {MissingFields: []string{"file: action2.yml"}}, + }, + totalIssues: 0, + verbose: false, + wantBold: 0, // Should not show details + }, + { + name: "no issues, verbose mode", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1}}, + {MissingFields: []string{testutil.ValidationTestFile2}}, + }, + totalIssues: 0, + verbose: true, + wantBold: 1, // Should show header even with no issues + }, + { + name: "some issues", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1, "name"}}, + {MissingFields: []string{testutil.ValidationTestFile2}}, + }, + totalIssues: 1, + verbose: false, + wantBold: 1, // Should show details + }, + { + name: "files with warnings", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1}, Warnings: []string{"author recommended"}}, + }, + totalIssues: 0, + verbose: false, + wantBold: 0, // No bold output (warnings don't count as issues, early return) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := newCapturedOutput() + gen := &Generator{ + Output: output, + Config: &AppConfig{Verbose: tt.verbose}, + } + + gen.showDetailedIssues(tt.results, tt.totalIssues) + + if len(output.BoldMessages) != tt.wantBold { + t.Errorf("showDetailedIssues() bold messages = %d, want %d", len(output.BoldMessages), tt.wantBold) + } + }) + } +} + +// TestReportValidationResults tests the main validation reporting function. +// reportCounts holds the expected counts for validation report output. +type reportCounts struct { + bold int + success bool + error bool +} + +// validateReportCounts validates that the report output contains expected message counts. +func validateReportCounts( + t *testing.T, + gotBold, gotSuccess, gotError int, + want reportCounts, + allowUnexpectedErrors bool, +) { + t.Helper() + + if gotBold < want.bold { + t.Errorf("Bold messages = %d, want at least %d", gotBold, want.bold) + } + + if want.success && gotSuccess == 0 { + t.Error("Expected success messages, got none") + } + + if want.error && gotError == 0 { + t.Error("Expected error messages, got none") + } + + if !allowUnexpectedErrors && gotError > 0 { + t.Errorf("Expected no error messages, got %d", gotError) + } +} + +func TestReportValidationResults(t *testing.T) { + tests := []struct { + name string + results []ValidationResult + errors []string + wantBold int // Minimum number of bold messages + wantSuccess bool + wantError bool + }{ + { + name: "all valid, no errors", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1}}, + {MissingFields: []string{testutil.ValidationTestFile2}}, + }, + errors: []string{}, + wantBold: 1, + wantSuccess: true, + wantError: false, + }, + { + name: "some invalid files", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1, "name"}}, + {MissingFields: []string{testutil.ValidationTestFile2}}, + }, + errors: []string{}, + wantBold: 2, // Summary + Details + wantSuccess: true, + wantError: true, + }, + { + name: "parse errors only", + results: []ValidationResult{}, + errors: []string{"Failed to parse action.yml"}, + wantBold: 2, // Summary + Parse Errors + wantSuccess: true, + wantError: true, + }, + { + name: "mixed validation issues and parse errors", + results: []ValidationResult{ + {MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}}, + }, + errors: []string{"Failed to parse action2.yml"}, + wantBold: 3, // Summary + Details + Parse Errors + wantSuccess: true, + wantError: true, + }, + { + name: "empty results", + results: []ValidationResult{}, + errors: []string{}, + wantBold: 1, + wantSuccess: true, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := newCapturedOutput() + gen := &Generator{ + Output: output, + Config: &AppConfig{Verbose: false}, + } + + gen.reportValidationResults(tt.results, tt.errors) + + counts := reportCounts{ + bold: tt.wantBold, + success: tt.wantSuccess, + error: tt.wantError, + } + validateReportCounts( + t, + len(output.BoldMessages), + len(output.SuccessMessages), + len(output.ErrorMessages), + counts, + tt.wantError, + ) + }) + } +} diff --git a/internal/generator_validation_test_helper.go b/internal/generator_validation_test_helper.go new file mode 100644 index 0000000..86f1c75 --- /dev/null +++ b/internal/generator_validation_test_helper.go @@ -0,0 +1,44 @@ +package internal + +// validationSummaryTestCase defines a test case for validation summary tests. +// This helper reduces duplication in test case definitions by providing +// a factory function with sensible defaults. +type validationSummaryTestCase struct { + name string + totalFiles int + validFiles int + totalIssues int + resultCount int + errorCount int + wantBold int + wantSuccess int + wantWarning int + wantError int + wantInfo int +} + +// validationSummaryParams holds parameters for creating validation summary test cases. +type validationSummaryParams struct { + name string + totalFiles, validFiles, totalIssues, resultCount, errorCount int + wantWarning, wantError, wantInfo int +} + +// createValidationSummaryTest creates a validation summary test case with defaults. +// Default values: wantBold=1, wantSuccess=1, wantWarning=0, wantError=0, wantInfo=0 +// Only provide the fields that differ from defaults. +func createValidationSummaryTest(params validationSummaryParams) validationSummaryTestCase { + return validationSummaryTestCase{ + name: params.name, + totalFiles: params.totalFiles, + validFiles: params.validFiles, + totalIssues: params.totalIssues, + resultCount: params.resultCount, + errorCount: params.errorCount, + wantBold: 1, // Always 1 + wantSuccess: 1, // Always 1 + wantWarning: params.wantWarning, + wantError: params.wantError, + wantInfo: params.wantInfo, + } +} diff --git a/internal/git/detector.go b/internal/git/detector.go index 2662bab..4116b61 100644 --- a/internal/git/detector.go +++ b/internal/git/detector.go @@ -155,7 +155,11 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) { // getDefaultBranch gets the default branch name. func getDefaultBranch(repoRoot string) string { - cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD") + cmd := exec.Command( + appconstants.GitCommand, + "symbolic-ref", + "refs/remotes/origin/HEAD", + ) // #nosec G204 -- controlled git command cmd.Dir = repoRoot output, err := cmd.Output() @@ -209,7 +213,7 @@ func parseGitHubURL(url string) (organization, repository string) { repo := matches[2] // Remove .git suffix if present - repo = strings.TrimSuffix(repo, ".git") + repo = strings.TrimSuffix(repo, appconstants.DirGit) return org, repo } diff --git a/internal/git/detector_test.go b/internal/git/detector_test.go index 35a8a0f..447ea09 100644 --- a/internal/git/detector_test.go +++ b/internal/git/detector_test.go @@ -1,7 +1,6 @@ package git import ( - "os" "path/filepath" "testing" @@ -22,18 +21,11 @@ func TestFindRepositoryRoot(t *testing.T) { 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 - if err != nil { - t.Fatalf("failed to create .git directory: %v", err) - } + testutil.SetupGitDirectory(t, tmpDir) // Create subdirectory to test from subDir := filepath.Join(tmpDir, "subdir", "nested") - err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - if err != nil { - t.Fatalf("failed to create subdirectory: %v", err) - } + testutil.CreateTestDir(t, subDir) return subDir }, @@ -59,10 +51,7 @@ func TestFindRepositoryRoot(t *testing.T) { 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) - } + testutil.CreateTestDir(t, subDir) return subDir }, @@ -123,19 +112,9 @@ func TestDetectGitRepository(t *testing.T) { setupFunc func(t *testing.T, tmpDir string) string checkFunc func(t *testing.T, info *RepoInfo) }{ - { + createGitRepoTestCase(gitTestCase{ 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 - if err != nil { - t.Fatalf("failed to create .git directory: %v", err) - } - - // Create config file with GitHub remote - configContent := `[core] + configContent: `[core] repositoryformatversion = 0 filemode = true bare = false @@ -146,45 +125,21 @@ func TestDetectGitRepository(t *testing.T) { [branch "main"] remote = origin merge = refs/heads/main -` - configPath := filepath.Join(gitDir, "config") - testutil.WriteTestFile(t, configPath, configContent) - - 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) - }, - }, - { +`, + expectedOrg: "owner", + expectedRepo: "repo", + expectedURL: "https://github.com/owner/repo.git", + }), + createGitRepoTestCase(gitTestCase{ 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 { - t.Fatalf("failed to create .git directory: %v", err) - } - - configContent := `[remote "origin"] + configContent: `[remote "origin"] url = git@github.com:owner/repo.git fetch = +refs/heads/*:refs/remotes/origin/* -` - configPath := filepath.Join(gitDir, "config") - testutil.WriteTestFile(t, configPath, configContent) - - 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) - }, - }, +`, + expectedOrg: "owner", + expectedRepo: "repo", + expectedURL: "git@github.com:owner/repo.git", + }), { name: "no git repository", setupFunc: func(_ *testing.T, tmpDir string) string { @@ -197,33 +152,16 @@ func TestDetectGitRepository(t *testing.T) { testutil.AssertEqual(t, "", info.Repository) }, }, - { + createGitRepoTestCase(gitTestCase{ 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 { - t.Fatalf("failed to create .git directory: %v", err) - } - - configContent := `[core] + configContent: `[core] repositoryformatversion = 0 filemode = true bare = false -` - configPath := filepath.Join(gitDir, "config") - testutil.WriteTestFile(t, configPath, configContent) - - 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) - }, - }, +`, + expectedOrg: "", + expectedRepo: "", + }), } for _, tt := range tests { @@ -298,7 +236,7 @@ func TestParseGitHubURL(t *testing.T) { } } -func TestRepoInfo_GetRepositoryName(t *testing.T) { +func TestRepoInfoGetRepositoryName(t *testing.T) { t.Parallel() tests := []struct { @@ -344,3 +282,532 @@ func TestRepoInfo_GetRepositoryName(t *testing.T) { }) } } + +// TestRepoInfoGenerateUsesStatement tests the GenerateUsesStatement method. +func TestRepoInfoGenerateUsesStatement(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repoInfo *RepoInfo + actionName string + version string + expected string + }{ + { + name: "repository-level action", + repoInfo: &RepoInfo{ + Organization: "actions", + Repository: "checkout", + }, + actionName: "", + version: "v3", + expected: testutil.TestActionCheckoutV3, + }, + { + name: "repository-level action with same name", + repoInfo: &RepoInfo{ + Organization: "actions", + Repository: "checkout", + }, + actionName: "checkout", + version: "v3", + expected: testutil.TestActionCheckoutV3, + }, + { + name: "subdirectory action", + repoInfo: &RepoInfo{ + Organization: "actions", + Repository: "toolkit", + }, + actionName: "cache", + version: "v2", + expected: "actions/toolkit/cache@v2", + }, + { + name: "without organization", + repoInfo: &RepoInfo{ + Organization: "", + Repository: "", + }, + actionName: "my-action", + version: "v1", + expected: "your-org/my-action@v1", + }, + { + name: "without organization and action name", + repoInfo: &RepoInfo{ + Organization: "", + Repository: "", + }, + actionName: "", + version: "v1", + expected: "your-org/your-action@v1", + }, + { + name: "with SHA version", + repoInfo: &RepoInfo{ + Organization: "actions", + Repository: "checkout", + }, + actionName: "", + version: "abc123def456", + expected: "actions/checkout@abc123def456", + }, + { + name: "with main branch", + repoInfo: &RepoInfo{ + Organization: "actions", + Repository: "setup-node", + }, + actionName: "", + version: "main", + expected: "actions/setup-node@main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := tt.repoInfo.GenerateUsesStatement(tt.actionName, tt.version) + testutil.AssertEqual(t, tt.expected, result) + }) + } +} + +// TestGetDefaultBranch_Fallbacks tests branch detection fallback logic. +func TestGetDefaultBranchFallbacks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectedBranch string + }{ + createDefaultBranchTestCase(defaultBranchTestCase{ + name: "git config with main branch", + branch: "main", + expectedBranch: "main", + }), + createDefaultBranchTestCase(defaultBranchTestCase{ + name: "git config with master branch - returns main fallback", + branch: "master", + expectedBranch: "main", + }), + createDefaultBranchTestCase(defaultBranchTestCase{ + name: "git config with develop branch - returns main fallback", + branch: "develop", + expectedBranch: "main", + }), + { + name: "no git config - returns main fallback", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + _ = testutil.SetupGitDirectory(t, tmpDir) + + return tmpDir + }, + expectedBranch: "main", // Falls back to "main" when git command fails + }, + { + name: "malformed git config - returns main fallback", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + gitDir := testutil.SetupGitDirectory(t, tmpDir) + + configContent := `[branch this is malformed` + configPath := filepath.Join(gitDir, "config") + testutil.WriteTestFile(t, configPath, configContent) + + return tmpDir + }, + expectedBranch: "main", // Falls back to "main" when git command fails + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + repoDir := tt.setupFunc(t, tmpDir) + branch := getDefaultBranch(repoDir) + + testutil.AssertEqual(t, tt.expectedBranch, branch) + }) + } +} + +// TestGetRemoteURL_AllSources tests all remote URL detection methods. +func TestGetRemoteURLAllSources(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectError bool + expectedURL string + }{ + createGitURLTestCase(gitURLTestCase{ + name: "remote from git config - https", + configContent: `[remote "origin"] + url = https://github.com/test/repo.git +`, + expectError: false, + expectedURL: "https://github.com/test/repo.git", + }), + createGitURLTestCase(gitURLTestCase{ + name: "remote from git config - ssh", + configContent: `[remote "origin"] + url = git@github.com:user/repo.git +`, + expectError: false, + expectedURL: "git@github.com:user/repo.git", + }), + createGitURLTestCase(gitURLTestCase{ + name: "multiple remotes - origin takes precedence", + configContent: `[remote "upstream"] + url = https://github.com/upstream/repo +[remote "origin"] + url = https://github.com/origin/repo +`, + expectError: false, + expectedURL: "https://github.com/origin/repo", + }), + { + name: "no remote configured", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + _ = testutil.SetupGitDirectory(t, tmpDir) + + return tmpDir + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + repoDir := tt.setupFunc(t, tmpDir) + url, err := getRemoteURL(repoDir) + + if tt.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, tt.expectedURL, url) + } + }) + } +} + +// TestGetRemoteURLFromConfig_EdgeCases tests git config parsing with edge cases. +func TestGetRemoteURLFromConfigEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configContent string + expectError bool + expectedURL string + description string + }{ + { + name: "standard git config", + configContent: `[remote "origin"] + url = ` + testutil.TestURLGitHubUserRepo + ` +`, + expectError: false, + expectedURL: testutil.TestURLGitHubUserRepo, + description: "Standard git config", + }, + { + name: "config with comments", + configContent: `# This is a comment +[remote "origin"] + # Another comment + url = ` + testutil.TestURLGitHubUserRepo + ` + fetch = +refs/heads/*:refs/remotes/origin/* +`, + expectError: false, + expectedURL: testutil.TestURLGitHubUserRepo, + description: "Config with comments should be parsed", + }, + { + name: "empty config", + configContent: ``, + expectError: true, + description: "Empty config", + }, + { + name: "incomplete section", + configContent: `[remote "origin" + url = ` + testutil.TestURLGitHubUserRepo + ` +`, + expectError: true, + description: "Malformed section", + }, + { + name: "url with spaces", + configContent: `[remote "origin"] + url = https://github.com/user name/repo name +`, + expectError: false, + expectedURL: "https://github.com/user name/repo name", + description: "URL with spaces should be preserved", + }, + { + name: "multiple origin sections - first wins", + configContent: `[remote "origin"] + url = https://github.com/first/repo +[remote "origin"] + url = https://github.com/second/repo +`, + expectError: false, + expectedURL: "https://github.com/first/repo", + description: "First origin section takes precedence", + }, + { + name: "ssh url format", + configContent: `[remote "origin"] + url = git@gitlab.com:user/repo.git +`, + expectError: false, + expectedURL: "git@gitlab.com:user/repo.git", + description: "SSH URL format", + }, + { + name: "url with trailing whitespace", + configContent: `[remote "origin"] + url = ` + testutil.TestURLGitHubUserRepo + ` +`, + expectError: false, + expectedURL: testutil.TestURLGitHubUserRepo, + description: "Trailing whitespace should be trimmed", + }, + { + name: "config without url field", + configContent: `[remote "origin"] + fetch = +refs/heads/*:refs/remotes/origin/* +`, + expectError: true, + description: "Remote without URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + gitDir := testutil.SetupGitDirectory(t, tmpDir) + + if tt.configContent != "" { + configPath := filepath.Join(gitDir, "config") + testutil.WriteTestFile(t, configPath, tt.configContent) + } + + url, err := getRemoteURLFromConfig(tmpDir) + + if tt.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, tt.expectedURL, url) + } + }) + } +} + +// TestFindRepositoryRoot_EdgeCases tests additional edge cases for repository root detection. +func TestFindRepositoryRootEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectError bool + checkFunc func(t *testing.T, tmpDir, repoRoot string) + }{ + { + name: "deeply nested subdirectory", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + testutil.SetupGitDirectory(t, tmpDir) + + deepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e") + testutil.CreateTestDir(t, deepPath) + + return deepPath + }, + expectError: false, + checkFunc: func(t *testing.T, tmpDir, repoRoot string) { + t.Helper() + testutil.AssertEqual(t, tmpDir, repoRoot) + }, + }, + { + name: "git worktree 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/worktree") + + return tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, tmpDir, repoRoot string) { + t.Helper() + testutil.AssertEqual(t, tmpDir, repoRoot) + }, + }, + { + name: "current directory is repo root", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + testutil.SetupGitDirectory(t, tmpDir) + + return tmpDir + }, + expectError: false, + checkFunc: func(t *testing.T, tmpDir, repoRoot string) { + t.Helper() + testutil.AssertEqual(t, tmpDir, repoRoot) + }, + }, + { + name: "path with spaces", + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + testutil.SetupGitDirectory(t, tmpDir) + + spacePath := filepath.Join(tmpDir, "path with spaces") + testutil.CreateTestDir(t, spacePath) + + return spacePath + }, + expectError: false, + checkFunc: func(t *testing.T, tmpDir, repoRoot string) { + t.Helper() + testutil.AssertEqual(t, tmpDir, repoRoot) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + testDir := tt.setupFunc(t, tmpDir) + repoRoot, err := FindRepositoryRoot(testDir) + + if tt.expectError { + testutil.AssertError(t, err) + } else { + testutil.AssertNoError(t, err) + tt.checkFunc(t, tmpDir, repoRoot) + } + }) + } +} + +// TestParseGitHubURL_EdgeCases tests additional URL parsing edge cases. +func TestParseGitHubURLEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remoteURL string + expectedOrg string + expectedRepo string + description string + }{ + { + name: "gitlab https url", + remoteURL: "https://gitlab.com/owner/repo.git", + expectedOrg: "", + expectedRepo: "", + description: "Non-GitHub URLs return empty", + }, + { + name: "github url with subgroups", + remoteURL: "https://github.com/org/subgroup/repo.git", + expectedOrg: "org", + expectedRepo: "subgroup", // Regex only captures first two path segments + description: "GitHub URLs with subpaths only capture org/subgroup", + }, + { + name: "ssh url without git suffix", + remoteURL: "git@github.com:owner/repo", + expectedOrg: "owner", + expectedRepo: "repo", + description: "SSH URL without .git suffix", + }, + { + name: "url with trailing slash", + remoteURL: "https://github.com/owner/repo/", + expectedOrg: "owner", + expectedRepo: "repo", + description: "Handles trailing slash", + }, + { + name: "url with query parameters", + remoteURL: "https://github.com/owner/repo?param=value", + expectedOrg: "owner", + expectedRepo: "repo?param=value", // Regex doesn't strip query params + description: "Query parameters are not stripped by regex", + }, + { + name: "malformed ssh url", + remoteURL: "git@github.com/owner/repo.git", + expectedOrg: "owner", + expectedRepo: "repo", // Actually matches the pattern + description: "Malformed SSH URL still matches pattern", + }, + { + name: "url with username", + remoteURL: "https://user@github.com/owner/repo.git", + expectedOrg: "owner", + expectedRepo: "repo", + description: "Handles URL with username", + }, + { + name: "github enterprise url", + remoteURL: "https://github.company.com/owner/repo.git", + expectedOrg: "", + expectedRepo: "", + description: "GitHub Enterprise URLs return empty (not github.com)", + }, + { + name: "short ssh format", + remoteURL: "github.com:owner/repo.git", + expectedOrg: "owner", + expectedRepo: "repo", // Actually matches the pattern with ':' + description: "Short SSH format matches the regex pattern", + }, + } + + 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) + testutil.AssertEqual(t, tt.expectedRepo, repo) + }) + } +} diff --git a/internal/git/detector_test_helper.go b/internal/git/detector_test_helper.go new file mode 100644 index 0000000..7239f76 --- /dev/null +++ b/internal/git/detector_test_helper.go @@ -0,0 +1,126 @@ +package git + +import ( + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// gitTestCase defines the configuration for a git repository test case. +type gitTestCase struct { + name string + configContent string + expectedOrg string + expectedRepo string + expectedBranch string + expectedURL string +} + +// createGitRepoTestCase creates a test table entry for git repository detection tests. +// setupGitTestRepo creates a test git directory with the specified config content. +// This helper is used by multiple test case creators to eliminate duplicate setup logic. +func setupGitTestRepo(t *testing.T, tmpDir, configContent string) string { + t.Helper() + gitDir := testutil.SetupGitDirectory(t, tmpDir) + configPath := filepath.Join(gitDir, "config") + testutil.WriteTestFile(t, configPath, configContent) + + return tmpDir +} + +// This helper reduces duplication by standardizing the setup and assertion patterns +// for git repository test cases. +func createGitRepoTestCase(tc gitTestCase) struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + checkFunc func(t *testing.T, info *RepoInfo) +} { + return struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + checkFunc func(t *testing.T, info *RepoInfo) + }{ + name: tc.name, + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + + return setupGitTestRepo(t, tmpDir, tc.configContent) + }, + checkFunc: func(t *testing.T, info *RepoInfo) { + t.Helper() + testutil.AssertEqual(t, tc.expectedOrg, info.Organization) + testutil.AssertEqual(t, tc.expectedRepo, info.Repository) + if tc.expectedBranch != "" { + testutil.AssertEqual(t, tc.expectedBranch, info.DefaultBranch) + } + if tc.expectedURL != "" { + testutil.AssertEqual(t, tc.expectedURL, info.RemoteURL) + } + }, + } +} + +// gitURLTestCase defines the configuration for git remote URL test cases. +type gitURLTestCase struct { + name string + configContent string + expectError bool + expectedURL string +} + +// createGitURLTestCase creates a test table entry for git remote URL detection tests. +// This helper reduces duplication by standardizing the setup pattern for URL tests. +func createGitURLTestCase(tc gitURLTestCase) struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectError bool + expectedURL string +} { + return struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectError bool + expectedURL string + }{ + name: tc.name, + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + + return setupGitTestRepo(t, tmpDir, tc.configContent) + }, + expectError: tc.expectError, + expectedURL: tc.expectedURL, + } +} + +// defaultBranchTestCase defines the configuration for default branch detection tests. +type defaultBranchTestCase struct { + name string + branch string + expectedBranch string +} + +// createDefaultBranchTestCase creates a test table entry for default branch tests. +// This helper reduces duplication for tests that set up git repos with different branches. +func createDefaultBranchTestCase(tc defaultBranchTestCase) struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectedBranch string +} { + return struct { + name string + setupFunc func(t *testing.T, tmpDir string) string + expectedBranch string + }{ + name: tc.name, + setupFunc: func(t *testing.T, tmpDir string) string { + t.Helper() + gitDir := testutil.SetupGitDirectory(t, tmpDir) + testutil.CreateGitConfigWithRemote(t, gitDir, testutil.TestURLGitHubUserRepo, tc.branch) + + return tmpDir + }, + expectedBranch: tc.expectedBranch, + } +} diff --git a/internal/helpers/analyzer_test.go b/internal/helpers/analyzer_test.go index 71426f6..261cddc 100644 --- a/internal/helpers/analyzer_test.go +++ b/internal/helpers/analyzer_test.go @@ -108,7 +108,7 @@ func TestCreateAnalyzerOrExit(t *testing.T) { // In a real-world scenario, we might refactor to return errors instead } -func TestCreateAnalyzer_Integration(t *testing.T) { +func TestCreateAnalyzerIntegration(t *testing.T) { t.Parallel() // Test integration with actual generator functionality diff --git a/internal/helpers/common_test.go b/internal/helpers/common_test.go index 04905bd..8931987 100644 --- a/internal/helpers/common_test.go +++ b/internal/helpers/common_test.go @@ -1,7 +1,6 @@ package helpers import ( - "os" "path/filepath" "strings" "testing" @@ -117,14 +116,11 @@ func TestFindGitRepoRoot(t *testing.T) { 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 - testutil.AssertNoError(t, err) + _ = testutil.SetupGitDirectory(t, tmpDir) // Create subdirectory to test from subDir := filepath.Join(tmpDir, "subdir") - err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - testutil.AssertNoError(t, err) + testutil.CreateTestDir(t, subDir) return subDir }, @@ -143,14 +139,11 @@ func TestFindGitRepoRoot(t *testing.T) { 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 - testutil.AssertNoError(t, err) + _ = testutil.SetupGitDirectory(t, tmpDir) // Create deeply nested subdirectory nestedDir := filepath.Join(tmpDir, "a", "b", "c") - err = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions - testutil.AssertNoError(t, err) + testutil.CreateTestDir(t, nestedDir) return nestedDir }, @@ -241,9 +234,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) { 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 - testutil.AssertNoError(t, err) + gitDir := testutil.SetupGitDirectory(t, tmpDir) // Create a basic git config to make it look like a real repo configContent := `[core] @@ -258,8 +249,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string { merge = refs/heads/main ` configPath := filepath.Join(gitDir, "config") - err = os.WriteFile(configPath, []byte(configContent), 0600) // #nosec G306 -- test file permissions - testutil.AssertNoError(t, err) + testutil.WriteTestFile(t, configPath, configContent) return tmpDir } @@ -267,9 +257,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 - testutil.AssertNoError(t, err) + _ = testutil.SetupGitDirectory(t, tmpDir) return tmpDir } @@ -282,7 +270,7 @@ func verifyRepoRoot(t *testing.T, repoRoot, tmpDir string) { } // Test error handling in GetGitRepoRootAndInfo. -func TestGetGitRepoRootAndInfo_ErrorHandling(t *testing.T) { +func TestGetGitRepoRootAndInfoErrorHandling(t *testing.T) { t.Parallel() t.Run("nonexistent directory", func(t *testing.T) { diff --git a/internal/html_test.go b/internal/html_test.go new file mode 100644 index 0000000..4231fb4 --- /dev/null +++ b/internal/html_test.go @@ -0,0 +1,318 @@ +package internal + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// mustSafePath validates that a path is safe (no "..", matches cleaned version). +// Fails the test if path is unsafe. +func mustSafePath(t *testing.T, p string) string { + t.Helper() + cleaned := filepath.Clean(p) + if cleaned != p { + t.Fatalf("path %q does not match cleaned path %q", p, cleaned) + } + if strings.Contains(cleaned, "..") { + t.Fatalf("path %q contains unsafe .. component", p) + } + + return cleaned +} + +// TestHTMLWriterWrite tests the HTMLWriter.Write function. +func TestHTMLWriterWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header string + footer string + content string + wantString string + }{ + { + name: "no header or footer", + header: "", + footer: "", + content: "

Test Content

", + wantString: "

Test Content

", + }, + { + name: "with header only", + header: "\n\n", + footer: "", + content: "Content", + wantString: "\n\nContent", + }, + { + name: "with footer only", + header: "", + footer: testutil.TestHTMLClosingTag, + content: "Content", + wantString: "Content\n", + }, + { + name: "with both header and footer", + header: "\n\n\n", + footer: "\n\n", + content: "

Main Content

", + wantString: "\n\n\n

Main Content

\n\n", + }, + { + name: "empty content", + header: "
", + footer: "", + content: "", + wantString: "
", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "test.html") + + writer := &HTMLWriter{ + Header: tt.header, + Footer: tt.footer, + } + + err := writer.Write(tt.content, outputPath) + if err != nil { + t.Errorf("Write() unexpected error = %v", err) + + return + } + + // Read the file and verify content + content, err := os.ReadFile(mustSafePath(t, outputPath)) + if err != nil { + t.Fatalf(testutil.TestMsgFailedToReadOutput, err) + } + + got := string(content) + if got != tt.wantString { + t.Errorf("Write() content = %q, want %q", got, tt.wantString) + } + }) + } +} + +// TestHTMLWriterWriteErrorPaths tests error handling in HTMLWriter.Write. +func TestHTMLWriterWriteErrorPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupPath func(t *testing.T) string + skipReason string + wantErr bool + }{ + { + name: "invalid path - directory doesn't exist", + setupPath: func(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + + return filepath.Join(tmpDir, "nonexistent", "file.html") + }, + wantErr: true, + }, + { + name: "permission denied - unwritable directory", + setupPath: func(t *testing.T) string { + t.Helper() + // Skip on Windows (chmod behavior differs) + if runtime.GOOS == "windows" { + return "" + } + // Skip if running as root (can write anywhere) + if os.Geteuid() == 0 { + return "" + } + + tmpDir := t.TempDir() + restrictedDir := filepath.Join(tmpDir, "restricted") + if err := os.Mkdir(restrictedDir, 0700); err != nil { + t.Fatalf("failed to create restricted dir: %v", err) + } + + // Make directory unwritable + if err := os.Chmod(restrictedDir, 0000); err != nil { + t.Fatalf("failed to chmod: %v", err) + } + + // Restore permissions in cleanup + t.Cleanup(func() { + _ = os.Chmod(restrictedDir, 0700) // #nosec G302 -- directory needs exec bit for cleanup + }) + + return filepath.Join(restrictedDir, "file.html") + }, + skipReason: "skipped on Windows or when running as root", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + path := tt.setupPath(t) + if path == "" { + t.Skip(tt.skipReason) + } + + writer := &HTMLWriter{ + Header: "
", + Footer: "", + } + + err := writer.Write("", path) + if (err != nil) != tt.wantErr { + t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestHTMLWriterWriteLargeContent tests writing large HTML content. +func TestHTMLWriterWriteLargeContent(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "large.html") + + // Create large content (10MB) + largeContent := strings.Repeat("

Test content line

\n", 500000) + + writer := &HTMLWriter{ + Header: "\n", + Footer: testutil.TestHTMLClosingTag, + } + + err := writer.Write(largeContent, outputPath) + if err != nil { + t.Errorf("Write() failed for large content: %v", err) + } + + // Verify file was created and has correct size + info, err := os.Stat(outputPath) + if err != nil { + t.Fatalf("Failed to stat output file: %v", err) + } + + expectedSize := len("\n") + len(largeContent) + len(testutil.TestHTMLClosingTag) + if int(info.Size()) != expectedSize { + t.Errorf("File size = %d, want %d", info.Size(), expectedSize) + } +} + +// TestHTMLWriterWriteSpecialCharacters tests writing HTML with special characters. +func TestHTMLWriterWriteSpecialCharacters(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "special.html") + + // Content with HTML entities and special characters + content := `
<script>alert("test")</script>
+

Special chars: & " ' < >

+

Unicode: 你好 مرحبا привет 🎉

` + + writer := &HTMLWriter{} + err := writer.Write(content, outputPath) + if err != nil { + t.Errorf("Write() failed for special characters: %v", err) + } + + // Verify content was written correctly + readContent, err := os.ReadFile(mustSafePath(t, outputPath)) + if err != nil { + t.Fatalf(testutil.TestMsgFailedToReadOutput, err) + } + + if string(readContent) != content { + t.Errorf("Content mismatch for special characters") + } +} + +// TestHTMLWriterWriteOverwrite tests overwriting an existing file. +func TestHTMLWriterWriteOverwrite(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + outputPath := filepath.Join(tmpDir, "overwrite.html") + + // Write initial content + writer := &HTMLWriter{} + err := writer.Write("Initial content", outputPath) + if err != nil { + t.Fatalf("Initial write failed: %v", err) + } + + // Overwrite with new content + err = writer.Write(testutil.TestHTMLNewContent, outputPath) + if err != nil { + t.Errorf("Overwrite failed: %v", err) + } + + // Verify new content + content, err := os.ReadFile(mustSafePath(t, outputPath)) + if err != nil { + t.Fatalf(testutil.TestMsgFailedToReadOutput, err) + } + + if string(content) != testutil.TestHTMLNewContent { + t.Errorf("Content = %q, want %q", string(content), testutil.TestHTMLNewContent) + } +} + +// TestHTMLWriterWriteEmptyPath tests writing to an empty path. +func TestHTMLWriterWriteEmptyPath(t *testing.T) { + t.Parallel() + + writer := &HTMLWriter{} + err := writer.Write("content", "") + + // Empty path should cause an error + if err == nil { + t.Error("Write() with empty path should return error") + } +} + +// TestHTMLWriterWriteValidPath tests writing to a valid nested path. +func TestHTMLWriterWriteValidPath(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create nested directory structure + nestedDir := filepath.Join(tmpDir, "nested", "directory") + testutil.CreateTestDir(t, nestedDir) + + outputPath := filepath.Join(nestedDir, "nested.html") + + writer := &HTMLWriter{ + Header: "", + Footer: "", + } + + err := writer.Write("Nested content", outputPath) + if err != nil { + t.Errorf("Write() to nested path failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Error("File was not created in nested path") + } +} diff --git a/internal/interfaces_test.go b/internal/interfaces_test.go index 289ea8d..9eb695d 100644 --- a/internal/interfaces_test.go +++ b/internal/interfaces_test.go @@ -2,6 +2,7 @@ package internal import ( + "fmt" "os" "strings" "testing" @@ -10,6 +11,7 @@ import ( "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" ) // MockMessageLogger implements MessageLogger for testing. @@ -22,28 +24,33 @@ type MockMessageLogger struct { } func (m *MockMessageLogger) Info(format string, args ...any) { - m.InfoCalls = append(m.InfoCalls, formatMessage(format, args...)) + m.recordCall(&m.InfoCalls, format, args...) } func (m *MockMessageLogger) Success(format string, args ...any) { - m.SuccessCalls = append(m.SuccessCalls, formatMessage(format, args...)) + m.recordCall(&m.SuccessCalls, format, args...) } func (m *MockMessageLogger) Warning(format string, args ...any) { - m.WarningCalls = append(m.WarningCalls, formatMessage(format, args...)) + m.recordCall(&m.WarningCalls, format, args...) } func (m *MockMessageLogger) Bold(format string, args ...any) { - m.BoldCalls = append(m.BoldCalls, formatMessage(format, args...)) + m.recordCall(&m.BoldCalls, format, args...) } func (m *MockMessageLogger) Printf(format string, args ...any) { - m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...)) + m.recordCall(&m.PrintfCalls, format, args...) } func (m *MockMessageLogger) Fprintf(_ *os.File, format string, args ...any) { // For testing, just track the formatted message - m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...)) + m.recordCall(&m.PrintfCalls, format, args...) +} + +// recordCall is a helper to reduce duplication in mock methods. +func (m *MockMessageLogger) recordCall(callSlice *[]string, format string, args ...any) { + *callSlice = append(*callSlice, fmt.Sprintf(format, args...)) } // MockErrorReporter implements ErrorReporter for testing. @@ -55,7 +62,7 @@ type MockErrorReporter struct { } func (m *MockErrorReporter) Error(format string, args ...any) { - m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...)) + m.recordCall(&m.ErrorCalls, format, args...) } func (m *MockErrorReporter) ErrorWithSuggestions(err *apperrors.ContextualError) { @@ -72,13 +79,23 @@ func (m *MockErrorReporter) ErrorWithSimpleFix(message, suggestion string) { m.ErrorWithSimpleFixCalls = append(m.ErrorWithSimpleFixCalls, message+": "+suggestion) } +// recordCall is a helper to reduce duplication in mock methods. +func (m *MockErrorReporter) recordCall(callSlice *[]string, format string, args ...any) { + *callSlice = append(*callSlice, fmt.Sprintf(format, args...)) +} + // MockProgressReporter implements ProgressReporter for testing. type MockProgressReporter struct { ProgressCalls []string } func (m *MockProgressReporter) Progress(format string, args ...any) { - m.ProgressCalls = append(m.ProgressCalls, formatMessage(format, args...)) + m.recordCall(&m.ProgressCalls, format, args...) +} + +// recordCall is a helper to reduce duplication in mock methods. +func (m *MockProgressReporter) recordCall(callSlice *[]string, format string, args ...any) { + *callSlice = append(*callSlice, fmt.Sprintf(format, args...)) } // MockOutputConfig implements OutputConfig for testing. @@ -101,7 +118,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)) + m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, fmt.Sprintf("%s (total: %d)", description, total)) return nil // Return nil for mock to avoid actual progress bar } @@ -109,7 +126,7 @@ func (m *MockProgressManager) CreateProgressBar(description string, total int) * func (m *MockProgressManager) CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar { m.CreateProgressBarForFilesCalls = append( m.CreateProgressBarForFilesCalls, - formatMessage("%s (files: %d)", description, len(files)), + fmt.Sprintf("%s (files: %d)", description, len(files)), ) return nil // Return nil for mock to avoid actual progress bar @@ -134,7 +151,7 @@ func (m *MockProgressManager) ProcessWithProgressBar( ) { m.ProcessWithProgressBarCalls = append( m.ProcessWithProgressBarCalls, - formatMessage("%s (items: %d)", description, len(items)), + fmt.Sprintf("%s (items: %d)", description, len(items)), ) // Execute the process function for each item for _, item := range items { @@ -142,57 +159,8 @@ func (m *MockProgressManager) ProcessWithProgressBar( } } -// Helper function to format messages consistently. -func formatMessage(format string, args ...any) string { - if len(args) == 0 { - return format - } - // Simple formatting for test purposes - result := format - for _, arg := range args { - result = strings.Replace(result, "%s", toString(arg), 1) - result = strings.Replace(result, "%d", toString(arg), 1) - result = strings.Replace(result, "%v", toString(arg), 1) - } - - return result -} - -func toString(v any) string { - switch val := v.(type) { - case string: - return val - case int: - return formatInt(val) - default: - return "unknown" - } -} - -func formatInt(i int) string { - // Simple int to string conversion for testing - if i == 0 { - return "0" - } - result := "" - negative := i < 0 - if negative { - i = -i - } - for i > 0 { - digit := i % 10 - result = string(rune('0'+digit)) + result - i /= 10 - } - if negative { - result = "-" + result - } - - return result -} - // Test that demonstrates improved testability with focused interfaces. -func TestFocusedInterfaces_SimpleLogger(t *testing.T) { +func TestFocusedInterfacesSimpleLogger(t *testing.T) { t.Parallel() mockLogger := &MockMessageLogger{} simpleLogger := NewSimpleLogger(mockLogger) @@ -202,7 +170,7 @@ func TestFocusedInterfaces_SimpleLogger(t *testing.T) { // Verify the expected calls were made if len(mockLogger.InfoCalls) != 1 { - t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls)) + t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls)) } if len(mockLogger.SuccessCalls) != 1 { t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls)) @@ -221,7 +189,7 @@ func TestFocusedInterfaces_SimpleLogger(t *testing.T) { } } -func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) { +func TestFocusedInterfacesSimpleLoggerWithFailure(t *testing.T) { t.Parallel() mockLogger := &MockMessageLogger{} simpleLogger := NewSimpleLogger(mockLogger) @@ -231,7 +199,7 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) { // Verify the expected calls were made if len(mockLogger.InfoCalls) != 1 { - t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls)) + t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls)) } if len(mockLogger.SuccessCalls) != 0 { t.Errorf("expected 0 Success calls, got %d", len(mockLogger.SuccessCalls)) @@ -241,10 +209,10 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) { } } -func TestFocusedInterfaces_ErrorManager(t *testing.T) { +func TestFocusedInterfacesErrorManager(t *testing.T) { t.Parallel() mockReporter := &MockErrorReporter{} - mockFormatter := &MockErrorFormatter{} + mockFormatter := &errorFormatterWrapper{&testutil.ErrorFormatterMock{}} mockManager := &mockErrorManager{ reporter: mockReporter, formatter: mockFormatter, @@ -264,7 +232,7 @@ func TestFocusedInterfaces_ErrorManager(t *testing.T) { } } -func TestFocusedInterfaces_TaskProgress(t *testing.T) { +func TestFocusedInterfacesTaskProgress(t *testing.T) { t.Parallel() mockReporter := &MockProgressReporter{} taskProgress := NewTaskProgress(mockReporter) @@ -282,7 +250,7 @@ func TestFocusedInterfaces_TaskProgress(t *testing.T) { } } -func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) { +func TestFocusedInterfacesConfigAwareComponent(t *testing.T) { t.Parallel() tests := []struct { name string @@ -316,7 +284,7 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) { } } -func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) { +func TestFocusedInterfacesCompositeOutputWriter(t *testing.T) { t.Parallel() // Create a composite mock that implements OutputWriter mockLogger := &MockMessageLogger{} @@ -337,7 +305,7 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) { // Verify that the composite writer uses both message logging and progress reporting // Should have called Info and Success for overall status if len(mockLogger.InfoCalls) != 1 { - t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls)) + t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls)) } if len(mockLogger.SuccessCalls) != 1 { t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls)) @@ -349,13 +317,13 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) { } } -func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) { +func TestFocusedInterfacesGeneratorWithDependencyInjection(t *testing.T) { t.Parallel() // Create focused mocks mockOutput := &mockCompleteOutput{ logger: &MockMessageLogger{}, reporter: &MockErrorReporter{}, - formatter: &MockErrorFormatter{}, + formatter: &errorFormatterWrapper{&testutil.ErrorFormatterMock{}}, progress: &MockProgressReporter{}, config: &MockOutputConfig{QuietMode: false}, } @@ -440,20 +408,14 @@ func (m *mockOutputWriter) Fprintf(w *os.File, format string, args ...any) { func (m *mockOutputWriter) Progress(format string, args ...any) { m.reporter.Progress(format, args...) } func (m *mockOutputWriter) IsQuiet() bool { return m.config.IsQuiet() } -// MockErrorFormatter implements ErrorFormatter for testing. -type MockErrorFormatter struct { - FormatContextualErrorCalls []string +// errorFormatterWrapper wraps testutil.ErrorFormatterMock to implement ErrorFormatter interface. +type errorFormatterWrapper struct { + *testutil.ErrorFormatterMock } -func (m *MockErrorFormatter) FormatContextualError(err *apperrors.ContextualError) string { - if err != nil { - formatted := err.Error() - m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted) - - return formatted - } - - return "" +// FormatContextualError adapts the generic error interface to ContextualError. +func (w *errorFormatterWrapper) FormatContextualError(err *apperrors.ContextualError) string { + return w.ErrorFormatterMock.FormatContextualError(err) } // mockErrorManager implements ErrorManager for testing. diff --git a/internal/internal_parser_test.go b/internal/internal_parser_test.go index 90b87c9..dd08cd2 100644 --- a/internal/internal_parser_test.go +++ b/internal/internal_parser_test.go @@ -6,7 +6,7 @@ import ( "github.com/ivuorinen/gh-action-readme/testutil" ) -func TestParseActionYML_Valid(t *testing.T) { +func TestParseActionYMLValid(t *testing.T) { t.Parallel() // Create temporary action file using fixture actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml") @@ -25,7 +25,7 @@ func TestParseActionYML_Valid(t *testing.T) { } } -func TestParseActionYML_MissingFile(t *testing.T) { +func TestParseActionYMLMissingFile(t *testing.T) { t.Parallel() _, err := ParseActionYML("notfound/action.yml") if err == nil { diff --git a/internal/internal_template_test.go b/internal/internal_template_test.go index c035baa..291d23f 100644 --- a/internal/internal_template_test.go +++ b/internal/internal_template_test.go @@ -21,7 +21,7 @@ func TestRenderReadme(t *testing.T) { "foo": {Description: "Foo input", Required: true}, }, } - tmpl := filepath.Join(tmpDir, "templates", "readme.tmpl") + tmpl := filepath.Join(tmpDir, "templates", testutil.TestTemplateReadme) opts := TemplateOptions{TemplatePath: tmpl, Format: "md"} out, err := RenderReadme(action, opts) if err != nil { diff --git a/internal/internal_validator_test.go b/internal/internal_validator_test.go index 06aeeaf..f5c3235 100644 --- a/internal/internal_validator_test.go +++ b/internal/internal_validator_test.go @@ -2,7 +2,7 @@ package internal import "testing" -func TestValidateActionYML_Required(t *testing.T) { +func TestValidateActionYMLRequired(t *testing.T) { t.Parallel() a := &ActionYML{ @@ -16,7 +16,7 @@ func TestValidateActionYML_Required(t *testing.T) { } } -func TestValidateActionYML_Valid(t *testing.T) { +func TestValidateActionYMLValid(t *testing.T) { t.Parallel() a := &ActionYML{ Name: "MyAction", diff --git a/internal/json_writer.go b/internal/json_writer.go index e33d4d7..3e31cb9 100644 --- a/internal/json_writer.go +++ b/internal/json_writer.go @@ -228,8 +228,8 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput { Badges: badges, Sections: sections, Links: map[string]string{ - "action.yml": "./action.yml", - "repository": "https://github.com/your-org/" + action.Name, + appconstants.ActionFileNameYML: "./" + appconstants.ActionFileNameYML, + "repository": "https://github.com/your-org/" + action.Name, }, }, Examples: examples, diff --git a/internal/output.go b/internal/output.go index 0a65ce9..6890427 100644 --- a/internal/output.go +++ b/internal/output.go @@ -43,14 +43,7 @@ func (co *ColoredOutput) IsQuiet() bool { // Success prints a success message in green. func (co *ColoredOutput) Success(format string, args ...any) { - if co.Quiet { - return - } - if co.NoColor { - fmt.Printf("✅ "+format+"\n", args...) - } else { - color.Green("✅ "+format, args...) - } + co.printWithIcon("✅", format, color.Green, args...) } // Error prints an error message in red to stderr. @@ -64,38 +57,17 @@ func (co *ColoredOutput) Error(format string, args ...any) { // Warning prints a warning message in yellow. func (co *ColoredOutput) Warning(format string, args ...any) { - if co.Quiet { - return - } - if co.NoColor { - fmt.Printf("⚠️ "+format+"\n", args...) - } else { - color.Yellow("⚠️ "+format, args...) - } + co.printWithIcon("⚠️ ", format, color.Yellow, args...) } // Info prints an info message in blue. func (co *ColoredOutput) Info(format string, args ...any) { - if co.Quiet { - return - } - if co.NoColor { - fmt.Printf("ℹ️ "+format+"\n", args...) - } else { - color.Blue("ℹ️ "+format, args...) - } + co.printWithIcon("ℹ️ ", format, color.Blue, args...) } // Progress prints a progress message in cyan. func (co *ColoredOutput) Progress(format string, args ...any) { - if co.Quiet { - return - } - if co.NoColor { - fmt.Printf("🔄 "+format+"\n", args...) - } else { - color.Cyan("🔄 "+format, args...) - } + co.printWithIcon("🔄", format, color.Cyan, args...) } // Bold prints text in bold. @@ -194,6 +166,20 @@ func (co *ColoredOutput) FormatContextualError(err *apperrors.ContextualError) s return strings.Join(parts, "\n") } +// printWithIcon is a helper for printing messages with icons and colors. +// It handles quiet mode, color toggling, and consistent formatting. +func (co *ColoredOutput) printWithIcon(icon, format string, colorFunc func(string, ...any), args ...any) { + if co.Quiet { + return + } + message := icon + " " + format + if co.NoColor { + fmt.Printf(message+"\n", args...) + } else { + colorFunc(message, args...) + } +} + // formatMainError formats the main error message with code. func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string { mainMsg := fmt.Sprintf("%s [%s]", err.Error(), err.Code) @@ -204,15 +190,19 @@ func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string return color.RedString("❌ ") + mainMsg } +// formatBoldSection formats a section header with or without color. +func (co *ColoredOutput) formatBoldSection(section string) string { + if co.NoColor { + return section + } + + return color.New(color.Bold).Sprint(section) +} + // formatDetailsSection formats the details section. func (co *ColoredOutput) formatDetailsSection(details map[string]string) []string { var parts []string - - if co.NoColor { - parts = append(parts, appconstants.SectionDetails) - } else { - parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionDetails)) - } + parts = append(parts, co.formatBoldSection(appconstants.SectionDetails)) for key, value := range details { if co.NoColor { @@ -230,12 +220,7 @@ func (co *ColoredOutput) formatDetailsSection(details map[string]string) []strin // formatSuggestionsSection formats the suggestions section. func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string { var parts []string - - if co.NoColor { - parts = append(parts, appconstants.SectionSuggestions) - } else { - parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionSuggestions)) - } + parts = append(parts, co.formatBoldSection(appconstants.SectionSuggestions)) for _, suggestion := range suggestions { if co.NoColor { diff --git a/internal/output_test.go b/internal/output_test.go new file mode 100644 index 0000000..4e98512 --- /dev/null +++ b/internal/output_test.go @@ -0,0 +1,542 @@ +package internal + +import ( + "os" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// testOutputMethod is a generic helper for testing output methods that follow the same pattern. +func testOutputMethod(t *testing.T, testMessage, expectedEmoji string, methodFunc func(*ColoredOutput, string)) { + t.Helper() + + tests := []struct { + name string + quiet bool + message string + wantEmpty bool + }{ + { + name: "message displayed", + quiet: false, + message: testMessage, + wantEmpty: false, + }, + { + name: testutil.TestMsgQuietSuppressOutput, + quiet: true, + message: testMessage, + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{Quiet: tt.quiet, NoColor: true} + + captured := testutil.CaptureStdout(func() { + methodFunc(output, tt.message) + }) + + if tt.wantEmpty && captured != "" { + t.Errorf(testutil.TestMsgNoOutputInQuiet, captured) + } + + if !tt.wantEmpty && !strings.Contains(captured, expectedEmoji) { + t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured) + } + }) + } +} + +// testErrorStderr is a helper for testing error output methods that write to stderr. +// Eliminates the repeated pattern of creating ColoredOutput, capturing stderr, and checking for emoji. +func testErrorStderr(t *testing.T, expectedEmoji string, testFunc func(*ColoredOutput)) { + t.Helper() + + output := &ColoredOutput{NoColor: true} + captured := testutil.CaptureStderr(func() { + testFunc(output) + }) + + if !strings.Contains(captured, expectedEmoji) { + t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured) + } +} + +// TestNewColoredOutput tests colored output creation. +func TestNewColoredOutput(t *testing.T) { + tests := []struct { + name string + quiet bool + wantQuiet bool + }{ + { + name: testutil.TestScenarioQuietEnabled, + quiet: true, + wantQuiet: true, + }, + { + name: testutil.TestScenarioQuietDisabled, + quiet: false, + wantQuiet: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := NewColoredOutput(tt.quiet) + + if output == nil { + t.Fatal("NewColoredOutput() returned nil") + } + + if output.Quiet != tt.wantQuiet { + t.Errorf("Quiet = %v, want %v", output.Quiet, tt.wantQuiet) + } + }) + } +} + +// TestIsQuiet tests quiet mode detection. +func TestIsQuiet(t *testing.T) { + tests := []struct { + name string + quiet bool + want bool + }{ + { + name: testutil.TestScenarioQuietEnabled, + quiet: true, + want: true, + }, + { + name: testutil.TestScenarioQuietDisabled, + quiet: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{Quiet: tt.quiet, NoColor: true} + got := output.IsQuiet() + + if got != tt.want { + t.Errorf("IsQuiet() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestSuccess tests success message output. +func TestSuccess(t *testing.T) { + testOutputMethod(t, testutil.TestMsgOperationCompleted, "✅", func(o *ColoredOutput, msg string) { + o.Success(msg) + }) +} + +// TestError tests error message output. +func TestError(t *testing.T) { + tests := []struct { + name string + message string + wantContains string + }{ + { + name: "error message displayed", + message: testutil.TestMsgFileNotFound, + wantContains: "❌ File not found", + }, + { + name: "error with formatting", + message: "Failed to process %s", + wantContains: "❌ Failed to process %!s(MISSING)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: true} + + captured := testutil.CaptureStderr(func() { + output.Error(tt.message) + }) + + if !strings.Contains(captured, "❌") { + t.Errorf(testutil.TestMsgOutputMissingEmoji, captured) + } + + if !strings.Contains(captured, strings.TrimPrefix(tt.wantContains, "❌ ")) { + t.Errorf("Output doesn't contain expected message. Got: %q", captured) + } + }) + } +} + +// TestWarning tests warning message output. +func TestWarning(t *testing.T) { + testOutputMethod(t, "Deprecated feature", "⚠️", func(o *ColoredOutput, msg string) { + o.Warning(msg) + }) +} + +// TestInfo tests info message output. +func TestInfo(t *testing.T) { + testOutputMethod(t, testutil.TestMsgProcessingStarted, "ℹ️", func(o *ColoredOutput, msg string) { + o.Info(msg) + }) +} + +// TestProgress tests progress message output. +func TestProgress(t *testing.T) { + testOutputMethod(t, "Loading data...", "🔄", func(o *ColoredOutput, msg string) { + o.Progress(msg) + }) +} + +// TestBold tests bold text output. +func TestBold(t *testing.T) { + testOutputMethod(t, "Important Notice", "Important Notice", func(o *ColoredOutput, msg string) { + o.Bold(msg) + }) +} + +// TestPrintf tests formatted print output. +func TestPrintf(t *testing.T) { + testOutputMethod(t, "Test message\n", "Test message", func(o *ColoredOutput, msg string) { + o.Printf("%s", msg) // #nosec G104 -- constant format string + }) +} + +// TestFprintf tests file output. +func TestFprintf(t *testing.T) { + // Create temporary file for testing + tmpfile, err := os.CreateTemp(t.TempDir(), "test-fprintf-*.txt") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpfile.Name()) }() // Ignore error + defer func() { _ = tmpfile.Close() }() // Ignore error + + output := &ColoredOutput{NoColor: true} + output.Fprintf(tmpfile, "Test message: %s\n", "hello") + + // Read back the content + _, _ = tmpfile.Seek(0, 0) // Ignore error in test + content := make([]byte, 100) + n, _ := tmpfile.Read(content) + + got := string(content[:n]) + want := "Test message: hello\n" + + if got != want { + t.Errorf("Fprintf() wrote %q, want %q", got, want) + } +} + +// TestErrorWithSuggestions tests contextual error output. +func TestErrorWithSuggestions(t *testing.T) { + tests := []struct { + name string + err *apperrors.ContextualError + wantContains string + }{ + { + name: "nil error does nothing", + err: nil, + wantContains: "", + }, + { + name: "error with suggestions", + err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound). + WithSuggestions(testutil.TestMsgCheckFilePath), + wantContains: "❌", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: true} + + captured := testutil.CaptureStderr(func() { + output.ErrorWithSuggestions(tt.err) + }) + + if tt.wantContains == "" && captured != "" { + t.Errorf("Expected no output for nil error, got %q", captured) + } + + if tt.wantContains != "" && !strings.Contains(captured, tt.wantContains) { + t.Errorf("Output doesn't contain %q. Got: %q", tt.wantContains, captured) + } + }) + } +} + +// TestErrorWithContext tests contextual error creation and output. +func TestErrorWithContext(t *testing.T) { + tests := []struct { + name string + code appconstants.ErrorCode + message string + context map[string]string + }{ + { + name: "error with context", + code: appconstants.ErrCodeFileNotFound, + message: testutil.TestMsgFileNotFound, + context: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML}, + }, + { + name: "error without context", + code: appconstants.ErrCodeInvalidYAML, + message: testutil.TestMsgInvalidYAML, + context: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: true} + + captured := testutil.CaptureStderr(func() { + output.ErrorWithContext(tt.code, tt.message, tt.context) + }) + + if !strings.Contains(captured, "❌") { + t.Errorf(testutil.TestMsgOutputMissingEmoji, captured) + } + }) + } +} + +// TestErrorWithSimpleFix tests simple error with fix output. +func TestErrorWithSimpleFix(t *testing.T) { + testErrorStderr(t, "❌", func(output *ColoredOutput) { + output.ErrorWithSimpleFix("Something went wrong", "Try running it again") + }) +} + +// TestFormatContextualError tests contextual error formatting. +func TestFormatContextualError(t *testing.T) { + tests := []struct { + name string + err *apperrors.ContextualError + wantContains []string + }{ + { + name: "nil error returns empty string", + err: nil, + wantContains: nil, + }, + { + name: "error with all sections", + err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound). + WithSuggestions(testutil.TestMsgCheckFilePath, testutil.TestMsgVerifyPermissions). + WithDetails(map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML}). + WithHelpURL(testutil.TestURLHelp), + wantContains: []string{ + "❌", + testutil.TestMsgFileNotFound, + testutil.TestMsgCheckFilePath, + testutil.TestURLHelp, + }, + }, + { + name: "error without suggestions", + err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML), + wantContains: []string{"❌", testutil.TestMsgInvalidYAML}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: true} + got := output.FormatContextualError(tt.err) + + if tt.err == nil && got != "" { + t.Errorf("Expected empty string for nil error, got %q", got) + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("FormatContextualError() missing %q. Got:\n%s", want, got) + } + } + }) + } +} + +// TestFormatMainError tests main error message formatting. +func TestFormatMainError(t *testing.T) { + tests := []struct { + name string + noColor bool + err *apperrors.ContextualError + wantContains []string + }{ + { + name: testutil.TestScenarioColorDisabled, + noColor: true, + err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound), + wantContains: []string{"❌", testutil.TestMsgFileNotFound, string(appconstants.ErrCodeFileNotFound)}, + }, + { + name: testutil.TestScenarioColorEnabled, + noColor: false, + err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML), + wantContains: []string{"❌", testutil.TestMsgInvalidYAML, string(appconstants.ErrCodeInvalidYAML)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: tt.noColor} + got := output.formatMainError(tt.err) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("formatMainError() missing %q. Got: %q", want, got) + } + } + }) + } +} + +// TestFormatDetailsSection tests details section formatting. +func TestFormatDetailsSection(t *testing.T) { + tests := []struct { + name string + noColor bool + details map[string]string + wantContains []string + }{ + { + name: testutil.TestScenarioColorDisabled, + noColor: true, + details: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML, "line": "10"}, + wantContains: []string{ + testutil.TestMsgDetails, + testutil.TestKeyFile, + appconstants.ActionFileNameYML, + "line", + "10", + }, + }, + { + name: testutil.TestScenarioColorEnabled, + noColor: false, + details: map[string]string{testutil.TestKeyPath: "/tmp/test"}, + wantContains: []string{testutil.TestMsgDetails, "path", "/tmp/test"}, + }, + { + name: "empty details", + noColor: true, + details: map[string]string{}, + wantContains: []string{testutil.TestMsgDetails}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: tt.noColor} + got := output.formatDetailsSection(tt.details) + gotStr := strings.Join(got, "\n") + + for _, want := range tt.wantContains { + if !strings.Contains(gotStr, want) { + t.Errorf("formatDetailsSection() missing %q. Got:\n%s", want, gotStr) + } + } + }) + } +} + +// TestFormatSuggestionsSection tests suggestions section formatting. +func TestFormatSuggestionsSection(t *testing.T) { + tests := []struct { + name string + noColor bool + suggestions []string + wantContains []string + }{ + { + name: testutil.TestScenarioColorDisabled, + noColor: true, + suggestions: []string{"Check the file", testutil.TestMsgVerifyPermissions}, + wantContains: []string{ + testutil.TestMsgSuggestions, + "•", + "Check the file", + testutil.TestMsgVerifyPermissions, + }, + }, + { + name: testutil.TestScenarioColorEnabled, + noColor: false, + suggestions: []string{testutil.TestMsgTryAgain}, + wantContains: []string{testutil.TestMsgSuggestions, "•", testutil.TestMsgTryAgain}, + }, + { + name: "empty suggestions", + noColor: true, + suggestions: []string{}, + wantContains: []string{testutil.TestMsgSuggestions}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: tt.noColor} + got := output.formatSuggestionsSection(tt.suggestions) + gotStr := strings.Join(got, "\n") + + for _, want := range tt.wantContains { + if !strings.Contains(gotStr, want) { + t.Errorf("formatSuggestionsSection() missing %q. Got:\n%s", want, gotStr) + } + } + }) + } +} + +// TestFormatHelpURLSection tests help URL section formatting. +func TestFormatHelpURLSection(t *testing.T) { + tests := []struct { + name string + noColor bool + helpURL string + wantContains []string + }{ + { + name: testutil.TestScenarioColorDisabled, + noColor: true, + helpURL: testutil.TestURLHelp, + wantContains: []string{"For more help", testutil.TestURLHelp}, + }, + { + name: testutil.TestScenarioColorEnabled, + noColor: false, + helpURL: "https://docs.example.com", + wantContains: []string{"For more help", "https://docs.example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := &ColoredOutput{NoColor: tt.noColor} + got := output.formatHelpURLSection(tt.helpURL) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("formatHelpURLSection() missing %q. Got: %q", want, got) + } + } + }) + } +} diff --git a/internal/parser.go b/internal/parser.go index ecc2ab0..835d267 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -264,11 +264,12 @@ func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]st } // Check only the specified directory (non-recursive) - return discoverActionFilesNonRecursive(dir), nil + return DiscoverActionFilesNonRecursive(dir), nil } -// discoverActionFilesNonRecursive finds action files in a single directory. -func discoverActionFilesNonRecursive(dir string) []string { +// DiscoverActionFilesNonRecursive finds action files (action.yml or action.yaml) in a single directory. +// This is exported for use by other packages that need to discover action files. +func DiscoverActionFilesNonRecursive(dir string) []string { var actionFiles []string for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} { path := filepath.Join(dir, filename) diff --git a/internal/parser_test.go b/internal/parser_test.go index b63a05f..e64fec5 100644 --- a/internal/parser_test.go +++ b/internal/parser_test.go @@ -17,48 +17,9 @@ const testPermissionWrite = "write" func parseActionFromContent(t *testing.T, content string) (*ActionYML, error) { t.Helper() - tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() + actionPath := testutil.CreateTempActionFile(t, content) - if _, err := tmpFile.WriteString(content); err != nil { - t.Fatal(err) - } - _ = tmpFile.Close() - - return ParseActionYML(tmpFile.Name()) -} - -// createTestDirWithAction creates a directory with an action.yml file and returns both paths. -func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) { - t.Helper() - dirPath := filepath.Join(baseDir, dirName) - if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { - t.Fatalf(testutil.ErrCreateDir(dirName), err) - } - actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML) - if err := os.WriteFile( - actionPath, []byte(yamlContent), appconstants.FilePermDefault, - ); err != nil { - t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err) - } - - return dirPath, actionPath -} - -// createTestFile creates a file with the given content and returns its path. -func createTestFile(t *testing.T, baseDir, fileName, content string) string { - t.Helper() - filePath := filepath.Join(baseDir, fileName) - if err := os.WriteFile( - filePath, []byte(content), appconstants.FilePermDefault, - ); err != nil { - t.Fatalf(testutil.ErrCreateFile(fileName), err) - } - - return filePath + return ParseActionYML(actionPath) } // validateDiscoveredFiles checks if discovered files match expected count and paths. @@ -183,18 +144,18 @@ func TestDiscoverActionFilesWithIgnoredDirectories(t *testing.T) { // action.yml (should be found) // Create root action.yml - rootAction := createTestFile(t, tmpDir, appconstants.ActionFileNameYML, appconstants.TestYAMLRoot) + rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot) // Create directories with action.yml files - _, nodeModulesAction := createTestDirWithAction( + _, nodeModulesAction := testutil.CreateNestedAction( t, tmpDir, appconstants.DirNodeModules, - appconstants.TestYAMLNodeModules, + testutil.TestYAMLNodeModules, ) - _, vendorAction := createTestDirWithAction(t, tmpDir, appconstants.DirVendor, appconstants.TestYAMLVendor) - _, gitAction := createTestDirWithAction(t, tmpDir, appconstants.DirGit, appconstants.TestYAMLGit) - _, srcAction := createTestDirWithAction(t, tmpDir, "src", appconstants.TestYAMLSrc) + _, vendorAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirVendor, testutil.TestYAMLVendor) + _, gitAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirGit, testutil.TestYAMLGit) + _, srcAction := testutil.CreateNestedAction(t, tmpDir, "src", testutil.TestYAMLSrc) tests := []struct { name string @@ -245,17 +206,9 @@ func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) { // nested/ // action.yml (should be ignored) - nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested") - if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { - t.Fatalf(testutil.ErrCreateDir("nested"), err) - } + nodeModulesDir := testutil.CreateTestSubdir(t, tmpDir, appconstants.DirNodeModules, "deep", "nested") - nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML) - if err := os.WriteFile( - nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault, - ); err != nil { - t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err) - } + testutil.WriteFileInDir(t, nodeModulesDir, appconstants.ActionFileNameYML, testutil.TestYAMLNested) files, err := DiscoverActionFiles(tmpDir, true, []string{appconstants.DirNodeModules}) if err != nil { @@ -273,24 +226,14 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) { tmpDir := t.TempDir() // Create action.yml in root - rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML) - if err := os.WriteFile( - rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault, - ); err != nil { - t.Fatalf(testutil.ErrCreateFile("action.yml"), err) - } + rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot) // Create subdirectory (should not be searched in non-recursive mode) subDir := filepath.Join(tmpDir, "sub") if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil { t.Fatalf(testutil.ErrCreateDir("sub"), err) } - subAction := filepath.Join(subDir, appconstants.ActionFileNameYML) - if err := os.WriteFile( - subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault, - ); err != nil { - t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err) - } + testutil.WriteFileInDir(t, subDir, appconstants.ActionFileNameYML, testutil.TestYAMLSub) files, err := DiscoverActionFiles(tmpDir, false, []string{}) if err != nil { @@ -317,23 +260,16 @@ func TestParsePermissionsFromComments(t *testing.T) { wantErr bool }{ { - name: "single permission with dash format", - content: `# yaml-language-server: $schema=https://json.schemastore.org/github-action.json -# permissions: -# - contents: read # Required for checking out repository -name: Test Action`, + name: "single permission with dash format", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashSingle)), want: map[string]string{ "contents": "read", }, wantErr: false, }, { - name: "multiple permissions", - content: `# permissions: -# - contents: read -# - issues: write -# - pull-requests: write -name: Test Action`, + name: "multiple permissions", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashMultiple)), want: map[string]string{ "contents": "read", "issues": "write", @@ -342,11 +278,8 @@ name: Test Action`, wantErr: false, }, { - name: "permissions without dash", - content: `# permissions: -# contents: read -# issues: write -name: Test Action`, + name: "permissions without dash", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsObject)), want: map[string]string{ "contents": "read", "issues": "write", @@ -354,18 +287,14 @@ name: Test Action`, wantErr: false, }, { - name: "no permissions block", - content: `# Just a comment -name: Test Action`, + name: "no permissions block", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsNone)), want: map[string]string{}, wantErr: false, }, { - name: "permissions with inline comments", - content: `# permissions: -# - contents: read # Needed for checkout -# - issues: write # To create issues -name: Test Action`, + name: "permissions with inline comments", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsInlineComments)), want: map[string]string{ "contents": "read", "issues": "write", @@ -373,18 +302,14 @@ name: Test Action`, wantErr: false, }, { - name: "empty permissions block", - content: `# permissions: -name: Test Action`, + name: "empty permissions block", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsEmpty)), want: map[string]string{}, wantErr: false, }, { - name: "permissions with mixed formats", - content: `# permissions: -# - contents: read -# issues: write -name: Test Action`, + name: "permissions with mixed formats", + content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsMixed)), want: map[string]string{ "contents": "read", "issues": "write", @@ -397,19 +322,8 @@ name: Test Action`, t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Create temp file - tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - - if _, err := tmpFile.WriteString(tt.content); err != nil { - t.Fatal(err) - } - _ = tmpFile.Close() - - got, err := parsePermissionsFromComments(tmpFile.Name()) + actionPath := testutil.CreateTempActionFile(t, tt.content) + got, err := parsePermissionsFromComments(actionPath) if (err != nil) != tt.wantErr { t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr) @@ -428,17 +342,17 @@ name: Test Action`, func TestParseActionYMLWithCommentPermissions(t *testing.T) { t.Parallel() - content := appconstants.TestPermissionsHeader + + content := testutil.TestPermissionsHeader + "# - contents: read\n" + - appconstants.TestActionNameLine + - appconstants.TestDescriptionLine + - appconstants.TestRunsLine + - appconstants.TestCompositeUsing + - appconstants.TestStepsEmpty + testutil.TestActionNameLine + + testutil.TestDescriptionLine + + testutil.TestRunsLine + + testutil.TestCompositeUsing + + testutil.TestStepsEmpty action, err := parseActionFromContent(t, content) if err != nil { - t.Fatalf(appconstants.TestErrorFormat, err) + t.Fatalf(testutil.TestErrorFormat, err) } if action.Permissions == nil { @@ -454,20 +368,20 @@ func TestParseActionYMLWithCommentPermissions(t *testing.T) { func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) { t.Parallel() - content := appconstants.TestPermissionsHeader + + content := testutil.TestPermissionsHeader + "# - contents: read\n" + "# - issues: write\n" + - appconstants.TestActionNameLine + - appconstants.TestDescriptionLine + + testutil.TestActionNameLine + + testutil.TestDescriptionLine + "permissions:\n" + " contents: write # YAML override\n" + - appconstants.TestRunsLine + - appconstants.TestCompositeUsing + - appconstants.TestStepsEmpty + testutil.TestRunsLine + + testutil.TestCompositeUsing + + testutil.TestStepsEmpty action, err := parseActionFromContent(t, content) if err != nil { - t.Fatalf(appconstants.TestErrorFormat, err) + t.Fatalf(testutil.TestErrorFormat, err) } // YAML should override comment @@ -491,18 +405,18 @@ func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) { func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) { t.Parallel() - content := appconstants.TestActionNameLine + - appconstants.TestDescriptionLine + + content := testutil.TestActionNameLine + + testutil.TestDescriptionLine + "permissions:\n" + " contents: read\n" + " issues: write\n" + - appconstants.TestRunsLine + - appconstants.TestCompositeUsing + - appconstants.TestStepsEmpty + testutil.TestRunsLine + + testutil.TestCompositeUsing + + testutil.TestStepsEmpty action, err := parseActionFromContent(t, content) if err != nil { - t.Fatalf(appconstants.TestErrorFormat, err) + t.Fatalf(testutil.TestErrorFormat, err) } if action.Permissions == nil { @@ -522,15 +436,15 @@ func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) { func TestParseActionYMLNoPermissions(t *testing.T) { t.Parallel() - content := appconstants.TestActionNameLine + - appconstants.TestDescriptionLine + - appconstants.TestRunsLine + - appconstants.TestCompositeUsing + - appconstants.TestStepsEmpty + content := testutil.TestActionNameLine + + testutil.TestDescriptionLine + + testutil.TestRunsLine + + testutil.TestCompositeUsing + + testutil.TestStepsEmpty action, err := parseActionFromContent(t, content) if err != nil { - t.Fatalf(appconstants.TestErrorFormat, err) + t.Fatalf(testutil.TestErrorFormat, err) } if action.Permissions != nil { @@ -542,8 +456,8 @@ func TestParseActionYMLNoPermissions(t *testing.T) { func TestParseActionYMLMalformedYAML(t *testing.T) { t.Parallel() - content := appconstants.TestActionNameLine + - appconstants.TestDescriptionLine + + content := testutil.TestActionNameLine + + testutil.TestDescriptionLine + "invalid-yaml: [\n" + // Unclosed bracket " - item" @@ -557,15 +471,8 @@ func TestParseActionYMLMalformedYAML(t *testing.T) { func TestParseActionYMLEmptyFile(t *testing.T) { t.Parallel() - tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - - _ = tmpFile.Close() - - _, err = ParseActionYML(tmpFile.Name()) + actionPath := testutil.CreateTempActionFile(t, "") + _, err := ParseActionYML(actionPath) // Empty file should return EOF error from YAML parser if err == nil { t.Error("Expected EOF error for empty file, got nil") @@ -656,7 +563,7 @@ func TestProcessPermissionEntryIndentationEdgeCases(t *testing.T) { }{ { name: "first item sets indent", - line: appconstants.TestContentsRead, + line: testutil.TestContentsRead, content: "contents: read", initialIndent: -1, wantBreak: false, @@ -721,8 +628,8 @@ func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { }{ { name: "duplicate permissions", - content: appconstants.TestPermissionsHeader + - appconstants.TestContentsRead + + content: testutil.TestPermissionsHeader + + testutil.TestContentsRead + "# contents: write\n", wantPerms: map[string]string{"contents": "write"}, wantErr: false, @@ -730,8 +637,8 @@ func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { }, { name: "mixed valid and invalid lines", - content: appconstants.TestPermissionsHeader + - appconstants.TestContentsRead + + content: testutil.TestPermissionsHeader + + testutil.TestContentsRead + "# invalid-line-no-value\n" + "# issues: write\n", wantPerms: map[string]string{"contents": "read", "issues": "write"}, @@ -740,9 +647,9 @@ func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { }, { name: "permissions block ends at non-comment", - content: appconstants.TestPermissionsHeader + - appconstants.TestContentsRead + - appconstants.TestActionNameLine + + content: testutil.TestPermissionsHeader + + testutil.TestContentsRead + + testutil.TestActionNameLine + "# issues: write\n", wantPerms: map[string]string{"contents": "read"}, wantErr: false, @@ -750,8 +657,8 @@ func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { }, { name: "only permissions header", - content: appconstants.TestPermissionsHeader + - appconstants.TestActionNameLine, + content: testutil.TestPermissionsHeader + + testutil.TestActionNameLine, wantPerms: map[string]string{}, wantErr: false, description: "empty permissions block", @@ -760,18 +667,8 @@ func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.yml") - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.Remove(tmpFile.Name()) }() - - if _, err := tmpFile.WriteString(tt.content); err != nil { - t.Fatal(err) - } - _ = tmpFile.Close() - - perms, err := parsePermissionsFromComments(tmpFile.Name()) + actionPath := testutil.CreateTempActionFile(t, tt.content) + perms, err := parsePermissionsFromComments(actionPath) if (err != nil) != tt.wantErr { t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr) @@ -868,7 +765,7 @@ func TestWalkFuncErrorHandling(t *testing.T) { testErr := filepath.SkipDir err = walker.walkFunc(tmpDir, info, testErr) if err != testErr { - t.Errorf("walkFunc() should propagate error, got %v, want %v", err, testErr) + t.Errorf("walkFunc() should propagate error, "+testutil.TestMsgGotWant, err, testErr) } } @@ -878,8 +775,8 @@ func TestParseActionYMLOnlyComments(t *testing.T) { content := "# This is a comment\n" + "# Another comment\n" + - appconstants.TestPermissionsHeader + - appconstants.TestContentsRead + testutil.TestPermissionsHeader + + testutil.TestContentsRead _, err := parseActionFromContent(t, content) // File with only comments should return EOF error from YAML parser diff --git a/internal/progress_test.go b/internal/progress_test.go index 55ef54c..3094bbe 100644 --- a/internal/progress_test.go +++ b/internal/progress_test.go @@ -1,12 +1,13 @@ package internal import ( + "io" "testing" "github.com/schollz/progressbar/v3" ) -func TestProgressBarManager_CreateProgressBar(t *testing.T) { +func TestProgressBarManagerCreateProgressBar(t *testing.T) { t.Parallel() tests := []struct { name string @@ -64,7 +65,7 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) { } } -func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) { +func TestProgressBarManagerCreateProgressBarForFiles(t *testing.T) { t.Parallel() pm := NewProgressBarManager(false) files := []string{"file1.yml", "file2.yml", "file3.yml"} @@ -76,33 +77,44 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) { } } -func TestProgressBarManager_FinishProgressBar(t *testing.T) { +func TestProgressBarManagerNilSafeOperations(t *testing.T) { t.Parallel() - // Use quiet mode to avoid cluttering test output - pm := NewProgressBarManager(true) - // Test with nil bar (should not panic) - pm.FinishProgressBar(nil) + tests := []struct { + name string + operation func(*ProgressBarManager, *progressbar.ProgressBar) + }{ + { + name: "FinishProgressBar handles nil", + operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) { + pm.FinishProgressBar(bar) + }, + }, + { + name: "UpdateProgressBar handles nil", + operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) { + pm.UpdateProgressBar(bar) + }, + }, + } - // Test with actual bar (will be nil in quiet mode) - bar := pm.CreateProgressBar("Test", 5) - pm.FinishProgressBar(bar) // Should handle nil gracefully + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Use quiet mode to avoid cluttering test output + pm := NewProgressBarManager(true) + + // Should not panic with nil + tt.operation(pm, nil) + + // Should not panic with actual bar (will be nil in quiet mode) + bar := pm.CreateProgressBar("Test", 5) + tt.operation(pm, bar) + }) + } } -func TestProgressBarManager_UpdateProgressBar(t *testing.T) { - t.Parallel() - // Use quiet mode to avoid cluttering test output - pm := NewProgressBarManager(true) - - // Test with nil bar (should not panic) - pm.UpdateProgressBar(nil) - - // Test with actual bar (will be nil in quiet mode) - bar := pm.CreateProgressBar("Test", 5) - pm.UpdateProgressBar(bar) // Should handle nil gracefully -} - -func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { +func TestProgressBarManagerProcessWithProgressBar(t *testing.T) { t.Parallel() // Use NullProgressManager to avoid cluttering test output pm := NewNullProgressManager() @@ -126,7 +138,7 @@ func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { } } -func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) { +func TestProgressBarManagerProcessWithProgressBarQuietMode(t *testing.T) { t.Parallel() pm := NewProgressBarManager(true) // quiet mode items := []string{"item1", "item2"} @@ -146,3 +158,32 @@ func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) { t.Errorf("expected %d processed items, got %d", len(items), len(processedItems)) } } + +// TestProgressBarManagerFinishProgressBarWithNewline tests finishing with newline. +func TestProgressBarManagerFinishProgressBarWithNewline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bar *progressbar.ProgressBar + }{ + { + name: "with valid progress bar", + bar: progressbar.NewOptions(10, progressbar.OptionSetWriter(io.Discard)), + }, + { + name: "with nil progress bar", + bar: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pm := NewProgressBarManager(false) + // Should not panic + pm.FinishProgressBarWithNewline(tt.bar) + }) + } +} diff --git a/internal/template.go b/internal/template.go index a15e05d..46d60de 100644 --- a/internal/template.go +++ b/internal/template.go @@ -13,7 +13,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" + templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed" ) // TemplateOptions defines options for rendering templates. @@ -60,32 +60,34 @@ func templateFuncs() template.FuncMap { } } -// getGitOrg returns the Git organization from template data. -func getGitOrg(data any) string { +// getFieldWithFallback extracts a field from TemplateData with Git-then-Config fallback logic. +func getFieldWithFallback(data any, gitGetter, configGetter func(*TemplateData) string, defaultValue string) string { if td, ok := data.(*TemplateData); ok { - if td.Git.Organization != "" { - return td.Git.Organization + if gitValue := gitGetter(td); gitValue != "" { + return gitValue } - if td.Config.Organization != "" { - return td.Config.Organization + if configValue := configGetter(td); configValue != "" { + return configValue } } - return appconstants.DefaultOrgPlaceholder + return defaultValue +} + +// getGitOrg returns the Git organization from template data. +func getGitOrg(data any) string { + return getFieldWithFallback(data, + func(td *TemplateData) string { return td.Git.Organization }, + func(td *TemplateData) string { return td.Config.Organization }, + appconstants.DefaultOrgPlaceholder) } // getGitRepo returns the Git repository name from template data. func getGitRepo(data any) string { - if td, ok := data.(*TemplateData); ok { - if td.Git.Repository != "" { - return td.Git.Repository - } - if td.Config.Repository != "" { - return td.Config.Repository - } - } - - return appconstants.DefaultRepoPlaceholder + return getFieldWithFallback(data, + func(td *TemplateData) string { return td.Git.Repository }, + func(td *TemplateData) string { return td.Config.Repository }, + appconstants.DefaultRepoPlaceholder) } // getGitUsesString returns a complete uses string for the action. @@ -289,7 +291,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 := templates_embed.ReadTemplate(opts.TemplatePath) + tmplContent, err := templatesembed.ReadTemplate(opts.TemplatePath) if err != nil { return "", err } @@ -301,11 +303,11 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) { } var head, foot string if opts.HeaderPath != "" { - h, _ := templates_embed.ReadTemplate(opts.HeaderPath) + h, _ := templatesembed.ReadTemplate(opts.HeaderPath) head = string(h) } if opts.FooterPath != "" { - f, _ := templates_embed.ReadTemplate(opts.FooterPath) + f, _ := templatesembed.ReadTemplate(opts.FooterPath) foot = string(f) } // Wrap template output in header/footer diff --git a/internal/template_helper_test.go b/internal/template_helper_test.go new file mode 100644 index 0000000..f694c81 --- /dev/null +++ b/internal/template_helper_test.go @@ -0,0 +1,165 @@ +package internal + +import ( + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/internal/dependencies" + "github.com/ivuorinen/gh-action-readme/internal/git" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// TestAssertTemplateData_Helper tests the assertTemplateData helper function. +func TestAssertTemplateDataHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func() (*TemplateData, *ActionYML, *AppConfig) + wantOrg string + wantRepo string + }{ + { + name: "valid template data", + setup: func() (*TemplateData, *ActionYML, *AppConfig) { + action := &ActionYML{ + Name: "Test Action", + Description: "A test action", + } + config := &AppConfig{ + Organization: testutil.TestOrgName, + Repository: testutil.TestRepoName, + } + data := &TemplateData{ + ActionYML: action, + Git: git.RepoInfo{ + Organization: testutil.TestOrgName, + Repository: testutil.TestRepoName, + }, + Config: config, + } + + return data, action, config + }, + wantOrg: testutil.TestOrgName, + wantRepo: testutil.TestRepoName, + }, + { + name: "template data with dependencies", + setup: func() (*TemplateData, *ActionYML, *AppConfig) { + action := &ActionYML{ + Name: "Action with deps", + } + config := &AppConfig{ + Organization: testutil.MyOrgName, + Repository: testutil.MyRepoName, + AnalyzeDependencies: true, + } + data := &TemplateData{ + ActionYML: action, + Git: git.RepoInfo{ + Organization: testutil.MyOrgName, + Repository: testutil.MyRepoName, + }, + Config: config, + Dependencies: []dependencies.Dependency{}, // Empty slice, not nil + } + + return data, action, config + }, + wantOrg: testutil.MyOrgName, + wantRepo: testutil.MyRepoName, + }, + { + name: "template data with empty organization", + setup: func() (*TemplateData, *ActionYML, *AppConfig) { + action := &ActionYML{ + Name: "Test", + } + config := &AppConfig{ + Organization: "", + Repository: testutil.RepoName, + } + data := &TemplateData{ + ActionYML: action, + Git: git.RepoInfo{ + Organization: "", + Repository: testutil.RepoName, + }, + Config: config, + } + + return data, action, config + }, + wantOrg: "", + wantRepo: testutil.RepoName, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + data, action, config := tt.setup() + + // Call the helper - it validates the template data + assertTemplateData(t, data, action, config, tt.wantOrg, tt.wantRepo) + }) + } +} + +// TestPrepareTestActionFile_Helper tests the prepareTestActionFile helper function. +func TestPrepareTestActionFileHelper(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actionPath string + wantExists bool + }{ + { + name: "analyzer fixture composite action", + actionPath: testutil.AnalyzerFixturePath + "composite-action.yml", + wantExists: true, + }, + { + name: "analyzer fixture docker action", + actionPath: testutil.AnalyzerFixturePath + "docker-action.yml", + wantExists: true, + }, + { + name: "analyzer fixture javascript action", + actionPath: testutil.AnalyzerFixturePath + "javascript-action.yml", + wantExists: true, + }, + { + name: "nonexistent file path", + actionPath: testutil.AnalyzerFixturePath + "nonexistent.yml", + wantExists: true, // Helper creates a path, even if file doesn't exist + }, + { + name: "non-analyzer path", + actionPath: "some/other/path.yml", + wantExists: true, // Returns tmpDir path + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Call the helper - it prepares a test action file + result := prepareTestActionFile(t, tt.actionPath) + + // Verify we got a path + if result == "" { + t.Error("prepareTestActionFile returned empty path") + } + + // Verify it's an absolute path or relative path + if !filepath.IsAbs(result) && !filepath.IsLocal(result) { + t.Logf("Note: path may be relative or absolute: %s", result) + } + }) + } +} diff --git a/internal/template_test.go b/internal/template_test.go index caa66b3..b6c390c 100644 --- a/internal/template_test.go +++ b/internal/template_test.go @@ -2,10 +2,12 @@ package internal import ( "path/filepath" + "strings" "testing" "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal/git" + "github.com/ivuorinen/gh-action-readme/testutil" ) // newTemplateData creates a TemplateData with common test values. @@ -59,7 +61,7 @@ func TestExtractActionSubdirectory(t *testing.T) { }, { name: "single level subdirectory", - actionPath: appconstants.TestRepoBuildActionPath, + actionPath: testutil.TestRepoBuildActionPath, repoRoot: "/repo", want: "build", }, @@ -71,7 +73,7 @@ func TestExtractActionSubdirectory(t *testing.T) { }, { name: "root action", - actionPath: appconstants.TestRepoActionPath, + actionPath: testutil.TestRepoActionPath, repoRoot: "/repo", want: "", }, @@ -83,7 +85,7 @@ func TestExtractActionSubdirectory(t *testing.T) { }, { name: "empty repo root", - actionPath: appconstants.TestRepoActionPath, + actionPath: testutil.TestRepoActionPath, repoRoot: "", want: "", }, @@ -138,7 +140,7 @@ func TestBuildUsesString(t *testing.T) { { name: "root action", td: &TemplateData{ - ActionPath: appconstants.TestRepoActionPath, + ActionPath: testutil.TestRepoActionPath, RepoRoot: "/repo", }, org: "ivuorinen", @@ -149,7 +151,7 @@ func TestBuildUsesString(t *testing.T) { { name: "empty org", td: &TemplateData{ - ActionPath: appconstants.TestRepoBuildActionPath, + ActionPath: testutil.TestRepoBuildActionPath, RepoRoot: "/repo", }, org: "", @@ -160,7 +162,7 @@ func TestBuildUsesString(t *testing.T) { { name: "empty repo", td: &TemplateData{ - ActionPath: appconstants.TestRepoBuildActionPath, + ActionPath: testutil.TestRepoBuildActionPath, RepoRoot: "/repo", }, org: "ivuorinen", @@ -274,19 +276,19 @@ func TestGetGitUsesString(t *testing.T) { { name: "monorepo action with explicit version", data: newTemplateData("Build Action", "v1.0.0", true, "main", "org", "actions", - appconstants.TestRepoBuildActionPath, "/repo"), + testutil.TestRepoBuildActionPath, "/repo"), want: "org/actions/build@v1.0.0", }, { name: "root level action with default branch", data: newTemplateData("My Action", "", true, "develop", "user", "my-action", - appconstants.TestRepoActionPath, "/repo"), + testutil.TestRepoActionPath, "/repo"), want: "user/my-action@develop", }, { name: "action with use_default_branch disabled", - data: newTemplateData("Test Action", "", false, "main", "org", "test", - appconstants.TestRepoActionPath, "/repo"), + data: newTemplateData(testutil.TestActionName, "", false, "main", "org", "test", + testutil.TestRepoActionPath, "/repo"), want: "org/test@v1", }, } @@ -330,12 +332,12 @@ func TestFormatVersion(t *testing.T) { { name: "version without @", version: "v1.2.3", - want: appconstants.TestVersionV123, + want: testutil.TestVersionV123, }, { name: "version with @", - version: appconstants.TestVersionV123, - want: appconstants.TestVersionV123, + version: testutil.TestVersionV123, + want: testutil.TestVersionV123, }, { name: "main branch", @@ -382,7 +384,7 @@ func TestBuildTemplateData(t *testing.T) { { name: "basic action with config overrides", action: &ActionYML{ - Name: "Test Action", + Name: testutil.TestActionName, Description: "Test description", }, config: &AppConfig{ @@ -390,7 +392,7 @@ func TestBuildTemplateData(t *testing.T) { Repository: "testrepo", }, repoRoot: ".", - actionPath: "action.yml", + actionPath: appconstants.ActionFileNameYML, wantOrg: "testorg", wantRepo: "testrepo", }, @@ -402,7 +404,7 @@ func TestBuildTemplateData(t *testing.T) { }, config: &AppConfig{}, repoRoot: ".", - actionPath: "action.yml", + actionPath: appconstants.ActionFileNameYML, wantOrg: "", wantRepo: "", }, @@ -469,6 +471,27 @@ func assertTemplateData( } // TestAnalyzeDependencies tests the analyzeDependencies function. +// prepareTestActionFile prepares a test action file for analyzeDependencies tests. +func prepareTestActionFile(t *testing.T, actionPath string) string { + t.Helper() + + if strings.HasPrefix(actionPath, "../../testdata/analyzer/") && + actionPath != "../../testdata/analyzer/nonexistent.yml" { + filename := filepath.Base(actionPath) + yamlContent := testutil.MustReadAnalyzerFixture(filename) + + tmpDir := t.TempDir() + tmpPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + tmpPath = testutil.ValidateTestPath(t, tmpPath, tmpDir) + testutil.WriteTestFile(t, tmpPath, yamlContent) + + return tmpPath + } + + // For nonexistent file test + return filepath.Join(t.TempDir(), "nonexistent.yml") +} + func TestAnalyzeDependencies(t *testing.T) { t.Parallel() @@ -508,18 +531,26 @@ func TestAnalyzeDependencies(t *testing.T) { config: &AppConfig{}, expectNil: false, // Should gracefully handle errors and return empty slice }, + { + name: "path traversal attempt", + actionPath: "../../etc/passwd", + config: &AppConfig{}, + expectNil: false, // Returns empty slice for invalid paths + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + actionPath := prepareTestActionFile(t, tt.actionPath) + gitInfo := git.RepoInfo{ Organization: "testorg", Repository: "testrepo", } - result := analyzeDependencies(tt.actionPath, tt.config, gitInfo) + result := analyzeDependencies(actionPath, tt.config, gitInfo) if tt.expectNil && result != nil { t.Errorf("analyzeDependencies() expected nil, got %v", result) diff --git a/internal/testoutput_test.go b/internal/testoutput_test.go new file mode 100644 index 0000000..cfd49e3 --- /dev/null +++ b/internal/testoutput_test.go @@ -0,0 +1,220 @@ +package internal + +import ( + "os" + "testing" + + "github.com/schollz/progressbar/v3" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" +) + +const testFormatString = "test %s %d" + +func TestNullOutput(t *testing.T) { + t.Parallel() + + no := NewNullOutput() + if no == nil { + t.Fatal("NewNullOutput() returned nil") + } + + // Test IsQuiet + if !no.IsQuiet() { + t.Error("NullOutput.IsQuiet() should return true") + } + + // Test all no-op methods don't panic + no.Success("test") + no.Error("test") + no.Warning("test") + no.Info("test") + no.Progress("test") + no.Bold("test") + no.Printf("test") + no.Fprintf(os.Stdout, "test") + + // Test error methods + err := apperrors.New(appconstants.ErrCodeUnknown, "test error") + no.ErrorWithSuggestions(err) + no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", map[string]string{}) + no.ErrorWithSimpleFix("test", "fix") + + // Test FormatContextualError + formatted := no.FormatContextualError(err) + if formatted != "" { + t.Errorf("NullOutput.FormatContextualError() = %q, want empty string", formatted) + } +} + +func TestNullProgressManager(t *testing.T) { + t.Parallel() + + npm := NewNullProgressManager() + if npm == nil { + t.Fatal("NewNullProgressManager() returned nil") + } + + // Test CreateProgressBar returns nil + bar := npm.CreateProgressBar("test", 10) + if bar != nil { + t.Error("NullProgressManager.CreateProgressBar() should return nil") + } + + // Test CreateProgressBarForFiles returns nil + bar = npm.CreateProgressBarForFiles("test", []string{"file1", "file2"}) + if bar != nil { + t.Error("NullProgressManager.CreateProgressBarForFiles() should return nil") + } + + // Test no-op methods don't panic + npm.FinishProgressBar(nil) + npm.FinishProgressBarWithNewline(nil) + npm.UpdateProgressBar(nil) + + // Test ProcessWithProgressBar executes function for each item + var count int + items := []string{"item1", "item2", "item3"} + npm.ProcessWithProgressBar("test", items, func(_ string, _ *progressbar.ProgressBar) { + count++ + }) + + if count != len(items) { + t.Errorf("ProcessWithProgressBar processed %d items, want %d", count, len(items)) + } +} + +// TestNullOutputEdgeCases tests NullOutput methods with edge case inputs. +func TestNullOutputEdgeCases(t *testing.T) { + t.Parallel() + + no := NewNullOutput() + + // Test with empty strings + no.Success("") + no.Error("") + no.Warning("") + no.Info("") + no.Progress("") + no.Bold("") + no.Printf("") + + // Test with special characters + specialChars := "\n\t\r\x00\a\b\v\f" + no.Success(specialChars) + no.Error(specialChars) + no.Warning(specialChars) + no.Info(specialChars) + no.Progress(specialChars) + no.Bold(specialChars) + no.Printf(specialChars) + + // Test with unicode + unicode := "🎉 emoji test 你好 мир" + no.Success(unicode) + no.Error(unicode) + no.Warning(unicode) + no.Info(unicode) + no.Progress(unicode) + no.Bold(unicode) + no.Printf(unicode) + + // Test with format strings and nil args + no.Printf(testFormatString, nil, nil) + no.Success(testFormatString, nil, nil) + no.Error(testFormatString, nil, nil) + + // Test with multiple args + no.Success("test", "arg1", "arg2", "arg3") + no.Error("test", 1, 2, 3, 4, 5) + no.Printf("test %s %d %v", "str", 42, true) +} + +// TestNullOutputErrorMethodsWithNil tests error methods with nil inputs. +func TestNullOutputErrorMethodsWithNil(t *testing.T) { + t.Parallel() + + no := NewNullOutput() + + // Test with nil error + no.ErrorWithSuggestions(nil) + no.FormatContextualError(nil) + + // Test with nil context + no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", nil) + + // Test with empty context + no.ErrorWithContext(appconstants.ErrCodeUnknown, "", map[string]string{}) + + // Test with empty simple fix + no.ErrorWithSimpleFix("", "") +} + +// TestNullProgressManagerEdgeCases tests NullProgressManager with edge cases. +func TestNullProgressManagerEdgeCases(t *testing.T) { + t.Parallel() + + npm := NewNullProgressManager() + + // Test with empty strings + bar := npm.CreateProgressBar("", 0) + if bar != nil { + t.Error("CreateProgressBar with empty string should return nil") + } + + // Test with negative count + bar = npm.CreateProgressBar("test", -1) + if bar != nil { + t.Error("CreateProgressBar with negative count should return nil") + } + + // Test with empty file list + bar = npm.CreateProgressBarForFiles("test", []string{}) + if bar != nil { + t.Error("CreateProgressBarForFiles with empty list should return nil") + } + + // Test with nil file list + bar = npm.CreateProgressBarForFiles("test", nil) + if bar != nil { + t.Error("CreateProgressBarForFiles with nil list should return nil") + } + + // Test ProcessWithProgressBar with empty items + callCount := 0 + npm.ProcessWithProgressBar("test", []string{}, func(_ string, _ *progressbar.ProgressBar) { + callCount++ + }) + if callCount != 0 { + t.Errorf("ProcessWithProgressBar with empty items called func %d times, want 0", callCount) + } + + // Test ProcessWithProgressBar with nil items + callCount = 0 + npm.ProcessWithProgressBar("test", nil, func(_ string, _ *progressbar.ProgressBar) { + callCount++ + }) + if callCount != 0 { + t.Errorf("ProcessWithProgressBar with nil items called func %d times, want 0", callCount) + } +} + +// TestNullOutputInterfaceCompliance verifies NullOutput implements CompleteOutput. +func TestNullOutputInterfaceCompliance(t *testing.T) { + t.Parallel() + + var _ CompleteOutput = (*NullOutput)(nil) + var _ MessageLogger = (*NullOutput)(nil) + var _ ErrorReporter = (*NullOutput)(nil) + var _ ErrorFormatter = (*NullOutput)(nil) + var _ ProgressReporter = (*NullOutput)(nil) + var _ OutputConfig = (*NullOutput)(nil) +} + +// TestNullProgressManagerInterfaceCompliance verifies NullProgressManager implements ProgressManager. +func TestNullProgressManagerInterfaceCompliance(t *testing.T) { + t.Parallel() + + var _ ProgressManager = (*NullProgressManager)(nil) +} diff --git a/internal/validation/strings_test.go b/internal/validation/strings_test.go new file mode 100644 index 0000000..7502f01 --- /dev/null +++ b/internal/validation/strings_test.go @@ -0,0 +1,146 @@ +package validation + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// TestTrimAndNormalize tests the TrimAndNormalize function. +func TestTrimAndNormalize(t *testing.T) { + t.Parallel() + + tests := []testutil.StringTestCase{ + { + Name: "no whitespace", + Input: "test", + Want: "test", + }, + { + Name: "leading and trailing whitespace", + Input: " test ", + Want: "test", + }, + { + Name: "multiple internal spaces", + Input: "hello world", + Want: testutil.HelloWorldStr, + }, + { + Name: "mixed whitespace", + Input: " hello world ", + Want: testutil.HelloWorldStr, + }, + { + Name: "newlines and tabs", + Input: "hello\n\t\tworld", + Want: testutil.HelloWorldStr, + }, + { + Name: "empty string", + Input: "", + Want: "", + }, + { + Name: "whitespace only", + Input: " \n\t ", + Want: "", + }, + { + Name: "multiple lines", + Input: "line one\n line two\n line three", + Want: "line one line two line three", + }, + } + + testutil.RunStringTests(t, tests, TrimAndNormalize) +} + +// TestFormatUsesStatement tests the FormatUsesStatement function. +func TestFormatUsesStatement(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + org string + repo string + version string + want string + }{ + { + name: "full statement with version", + org: "actions", + repo: "checkout", + version: "v3", + want: testutil.TestActionCheckoutV3, + }, + { + name: "without version defaults to v1", + org: "actions", + repo: "setup-node", + version: "", + want: "actions/setup-node@v1", + }, + { + name: "version with @ prefix", + org: "actions", + repo: "cache", + version: "@v2", + want: "actions/cache@v2", + }, + { + name: "version without @ prefix", + org: "actions", + repo: "upload-artifact", + version: "v4", + want: "actions/upload-artifact@v4", + }, + { + name: "empty org returns empty", + org: "", + repo: "checkout", + version: "v3", + want: "", + }, + { + name: "empty repo returns empty", + org: "actions", + repo: "", + version: "v3", + want: "", + }, + { + name: "both org and repo empty", + org: "", + repo: "", + version: "v3", + want: "", + }, + { + name: "sha as version", + org: "actions", + repo: "checkout", + version: "abc123def456", + want: "actions/checkout@abc123def456", + }, + { + name: "main branch as version", + org: "actions", + repo: "checkout", + version: "main", + want: "actions/checkout@main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := FormatUsesStatement(tt.org, tt.repo, tt.version) + if got != tt.want { + t.Errorf("FormatUsesStatement(%q, %q, %q) = %q, want %q", + tt.org, tt.repo, tt.version, got, tt.want) + } + }) + } +} diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index 6650752..d5035f9 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -1,11 +1,9 @@ package validation import ( - "os" "path/filepath" "testing" - "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/testutil" ) @@ -23,7 +21,7 @@ func TestValidateActionYMLPath(t *testing.T) { setupFunc: func(t *testing.T, tmpDir string) string { t.Helper() - return testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + return testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, expectError: false, }, @@ -32,7 +30,7 @@ func TestValidateActionYMLPath(t *testing.T) { setupFunc: func(t *testing.T, tmpDir string) string { t.Helper() - return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", appconstants.TestFixtureMinimalAction) + return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", testutil.TestFixtureMinimalAction) }, expectError: false, }, @@ -48,7 +46,7 @@ func TestValidateActionYMLPath(t *testing.T) { setupFunc: func(t *testing.T, tmpDir string) string { t.Helper() - return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", appconstants.TestFixtureJavaScriptSimple) + return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", testutil.TestFixtureJavaScriptSimple) }, expectError: true, }, @@ -91,7 +89,7 @@ func TestIsCommitSHA(t *testing.T) { }{ { name: "full commit SHA", - version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + version: testutil.TestSHAForTesting, expected: true, }, { @@ -101,16 +99,16 @@ func TestIsCommitSHA(t *testing.T) { }, { name: "semantic version", - version: "v1.2.3", + version: testutil.TestVersionSemantic, expected: false, }, { name: "branch name", - version: "main", + version: testutil.TestBranchMain, expected: false, }, { - name: "empty string", + name: testutil.TestCaseNameEmpty, version: "", expected: false, }, @@ -141,12 +139,12 @@ func TestIsSemanticVersion(t *testing.T) { }{ { name: "semantic version with v prefix", - version: "v1.2.3", + version: testutil.TestVersionSemantic, expected: true, }, { name: "semantic version without v prefix", - version: "1.2.3", + version: testutil.TestVersionPlain, expected: true, }, { @@ -166,16 +164,16 @@ func TestIsSemanticVersion(t *testing.T) { }, { name: "commit SHA", - version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + version: testutil.TestSHAForTesting, expected: false, }, { name: "branch name", - version: "main", + version: testutil.TestBranchMain, expected: false, }, { - name: "empty string", + name: testutil.TestCaseNameEmpty, version: "", expected: false, }, @@ -201,12 +199,12 @@ func TestIsVersionPinned(t *testing.T) { }{ { name: "full semantic version", - version: "v1.2.3", + version: testutil.TestVersionSemantic, expected: true, }, { name: "full commit SHA", - version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + version: testutil.TestSHAForTesting, expected: true, }, { @@ -221,7 +219,7 @@ func TestIsVersionPinned(t *testing.T) { }, { name: "branch name", - version: "main", + version: testutil.TestBranchMain, expected: false, }, { @@ -230,7 +228,7 @@ func TestIsVersionPinned(t *testing.T) { expected: false, }, { - name: "empty string", + name: testutil.TestCaseNameEmpty, version: "", expected: false, }, @@ -258,28 +256,27 @@ func TestValidateGitBranch(t *testing.T) { name: "valid git repository with main branch", setupFunc: func(_ *testing.T, tmpDir string) (string, string) { // Create a simple git repository - gitDir := filepath.Join(tmpDir, ".git") - _ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions + gitDir := testutil.SetupGitDirectory(t, tmpDir) // Create a basic git config configContent := `[core] repositoryformatversion = 0 filemode = true bare = false -[branch "main"] +[branch testutil.TestBranchMain] remote = origin merge = refs/heads/main ` testutil.WriteTestFile(t, filepath.Join(gitDir, "config"), configContent) - return tmpDir, "main" + return tmpDir, testutil.TestBranchMain }, expected: true, // This may vary based on actual git repo state }, { name: "non-git directory", setupFunc: func(_ *testing.T, tmpDir string) (string, string) { - return tmpDir, "main" + return tmpDir, testutil.TestBranchMain }, expected: false, }, @@ -320,8 +317,7 @@ func TestIsGitRepository(t *testing.T) { { name: "directory with .git folder", setupFunc: func(_ *testing.T, tmpDir string) string { - gitDir := filepath.Join(tmpDir, ".git") - _ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions + _ = testutil.SetupGitDirectory(t, tmpDir) return tmpDir }, @@ -378,28 +374,28 @@ func TestCleanVersionString(t *testing.T) { }{ { name: "version with v prefix", - input: "v1.2.3", - expected: "1.2.3", + input: testutil.TestVersionSemantic, + expected: testutil.TestVersionPlain, }, { name: "version without v prefix", - input: "1.2.3", - expected: "1.2.3", + input: testutil.TestVersionPlain, + expected: testutil.TestVersionPlain, }, { name: "version with leading/trailing spaces", input: " v1.2.3 ", - expected: "1.2.3", + expected: testutil.TestVersionPlain, }, { - name: "empty string", + name: testutil.TestCaseNameEmpty, input: "", expected: "", }, { name: "commit SHA", - input: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", - expected: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e", + input: testutil.TestSHAForTesting, + expected: testutil.TestSHAForTesting, }, } @@ -489,7 +485,7 @@ func TestSanitizeActionName(t *testing.T) { expected: "My Action", }, { - name: "empty string", + name: testutil.TestCaseNameEmpty, input: "", expected: "", }, diff --git a/internal/wizard/detector.go b/internal/wizard/detector.go index fd9ef60..44315b4 100644 --- a/internal/wizard/detector.go +++ b/internal/wizard/detector.go @@ -59,7 +59,7 @@ type DetectedSettings struct { func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) { settings := &DetectedSettings{ SuggestedPermissions: make(map[string]string), - SuggestedRunsOn: []string{"ubuntu-latest"}, + SuggestedRunsOn: []string{appconstants.RunnerUbuntuLatest}, } // Detect repository information @@ -223,28 +223,71 @@ func (d *ProjectDetector) findActionFiles(dir string, recursive bool) ([]string, } // findActionFilesRecursive discovers action files recursively using filepath.Walk. +// + func (d *ProjectDetector) findActionFilesRecursive(dir string) ([]string, error) { + // Validate directory path + if err := validateDirectoryPath(dir); err != nil { + return nil, err + } + var actionFiles []string - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { if err != nil { - return filepath.SkipDir // Skip errors by skipping this directory + return filepath.SkipDir } - if info.IsDir() { - return d.handleDirectory(info) - } - - if d.isActionFile(info.Name()) { - actionFiles = append(actionFiles, path) - } - - return nil + return d.processWalkDirEntry(path, entry, &actionFiles) }) return actionFiles, err } +// validateDirectoryPath checks for path traversal attempts. +func validateDirectoryPath(dir string) error { + cleanDir := filepath.Clean(dir) + + // Check for ".." as a path component, not substring + for _, component := range strings.Split(filepath.ToSlash(cleanDir), "/") { + if component == ".." { + return fmt.Errorf("invalid directory path: traversal detected in %q", dir) + } + } + + return nil +} + +// processWalkDirEntry processes a single entry during directory walking. +func (d *ProjectDetector) processWalkDirEntry(path string, entry os.DirEntry, actionFiles *[]string) error { + // Check for symlinks - skip them + if entry.Type()&os.ModeSymlink != 0 { + return nil // Skip all symlinks + } + + // Handle directories + if entry.IsDir() { + return d.handleDirectoryEntry(entry) + } + + // Check if it's an action file + if d.isActionFile(entry.Name()) { + *actionFiles = append(*actionFiles, path) + } + + return nil +} + +// handleDirectoryEntry decides whether to skip a directory during walk. +func (d *ProjectDetector) handleDirectoryEntry(entry os.DirEntry) error { + info, err := entry.Info() + if err != nil { + return filepath.SkipDir + } + + return d.handleDirectory(info) +} + // handleDirectory decides whether to skip a directory during recursive search. func (d *ProjectDetector) handleDirectory(info os.FileInfo) error { name := info.Name() @@ -257,16 +300,7 @@ func (d *ProjectDetector) handleDirectory(info os.FileInfo) error { // findActionFilesInDirectory finds action files only in the specified directory. func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, error) { - var actionFiles []string - - for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} { - actionPath := filepath.Join(dir, filename) - if _, err := os.Stat(actionPath); err == nil { - actionFiles = append(actionFiles, actionPath) - } - } - - return actionFiles, nil + return internal.DiscoverActionFilesNonRecursive(dir), nil } // isActionFile checks if a filename is an action file. @@ -454,15 +488,19 @@ func (d *ProjectDetector) suggestTheme(settings *DetectedSettings) { // suggestRunsOn suggests appropriate runners based on language/framework. func (d *ProjectDetector) suggestRunsOn(settings *DetectedSettings) { - if len(settings.SuggestedRunsOn) != 1 || settings.SuggestedRunsOn[0] != "ubuntu-latest" { + if len(settings.SuggestedRunsOn) != 1 || settings.SuggestedRunsOn[0] != appconstants.RunnerUbuntuLatest { return } switch settings.Language { case appconstants.LangJavaScriptTypeScript: - settings.SuggestedRunsOn = []string{"ubuntu-latest", "windows-latest", "macos-latest"} + settings.SuggestedRunsOn = []string{ + appconstants.RunnerUbuntuLatest, + appconstants.RunnerWindowsLatest, + appconstants.RunnerMacosLatest, + } case appconstants.LangGo, appconstants.LangPython: - settings.SuggestedRunsOn = []string{"ubuntu-latest"} + settings.SuggestedRunsOn = []string{appconstants.RunnerUbuntuLatest} } } diff --git a/internal/wizard/detector_test.go b/internal/wizard/detector_test.go index d308e20..cec6c48 100644 --- a/internal/wizard/detector_test.go +++ b/internal/wizard/detector_test.go @@ -5,28 +5,27 @@ import ( "path/filepath" "testing" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/testutil" ) -func TestProjectDetector_analyzeProjectFiles(t *testing.T) { +func TestProjectDetectorAnalyzeProjectFiles(t *testing.T) { t.Parallel() // Create temporary directory for testing tempDir := t.TempDir() // Create test files (go.mod should be processed last to be the final language) testFiles := map[string]string{ - "Dockerfile": "FROM alpine", - "action.yml": "name: Test Action", - "next.config.js": "module.exports = {}", - "package.json": `{"name": "test", "version": "1.0.0"}`, - "go.mod": "module test", // This should be detected last + "Dockerfile": "FROM alpine", + appconstants.ActionFileNameYML: "name: Test Action", + "next.config.js": "module.exports = {}", + appconstants.PackageJSON: `{"name": "test", "version": "1.0.0"}`, + "go.mod": "module test", // This should be detected last } for filename, content := range testFiles { - filePath := filepath.Join(tempDir, filename) - if err := os.WriteFile(filePath, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions - t.Fatalf("Failed to create test file %s: %v", filename, err) - } + testutil.WriteFileInDir(t, tempDir, filename, content) } // Create detector with temp directory @@ -38,10 +37,10 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) { characteristics := detector.analyzeProjectFiles() - // Test that a language is detected (either Go or JavaScript/TypeScript is valid) + // Test that a language is detected (either Go or testutil.TestLangJavaScriptTypeScript is valid) language := characteristics["language"] - if language != "Go" && language != "JavaScript/TypeScript" { - t.Errorf("Expected language 'Go' or 'JavaScript/TypeScript', got '%s'", language) + if language != "Go" && language != testutil.TestLangJavaScriptTypeScript { + t.Errorf("Expected language 'Go' or '%s', got '%s'", testutil.TestLangJavaScriptTypeScript, language) } // Test that appropriate type is detected @@ -64,7 +63,7 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) { } } -func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) { +func TestProjectDetectorDetectVersionFromPackageJSON(t *testing.T) { t.Parallel() tempDir := t.TempDir() @@ -75,10 +74,7 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) { "description": "Test package" }` - packagePath := filepath.Join(tempDir, "package.json") - if err := os.WriteFile(packagePath, []byte(packageJSON), 0600); err != nil { // #nosec G306 -- test file permissions - t.Fatalf("Failed to create package.json: %v", err) - } + testutil.WriteFileInDir(t, tempDir, appconstants.PackageJSON, packageJSON) output := internal.NewColoredOutput(true) detector := &ProjectDetector{ @@ -92,16 +88,13 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) { } } -func TestProjectDetector_detectVersionFromFiles(t *testing.T) { +func TestProjectDetectorDetectVersionFromFiles(t *testing.T) { t.Parallel() tempDir := t.TempDir() // Create VERSION file versionContent := "3.2.1\n" - versionPath := filepath.Join(tempDir, "VERSION") - if err := os.WriteFile(versionPath, []byte(versionContent), 0600); err != nil { // #nosec G306 -- test file permissions - t.Fatalf("Failed to create VERSION file: %v", err) - } + testutil.WriteFileInDir(t, tempDir, "VERSION", versionContent) output := internal.NewColoredOutput(true) detector := &ProjectDetector{ @@ -115,34 +108,20 @@ func TestProjectDetector_detectVersionFromFiles(t *testing.T) { } } -func TestProjectDetector_findActionFiles(t *testing.T) { +func TestProjectDetectorFindActionFiles(t *testing.T) { t.Parallel() tempDir := t.TempDir() // Create action files - actionYML := filepath.Join(tempDir, "action.yml") - if err := os.WriteFile( - actionYML, - []byte("name: Test Action"), - 0600, // #nosec G306 -- test file permissions - ); err != nil { - t.Fatalf("Failed to create action.yml: %v", err) - } + actionYML := filepath.Join(tempDir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, actionYML, "name: Test Action") // Create subdirectory with another action file subDir := filepath.Join(tempDir, "subaction") - if err := os.MkdirAll(subDir, 0750); err != nil { // #nosec G301 -- test directory permissions - t.Fatalf("Failed to create subdirectory: %v", err) - } + testutil.CreateTestDir(t, subDir) subActionYAML := filepath.Join(subDir, "action.yaml") - if err := os.WriteFile( - subActionYAML, - []byte("name: Sub Action"), - 0600, // #nosec G306 -- test file permissions - ); err != nil { - t.Fatalf("Failed to create sub action.yaml: %v", err) - } + testutil.WriteTestFile(t, subActionYAML, "name: Sub Action") output := internal.NewColoredOutput(true) detector := &ProjectDetector{ @@ -171,7 +150,7 @@ func TestProjectDetector_findActionFiles(t *testing.T) { } } -func TestProjectDetector_isActionFile(t *testing.T) { +func TestProjectDetectorIsActionFile(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) detector := &ProjectDetector{ @@ -182,7 +161,7 @@ func TestProjectDetector_isActionFile(t *testing.T) { filename string expected bool }{ - {"action.yml", true}, + {appconstants.ActionFileNameYML, true}, {"action.yaml", true}, {"Action.yml", false}, {"action.yml.bak", false}, @@ -201,7 +180,7 @@ func TestProjectDetector_isActionFile(t *testing.T) { } } -func TestProjectDetector_suggestConfiguration(t *testing.T) { +func TestProjectDetectorSuggestConfiguration(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) detector := &ProjectDetector{ @@ -258,3 +237,610 @@ func TestProjectDetector_suggestConfiguration(t *testing.T) { }) } } + +// TestProjectDetectorSuggestRunsOn tests the runner suggestion logic. +func TestProjectDetectorSuggestRunsOn(t *testing.T) { + t.Parallel() + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + } + + tests := []struct { + name string + settings *DetectedSettings + expected []string + }{ + { + name: "javascript/typescript project", + settings: &DetectedSettings{ + Language: testutil.TestLangJavaScriptTypeScript, + SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest}, + }, + expected: []string{ + testutil.RunnerUbuntuLatest, + testutil.RunnerWindowsLatest, + testutil.RunnerMacosLatest, + }, + }, + { + name: "go project", + settings: &DetectedSettings{ + Language: "Go", + SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest}, + }, + expected: []string{testutil.RunnerUbuntuLatest}, + }, + { + name: "python project", + settings: &DetectedSettings{ + Language: "Python", + SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest}, + }, + expected: []string{testutil.RunnerUbuntuLatest}, + }, + { + name: "already has multiple runners", + settings: &DetectedSettings{ + Language: testutil.TestLangJavaScriptTypeScript, + SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest, "custom-runner"}, + }, + expected: []string{testutil.RunnerUbuntuLatest, "custom-runner"}, + }, + { + name: "unknown language", + settings: &DetectedSettings{ + Language: "Rust", + SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest}, + }, + expected: []string{testutil.RunnerUbuntuLatest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + detector.suggestRunsOn(tt.settings) + + if len(tt.settings.SuggestedRunsOn) != len(tt.expected) { + t.Errorf("Expected %d runners, got %d", len(tt.expected), len(tt.settings.SuggestedRunsOn)) + + return + } + + for i, expectedRunner := range tt.expected { + if tt.settings.SuggestedRunsOn[i] != expectedRunner { + t.Errorf("Expected runner at index %d to be %s, got %s", + i, expectedRunner, tt.settings.SuggestedRunsOn[i]) + } + } + }) + } +} + +// assertPermissionsMatch is a helper to validate permissions in tests. +func assertPermissionsMatch(t *testing.T, expected, actual map[string]string) { + t.Helper() + + if expected == nil && actual != nil { + t.Errorf("Expected nil permissions, got %v", actual) + + return + } + + if expected != nil && actual == nil { + t.Errorf("Expected permissions %v, got nil", expected) + + return + } + + if expected == nil { + return + } + + if len(actual) != len(expected) { + t.Errorf("Expected %d permissions, got %d", len(expected), len(actual)) + + return + } + + for key, expectedValue := range expected { + if actualValue, ok := actual[key]; !ok { + t.Errorf("Expected permission %s not found", key) + } else if actualValue != expectedValue { + t.Errorf("Expected permission %s=%s, got %s=%s", + key, expectedValue, key, actualValue) + } + } +} + +// TestProjectDetectorSuggestPermissions tests the permissions suggestion logic. +func TestProjectDetectorSuggestPermissions(t *testing.T) { + t.Parallel() + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + } + + tests := []struct { + name string + settings *DetectedSettings + expected map[string]string + }{ + { + name: "github action without permissions", + settings: &DetectedSettings{ + IsGitHubAction: true, + SuggestedPermissions: nil, + }, + expected: map[string]string{ + "contents": "read", + }, + }, + { + name: "github action with existing permissions", + settings: &DetectedSettings{ + IsGitHubAction: true, + SuggestedPermissions: map[string]string{ + "contents": "write", + "issues": "read", + }, + }, + expected: map[string]string{ + "contents": "write", + "issues": "read", + }, + }, + { + name: "not a github action", + settings: &DetectedSettings{ + IsGitHubAction: false, + SuggestedPermissions: nil, + }, + expected: nil, + }, + { + name: "github action with empty permissions map", + settings: &DetectedSettings{ + IsGitHubAction: true, + SuggestedPermissions: map[string]string{}, + }, + expected: map[string]string{ + "contents": "read", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + detector.suggestPermissions(tt.settings) + assertPermissionsMatch(t, tt.expected, tt.settings.SuggestedPermissions) + }) + } +} + +// TestNewProjectDetector tests creating a new project detector. +func TestNewProjectDetector(t *testing.T) { + t.Parallel() + + output := internal.NewColoredOutput(true) + detector, err := NewProjectDetector(output) + if err != nil { + t.Fatalf("NewProjectDetector() error = %v", err) + } + + if detector == nil { + t.Fatal("NewProjectDetector() returned nil") + } + + if detector.output == nil { + t.Error("detector.output is nil") + } + + if detector.currentDir == "" { + t.Error("detector.currentDir is empty") + } +} + +// TestDetectProjectSettingsIntegration tests the main detection logic. +func TestDetectProjectSettingsIntegration(t *testing.T) { + // Cannot use t.Parallel() because this test uses t.Chdir() + + // Create a temporary directory with test files + tempDir := t.TempDir() + + // Create action.yml + testutil.WriteActionFixture(t, tempDir, testutil.TestFixtureCompositeWithShellStep) + + // Change to temp directory (cleanup automatic via t.Chdir) + t.Chdir(tempDir) + + output := internal.NewColoredOutput(true) + detector, err := NewProjectDetector(output) + if err != nil { + t.Fatalf("NewProjectDetector() error = %v", err) + } + + settings, err := detector.DetectProjectSettings() + if err != nil { + t.Fatalf("DetectProjectSettings() error = %v", err) + } + + if settings == nil { + t.Fatal("DetectProjectSettings() returned nil") + } + + // Verify action file was detected + if !settings.IsGitHubAction { + t.Error("Expected IsGitHubAction to be true") + } + + if len(settings.ActionFiles) == 0 { + t.Error("Expected at least one action file to be detected") + } + + // Verify default values are set + if len(settings.SuggestedRunsOn) == 0 { + t.Error("Expected SuggestedRunsOn to have default values") + } + + if settings.SuggestedPermissions == nil { + t.Error("Expected SuggestedPermissions to be initialized") + } +} + +// TestDetectRepositoryInfo tests repository info detection. +func TestDetectRepositoryInfo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repoRoot string + wantErr bool + }{ + { + name: "no git repository", + repoRoot: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + repoRoot: tt.repoRoot, + } + + settings := &DetectedSettings{ + SuggestedPermissions: make(map[string]string), + } + + err := detector.detectRepositoryInfo(settings) + if (err != nil) != tt.wantErr { + t.Errorf("detectRepositoryInfo() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestDetectActionFiles tests action file detection. +// +// validateDetectActionFilesResult validates the results of detectActionFiles call. +func validateDetectActionFilesResult( + t *testing.T, + settings *DetectedSettings, + err error, + wantActionCount int, + wantErr bool, +) { + t.Helper() + + if (err != nil) != wantErr { + t.Errorf("detectActionFiles() error = %v, wantErr %v", err, wantErr) + } + + if len(settings.ActionFiles) != wantActionCount { + t.Errorf("Expected %d action files, got %d", wantActionCount, len(settings.ActionFiles)) + } + + if wantActionCount > 0 && !settings.IsGitHubAction { + t.Error("Expected IsGitHubAction to be true") + } +} + +func TestDetectActionFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, dir string) + wantActionCount int + wantErr bool + }{ + { + name: "detects action file", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + content := "name: Test Action\ndescription: Test" + testutil.WriteFileInDir(t, dir, appconstants.ActionFileNameYML, content) + }, + wantActionCount: 1, + wantErr: false, + }, + { + name: "no action files", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // Don't create any files + }, + wantActionCount: 0, + wantErr: false, + }, + { + name: "skips symlink to sensitive file", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + // Create symlink: action.yml -> /etc/passwd + symlinkPath := filepath.Join(dir, appconstants.ActionFileNameYML) + err := os.Symlink("/etc/passwd", symlinkPath) + if err != nil { + t.Skip("symlink creation not supported on this platform") + } + }, + wantActionCount: 0, // Should skip symlinks for security + wantErr: false, + }, + { + name: "handles directory with .. components safely", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + // Create subdirectory with action.yml + content := "name: Test\ndescription: Test" + testutil.CreateNestedAction(t, dir, "subdir", content) + }, + wantActionCount: 1, // Should find the file safely + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + if tt.setupFunc != nil { + tt.setupFunc(t, tempDir) + } + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + currentDir: tempDir, + } + + settings := &DetectedSettings{ + SuggestedPermissions: make(map[string]string), + } + + err := detector.detectActionFiles(settings) + + validateDetectActionFilesResult(t, settings, err, tt.wantActionCount, tt.wantErr) + }) + } +} + +// TestDetectProjectCharacteristics tests project characteristics detection. +func TestDetectProjectCharacteristics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, dir string) + wantDockerfile bool + }{ + { + name: "detects Dockerfile", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + content := "FROM alpine:latest" + testutil.WriteFileInDir(t, dir, "Dockerfile", content) + }, + wantDockerfile: true, + }, + { + name: "no Dockerfile", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // Don't create Dockerfile + }, + wantDockerfile: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + if tt.setupFunc != nil { + tt.setupFunc(t, tempDir) + } + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + currentDir: tempDir, + } + + settings := &DetectedSettings{ + SuggestedPermissions: make(map[string]string), + } + + detector.detectProjectCharacteristics(settings) + + if settings.HasDockerfile != tt.wantDockerfile { + t.Errorf("HasDockerfile = %v, want %v", settings.HasDockerfile, tt.wantDockerfile) + } + }) + } +} + +// TestDetectVersion tests version detection from various sources. +func TestDetectVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, dir string) + want string + }{ + { + name: "detects version from package.json", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + content := `{"version": "1.2.3"}` + testutil.WriteFileInDir(t, dir, appconstants.PackageJSON, content) + }, + want: "1.2.3", + }, + { + name: "detects version from VERSION file", + setupFunc: func(t *testing.T, dir string) { + t.Helper() + content := "2.0.0\n" + testutil.WriteFileInDir(t, dir, "VERSION", content) + }, + want: "2.0.0", + }, + { + name: "no version found", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // Don't create version files + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + if tt.setupFunc != nil { + tt.setupFunc(t, tempDir) + } + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + currentDir: tempDir, + } + + version := detector.detectVersion() + if version != tt.want { + t.Errorf("detectVersion() = %q, want %q", version, tt.want) + } + }) + } +} + +// TestDetectVersionFromGitTags tests git tag version detection. +func TestDetectVersionFromGitTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repoRoot string + want string + }{ + { + name: "no git repository", + repoRoot: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + repoRoot: tt.repoRoot, + } + + version := detector.detectVersionFromGitTags() + if version != tt.want { + t.Errorf("detectVersionFromGitTags() = %q, want %q", version, tt.want) + } + }) + } +} + +// TestAnalyzeActionFile tests action file analysis. +func TestAnalyzeActionFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr bool + checkFunc func(t *testing.T, settings *DetectedSettings) + }{ + { + name: "analyzes composite action", + content: testutil.MustReadFixture(testutil.TestFixtureCompositeWithShellStep), + wantErr: false, + checkFunc: func(t *testing.T, settings *DetectedSettings) { + t.Helper() + if !settings.HasCompositeAction { + t.Error("Expected HasCompositeAction to be true") + } + }, + }, + { + name: "handles invalid YAML", + content: "invalid: yaml: content:", + wantErr: true, + checkFunc: func(t *testing.T, _ *DetectedSettings) { + t.Helper() + // No specific checks for error case + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + actionPath := testutil.WriteFileInDir(t, tempDir, appconstants.ActionFileNameYML, tt.content) + + output := internal.NewColoredOutput(true) + detector := &ProjectDetector{ + output: output, + currentDir: tempDir, + } + + settings := &DetectedSettings{ + SuggestedPermissions: make(map[string]string), + } + + err := detector.analyzeActionFile(actionPath, settings) + if (err != nil) != tt.wantErr { + t.Errorf("analyzeActionFile() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.checkFunc != nil { + tt.checkFunc(t, settings) + } + }) + } +} diff --git a/internal/wizard/exporter.go b/internal/wizard/exporter.go index f1bb996..640bc13 100644 --- a/internal/wizard/exporter.go +++ b/internal/wizard/exporter.go @@ -267,24 +267,22 @@ func (e *ConfigExporter) writeWorkflowSection(file *os.File, config *internal.Ap // writePermissionsSection writes the permissions section. func (e *ConfigExporter) writePermissionsSection(file *os.File, config *internal.AppConfig) { - if len(config.Permissions) == 0 { - return - } - - _, _ = fmt.Fprintf(file, "\n[permissions]\n") - for key, value := range config.Permissions { - _, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value) - } + e.writeMapSection(file, "[permissions]", config.Permissions) } // writeVariablesSection writes the variables section. func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.AppConfig) { - if len(config.Variables) == 0 { + e.writeMapSection(file, "[variables]", config.Variables) +} + +// writeMapSection writes a TOML section with key-value pairs from a map. +func (e *ConfigExporter) writeMapSection(file *os.File, sectionName string, data map[string]string) { + if len(data) == 0 { return } - _, _ = fmt.Fprintf(file, "\n[variables]\n") - for key, value := range config.Variables { + _, _ = fmt.Fprintf(file, "\n%s\n", sectionName) + for key, value := range data { _, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value) } } diff --git a/internal/wizard/exporter_test.go b/internal/wizard/exporter_test.go index 1e0f772..ddb865b 100644 --- a/internal/wizard/exporter_test.go +++ b/internal/wizard/exporter_test.go @@ -13,7 +13,7 @@ import ( "github.com/ivuorinen/gh-action-readme/testutil" ) -func TestConfigExporter_ExportConfig(t *testing.T) { +func TestConfigExporterExportConfig(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) // quiet mode for testing exporter := NewConfigExporter(output) @@ -62,11 +62,11 @@ func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(* return func(t *testing.T) { t.Helper() tempDir := t.TempDir() - outputPath := filepath.Join(tempDir, "config.yaml") + outputPath := filepath.Join(tempDir, testutil.TestFileConfigYAML) err := exporter.ExportConfig(config, FormatYAML, outputPath) if err != nil { - t.Fatalf("ExportConfig() error = %v", err) + t.Fatalf(testutil.TestMsgExportConfigError, err) } testutil.AssertFileExists(t, outputPath) @@ -83,7 +83,7 @@ func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(* err := exporter.ExportConfig(config, FormatJSON, outputPath) if err != nil { - t.Fatalf("ExportConfig() error = %v", err) + t.Fatalf(testutil.TestMsgExportConfigError, err) } testutil.AssertFileExists(t, outputPath) @@ -100,7 +100,7 @@ func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(* err := exporter.ExportConfig(config, FormatTOML, outputPath) if err != nil { - t.Fatalf("ExportConfig() error = %v", err) + t.Fatalf(testutil.TestMsgExportConfigError, err) } testutil.AssertFileExists(t, outputPath) @@ -113,7 +113,7 @@ func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppCo t.Helper() data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path if err != nil { - t.Fatalf("Failed to read output file: %v", err) + t.Fatalf(testutil.TestMsgFailedReadOutput, err) } var yamlConfig internal.AppConfig @@ -134,7 +134,7 @@ func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppCo t.Helper() data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path if err != nil { - t.Fatalf("Failed to read output file: %v", err) + t.Fatalf(testutil.TestMsgFailedReadOutput, err) } var jsonConfig internal.AppConfig @@ -155,7 +155,7 @@ 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) + t.Fatalf(testutil.TestMsgFailedReadOutput, err) } content := string(data) @@ -167,7 +167,7 @@ func verifyTOMLContent(t *testing.T, outputPath string) { } } -func TestConfigExporter_sanitizeConfig(t *testing.T) { +func TestConfigExporterSanitizeConfig(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -201,7 +201,7 @@ func TestConfigExporter_sanitizeConfig(t *testing.T) { } } -func TestConfigExporter_GetSupportedFormats(t *testing.T) { +func TestConfigExporterGetSupportedFormats(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -226,7 +226,7 @@ func TestConfigExporter_GetSupportedFormats(t *testing.T) { } } -func TestConfigExporter_GetDefaultOutputPath(t *testing.T) { +func TestConfigExporterGetDefaultOutputPath(t *testing.T) { t.Parallel() output := internal.NewColoredOutput(true) exporter := NewConfigExporter(output) @@ -235,7 +235,7 @@ func TestConfigExporter_GetDefaultOutputPath(t *testing.T) { format ExportFormat expected string }{ - {FormatYAML, "config.yaml"}, + {FormatYAML, testutil.TestFileConfigYAML}, {FormatJSON, "config.json"}, {FormatTOML, "config.toml"}, } diff --git a/internal/wizard/validator.go b/internal/wizard/validator.go index 54dcc43..d334b4f 100644 --- a/internal/wizard/validator.go +++ b/internal/wizard/validator.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "github.com/ivuorinen/gh-action-readme/appconstants" @@ -34,6 +35,22 @@ type ValidationWarning struct { Value string } +// validPermissionsMap defines valid GitHub Actions permissions and their allowed values. +var validPermissionsMap = map[string][]string{ + "actions": {"read", "write"}, + "checks": {"read", "write"}, + "contents": {"read", "write"}, + "deployments": {"read", "write"}, + "id-token": {"write"}, + "issues": {"read", "write"}, + "discussions": {"read", "write"}, + "packages": {"read", "write"}, + "pull-requests": {"read", "write"}, + "repository-projects": {"read", "write"}, + "security-events": {"read", "write"}, + "statuses": {"read", "write"}, +} + // ConfigValidator handles configuration validation with immediate feedback. type ConfigValidator struct { output *internal.ColoredOutput @@ -139,50 +156,38 @@ func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) { // validateOrganization validates the organization field. func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) { - if org == "" { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "organization", - Message: "Organization is empty - will use auto-detected value", - Value: org, - }) - - return - } - - // GitHub username/organization rules - if !v.isValidGitHubName(org) { - result.Errors = append(result.Errors, ValidationError{ - Field: "organization", - Message: "Invalid organization name format", - Value: org, - }) - result.Suggestions = append(result.Suggestions, - "Organization names can only contain alphanumeric characters and hyphens") - } + v.validateFieldWithEmptyCheck( + "organization", + org, + v.isValidGitHubName, + "Organization is empty - will use auto-detected value", + "Invalid organization name format", + "Organization names can only contain alphanumeric characters and hyphens", + result, + ) } // validateRepository validates the repository field. func (v *ConfigValidator) validateRepository(repo string, result *ValidationResult) { - if repo == "" { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "repository", - Message: "Repository is empty - will use auto-detected value", - Value: repo, - }) + v.validateFieldWithEmptyCheck( + "repository", + repo, + v.isValidGitHubName, + "Repository is empty - will use auto-detected value", + "Invalid repository name format", + "Repository names can only contain alphanumeric characters, hyphens, and underscores", + result, + ) +} - return - } - - // GitHub repository name rules - if !v.isValidGitHubName(repo) { - result.Errors = append(result.Errors, ValidationError{ - Field: "repository", - Message: "Invalid repository name format", - Value: repo, - }) - result.Suggestions = append(result.Suggestions, - "Repository names can only contain alphanumeric characters, hyphens, and underscores") - } +// addWarningWithSuggestion is a helper to add a warning and suggestion together. +func addWarningWithSuggestion(result *ValidationResult, field, message, value, suggestion string) { + result.Warnings = append(result.Warnings, ValidationWarning{ + Field: field, + Message: message, + Value: value, + }) + result.Suggestions = append(result.Suggestions, suggestion) } // validateVersion validates the version field. @@ -194,62 +199,32 @@ func (v *ConfigValidator) validateVersion(version string, result *ValidationResu // Check if it follows semantic versioning if !v.isValidSemanticVersion(version) { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "version", - Message: "Version does not follow semantic versioning (x.y.z)", - Value: version, - }) - result.Suggestions = append(result.Suggestions, + addWarningWithSuggestion(result, + "version", + "Version does not follow semantic versioning (x.y.z)", + version, "Consider using semantic versioning format (e.g., 1.0.0)") } } // validateTheme validates the theme field. func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) { - validThemes := []string{"default", "github", "gitlab", "minimal", "professional"} - - found := false - for _, validTheme := range validThemes { - if theme == validTheme { - found = true - - break - } + validThemes := []string{ + appconstants.ThemeDefault, + appconstants.ThemeGitHub, + appconstants.ThemeGitLab, + appconstants.ThemeMinimal, + appconstants.ThemeProfessional, } - if !found { - result.Errors = append(result.Errors, ValidationError{ - Field: "theme", - Message: "Invalid theme", - Value: theme, - }) - result.Suggestions = append(result.Suggestions, - "Valid themes: "+strings.Join(validThemes, ", ")) - } + v.validateFieldInList("theme", theme, validThemes, "Invalid theme", result) } // validateOutputFormat validates the output format field. func (v *ConfigValidator) validateOutputFormat(format string, result *ValidationResult) { - validFormats := []string{"md", "html", "json", "asciidoc"} + validFormats := appconstants.GetSupportedOutputFormats() - found := false - for _, validFormat := range validFormats { - if format == validFormat { - found = true - - break - } - } - - if !found { - result.Errors = append(result.Errors, ValidationError{ - Field: "output_format", - Message: "Invalid output format", - Value: format, - }) - result.Suggestions = append(result.Suggestions, - "Valid formats: "+strings.Join(validFormats, ", ")) - } + v.validateFieldInList("output_format", format, validFormats, "Invalid output format", result) } // validateOutputDir validates the output directory field. @@ -270,24 +245,20 @@ func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult parent := filepath.Dir(dir) if parent != "." { if _, err := os.Stat(parent); os.IsNotExist(err) { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "output_dir", - Message: "Parent directory does not exist", - Value: dir, - }) - result.Suggestions = append(result.Suggestions, + addWarningWithSuggestion(result, + "output_dir", + "Parent directory does not exist", + dir, "Ensure the parent directory exists or will be created") } } } else { // Absolute path - check if it exists if _, err := os.Stat(dir); os.IsNotExist(err) { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "output_dir", - Message: "Directory does not exist", - Value: dir, - }) - result.Suggestions = append(result.Suggestions, + addWarningWithSuggestion(result, + "output_dir", + "Directory does not exist", + dir, "Directory will be created if it doesn't exist") } } @@ -321,30 +292,32 @@ func (v *ConfigValidator) validateGitHubToken(token string, result *ValidationRe "Consider using GITHUB_TOKEN environment variable instead") } +// validatePermissionValue validates a single permission value and updates the result. +func (v *ConfigValidator) validatePermissionValue( + permission, value string, + validValues []string, + result *ValidationResult, +) { + if !v.isValueInList(value, validValues) { + result.Errors = append(result.Errors, ValidationError{ + Field: "permissions." + permission, + Message: "Invalid permission value", + Value: value, + }) + result.Suggestions = append(result.Suggestions, + fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", "))) + } +} + // validatePermissions validates the permissions field. func (v *ConfigValidator) validatePermissions(permissions map[string]string, result *ValidationResult) { if len(permissions) == 0 { return } - validPermissions := map[string][]string{ - "actions": {"read", "write"}, - "checks": {"read", "write"}, - "contents": {"read", "write"}, - "deployments": {"read", "write"}, - "id-token": {"write"}, - "issues": {"read", "write"}, - "discussions": {"read", "write"}, - "packages": {"read", "write"}, - "pull-requests": {"read", "write"}, - "repository-projects": {"read", "write"}, - "security-events": {"read", "write"}, - "statuses": {"read", "write"}, - } - for permission, value := range permissions { // Check if permission is valid - validValues, permissionExists := validPermissions[permission] + validValues, permissionExists := validPermissionsMap[permission] if !permissionExists { result.Warnings = append(result.Warnings, ValidationWarning{ Field: "permissions", @@ -356,24 +329,7 @@ func (v *ConfigValidator) validatePermissions(permissions map[string]string, res } // Check if value is valid - validValue := false - for _, validVal := range validValues { - if value == validVal { - validValue = true - - break - } - } - - if !validValue { - result.Errors = append(result.Errors, ValidationError{ - Field: "permissions", - Message: "Invalid value for permission " + permission, - Value: value, - }) - result.Suggestions = append(result.Suggestions, - fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", "))) - } + v.validatePermissionValue(permission, value, validValues, result) } } @@ -392,31 +348,22 @@ func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResu } validRunners := []string{ - "ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04", - "windows-latest", "windows-2022", "windows-2019", - "macos-latest", "macos-13", "macos-12", "macos-11", + appconstants.RunnerUbuntuLatest, "ubuntu-22.04", "ubuntu-20.04", + appconstants.RunnerWindowsLatest, "windows-2022", "windows-2019", + appconstants.RunnerMacosLatest, "macos-13", "macos-12", "macos-11", } for _, runner := range runsOn { // Check if it's a GitHub-hosted runner - isValid := false - for _, validRunner := range validRunners { - if runner == validRunner { - isValid = true - - break - } - } + isValid := v.isValueInList(runner, validRunners) // If not a standard runner, it might be self-hosted if !isValid { if !strings.HasPrefix(runner, "self-hosted") { - result.Warnings = append(result.Warnings, ValidationWarning{ - Field: "runs_on", - Message: "Unknown runner: " + runner, - Value: runner, - }) - result.Suggestions = append(result.Suggestions, + addWarningWithSuggestion(result, + "runs_on", + "Unknown runner: "+runner, + runner, "Ensure the runner is available in your GitHub organization") } } @@ -457,6 +404,11 @@ func (v *ConfigValidator) validateVariables(variables map[string]string, result } } +// isValueInList checks if a value exists in a list of valid options. +func (v *ConfigValidator) isValueInList(value string, validOptions []string) bool { + return slices.Contains(validOptions, value) +} + // isValidGitHubName checks if a name follows GitHub naming rules. func (v *ConfigValidator) isValidGitHubName(name string) bool { if len(name) == 0 || len(name) > 39 { diff --git a/internal/wizard/validator_helper.go b/internal/wizard/validator_helper.go new file mode 100644 index 0000000..69252cb --- /dev/null +++ b/internal/wizard/validator_helper.go @@ -0,0 +1,60 @@ +package wizard + +import ( + "fmt" + "strings" +) + +// validateFieldWithEmptyCheck is a generic helper for fields that: +// - Allow empty values (with optional warning) +// - Validate non-empty values with a custom validator function +// - Add error and optional suggestion if validation fails. +func (v *ConfigValidator) validateFieldWithEmptyCheck( + field, fieldValue string, + isValid func(string) bool, + emptyWarning, errorMsg, suggestion string, + result *ValidationResult, +) { + if fieldValue == "" { + if emptyWarning != "" { + result.Warnings = append(result.Warnings, ValidationWarning{ + Field: field, + Message: emptyWarning, + Value: fieldValue, + }) + } + + return + } + + if !isValid(fieldValue) { + result.Errors = append(result.Errors, ValidationError{ + Field: field, + Message: errorMsg, + Value: fieldValue, + }) + + if suggestion != "" { + result.Suggestions = append(result.Suggestions, suggestion) + } + } +} + +// validateFieldInList is a generic helper for fields that must be +// one of a predefined list of valid values. +func (v *ConfigValidator) validateFieldInList( + field, fieldValue string, + validValues []string, + errorMsg string, + result *ValidationResult, +) { + if !v.isValueInList(fieldValue, validValues) { + result.Errors = append(result.Errors, ValidationError{ + Field: field, + Message: errorMsg, + Value: fieldValue, + }) + result.Suggestions = append(result.Suggestions, + fmt.Sprintf("Valid %ss: %s", field, strings.Join(validValues, ", "))) + } +} diff --git a/internal/wizard/validator_test.go b/internal/wizard/validator_test.go index d4461ef..6148b0b 100644 --- a/internal/wizard/validator_test.go +++ b/internal/wizard/validator_test.go @@ -6,10 +6,46 @@ import ( "github.com/ivuorinen/gh-action-readme/internal" ) -func TestConfigValidator_ValidateConfig(t *testing.T) { +// newTestValidator creates a ConfigValidator for testing with quiet output. +// Reduces duplication across validator tests. +func newTestValidator() *ConfigValidator { + output := internal.NewColoredOutput(true) + + return NewConfigValidator(output) +} + +// validationTestCase defines a test case for string validation methods. +type validationTestCase struct { + name string + input string + want bool +} + +// runValidationTests is a generic helper for testing validator methods that take a string and return bool. +// This eliminates duplication across isValidGitHubName, isValidSemanticVersion, isValidGitHubToken, etc. +func runValidationTests( + t *testing.T, + tests []validationTestCase, + validatorFunc func(string) bool, + funcName string, +) { + t.Helper() t.Parallel() - output := internal.NewColoredOutput(true) // quiet mode for testing - validator := NewConfigValidator(output) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := validatorFunc(tt.input) + if got != tt.want { + t.Errorf("%s(%q) = %v, want %v", funcName, tt.input, got, tt.want) + } + }) + } +} + +func TestConfigValidatorValidateConfig(t *testing.T) { + t.Parallel() + validator := newTestValidator() tests := []struct { name string @@ -93,10 +129,9 @@ func TestConfigValidator_ValidateConfig(t *testing.T) { } } -func TestConfigValidator_ValidateField(t *testing.T) { +func TestConfigValidatorValidateField(t *testing.T) { t.Parallel() - output := internal.NewColoredOutput(true) - validator := NewConfigValidator(output) + validator := newTestValidator() tests := []struct { name string @@ -128,16 +163,10 @@ func TestConfigValidator_ValidateField(t *testing.T) { } } -func TestConfigValidator_isValidGitHubName(t *testing.T) { - t.Parallel() - output := internal.NewColoredOutput(true) - validator := NewConfigValidator(output) +func TestConfigValidatorIsValidGitHubName(t *testing.T) { + validator := newTestValidator() - tests := []struct { - name string - input string - want bool - }{ + tests := []validationTestCase{ {"valid name", "test-org", true}, {"valid name with numbers", "test123", true}, {"valid name with underscore", "test_org", true}, @@ -149,27 +178,13 @@ func TestConfigValidator_isValidGitHubName(t *testing.T) { {"very long name", "this-is-a-very-long-organization-name-that-exceeds-the-limit", false}, } - 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) - } - }) - } + runValidationTests(t, tests, validator.isValidGitHubName, "isValidGitHubName") } -func TestConfigValidator_isValidSemanticVersion(t *testing.T) { - t.Parallel() - output := internal.NewColoredOutput(true) - validator := NewConfigValidator(output) +func TestConfigValidatorIsValidSemanticVersion(t *testing.T) { + validator := newTestValidator() - tests := []struct { - name string - input string - want bool - }{ + tests := []validationTestCase{ {"valid version", "1.0.0", true}, {"valid version with pre-release", "1.0.0-alpha", true}, {"valid version with build", "1.0.0+build.1", true}, @@ -180,27 +195,13 @@ func TestConfigValidator_isValidSemanticVersion(t *testing.T) { {"empty version", "", false}, } - 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) - } - }) - } + runValidationTests(t, tests, validator.isValidSemanticVersion, "isValidSemanticVersion") } -func TestConfigValidator_isValidGitHubToken(t *testing.T) { - t.Parallel() - output := internal.NewColoredOutput(true) - validator := NewConfigValidator(output) +func TestConfigValidatorIsValidGitHubToken(t *testing.T) { + validator := newTestValidator() - tests := []struct { - name string - input string - want bool - }{ + tests := []validationTestCase{ {"classic token", "ghp_1234567890abcdef1234567890abcdef12345678", true}, {"fine-grained token", "github_pat_1234567890abcdef", true}, {"app token", "ghs_1234567890abcdef", true}, @@ -211,27 +212,13 @@ func TestConfigValidator_isValidGitHubToken(t *testing.T) { {"empty token", "", false}, } - 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) - } - }) - } + runValidationTests(t, tests, validator.isValidGitHubToken, "isValidGitHubToken") } -func TestConfigValidator_isValidVariableName(t *testing.T) { - t.Parallel() - output := internal.NewColoredOutput(true) - validator := NewConfigValidator(output) +func TestConfigValidatorIsValidVariableName(t *testing.T) { + validator := newTestValidator() - tests := []struct { - name string - input string - want bool - }{ + tests := []validationTestCase{ {"valid name", "MY_VAR", true}, {"valid name with underscore", "_MY_VAR", true}, {"valid name lowercase", "my_var", true}, @@ -243,13 +230,5 @@ func TestConfigValidator_isValidVariableName(t *testing.T) { {"empty name", "", false}, } - 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) - } - }) - } + runValidationTests(t, tests, validator.isValidVariableName, "isValidVariableName") } diff --git a/internal/wizard/validator_test_helpers.go b/internal/wizard/validator_test_helpers.go new file mode 100644 index 0000000..8478156 --- /dev/null +++ b/internal/wizard/validator_test_helpers.go @@ -0,0 +1 @@ +package wizard diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index 3673c7f..1203ebc 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -141,7 +141,7 @@ func (w *ConfigWizard) configureThemeSelection() { // configureOutputFormat handles output format selection. func (w *ConfigWizard) configureOutputFormat() { w.output.Info("\nAvailable output formats:") - formats := []string{"md", "html", "json", "asciidoc"} + formats := appconstants.GetSupportedOutputFormats() w.displayFormatOptions(formats) @@ -165,11 +165,11 @@ func (w *ConfigWizard) getAvailableThemes() []struct { name string desc string }{ - {"default", "Original simple template"}, - {"github", "GitHub-style with badges and collapsible sections"}, - {"gitlab", "GitLab-focused with CI/CD examples"}, - {"minimal", "Clean and concise documentation"}, - {"professional", "Comprehensive with troubleshooting and ToC"}, + {appconstants.ThemeDefault, "Original simple template"}, + {appconstants.ThemeGitHub, "GitHub-style with badges and collapsible sections"}, + {appconstants.ThemeGitLab, "GitLab-focused with CI/CD examples"}, + {appconstants.ThemeMinimal, "Clean and concise documentation"}, + {appconstants.ThemeProfessional, "Comprehensive with troubleshooting and ToC"}, } } @@ -357,15 +357,20 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool { // findActionFiles discovers action files in the given directory. func (w *ConfigWizard) findActionFiles(dir string) []string { - var actionFiles []string - - // Check for action.yml and action.yaml - for _, filename := range []string{"action.yml", "action.yaml"} { - actionPath := filepath.Join(dir, filename) - if _, err := os.Stat(actionPath); err == nil { - actionFiles = append(actionFiles, actionPath) + // Check for path traversal attempts in the raw input before cleaning + for _, component := range strings.Split(filepath.ToSlash(dir), "/") { + if component == ".." { + return []string{} // Return empty for invalid paths } } - return actionFiles + // Validate and clean the input path + cleanDir := filepath.Clean(dir) + // Verify Clean didn't change the path (indicates normalization/traversal) + if cleanDir != dir { + return []string{} // Return empty for paths with traversal + } + + // Check for action.yml and action.yaml using validated path + return internal.DiscoverActionFilesNonRecursive(cleanDir) } diff --git a/internal/wizard/wizard_test.go b/internal/wizard/wizard_test.go new file mode 100644 index 0000000..9275e51 --- /dev/null +++ b/internal/wizard/wizard_test.go @@ -0,0 +1,1200 @@ +package wizard + +import ( + "bufio" + "path/filepath" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// testWizard creates a wizard with mocked input for testing. +func testWizard(inputs string) *ConfigWizard { + // Create a scanner from the input string + scanner := bufio.NewScanner(strings.NewReader(inputs)) + + // Create wizard with quiet output to avoid console spam + wizard := &ConfigWizard{ + output: &internal.ColoredOutput{NoColor: true, Quiet: true}, + scanner: scanner, + config: internal.DefaultAppConfig(), + } + + return wizard +} + +// Note: Output verification tests are simplified since ColoredOutput is a concrete type +// Tests focus on logic and state changes rather than output messages + +// TestPromptWithDefault tests the prompt with default value function. +func TestPromptWithDefault(t *testing.T) { + tests := []struct { + name string + input string + prompt string + defaultValue string + want string + }{ + { + name: "user provides value", + input: "custom-value\n", + prompt: testutil.WizardPromptEnter, + defaultValue: appconstants.ThemeDefault, + want: "custom-value", + }, + { + name: "user accepts default (empty input)", + input: "\n", + prompt: testutil.WizardPromptEnter, + defaultValue: appconstants.ThemeDefault, + want: appconstants.ThemeDefault, + }, + { + name: "user provides empty string with no default", + input: "\n", + prompt: testutil.WizardPromptEnter, + defaultValue: "", + want: "", + }, + { + name: "user provides value with whitespace", + input: " value-with-spaces \n", + prompt: testutil.WizardPromptEnter, + defaultValue: appconstants.ThemeDefault, + want: "value-with-spaces", + }, + { + name: "no default provided, user enters value", + input: "myvalue\n", + prompt: testutil.WizardPromptEnter, + defaultValue: "", + want: "myvalue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + got := wizard.promptWithDefault(tt.prompt, tt.defaultValue) + + if got != tt.want { + t.Errorf("promptWithDefault() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestPromptYesNo tests the yes/no prompt function. +func TestPromptYesNo(t *testing.T) { + tests := []struct { + name string + input string + prompt string + defaultValue bool + want bool + }{ + { + name: "user enters yes", + input: "yes\n", + prompt: testutil.WizardPromptContinue, + defaultValue: false, + want: true, + }, + { + name: "user enters y", + input: testutil.WizardInputYes, + prompt: testutil.WizardPromptContinue, + defaultValue: false, + want: true, + }, + { + name: "user enters no", + input: "no\n", + prompt: testutil.WizardPromptContinue, + defaultValue: true, + want: false, + }, + { + name: "user enters n", + input: testutil.WizardInputNo, + prompt: testutil.WizardPromptContinue, + defaultValue: true, + want: false, + }, + { + name: "user accepts default true", + input: "\n", + prompt: testutil.WizardPromptContinue, + defaultValue: true, + want: true, + }, + { + name: "user accepts default false", + input: "\n", + prompt: testutil.WizardPromptContinue, + defaultValue: false, + want: false, + }, + { + name: "invalid input then default", + input: "maybe\n", + prompt: testutil.WizardPromptContinue, + defaultValue: true, + want: true, + }, + { + name: "case insensitive YES", + input: "YES\n", + prompt: testutil.WizardPromptContinue, + defaultValue: false, + want: true, + }, + { + name: "case insensitive NO", + input: "NO\n", + prompt: testutil.WizardPromptContinue, + defaultValue: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + got := wizard.promptYesNo(tt.prompt, tt.defaultValue) + + if got != tt.want { + t.Errorf("promptYesNo() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestPromptSensitive tests the sensitive input prompt function. +func TestPromptSensitive(t *testing.T) { + tests := []struct { + name string + input string + prompt string + want string + }{ + { + name: "user provides token", + input: "ghp_1234567890abcdef\n", + prompt: testutil.WizardInputEnterToken, + want: "ghp_1234567890abcdef", + }, + { + name: "user provides empty input", + input: "\n", + prompt: testutil.WizardInputEnterToken, + want: "", + }, + { + name: "user provides value with whitespace", + input: " token-value \n", + prompt: testutil.WizardInputEnterToken, + want: "token-value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + got := wizard.promptSensitive(tt.prompt) + + if got != tt.want { + t.Errorf("promptSensitive() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestConfigureBasicSettings tests basic settings configuration. +// + +func TestConfigureBasicSettings(t *testing.T) { + tests := []struct { + name string + inputs string + wantOrg string + wantRepo string + wantVer string + }{ + { + name: "all custom values", + inputs: "myorg\nmyrepo\nv1.0.0\n", + wantOrg: "myorg", + wantRepo: "myrepo", + wantVer: testutil.TestVersion, + }, + { + name: "use defaults for org and repo, custom version", + inputs: "\n\nv2.0.0\n", + wantOrg: "", + wantRepo: "", + wantVer: "v2.0.0", + }, + { + name: "custom org and repo, no version", + inputs: "testorg\ntestrepo\n\n", + wantOrg: testutil.WizardOrgTest, + wantRepo: testutil.WizardRepoTest, + wantVer: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.inputs) + wizard.configureBasicSettings() + + if wizard.config.Organization != tt.wantOrg { + t.Errorf("Organization = %q, want %q", wizard.config.Organization, tt.wantOrg) + } + if wizard.config.Repository != tt.wantRepo { + t.Errorf("Repository = %q, want %q", wizard.config.Repository, tt.wantRepo) + } + if wizard.config.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", wizard.config.Version, tt.wantVer) + } + }) + } +} + +// TestConfigureThemeSelection tests theme selection. +func TestConfigureThemeSelection(t *testing.T) { + tests := []struct { + name string + input string + wantTheme string + }{ + { + name: "select default theme (1)", + input: "1\n", + wantTheme: appconstants.ThemeDefault, + }, + { + name: "select github theme (2)", + input: "2\n", + wantTheme: appconstants.ThemeGitHub, + }, + { + name: "select gitlab theme (3)", + input: "3\n", + wantTheme: appconstants.ThemeGitLab, + }, + { + name: "select minimal theme (4)", + input: "4\n", + wantTheme: appconstants.ThemeMinimal, + }, + { + name: "select professional theme (5)", + input: "5\n", + wantTheme: appconstants.ThemeProfessional, + }, + { + name: "invalid choice defaults to first", + input: "99\n", + wantTheme: appconstants.ThemeDefault, // Default config theme + }, + { + name: "empty input uses default", + input: "\n", + wantTheme: appconstants.ThemeDefault, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + wizard.configureThemeSelection() + + if wizard.config.Theme != tt.wantTheme { + t.Errorf(testutil.TestMsgThemeFormat, wizard.config.Theme, tt.wantTheme) + } + }) + } +} + +// TestConfigureOutputFormat tests output format selection. +func TestConfigureOutputFormat(t *testing.T) { + tests := []struct { + name string + input string + wantFormat string + }{ + { + name: "select markdown (1)", + input: "1\n", + wantFormat: appconstants.OutputFormatMarkdown, + }, + { + name: "select html (2)", + input: "2\n", + wantFormat: appconstants.OutputFormatHTML, + }, + { + name: "select json (3)", + input: "3\n", + wantFormat: appconstants.OutputFormatJSON, + }, + { + name: "select asciidoc (4)", + input: "4\n", + wantFormat: "asciidoc", + }, + { + name: "invalid choice keeps default", + input: "99\n", + wantFormat: appconstants.OutputFormatMarkdown, // Default format + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + wizard.configureOutputFormat() + + if wizard.config.OutputFormat != tt.wantFormat { + t.Errorf("OutputFormat = %q, want %q", wizard.config.OutputFormat, tt.wantFormat) + } + }) + } +} + +// TestConfigureFeatures tests feature configuration. +func TestConfigureFeatures(t *testing.T) { + tests := []struct { + name string + inputs string + wantAnalyzeDeps bool + wantShowSecurityInfo bool + }{ + { + name: "enable both features", + inputs: testutil.WizardInputYesNewline, + wantAnalyzeDeps: true, + wantShowSecurityInfo: true, + }, + { + name: "disable both features", + inputs: "n\nn\n", + wantAnalyzeDeps: false, + wantShowSecurityInfo: false, + }, + { + name: "enable deps, disable security", + inputs: "yes\nno\n", + wantAnalyzeDeps: true, + wantShowSecurityInfo: false, + }, + { + name: "use defaults", + inputs: "\n\n", + wantAnalyzeDeps: false, // Default is false + wantShowSecurityInfo: false, // Default is false + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.inputs) + wizard.configureFeatures() + + if wizard.config.AnalyzeDependencies != tt.wantAnalyzeDeps { + t.Errorf("AnalyzeDependencies = %v, want %v", wizard.config.AnalyzeDependencies, tt.wantAnalyzeDeps) + } + if wizard.config.ShowSecurityInfo != tt.wantShowSecurityInfo { + t.Errorf("ShowSecurityInfo = %v, want %v", wizard.config.ShowSecurityInfo, tt.wantShowSecurityInfo) + } + }) + } +} + +// TestGetAvailableThemes tests the theme list function. +func TestGetAvailableThemes(t *testing.T) { + wizard := testWizard("") + themes := wizard.getAvailableThemes() + + if len(themes) != 5 { + t.Errorf("getAvailableThemes() returned %d themes, want 5", len(themes)) + } + + // Verify theme names + expectedThemes := []string{ + appconstants.ThemeDefault, + appconstants.ThemeGitHub, + appconstants.ThemeGitLab, + appconstants.ThemeMinimal, + appconstants.ThemeProfessional, + } + for i, expected := range expectedThemes { + if themes[i].name != expected { + t.Errorf("Theme %d = %q, want %q", i, themes[i].name, expected) + } + } +} + +// TestFindActionFiles tests action file discovery. +func TestFindActionFiles(t *testing.T) { + wizard := testWizard("") + + t.Run("non-existent directory", func(t *testing.T) { + files := wizard.findActionFiles("/nonexistent/path") + if len(files) != 0 { + t.Errorf("findActionFiles() for non-existent dir = %d files, want 0", len(files)) + } + }) + + t.Run("testdata example-action directory", func(t *testing.T) { + // Get absolute path to avoid traversal issues + absPath, err := filepath.Abs("../../testdata/example-action") + if err != nil { + t.Fatalf("Failed to get absolute path: %v", err) + } + files := wizard.findActionFiles(absPath) + if len(files) == 0 { + t.Error("findActionFiles() should find action files in testdata/example-action") + } + }) + + t.Run("testdata composite-action directory", func(t *testing.T) { + // Get absolute path to avoid traversal issues + absPath, err := filepath.Abs("../../testdata/composite-action") + if err != nil { + t.Fatalf("Failed to get absolute path: %v", err) + } + files := wizard.findActionFiles(absPath) + if len(files) == 0 { + t.Error("findActionFiles() should find action files in testdata/composite-action") + } + }) +} + +// TestNewConfigWizard tests wizard initialization. +func TestNewConfigWizard(t *testing.T) { + output := &internal.ColoredOutput{NoColor: true, Quiet: true} + wizard := NewConfigWizard(output) + + if wizard == nil { + t.Fatal("NewConfigWizard() returned nil") + } + + if wizard.output != output { + t.Error("NewConfigWizard() did not set output correctly") + } + + if wizard.scanner == nil { + t.Error("NewConfigWizard() did not initialize scanner") + } + + if wizard.config == nil { + t.Error("NewConfigWizard() did not initialize config") + } + + // Verify default config values + if wizard.config.Theme == "" { + t.Error("NewConfigWizard() config has empty theme") + } + + if wizard.config.OutputFormat == "" { + t.Error("NewConfigWizard() config has empty output format") + } +} + +// TestConfigureOutputDirectory tests output directory configuration. +func TestConfigureOutputDirectory(t *testing.T) { + tests := []struct { + name string + input string + initial string + want string + }{ + { + name: "custom directory", + input: "/custom/output\n", + initial: ".", + want: "/custom/output", + }, + { + name: "use default directory", + input: "\n", + initial: testutil.TestDirDocs, + want: testutil.TestDirDocs, + }, + { + name: "relative path", + input: testutil.TestDirOutput + "\n", + initial: ".", + want: testutil.TestDirOutput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + wizard.config.OutputDir = tt.initial + wizard.configureOutputDirectory() + + if wizard.config.OutputDir != tt.want { + t.Errorf(testutil.ErrOutputDirMismatch, wizard.config.OutputDir, tt.want) + } + }) + } +} + +// TestConfigureTemplateSettings tests template settings configuration. +// + +func TestConfigureTemplateSettings(t *testing.T) { + tests := []struct { + name string + inputs string + wantTheme string + wantFormat string + wantDir string + }{ + { + name: "all defaults", + inputs: testutil.WizardInputThreeNewlines, + wantTheme: appconstants.ThemeDefault, + wantFormat: appconstants.OutputFormatMarkdown, + wantDir: ".", + }, + { + name: "custom theme and format", + inputs: "2\n3\n" + testutil.TestDirOutput + "\n", + wantTheme: appconstants.ThemeGitHub, + wantFormat: appconstants.OutputFormatJSON, + wantDir: testutil.TestDirOutput, + }, + { + name: "professional theme html format", + inputs: "5\n2\n" + testutil.TestDirDocs + "\n", + wantTheme: appconstants.ThemeProfessional, + wantFormat: appconstants.OutputFormatHTML, + wantDir: testutil.TestDirDocs, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.inputs) + wizard.configureTemplateSettings() + + if wizard.config.Theme != tt.wantTheme { + t.Errorf(testutil.TestMsgThemeFormat, wizard.config.Theme, tt.wantTheme) + } + if wizard.config.OutputFormat != tt.wantFormat { + t.Errorf("OutputFormat = %q, want %q", wizard.config.OutputFormat, tt.wantFormat) + } + if wizard.config.OutputDir != tt.wantDir { + t.Errorf(testutil.ErrOutputDirMismatch, wizard.config.OutputDir, tt.wantDir) + } + }) + } +} + +// TestConfigureGitHubIntegration tests GitHub integration configuration. +func TestConfigureGitHubIntegration(t *testing.T) { + tests := []struct { + name string + inputs string + existingToken string + wantTokenSet bool + wantTokenValue string + }{ + { + name: "skip token setup", + inputs: testutil.WizardInputNo, + existingToken: "", + wantTokenSet: false, + wantTokenValue: "", + }, + { + name: "provide valid personal token", + inputs: "y\nghp_1234567890abcdefghijklmnopqrstuvwxyz\n", + existingToken: "", + wantTokenSet: true, + wantTokenValue: "ghp_1234567890abcdefghijklmnopqrstuvwxyz", + }, + { + name: "provide valid PAT token", + inputs: "y\ngithub_pat_1234567890abcdefghijklmnopqrstuvwxyz\n", + existingToken: "", + wantTokenSet: true, + wantTokenValue: "github_pat_1234567890abcdefghijklmnopqrstuvwxyz", + }, + { + name: "provide unusual token format", + inputs: "y\ntoken_unusual_format\n", + existingToken: "", + wantTokenSet: true, + wantTokenValue: "token_unusual_format", + }, + { + name: "empty token after yes", + inputs: "y\n\n", + existingToken: "", + wantTokenSet: false, + wantTokenValue: "", + }, + { + name: "existing token skips setup", + inputs: "", + existingToken: "ghp_existing_token", + wantTokenSet: true, + wantTokenValue: "ghp_existing_token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.inputs) + if tt.existingToken != "" { + wizard.config.GitHubToken = tt.existingToken + } + + wizard.configureGitHubIntegration() + + tokenSet := wizard.config.GitHubToken != "" + if tokenSet != tt.wantTokenSet { + t.Errorf("Token set = %v, want %v", tokenSet, tt.wantTokenSet) + } + + if tt.wantTokenSet && wizard.config.GitHubToken != tt.wantTokenValue { + t.Errorf("GitHubToken = %q, want %q", wizard.config.GitHubToken, tt.wantTokenValue) + } + }) + } +} + +// TestShowSummaryAndConfirm tests summary display and confirmation. +func TestShowSummaryAndConfirm(t *testing.T) { + tests := []struct { + name string + input string + config *internal.AppConfig + wantErr bool + }{ + { + name: "user confirms with yes", + input: testutil.WizardInputYes, + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + Theme: appconstants.ThemeDefault, + OutputFormat: appconstants.OutputFormatMarkdown, + OutputDir: ".", + }, + wantErr: false, + }, + { + name: "user confirms with Y", + input: "Y\n", + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + }, + wantErr: false, + }, + { + name: "user cancels with n", + input: testutil.WizardInputNo, + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + }, + wantErr: true, + }, + { + name: "user cancels with no", + input: "no\n", + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + }, + wantErr: true, + }, + { + name: "user accepts default (yes)", + input: "\n", + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + }, + wantErr: false, + }, + { + name: "config with version", + input: testutil.WizardInputYes, + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + Version: testutil.TestVersion, + }, + wantErr: false, + }, + { + name: "config with features enabled", + input: testutil.WizardInputYes, + config: &internal.AppConfig{ + Organization: testutil.WizardOrgTest, + Repository: testutil.WizardRepoTest, + AnalyzeDependencies: true, + ShowSecurityInfo: true, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + wizard.config = tt.config + + err := wizard.showSummaryAndConfirm() + + if (err != nil) != tt.wantErr { + t.Errorf("showSummaryAndConfirm() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr && err != nil { + // Verify error message contains "canceled" + if !strings.Contains(err.Error(), "canceled") { + t.Errorf("showSummaryAndConfirm() error = %v, expected 'canceled' in error message", err) + } + } + }) + } +} + +// Test verification helpers for TestRun. + +func verifyCompleteWizardFlow(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + if cfg.Organization != "myorg" { + t.Errorf("Organization = %q, want 'myorg'", cfg.Organization) + } + if cfg.Repository != "myrepo" { + t.Errorf("Repository = %q, want 'myrepo'", cfg.Repository) + } + if cfg.Version != testutil.TestVersion { + t.Errorf("Version = %q, want 'v1.0.0'", cfg.Version) + } + if cfg.Theme != appconstants.ThemeGitHub { + t.Errorf("Theme = %q, want 'github'", cfg.Theme) + } + if cfg.OutputFormat != appconstants.OutputFormatHTML { + t.Errorf("OutputFormat = %q, want 'html'", cfg.OutputFormat) + } + if cfg.OutputDir != testutil.TestDirDocs { + t.Errorf(testutil.ErrOutputDirMismatch, cfg.OutputDir, testutil.TestDirDocs) + } + if !cfg.AnalyzeDependencies { + t.Error(testutil.TestMsgAnalyzeDepsTrue) + } + if !cfg.ShowSecurityInfo { + t.Error("ShowSecurityInfo should be true") + } +} + +func verifyWizardDefaults(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + const defaultTheme = appconstants.ThemeDefault + if cfg.Theme != defaultTheme { + t.Errorf(testutil.TestMsgThemeFormat, cfg.Theme, defaultTheme) + } + if cfg.OutputFormat != appconstants.OutputFormatMarkdown { + t.Errorf("OutputFormat = %q, want 'md'", cfg.OutputFormat) + } +} + +func verifyGitHubToken(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + if cfg.GitHubToken != "ghp_testtoken123456" { + t.Errorf("GitHubToken = %q, want 'ghp_testtoken123456'", cfg.GitHubToken) + } +} + +func verifyMinimalThemeJSON(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + if cfg.Theme != appconstants.ThemeMinimal { + t.Errorf("Theme = %q, want 'minimal'", cfg.Theme) + } + if cfg.OutputFormat != appconstants.OutputFormatJSON { + t.Errorf("OutputFormat = %q, want 'json'", cfg.OutputFormat) + } + if cfg.OutputDir != testutil.TestDirOutput { + t.Errorf(testutil.ErrOutputDirMismatch, cfg.OutputDir, testutil.TestDirOutput) + } + if cfg.AnalyzeDependencies { + t.Error("AnalyzeDependencies should be false") + } + if cfg.ShowSecurityInfo { + t.Error("ShowSecurityInfo should be false") + } +} + +func verifyGitLabThemeASCIIDoc(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + if cfg.Theme != appconstants.ThemeGitLab { + t.Errorf("Theme = %q, want 'gitlab'", cfg.Theme) + } + if cfg.OutputFormat != "asciidoc" { + t.Errorf("OutputFormat = %q, want 'asciidoc'", cfg.OutputFormat) + } + if !cfg.AnalyzeDependencies { + t.Error(testutil.TestMsgAnalyzeDepsTrue) + } + if cfg.ShowSecurityInfo { + t.Error("ShowSecurityInfo should be false") + } +} + +func verifyProfessionalThemeAllFeatures(t *testing.T, cfg *internal.AppConfig) { + t.Helper() + if cfg.Theme != appconstants.ThemeProfessional { + t.Errorf("Theme = %q, want 'professional'", cfg.Theme) + } + if cfg.OutputFormat != appconstants.OutputFormatMarkdown { + t.Errorf("OutputFormat = %q, want 'md'", cfg.OutputFormat) + } + if cfg.OutputDir != "." { + t.Errorf("OutputDir = %q, want '.'", cfg.OutputDir) + } + if !cfg.AnalyzeDependencies { + t.Error(testutil.TestMsgAnalyzeDepsTrue) + } + if !cfg.ShowSecurityInfo { + t.Error("ShowSecurityInfo should be true") + } + if cfg.GitHubToken != "github_pat_testtoken" { + t.Errorf("GitHubToken = %q, want 'github_pat_testtoken'", cfg.GitHubToken) + } +} + +// TestRun tests the complete wizard workflow. +// verifyWizardTestResult validates the result of a wizard Run() call. +func verifyWizardTestResult( + t *testing.T, + err error, + wantErr bool, + config *internal.AppConfig, + verify func(*testing.T, *internal.AppConfig), +) { + t.Helper() + + if (err != nil) != wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, wantErr) + + return + } + + if wantErr { + if config != nil { + t.Error("Run() should return nil config on error") + } + + return + } + + if config == nil { + t.Fatal("Run() returned nil config") + } + + if verify != nil { + verify(t, config) + } +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + inputs string + wantErr bool + verify func(*testing.T, *internal.AppConfig) + }{ + { + name: "complete wizard flow with all custom values", + inputs: "myorg\nmyrepo\nv1.0.0\n" + // Basic settings + "2\n" + // GitHub theme + "2\n" + // HTML format + testutil.TestDirDocs + "\n" + // Output dir + testutil.WizardInputYesNewline + // Features: enable both + testutil.WizardInputNo + // GitHub: skip token + testutil.WizardInputYes, // Confirm + wantErr: false, + verify: verifyCompleteWizardFlow, + }, + { + name: "wizard with defaults and confirmation", + inputs: testutil.WizardInputThreeNewlines + // Basic: all defaults + testutil.WizardInputThreeNewlines + // Template: all defaults + "\n\n" + // Features: all defaults + testutil.WizardInputNo + // GitHub: skip + testutil.WizardInputYes, // Confirm + wantErr: false, + verify: verifyWizardDefaults, + }, + { + name: "wizard with GitHub token", + inputs: testutil.WizardInputThreeNewlines + // Basic: all defaults + testutil.WizardInputThreeNewlines + // Template: all defaults + "\n\n" + // Features: all defaults + "y\nghp_testtoken123456\n" + // GitHub: set token + testutil.WizardInputYes, // Confirm + wantErr: false, + verify: verifyGitHubToken, + }, + { + name: "user cancels at confirmation", + inputs: "testorg\ntestrepo\n\n" + // Basic settings + testutil.WizardInputThreeNewlines + // Template: all defaults + "\n\n" + // Features: all defaults + testutil.WizardInputNo + // GitHub: skip + testutil.WizardInputNo, // Cancel at confirmation + wantErr: true, + verify: nil, + }, + { + name: "minimal theme with json output", + inputs: "org\nrepo\n\n" + // Basic + "4\n3\n" + testutil.TestDirOutput + "\n" + // Minimal theme, JSON format + "n\nn\n" + // Features: disable both + testutil.WizardInputNo + // GitHub: skip + testutil.WizardInputYes, // Confirm + wantErr: false, + verify: verifyMinimalThemeJSON, + }, + { + name: "gitlab theme with asciidoc format", + inputs: "gitlab-org\nmy-project\nv2.5.0\n" + // Basic + "3\n4\n" + testutil.TestDirDocs + "\n" + // GitLab theme, AsciiDoc format + "yes\nno\n" + // Features: deps yes, security no + testutil.WizardInputNo + // GitHub: skip + "yes\n", // Confirm with 'yes' + wantErr: false, + verify: verifyGitLabThemeASCIIDoc, + }, + { + name: "professional theme with all features", + inputs: "my-org\nawesome-action\n\n" + // Basic (no version) + "5\n1\n.\n" + // Professional theme, markdown, current dir + testutil.WizardInputYesNewline + // Features: both enabled + "y\ngithub_pat_testtoken\n" + // GitHub: set PAT token + testutil.WizardInputYes, // Confirm + wantErr: false, + verify: verifyProfessionalThemeAllFeatures, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.inputs) + + config, err := wizard.Run() + + verifyWizardTestResult(t, err, tt.wantErr, config, tt.verify) + }) + } +} + +// TestDetectProjectSettings tests project settings auto-detection. +func TestDetectProjectSettings(t *testing.T) { + t.Run("detect in non-git directory", func(t *testing.T) { + wizard := testWizard("") + + // Should not error even if not in git repo + err := wizard.detectProjectSettings() + + // This should not fail, just log warnings + if err != nil { + // Error is acceptable but shouldn't crash + t.Logf("detectProjectSettings() error = %v (expected in non-git context)", err) + } + + // Action directory should be set + if wizard.actionDir == "" { + t.Error("detectProjectSettings() did not set actionDir") + } + }) + + t.Run("sets action directory", func(t *testing.T) { + wizard := testWizard("") + + _ = wizard.detectProjectSettings() + + if wizard.actionDir == "" { + t.Error("detectProjectSettings() should set actionDir") + } + }) + + t.Run("detects repo info when available", func(t *testing.T) { + wizard := testWizard("") + + // This test runs in the project directory which is a git repo + err := wizard.detectProjectSettings() + + // Should not error + if err != nil { + t.Logf("detectProjectSettings() error = %v", err) + } + + // Should have detected action directory + if wizard.actionDir == "" { + t.Error("actionDir should be set") + } + + // RepoInfo might be set if we're in a git repo + if wizard.repoInfo != nil { + t.Logf("Detected repo info: %+v", wizard.repoInfo) + } + }) +} + +// TestShowSummaryWithTokenFromEnv tests summary with token from environment. +func TestShowSummaryWithTokenFromEnv(t *testing.T) { + const defaultTheme = appconstants.ThemeDefault + + // Test to improve showSummaryAndConfirm coverage + wizard := testWizard(testutil.WizardInputYes) + wizard.config = &internal.AppConfig{ + Organization: "test", + Repository: "repo", + Theme: defaultTheme, + OutputFormat: appconstants.OutputFormatMarkdown, + OutputDir: ".", + AnalyzeDependencies: true, + ShowSecurityInfo: false, + } + + // Set env var to simulate token from environment + t.Setenv("GITHUB_TOKEN", "test_token_from_env") + + err := wizard.showSummaryAndConfirm() + if err != nil { + t.Errorf("showSummaryAndConfirm() unexpected error = %v", err) + } +} + +// TestPromptWithDefaultEdgeCases tests edge cases for promptWithDefault. +func TestPromptWithDefaultEdgeCases(t *testing.T) { + t.Run("scanner error returns default", func(t *testing.T) { + // Create a wizard with an input that will cause scanner to return false + wizard := testWizard("") + // Scanner will immediately return false since input is exhausted + result := wizard.promptWithDefault("test", appconstants.ThemeDefault) + if result != appconstants.ThemeDefault { + t.Errorf("Expected default value when scanner fails, got %q", result) + } + }) +} + +// TestPromptYesNoEdgeCases tests edge cases for promptYesNo. +func TestPromptYesNoEdgeCases(t *testing.T) { + t.Run("scanner error returns default", func(t *testing.T) { + wizard := testWizard("") + // Scanner will immediately return false since input is exhausted + result := wizard.promptYesNo("test", true) + if result != true { + t.Errorf("Expected default true when scanner fails, got %v", result) + } + }) +} + +// TestPromptSensitiveEdgeCases tests edge cases for promptSensitive. +func TestPromptSensitiveEdgeCases(t *testing.T) { + t.Run("scanner error returns empty string", func(t *testing.T) { + wizard := testWizard("") + // Scanner will immediately return false since input is exhausted + result := wizard.promptSensitive("test") + if result != "" { + t.Errorf("Expected empty string when scanner fails, got %q", result) + } + }) + + t.Run("whitespace is trimmed", func(t *testing.T) { + wizard := testWizard(" value \n") + result := wizard.promptSensitive("test") + if result != "value" { + t.Errorf("Expected trimmed value, got %q", result) + } + }) +} + +// TestDisplayThemeOptions tests theme display (verifies no panic). +func TestDisplayThemeOptions(t *testing.T) { + wizard := testWizard("") + themes := wizard.getAvailableThemes() + + // Should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("displayThemeOptions() panicked: %v", r) + } + }() + + wizard.displayThemeOptions(themes) +} + +// TestDisplayFormatOptions tests format display (verifies no panic). +func TestDisplayFormatOptions(t *testing.T) { + wizard := testWizard("") + formats := []string{ + appconstants.OutputFormatMarkdown, + appconstants.OutputFormatHTML, + appconstants.OutputFormatJSON, + "asciidoc", + } + + // Should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("displayFormatOptions() panicked: %v", r) + } + }() + + wizard.displayFormatOptions(formats) +} + +// TestConfirmConfiguration tests configuration confirmation. +func TestConfirmConfiguration(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "user confirms", + input: testutil.WizardInputYes, + wantErr: false, + }, + { + name: "user cancels", + input: testutil.WizardInputNo, + wantErr: true, + }, + { + name: "user accepts default (yes)", + input: "\n", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wizard := testWizard(tt.input) + err := wizard.confirmConfiguration() + + if (err != nil) != tt.wantErr { + t.Errorf("confirmConfiguration() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/main.go b/main.go index d119dfb..e35d1ea 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,10 @@ package main import ( + "errors" "fmt" - "log" "os" "path/filepath" - "strconv" "strings" "github.com/schollz/progressbar/v3" @@ -35,6 +34,37 @@ var ( quiet bool ) +// InputReader interface for reading user input (enables testing). +type InputReader interface { + ReadLine() (string, error) +} + +// StdinReader reads from actual stdin. +type StdinReader struct{} + +func (r *StdinReader) ReadLine() (string, error) { + var response string + _, err := fmt.Scanln(&response) + + return strings.TrimSpace(response), err +} + +// TestInputReader allows injecting test responses for testing. +type TestInputReader struct { + responses []string + index int +} + +func (r *TestInputReader) ReadLine() (string, error) { + if r.index >= len(r.responses) { + return "", errors.New("no more test responses") + } + response := r.responses[r.index] + r.index++ + + return response, nil +} + // Helper functions to reduce duplication. func createOutputManager(quiet bool) *internal.ColoredOutput { @@ -89,13 +119,52 @@ func createAnalyzer(generator *internal.Generator, output *internal.ColoredOutpu return helpers.CreateAnalyzer(generator, output) } +// wrapHandlerWithErrorHandling converts error-returning handler to Cobra handler. +// This allows handlers to return errors for testing while maintaining Cobra compatibility. +func wrapHandlerWithErrorHandling(handler func(*cobra.Command, []string) error) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + // Ensure globalConfig is initialized (important for testing) + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + + if err := handler(cmd, args); err != nil { + output := createOutputManager(globalConfig.Quiet) + output.Error(err.Error()) + os.Exit(1) + } + } +} + +// wrapError wraps an error with a message constant. +// This is a helper to reduce duplication of the fmt.Errorf("%s: %w", msg, err) pattern. +func wrapError(msgConstant string, err error) error { + return fmt.Errorf("%s: %w", msgConstant, err) +} + +// handleNoFilesFoundError handles errors where no action files are found, showing a warning instead of failing. +// Returns nil if the error is about no files found (after showing warning), otherwise returns the original error. +func handleNoFilesFoundError(err error, output *internal.ColoredOutput) error { + if err == nil { + return nil + } + + if strings.Contains(err.Error(), appconstants.ErrNoActionFilesFound) { + output.Warning(appconstants.ErrNoActionFilesFound) + + return nil + } + + return err +} + func main() { rootCmd := &cobra.Command{ Use: "gh-action-readme", Short: "Auto-generate beautiful README and HTML documentation for GitHub Actions.", Long: `gh-action-readme is a CLI tool for parsing one or many action.yml files and ` + `generating informative, modern, and customizable documentation.`, - PersistentPreRun: initConfig, + PersistentPreRunE: initConfig, } // Global flags @@ -141,14 +210,14 @@ func main() { } } -func initConfig(_ *cobra.Command, _ []string) { +func initConfig(_ *cobra.Command, _ []string) error { var err error // Use ConfigurationLoader for loading global configuration loader := internal.NewConfigurationLoader() globalConfig, err = loader.LoadGlobalConfig(configFile) if err != nil { - log.Fatalf("Failed to initialize configuration: %v", err) + return fmt.Errorf("failed to initialize configuration: %w", err) } // Override with command line flags @@ -159,6 +228,8 @@ func initConfig(_ *cobra.Command, _ []string) { globalConfig.Quiet = true globalConfig.Verbose = false // quiet overrides verbose } + + return nil } func newGenCmd() *cobra.Command { @@ -175,10 +246,15 @@ Examples: gh-action-readme gen -f html --output custom.html testdata/action/ gh-action-readme gen --output docs/action1.html testdata/action1/`, Args: cobra.MaximumNArgs(1), - Run: genHandler, + Run: wrapHandlerWithErrorHandling(genHandler), } - cmd.Flags().StringP(appconstants.FlagOutputFormat, "f", "md", "output format: md, html, json, asciidoc") + cmd.Flags().StringP( + appconstants.FlagOutputFormat, + "f", + appconstants.OutputFormatMarkdown, + "output format: md, html, json, asciidoc", + ) cmd.Flags().StringP(appconstants.FlagOutputDir, "o", ".", "output directory") cmd.Flags().StringP(appconstants.FlagOutput, "", "", "custom output filename (overrides default naming)") cmd.Flags().StringP(appconstants.ConfigKeyTheme, "t", "", "template theme: github, gitlab, minimal, professional") @@ -196,7 +272,7 @@ func newValidateCmd() *cobra.Command { return &cobra.Command{ Use: "validate", Short: "Validate action.yml files and optionally autofill missing fields.", - Run: validateHandler, + Run: wrapHandlerWithErrorHandling(validateHandler), } } @@ -208,9 +284,9 @@ func newSchemaCmd() *cobra.Command { } } -func genHandler(cmd *cobra.Command, args []string) { - output := createOutputManager(globalConfig.Quiet) - +// resolveAndValidateTargetPath resolves the target path from arguments or current directory, +// validates it exists, and returns the absolute path and file info. +func resolveAndValidateTargetPath(args []string) (string, os.FileInfo, error) { // Determine target path from arguments or current directory var targetPath string if len(args) > 0 { @@ -219,23 +295,35 @@ func genHandler(cmd *cobra.Command, args []string) { var err error targetPath, err = helpers.GetCurrentDir() if err != nil { - output.Error(appconstants.ErrErrorGettingCurrentDir, err) - os.Exit(1) + return "", nil, wrapError(appconstants.ErrErrorGettingCurrentDir, err) } } // Resolve target path to absolute path absTargetPath, err := filepath.Abs(targetPath) if err != nil { - output.Error("Error resolving path %s: %v", targetPath, err) - os.Exit(1) + return "", nil, fmt.Errorf("error resolving path %s: %w", targetPath, err) } // Check if target exists info, err := os.Stat(absTargetPath) if err != nil { - output.Error("Path does not exist: %s", targetPath) - os.Exit(1) + return "", nil, fmt.Errorf("path does not exist: %s", targetPath) + } + + return absTargetPath, info, nil +} + +func genHandler(cmd *cobra.Command, args []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + + // Resolve and validate target path + absTargetPath, info, err := resolveAndValidateTargetPath(args) + if err != nil { + return err } var workingDir string @@ -260,46 +348,46 @@ func genHandler(cmd *cobra.Command, args []string) { "documentation generation", ) if err != nil { - os.Exit(1) + return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err) } } else { // Target is a file - validate it's an action file lowerPath := strings.ToLower(absTargetPath) if !strings.HasSuffix(lowerPath, ".yml") && !strings.HasSuffix(lowerPath, ".yaml") { - output.Error("File must be a YAML file (.yml or .yaml): %s", targetPath) - os.Exit(1) + return fmt.Errorf("file must be a YAML file (.yml or .yaml): %s", absTargetPath) } workingDir = filepath.Dir(absTargetPath) actionFiles = []string{absTargetPath} } repoRoot := helpers.FindGitRepoRoot(workingDir) - config := loadGenConfig(repoRoot, workingDir) + config, err := loadGenConfig(repoRoot, workingDir) + if err != nil { + return err + } applyGlobalFlags(config) applyCommandFlags(cmd, config) generator := internal.NewGenerator(config) logConfigInfo(generator, config, repoRoot) - processActionFiles(generator, actionFiles) + return processActionFiles(generator, actionFiles) } // loadGenConfig loads multi-level configuration using ConfigurationLoader. -func loadGenConfig(repoRoot, currentDir string) *internal.AppConfig { +func loadGenConfig(repoRoot, currentDir string) (*internal.AppConfig, error) { loader := internal.NewConfigurationLoader() config, err := loader.LoadConfiguration(configFile, repoRoot, currentDir) if err != nil { - fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("error loading configuration: %w", err) } // Validate the loaded configuration if err := loader.ValidateConfiguration(config); err != nil { - fmt.Fprintf(os.Stderr, "Configuration validation error: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("configuration validation error: %w", err) } - return config + return config, nil } // applyGlobalFlags applies global verbose/quiet flags. @@ -320,7 +408,7 @@ func applyCommandFlags(cmd *cobra.Command, config *internal.AppConfig) { outputFilename, _ := cmd.Flags().GetString(appconstants.FlagOutput) theme, _ := cmd.Flags().GetString(appconstants.ConfigKeyTheme) - if outputFormat != "md" { + if outputFormat != appconstants.OutputFormatMarkdown { config.OutputFormat = outputFormat } if outputDir != "." { @@ -345,18 +433,23 @@ func logConfigInfo(generator *internal.Generator, config *internal.AppConfig, re } // processActionFiles processes discovered files. -func processActionFiles(generator *internal.Generator, actionFiles []string) { +func processActionFiles(generator *internal.Generator, actionFiles []string) error { if err := generator.ProcessBatch(actionFiles); err != nil { - generator.Output.Error("Error during generation: %v", err) - os.Exit(1) + return fmt.Errorf("error during generation: %w", err) } + + return nil } -func validateHandler(_ *cobra.Command, _ []string) { +func validateHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + currentDir, err := helpers.GetCurrentDir() if err != nil { - _, errorHandler := setupOutputAndErrorHandling() - errorHandler.HandleSimpleError("Unable to determine current directory", err) + return fmt.Errorf("unable to determine current directory: %w", err) } generator := internal.NewGenerator(globalConfig) @@ -367,23 +460,17 @@ func validateHandler(_ *cobra.Command, _ []string) { "validation", ) // Recursive for validation if err != nil { - os.Exit(1) + return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err) } // Validate the discovered files if err := generator.ValidateFiles(actionFiles); err != nil { - generator.Output.ErrorWithContext( - appconstants.ErrCodeValidation, - "validation failed", - map[string]string{ - "files_count": strconv.Itoa(len(actionFiles)), - appconstants.ContextKeyError: err.Error(), - }, - ) - os.Exit(1) + return fmt.Errorf("validation failed for %d files: %w", len(actionFiles), err) } generator.Output.Success("\nAll validations passed successfully!") + + return nil } func schemaHandler(_ *cobra.Command, _ []string) { @@ -417,14 +504,14 @@ func newConfigCmd() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "init", Short: "Initialize default configuration file", - Run: configInitHandler, + Run: wrapHandlerWithErrorHandling(configInitHandler), }) initCmd := &cobra.Command{ Use: "wizard", Short: "Interactive configuration wizard", Long: "Launch an interactive wizard to set up your configuration step by step", - Run: configWizardHandler, + Run: wrapHandlerWithErrorHandling(configWizardHandler), } initCmd.Flags().String(appconstants.FlagFormat, "yaml", "Export format: yaml, json, toml") initCmd.Flags().String(appconstants.FlagOutput, "", "Output path (default: XDG config directory)") @@ -445,31 +532,36 @@ func newConfigCmd() *cobra.Command { return cmd } -func configInitHandler(_ *cobra.Command, _ []string) { +func configInitHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) // Check if config already exists configPath, err := internal.GetConfigPath() if err != nil { - output.Error("Failed to get config path: %v", err) - os.Exit(1) + return fmt.Errorf("failed to get config path: %w", err) } 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 + return nil } // Create default config if err := internal.WriteDefaultConfig(); err != nil { - output.Error("Failed to write default configuration: %v", err) - os.Exit(1) + return fmt.Errorf("failed to write default configuration: %w", err) } output.Success("Created default configuration at: %s", configPath) output.Info("Edit this file to customize your settings") + + return nil } func configShowHandler(_ *cobra.Command, _ []string) { @@ -521,19 +613,19 @@ func newDepsCmd() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "list", Short: "List all dependencies in action files", - Run: depsListHandler, + Run: wrapHandlerWithErrorHandling(depsListHandler), }) cmd.AddCommand(&cobra.Command{ Use: "security", Short: "Analyze dependency security (pinned vs floating versions)", - Run: depsSecurityHandler, + Run: wrapHandlerWithErrorHandling(depsSecurityHandler), }) cmd.AddCommand(&cobra.Command{ Use: "outdated", Short: "Check for outdated dependencies", - Run: depsOutdatedHandler, + Run: wrapHandlerWithErrorHandling(depsOutdatedHandler), }) cmd.AddCommand(&cobra.Command{ @@ -546,18 +638,18 @@ func newDepsCmd() *cobra.Command { Use: "upgrade", Short: "Upgrade dependencies with interactive or CI mode", Long: "Upgrade dependencies to latest versions. Use --ci for automated pinned updates.", - Run: depsUpgradeHandler, + Run: wrapHandlerWithErrorHandling(depsUpgradeHandler), } - upgradeCmd.Flags().Bool("ci", false, "CI/CD mode: automatically pin all updates to commit SHAs") + upgradeCmd.Flags().Bool(appconstants.FlagCI, false, "CI/CD mode: automatically pin all updates to commit SHAs") upgradeCmd.Flags().Bool(appconstants.InputAll, false, "Update all outdated dependencies without prompts") upgradeCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be updated without making changes") cmd.AddCommand(upgradeCmd) pinCmd := &cobra.Command{ - Use: "pin", + Use: appconstants.CommandPin, Short: "Pin floating versions to specific commits", Long: "Convert floating versions (like @v4) to pinned commit SHAs with version comments.", - Run: depsUpgradeHandler, // Uses same handler with different flags + Run: wrapHandlerWithErrorHandling(depsUpgradeHandler), // Uses same handler with different flags } pinCmd.Flags().Bool(appconstants.InputAll, false, "Pin all floating dependencies") pinCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be pinned without making changes") @@ -576,30 +668,34 @@ func newCacheCmd() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "clear", Short: "Clear the dependency cache", - Run: cacheClearHandler, + Run: wrapHandlerWithErrorHandling(cacheClearHandler), }) cmd.AddCommand(&cobra.Command{ Use: "stats", Short: "Show cache statistics", - Run: cacheStatsHandler, + Run: wrapHandlerWithErrorHandling(cacheStatsHandler), }) cmd.AddCommand(&cobra.Command{ Use: "path", Short: "Show cache directory path", - Run: cachePathHandler, + Run: wrapHandlerWithErrorHandling(cachePathHandler), }) return cmd } -func depsListHandler(_ *cobra.Command, _ []string) { +func depsListHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error(appconstants.ErrErrorGettingCurrentDir, err) - os.Exit(1) + return wrapError(appconstants.ErrErrorGettingCurrentDir, err) } generator := internal.NewGenerator(globalConfig) @@ -609,11 +705,8 @@ func depsListHandler(_ *cobra.Command, _ []string) { globalConfig.IgnoredDirectories, "dependency listing", ) - if err != nil { - // For deps list, we can continue if no files found (show warning instead of error) - output.Warning(appconstants.ErrNoActionFilesFound) - - return + if err := handleNoFilesFoundError(err, output); err != nil { + return err } analyzer := createAnalyzer(generator, output) @@ -622,6 +715,8 @@ func depsListHandler(_ *cobra.Command, _ []string) { if totalDeps > 0 { output.Bold("\nTotal dependencies: %d", totalDeps) } + + return nil } // analyzeDependencies analyzes and displays dependencies. @@ -678,12 +773,17 @@ func analyzeActionFileDeps(output *internal.ColoredOutput, actionFile string, an return len(deps) } -func depsSecurityHandler(_ *cobra.Command, _ []string) { - output, errorHandler := setupOutputAndErrorHandling() +func depsSecurityHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + + output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - errorHandler.HandleSimpleError("Failed to get current directory", err) + return fmt.Errorf("failed to get current directory: %w", err) } generator := internal.NewGenerator(globalConfig) @@ -694,16 +794,23 @@ func depsSecurityHandler(_ *cobra.Command, _ []string) { "security analysis", ) if err != nil { - os.Exit(1) + return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err) } analyzer := createAnalyzer(generator, output) if analyzer == nil { - return + output.Warning( + "⚠️ Analyzer disabled: GitHub token not configured. " + + "Use GITHUB_TOKEN or GH_README_GITHUB_TOKEN environment variable.", + ) + + return nil // Analyzer can be nil if token isn't configured, gracefully handle } pinnedCount, floatingDeps := analyzeSecurityDeps(output, actionFiles, analyzer) displaySecuritySummary(output, currentDir, pinnedCount, floatingDeps) + + return nil } // analyzeSecurityDeps analyzes dependencies for security issues. @@ -781,12 +888,16 @@ func displayFloatingDeps(output *internal.ColoredOutput, currentDir string, floa } } -func depsOutdatedHandler(_ *cobra.Command, _ []string) { +func depsOutdatedHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error(appconstants.ErrErrorGettingCurrentDir, err) - os.Exit(1) + return wrapError(appconstants.ErrErrorGettingCurrentDir, err) } generator := internal.NewGenerator(globalConfig) @@ -796,24 +907,23 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { globalConfig.IgnoredDirectories, "outdated dependency analysis", ) - if err != nil { - // For deps outdated, we can continue if no files found (show warning instead of error) - output.Warning(appconstants.ErrNoActionFilesFound) - - return + if err := handleNoFilesFoundError(err, output); err != nil { + return err } analyzer := createAnalyzer(generator, output) - if analyzer == nil { - return + if !validateGitHubToken(output) { + return nil // Not an error, just no token available } - if !validateGitHubToken(output) { - return + if analyzer == nil { + return nil // Analyzer can be nil if token isn't configured, gracefully handle } allOutdated := checkAllOutdated(output, actionFiles, analyzer) displayOutdatedResults(output, allOutdated) + + return nil } // validateGitHubToken checks if GitHub token is available. @@ -884,25 +994,30 @@ func displayOutdatedResults(output *internal.ColoredOutput, allOutdated []depend output.Info("\nRun 'gh-action-readme deps upgrade' to update dependencies") } -func depsUpgradeHandler(cmd *cobra.Command, _ []string) { +func depsUpgradeHandler(cmd *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error(appconstants.ErrErrorGettingCurrentDir, err) - os.Exit(1) + return wrapError(appconstants.ErrErrorGettingCurrentDir, err) } // Setup and validation - analyzer, actionFiles := setupDepsUpgrade(output, currentDir) - if analyzer == nil || len(actionFiles) == 0 { - return + analyzer, actionFiles, err := setupDepsUpgrade(output, currentDir, nil) + if err != nil { + // setupDepsUpgrade returns descriptive errors, so just pass them through + return err } // Parse flags and show mode - ciMode, _ := cmd.Flags().GetBool("ci") + ciMode, _ := cmd.Flags().GetBool(appconstants.FlagCI) allFlag, _ := cmd.Flags().GetBool(appconstants.InputAll) dryRun, _ := cmd.Flags().GetBool(appconstants.InputDryRun) - isPinCmd := cmd.Use == "pin" + isPinCmd := cmd.Use == appconstants.CommandPin showUpgradeMode(output, ciMode, isPinCmd) @@ -911,47 +1026,57 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) { if len(allUpdates) == 0 { output.Success("✅ No updates needed - all dependencies are current and pinned!") - return + return nil } // Show and apply updates showPendingUpdates(output, allUpdates, currentDir) if !dryRun { - applyUpdates(output, analyzer, allUpdates, ciMode || allFlag) + if err := applyUpdates(output, analyzer, allUpdates, ciMode || allFlag, nil); err != nil { + return err + } } else { output.Info("\n🔍 Dry run complete - no changes made") } + + return nil } // setupDepsUpgrade handles initial setup and validation for dependency upgrades. -func setupDepsUpgrade(output *internal.ColoredOutput, currentDir string) (*dependencies.Analyzer, []string) { - generator := internal.NewGenerator(globalConfig) - actionFiles, err := generator.DiscoverActionFiles(currentDir, true, globalConfig.IgnoredDirectories) +// The config parameter allows injection for testing (pass nil to use globalConfig). +func setupDepsUpgrade( + _ *internal.ColoredOutput, + currentDir string, + config *internal.AppConfig, +) (*dependencies.Analyzer, []string, error) { + // Default to globalConfig if not provided (backward compatible) + if config == nil { + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + config = globalConfig + } + + generator := internal.NewGenerator(config) + actionFiles, err := generator.DiscoverActionFiles(currentDir, true, config.IgnoredDirectories) if err != nil { - output.Error("Error discovering action files: %v", err) - os.Exit(1) + return nil, nil, fmt.Errorf("error discovering action files: %w", err) } if len(actionFiles) == 0 { - output.Warning("No action files found") - - return nil, nil + return nil, nil, errors.New(appconstants.ErrNoActionFilesFound) } analyzer, err := generator.CreateDependencyAnalyzer() if err != nil { - output.Warning(appconstants.ErrCouldNotCreateDependencyAnalyzer, err) - - return nil, nil + return nil, nil, fmt.Errorf("could not create dependency analyzer: %w", err) } - if globalConfig.GitHubToken == "" { - output.Warning("No GitHub token found. Set GITHUB_TOKEN environment variable") - - return nil, nil + if config.GitHubToken == "" { + return nil, nil, errors.New("no GitHub token found, set GITHUB_TOKEN environment variable") } - return analyzer, actionFiles + return analyzer, actionFiles, nil } // showUpgradeMode displays the current upgrade mode to the user. @@ -1024,37 +1149,46 @@ func showPendingUpdates( } // applyUpdates applies the collected updates either automatically or interactively. +// The reader parameter allows injection of input for testing (pass nil to use stdin). func applyUpdates( output *internal.ColoredOutput, analyzer *dependencies.Analyzer, allUpdates []dependencies.PinnedUpdate, automatic bool, -) { + reader InputReader, +) error { + // Default to stdin if not provided + if reader == nil { + reader = &StdinReader{} + } + if automatic { output.Info("\n🚀 Applying updates...") if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil { - output.Error(appconstants.ErrFailedToApplyUpdates, err) - os.Exit(1) + return fmt.Errorf(appconstants.ErrFailedToApplyUpdatesWrapped, err) } output.Success("✅ Successfully updated %d dependencies with pinned commit SHAs", len(allUpdates)) } else { // Interactive mode output.Info("\n❓ This will modify your action.yml files. Continue? (y/N): ") - var response string - _, _ = fmt.Scanln(&response) // User input, scan error not critical + response, err := reader.ReadLine() + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } if strings.ToLower(response) != "y" && strings.ToLower(response) != appconstants.InputYes { output.Info("Canceled") - return + return nil } output.Info("🚀 Applying updates...") if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil { - output.Error(appconstants.ErrFailedToApplyUpdates, err) - os.Exit(1) + return fmt.Errorf(appconstants.ErrFailedToApplyUpdatesWrapped, err) } output.Success("✅ Successfully updated %d dependencies", len(allUpdates)) } + + return nil } func depsGraphHandler(_ *cobra.Command, _ []string) { @@ -1064,39 +1198,48 @@ func depsGraphHandler(_ *cobra.Command, _ []string) { output.Printf("This feature is not yet implemented\n") } -func cacheClearHandler(_ *cobra.Command, _ []string) { +func cacheClearHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) output.Info("Clearing dependency cache...") // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error(appconstants.ErrFailedToAccessCache, err) - os.Exit(1) + return wrapError(appconstants.ErrFailedToAccessCache, err) } if err := cacheInstance.Clear(); err != nil { - output.Error("Failed to clear cache: %v", err) - os.Exit(1) + return fmt.Errorf("failed to clear cache: %w", err) } output.Success("Cache cleared successfully") + + return nil } -func cacheStatsHandler(_ *cobra.Command, _ []string) { +func cacheStatsHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error(appconstants.ErrFailedToAccessCache, err) - os.Exit(1) + return wrapError(appconstants.ErrFailedToAccessCache, err) } stats := cacheInstance.Stats() output.Bold("Cache Statistics:") - output.Printf("Cache location: %s\n", stats["cache_dir"]) + output.Printf("Cache location: %s\n", stats[appconstants.CacheStatsKeyDir]) output.Printf("Total entries: %d\n", stats["total_entries"]) output.Printf("Expired entries: %d\n", stats["expired_count"]) @@ -1107,20 +1250,26 @@ func cacheStatsHandler(_ *cobra.Command, _ []string) { } sizeStr := formatSize(totalSize) output.Printf("Total size: %s\n", sizeStr) + + return nil } -func cachePathHandler(_ *cobra.Command, _ []string) { +func cachePathHandler(_ *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error(appconstants.ErrFailedToAccessCache, err) - os.Exit(1) + return wrapError(appconstants.ErrFailedToAccessCache, err) } stats := cacheInstance.Stats() - cachePath, ok := stats["cache_dir"].(string) + cachePath, ok := stats[appconstants.CacheStatsKeyDir].(string) if !ok { cachePath = appconstants.ScopeUnknown } @@ -1134,17 +1283,23 @@ func cachePathHandler(_ *cobra.Command, _ []string) { } else if os.IsNotExist(err) { output.Warning("Directory does not exist (will be created on first use)") } + + return nil } -func configWizardHandler(cmd *cobra.Command, _ []string) { +func configWizardHandler(cmd *cobra.Command, _ []string) error { + // Ensure globalConfig is initialized + if globalConfig == nil { + globalConfig = internal.DefaultAppConfig() + } + output := createOutputManager(globalConfig.Quiet) // Create and run the wizard configWizard := wizard.NewConfigWizard(output) config, err := configWizard.Run() if err != nil { - output.Error("Wizard failed: %v", err) - os.Exit(1) + return fmt.Errorf("wizard failed: %w", err) } // Get export format and output path @@ -1159,8 +1314,7 @@ func configWizardHandler(cmd *cobra.Command, _ []string) { exportFormat := resolveExportFormat(format) defaultPath, err := exporter.GetDefaultOutputPath(exportFormat) if err != nil { - output.Error("Failed to get default output path: %v", err) - os.Exit(1) + return fmt.Errorf("failed to get default output path: %w", err) } outputPath = defaultPath } @@ -1169,10 +1323,11 @@ func configWizardHandler(cmd *cobra.Command, _ []string) { exportFormat := resolveExportFormat(format) if err := exporter.ExportConfig(config, exportFormat, outputPath); err != nil { - output.Error("Failed to export configuration: %v", err) - os.Exit(1) + return fmt.Errorf("failed to export configuration: %w", err) } output.Info("\n🎉 Configuration wizard completed successfully!") output.Info("You can now use 'gh-action-readme gen' to generate documentation.") + + return nil } diff --git a/main_test.go b/main_test.go index 385927c..9165e01 100644 --- a/main_test.go +++ b/main_test.go @@ -2,18 +2,93 @@ package main import ( "bytes" + "errors" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" + "github.com/spf13/cobra" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/internal/dependencies" + "github.com/ivuorinen/gh-action-readme/internal/git" "github.com/ivuorinen/gh-action-readme/internal/wizard" "github.com/ivuorinen/gh-action-readme/testutil" ) +const ( + testCmdGen = "gen" + testCmdConfig = "config" + testCmdValidate = "validate" + testCmdDeps = "deps" + testCmdList = "list" + testCmdShow = "show" + testFormatJSON = "json" + testFormatHTML = "html" + testThemeGitHub = "github" + testThemePro = "professional" + testFlagOutputFmt = "--output-format" + testFlagTheme = "--theme" + testActionBasic = "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []" + testErrExpectedShort = "expected Short description to be non-empty" + testErrExpectedRunFn = "expected command to have a Run or RunE function" + testMsgUsesGlobalCfg = "uses globalConfig when config parameter is nil" +) + +// createFixtureTestCase creates a test table entry for tests that load a fixture +// and expect a specific error outcome. This helper reduces duplication by standardizing +// the creation of test structures that follow the "load fixture, write to tmpDir, expect error" pattern. +func createFixtureTestCase(name, fixturePath string, wantErr bool) struct { + name string + setupFunc func(t *testing.T, tmpDir string) + wantErr bool +} { + return struct { + name string + setupFunc func(t *testing.T, tmpDir string) + wantErr bool + }{ + name: name, + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + fixtureContent := testutil.MustReadFixture(fixturePath) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + wantErr: wantErr, + } +} + +// createFixtureTestCaseWithPaths creates a test table entry for tests that load a fixture +// and return paths for processing. This helper reduces duplication for the pattern where +// setupFunc returns []string paths. +func createFixtureTestCaseWithPaths(name, fixturePath string, wantErr bool) struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantErr bool + setFlags func(cmd *cobra.Command) +} { + return struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantErr bool + setFlags func(cmd *cobra.Command) + }{ + name: name, + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + fixtureContent := testutil.MustReadFixture(fixturePath) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + + return []string{tmpDir} + }, + wantErr: wantErr, + } +} + // TestCLICommands tests the main CLI commands using subprocess execution. func TestCLICommands(t *testing.T) { t.Parallel() @@ -49,44 +124,44 @@ func TestCLICommands(t *testing.T) { }, { name: "gen command with valid action", - args: []string{"gen", "--output-format", "md"}, + args: []string{testCmdGen, testFlagOutputFmt, "md"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 0, }, { name: "gen command with theme flag", - args: []string{"gen", "--theme", "github", "--output-format", "json"}, + args: []string{testCmdGen, testFlagTheme, testThemeGitHub, testFlagOutputFmt, testFormatJSON}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 0, }, { name: "gen command with no action files", - args: []string{"gen"}, + args: []string{testCmdGen}, wantExit: 1, wantStderr: "no GitHub Action files found for documentation generation [NO_ACTION_FILES]", }, { name: "validate command with valid action", - args: []string{"validate"}, + args: []string{testCmdValidate}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 0, wantStdout: "All validations passed successfully", }, { name: "validate command with invalid action", - args: []string{"validate"}, + args: []string{testCmdValidate}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureInvalidMissingDescription) + createTestActionFile(t, tmpDir, testutil.TestFixtureInvalidMissingDescription) }, wantExit: 1, }, @@ -98,35 +173,35 @@ func TestCLICommands(t *testing.T) { }, { name: "config command default", - args: []string{"config"}, + args: []string{testCmdConfig}, wantExit: 0, wantStdout: "Configuration file location:", }, { name: "config show command", - args: []string{"config", "show"}, + args: []string{testCmdConfig, testCmdShow}, wantExit: 0, wantStdout: "Current Configuration:", }, { name: "config themes command", - args: []string{"config", "themes"}, + args: []string{testCmdConfig, "themes"}, wantExit: 0, wantStdout: "Available Themes:", }, { name: "deps list command no files", - args: []string{"deps", "list"}, + args: []string{testCmdDeps, testCmdList}, wantExit: 0, // Changed: deps list now outputs warning instead of error when no files found - wantStdout: "No action files found", + wantStdout: "no action files found", }, { name: "deps list command with composite action", - args: []string{"deps", "list"}, + args: []string{testCmdDeps, testCmdList}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) + testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(testutil.TestFixtureCompositeBasic)) }, wantExit: 0, }, @@ -181,18 +256,18 @@ func TestCLIFlags(t *testing.T) { }{ { name: "verbose flag", - args: []string{"--verbose", "config", "show"}, + args: []string{"--verbose", testCmdConfig, testCmdShow}, wantExit: 0, contains: "Current Configuration:", }, { name: "quiet flag", - args: []string{"--quiet", "config", "show"}, + args: []string{"--quiet", testCmdConfig, testCmdShow}, wantExit: 0, }, { name: "config file flag", - args: []string{"--config", "nonexistent.yml", "config", "show"}, + args: []string{"--config", "nonexistent.yml", testCmdConfig, testCmdShow}, wantExit: 1, }, { @@ -217,9 +292,9 @@ func TestCLIFlags(t *testing.T) { result := runTestCommand(binaryPath, tt.args, tmpDir) if result.exitCode != tt.wantExit { - t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode) - t.Logf(appconstants.TestMsgStdout, result.stdout) - t.Logf(appconstants.TestMsgStderr, result.stderr) + t.Errorf(testutil.TestMsgExitCode, tt.wantExit, result.exitCode) + t.Logf(testutil.TestMsgStdout, result.stdout) + t.Logf(testutil.TestMsgStderr, result.stderr) } if tt.contains != "" { @@ -239,8 +314,8 @@ func TestCLIRecursiveFlag(t *testing.T) { defer cleanup() // Create nested directory structure with action files - testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) - testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, appconstants.TestFixtureCompositeBasic) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + testutil.CreateActionSubdir(t, tmpDir, testutil.TestDirSubdir, testutil.TestFixtureCompositeBasic) tests := []struct { name string @@ -250,13 +325,13 @@ func TestCLIRecursiveFlag(t *testing.T) { }{ { name: "without recursive flag", - args: []string{"gen", "--output-format", "json"}, + args: []string{testCmdGen, testFlagOutputFmt, testFormatJSON}, wantExit: 0, minFiles: 1, // should only process root action.yml }, { name: "with recursive flag", - args: []string{"gen", "--recursive", "--output-format", "json"}, + args: []string{testCmdGen, "--recursive", testFlagOutputFmt, testFormatJSON}, wantExit: 0, minFiles: 2, // should process both action.yml files }, @@ -269,7 +344,7 @@ func TestCLIRecursiveFlag(t *testing.T) { // For recursive tests, check that appropriate number of files were processed // This is a simple heuristic - could be made more sophisticated - if tt.minFiles > 1 && !strings.Contains(result.stdout, appconstants.TestDirSubdir) { + if tt.minFiles > 1 && !strings.Contains(result.stdout, testutil.TestDirSubdir) { t.Errorf("expected recursive processing to include subdirectory") } }) @@ -290,17 +365,17 @@ func TestCLIErrorHandling(t *testing.T) { }{ { name: "permission denied on output directory", - args: []string{"gen", "--output-dir", "/root/restricted"}, + args: []string{testCmdGen, "--output-dir", "/root/restricted"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 1, wantError: "encountered 1 errors during batch processing", }, { name: "invalid YAML in action file", - args: []string{"validate"}, + args: []string{testCmdValidate}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() testutil.WriteTestFile( @@ -313,22 +388,103 @@ func TestCLIErrorHandling(t *testing.T) { }, { name: "unknown output format", - args: []string{"gen", "--output-format", "unknown"}, + args: []string{testCmdGen, testFlagOutputFmt, "unknown"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 1, }, { name: "unknown theme", - args: []string{"gen", "--theme", "nonexistent-theme"}, + args: []string{testCmdGen, testFlagTheme, "nonexistent-theme"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + createTestActionFile(t, tmpDir, testutil.TestFixtureJavaScriptSimple) }, wantExit: 1, }, + // Phase 5: Additional error path tests for gen handler + { + name: "gen with empty directory (no action.yml)", + args: []string{testCmdGen}, + setupFunc: nil, // Empty directory + wantExit: 1, + wantError: "no GitHub Action files found", + }, + { + name: "gen with malformed YAML syntax", + args: []string{testCmdGen}, + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + testutil.WriteTestFile( + t, + filepath.Join(tmpDir, appconstants.ActionFileNameYML), + "name: Test\ndescription: Test\nruns: [invalid:::", + ) + }, + wantExit: 1, + wantError: "error", + }, + { + name: "gen with invalid action path", + args: []string{testCmdGen, "/nonexistent/path/action.yml"}, + setupFunc: func(t *testing.T, _ string) { + t.Helper() + }, + wantExit: 1, + wantError: "does not exist", + }, + // Phase 5: Additional error path tests for validate handler + { + name: "validate with missing required field (description)", + args: []string{testCmdValidate}, + setupFunc: setupFixtureInDir(testutil.TestFixtureInvalidMissingDescription), + wantExit: 1, + wantError: "validation failed", + }, + { + name: "validate with missing runs field", + args: []string{testCmdValidate}, + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + testutil.WriteTestFile( + t, + filepath.Join(tmpDir, appconstants.ActionFileNameYML), + "name: Test\ndescription: Test action", + ) + }, + wantExit: 1, + wantError: "validation", + }, + // Phase 5: Additional error path tests for deps commands + { + name: "deps list with no dependencies", + args: []string{testCmdDeps, testCmdList}, + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + // Create an action with no dependencies + testutil.WriteTestFile( + t, + filepath.Join(tmpDir, appconstants.ActionFileNameYML), + testActionBasic, + ) + }, + wantExit: 0, // Not an error, just no dependencies + }, + { + name: "deps list with malformed action - graceful handling", + args: []string{testCmdDeps, testCmdList}, + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + testutil.WriteTestFile( + t, + filepath.Join(tmpDir, appconstants.ActionFileNameYML), + testutil.TestInvalidYAMLPrefix, + ) + }, + wantExit: 0, // deps list handles errors gracefully + }, } for _, tt := range tests { @@ -343,9 +499,9 @@ func TestCLIErrorHandling(t *testing.T) { result := runTestCommand(binaryPath, tt.args, tmpDir) if result.exitCode != tt.wantExit { - t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode) - t.Logf(appconstants.TestMsgStdout, result.stdout) - t.Logf(appconstants.TestMsgStderr, result.stderr) + t.Errorf(testutil.TestMsgExitCode, tt.wantExit, result.exitCode) + t.Logf(testutil.TestMsgStdout, result.stdout) + t.Logf(testutil.TestMsgStderr, result.stderr) } if tt.wantError != "" { @@ -367,7 +523,7 @@ func TestCLIConfigInitialization(t *testing.T) { defer cleanup() // Test config init command - cmd := exec.Command(binaryPath, "config", "init") // #nosec G204 -- controlled test input + cmd := exec.Command(binaryPath, testCmdConfig, "init") // #nosec G204 -- controlled test input cmd.Dir = tmpDir // Set XDG_CONFIG_HOME to temp directory @@ -502,11 +658,11 @@ func TestNewGenCmd(t *testing.T) { } if cmd.Short == "" { - t.Error("expected Short description to be non-empty") + t.Error(testErrExpectedShort) } if cmd.RunE == nil && cmd.Run == nil { - t.Error("expected command to have a Run or RunE function") + t.Error(testErrExpectedRunFn) } // Check that required flags exist @@ -522,16 +678,16 @@ func TestNewValidateCmd(t *testing.T) { t.Parallel() cmd := newValidateCmd() - if cmd.Use != "validate" { + if cmd.Use != testCmdValidate { t.Errorf("expected Use to be 'validate', got %q", cmd.Use) } if cmd.Short == "" { - t.Error("expected Short description to be non-empty") + t.Error(testErrExpectedShort) } if cmd.RunE == nil && cmd.Run == nil { - t.Error("expected command to have a Run or RunE function") + t.Error(testErrExpectedRunFn) } } @@ -544,11 +700,11 @@ func TestNewSchemaCmd(t *testing.T) { } if cmd.Short == "" { - t.Error("expected Short description to be non-empty") + t.Error(testErrExpectedShort) } if cmd.RunE == nil && cmd.Run == nil { - t.Error("expected command to have a Run or RunE function") + t.Error(testErrExpectedRunFn) } } @@ -598,9 +754,9 @@ func assertCommandResult(t *testing.T, result cmdResult, wantExit int, wantStdou t.Helper() if result.exitCode != wantExit { - t.Errorf(appconstants.TestMsgExitCode, wantExit, result.exitCode) - t.Logf(appconstants.TestMsgStdout, result.stdout) - t.Logf(appconstants.TestMsgStderr, result.stderr) + t.Errorf(testutil.TestMsgExitCode, wantExit, result.exitCode) + t.Logf(testutil.TestMsgStdout, result.stdout) + t.Logf(testutil.TestMsgStderr, result.stderr) } // Check stdout if specified @@ -617,3 +773,2016 @@ func assertCommandResult(t *testing.T, result cmdResult, wantExit int, wantStdou } } } + +// Unit Tests for Handler Functions +// These test the handler logic directly without subprocess execution + +func TestCacheClearHandler(t *testing.T) { + // Handler should execute without error + // The actual cache clearing logic is tested in cache package + testSimpleHandler(t, cacheClearHandler, "cacheClearHandler") +} + +func TestCacheStatsHandler(t *testing.T) { + testSimpleHandler(t, cacheStatsHandler, "cacheStatsHandler") +} + +func TestCachePathHandler(t *testing.T) { + testSimpleHandler(t, cachePathHandler, "cachePathHandler") +} + +func TestSchemaHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + verbose bool + }{ + { + name: "non-verbose mode", + verbose: false, + }, + { + name: "verbose mode", + verbose: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(_ *testing.T) { + // Note: Cannot use t.Parallel() because test modifies shared globalConfig + + originalConfig := globalConfig + defer func() { globalConfig = originalConfig }() + + globalConfig = &internal.AppConfig{ + Quiet: true, + Verbose: tt.verbose, + Schema: "schemas/custom.json", + } + + cmd := &cobra.Command{} + schemaHandler(cmd, []string{}) + // Should not panic - output is tested via integration tests + }) + } +} + +func TestConfigThemesHandler(t *testing.T) { + testSimpleVoidHandler(t, configThemesHandler) +} + +func TestConfigShowHandler(t *testing.T) { + testSimpleVoidHandler(t, configShowHandler) +} + +func TestDepsGraphHandler(t *testing.T) { + testSimpleVoidHandler(t, depsGraphHandler) +} + +func TestCreateAnalyzer(t *testing.T) { + output := &internal.ColoredOutput{NoColor: true, Quiet: true} + config := internal.DefaultAppConfig() + generator := internal.NewGenerator(config) + + analyzer := createAnalyzer(generator, output) + + if analyzer == nil { + t.Error("createAnalyzer() returned nil") + } +} + +// Test helper functions that don't require complex setup + +func TestBuildTestBinary(t *testing.T) { + // This test verifies that buildTestBinary works + binaryPath := buildTestBinary(t) + + // Clean and validate the path + cleanedPath := filepath.Clean(binaryPath) + if strings.Contains(cleanedPath, "..") { + t.Fatalf("binary path contains .. components: %q", cleanedPath) + } + + // Check that binary exists + if _, err := os.Stat(cleanedPath); err != nil { + t.Errorf("buildTestBinary() created binary does not exist: %v", err) + } + + // Check that binary is executable + info, err := os.Stat(cleanedPath) + if err != nil { + t.Fatalf("Failed to stat binary: %v", err) + } + + // Check executable bit on Unix systems only + if runtime.GOOS != "windows" { + if info.Mode()&0111 == 0 { + t.Error("buildTestBinary() created binary is not executable") + } + } +} + +// TestApplyGlobalFlags tests global flag application. +func TestApplyGlobalFlags(t *testing.T) { + tests := []struct { + name string + verbose bool + quiet bool + wantV bool + wantQ bool + }{ + { + name: "verbose flag", + verbose: true, + quiet: false, + wantV: true, + wantQ: false, + }, + { + name: "quiet flag", + verbose: false, + quiet: true, + wantV: false, + wantQ: true, + }, + { + name: "no flags", + verbose: false, + quiet: false, + wantV: false, + wantQ: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original global flag values + origVerbose := verbose + origQuiet := quiet + defer func() { + verbose = origVerbose + quiet = origQuiet + }() + + // Set global flags to test values + verbose = tt.verbose + quiet = tt.quiet + + config := internal.DefaultAppConfig() + applyGlobalFlags(config) + + if config.Verbose != tt.wantV { + t.Errorf("Verbose = %v, want %v", config.Verbose, tt.wantV) + } + if config.Quiet != tt.wantQ { + t.Errorf("Quiet = %v, want %v", config.Quiet, tt.wantQ) + } + }) + } +} + +// TestApplyCommandFlags tests command flag application. +func TestApplyCommandFlags(t *testing.T) { + tests := []struct { + name string + theme string + format string + wantTheme string + wantFmt string + }{ + { + name: "with theme flag only", + theme: "github", + format: appconstants.OutputFormatMarkdown, // Must set format to avoid empty string + wantTheme: testThemeGitHub, + wantFmt: appconstants.OutputFormatMarkdown, + }, + { + name: "with format flag", + theme: "", + format: testFormatHTML, + wantTheme: "default", // Default from DefaultAppConfig + wantFmt: "html", + }, + { + name: "with both flags", + theme: testThemePro, + format: testFormatJSON, + wantTheme: testThemePro, + wantFmt: "json", + }, + } + + for _, tt := range tests { + config := internal.DefaultAppConfig() + cmd := &cobra.Command{} + + // Always define flags with proper defaults + cmd.Flags().String("theme", "", "") + cmd.Flags().String(appconstants.FlagOutputFormat, appconstants.OutputFormatMarkdown, "") + + if tt.theme != "" { + _ = cmd.Flags().Set("theme", tt.theme) + } + if tt.format != appconstants.OutputFormatMarkdown { + _ = cmd.Flags().Set(appconstants.FlagOutputFormat, tt.format) + } + + applyCommandFlags(cmd, config) + + if config.Theme != tt.wantTheme { + t.Errorf("%s: Theme = %v, want %v", tt.name, config.Theme, tt.wantTheme) + } + if config.OutputFormat != tt.wantFmt { + t.Errorf("%s: OutputFormat = %v, want %v", tt.name, config.OutputFormat, tt.wantFmt) + } + } +} + +// TestValidateGitHubToken tests GitHub token validation. +func TestValidateGitHubToken(t *testing.T) { + tests := []struct { + name string + token string + want bool + }{ + { + name: "with valid token", + token: "ghp_test_token_123", + want: true, + }, + { + name: "with empty token", + token: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original global config + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Set test token + globalConfig = &internal.AppConfig{ + GitHubToken: tt.token, + Quiet: true, + } + + output := createOutputManager(true) + got := validateGitHubToken(output) + + if got != tt.want { + t.Errorf("validateGitHubToken() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestLogConfigInfo tests configuration info logging. +func TestLogConfigInfo(_ *testing.T) { + tests := []struct { + name string + verbose bool + repoRoot string + }{ + { + name: "verbose with repo root", + verbose: true, + repoRoot: "/path/to/repo", + }, + { + name: "verbose without repo root", + verbose: true, + repoRoot: "", + }, + { + name: "not verbose", + verbose: false, + repoRoot: "/path/to/repo", + }, + } + + for _, tt := range tests { + config := &internal.AppConfig{ + Verbose: tt.verbose, + Quiet: true, + } + generator := internal.NewGenerator(config) + + // Just call it to ensure it doesn't panic + logConfigInfo(generator, config, tt.repoRoot) + } +} + +// TestShowUpgradeMode tests upgrade mode display. +func TestShowUpgradeMode(_ *testing.T) { + tests := []struct { + name string + ciMode bool + isPinCmd bool + wantEmpty bool + }{ + { + name: "CI mode", + ciMode: true, + isPinCmd: false, + wantEmpty: false, + }, + { + name: "pin command", + ciMode: false, + isPinCmd: true, + wantEmpty: false, + }, + { + name: "interactive mode", + ciMode: false, + isPinCmd: false, + wantEmpty: false, + }, + } + + for _, tt := range tests { + output := createOutputManager(true) + // Just call it to ensure it doesn't panic + showUpgradeMode(output, tt.ciMode, tt.isPinCmd) + } +} + +// TestDisplayOutdatedResults tests outdated dependencies display. +func TestDisplayOutdatedResults(_ *testing.T) { + tests := []struct { + name string + allOutdated []dependencies.OutdatedDependency + }{ + { + name: "no outdated dependencies", + allOutdated: []dependencies.OutdatedDependency{}, + }, + { + name: "with outdated dependencies", + allOutdated: []dependencies.OutdatedDependency{ + { + Current: dependencies.Dependency{ + Name: testutil.TestActionCheckout, + Version: "v3", + }, + LatestVersion: "v4", + UpdateType: "major", + }, + }, + }, + { + name: "with security update", + allOutdated: []dependencies.OutdatedDependency{ + { + Current: dependencies.Dependency{ + Name: "actions/setup-node", + Version: "v3", + }, + LatestVersion: "v4", + UpdateType: "major", + IsSecurityUpdate: true, + }, + }, + }, + } + + for _, tt := range tests { + output := createOutputManager(true) + // Just call it to ensure it doesn't panic + displayOutdatedResults(output, tt.allOutdated) + } +} + +// TestDisplayFloatingDeps tests floating dependencies display. +func TestDisplayFloatingDeps(_ *testing.T) { + + output := createOutputManager(true) + floatingDeps := []struct { + file string + dep dependencies.Dependency + }{ + { + file: testutil.TestTmpActionFile, + dep: dependencies.Dependency{ + Name: testutil.TestActionCheckout, + Version: "v4", + }, + }, + } + + // Just call it to ensure it doesn't panic + displayFloatingDeps(output, "/tmp", floatingDeps) +} + +// TestDisplaySecuritySummary tests security summary display. +func TestDisplaySecuritySummary(_ *testing.T) { + tests := []struct { + name string + pinnedCount int + floatingDeps []struct { + file string + dep dependencies.Dependency + } + }{ + { + name: "all pinned", + pinnedCount: 5, + floatingDeps: nil, + }, + { + name: "with floating dependencies", + pinnedCount: 3, + floatingDeps: []struct { + file string + dep dependencies.Dependency + }{ + { + file: testutil.TestTmpActionFile, + dep: dependencies.Dependency{ + Name: testutil.TestActionCheckout, + Version: "v4", + }, + }, + }, + }, + { + name: "no dependencies", + pinnedCount: 0, + floatingDeps: nil, + }, + } + + for _, tt := range tests { + output := createOutputManager(true) + // Just call it to ensure it doesn't panic + displaySecuritySummary(output, "/tmp", tt.pinnedCount, tt.floatingDeps) + } +} + +// TestShowPendingUpdates tests displaying pending dependency updates. +func TestShowPendingUpdates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + updates []dependencies.PinnedUpdate + currentDir string + }{ + { + name: "no updates", + updates: []dependencies.PinnedUpdate{}, + currentDir: "/tmp", + }, + { + name: "single update", + updates: []dependencies.PinnedUpdate{ + { + FilePath: testutil.TestTmpActionFile, + OldUses: testutil.TestActionCheckoutV3, + NewUses: testutil.TestActionCheckoutV4, + UpdateType: "major", + }, + }, + currentDir: "/tmp", + }, + { + name: "multiple updates", + updates: []dependencies.PinnedUpdate{ + { + FilePath: testutil.TestTmpActionFile, + OldUses: testutil.TestActionCheckoutV3, + NewUses: testutil.TestActionCheckoutV4, + UpdateType: "major", + }, + { + FilePath: "/tmp/workflow.yml", + OldUses: "actions/setup-node@v2", + NewUses: testutil.TestActionSetupNodeV3, + UpdateType: "major", + }, + }, + currentDir: "/tmp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + output := createOutputManager(true) + // Just call it to ensure it doesn't panic + showPendingUpdates(output, tt.updates, tt.currentDir) + }) + } +} + +// TestAnalyzeActionFileDeps tests action file dependency analysis. +func TestAnalyzeActionFileDeps(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) (string, *dependencies.Analyzer) + wantDepCnt int + }{ + { + name: "nil analyzer returns 0", + setupFunc: func(t *testing.T) (string, *dependencies.Analyzer) { + t.Helper() + + return testutil.TestTmpActionFile, nil + }, + wantDepCnt: 0, + }, + { + name: "action with dependencies", + setupFunc: func(t *testing.T) (string, *dependencies.Analyzer) { + t.Helper() + tmpDir := t.TempDir() + actionFile := testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureCompositeMultipleNamedSteps) + + // Create a basic analyzer without GitHub client + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + + return actionFile, analyzer + }, + wantDepCnt: 2, // 2 uses statements + }, + { + name: "action without dependencies", + setupFunc: func(t *testing.T) (string, *dependencies.Analyzer) { + t.Helper() + tmpDir := t.TempDir() + actionFile := testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + + // Create a basic analyzer without GitHub client + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + + return actionFile, analyzer + }, + wantDepCnt: 0, + }, + { + name: "invalid action file", + setupFunc: func(t *testing.T) (string, *dependencies.Analyzer) { + t.Helper() + tmpDir := t.TempDir() + actionFile := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + // Write invalid YAML (unclosed bracket) + if err := os.WriteFile(actionFile, []byte(testutil.TestInvalidYAMLPrefix), 0600); err != nil { + t.Fatalf("Failed to write invalid action file: %v", err) + } + + // Create a basic analyzer without GitHub client + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + + return actionFile, analyzer + }, + wantDepCnt: 0, // Returns 0 on error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actionFile, analyzer := tt.setupFunc(t) + output := createOutputManager(true) + + got := analyzeActionFileDeps(output, actionFile, analyzer) + if got != tt.wantDepCnt { + t.Errorf("analyzeActionFileDeps() = %v, want %v", got, tt.wantDepCnt) + } + }) + } +} + +// TestNewConfigCmd tests config command creation. +// verifySubcommandsExist checks that all expected subcommands exist in the command. +func verifySubcommandsExist(t *testing.T, cmd *cobra.Command, expectedSubcommands []string) { + t.Helper() + subcommands := cmd.Commands() + + if len(subcommands) < len(expectedSubcommands) { + t.Errorf("newConfigCmd() has %d subcommands, want at least %d", len(subcommands), len(expectedSubcommands)) + } + + // Verify each expected subcommand exists + for _, expected := range expectedSubcommands { + found := false + for _, sub := range subcommands { + if sub.Use == expected { + found = true + + break + } + } + if !found { + t.Errorf("newConfigCmd() missing subcommand: %s", expected) + } + } +} + +func TestNewConfigCmd(t *testing.T) { + // Note: Cannot use t.Parallel() because test modifies shared globalConfig + + // Save original global config + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + globalConfig = &internal.AppConfig{ + Quiet: true, + } + + t.Run("creates command with correct properties", func(t *testing.T) { + cmd := newConfigCmd() + if cmd == nil { + t.Fatal("newConfigCmd() returned nil") + } + if cmd.Use != testCmdConfig { + t.Errorf("newConfigCmd().Use = %v, want 'config'", cmd.Use) + } + }) + + t.Run("has all expected subcommands", func(t *testing.T) { + cmd := newConfigCmd() + expectedSubcommands := []string{"init", "wizard", testCmdShow, "themes"} + verifySubcommandsExist(t, cmd, expectedSubcommands) + }) + + t.Run("wizard subcommand has required flags", func(t *testing.T) { + cmd := newConfigCmd() + wizardCmd, _, err := cmd.Find([]string{"wizard"}) + if err != nil { + t.Fatalf("Failed to find wizard subcommand: %v", err) + } + if wizardCmd == nil { + t.Fatal("wizard subcommand is nil") + } + + if wizardCmd.Flags().Lookup(appconstants.FlagFormat) == nil { + t.Error("wizard subcommand missing --format flag") + } + if wizardCmd.Flags().Lookup(appconstants.FlagOutput) == nil { + t.Error("wizard subcommand missing --output flag") + } + }) +} + +// TestNewDepsCmd tests deps command creation. +func TestNewDepsCmd(t *testing.T) { + + cmd := newDepsCmd() + if cmd == nil { + t.Fatal("newDepsCmd() returned nil") + } + if cmd.Use != testCmdDeps { + t.Errorf("newDepsCmd().Use = %v, want 'deps'", cmd.Use) + } +} + +// TestNewCacheCmd tests cache command creation. +func TestNewCacheCmd(t *testing.T) { + + cmd := newCacheCmd() + if cmd == nil { + t.Fatal("newCacheCmd() returned nil") + } + if cmd.Use != "cache" { + t.Errorf("newCacheCmd().Use = %v, want 'cache'", cmd.Use) + } +} + +// TestGenHandlerIntegration tests genHandler with various scenarios. +// Note: Not using t.Parallel() because these tests modify shared globalConfig. +func TestGenHandlerIntegration(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantErr bool + setFlags func(cmd *cobra.Command) + }{ + { + name: "generates README from valid action in current dir", + setupFunc: setupWithSingleFixture(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + }, + { + name: "generates HTML output", + setupFunc: setupWithSingleFixture(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set(appconstants.FlagOutputFormat, testFormatHTML) + }, + }, + { + name: "generates JSON output", + setupFunc: setupWithSingleFixture(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set(appconstants.FlagOutputFormat, testFormatJSON) + }, + }, + { + name: "generates with theme override", + setupFunc: setupWithSingleFixture(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("theme", testThemeGitHub) + }, + }, + { + name: "processes composite action", + setupFunc: setupWithSingleFixture(testutil.TestFixtureCompositeBasic), + wantErr: false, + }, + { + name: "processes docker action", + setupFunc: setupWithSingleFixture(testutil.TestFixtureDockerBasic), + wantErr: false, + }, + { + name: "processes action with custom output file", + setupFunc: setupWithSingleFixture(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("output", "custom-readme.md") + }, + }, + { + name: "recursive processing with subdirectories", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + testutil.CreateActionSubdir(t, tmpDir, testutil.TestDirSubdir, testutil.TestFixtureCompositeBasic) + + return []string{tmpDir} + }, + wantErr: false, + setFlags: func(cmd *cobra.Command) { + _ = cmd.Flags().Set("recursive", "true") + }, + }, + { + name: "processes specific action file", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + + return []string{filepath.Join(tmpDir, appconstants.ActionFileNameYML)} + }, + wantErr: false, + }, + // Error scenarios using fixtures + createFixtureTestCaseWithPaths( + "returns error for invalid YAML syntax", + testutil.TestErrorScenarioInvalidYAML, + true, + ), + createFixtureTestCaseWithPaths( + "returns error for missing required fields", + testutil.TestErrorScenarioMissingFields, + true, + ), + { + name: "returns error for empty directory with no action files", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + // Don't write any action file - directory is empty + return []string{tmpDir} + }, + wantErr: true, + }, + { + name: "returns error for nonexistent path", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + + return []string{filepath.Join(tmpDir, "nonexistent")} + }, + wantErr: true, + }, + // Empty steps is valid + createFixtureTestCaseWithPaths( + "handles empty action file gracefully", + testutil.TestFixtureEmptyAction, + false, + ), + // Old deps don't cause generation to fail + createFixtureTestCaseWithPaths( + "processes action with outdated dependencies", + testutil.TestErrorScenarioOldDeps, + false, + ), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment and get args + var args []string + if tt.setupFunc != nil { + args = tt.setupFunc(t, tmpDir) + } + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + + // Create command and set flags + cmd := newGenCmd() + if tt.setFlags != nil { + tt.setFlags(cmd) + } + + // Execute handler - now returns error instead of os.Exit + err := genHandler(cmd, args) + if (err != nil) != tt.wantErr { + t.Errorf("genHandler() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestValidateHandlerIntegration tests validateHandler with various scenarios. +// Note: Not using t.Parallel() because these tests modify shared globalConfig. +func TestValidateHandlerIntegration(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) + wantErr bool + }{ + { + name: "validates valid action successfully", + setupFunc: setupFixtureInDir(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + }, + { + name: "validates composite action", + setupFunc: setupFixtureInDir(testutil.TestFixtureCompositeBasic), + wantErr: false, + }, + { + name: "validates docker action", + setupFunc: setupFixtureInDir(testutil.TestFixtureDockerBasic), + wantErr: false, + }, + { + name: "validates multiple actions recursively", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + testutil.CreateActionSubdir(t, tmpDir, testutil.TestDirSubdir, testutil.TestFixtureCompositeBasic) + }, + wantErr: false, + }, + // Error scenarios using fixtures + createFixtureTestCase( + "returns error for invalid YAML syntax", + testutil.TestErrorScenarioInvalidYAML, + true, + ), + createFixtureTestCase( + "returns error for missing required fields", + testutil.TestErrorScenarioMissingFields, + true, + ), + // Outdated dependencies don't fail validation + createFixtureTestCase( + "validates action with outdated dependencies", + testutil.TestErrorScenarioOldDeps, + false, + ), + { + name: "returns error for empty directory with no action files", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // Don't write any action file - directory is empty + }, + wantErr: true, + }, + { + name: "validates empty action file with no steps", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + fixtureContent := testutil.MustReadFixture(testutil.TestFixtureEmptyAction) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + wantErr: false, // Empty steps is valid YAML structure + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment BEFORE changing directory + // (so setupFunc can access testdata/ fixtures in project root) + if tt.setupFunc != nil { + tt.setupFunc(t, tmpDir) + } + + // Change to temp directory for validation + t.Chdir(tmpDir) + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + + // Create command + cmd := newValidateCmd() + + // Execute handler - now returns error instead of os.Exit + err := validateHandler(cmd, []string{}) + if (err != nil) != tt.wantErr { + t.Errorf("validateHandler() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestConfigInitHandlerIntegration tests configInitHandler. +// Note: This test is limited because configInitHandler uses internal.GetConfigPath() +// which uses the real XDG config directory. Full integration testing is done via +// subprocess tests in TestCLIConfigInitialization. +func TestConfigInitHandlerIntegration(t *testing.T) { + // Skip parallelization as we need to manipulate global config path + // which is shared state + + tests := []struct { + name string + setupFunc func(t *testing.T) string + wantErr bool + validate func(t *testing.T, tmpDir string, err error) + }{ + { + name: "creates config when not exists", + setupFunc: func(t *testing.T) string { + t.Helper() + + return t.TempDir() + }, + wantErr: false, + validate: func(t *testing.T, _ string, err error) { + t.Helper() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + // Note: Since configInitHandler uses internal.GetConfigPath() which points to real + // user config directory, we can only verify no error occurred. + // File creation is tested in subprocess tests. + }, + }, + { + name: "handles existing config gracefully", + setupFunc: func(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + // Create a config file first + configPath, err := internal.GetConfigPath() + testutil.AssertNoError(t, err) + // If config exists, handler should return nil (not error) + _ = configPath + + return tmpDir + }, + wantErr: false, // Handler returns nil when config exists, just warns + validate: func(t *testing.T, _ string, err error) { + t.Helper() + // No error expected - handler just warns if config exists + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + + tmpDir := tt.setupFunc(t) + + // Create command + cmd := &cobra.Command{} + + // Execute handler + err := configInitHandler(cmd, []string{}) + + if (err != nil) != tt.wantErr { + t.Errorf("configInitHandler() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.validate != nil { + tt.validate(t, tmpDir, err) + } + }) + } +} + +// TestLoadGenConfigIntegration tests loadGenConfig configuration loading. +func TestLoadGenConfigIntegration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) (repoRoot, currentDir string) + wantTheme string + }{ + { + name: "loads default config", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + t.Helper() + + return tmpDir, tmpDir + }, + wantTheme: "default", + }, + { + name: "loads repo-specific config", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + t.Helper() + configContent := "theme: professional\noutput_format: html\n" + testutil.WriteTestFile(t, filepath.Join(tmpDir, ".ghreadme.yaml"), configContent) + + return tmpDir, tmpDir + }, + wantTheme: testThemePro, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment + repoRoot, currentDir := tt.setupFunc(t, tmpDir) + + // Load config + config, err := loadGenConfig(repoRoot, currentDir) + if err != nil { + t.Fatalf("loadGenConfig() error = %v", err) + } + + if config == nil { + t.Fatal("loadGenConfig() returned nil") + } + + if config.Theme != tt.wantTheme { + t.Errorf("loadGenConfig() theme = %v, want %v", config.Theme, tt.wantTheme) + } + }) + } +} + +// TestProcessActionFilesIntegration tests processActionFiles batch processing. +func TestProcessActionFilesIntegration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantErr bool + }{ + { + name: "processes single action file", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + actionPath := testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + + return []string{actionPath} + }, + wantErr: false, + }, + { + name: "processes multiple action files", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + action1 := testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple) + action2 := testutil.CreateActionSubdir( + t, + tmpDir, + testutil.TestDirSubdir, + testutil.TestFixtureCompositeBasic, + ) + + return []string{action1, action2} + }, + wantErr: false, + }, + // Note: "handles empty file list" case removed as it calls os.Exit + // when there are no files to process. This scenario is tested via + // subprocess tests in TestCLICommands instead. + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment + actionFiles := tt.setupFunc(t, tmpDir) + + // Create generator with test config + config := internal.DefaultAppConfig() + config.Quiet = true + generator := internal.NewGenerator(config) + + // Execute handler - just test that it doesn't panic + defer func() { + if r := recover(); r != nil && !tt.wantErr { + t.Errorf("processActionFiles() unexpected panic: %v", r) + } + }() + + err := processActionFiles(generator, actionFiles) + testutil.AssertNoError(t, err) + }) + } +} + +// TestDepsListHandlerIntegration tests depsListHandler. +func TestDepsListHandlerIntegration(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) + wantErr bool + }{ + { + name: "lists dependencies from composite action", + setupFunc: setupFixtureInDir(testutil.TestFixtureCompositeWithDeps), + wantErr: false, + }, + { + name: testutil.TestScenarioNoDeps, + setupFunc: setupFixtureInDir(testutil.TestFixtureJavaScriptSimple), + wantErr: false, + }, + { + name: "handles no action files", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // No action files + }, + wantErr: false, + }, + // Error scenarios using fixtures + // depsListHandler shows warning but returns nil + createFixtureTestCase( + "handles invalid YAML syntax with warning", + testutil.TestErrorScenarioInvalidYAML, + false, + ), + // depsListHandler shows warning but returns nil + createFixtureTestCase( + "handles missing required fields with warning", + testutil.TestErrorScenarioMissingFields, + false, + ), + // Should successfully list the outdated deps + createFixtureTestCase( + "lists dependencies from action with outdated deps", + testutil.TestErrorScenarioOldDeps, + false, + ), + { + name: "handles multiple action files recursively", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + // Create main action + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureCompositeWithDeps) + // Create subdirectory with another action + subdir := filepath.Join(tmpDir, "subaction") + testutil.AssertNoError(t, os.MkdirAll(subdir, 0750)) + fixtureContent := testutil.MustReadFixture(testutil.TestErrorScenarioOldDeps) + testutil.WriteTestFile(t, filepath.Join(subdir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + wantErr: false, // Should list deps from both actions + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment BEFORE changing directory + // (so setupFunc can access testdata/ fixtures in project root) + if tt.setupFunc != nil { + tt.setupFunc(t, tmpDir) + } + + // Change to temp directory + t.Chdir(tmpDir) + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + globalConfig.Quiet = true + + // Execute handler - now returns error instead of os.Exit + err := depsListHandler(&cobra.Command{}, []string{}) + if (err != nil) != tt.wantErr { + t.Errorf("depsListHandler() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestDepsSecurityHandlerIntegration tests depsSecurityHandler. +func TestDepsSecurityHandlerIntegration(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) + setToken bool + wantErr bool + }{ + { + name: "analyzes security with GitHub token", + setupFunc: setupFixtureInDir(testutil.TestFixtureCompositeWithDeps), + setToken: true, + wantErr: false, + }, + { + name: testutil.TestScenarioNoDeps, + setupFunc: setupFixtureInDir(testutil.TestFixtureJavaScriptSimple), + setToken: true, + wantErr: false, + }, + { + name: "handles invalid YAML syntax gracefully", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + fixtureContent := testutil.MustReadFixture(testutil.TestErrorScenarioInvalidYAML) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + setToken: true, + wantErr: false, // depsSecurityHandler handles YAML errors gracefully + }, + { + name: "handles missing required fields gracefully", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + fixtureContent := testutil.MustReadFixture(testutil.TestErrorScenarioMissingFields) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + setToken: true, + wantErr: false, // depsSecurityHandler handles YAML errors gracefully + }, + { + name: "analyzes action with outdated dependencies", + setupFunc: func(t *testing.T, tmpDir string) { + t.Helper() + fixtureContent := testutil.MustReadFixture(testutil.TestErrorScenarioOldDeps) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.ActionFileNameYML), string(fixtureContent)) + }, + setToken: true, + wantErr: false, + }, + { + name: "returns error for no action files", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // Don't create any action files + }, + setToken: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Setup test environment BEFORE changing directory + // (so setupFunc can access testdata/ fixtures in project root) + if tt.setupFunc != nil { + tt.setupFunc(t, tmpDir) + } + + // Change to temp directory + t.Chdir(tmpDir) + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + globalConfig.Quiet = true + if tt.setToken { + globalConfig.GitHubToken = testutil.TestTokenValue + } + + // Execute handler - now returns error instead of os.Exit + err := depsSecurityHandler(&cobra.Command{}, []string{}) + if (err != nil) != tt.wantErr { + t.Errorf("depsSecurityHandler() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestDepsOutdatedHandlerIntegration tests depsOutdatedHandler. +func TestDepsOutdatedHandlerIntegration(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) + setToken bool + wantErr bool + }{ + { + name: "checks outdated with GitHub token", + setupFunc: setupFixtureInDir(testutil.TestFixtureCompositeWithDeps), + setToken: true, + wantErr: false, + }, + { + name: "handles no action files", + setupFunc: func(t *testing.T, _ string) { + t.Helper() + // No action files + }, + setToken: true, + wantErr: false, + }, + { + name: "handles missing GitHub token", + setupFunc: setupFixtureInDir(testutil.TestFixtureCompositeWithDeps), + setToken: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Not using t.Parallel() because these tests modify shared globalConfig + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory and change to it + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + t.Chdir(tmpDir) + + // Setup test environment + if tt.setupFunc != nil { + tt.setupFunc(t, tmpDir) + } + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + globalConfig.Quiet = true + if tt.setToken { + globalConfig.GitHubToken = testutil.TestTokenValue + } + + // Execute handler - now returns error instead of os.Exit + err := depsOutdatedHandler(&cobra.Command{}, []string{}) + if (err != nil) != tt.wantErr { + t.Errorf("depsOutdatedHandler() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestConfigWizardHandlerIntegration tests configWizardHandler. +func TestConfigWizardHandlerIntegration(t *testing.T) { + // Note: This is a limited test as wizard requires interactive input + // Full wizard testing is done in the wizard package + + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Create temp directory + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Set XDG_CONFIG_HOME to temp directory + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + // Initialize global config + globalConfig = internal.DefaultAppConfig() + globalConfig.Quiet = true + + // Create command with output flag pointing to temp file + cmd := &cobra.Command{} + cmd.Flags().String("format", "yaml", "") + outputPath := filepath.Join(tmpDir, "test-config.yaml") + cmd.Flags().String("output", outputPath, "") + + // Note: We can't fully test wizard handler without mocking stdin + // The wizard requires interactive input which is tested in wizard package + // This test just ensures the handler doesn't panic on setup +} + +// Phase 6: Tests for zero-coverage business logic functions + +// TestCheckAllOutdated tests the checkAllOutdated function. +func TestCheckAllOutdated(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + mockAnalyzer bool + wantOutdatedCnt int + wantErr bool + }{ + { + name: "finds outdated dependencies", + setupFunc: setupFixtureReturningPath(testutil.TestFixtureCompositeWithDeps), + mockAnalyzer: true, + wantOutdatedCnt: 0, // Mock analyzer will return no outdated deps + }, + { + name: testutil.TestScenarioNoDeps, + setupFunc: setupWithActionContent(testActionBasic), + mockAnalyzer: true, + wantOutdatedCnt: 0, + }, + { + name: "handles multiple action files", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + action1 := filepath.Join(tmpDir, testutil.TestFileAction1) + action2 := filepath.Join(tmpDir, testutil.TestFileAction2) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureCompositeWithDeps) + _ = os.Rename(filepath.Join(tmpDir, appconstants.ActionFileNameYML), action1) + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureCompositeBasic) + _ = os.Rename(filepath.Join(tmpDir, appconstants.ActionFileNameYML), action2) + + return []string{action1, action2} + }, + mockAnalyzer: true, + wantOutdatedCnt: 0, + }, + { + name: "handles invalid action file gracefully", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, actionPath, testutil.TestInvalidYAMLPrefix) + + return []string{actionPath} + }, + mockAnalyzer: true, + wantOutdatedCnt: 0, // Should handle error and return empty list + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionFiles := tt.setupFunc(t, tmpDir) + + output := createOutputManager(true) // quiet mode + analyzer := &dependencies.Analyzer{} // Basic analyzer without GitHub token + + outdated := checkAllOutdated(output, actionFiles, analyzer) + + if len(outdated) != tt.wantOutdatedCnt { + t.Errorf("checkAllOutdated() returned %d outdated deps, want %d", + len(outdated), tt.wantOutdatedCnt) + } + }) + } +} + +// TestAnalyzeSecurityDeps tests the analyzeSecurityDeps function. +func TestAnalyzeSecurityDeps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantPinned int + }{ + { + name: "analyzes action with dependencies", + setupFunc: setupFixtureReturningPath(testutil.TestFixtureCompositeWithDeps), + wantPinned: 2, // TestFixtureCompositeWithDeps has 2 pinned dependencies + }, + { + name: testutil.TestScenarioNoDeps, + setupFunc: setupWithActionContent(testActionBasic), + wantPinned: 0, + }, + { + name: "handles multiple action files", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + action1 := filepath.Join(tmpDir, testutil.TestFileAction1) + action2 := filepath.Join(tmpDir, testutil.TestFileAction2) + + testutil.WriteTestFile( + t, + action1, + "name: Test1\ndescription: Test1\nruns:\n using: composite\n steps:\n - uses: actions/checkout@v4", + ) + testutil.WriteTestFile( + t, + action2, + "name: Test2\ndescription: Test2\nruns:\n using: composite\n steps:\n - uses: actions/setup-node@v3", + ) + + return []string{action1, action2} + }, + wantPinned: 0, // Without GitHub token, won't verify pins + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionFiles := tt.setupFunc(t, tmpDir) + + output := createOutputManager(true) // quiet mode + analyzer := &dependencies.Analyzer{} // Basic analyzer without GitHub token + + pinnedCount, _ := analyzeSecurityDeps(output, actionFiles, analyzer) + + if pinnedCount != tt.wantPinned { + t.Errorf("analyzeSecurityDeps() returned %d pinned deps, want %d", + pinnedCount, tt.wantPinned) + } + }) + } +} + +// TestCollectAllUpdates tests the collectAllUpdates function. +func TestCollectAllUpdates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) []string + wantUpdateCnt int + }{ + { + name: "collects updates from single action", + setupFunc: setupFixtureReturningPath(testutil.TestFixtureCompositeWithDeps), + wantUpdateCnt: 0, // Without GitHub token, won't fetch updates + }, + { + name: "collects from multiple actions", + setupFunc: func(t *testing.T, tmpDir string) []string { + t.Helper() + action1 := filepath.Join(tmpDir, testutil.TestFileAction1) + action2 := filepath.Join(tmpDir, testutil.TestFileAction2) + + testutil.WriteTestFile( + t, + action1, + "name: Test1\ndescription: Test1\nruns:\n using: composite\n steps:\n - uses: actions/checkout@v3", + ) + testutil.WriteTestFile( + t, + action2, + "name: Test2\ndescription: Test2\nruns:\n using: composite\n steps:\n - uses: actions/setup-node@v2", + ) + + return []string{action1, action2} + }, + wantUpdateCnt: 0, // Without GitHub token, won't fetch updates + }, + { + name: testutil.TestScenarioNoDeps, + setupFunc: setupWithActionContent(testActionBasic), + wantUpdateCnt: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + actionFiles := tt.setupFunc(t, tmpDir) + + output := createOutputManager(true) // quiet mode + analyzer := &dependencies.Analyzer{} // Basic analyzer without GitHub token + + updates := collectAllUpdates(output, analyzer, actionFiles) + + if len(updates) != tt.wantUpdateCnt { + t.Errorf("collectAllUpdates() returned %d updates, want %d", + len(updates), tt.wantUpdateCnt) + } + }) + } +} + +// TestWrapError tests the wrapError helper function. +func TestWrapError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msgConstant string + err error + wantContains []string + }{ + { + name: "wraps error with message constant", + msgConstant: "operation failed", + err: errors.New("original error"), + wantContains: []string{ + "operation failed", + "original error", + }, + }, + { + name: "handles empty message constant", + msgConstant: "", + err: errors.New("test error"), + wantContains: []string{ + "test error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := wrapError(tt.msgConstant, tt.err) + if result == nil { + t.Fatal("wrapError() returned nil, want error") + } + + resultStr := result.Error() + for _, want := range tt.wantContains { + if !strings.Contains(resultStr, want) { + t.Errorf("wrapError() = %q, want to contain %q", resultStr, want) + } + } + + // Verify it's a wrapped error + if !errors.Is(result, tt.err) { + t.Errorf("wrapError() did not wrap original error properly") + } + }) + } +} + +// TestWrapHandlerWithErrorHandling tests the wrapper function for handlers. +func TestWrapHandlerWithErrorHandling(t *testing.T) { + // Save and restore global state + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + tests := []struct { + name string + handler func(*cobra.Command, []string) error + wantErr bool + }{ + { + name: "handler returns nil - no error", + handler: func(_ *cobra.Command, _ []string) error { + return nil + }, + wantErr: false, + }, + { + name: "initializes globalConfig if nil before calling handler", + handler: func(_ *cobra.Command, _ []string) error { + // Verify globalConfig was initialized by wrapper + if globalConfig == nil { + return errors.New("globalConfig is nil in handler") + } + + return nil + }, + wantErr: false, + }, + // Note: Cannot test error path because wrapHandlerWithErrorHandling calls os.Exit(1) + // which would terminate the test process. Error path is tested via subprocess tests. + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set globalConfig to nil to test initialization + if tt.name == "initializes globalConfig if nil before calling handler" { + globalConfig = nil + } else { + globalConfig = internal.DefaultAppConfig() + } + + cmd := &cobra.Command{} + wrapped := wrapHandlerWithErrorHandling(tt.handler) + + // Execute wrapped handler (should not panic) + wrapped(cmd, []string{}) + + // Verify globalConfig was initialized + if globalConfig == nil { + t.Error("wrapHandlerWithErrorHandling() did not initialize globalConfig") + } + }) + } +} + +func TestApplyUpdates(t *testing.T) { + t.Parallel() + + // Test cases that don't require calling ApplyPinnedUpdates (user cancellation) + t.Run("interactive mode cancellation", func(t *testing.T) { + tests := []struct { + name string + response string + }{ + {name: "response 'n' cancels", response: "n"}, + {name: "response 'no' cancels", response: "no"}, + {name: "empty response cancels", response: ""}, + {name: "random text cancels", response: "random"}, + {name: "uppercase N cancels", response: "N"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create test reader with response + reader := &TestInputReader{responses: []string{tt.response}} + + // Create minimal analyzer (won't be used since we're canceling) + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + + output := createOutputManager(true) // Quiet mode for tests + updates := []dependencies.PinnedUpdate{ + {OldUses: testutil.TestActionCheckoutV3, NewUses: testutil.TestActionCheckoutV4}, + } + + // Execute function - should not call ApplyPinnedUpdates + err := applyUpdates(output, analyzer, updates, false, reader) + + // Should not error when user cancels + if err != nil { + t.Errorf("applyUpdates() with cancel should not error, got: %v", err) + } + + // Verify reader was used + if reader.index != 1 { + t.Errorf("InputReader was not used, index = %d, want 1", reader.index) + } + }) + } + }) + + // Test automatic mode bypasses prompting + t.Run("automatic mode bypasses prompting", func(t *testing.T) { + t.Parallel() + + // Create minimal analyzer + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + + // Create temp directory for test action file + tmpDir := t.TempDir() + actionFile := testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureActionWithCheckoutV3) + + output := createOutputManager(true) + updates := []dependencies.PinnedUpdate{ + { + OldUses: testutil.TestActionCheckoutV3, + NewUses: "actions/checkout@abc123", + FilePath: actionFile, + }, + } + + // Call with automatic=true, reader should not be used (can pass nil) + err := applyUpdates(output, analyzer, updates, true, nil) + + // May error due to nil github client or other reasons, but that's expected + // The important thing is it didn't block on stdin prompting the user + _ = err // Accept any result for this integration test + }) + + // Test that InputReader is used when provided + t.Run("InputReader is used in interactive mode", func(t *testing.T) { + t.Parallel() + + // Create test reader + reader := &TestInputReader{responses: []string{"n"}} + + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + output := createOutputManager(true) + updates := []dependencies.PinnedUpdate{{OldUses: "old", NewUses: "new"}} + + _ = applyUpdates(output, analyzer, updates, false, reader) + + // Verify reader was actually used (index should be 1 after reading first response) + if reader.index != 1 { + t.Errorf("InputReader was not used, index = %d, want 1", reader.index) + } + }) + + // Test that default StdinReader is used when reader is nil + t.Run("defaults to StdinReader when reader is nil", func(t *testing.T) { + t.Parallel() + + // This test verifies the nil check works, but can't test actual stdin + // Just verify the function accepts nil and doesn't panic + + analyzer := dependencies.NewAnalyzer(nil, git.RepoInfo{}, nil) + output := createOutputManager(true) + updates := []dependencies.PinnedUpdate{{OldUses: "old", NewUses: "new"}} + + // With automatic=true and nil reader, should not prompt + err := applyUpdates(output, analyzer, updates, true, nil) + + // May error, but shouldn't panic from nil reader + _ = err + }) +} + +func TestSetupDepsUpgrade(t *testing.T) { + // Note: Cannot use t.Parallel() because one subtest modifies shared globalConfig + + tests := []struct { + name string + setupFunc func(t *testing.T) (string, *internal.AppConfig) + wantErr bool + errContain string + }{ + { + name: testutil.TestMsgNoGitHubToken, + setupFunc: func(t *testing.T) (string, *internal.AppConfig) { + t.Helper() + tmpDir := t.TempDir() + // Create a valid action file + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureActionWithCheckoutV4) + + config := internal.DefaultAppConfig() + config.GitHubToken = "" // No token + + return tmpDir, config + }, + wantErr: true, + errContain: "no GitHub token", + }, + { + name: "succeeds with valid token and action files", + setupFunc: func(t *testing.T) (string, *internal.AppConfig) { + t.Helper() + tmpDir := t.TempDir() + // Create a valid action file + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureActionWithCheckoutV4) + + config := internal.DefaultAppConfig() + config.GitHubToken = "test-token-123" + + return tmpDir, config + }, + wantErr: false, + }, + { + name: "returns error when no action files found", + setupFunc: func(t *testing.T) (string, *internal.AppConfig) { + t.Helper() + tmpDir := t.TempDir() // Empty directory + config := internal.DefaultAppConfig() + config.GitHubToken = "test-token-123" + + return tmpDir, config + }, + wantErr: true, + errContain: "no action files", + }, + { + name: testMsgUsesGlobalCfg, + setupFunc: func(t *testing.T) (string, *internal.AppConfig) { + t.Helper() + tmpDir := t.TempDir() + // Create a valid action file + testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureActionWithCheckoutV4) + + // Set globalConfig instead of passing config + origConfig := globalConfig + globalConfig = internal.DefaultAppConfig() + globalConfig.GitHubToken = "test-token-from-global" + t.Cleanup(func() { globalConfig = origConfig }) + + return tmpDir, nil // Pass nil config + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Cannot use t.Parallel() for testMsgUsesGlobalCfg + // because it mutates shared globalConfig + if tt.name != testMsgUsesGlobalCfg { + t.Parallel() + } + + currentDir, config := tt.setupFunc(t) + output := createOutputManager(true) + + _, _, err := setupDepsUpgrade(output, currentDir, config) + + validateDepsUpgradeError(t, err, tt.wantErr, tt.errContain) + }) + } +} + +// validateDepsUpgradeError validates error expectations for deps upgrade tests. +func validateDepsUpgradeError(t *testing.T, err error, wantErr bool, errContain string) { + t.Helper() + + if (err != nil) != wantErr { + t.Errorf("error = %v, wantErr %v", err, wantErr) + + return + } + + if wantErr && errContain != "" { + if err == nil || !strings.Contains(err.Error(), errContain) { + t.Errorf("error should contain %q, got %v", errContain, err) + } + } +} + +func TestConfigWizardHandlerInitialization(t *testing.T) { + // Note: Cannot use t.Parallel() because test modifies shared globalConfig + + t.Run("initializes globalConfig when nil", func(t *testing.T) { + // Save and restore + origConfig := globalConfig + defer func() { globalConfig = origConfig }() + + // Set to nil + globalConfig = nil + + // Create minimal command + cmd := &cobra.Command{} + cmd.Flags().String(appconstants.FlagFormat, "yaml", "") + cmd.Flags().String(appconstants.FlagOutput, "", "") + + // Call handler (will error on wizard.Run, but should initialize config first) + _ = configWizardHandler(cmd, []string{}) + + // Verify globalConfig was initialized + if globalConfig == nil { + t.Error("configWizardHandler should initialize globalConfig when nil") + } + }) +} diff --git a/main_test_helper.go b/main_test_helper.go new file mode 100644 index 0000000..887f746 --- /dev/null +++ b/main_test_helper.go @@ -0,0 +1,118 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// testSimpleHandler is a helper for testing simple command handlers that: +// - Don't need specific setup beyond globalConfig +// - Return an error +// - Should complete without error +// +// This reduces duplication in tests like TestCacheClearHandler, TestCacheStatsHandler, etc. +func testSimpleHandler( + t *testing.T, + handlerFunc func(cmd *cobra.Command, args []string) error, + handlerName string, +) { + t.Helper() + + // Save and restore globalConfig + originalConfig := globalConfig + defer func() { globalConfig = originalConfig }() + + globalConfig = &internal.AppConfig{Quiet: true} + + // Execute handler + cmd := &cobra.Command{} + err := handlerFunc(cmd, []string{}) + if err != nil { + t.Errorf("%s() unexpected error: %v", handlerName, err) + } +} + +// testSimpleVoidHandler is a helper for testing void command handlers that: +// - Don't need specific setup beyond globalConfig +// - Don't return an error +// - Should complete without panicking +// +// This reduces duplication in tests like TestConfigThemesHandler, TestConfigShowHandler, etc. +func testSimpleVoidHandler( + t *testing.T, + handlerFunc func(cmd *cobra.Command, args []string), +) { + t.Helper() + + // Save and restore globalConfig + originalConfig := globalConfig + defer func() { globalConfig = originalConfig }() + + globalConfig = &internal.AppConfig{Quiet: true} + + // Execute handler (should not panic) + cmd := &cobra.Command{} + handlerFunc(cmd, []string{}) +} + +// setupFixtureReturningPath is a helper for test setup functions that: +// - Write a single action fixture to tmpDir +// - Return []string{actionPath} pointing to the created action file +// +// This reduces duplication in tests that need the action file path for processing. +func setupFixtureReturningPath(fixturePath string) func(*testing.T, string) []string { + return func(t *testing.T, tmpDir string) []string { + t.Helper() + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + testutil.WriteActionFixture(t, tmpDir, fixturePath) + + return []string{actionPath} + } +} + +// setupFixtureInDir is a helper for E2E test setup functions that: +// - Write a single action fixture to tmpDir +// - Don't return anything (void setupFunc) +// +// This reduces duplication in E2E integration tests where many cases write a single fixture. +func setupFixtureInDir(fixturePath string) func(*testing.T, string) { + return func(t *testing.T, tmpDir string) { + t.Helper() + testutil.WriteActionFixture(t, tmpDir, fixturePath) + } +} + +// setupWithSingleFixture is a helper for test setup functions that: +// - Write a single action fixture to tmpDir +// - Return []string{tmpDir} for test processing +// +// This reduces duplication in genHandler tests where many cases follow the same pattern. +func setupWithSingleFixture(fixturePath string) func(*testing.T, string) []string { + return func(t *testing.T, tmpDir string) []string { + t.Helper() + testutil.WriteActionFixture(t, tmpDir, fixturePath) + + return []string{tmpDir} + } +} + +// setupWithActionContent is a helper for test setup functions that: +// - Write action content to tmpDir/action.yml +// - Return []string{actionPath} pointing to the created action file +// +// This reduces duplication in tests that need to create action files from string content. +func setupWithActionContent(content string) func(*testing.T, string) []string { + return func(t *testing.T, tmpDir string) []string { + t.Helper() + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + testutil.WriteTestFile(t, actionPath, content) + + return []string{actionPath} + } +} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..8fd7927 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,20 @@ +# SonarCloud project configuration +sonar.projectKey=ivuorinen_gh-action-readme +sonar.organization=ivuorinen + +# Source and test paths +sonar.sources=. +sonar.tests=. +sonar.test.inclusions=**/*_test.go +sonar.exclusions=**/*_test.go,**/vendor/**,**/testdata/**,**/dist/**,.serena/**,.claude/**,**/.git/** + +# Go specific settings +sonar.go.coverage.reportPaths=coverage.out + +# Disable go:S100 (function naming) for test files +# Rationale: Go convention uses underscores in test names for readability +# (e.g., TestFoo_EdgeCase is more readable than TestFooEdgeCase) +sonar.issue.ignore.multicriteria=e1 + +sonar.issue.ignore.multicriteria.e1.ruleKey=go:S100 +sonar.issue.ignore.multicriteria.e1.resourceKey=**/*_test.go diff --git a/templates_embed/embed.go b/templates_embed/embed.go index 91745c7..8df937d 100644 --- a/templates_embed/embed.go +++ b/templates_embed/embed.go @@ -1,9 +1,7 @@ -// Package templates_embed provides embedded template filesystem functionality for gh-action-readme. +// Package templatesembed 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 +package templatesembed import ( "embed" diff --git a/templates_embed/embed_test.go b/templates_embed/embed_test.go new file mode 100644 index 0000000..cf27445 --- /dev/null +++ b/templates_embed/embed_test.go @@ -0,0 +1,238 @@ +package templatesembed + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// TestGetEmbeddedTemplate tests reading templates from embedded filesystem. +func TestGetEmbeddedTemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + templatePath string + expectError bool + description string + }{ + { + name: "valid default template", + templatePath: testutil.TestTemplateReadme, + expectError: false, + description: "Should read default template successfully", + }, + { + name: "valid template with templates/ prefix", + templatePath: testutil.TestTemplateWithPrefix, + expectError: false, + description: "Should handle templates/ prefix correctly", + }, + { + name: "valid GitHub theme", + templatePath: testutil.TestTemplateGitHub, + expectError: false, + description: "Should read theme template successfully", + }, + { + name: "valid template with leading slash", + templatePath: "/readme.tmpl", + expectError: false, + description: "Should strip leading slash and read template", + }, + { + name: "non-existent template", + templatePath: "nonexistent.tmpl", + expectError: true, + description: "Should return error for missing template", + }, + { + name: "empty path", + templatePath: "", + expectError: true, + description: "Should return error for empty path", + }, + { + name: "path traversal attempt", + templatePath: "../../../etc/passwd", + expectError: true, + description: "Should reject path traversal", + }, + { + name: "Windows-style path", + templatePath: "themes\\github\\readme.tmpl", + expectError: true, + description: "Windows paths won't work directly in embedded FS", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + content, err := GetEmbeddedTemplate(tt.templatePath) + + assertTemplateLoaded(t, content, err, tt.expectError, 1) + }) + } +} + +// TestGetEmbeddedTemplateFS verifies the filesystem is accessible. +func TestGetEmbeddedTemplateFS(t *testing.T) { + t.Parallel() + + fs := GetEmbeddedTemplateFS() + if fs == nil { + t.Fatal("GetEmbeddedTemplateFS() returned nil") + } + + // Verify we can read from the filesystem + file, err := fs.Open(testutil.TestTemplateWithPrefix) + if err != nil { + t.Errorf("failed to open default template: %v", err) + } + if file != nil { + _ = file.Close() + } +} + +// TestIsEmbeddedTemplateAvailable tests template existence checking. +func TestIsEmbeddedTemplateAvailable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + templatePath string + expectExists bool + }{ + { + name: "default template exists", + templatePath: testutil.TestTemplateReadme, + expectExists: true, + }, + { + name: "GitHub theme exists", + templatePath: testutil.TestTemplateGitHub, + expectExists: true, + }, + { + name: "non-existent template", + templatePath: "nonexistent.tmpl", + expectExists: false, + }, + { + name: "empty path", + templatePath: "", + expectExists: false, + }, + { + name: "path with leading slash", + templatePath: "/readme.tmpl", + expectExists: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + exists := IsEmbeddedTemplateAvailable(tt.templatePath) + if exists != tt.expectExists { + t.Errorf("IsEmbeddedTemplateAvailable(%q) = %v, want %v", + tt.templatePath, exists, tt.expectExists) + } + }) + } +} + +// TestReadTemplate tests the fallback logic (embedded → filesystem). +func TestReadTemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + templatePath string + expectError bool + description string + }{ + { + name: "read embedded template", + templatePath: testutil.TestTemplateReadme, + expectError: false, + description: "Should read from embedded filesystem", + }, + { + name: "absolute path - valid", + templatePath: "/tmp/test-template.tmpl", + expectError: true, // Will fail unless file exists + description: "Should attempt filesystem read for absolute path", + }, + { + name: "path traversal protection - relative", + templatePath: "../../../etc/passwd", + expectError: true, + description: "Should reject path traversal in relative paths", + }, + { + name: "path traversal protection - with dots", + templatePath: "templates/../../../etc/passwd", + expectError: true, + description: "Should detect unclean paths", + }, + { + name: "non-existent embedded template", + templatePath: "missing.tmpl", + expectError: true, + description: "Should fail when template doesn't exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + content, err := ReadTemplate(tt.templatePath) + + assertTemplateLoaded(t, content, err, tt.expectError, 1) + }) + } +} + +// TestReadTemplate_PathValidation tests security aspects of path handling. +func TestReadTemplatePathValidation(t *testing.T) { + t.Parallel() + + securityTests := []struct { + name string + path string + description string + }{ + { + name: "double dot traversal", + path: "../templates/readme.tmpl", + description: "Should reject paths with ..", + }, + { + name: "null byte injection", + path: "readme.tmpl\x00.evil", + description: "Should reject null bytes", + }, + { + name: "absolute traversal", + path: "/nonexistent/absolute/path/file.txt", + description: "Should validate absolute paths and fail for non-existent", + }, + } + + for _, tt := range securityTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := ReadTemplate(tt.path) + // All these should fail (either not found or security rejection) + if err == nil { + t.Errorf("security test failed: %s should have been rejected", tt.description) + } + }) + } +} diff --git a/templates_embed/embed_test_helpers.go b/templates_embed/embed_test_helpers.go new file mode 100644 index 0000000..1ee2098 --- /dev/null +++ b/templates_embed/embed_test_helpers.go @@ -0,0 +1,31 @@ +package templatesembed + +import ( + "testing" + + "github.com/ivuorinen/gh-action-readme/testutil" +) + +// assertTemplateLoaded validates template loading results. +// This helper reduces cognitive complexity in embed tests by centralizing +// the template loading validation logic that was repeated across test functions. +func assertTemplateLoaded(t *testing.T, content []byte, err error, expectError bool, minContentLength int) { + t.Helper() + + if expectError { + if err == nil { + t.Error(testutil.TestErrNoErrorGotNone) + } + + return + } + + // Success case + if err != nil { + t.Errorf(testutil.TestErrUnexpected, err) + } + + if len(content) < minContentLength { + t.Errorf("content too short: got %d bytes, want at least %d", len(content), minContentLength) + } +} diff --git a/testdata/analyzer/composite-action.yml b/testdata/analyzer/composite-action.yml new file mode 100644 index 0000000..63b7174 --- /dev/null +++ b/testdata/analyzer/composite-action.yml @@ -0,0 +1,6 @@ +name: Test Action +runs: + using: composite + steps: + - run: echo "test" + shell: bash diff --git a/testdata/analyzer/docker-action.yml b/testdata/analyzer/docker-action.yml new file mode 100644 index 0000000..60339f5 --- /dev/null +++ b/testdata/analyzer/docker-action.yml @@ -0,0 +1,4 @@ +name: Test Action +runs: + using: docker + image: Dockerfile diff --git a/testdata/analyzer/invalid.yml b/testdata/analyzer/invalid.yml new file mode 100644 index 0000000..376e841 --- /dev/null +++ b/testdata/analyzer/invalid.yml @@ -0,0 +1 @@ +invalid: [yaml: content diff --git a/testdata/analyzer/javascript-action.yml b/testdata/analyzer/javascript-action.yml new file mode 100644 index 0000000..e1ac838 --- /dev/null +++ b/testdata/analyzer/javascript-action.yml @@ -0,0 +1,4 @@ +name: Test Action +runs: + using: node20 + main: index.js diff --git a/testdata/yaml-fixtures/actions/composite/with-multiple-named-steps.yml b/testdata/yaml-fixtures/actions/composite/with-multiple-named-steps.yml new file mode 100644 index 0000000..67dff52 --- /dev/null +++ b/testdata/yaml-fixtures/actions/composite/with-multiple-named-steps.yml @@ -0,0 +1,9 @@ +name: Test Action +description: Test composite action with dependencies +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v3 diff --git a/testdata/yaml-fixtures/actions/composite/with-shell-step.yml b/testdata/yaml-fixtures/actions/composite/with-shell-step.yml new file mode 100644 index 0000000..7a464db --- /dev/null +++ b/testdata/yaml-fixtures/actions/composite/with-shell-step.yml @@ -0,0 +1,8 @@ +name: Test Action +description: Test action for detection +runs: + using: composite + steps: + - name: Test step + run: echo "test" + shell: bash diff --git a/testdata/yaml-fixtures/actions/minimal/action.yml b/testdata/yaml-fixtures/actions/minimal/action.yml new file mode 100644 index 0000000..1fbd404 --- /dev/null +++ b/testdata/yaml-fixtures/actions/minimal/action.yml @@ -0,0 +1,5 @@ +name: Test +description: Test +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/actions/simple/action.yml b/testdata/yaml-fixtures/actions/simple/action.yml new file mode 100644 index 0000000..62ac74a --- /dev/null +++ b/testdata/yaml-fixtures/actions/simple/action.yml @@ -0,0 +1,11 @@ +name: Test Action +description: Test Description +inputs: + test-input: + description: Test input + required: true +runs: + using: composite + steps: + - run: echo "test" + shell: bash diff --git a/testdata/yaml-fixtures/configs/action-config-professional.yml b/testdata/yaml-fixtures/configs/action-config-professional.yml new file mode 100644 index 0000000..02c754a --- /dev/null +++ b/testdata/yaml-fixtures/configs/action-config-professional.yml @@ -0,0 +1,3 @@ +theme: professional +template: custom-template.tmpl +output_dir: docs diff --git a/testdata/yaml-fixtures/configs/action-config-simple.yml b/testdata/yaml-fixtures/configs/action-config-simple.yml new file mode 100644 index 0000000..703411a --- /dev/null +++ b/testdata/yaml-fixtures/configs/action-config-simple.yml @@ -0,0 +1,2 @@ +theme: professional +output_dir: output diff --git a/testdata/yaml-fixtures/configs/config-minimal-theme.yml b/testdata/yaml-fixtures/configs/config-minimal-theme.yml new file mode 100644 index 0000000..f37e339 --- /dev/null +++ b/testdata/yaml-fixtures/configs/config-minimal-theme.yml @@ -0,0 +1,2 @@ +theme: minimal +output_format: json diff --git a/testdata/yaml-fixtures/configs/github-verbose-simple.yml b/testdata/yaml-fixtures/configs/github-verbose-simple.yml new file mode 100644 index 0000000..c4c0bdc --- /dev/null +++ b/testdata/yaml-fixtures/configs/github-verbose-simple.yml @@ -0,0 +1,2 @@ +theme: github +verbose: true diff --git a/testdata/yaml-fixtures/configs/global-base-token.yml b/testdata/yaml-fixtures/configs/global-base-token.yml new file mode 100644 index 0000000..5b79e22 --- /dev/null +++ b/testdata/yaml-fixtures/configs/global-base-token.yml @@ -0,0 +1,4 @@ +theme: default +output_format: md +github_token: base-token +verbose: false diff --git a/testdata/yaml-fixtures/configs/global-config-default.yml b/testdata/yaml-fixtures/configs/global-config-default.yml new file mode 100644 index 0000000..2c12353 --- /dev/null +++ b/testdata/yaml-fixtures/configs/global-config-default.yml @@ -0,0 +1,4 @@ +theme: default +output_format: md +verbose: false +github_token: ghp_test1234567890abcdefghijklmnopqrstuvwxyz diff --git a/testdata/yaml-fixtures/configs/invalid-config-incomplete.yml b/testdata/yaml-fixtures/configs/invalid-config-incomplete.yml new file mode 100644 index 0000000..2d4eecb --- /dev/null +++ b/testdata/yaml-fixtures/configs/invalid-config-incomplete.yml @@ -0,0 +1,2 @@ +unknown_field: value +invalid_theme: nonexistent diff --git a/testdata/yaml-fixtures/configs/invalid-config-malformed.yml b/testdata/yaml-fixtures/configs/invalid-config-malformed.yml new file mode 100644 index 0000000..05129cb --- /dev/null +++ b/testdata/yaml-fixtures/configs/invalid-config-malformed.yml @@ -0,0 +1,3 @@ +theme: [invalid yaml structure +output_format: "missing quote +verbose: not_a_boolean diff --git a/testdata/yaml-fixtures/configs/invalid-config-nonexistent-theme.yml b/testdata/yaml-fixtures/configs/invalid-config-nonexistent-theme.yml new file mode 100644 index 0000000..64d7b5d --- /dev/null +++ b/testdata/yaml-fixtures/configs/invalid-config-nonexistent-theme.yml @@ -0,0 +1,2 @@ +theme: nonexistent_theme +template: /path/to/nonexistent/template.tmpl diff --git a/testdata/yaml-fixtures/configs/minimal-dist.yml b/testdata/yaml-fixtures/configs/minimal-dist.yml new file mode 100644 index 0000000..2446566 --- /dev/null +++ b/testdata/yaml-fixtures/configs/minimal-dist.yml @@ -0,0 +1,2 @@ +theme: minimal +output_dir: dist diff --git a/testdata/yaml-fixtures/configs/minimal-simple.yml b/testdata/yaml-fixtures/configs/minimal-simple.yml new file mode 100644 index 0000000..316c889 --- /dev/null +++ b/testdata/yaml-fixtures/configs/minimal-simple.yml @@ -0,0 +1 @@ +theme: minimal diff --git a/testdata/yaml-fixtures/configs/professional-quiet.yml b/testdata/yaml-fixtures/configs/professional-quiet.yml new file mode 100644 index 0000000..b727650 --- /dev/null +++ b/testdata/yaml-fixtures/configs/professional-quiet.yml @@ -0,0 +1,2 @@ +theme: professional +quiet: true diff --git a/testdata/yaml-fixtures/configs/professional-simple.yml b/testdata/yaml-fixtures/configs/professional-simple.yml new file mode 100644 index 0000000..5865137 --- /dev/null +++ b/testdata/yaml-fixtures/configs/professional-simple.yml @@ -0,0 +1 @@ +theme: professional diff --git a/testdata/yaml-fixtures/configs/repo-config-github.yml b/testdata/yaml-fixtures/configs/repo-config-github.yml new file mode 100644 index 0000000..ac8af4b --- /dev/null +++ b/testdata/yaml-fixtures/configs/repo-config-github.yml @@ -0,0 +1,4 @@ +theme: github +output_format: html +verbose: true +schema: custom-schema.json diff --git a/testdata/yaml-fixtures/configs/repo-config-simple.yml b/testdata/yaml-fixtures/configs/repo-config-simple.yml new file mode 100644 index 0000000..3e560f7 --- /dev/null +++ b/testdata/yaml-fixtures/configs/repo-config-simple.yml @@ -0,0 +1,2 @@ +theme: github +output_format: html diff --git a/testdata/yaml-fixtures/configs/repo-config-verbose.yml b/testdata/yaml-fixtures/configs/repo-config-verbose.yml new file mode 100644 index 0000000..542bb9e --- /dev/null +++ b/testdata/yaml-fixtures/configs/repo-config-verbose.yml @@ -0,0 +1,3 @@ +theme: github +output_format: html +verbose: true diff --git a/testdata/yaml-fixtures/dependencies/action-with-checkout-v3.yml b/testdata/yaml-fixtures/dependencies/action-with-checkout-v3.yml new file mode 100644 index 0000000..96c3076 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/action-with-checkout-v3.yml @@ -0,0 +1,6 @@ +name: Test +description: Test action +runs: + using: composite + steps: + - uses: actions/checkout@v3 diff --git a/testdata/yaml-fixtures/dependencies/action-with-checkout-v4.yml b/testdata/yaml-fixtures/dependencies/action-with-checkout-v4.yml new file mode 100644 index 0000000..35ba8b4 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/action-with-checkout-v4.yml @@ -0,0 +1,7 @@ +name: Action 1 +description: First action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/action-with-setup-node-v3.yml b/testdata/yaml-fixtures/dependencies/action-with-setup-node-v3.yml new file mode 100644 index 0000000..112385b --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/action-with-setup-node-v3.yml @@ -0,0 +1,7 @@ +name: Action 2 +description: Second action +runs: + using: composite + steps: + - name: Setup Node + uses: actions/setup-node@v3 diff --git a/testdata/yaml-fixtures/dependencies/action1-checkout.yml b/testdata/yaml-fixtures/dependencies/action1-checkout.yml new file mode 100644 index 0000000..35ba8b4 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/action1-checkout.yml @@ -0,0 +1,7 @@ +name: Action 1 +description: First action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/action2-setup-node.yml b/testdata/yaml-fixtures/dependencies/action2-setup-node.yml new file mode 100644 index 0000000..112385b --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/action2-setup-node.yml @@ -0,0 +1,7 @@ +name: Action 2 +description: Second action +runs: + using: composite + steps: + - name: Setup Node + uses: actions/setup-node@v3 diff --git a/testdata/yaml-fixtures/dependencies/already-pinned.yml b/testdata/yaml-fixtures/dependencies/already-pinned.yml new file mode 100644 index 0000000..819f9f9 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/already-pinned.yml @@ -0,0 +1,7 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 diff --git a/testdata/yaml-fixtures/dependencies/invalid-syntax.yml b/testdata/yaml-fixtures/dependencies/invalid-syntax.yml new file mode 100644 index 0000000..7ec3ea5 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/invalid-syntax.yml @@ -0,0 +1,6 @@ +name: Test Action +description: Test action +runs: + using: composite + steps: + - uses: invalid:::syntax:: diff --git a/testdata/yaml-fixtures/dependencies/invalid-using.yml b/testdata/yaml-fixtures/dependencies/invalid-using.yml new file mode 100644 index 0000000..4dc8576 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/invalid-using.yml @@ -0,0 +1,5 @@ +name: Test Action +description: Test action +runs: + using: invalid-runtime + main: index.js diff --git a/testdata/yaml-fixtures/dependencies/invalid-yaml-syntax.yml b/testdata/yaml-fixtures/dependencies/invalid-yaml-syntax.yml new file mode 100644 index 0000000..e4c8d2f --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/invalid-yaml-syntax.yml @@ -0,0 +1,3 @@ +name: Test Action +description: Test action +runs: [invalid::: diff --git a/testdata/yaml-fixtures/dependencies/missing-description.yml b/testdata/yaml-fixtures/dependencies/missing-description.yml new file mode 100644 index 0000000..0dadbfa --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/missing-description.yml @@ -0,0 +1,4 @@ +name: Test Action +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/dependencies/missing-name.yml b/testdata/yaml-fixtures/dependencies/missing-name.yml new file mode 100644 index 0000000..4a0d578 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/missing-name.yml @@ -0,0 +1,4 @@ +description: Test action +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/dependencies/missing-runs.yml b/testdata/yaml-fixtures/dependencies/missing-runs.yml new file mode 100644 index 0000000..a7d7f73 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/missing-runs.yml @@ -0,0 +1,2 @@ +name: Test Action +description: Test action diff --git a/testdata/yaml-fixtures/dependencies/multiple-actions.yml b/testdata/yaml-fixtures/dependencies/multiple-actions.yml new file mode 100644 index 0000000..92fd280 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/multiple-actions.yml @@ -0,0 +1,9 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v3 diff --git a/testdata/yaml-fixtures/dependencies/multiple-steps.yml b/testdata/yaml-fixtures/dependencies/multiple-steps.yml new file mode 100644 index 0000000..92fd280 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/multiple-steps.yml @@ -0,0 +1,9 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v3 diff --git a/testdata/yaml-fixtures/dependencies/named-step.yml b/testdata/yaml-fixtures/dependencies/named-step.yml new file mode 100644 index 0000000..7f68b18 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/named-step.yml @@ -0,0 +1,7 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/simple-list-step.yml b/testdata/yaml-fixtures/dependencies/simple-list-step.yml new file mode 100644 index 0000000..7d0a9d3 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/simple-list-step.yml @@ -0,0 +1,6 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/simple-test-checkout.yml b/testdata/yaml-fixtures/dependencies/simple-test-checkout.yml new file mode 100644 index 0000000..9bbcafd --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/simple-test-checkout.yml @@ -0,0 +1,6 @@ +name: Test +description: Test +runs: + using: composite + steps: + - uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/simple-test-step.yml b/testdata/yaml-fixtures/dependencies/simple-test-step.yml new file mode 100644 index 0000000..4326865 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/simple-test-step.yml @@ -0,0 +1,7 @@ +name: Test Action +description: Test action +runs: + using: composite + steps: + - name: Test + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/single-checkout-v4.yml b/testdata/yaml-fixtures/dependencies/single-checkout-v4.yml new file mode 100644 index 0000000..7f68b18 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/single-checkout-v4.yml @@ -0,0 +1,7 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/step-with-parameters.yml b/testdata/yaml-fixtures/dependencies/step-with-parameters.yml new file mode 100644 index 0000000..0765507 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/step-with-parameters.yml @@ -0,0 +1,9 @@ +name: Test Action +description: Test composite action +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main diff --git a/testdata/yaml-fixtures/dependencies/test-checkout-pinned.yml b/testdata/yaml-fixtures/dependencies/test-checkout-pinned.yml new file mode 100644 index 0000000..a93b31e --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-checkout-pinned.yml @@ -0,0 +1,7 @@ +name: Test +description: Test +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@abc123 # v4.1.1 diff --git a/testdata/yaml-fixtures/dependencies/test-checkout-v4-1-0.yml b/testdata/yaml-fixtures/dependencies/test-checkout-v4-1-0.yml new file mode 100644 index 0000000..c4bc716 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-checkout-v4-1-0.yml @@ -0,0 +1,7 @@ +name: Test +description: Test +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4.1.0 diff --git a/testdata/yaml-fixtures/dependencies/test-checkout-v4.yml b/testdata/yaml-fixtures/dependencies/test-checkout-v4.yml new file mode 100644 index 0000000..122a293 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-checkout-v4.yml @@ -0,0 +1,7 @@ +name: Test +description: Test +runs: + using: composite + steps: + - name: Checkout + uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/test-checkout-with-comment-pinned.yml b/testdata/yaml-fixtures/dependencies/test-checkout-with-comment-pinned.yml new file mode 100644 index 0000000..51af15a --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-checkout-with-comment-pinned.yml @@ -0,0 +1,10 @@ +name: Test +description: Test +runs: + using: composite + steps: + # Comment about checkout + - name: Checkout + uses: actions/checkout@abc123 # v4.1.1 + with: + fetch-depth: 0 diff --git a/testdata/yaml-fixtures/dependencies/test-checkout-with-comment.yml b/testdata/yaml-fixtures/dependencies/test-checkout-with-comment.yml new file mode 100644 index 0000000..68f663f --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-checkout-with-comment.yml @@ -0,0 +1,10 @@ +name: Test +description: Test +runs: + using: composite + steps: + # Comment about checkout + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 diff --git a/testdata/yaml-fixtures/dependencies/test-multiple-checkout-pinned.yml b/testdata/yaml-fixtures/dependencies/test-multiple-checkout-pinned.yml new file mode 100644 index 0000000..f9bcc53 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-multiple-checkout-pinned.yml @@ -0,0 +1,9 @@ +name: Test +description: Test +runs: + using: composite + steps: + - uses: actions/checkout@abc123 # v4.1.1 + - run: echo "test" + shell: bash + - uses: actions/checkout@abc123 # v4.1.1 diff --git a/testdata/yaml-fixtures/dependencies/test-multiple-checkout.yml b/testdata/yaml-fixtures/dependencies/test-multiple-checkout.yml new file mode 100644 index 0000000..f5b40e3 --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/test-multiple-checkout.yml @@ -0,0 +1,9 @@ +name: Test +description: Test +runs: + using: composite + steps: + - uses: actions/checkout@v4 + - run: echo "test" + shell: bash + - uses: actions/checkout@v4 diff --git a/testdata/yaml-fixtures/dependencies/valid-composite-action.yml b/testdata/yaml-fixtures/dependencies/valid-composite-action.yml new file mode 100644 index 0000000..5c0206f --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/valid-composite-action.yml @@ -0,0 +1,5 @@ +name: Test Action +description: Test composite action for minimal validation +runs: + using: composite + steps: [] # Empty steps intentionally for edge case testing diff --git a/testdata/yaml-fixtures/dependencies/valid-docker-action.yml b/testdata/yaml-fixtures/dependencies/valid-docker-action.yml new file mode 100644 index 0000000..deda4db --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/valid-docker-action.yml @@ -0,0 +1,5 @@ +name: Test Action +description: Test Docker action +runs: + using: docker + image: Dockerfile diff --git a/testdata/yaml-fixtures/dependencies/valid-javascript-action.yml b/testdata/yaml-fixtures/dependencies/valid-javascript-action.yml new file mode 100644 index 0000000..f22383f --- /dev/null +++ b/testdata/yaml-fixtures/dependencies/valid-javascript-action.yml @@ -0,0 +1,5 @@ +name: Test Action +description: Test JavaScript action +runs: + using: node20 + main: index.js diff --git a/testdata/yaml-fixtures/error-scenarios/action-with-old-deps.yml b/testdata/yaml-fixtures/error-scenarios/action-with-old-deps.yml new file mode 100644 index 0000000..f6a3bf4 --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/action-with-old-deps.yml @@ -0,0 +1,11 @@ +name: Action with Outdated Dependencies +description: This action uses old versions of dependencies for testing outdated detection +runs: + using: composite + steps: + - name: Checkout old version + uses: actions/checkout@v2 + - name: Setup Node old version + uses: actions/setup-node@v2 + - name: Cache old version + uses: actions/cache@v2 diff --git a/testdata/yaml-fixtures/error-scenarios/empty-action.yml b/testdata/yaml-fixtures/error-scenarios/empty-action.yml new file mode 100644 index 0000000..e804c44 --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/empty-action.yml @@ -0,0 +1,5 @@ +name: Empty Action +description: Minimal action with no steps for edge case testing +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/error-scenarios/invalid-yaml-syntax.yml b/testdata/yaml-fixtures/error-scenarios/invalid-yaml-syntax.yml new file mode 100644 index 0000000..e9a6916 --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/invalid-yaml-syntax.yml @@ -0,0 +1,9 @@ +name: Invalid YAML Action +description: This action has invalid YAML syntax +runs: + using: composite + steps: + - name: Invalid Step + # Malformed YAML - missing colon after key + invalid_field without colon + another_field: value diff --git a/testdata/yaml-fixtures/error-scenarios/malformed-bracket.yml b/testdata/yaml-fixtures/error-scenarios/malformed-bracket.yml new file mode 100644 index 0000000..0b278f3 --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/malformed-bracket.yml @@ -0,0 +1,4 @@ +name: Test Action +description: Test +invalid-yaml: [ + - item diff --git a/testdata/yaml-fixtures/error-scenarios/malformed-indentation.yml b/testdata/yaml-fixtures/error-scenarios/malformed-indentation.yml new file mode 100644 index 0000000..c332e32 --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/malformed-indentation.yml @@ -0,0 +1,4 @@ +name: Test Action + description: Test + runs: + using: composite diff --git a/testdata/yaml-fixtures/error-scenarios/missing-required-fields.yml b/testdata/yaml-fixtures/error-scenarios/missing-required-fields.yml new file mode 100644 index 0000000..8e1d27f --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/missing-required-fields.yml @@ -0,0 +1,7 @@ +# Missing required 'name' and 'description' fields +runs: + using: composite + steps: + - name: Test Step + run: echo "test" + shell: bash diff --git a/testdata/yaml-fixtures/error-scenarios/permission-denied/action.yml b/testdata/yaml-fixtures/error-scenarios/permission-denied/action.yml new file mode 100644 index 0000000..06c12cd --- /dev/null +++ b/testdata/yaml-fixtures/error-scenarios/permission-denied/action.yml @@ -0,0 +1,8 @@ +name: Permission Test Action +description: Action file in directory for permission testing +runs: + using: composite + steps: + - name: Test step + run: echo "test" + shell: bash diff --git a/testdata/yaml-fixtures/permissions/dash-format-multiple.yml b/testdata/yaml-fixtures/permissions/dash-format-multiple.yml new file mode 100644 index 0000000..cb664b8 --- /dev/null +++ b/testdata/yaml-fixtures/permissions/dash-format-multiple.yml @@ -0,0 +1,9 @@ +# permissions: +# - contents: read +# - issues: write +# - pull-requests: write +name: Test Action +description: Test action with multiple permissions in dash format +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/dash-format-single.yml b/testdata/yaml-fixtures/permissions/dash-format-single.yml new file mode 100644 index 0000000..98d810b --- /dev/null +++ b/testdata/yaml-fixtures/permissions/dash-format-single.yml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +# permissions: +# - contents: read # Required for checking out repository +name: Test Action +description: Test action with single permission in dash format +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/empty-block.yml b/testdata/yaml-fixtures/permissions/empty-block.yml new file mode 100644 index 0000000..81863b1 --- /dev/null +++ b/testdata/yaml-fixtures/permissions/empty-block.yml @@ -0,0 +1,6 @@ +# permissions: +name: Test Action +description: Test action with empty permissions block +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/inline-comments.yml b/testdata/yaml-fixtures/permissions/inline-comments.yml new file mode 100644 index 0000000..94afe10 --- /dev/null +++ b/testdata/yaml-fixtures/permissions/inline-comments.yml @@ -0,0 +1,8 @@ +# permissions: +# - contents: read # Needed for checkout +# - issues: write # To create issues +name: Test Action +description: Test action with permissions with inline comments +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/mixed-format.yml b/testdata/yaml-fixtures/permissions/mixed-format.yml new file mode 100644 index 0000000..1948f9e --- /dev/null +++ b/testdata/yaml-fixtures/permissions/mixed-format.yml @@ -0,0 +1,8 @@ +# permissions: +# - contents: read +# issues: write +name: Test Action +description: Test action with mixed permission formats +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/no-permissions.yml b/testdata/yaml-fixtures/permissions/no-permissions.yml new file mode 100644 index 0000000..bcd8de0 --- /dev/null +++ b/testdata/yaml-fixtures/permissions/no-permissions.yml @@ -0,0 +1,6 @@ +# Just a comment +name: Test Action +description: Test action with no permissions block +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/permissions/object-format.yml b/testdata/yaml-fixtures/permissions/object-format.yml new file mode 100644 index 0000000..01efa2c --- /dev/null +++ b/testdata/yaml-fixtures/permissions/object-format.yml @@ -0,0 +1,8 @@ +# permissions: +# contents: read +# issues: write +name: Test Action +description: Test action with permissions in object format (no dash) +runs: + using: composite + steps: [] diff --git a/testdata/yaml-fixtures/template-fixtures/broken-template.tmpl b/testdata/yaml-fixtures/template-fixtures/broken-template.tmpl new file mode 100644 index 0000000..987b166 --- /dev/null +++ b/testdata/yaml-fixtures/template-fixtures/broken-template.tmpl @@ -0,0 +1,3 @@ +# {{ .Name } +{{ .InvalidField }} +{{ range .NonExistentField }} diff --git a/testutil/context_helpers.go b/testutil/context_helpers.go new file mode 100644 index 0000000..ab4e10f --- /dev/null +++ b/testutil/context_helpers.go @@ -0,0 +1,74 @@ +package testutil + +// ContextWithPath creates a context map with a path entry. +// Used in error handler and suggestions tests to reduce duplication. +func ContextWithPath(path string) map[string]string { + return map[string]string{"path": path} +} + +// ContextWithError creates a context map with an error entry. +// Used in error handler and suggestions tests to reduce duplication. +func ContextWithError(err string) map[string]string { + return map[string]string{"error": err} +} + +// ContextWithStatusCode creates a context map with a status code entry. +// Used in error handler and suggestions tests to reduce duplication. +func ContextWithStatusCode(code string) map[string]string { + return map[string]string{"status_code": code} +} + +// EmptyContext creates an empty context map. +// Used in error handler and suggestions tests to reduce duplication. +func EmptyContext() map[string]string { + return map[string]string{} +} + +// ContextWithLine creates a context with a line number. +// Useful for YAML parsing error suggestions. +func ContextWithLine(line string) map[string]string { + return map[string]string{"line": line} +} + +// ContextWithMissingFields creates a context with missing field names. +// Useful for validation error suggestions. +func ContextWithMissingFields(fields string) map[string]string { + return map[string]string{"missing_fields": fields} +} + +// ContextWithDirectory creates a context with a directory path. +// Useful for file discovery error suggestions. +func ContextWithDirectory(dir string) map[string]string { + return map[string]string{"directory": dir} +} + +// ContextWithConfigPath creates a context with a config file path. +// Useful for configuration error suggestions. +func ContextWithConfigPath(path string) map[string]string { + return map[string]string{"config_path": path} +} + +// ContextWithCommand creates a context with a command name. +// Useful for command execution error suggestions. +func ContextWithCommand(cmd string) map[string]string { + return map[string]string{"command": cmd} +} + +// ContextWithField creates a context with a single field value. +// Generic helper for any single-field context. +func ContextWithField(key, value string) map[string]string { + return map[string]string{key: value} +} + +// MergeContexts merges multiple context maps into one. +// Later maps override earlier maps for duplicate keys. +func MergeContexts(contexts ...map[string]string) map[string]string { + result := make(map[string]string) + for _, ctx := range contexts { + for k, v := range ctx { + result[k] = v + } + } + + return result +} diff --git a/testutil/context_helpers_test.go b/testutil/context_helpers_test.go new file mode 100644 index 0000000..9c48aa2 --- /dev/null +++ b/testutil/context_helpers_test.go @@ -0,0 +1,127 @@ +package testutil + +import "testing" + +const testErrorMessage = "test error" + +func TestContextHelpers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + value string + contextFunc func(string) map[string]string + }{ + {"ContextWithPath", TestKeyPath, "/test/path", ContextWithPath}, + {"ContextWithError", "error", testErrorMessage, ContextWithError}, + {"ContextWithStatusCode", "status_code", "404", ContextWithStatusCode}, + {"ContextWithLine", "line", "42", ContextWithLine}, + {"ContextWithMissingFields", "missing_fields", "field1,field2", ContextWithMissingFields}, + {"ContextWithDirectory", "directory", "/test/dir", ContextWithDirectory}, + {"ContextWithConfigPath", "config_path", "/config.yaml", ContextWithConfigPath}, + {"ContextWithCommand", "command", TestCmdGen, ContextWithCommand}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := tt.contextFunc(tt.value) + if result[tt.key] != tt.value { + t.Errorf("expected %s='%s', got '%s'", tt.key, tt.value, result[tt.key]) + } + }) + } +} + +func TestEmptyContext(t *testing.T) { + t.Parallel() + + result := EmptyContext() + if len(result) != 0 { + t.Errorf("expected empty context, got %d entries", len(result)) + } +} + +func TestContextWithField(t *testing.T) { + t.Parallel() + + result := ContextWithField("theme", "custom") + if result["theme"] != "custom" { + t.Errorf("expected theme='custom', got '%s'", result["theme"]) + } +} + +func TestMergeContexts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contexts []map[string]string + expected map[string]string + }{ + { + name: "empty contexts", + contexts: []map[string]string{}, + expected: map[string]string{}, + }, + { + name: "single context", + contexts: []map[string]string{ + {"key": "value"}, + }, + expected: map[string]string{"key": "value"}, + }, + { + name: "multiple contexts without overlap", + contexts: []map[string]string{ + {"key1": "value1"}, + {"key2": "value2"}, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "multiple contexts with overlap - later wins", + contexts: []map[string]string{ + {"key": "first"}, + {"key": "second"}, + }, + expected: map[string]string{"key": "second"}, + }, + { + name: "complex merge", + contexts: []map[string]string{ + {"path": "/test", "error": "not found"}, + {"status_code": "404"}, + {"error": "file not found"}, + }, + expected: map[string]string{ + "path": "/test", + "error": "file not found", + "status_code": "404", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := MergeContexts(tt.contexts...) + + if len(result) != len(tt.expected) { + t.Errorf("expected %d entries, got %d", len(tt.expected), len(result)) + } + + for key, expectedValue := range tt.expected { + if result[key] != expectedValue { + t.Errorf("expected %s='%s', got '%s'", key, expectedValue, result[key]) + } + } + }) + } +} diff --git a/testutil/fixtures.go b/testutil/fixtures.go index d1562ad..704e59b 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -8,6 +8,7 @@ import ( "runtime" "strings" "sync" + "testing" "github.com/goccy/go-yaml" @@ -22,6 +23,27 @@ var fixtureCache = struct { cache: make(map[string]string), } +// validateFixtureFilename ensures filename is safe from path traversal. +func validateFixtureFilename(filename string) error { + // Reject absolute paths + if filepath.IsAbs(filename) { + return fmt.Errorf("fixture filename must be relative, got: %s", filename) + } + + // Clean the path and check for traversal attempts + cleaned := filepath.Clean(filename) + if cleaned != filename || strings.Contains(cleaned, "..") { + return fmt.Errorf("fixture filename contains invalid path components: %s", filename) + } + + // Ensure filename doesn't start with .. (path traversal attempt) + if strings.HasPrefix(cleaned, "..") { + return fmt.Errorf("fixture filename cannot traverse directories: %s", filename) + } + + return nil +} + // MustReadFixture reads a YAML fixture file from testdata/yaml-fixtures. func MustReadFixture(filename string) string { return mustReadFixture(filename) @@ -29,6 +51,11 @@ func MustReadFixture(filename string) string { // mustReadFixture reads a YAML fixture file from testdata/yaml-fixtures with caching. func mustReadFixture(filename string) string { + // Validate filename first (BEFORE cache lookup) + if err := validateFixtureFilename(filename); err != nil { + panic("invalid fixture filename: " + err.Error()) + } + // Try to get from cache first (read lock) fixtureCache.mu.RLock() if content, exists := fixtureCache.cache[filename]; exists { @@ -70,6 +97,33 @@ func mustReadFixture(filename string) string { return content } +// MustReadAnalyzerFixture reads a fixture file from testdata/analyzer. +// This is for analyzer-specific test fixtures that aren't in yaml-fixtures. +// Panics on error to simplify test code. +func MustReadAnalyzerFixture(filename string) string { + // Validate filename first + if err := validateFixtureFilename(filename); err != nil { + panic("invalid fixture filename: " + err.Error()) + } + + // Get project root using runtime.Caller + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + panic(appconstants.ErrFailedToGetCurrentFilePath) + } + + // Get the project root (go up from testutil/fixtures.go to project root) + projectRoot := filepath.Dir(filepath.Dir(currentFile)) + fixturePath := filepath.Join(projectRoot, appconstants.DirTestdata, "analyzer", filename) + + contentBytes, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure + if err != nil { + panic("failed to read analyzer fixture " + filename + ": " + err.Error()) + } + + return string(contentBytes) +} + // ActionType represents the type of GitHub Action being tested. type ActionType string @@ -786,7 +840,7 @@ func (fm *FixtureManager) createDefaultScenarios(scenarioFile string) error { return fmt.Errorf("failed to marshal default scenarios: %w", err) } - if err := os.WriteFile(scenarioFile, data, 0600); err != nil { + if err := os.WriteFile(scenarioFile, data, appconstants.FilePermDefault); err != nil { return fmt.Errorf("failed to write scenarios file: %w", err) } @@ -795,16 +849,20 @@ func (fm *FixtureManager) createDefaultScenarios(scenarioFile string) error { } // Global fixture manager instance. -var defaultFixtureManager *FixtureManager +var ( + defaultFixtureManager *FixtureManager + fixtureManagerOnce sync.Once +) // GetFixtureManager returns the global fixture manager instance. +// Thread-safe singleton initialization using sync.Once. func GetFixtureManager() *FixtureManager { - if defaultFixtureManager == nil { + fixtureManagerOnce.Do(func() { defaultFixtureManager = NewFixtureManager() if err := defaultFixtureManager.LoadScenarios(); err != nil { panic(fmt.Sprintf("failed to load test scenarios: %v", err)) } - } + }) return defaultFixtureManager } @@ -840,3 +898,115 @@ func GetValidFixtures() []string { func GetInvalidFixtures() []string { return GetFixtureManager().GetInvalidFixtures() } + +// Validation Helpers for Updater Tests + +// ValidatePinnedUpdate validates that a pinned dependency was correctly updated. +// Checks that backup exists if requested and validates content with provided validator. +func ValidatePinnedUpdate(t *testing.T, filePath string, requireBackup bool, validator func(content string) error) { + t.Helper() + + // Check backup exists if required + if requireBackup { + backupPath := filePath + ".bak" + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Errorf("backup file not created: %s", backupPath) + } + } + + // Read and validate file content + content, err := os.ReadFile(filePath) // #nosec G304 -- test file path validated by caller + if err != nil { + t.Fatalf(TestMsgFailedReadFile, filePath, err) + } + + if validator != nil { + if err := validator(string(content)); err != nil { + t.Errorf("validation failed for %s: %v", filePath, err) + } + } +} + +// ValidateRollback validates that a file was successfully rolled back to original content. +func ValidateRollback(t *testing.T, filePath, originalContent string) { + t.Helper() + + content, err := os.ReadFile(filePath) // #nosec G304 -- test file path validated by caller + if err != nil { + t.Fatalf("failed to read file after rollback %s: %v", filePath, err) + } + + if string(content) != originalContent { + t.Errorf("rollback failed: content mismatch in %s", filePath) + t.Logf("Expected:\n%s\n\nGot:\n%s", originalContent, string(content)) + } +} + +// AssertFileContains checks that a file contains the expected substring. +func AssertFileContains(t *testing.T, filePath, expectedSubstring string) { + t.Helper() + + content, err := os.ReadFile(filePath) // #nosec G304 -- test file path validated by caller + if err != nil { + t.Fatalf(TestMsgFailedReadFile, filePath, err) + } + + if !strings.Contains(string(content), expectedSubstring) { + t.Errorf("file %s does not contain expected substring: %q", filePath, expectedSubstring) + t.Logf(TestMsgFileContent, string(content)) + } +} + +// AssertFileNotContains checks that a file does NOT contain the given substring. +func AssertFileNotContains(t *testing.T, filePath, unexpectedSubstring string) { + t.Helper() + + content, err := os.ReadFile(filePath) // #nosec G304 -- test file path validated by caller + if err != nil { + t.Fatalf(TestMsgFailedReadFile, filePath, err) + } + + if strings.Contains(string(content), unexpectedSubstring) { + t.Errorf("file %s should not contain substring: %q", filePath, unexpectedSubstring) + t.Logf(TestMsgFileContent, string(content)) + } +} + +// AssertBackupNotExists checks that a backup file does not exist. +// Used to verify backup cleanup after successful operations. +func AssertBackupNotExists(t *testing.T, filePath string) { + t.Helper() + + backupPath := filePath + ".bak" + AssertFileNotExists(t, backupPath) +} + +// AssertFileContentEquals compares file content with expected after trimming whitespace. +// Useful for YAML file comparisons where formatting may vary slightly. +func AssertFileContentEquals(t *testing.T, filePath, expectedContent string) { + t.Helper() + + actualContent, err := os.ReadFile(filePath) // #nosec G304 -- test file path validated by caller + if err != nil { + t.Fatalf(TestMsgFailedReadFile, filePath, err) + } + + actual := strings.TrimSpace(string(actualContent)) + expected := strings.TrimSpace(expectedContent) + + if actual != expected { + t.Errorf("file content mismatch in %s\nGot:\n%s\n\nWant:\n%s", + filePath, actual, expected) + } +} + +// WriteActionFile creates an action.yml file in the given directory. +// Returns the full path to the created file. +func WriteActionFile(t *testing.T, dir, content string) string { + t.Helper() + + actionPath := filepath.Join(dir, appconstants.ActionFileNameYML) + WriteTestFile(t, actionPath, content) + + return actionPath +} diff --git a/testutil/fixtures_test.go b/testutil/fixtures_test.go index a182046..fdea9e3 100644 --- a/testutil/fixtures_test.go +++ b/testutil/fixtures_test.go @@ -2,12 +2,15 @@ package testutil import ( "encoding/json" + "errors" "os" "path/filepath" "strings" "testing" "github.com/goccy/go-yaml" + + "github.com/ivuorinen/gh-action-readme/appconstants" ) const testVersion = "v4.1.1" @@ -57,7 +60,7 @@ func TestMustReadFixture(t *testing.T) { } } -func TestMustReadFixture_Panic(t *testing.T) { +func TestMustReadFixturePanic(t *testing.T) { t.Parallel() t.Run("missing file panics", func(t *testing.T) { t.Parallel() @@ -586,3 +589,99 @@ func TestHelperFunctions(t *testing.T) { _ = basicTaggedFixtures }) } + +// TestValidatePinnedUpdate tests the ValidatePinnedUpdate helper function. +func TestValidatePinnedUpdate(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := TempDir(t) + defer cleanup() + + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + testContent := "uses: " + TestActionCheckoutV3 + WriteTestFile(t, actionPath, testContent) + + t.Run("validates without backup", func(t *testing.T) { + ValidatePinnedUpdate(t, actionPath, false, func(content string) error { + if !strings.Contains(content, TestActionCheckoutV3) { + return errors.New("content does not contain expected string") + } + + return nil + }) + }) + + t.Run("validates with backup", func(t *testing.T) { + // Create backup file + backupPath := actionPath + ".bak" + WriteTestFile(t, backupPath, testContent) + + ValidatePinnedUpdate(t, actionPath, true, func(content string) error { + if !strings.Contains(content, TestActionCheckoutV3) { + return errors.New("content does not contain expected string") + } + + return nil + }) + }) + + t.Run("validates without validator function", func(t *testing.T) { + ValidatePinnedUpdate(t, actionPath, false, nil) + }) +} + +// TestValidateRollback tests the ValidateRollback helper function. +func TestValidateRollback(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := TempDir(t) + defer cleanup() + + actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML) + originalContent := "uses: " + TestActionCheckoutV3 + WriteTestFile(t, actionPath, originalContent) + + t.Run("validates successful rollback", func(t *testing.T) { + ValidateRollback(t, actionPath, originalContent) + }) +} + +// TestAssertFileContains tests the AssertFileContains helper function. +func TestAssertFileContains(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := TempDir(t) + defer cleanup() + + testPath := filepath.Join(tmpDir, "test.txt") + testContent := "This is a test file with some content" + WriteTestFile(t, testPath, testContent) + + t.Run("finds existing substring", func(t *testing.T) { + AssertFileContains(t, testPath, "test file") + }) + + t.Run("finds another existing substring", func(t *testing.T) { + AssertFileContains(t, testPath, "some content") + }) +} + +// TestAssertFileNotContains tests the AssertFileNotContains helper function. +func TestAssertFileNotContains(t *testing.T) { + t.Parallel() + + tmpDir, cleanup := TempDir(t) + defer cleanup() + + testPath := filepath.Join(tmpDir, "test.txt") + testContent := "This is a test file" + WriteTestFile(t, testPath, testContent) + + t.Run("confirms substring is absent", func(t *testing.T) { + AssertFileNotContains(t, testPath, "nonexistent string") + }) + + t.Run("confirms another substring is absent", func(t *testing.T) { + AssertFileNotContains(t, testPath, "missing content") + }) +} diff --git a/testutil/git_helpers.go b/testutil/git_helpers.go new file mode 100644 index 0000000..068fe5e --- /dev/null +++ b/testutil/git_helpers.go @@ -0,0 +1,74 @@ +package testutil + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" +) + +// SetupGitDirectory creates a .git directory in the given path. +// Returns the path to the created .git directory. +// Used in git detector tests to reduce duplication. +func SetupGitDirectory(t *testing.T, tmpDir string) string { + t.Helper() + gitDir := filepath.Join(tmpDir, appconstants.DirGit) + err := os.MkdirAll(gitDir, appconstants.FilePermDir) + AssertNoError(t, err) + + return gitDir +} + +// SetupGitConfig creates a git config file with the given remote URL. +// The config file is created in the specified gitDir. +// Used in git detector tests to reduce duplication. +func SetupGitConfig(t *testing.T, gitDir, remoteURL string) { + t.Helper() + configPath := filepath.Join(gitDir, TestCmdConfig) + config := fmt.Sprintf(`[remote "origin"] + url = %s + fetch = +refs/heads/*:refs/remotes/origin/* +`, remoteURL) + WriteTestFile(t, configPath, config) +} + +// CreateGitConfigWithRemote creates a git config file with remote and branch configuration. +// Consolidates 6+ duplicated git config setups in detector_test.go. +// Returns the path to the created config file. +func CreateGitConfigWithRemote(t *testing.T, gitDir, remoteURL, branchName string) string { + t.Helper() + + configContent := fmt.Sprintf(`[remote "origin"] + url = %s + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "%s"] + remote = origin + merge = refs/heads/%s +`, remoteURL, branchName, branchName) + + configPath := filepath.Join(gitDir, TestCmdConfig) + WriteTestFile(t, configPath, configContent) + + return configPath +} + +// WriteGitConfigFile creates a .git directory and writes a config file. +// Returns the path to the config file for further assertions. +// This is a convenience wrapper combining SetupGitDirectory + file writing. +// +// Example: +// +// configPath := testutil.WriteGitConfigFile(t, tmpDir, `[remote "origin"]...`) +func WriteGitConfigFile(t *testing.T, baseDir, configContent string) string { + t.Helper() + + gitDir := filepath.Join(baseDir, appconstants.DirGit) + CreateTestDir(t, gitDir) + + configPath := filepath.Join(gitDir, TestCmdConfig) + WriteTestFile(t, configPath, configContent) + + return configPath +} diff --git a/testutil/helpers_test.go b/testutil/helpers_test.go new file mode 100644 index 0000000..8698d9c --- /dev/null +++ b/testutil/helpers_test.go @@ -0,0 +1,60 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" +) + +// TestGitHelpers tests the git setup helper functions. +func TestGitHelpers(t *testing.T) { + t.Parallel() + + t.Run("SetupGitDirectory", func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := TempDir(t) + defer cleanup() + + gitDir := SetupGitDirectory(t, tmpDir) + + // Verify git directory exists + expectedGitDir := filepath.Join(tmpDir, ".git") + if gitDir != expectedGitDir { + t.Errorf("SetupGitDirectory() = %v, want %v", gitDir, expectedGitDir) + } + + // Verify directory was created + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + t.Errorf("SetupGitDirectory() did not create .git directory") + } + }) + + t.Run("SetupGitConfig", func(t *testing.T) { + t.Parallel() + tmpDir, cleanup := TempDir(t) + defer cleanup() + + gitDir := SetupGitDirectory(t, tmpDir) + remoteURL := "https://github.com/owner/repo.git" + SetupGitConfig(t, gitDir, remoteURL) + + // Verify config file exists + configPath := filepath.Join(gitDir, "config") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Errorf("SetupGitConfig() did not create config file") + } + + // Verify config content + content, err := os.ReadFile(configPath) // #nosec G304 -- Test code reading from controlled temp directory + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + contentStr := string(content) + if !contains(contentStr, remoteURL) { + t.Errorf("SetupGitConfig() config does not contain remote URL: %s", remoteURL) + } + if !contains(contentStr, `[remote "origin"]`) { + t.Errorf("SetupGitConfig() config does not contain remote origin section") + } + }) +} diff --git a/testutil/interface_mocks.go b/testutil/interface_mocks.go new file mode 100644 index 0000000..638ce28 --- /dev/null +++ b/testutil/interface_mocks.go @@ -0,0 +1,143 @@ +package testutil + +import ( + "fmt" + "os" + "sync" + + "github.com/ivuorinen/gh-action-readme/appconstants" +) + +// MessageLoggerMock tracks message logger calls for testing. +type MessageLoggerMock struct { + mu sync.Mutex + InfoCalls []string + SuccessCalls []string + WarningCalls []string + BoldCalls []string + PrintfCalls []string + FprintfCalls []string +} + +// Info captures info message calls. +func (m *MessageLoggerMock) Info(format string, args ...any) { + m.recordMessage(&m.InfoCalls, format, args...) +} + +// Success captures success message calls. +func (m *MessageLoggerMock) Success(format string, args ...any) { + m.recordMessage(&m.SuccessCalls, format, args...) +} + +// Warning captures warning message calls. +func (m *MessageLoggerMock) Warning(format string, args ...any) { + m.recordMessage(&m.WarningCalls, format, args...) +} + +// Bold captures bold message calls. +func (m *MessageLoggerMock) Bold(format string, args ...any) { + m.recordMessage(&m.BoldCalls, format, args...) +} + +// Printf captures printf calls. +func (m *MessageLoggerMock) Printf(format string, args ...any) { + m.recordMessage(&m.PrintfCalls, format, args...) +} + +// Fprintf captures fprintf calls. +func (m *MessageLoggerMock) Fprintf(_ *os.File, format string, args ...any) { + m.recordMessage(&m.FprintfCalls, format, args...) +} + +// recordMessage is a generic helper for recording formatted messages with thread-safety. +func (m *MessageLoggerMock) recordMessage(callSlice *[]string, format string, args ...any) { + m.mu.Lock() + defer m.mu.Unlock() + *callSlice = append(*callSlice, fmt.Sprintf(format, args...)) +} + +// ErrorReporterMock tracks error reporter calls for testing. +type ErrorReporterMock struct { + mu sync.Mutex + ErrorCalls []string + ErrorWithSuggestionsCalls []string + ErrorWithContextCalls []string + ErrorWithSimpleFixCalls []string +} + +// Error captures error calls. +func (m *ErrorReporterMock) Error(format string, args ...any) { + m.recordError(&m.ErrorCalls, fmt.Sprintf(format, args...)) +} + +// ErrorWithSuggestions captures error with suggestions calls. +func (m *ErrorReporterMock) ErrorWithSuggestions(err error) { + if err != nil { + m.recordError(&m.ErrorWithSuggestionsCalls, err.Error()) + } +} + +// ErrorWithContext captures error with context calls. +func (m *ErrorReporterMock) ErrorWithContext(_ appconstants.ErrorCode, message string, _ map[string]string) { + m.recordError(&m.ErrorWithContextCalls, message) +} + +// ErrorWithSimpleFix captures error with simple fix calls. +func (m *ErrorReporterMock) ErrorWithSimpleFix(message, suggestion string) { + m.recordError(&m.ErrorWithSimpleFixCalls, message+": "+suggestion) +} + +// recordError is a generic helper for recording error messages with thread-safety. +func (m *ErrorReporterMock) recordError(callSlice *[]string, message string) { + m.mu.Lock() + defer m.mu.Unlock() + *callSlice = append(*callSlice, message) +} + +// ProgressReporterMock tracks progress reporter calls for testing. +type ProgressReporterMock struct { + mu sync.Mutex + ProgressCalls []string +} + +// Progress captures progress calls. +func (m *ProgressReporterMock) Progress(format string, args ...any) { + m.recordProgress(&m.ProgressCalls, format, args...) +} + +// recordProgress is a generic helper for recording progress messages with thread-safety. +func (m *ProgressReporterMock) recordProgress(callSlice *[]string, format string, args ...any) { + m.mu.Lock() + defer m.mu.Unlock() + *callSlice = append(*callSlice, fmt.Sprintf(format, args...)) +} + +// ErrorFormatterMock tracks error formatter calls for testing. +type ErrorFormatterMock struct { + mu sync.Mutex + FormatContextualErrorCalls []string +} + +// FormatContextualError captures contextual error formatting calls. +func (m *ErrorFormatterMock) FormatContextualError(err error) string { + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + formatted := err.Error() + m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted) + + return formatted + } + + return "" +} + +// OutputConfigMock implements OutputConfig for testing. +type OutputConfigMock struct { + QuietMode bool +} + +// IsQuiet returns whether quiet mode is enabled. +func (m *OutputConfigMock) IsQuiet() bool { + return m.QuietMode +} diff --git a/testutil/mocks.go b/testutil/mocks.go new file mode 100644 index 0000000..670006c --- /dev/null +++ b/testutil/mocks.go @@ -0,0 +1,160 @@ +package testutil + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" +) + +// CapturedOutput captures all output for testing. +// Implements CompleteOutput interface (all focused interfaces). +type CapturedOutput struct { + BoldMessages []string + SuccessMessages []string + ErrorMessages []string + WarningMessages []string + InfoMessages []string + PrintfMessages []string + ProgressMessages []string + ErrorWithSuggestionsCalls []string + ErrorWithContextCalls []string + ErrorWithSimpleFixCalls []string + FormatContextualErrorCalls []string + QuietMode bool +} + +// Bold appends a bold-formatted message to the captured output. +func (c *CapturedOutput) Bold(format string, args ...any) { + c.recordMessage(&c.BoldMessages, format, args...) +} + +// Success appends a success message to the captured output. +func (c *CapturedOutput) Success(format string, args ...any) { + c.recordMessage(&c.SuccessMessages, format, args...) +} + +// Error appends an error message to the captured output. +func (c *CapturedOutput) Error(format string, args ...any) { + c.recordMessage(&c.ErrorMessages, format, args...) +} + +// Warning appends a warning message to the captured output. +func (c *CapturedOutput) Warning(format string, args ...any) { + c.recordMessage(&c.WarningMessages, format, args...) +} + +// Info appends an info message to the captured output. +func (c *CapturedOutput) Info(format string, args ...any) { + c.recordMessage(&c.InfoMessages, format, args...) +} + +// Printf appends a printf-formatted message to the captured output. +func (c *CapturedOutput) Printf(format string, args ...any) { + c.recordMessage(&c.PrintfMessages, format, args...) +} + +// Fprintf appends a fprintf-formatted message to the captured output. +func (c *CapturedOutput) Fprintf(_ *os.File, format string, args ...any) { + c.recordMessage(&c.PrintfMessages, format, args...) +} + +// ErrorWithSuggestions captures error reporting with suggestions. +func (c *CapturedOutput) ErrorWithSuggestions(err error) { + if err != nil { + c.ErrorWithSuggestionsCalls = append(c.ErrorWithSuggestionsCalls, err.Error()) + } +} + +// ErrorWithContext captures contextual error reporting. +func (c *CapturedOutput) ErrorWithContext(_ appconstants.ErrorCode, message string, _ map[string]string) { + c.ErrorWithContextCalls = append(c.ErrorWithContextCalls, message) +} + +// ErrorWithSimpleFix captures error reporting with a simple fix suggestion. +func (c *CapturedOutput) ErrorWithSimpleFix(message, suggestion string) { + c.ErrorWithSimpleFixCalls = append(c.ErrorWithSimpleFixCalls, message+": "+suggestion) +} + +// FormatContextualError captures and returns formatted contextual error. +func (c *CapturedOutput) FormatContextualError(err error) string { + if err != nil { + formatted := err.Error() + c.FormatContextualErrorCalls = append(c.FormatContextualErrorCalls, formatted) + + return formatted + } + + return "" +} + +// Progress captures progress reporting messages. +func (c *CapturedOutput) Progress(format string, args ...any) { + c.recordMessage(&c.ProgressMessages, format, args...) +} + +// IsQuiet returns whether the output is in quiet mode. +func (c *CapturedOutput) IsQuiet() bool { + return c.QuietMode +} + +// AllMessages consolidates all message slices into a single slice. +func (c *CapturedOutput) AllMessages() []string { + messages := make([]string, 0, + len(c.BoldMessages)+len(c.SuccessMessages)+ + len(c.InfoMessages)+len(c.ErrorMessages)+ + len(c.WarningMessages)+len(c.PrintfMessages)+ + len(c.ProgressMessages)) + messages = append(messages, c.BoldMessages...) + messages = append(messages, c.SuccessMessages...) + messages = append(messages, c.InfoMessages...) + messages = append(messages, c.ErrorMessages...) + messages = append(messages, c.WarningMessages...) + messages = append(messages, c.PrintfMessages...) + messages = append(messages, c.ProgressMessages...) + + return messages +} + +// ContainsMessage checks if any message in the consolidated list contains the needle. +func (c *CapturedOutput) ContainsMessage(needle string) bool { + return ContainsInSlice(c.AllMessages(), needle) +} + +// ContainsError checks if any error message contains the needle. +func (c *CapturedOutput) ContainsError(needle string) bool { + return ContainsInSlice(c.ErrorMessages, needle) +} + +// ContainsWarning checks if any warning message contains the needle. +func (c *CapturedOutput) ContainsWarning(needle string) bool { + return ContainsInSlice(c.WarningMessages, needle) +} + +// recordMessage is a helper that appends a formatted message to the specified message slice. +// This reduces duplication across Bold, Success, Error, Warning, Info, Printf, and Progress methods. +func (c *CapturedOutput) recordMessage(messageSlice *[]string, format string, args ...any) { + *messageSlice = append(*messageSlice, fmt.Sprintf(format, args...)) +} + +// ContainsInSlice checks if any string in the slice contains the substring. +func ContainsInSlice(slice []string, substring string) bool { + for _, s := range slice { + if strings.Contains(s, substring) { + return true + } + } + + return false +} + +// AssertSliceLength asserts that a slice has the expected length. +func AssertSliceLength(t *testing.T, slice []string, expected int, label string) { + t.Helper() + + if len(slice) != expected { + t.Errorf("%s length = %d, want %d", label, len(slice), expected) + } +} diff --git a/testutil/path_validation.go b/testutil/path_validation.go new file mode 100644 index 0000000..b0cd0b0 --- /dev/null +++ b/testutil/path_validation.go @@ -0,0 +1,54 @@ +package testutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ValidateTestPath validates a path for use in tests. +// It ensures the path doesn't contain traversal attempts and is within expected boundaries. +func ValidateTestPath(t *testing.T, path string, expectedRoot string) string { + t.Helper() + + // Clean the path + cleanPath := filepath.Clean(path) + + // Check if Clean modified the path (indicates traversal attempt) + if cleanPath != path { + t.Fatalf("path contains traversal attempt: original=%q clean=%q", path, cleanPath) + } + + // Check for explicit ".." components + if strings.Contains(cleanPath, "..") { + t.Fatalf("path contains .. traversal: %q", cleanPath) + } + + // If expectedRoot provided, verify path is within boundary + if expectedRoot != "" { + cleanRoot := filepath.Clean(expectedRoot) + relPath, err := filepath.Rel(cleanRoot, cleanPath) + if err != nil || strings.HasPrefix(relPath, "..") { + t.Fatalf("path escapes expected root: path=%q root=%q (rel=%q, err=%v)", cleanPath, cleanRoot, relPath, err) + } + } + + return cleanPath +} + +// SafeReadFile reads a file after validating the path. +// For temp directory paths, pass the temp dir as expectedRoot. +// For fixture paths, use MustReadFixture instead. +func SafeReadFile(t *testing.T, path string, expectedRoot string) []byte { + t.Helper() + + validPath := ValidateTestPath(t, path, expectedRoot) + + content, err := os.ReadFile(validPath) // #nosec G304 -- path validated + if err != nil { + t.Fatalf("failed to read file %q: %v", validPath, err) + } + + return content +} diff --git a/testutil/test_assertions.go b/testutil/test_assertions.go new file mode 100644 index 0000000..ed9f913 --- /dev/null +++ b/testutil/test_assertions.go @@ -0,0 +1,35 @@ +package testutil + +import "testing" + +// AssertMessageCounts verifies that output has expected message counts. +// Reduces duplication in validation tests (8+ occurrences). +// +// Example: +// +// output := captureOutput(...) +// testutil.AssertMessageCounts(t, "test case", output, 2, 1, 0, 1) +func AssertMessageCounts(t *testing.T, testName string, output *CapturedOutput, + wantInfo, wantError, wantWarning, wantBold int) { + t.Helper() + + if len(output.InfoMessages) != wantInfo { + t.Errorf("%s: info messages = %d, want %d", + testName, len(output.InfoMessages), wantInfo) + } + + if len(output.ErrorMessages) != wantError { + t.Errorf("%s: error messages = %d, want %d", + testName, len(output.ErrorMessages), wantError) + } + + if len(output.WarningMessages) != wantWarning { + t.Errorf("%s: warning messages = %d, want %d", + testName, len(output.WarningMessages), wantWarning) + } + + if len(output.BoldMessages) != wantBold { + t.Errorf("%s: bold messages = %d, want %d", + testName, len(output.BoldMessages), wantBold) + } +} diff --git a/testutil/test_constants.go b/testutil/test_constants.go new file mode 100644 index 0000000..11f8bd0 --- /dev/null +++ b/testutil/test_constants.go @@ -0,0 +1,514 @@ +package testutil + +// This file contains test-only constants moved from appconstants. +// These constants are exported for use across test files in different packages. + +// Test cache constants for reducing string duplication. +const ( + CacheTestKey = "test-key" + CacheTestValue = "test-value" + CacheTestKey1 = "key1" + CacheTestKey2 = "key2" + CacheTestValue1 = "value1" +) + +// Error handler test constants for reducing string duplication. +const ( + UnknownErrorMsg = "unknown error" + HelloWorldStr = "hello world" + + // TestErrFileNotFound is used in error handler tests for file not found scenarios. + TestErrFileNotFound = "file not found" + + // TestErrFileError is used in error handler tests for generic file errors. + TestErrFileError = "file error" + + // TestErrPermissionDenied is used in error handler tests for permission errors. + TestErrPermissionDenied = "permission denied" +) + +// Validation component test constants for reducing string duplication. +const ( + TestItemName = "test-item" +) + +// Wizard test constants for reducing string duplication. +const ( + ErrOutputDirMismatch = "OutputDir = %q, want %q" +) + +// Generator test constants for reducing string duplication. +const ( + TestActionName = "Test Action" + TestActionDesc = "Test Description" +) + +// GitHub authentication test constants for reducing string duplication. +const ( + TestTokenValue = "test-token" +) + +// Validation test file identifiers for reducing string duplication. +const ( + ValidationTestFile1 = "file: action1.yml" + ValidationTestFile2 = "file: action2.yml" + ValidationTestFile3 = "file: action.yml" +) + +// GitHub Actions runner names for reducing string duplication. +const ( + RunnerUbuntuLatest = "ubuntu-latest" + RunnerWindowsLatest = "windows-latest" + RunnerMacosLatest = "macos-latest" +) + +// Test assertion message format templates for reducing string duplication. +const ( + TestMsgExitCode = "expected exit code %d, got %d" + TestMsgStdout = "stdout: %s" + TestMsgStderr = "stderr: %s" +) + +// Test fixture path constants for reducing string duplication. +const ( + TestFixtureJavaScriptSimple = "actions/javascript/simple.yml" + TestFixtureCompositeBasic = "actions/composite/basic.yml" + TestFixtureCompositeWithDeps = "actions/composite/with-dependencies.yml" + TestFixtureCompositeMultipleNamedSteps = "actions/composite/with-multiple-named-steps.yml" + TestFixtureCompositeWithShellStep = "actions/composite/with-shell-step.yml" + TestFixtureDockerBasic = "actions/docker/basic.yml" + TestFixtureInvalidMissingDescription = "actions/invalid/missing-description.yml" + TestFixtureInvalidInvalidUsing = "actions/invalid/invalid-using.yml" + TestFixtureMinimalAction = "minimal-action.yml" + TestFixtureTestCompositeAction = "test-composite-action.yml" + TestFixtureMyNewAction = "my-new-action.yml" + TestFixtureActionWithCheckoutV3 = "dependencies/action-with-checkout-v3.yml" + TestFixtureActionWithCheckoutV4 = "dependencies/action-with-checkout-v4.yml" + TestFixtureSimpleCheckout = "dependencies/simple-test-checkout.yml" + TestFixtureEmptyAction = "error-scenarios/empty-action.yml" + TestFixtureGlobalConfig = "configs/global/default.yml" + TestFixtureProfessionalConfig = "professional-config.yml" + TestFixtureRepoConfig = "repo-config.yml" + TestFixtureActionSimple = "actions/simple/action.yml" + TestFixtureActionMinimal = "actions/minimal/action.yml" + + // Permission test fixtures for parser tests. + TestFixturePermissionsDashSingle = "permissions/dash-format-single.yml" + TestFixturePermissionsDashMultiple = "permissions/dash-format-multiple.yml" + TestFixturePermissionsObject = "permissions/object-format.yml" + TestFixturePermissionsInlineComments = "permissions/inline-comments.yml" + TestFixturePermissionsMixed = "permissions/mixed-format.yml" + TestFixturePermissionsEmpty = "permissions/empty-block.yml" + TestFixturePermissionsNone = "permissions/no-permissions.yml" +) + +// Dependency update test constants for reducing string duplication in updater_test.go. +const ( + // Actions checkout references for dependency update tests. + TestCheckoutV4OldUses = "actions/checkout@v4" + TestCheckoutPinnedV417 = "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7" + TestCheckoutPinnedV411 = "actions/checkout@abc123 # v4.1.1" + + // Version string for dependency tests. + TestVersionV417 = "v4.1.7" +) + +// Test file path constants for reducing string duplication. +const ( + TestPathConfigYML = "config.yml" +) + +// Test directory path constants for reducing string duplication. +const ( + TestDirSubdir = "subdir" + TestDirDotConfig = ".config" + TestDirConfigGhActionReadme = ".config/gh-action-readme" +) + +// Test YAML content for parser tests. +const ( + TestYAMLRoot = "name: root" + TestYAMLNodeModules = "name: node_modules" + TestYAMLVendor = "name: vendor" + TestYAMLGit = "name: git" + TestYAMLSrc = "name: src" + TestYAMLNested = "name: nested" + TestYAMLSub = "name: sub" +) + +// Test YAML template strings for parser tests. +const ( + TestActionFilePattern = "action-*.yml" + TestPermissionsHeader = "# permissions:\n" + TestActionNameLine = "name: Test Action\n" + TestDescriptionLine = "description: Test\n" + TestRunsLine = "runs:\n" + TestCompositeUsing = " using: composite\n" + TestStepsEmpty = " steps: []\n" + TestErrorFormat = "ParseActionYML() error = %v" + TestContentsRead = "# contents: read\n" +) + +// Test path constants for template tests. +const ( + TestRepoActionPath = "/repo/action.yml" + TestRepoBuildActionPath = "/repo/build/action.yml" + TestVersionV123 = "@v1.2.3" +) + +// Test error message formats for testutil tests. +const ( + TestErrUnexpected = "unexpected error: %v" + TestErrNonEmptyAction = "expected non-empty action content" + TestErrStatusCode = "expected status 200, got %d" + + // Common test assertion format strings for reducing duplication. + TestMsgGotWant = "got %v, want %v" // Used in test runners and assertions + TestErrNoErrorGotNone = "expected error but got none" // Used in error validation helpers + TestMsgFailedReadFile = "failed to read file %s: %v" // Used in file assertion helpers + TestMsgFileContent = "File content:\n%s" // Used in file content logging + TestMsgExpectedNonEmpty = "expected non-empty result" // Used for non-empty result assertions + TestMsgFailedReadOutput = "Failed to read output file: %v" // Used for output file read errors + TestMsgExpected1InfoCall = "expected 1 Info call, got %d" // Used in logger mock tests + TestMsgExportConfigError = "ExportConfig() error = %v" // Used in config export tests +) + +// Validation test constants. +const ( + TestVersionSemantic = "v1.2.3" + TestVersionPlain = "1.2.3" + TestCaseNameEmpty = "empty string" + TestBranchMain = "main" + TestGitRefMain = "refs/heads/main" +) + +// Wizard test constants. +const ( + WizardInputYes = "y\n" + WizardInputNo = "n\n" + WizardInputYesNewline = "y\ny\n" + WizardInputThreeNewlines = "\n\n\n" + WizardInputEnterToken = "Enter token" + WizardPromptContinue = "Continue?" + WizardOrgTest = "testorg" + WizardRepoTest = "testrepo" + WizardPromptEnter = "Enter value" +) + +// Test directories and paths for wizard tests. +const ( + TestDirDocs = "./docs" + TestDirOutput = "./output" +) + +// Test file names for multiple action scenarios. +const ( + TestFileAction1 = "action1.yml" + TestFileAction2 = "action2.yml" +) + +// Test action references. +const ( + TestActionCheckout = "actions/checkout" + TestActionCheckoutV4 = "actions/checkout@v4" +) + +// Test assertion and error message formats. +const ( + TestMsgThemeFormat = "Theme = %q, want %q" + TestMsgAnalyzeDepsTrue = "AnalyzeDependencies should be true" + TestMsgNoGitHubToken = "returns error when no GitHub token" + TestMsgGitNotInstalled = "git not installed" + TestErrPathTraversal = "path traversal" + TestInvalidYAMLPrefix = "invalid: [yaml" + TestLangJavaScriptTypeScript = "JavaScript/TypeScript" + TestMsgExpectedNonNilConfig = "expected non-nil config" +) + +// Test commands - moved from appconstants for better separation. +const ( + TestCmdGen = "gen" + TestCmdConfig = "config" + TestCmdValidate = "validate" + TestCmdDeps = "deps" + TestCmdShow = "show" + TestCmdList = "list" + TestCmdUpgrade = "upgrade" +) + +// Test file paths and names - moved from appconstants. +const ( + TestTmpDir = "/tmp" + TestTmpActionFile = "/tmp/action.yml" + TestPathTempAction = "/tmp/test-action/action.yml" + TestErrorScenarioOldDeps = "error-scenarios/action-with-old-deps.yml" + TestErrorScenarioInvalidYAML = "error-scenarios/invalid-yaml-syntax.yml" + TestErrorScenarioMissingFields = "error-scenarios/missing-required-fields.yml" +) + +// TestMinimalAction is the minimal action YAML content for testing. +const TestMinimalAction = "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []" + +// TestScenarioNoDeps is the common test scenario description for actions with no dependencies. +const TestScenarioNoDeps = "handles action with no dependencies" + +// Test messages and error strings - moved from appconstants. +const ( + TestMsgFileNotFound = "File not found" + TestMsgInvalidYAML = "Invalid YAML" + TestMsgQuietSuppressOutput = "quiet mode suppresses output" + TestMsgNoOutputInQuiet = "Expected no output in quiet mode, got %q" + TestMsgVerifyPermissions = "Verify permissions" + TestMsgSuggestions = "Suggestions" + TestMsgDetails = "Details" + TestMsgCheckFilePath = "Check the file path" + TestMsgTryAgain = "Try again" + TestMsgProcessingStarted = "Processing started" + TestMsgOperationCompleted = "Operation completed" + TestMsgOutputMissingEmoji = "Output missing error emoji: %q" +) + +// Test scenario names - moved from appconstants. +const ( + TestScenarioColorEnabled = "with color enabled" + TestScenarioColorDisabled = "with color disabled" + TestScenarioQuietEnabled = "quiet mode enabled" + TestScenarioQuietDisabled = "quiet mode disabled" +) + +// Test URLs and paths - moved from appconstants. +const ( + TestURLHelp = "https://example.com/help" + TestURLGitHubAPI = "https://api.github.com/" + TestURLGitHub = "https://github.com/" + TestURLGitHubUserRepo = "https://github.com/user/repo" + TestKeyFile = "file" + TestKeyPath = "path" +) + +// Test repository and organization values - moved from appconstants. +const ( + TestValue = "test" + TestVersion = "v1.0.0" +) + +// Test dependency actions - moved from appconstants. +const ( + TestActionCheckoutV3 = "actions/checkout@v3" + TestActionCheckoutSHA = "692973e3d937129bcbf40652eb9f2f61becf3332" + TestActionSetupNodeV3 = "actions/setup-node@v3" + TestActionSetupGoV4 = "actions/setup-go@v4" +) + +// Test paths and output - moved from appconstants. +const ( + TestOutputPath = "/tmp/output" +) + +// Test HTML content - moved from appconstants. +const ( + TestHTMLNewContent = "New content" + TestHTMLClosingTag = "\n" + TestMsgFailedToReadOutput = "Failed to read output file: %v" +) + +// Test detector messages - moved from appconstants. +const ( + TestMsgFailedToCreateAction = "Failed to create action.yml: %v" + TestPermRead = "read" + TestPermWrite = "write" + TestPermContents = "contents" +) + +// Test repository names - moved from appconstants. +const ( + TestRepoTestOrgTestRepo = "test-org/test-repo" + TestRepoTestRepo = "test/repo" +) + +// Integration test directory and file names - moved from appconstants. +const ( + TestDirDotGitHub = ".github" + TestFileGitIgnore = ".gitignore" + TestFileGHActionReadme = "gh-action-readme.yml" + TestBinaryName = "gh-action-readme" +) + +// Integration test CLI flags - moved from appconstants. +const ( + TestFlagOutputFormat = "--output-format" + TestFlagRecursive = "--recursive" + TestFlagTheme = "--theme" + TestFlagVerbose = "--verbose" +) + +// Integration test output messages - moved from appconstants. +const ( + TestMsgCurrentConfig = "Current Configuration" + TestMsgDependenciesFound = "Dependencies found" +) + +// Integration test file patterns - moved from appconstants. +const ( + TestPatternHTML = "*.html" + TestPatternREADME = "README*.md" + TestPatternREADMEAll = "**/README*.md" +) + +// Config test constants - moved from appconstants. +const ( + TestFileGHReadmeYAML = ".ghreadme.yaml" + TestFileConfigYAML = "config.yaml" + TestTokenConfig = "config-token" + TestTokenStd = "ghp_test1234567890abcdefghijklmnopqrstuvwxyz" + TestTokenEnv = "env-token" + TestFileCustomConfig = "custom-config.yml" +) + +// Theme constants for testing - reducing string duplication across test files. +const ( + TestThemeDefault = "default" + TestThemeGitHub = "github" + TestThemeGitLab = "gitlab" + TestThemeMinimal = "minimal" + TestThemeProfessional = "professional" + TestThemeASCIIDoc = "asciidoc" +) + +// Template path constants for testing - reducing hardcoded template paths. +const ( + TestTemplateReadme = "readme.tmpl" + TestTemplateWithPrefix = "templates/readme.tmpl" + TestTemplateGitHub = "themes/github/readme.tmpl" + TestTemplateGitLab = "themes/gitlab/readme.tmpl" + TestTemplateMinimal = "themes/minimal/readme.tmpl" + TestTemplateProfessional = "themes/professional/readme.tmpl" + TestTemplateASCIIDoc = "themes/asciidoc/readme.adoc" +) + +// Dependency analyzer test constants - moved from appconstants. +const ( + TestVersionV4_1_1 = "v4.1.1" + TestVersionV4_0_0 = "v4.0.0" + TestSHAForTesting = "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e" +) + +// File discovery test error messages for reducing string duplication in tests. +const ( + // TestErrDiscoveredFileCountFormat is used when file discovery returns unexpected count. + TestErrDiscoveredFileCountFormat = "DiscoverActionFiles() returned %d files, want %d" + + // TestErrFileNotFoundInResults is used when expected file is missing from discovery. + TestErrFileNotFoundInResults = "Expected file %s not found in results" + + // TestErrDiscoveredNestedFilesSkipped is used when nested files should be skipped. + TestErrDiscoveredNestedFilesSkipped = "DiscoverActionFiles() returned %d files, want 0 (nested dirs should be skipped)" + + // TestErrDiscoveredNonRecursive is used for non-recursive discovery tests. + TestErrDiscoveredNonRecursive = "DiscoverActionFiles() non-recursive returned %d files, want %d" + + // TestErrMsgParseActionYAML is used when action.yml parsing fails. + TestErrMsgParseActionYAML = "failed to parse action.yml" + + // TestErrMsgInvalidConfig is used when configuration is invalid. + TestErrMsgInvalidConfig = "invalid configuration" +) + +// Assertion message formats for reducing string duplication in tests. +const ( + // TestMsgShouldIgnoreDirectory is used in shouldIgnoreDirectory tests. + TestMsgShouldIgnoreDirectory = "shouldIgnoreDirectory(%q, %v) = %v, want %v" + + // TestMsgWalkFuncError is used in walkFunc tests. + TestMsgWalkFuncError = "walkFunc() with valid directory should return nil, got: %v" + + // TestMsgFileContentMismatch is used when file content doesn't match expectations. + TestMsgFileContentMismatch = "file content mismatch in %s" +) + +// Malformed YAML fixture paths for reducing string duplication in error scenario tests. +const ( + // TestFixtureMalformedBracket has unclosed bracket for testing YAML parse errors. + TestFixtureMalformedBracket = "error-scenarios/malformed-bracket.yml" + + // TestFixtureMalformedIndentation has invalid indentation for testing YAML parse errors. + TestFixtureMalformedIndentation = "error-scenarios/malformed-indentation.yml" +) + +// Additional assertion message formats for reducing string duplication in tests. +const ( + // TestMsgExpectedError is used when error is expected but not returned. + TestMsgExpectedError = "expected error, got nil" + + // TestMsgUnexpectedSuccess is used when expecting success but got error. + TestMsgUnexpectedSuccess = "expected success, got error: %v" + + // TestMsgCountMismatch is used when counts don't match expectations. + TestMsgCountMismatch = "expected %d items, got %d" +) + +// Config-related test constants for reducing string duplication in config tests. +const ( + // TestConfigEmpty is an empty JSON config. + TestConfigEmpty = "{}" + + // TestConfigMinimal is a minimal JSON config with version. + TestConfigMinimal = `{"version": "1.0.0"}` +) + +// Validation message constants for reducing string duplication in validation tests. +const ( + // TestMsgCannotBeEmpty is a common validation error message. + TestMsgCannotBeEmpty = "cannot be empty" + + // TestMsgInvalidVariableName is a common validation error for variable names. + TestMsgInvalidVariableName = "Invalid variable name" +) + +// Template helper test constants for reducing string duplication in template tests. +const ( + // Test organization and repository names for template data tests. + TestOrgName = "test-org" + TestRepoName = "test-repo" + MyOrgName = "my-org" + MyRepoName = "my-repo" + RepoName = "repo" + + // Config test organization and repository names for RepoOverrides tests. + OrgName = "org" + ExistingOrgName = "existing" + NewOrgName = "new" + OrgRepo = "org/repo" + ExistingRepo = "existing/repo" + NewRepo = "new/repo" + + // Analyzer fixture path for template helper tests. + AnalyzerFixturePath = "../../testdata/analyzer/" +) + +// Config fixture path constants for reducing string duplication. +const ( + // Global configs. + TestConfigGlobalDefault = "configs/global-config-default.yml" + //nolint:gosec // G101: False positive - this is a test fixture path, not a credential + TestConfigGlobalBaseToken = "configs/global-base-token.yml" + TestConfigRepoGitHub = "configs/repo-config-github.yml" + TestConfigRepoSimple = "configs/repo-config-simple.yml" + TestConfigActionProfessional = "configs/action-config-professional.yml" + TestConfigActionSimple = "configs/action-config-simple.yml" + TestConfigRepoVerbose = "configs/repo-config-verbose.yml" + TestConfigGitHubVerbose = "configs/github-verbose-simple.yml" + TestConfigProfessionalQuiet = "configs/professional-quiet.yml" + TestConfigMinimalTheme = "configs/config-minimal-theme.yml" + TestConfigMinimalSimple = "configs/minimal-simple.yml" + TestConfigProfessionalSimple = "configs/professional-simple.yml" + TestConfigMinimalDist = "configs/minimal-dist.yml" + + // Invalid/error configs. + TestConfigInvalidMalformed = "configs/invalid-config-malformed.yml" + TestConfigInvalidIncomplete = "configs/invalid-config-incomplete.yml" + TestConfigInvalidTheme = "configs/invalid-config-nonexistent-theme.yml" + + // Template fixtures. + TestTemplateBroken = "template-fixtures/broken-template.tmpl" +) diff --git a/testutil/test_runner.go b/testutil/test_runner.go new file mode 100644 index 0000000..16466b6 --- /dev/null +++ b/testutil/test_runner.go @@ -0,0 +1,153 @@ +package testutil + +import "testing" + +// StringTestCase represents a test case for string transformation functions. +type StringTestCase struct { + Name string + Input string + Want string +} + +// RunStringTests runs a suite of string transformation tests. +// The function fn should transform the input string and return the result. +func RunStringTests(t *testing.T, tests []StringTestCase, fn func(string) string) { + t.Helper() + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + got := fn(tt.Input) + if got != tt.Want { + t.Errorf("got %q, want %q", got, tt.Want) + } + }) + } +} + +// BoolTestCase represents a test case for boolean validation functions. +type BoolTestCase struct { + Name string + Input string + Want bool +} + +// RunBoolTests runs a suite of validation tests. +// The function fn should validate the input string and return true/false. +func RunBoolTests(t *testing.T, tests []BoolTestCase, fn func(string) bool) { + t.Helper() + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + got := fn(tt.Input) + if got != tt.Want { + t.Errorf(TestMsgGotWant, got, tt.Want) + } + }) + } +} + +// ErrorTestCase represents a test case for functions that return errors. +type ErrorTestCase struct { + Name string + Input string + WantErr bool + ErrContains string // Optional: check if error message contains this string +} + +// RunErrorTests runs a suite of error-returning function tests. +// The function fn should process the input and return an error if validation fails. +func RunErrorTests(t *testing.T, tests []ErrorTestCase, fn func(string) error) { + t.Helper() + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + err := fn(tt.Input) + if tt.WantErr { + if err == nil { + t.Errorf("expected error, got nil") + } else if tt.ErrContains != "" && !contains(err.Error(), tt.ErrContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.ErrContains) + } + } else { + if err != nil { + t.Errorf(TestErrUnexpected, err) + } + } + }) + } +} + +// contains checks if s contains substr (case-sensitive). +func contains(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(s) < len(substr) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} + +// MapValidationTestCase represents a test case that validates maps. +type MapValidationTestCase struct { + Name string + Input map[string]string + Validate func(map[string]string) error +} + +// RunMapValidationTests runs validation tests on maps. +func RunMapValidationTests(t *testing.T, tests []MapValidationTestCase) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + if err := tt.Validate(tt.Input); err != nil { + t.Errorf("validation failed: %v", err) + } + }) + } +} + +// StringSliceTestCase represents a test case for string slice operations. +type StringSliceTestCase struct { + Name string + Input []string + Want []string + Fn func([]string) []string +} + +// RunStringSliceTests runs tests on string slice functions. +func RunStringSliceTests(t *testing.T, tests []StringSliceTestCase) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + got := tt.Fn(tt.Input) + if !slicesEqual(got, tt.Want) { + t.Errorf(TestMsgGotWant, got, tt.Want) + } + }) + } +} + +// slicesEqual compares two string slices for equality. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/testutil/test_runner_test.go b/testutil/test_runner_test.go new file mode 100644 index 0000000..bf8f5c8 --- /dev/null +++ b/testutil/test_runner_test.go @@ -0,0 +1,174 @@ +package testutil + +import ( + "errors" + "strings" + "testing" +) + +func TestRunStringTests(t *testing.T) { + t.Parallel() + + tests := []StringTestCase{ + {Name: "uppercase", Input: "hello", Want: "HELLO"}, + {Name: "lowercase", Input: "WORLD", Want: "world"}, + } + + RunStringTests(t, tests, func(s string) string { + if s == "hello" { + return strings.ToUpper(s) + } + + return strings.ToLower(s) + }) +} + +func TestRunBoolTests(t *testing.T) { + t.Parallel() + + tests := []BoolTestCase{ + {Name: "empty string", Input: "", Want: false}, + {Name: "non-empty string", Input: "test", Want: true}, + } + + RunBoolTests(t, tests, func(s string) bool { + return len(s) > 0 + }) +} + +func TestRunErrorTests(t *testing.T) { + t.Parallel() + + tests := []ErrorTestCase{ + {Name: "valid input", Input: "valid", WantErr: false}, + {Name: "invalid input", Input: "invalid", WantErr: true, ErrContains: "invalid"}, + {Name: "error without check", Input: "bad", WantErr: true}, + } + + RunErrorTests(t, tests, func(s string) error { + if s == "valid" { + return nil + } + if s == "invalid" { + return errors.New("invalid input") + } + + return errors.New("something went wrong") + }) +} + +func TestContains(t *testing.T) { + t.Parallel() + + const testString = "hello world" + + tests := []struct { + name string + s string + substr string + want bool + }{ + {"empty substring", "hello", "", true}, + {"exact match", "test", "test", true}, + {"substring at start", testString, "hello", true}, + {"substring at end", testString, "world", true}, + {"substring in middle", testString, "lo wo", true}, + {"not found", "hello", "goodbye", false}, + {"longer substring", "hi", "hello", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := contains(tt.s, tt.substr) + if got != tt.want { + t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want) + } + }) + } +} + +func TestRunMapValidationTests(t *testing.T) { + t.Parallel() + + tests := []MapValidationTestCase{ + { + Name: "valid map", + Input: map[string]string{"key": "value"}, + Validate: func(m map[string]string) error { + if m["key"] != "value" { + return errors.New("unexpected value") + } + + return nil + }, + }, + { + Name: "empty map", + Input: map[string]string{}, + Validate: func(m map[string]string) error { + if len(m) != 0 { + return errors.New("expected empty map") + } + + return nil + }, + }, + { + Name: "map with multiple keys", + Input: map[string]string{"key1": "value1", "key2": "value2"}, + Validate: func(m map[string]string) error { + if len(m) != 2 { + return errors.New("expected 2 keys") + } + + return nil + }, + }, + } + + RunMapValidationTests(t, tests) +} + +func TestRunStringSliceTests(t *testing.T) { + t.Parallel() + + tests := []StringSliceTestCase{ + { + Name: "reverse slice", + Input: []string{"a", "b", "c"}, + Want: []string{"c", "b", "a"}, + Fn: func(s []string) []string { + result := make([]string, len(s)) + for i, v := range s { + result[len(s)-1-i] = v + } + + return result + }, + }, + { + Name: "uppercase slice", + Input: []string{"hello", "world"}, + Want: []string{"HELLO", "WORLD"}, + Fn: func(s []string) []string { + result := make([]string, len(s)) + for i, v := range s { + result[i] = strings.ToUpper(v) + } + + return result + }, + }, + { + Name: "empty slice", + Input: []string{}, + Want: []string{}, + Fn: func(s []string) []string { + return s + }, + }, + } + + RunStringSliceTests(t, tests) +} diff --git a/testutil/test_suites.go b/testutil/test_suites.go index 02b3972..1721e6e 100644 --- a/testutil/test_suites.go +++ b/testutil/test_suites.go @@ -97,7 +97,7 @@ type TestResult struct { // MockSuite holds all configured mocks for a test. type MockSuite struct { GitHubClient *github.Client - ColoredOutput *MockColoredOutput + ColoredOutput *CapturedOutput HTTPClient *MockHTTPClient Environment map[string]string TempDirs []string @@ -307,9 +307,7 @@ func createMockSuite(t *testing.T, config *MockConfig) *MockSuite { // Set up colored output mock if config.ColoredOutput { - suite.ColoredOutput = &MockColoredOutput{ - Messages: make([]string, 0), - } + suite.ColoredOutput = &CapturedOutput{} } // Set up HTTP client mock @@ -476,17 +474,13 @@ func validateCustom(t *testing.T, expected *ExpectedResult, result *TestResult) // Helper functions for specific test types -// RunActionTests executes action-related test cases. -func RunActionTests(t *testing.T, cases []ActionTestCase) { +// runTypedTestSuite is a helper to reduce duplication in test runner functions. +// It converts typed test cases to TestCase and runs them in a suite. +func runTypedTestSuite(t *testing.T, suiteName string, testCases []TestCase) { t.Helper() - testCases := make([]TestCase, len(cases)) - for i, actionCase := range cases { - testCases[i] = actionCase.TestCase - } - suite := TestSuite{ - Name: "Action Tests", + Name: suiteName, Cases: testCases, Parallel: true, } @@ -494,40 +488,43 @@ func RunActionTests(t *testing.T, cases []ActionTestCase) { RunTestSuite(t, suite) } +// extractTestCasesGeneric extracts TestCase slices from typed test case slices. +// This helper reduces duplication across RunActionTests, RunGeneratorTests, and RunValidationTests. +func extractTestCasesGeneric[T interface { + ActionTestCase | GeneratorTestCase | ValidationTestCase +}](cases []T) []TestCase { + testCases := make([]TestCase, len(cases)) + for i := range cases { + // Use type assertion to access TestCase field + switch c := any(cases[i]).(type) { + case ActionTestCase: + testCases[i] = c.TestCase + case GeneratorTestCase: + testCases[i] = c.TestCase + case ValidationTestCase: + testCases[i] = c.TestCase + } + } + + return testCases +} + +// RunActionTests executes action-related test cases. +func RunActionTests(t *testing.T, cases []ActionTestCase) { + t.Helper() + runTypedTestSuite(t, "Action Tests", extractTestCasesGeneric(cases)) +} + // RunGeneratorTests executes generator test cases. func RunGeneratorTests(t *testing.T, cases []GeneratorTestCase) { t.Helper() - - testCases := make([]TestCase, len(cases)) - for i, genCase := range cases { - testCases[i] = genCase.TestCase - } - - suite := TestSuite{ - Name: "Generator Tests", - Cases: testCases, - Parallel: true, - } - - RunTestSuite(t, suite) + runTypedTestSuite(t, "Generator Tests", extractTestCasesGeneric(cases)) } // RunValidationTests executes validation test cases. func RunValidationTests(t *testing.T, cases []ValidationTestCase) { t.Helper() - - testCases := make([]TestCase, len(cases)) - for i, valCase := range cases { - testCases[i] = valCase.TestCase - } - - suite := TestSuite{ - Name: "Validation Tests", - Cases: testCases, - Parallel: true, - } - - RunTestSuite(t, suite) + runTypedTestSuite(t, "Validation Tests", extractTestCasesGeneric(cases)) } // Utility functions @@ -757,9 +754,7 @@ func CreateMockSuite(config *MockConfig) *MockSuite { // Set up colored output mock if config.ColoredOutput { - suite.ColoredOutput = &MockColoredOutput{ - Messages: make([]string, 0), - } + suite.ColoredOutput = &CapturedOutput{} } // Set up HTTP client mock @@ -823,7 +818,7 @@ func ValidateActionFixture(t *testing.T, fixture *ActionFixture) { func TestAllThemes(t *testing.T, testFunc func(*testing.T, string)) { t.Helper() - themes := []string{"default", "github", "minimal", "professional"} + themes := []string{TestThemeDefault, TestThemeGitHub, TestThemeMinimal, TestThemeProfessional} for _, theme := range themes { theme := theme // capture loop variable @@ -993,7 +988,7 @@ func getExpectedFilename(outputFormat string) string { // CreateGeneratorTestCases creates test cases for generator testing. func CreateGeneratorTestCases() []GeneratorTestCase { validFixtures := GetValidFixtures() - themes := []string{"default", "github", "minimal", "professional"} + themes := []string{TestThemeDefault, TestThemeGitHub, TestThemeMinimal, TestThemeProfessional} formats := []string{ appconstants.OutputFormatMarkdown, appconstants.OutputFormatHTML, diff --git a/testutil/testutil.go b/testutil/testutil.go index 05b0fc2..cb313eb 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -69,17 +70,19 @@ func MockGitHubClient(responses map[string]string) *github.Client { } } - client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}}) + client := github.NewClient(&http.Client{Transport: &MockTransport{Client: mockClient}}) return client } -type mockTransport struct { - client *MockHTTPClient +// MockTransport implements http.RoundTripper for testing HTTP clients. +type MockTransport struct { + Client *MockHTTPClient } -func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return t.client.Do(req) +// RoundTrip implements http.RoundTripper interface. +func (t *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return t.Client.Do(req) } // TempDir creates a temporary directory for testing and returns cleanup function. @@ -165,6 +168,17 @@ func WriteTestFile(t *testing.T, path, content string) { } } +// WriteFileInDir writes a file with the given filename in the specified directory. +// This is a convenience wrapper that combines filepath.Join + WriteTestFile. +// Eliminates the pattern: path := filepath.Join(dir, filename); WriteTestFile(t, path, content). +func WriteFileInDir(t *testing.T, dir, filename, content string) string { + t.Helper() + path := filepath.Join(dir, filename) + WriteTestFile(t, path, content) + + return path +} + // WriteActionFixture writes an action fixture to a standard action.yml file. func WriteActionFixture(t *testing.T, dir, fixturePath string) string { t.Helper() @@ -185,10 +199,108 @@ func WriteActionFixtureAs(t *testing.T, dir, filename, fixturePath string) strin return actionPath } +// CreateActionInTempDir creates a temporary directory with an action.yml file. +// This is a convenience wrapper for the common pattern of t.TempDir() + WriteTestFile. +// Returns the temp directory path and the full path to the action.yml file. +// +// Example: +// +// tmpDir, actionPath := testutil.CreateActionInTempDir(t, "name: Test") +func CreateActionInTempDir(t *testing.T, yamlContent string) (tmpDir, actionPath string) { + t.Helper() + + tmpDir = t.TempDir() + actionPath = filepath.Join(tmpDir, appconstants.ActionFileNameYML) + WriteTestFile(t, actionPath, yamlContent) + + return tmpDir, actionPath +} + +// CreateNestedAction creates a nested action directory structure with an action.yml file. +// This is useful for testing monorepo scenarios with multiple actions in subdirectories. +// Returns the subdirectory path and the full path to the action.yml file. +// +// Example: +// +// dirPath, actionPath := testutil.CreateNestedAction(t, tmpDir, "actions/build", "name: Build") +func CreateNestedAction(t *testing.T, baseDir, subdir, yamlContent string) (dirPath, actionPath string) { + t.Helper() + + dirPath = filepath.Join(baseDir, subdir) + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(dirPath, appconstants.FilePermDir); err != nil { + t.Fatalf("failed to create nested directory %s: %v", subdir, err) + } + + actionPath = filepath.Join(dirPath, appconstants.ActionFileNameYML) + WriteTestFile(t, actionPath, yamlContent) + + return dirPath, actionPath +} + +// CreateTestSubdir creates a subdirectory within the base directory. +// This is useful for test setup that needs directory structures without action files. +// Returns the full path to the created subdirectory. +// +// Example: +// +// subdir := testutil.CreateTestSubdir(t, tmpDir, ".config", "gh-action-readme") +// // Creates tmpDir/.config/gh-action-readme +func CreateTestSubdir(t *testing.T, baseDir string, subdirs ...string) string { + t.Helper() + + pathParts := append([]string{baseDir}, subdirs...) + fullPath := filepath.Join(pathParts...) + + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(fullPath, appconstants.FilePermDir); err != nil { + t.Fatalf("failed to create test subdirectory %s: %v", fullPath, err) + } + + return fullPath +} + +// CreateTestDir creates a directory with test-appropriate permissions (0750). +// Automatically fails the test if directory creation fails. +// This is a convenience wrapper to reduce the 30+ instances of: +// +// if err := os.MkdirAll(dir, 0750); err != nil { t.Fatalf(...) } +// +// Example: +// +// testutil.CreateTestDir(t, filepath.Join(tmpDir, ".git")) +func CreateTestDir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0750); err != nil { // #nosec G301 -- test directory permissions + t.Fatalf("failed to create directory %s: %v", path, err) + } +} + +// RunBinaryCommand executes the built binary with arguments in the given directory. +// Returns the combined output (stdout + stderr) and error for verification in tests. +// This helper consolidates the common pattern of running subprocess commands in integration tests. +// +// Example: +// +// output, err := testutil.RunBinaryCommand(t, binaryPath, tmpDir, "gen", "--theme", "github") +// testutil.AssertNoError(t, err) +// if !strings.Contains(output, "Generated") { +// t.Error("expected success message in output") +// } +func RunBinaryCommand(t *testing.T, binaryPath, dir string, args ...string) (output string, err error) { + t.Helper() + + cmd := exec.Command(binaryPath, args...) // #nosec G204 -- controlled test input + cmd.Dir = dir + out, err := cmd.CombinedOutput() + + return string(out), err +} + // CreateConfigDir creates a standard .config/gh-action-readme directory. func CreateConfigDir(t *testing.T, baseDir string) string { t.Helper() - configDir := filepath.Join(baseDir, appconstants.TestDirConfigGhActionReadme) + configDir := filepath.Join(baseDir, TestDirConfigGhActionReadme) // #nosec G301 -- test directory permissions if err := os.MkdirAll(configDir, appconstants.FilePermDir); err != nil { t.Fatalf("failed to create config dir: %v", err) @@ -207,6 +319,45 @@ func WriteConfigFile(t *testing.T, baseDir, content string) string { return configPath } +// SetupConfigEnvironment sets up HOME and XDG_CONFIG_HOME environment variables for testing. +// This is commonly needed for config hierarchy tests. +// +// Example: +// +// testutil.SetupConfigEnvironment(t, tmpDir) +func SetupConfigEnvironment(t *testing.T, tmpDir string) { + t.Helper() + t.Setenv("HOME", tmpDir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, TestDirDotConfig)) +} + +// CreateGitRepoWithRemote initializes a git repository and sets up a remote. +// Returns the path to the git config file for further customization if needed. +// +// Example: +// +// testutil.CreateGitRepoWithRemote(t, tmpDir, "https://github.com/user/repo.git") +func CreateGitRepoWithRemote(t *testing.T, tmpDir, remoteURL string) string { + t.Helper() + + InitGitRepo(t, tmpDir) + + gitDir := filepath.Join(tmpDir, ".git") + configPath := filepath.Join(gitDir, "config") + + configContent := fmt.Sprintf(`[remote "origin"] + url = %s + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "main"] + remote = origin + merge = refs/heads/main +`, remoteURL) + + WriteTestFile(t, configPath, configContent) + + return configPath +} + // CreateActionSubdir creates a subdirectory and writes an action fixture to it. func CreateActionSubdir(t *testing.T, baseDir, subdirName, fixturePath string) string { t.Helper() @@ -242,86 +393,6 @@ func AssertFileNotExists(t *testing.T, path string) { // err != nil && os.IsNotExist(err) - this is the success case } -// MockColoredOutput captures output for testing. -type MockColoredOutput struct { - Messages []string - Errors []string - Quiet bool -} - -// NewMockColoredOutput creates a new mock colored output. -func NewMockColoredOutput(quiet bool) *MockColoredOutput { - return &MockColoredOutput{Quiet: quiet} -} - -// Info captures info messages. -func (m *MockColoredOutput) Info(format string, args ...any) { - if !m.Quiet { - m.Messages = append(m.Messages, fmt.Sprintf("INFO: "+format, args...)) - } -} - -// Success captures success messages. -func (m *MockColoredOutput) Success(format string, args ...any) { - if !m.Quiet { - m.Messages = append(m.Messages, fmt.Sprintf("SUCCESS: "+format, args...)) - } -} - -// Warning captures warning messages. -func (m *MockColoredOutput) Warning(format string, args ...any) { - if !m.Quiet { - m.Messages = append(m.Messages, fmt.Sprintf("WARNING: "+format, args...)) - } -} - -// Error captures error messages. -func (m *MockColoredOutput) Error(format string, args ...any) { - m.Errors = append(m.Errors, fmt.Sprintf("ERROR: "+format, args...)) -} - -// Bold captures bold messages. -func (m *MockColoredOutput) Bold(format string, args ...any) { - if !m.Quiet { - m.Messages = append(m.Messages, fmt.Sprintf("BOLD: "+format, args...)) - } -} - -// Printf captures printf messages. -func (m *MockColoredOutput) Printf(format string, args ...any) { - if !m.Quiet { - m.Messages = append(m.Messages, fmt.Sprintf(format, args...)) - } -} - -// Reset clears all captured messages. -func (m *MockColoredOutput) Reset() { - m.Messages = nil - m.Errors = nil -} - -// HasMessage checks if a message contains the given substring. -func (m *MockColoredOutput) HasMessage(substring string) bool { - for _, msg := range m.Messages { - if strings.Contains(msg, substring) { - return true - } - } - - return false -} - -// HasError checks if an error contains the given substring. -func (m *MockColoredOutput) HasError(substring string) bool { - for _, err := range m.Errors { - if strings.Contains(err, substring) { - return true - } - } - - return false -} - // CreateTestAction creates a test action.yml file content. func CreateTestAction(name, description string, inputs map[string]string) string { var inputsYAML bytes.Buffer @@ -355,7 +426,7 @@ func SetupTestTemplates(t *testing.T, dir string) { themesDir := filepath.Join(templatesDir, "themes") // Create directories - for _, theme := range []string{"github", "gitlab", "minimal", "professional"} { + for _, theme := range []string{TestThemeGitHub, TestThemeGitLab, TestThemeMinimal, TestThemeProfessional} { themeDir := filepath.Join(themesDir, theme) // #nosec G301 -- test directory permissions if err := os.MkdirAll(themeDir, appconstants.FilePermDir); err != nil { @@ -600,3 +671,122 @@ func ErrCreateDir(name string) string { func ErrDiscoverActionFiles() string { return "DiscoverActionFiles() error = %v" } + +// InitGitRepo initializes a git repository in the given directory. +// It runs git init and creates an initial commit. +func InitGitRepo(t *testing.T, dir string) { + t.Helper() + + // Initialize git repo + cmd := exec.Command(appconstants.GitCommand, "init") // #nosec G204 -- test helper with controlled input + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to initialize git repo: %v", err) + } + + // Configure git user for commits + configCmds := [][]string{ + {appconstants.GitCommand, "config", "user.name", "Test User"}, + {appconstants.GitCommand, "config", "user.email", "test@example.com"}, + } + + for _, args := range configCmds { + cmd := exec.Command(args[0], args[1:]...) // #nosec G204 -- test helper + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to configure git: %v", err) + } + } + + // Create an initial commit + readmePath := filepath.Join(dir, appconstants.ReadmeMarkdown) + if err := os.WriteFile(readmePath, []byte("# Test Repository\n"), appconstants.FilePermDefault); err != nil { + t.Fatalf("Failed to create README: %v", err) + } + + addCmd := exec.Command(appconstants.GitCommand, "add", appconstants.ReadmeMarkdown) // #nosec G204 -- test helper + addCmd.Dir = dir + if err := addCmd.Run(); err != nil { + t.Fatalf("Failed to add file to git: %v", err) + } + + commitCmd := exec.Command(appconstants.GitCommand, "commit", "-m", "Initial commit") // #nosec G204 -- test helper + commitCmd.Dir = dir + if err := commitCmd.Run(); err != nil { + t.Fatalf("Failed to create initial commit: %v", err) + } +} + +// CaptureStdout captures stdout output during function execution. +// Useful for testing functions that write to os.Stdout. +func CaptureStdout(f func()) string { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + _ = w.Close() // Ignore error in test helper + os.Stdout = oldStdout + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) // Ignore error in test helper + + return buf.String() +} + +// CaptureStderr captures stderr output during function execution. +// Useful for testing functions that write to os.Stderr. +func CaptureStderr(f func()) string { + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + f() + + _ = w.Close() // Ignore error in test helper + os.Stderr = oldStderr + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) // Ignore error in test helper + + return buf.String() +} + +// OutputStreams holds both stdout and stderr capture results. +type OutputStreams struct { + Stdout string + Stderr string +} + +// CaptureOutputStreams captures both stdout and stderr during function execution. +// Returns a struct with both outputs for convenience. +func CaptureOutputStreams(f func()) *OutputStreams { + return &OutputStreams{ + Stdout: CaptureStdout(f), + Stderr: CaptureStderr(f), + } +} + +// CreateTempActionFile creates a temporary action.yml file with content. +// Returns the file path. File is automatically cleaned up by t.TempDir(). +// Used to eliminate duplication in parser tests (4 occurrences). +func CreateTempActionFile(t *testing.T, content string) string { + t.Helper() + + tmpFile, err := os.CreateTemp(t.TempDir(), TestActionFilePattern) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + if _, err := tmpFile.WriteString(content); err != nil { + _ = tmpFile.Close() + t.Fatalf("failed to write temp file: %v", err) + } + + if err := tmpFile.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + + return tmpFile.Name() +} diff --git a/testutil/testutil_test.go b/testutil/testutil_test.go index 30bafe2..7e43e49 100644 --- a/testutil/testutil_test.go +++ b/testutil/testutil_test.go @@ -2,6 +2,7 @@ package testutil import ( "context" + "fmt" "io" "net/http" "os" @@ -35,7 +36,7 @@ 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") + req := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"test") resp := executeRequest(t, client, req) defer func() { _ = resp.Body.Close() }() @@ -50,7 +51,7 @@ func testMockHTTPClientUnconfiguredEndpoints(t *testing.T) { Responses: make(map[string]*http.Response), } - req := createTestRequest(t, "GET", "https://api.github.com/nonexistent") + req := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"nonexistent") resp := executeRequest(t, client, req) defer func() { _ = resp.Body.Close() }() @@ -64,13 +65,13 @@ func testMockHTTPClientRequestTracking(t *testing.T) { Responses: make(map[string]*http.Response), } - req1 := createTestRequest(t, "GET", "https://api.github.com/test1") - req2 := createTestRequest(t, "POST", "https://api.github.com/test2") + req1 := createTestRequest(t, "GET", ""+TestURLGitHubAPI+"test1") + req2 := createTestRequest(t, "POST", ""+TestURLGitHubAPI+"test2") executeAndCloseResponse(client, req1) executeAndCloseResponse(client, req2) - validateRequestTracking(t, client, 2, "https://api.github.com/test1", "POST") + validateRequestTracking(t, client, 2, ""+TestURLGitHubAPI+"test1", "POST") } // createMockHTTPClientWithResponse creates a mock HTTP client with a single configured response. @@ -101,7 +102,7 @@ func executeRequest(t *testing.T, client *MockHTTPClient, req *http.Request) *ht t.Helper() resp, err := client.Do(req) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(TestErrUnexpected, err) } return resp @@ -176,11 +177,11 @@ func TestMockGitHubClient(t *testing.T) { ctx := context.Background() _, resp, err := client.Repositories.Get(ctx, "test", "repo") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(TestErrUnexpected, err) } if resp.StatusCode != http.StatusOK { - t.Errorf("expected status 200, got %d", resp.StatusCode) + t.Errorf(TestErrStatusCode, resp.StatusCode) } }) @@ -193,11 +194,11 @@ func TestMockGitHubClient(t *testing.T) { ctx := context.Background() _, resp, err := client.Repositories.Get(ctx, "actions", "checkout") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(TestErrUnexpected, err) } if resp.StatusCode != http.StatusOK { - t.Errorf("expected status 200, got %d", resp.StatusCode) + t.Errorf(TestErrStatusCode, resp.StatusCode) } }) } @@ -213,21 +214,21 @@ func TestMockTransport(t *testing.T) { }, } - transport := &mockTransport{client: client} + transport := &MockTransport{Client: client} - req, err := http.NewRequest(http.MethodGet, "https://api.github.com/test", nil) + req, err := http.NewRequest(http.MethodGet, ""+TestURLGitHubAPI+"test", nil) if err != nil { t.Fatalf("failed to create request: %v", err) } resp, err := transport.RoundTrip(req) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(TestErrUnexpected, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - t.Errorf("expected status 200, got %d", resp.StatusCode) + t.Errorf(TestErrStatusCode, resp.StatusCode) } } @@ -352,7 +353,7 @@ func TestSetupTestTemplates(t *testing.T) { } // Verify theme directories exist - themes := []string{"github", "gitlab", "minimal", "professional"} + themes := []string{TestThemeGitHub, TestThemeGitLab, TestThemeMinimal, TestThemeProfessional} for _, theme := range themes { themeDir := filepath.Join(templatesDir, "themes", theme) if _, err := os.Stat(themeDir); os.IsNotExist(err) { @@ -360,7 +361,7 @@ func TestSetupTestTemplates(t *testing.T) { } // Verify theme template file exists - templateFile := filepath.Join(themeDir, "readme.tmpl") + templateFile := filepath.Join(themeDir, TestTemplateReadme) if _, err := os.Stat(templateFile); os.IsNotExist(err) { t.Errorf("template file for theme %s was not created", theme) } @@ -377,273 +378,12 @@ func TestSetupTestTemplates(t *testing.T) { } // Verify default template exists - defaultTemplate := filepath.Join(templatesDir, "readme.tmpl") + defaultTemplate := filepath.Join(templatesDir, TestTemplateReadme) if _, err := os.Stat(defaultTemplate); os.IsNotExist(err) { t.Error("default template was not created") } } -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) - validateEmptyMessagesAndErrors(t, output) -} - -// 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") -} - -// 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") -} - -// 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") -} - -// 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") - - // Test errors in quiet mode - output.Quiet = true - output.Error("quiet error") - validateErrorCount(t, output, 2) -} - -// 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") -} - -// 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") -} - -// testMockColoredOutputQuietMode tests quiet mode behavior. -func testMockColoredOutputQuietMode(t *testing.T) { - t.Helper() - output := NewMockColoredOutput(true) - - // Send various message types - output.Info("info message") - output.Success("success message") - output.Warning("warning message") - output.Bold("bold message") - output.Printf("printf message") - - validateMessageCount(t, output, 0) - - // Errors should still be captured - output.Error("error message") - validateErrorCount(t, output, 1) -} - -// testMockColoredOutputHasMessage tests HasMessage functionality. -func testMockColoredOutputHasMessage(t *testing.T) { - t.Helper() - output := NewMockColoredOutput(false) - output.Info("test message with keyword") - output.Success("another message") - - validateMessageContains(t, output, "keyword", true) - validateMessageContains(t, output, "another", true) - validateMessageContains(t, output, "nonexistent", false) -} - -// testMockColoredOutputHasError tests HasError functionality. -func testMockColoredOutputHasError(t *testing.T) { - t.Helper() - output := NewMockColoredOutput(false) - output.Error("connection failed") - output.Error("timeout occurred") - - validateErrorContains(t, output, "connection", true) - validateErrorContains(t, output, "timeout", true) - validateErrorContains(t, output, "success", false) -} - -// testMockColoredOutputReset tests Reset functionality. -func testMockColoredOutputReset(t *testing.T) { - t.Helper() - output := NewMockColoredOutput(false) - output.Info("test message") - output.Error("test error") - - validateNonEmptyMessagesAndErrors(t, output) - - output.Reset() - - validateEmptyMessagesAndErrors(t, output) -} - -// Helper functions for validation - -// 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") - } -} - -// 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) - } -} - -// 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") - } -} - -// 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]) - } -} - -// 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]) - } -} - -// 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)) - } -} - -// 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)) - } -} - -// 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) - } -} - -// 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) { @@ -658,7 +398,7 @@ func TestCreateTestAction(t *testing.T) { action := CreateTestAction(name, description, inputs) if action == "" { - t.Fatal("expected non-empty action content") + t.Fatal(TestErrNonEmptyAction) } // Verify the action contains our values @@ -685,7 +425,7 @@ func TestCreateTestAction(t *testing.T) { action := CreateTestAction("Simple Action", "No inputs", nil) if action == "" { - t.Fatal("expected non-empty action content") + t.Fatal(TestErrNonEmptyAction) } if !strings.Contains(action, "Simple Action") { @@ -701,14 +441,14 @@ func TestCreateCompositeAction(t *testing.T) { name := "Composite Test" description := "A composite action" steps := []string{ - "actions/checkout@v4", + TestActionCheckoutV4, "actions/setup-node@v4", } action := CreateCompositeAction(name, description, steps) if action == "" { - t.Fatal("expected non-empty action content") + t.Fatal(TestErrNonEmptyAction) } // Verify the action contains our values @@ -732,7 +472,7 @@ func TestCreateCompositeAction(t *testing.T) { action := CreateCompositeAction("Empty Composite", "No steps", nil) if action == "" { - t.Fatal("expected non-empty action content") + t.Fatal(TestErrNonEmptyAction) } if !strings.Contains(action, "Empty Composite") { @@ -804,7 +544,7 @@ func createFullOverrides() *TestAppConfig { // createPartialOverrides creates a partial set of test overrides. func createPartialOverrides() *TestAppConfig { return &TestAppConfig{ - Theme: "professional", + Theme: TestThemeProfessional, Verbose: true, } } @@ -845,7 +585,7 @@ 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") + validateStringField(t, config.Theme, TestThemeProfessional, "theme") validateBoolField(t, config.Verbose, true, "verbose") } @@ -1099,3 +839,44 @@ func TestNewStringReader(t *testing.T) { } }) } + +func TestCaptureStdout(t *testing.T) { + // Note: Cannot run in parallel as it manipulates global os.Stdout + + output := CaptureStdout(func() { + fmt.Print("test output") + }) + + if output != "test output" { + t.Errorf("expected 'test output', got %q", output) + } +} + +func TestCaptureStderr(t *testing.T) { + // Note: Cannot run in parallel as it manipulates global os.Stderr + + output := CaptureStderr(func() { + fmt.Fprint(os.Stderr, "test error") + }) + + if output != "test error" { + t.Errorf("expected 'test error', got %q", output) + } +} + +func TestCaptureOutputStreams(t *testing.T) { + // Note: Cannot run in parallel as it manipulates global os.Stdout/Stderr + + output := CaptureOutputStreams(func() { + fmt.Print("stdout message") + fmt.Fprint(os.Stderr, "stderr message") + }) + + if output.Stdout != "stdout message" { + t.Errorf("expected stdout 'stdout message', got %q", output.Stdout) + } + + if output.Stderr != "stderr message" { + t.Errorf("expected stderr 'stderr message', got %q", output.Stderr) + } +}