Files
gh-action-readme/main_test.go
Ismo Vuorinen 93294f6fd3 feat: ignore vendored directories (#135)
* feat: ignore vendored directories

* chore: cr tweaks

* fix: sonarcloud detected issues

* fix: sonarcloud detected issues
2026-01-03 00:55:09 +02:00

620 lines
16 KiB
Go

package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/wizard"
"github.com/ivuorinen/gh-action-readme/testutil"
)
// TestCLICommands tests the main CLI commands using subprocess execution.
func TestCLICommands(t *testing.T) {
t.Parallel()
// Build the binary for testing
binaryPath := buildTestBinary(t)
tests := []struct {
name string
args []string
setupFunc func(t *testing.T, tmpDir string)
wantExit int
wantStdout string
wantStderr string
}{
{
name: "version command",
args: []string{"version"},
wantExit: 0,
wantStdout: "dev",
},
{
name: "about command",
args: []string{"about"},
wantExit: 0,
wantStdout: "gh-action-readme: Generates README.md and HTML for GitHub Actions",
},
{
name: "help command",
args: []string{"--help"},
wantExit: 0,
wantStdout: "gh-action-readme is a CLI tool for parsing one or many action.yml files and " +
"generating informative, modern, and customizable documentation",
},
{
name: "gen command with valid action",
args: []string{"gen", "--output-format", "md"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 0,
},
{
name: "gen command with theme flag",
args: []string{"gen", "--theme", "github", "--output-format", "json"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 0,
},
{
name: "gen command with no action files",
args: []string{"gen"},
wantExit: 1,
wantStderr: "no GitHub Action files found for documentation generation [NO_ACTION_FILES]",
},
{
name: "validate command with valid action",
args: []string{"validate"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 0,
wantStdout: "All validations passed successfully",
},
{
name: "validate command with invalid action",
args: []string{"validate"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureInvalidMissingDescription)
},
wantExit: 1,
},
{
name: "schema command",
args: []string{"schema"},
wantExit: 0,
wantStdout: "schemas/action.schema.json",
},
{
name: "config command default",
args: []string{"config"},
wantExit: 0,
wantStdout: "Configuration file location:",
},
{
name: "config show command",
args: []string{"config", "show"},
wantExit: 0,
wantStdout: "Current Configuration:",
},
{
name: "config themes command",
args: []string{"config", "themes"},
wantExit: 0,
wantStdout: "Available Themes:",
},
{
name: "deps list command no files",
args: []string{"deps", "list"},
wantExit: 0, // Changed: deps list now outputs warning instead of error when no files found
wantStdout: "No action files found",
},
{
name: "deps list command with composite action",
args: []string{"deps", "list"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
},
wantExit: 0,
},
{
name: "cache path command",
args: []string{"cache", "path"},
wantExit: 0,
wantStdout: "Cache Directory:",
},
{
name: "cache stats command",
args: []string{"cache", "stats"},
wantExit: 0,
wantStdout: "Cache Statistics:",
},
{
name: "invalid command",
args: []string{"invalid-command"},
wantExit: 1,
wantStderr: "unknown command",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory for test
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Setup test environment if needed
if tt.setupFunc != nil {
tt.setupFunc(t, tmpDir)
}
// Run the command in the temporary directory
result := runTestCommand(binaryPath, tt.args, tmpDir)
assertCommandResult(t, result, tt.wantExit, tt.wantStdout, tt.wantStderr)
})
}
}
// TestCLIFlags tests various flag combinations.
func TestCLIFlags(t *testing.T) {
t.Parallel()
binaryPath := buildTestBinary(t)
tests := []struct {
name string
args []string
wantExit int
contains string
}{
{
name: "verbose flag",
args: []string{"--verbose", "config", "show"},
wantExit: 0,
contains: "Current Configuration:",
},
{
name: "quiet flag",
args: []string{"--quiet", "config", "show"},
wantExit: 0,
},
{
name: "config file flag",
args: []string{"--config", "nonexistent.yml", "config", "show"},
wantExit: 1,
},
{
name: "help flag",
args: []string{"-h"},
wantExit: 0,
contains: "Usage:",
},
{
name: "version short flag",
args: []string{"-v", "version"}, // -v is verbose, not version
wantExit: 0,
contains: "dev",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
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)
}
if tt.contains != "" {
// For contains check, look in both stdout and stderr
assertCommandResult(t, result, tt.wantExit, tt.contains, "")
}
})
}
}
// TestCLIRecursiveFlag tests the recursive flag functionality.
func TestCLIRecursiveFlag(t *testing.T) {
t.Parallel()
binaryPath := buildTestBinary(t)
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Create nested directory structure with action files
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, appconstants.TestFixtureCompositeBasic)
tests := []struct {
name string
args []string
wantExit int
minFiles int // minimum number of files that should be processed
}{
{
name: "without recursive flag",
args: []string{"gen", "--output-format", "json"},
wantExit: 0,
minFiles: 1, // should only process root action.yml
},
{
name: "with recursive flag",
args: []string{"gen", "--recursive", "--output-format", "json"},
wantExit: 0,
minFiles: 2, // should process both action.yml files
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runTestCommand(binaryPath, tt.args, tmpDir)
assertCommandResult(t, result, tt.wantExit, "", "")
// 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) {
t.Errorf("expected recursive processing to include subdirectory")
}
})
}
}
// TestCLIErrorHandling tests error scenarios.
func TestCLIErrorHandling(t *testing.T) {
t.Parallel()
binaryPath := buildTestBinary(t)
tests := []struct {
name string
args []string
setupFunc func(t *testing.T, tmpDir string)
wantExit int
wantError string
}{
{
name: "permission denied on output directory",
args: []string{"gen", "--output-dir", "/root/restricted"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 1,
wantError: "encountered 1 errors during batch processing",
},
{
name: "invalid YAML in action file",
args: []string{"validate"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
testutil.WriteTestFile(
t,
filepath.Join(tmpDir, appconstants.ActionFileNameYML),
"invalid: yaml: content: [",
)
},
wantExit: 1,
},
{
name: "unknown output format",
args: []string{"gen", "--output-format", "unknown"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 1,
},
{
name: "unknown theme",
args: []string{"gen", "--theme", "nonexistent-theme"},
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
wantExit: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
if tt.setupFunc != nil {
tt.setupFunc(t, tmpDir)
}
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)
}
if tt.wantError != "" {
output := result.stdout + result.stderr
if !strings.Contains(strings.ToLower(output), strings.ToLower(tt.wantError)) {
t.Errorf("expected error containing %q, got: %s", tt.wantError, output)
}
}
})
}
}
// TestCLIConfigInitialization tests configuration initialization.
func TestCLIConfigInitialization(t *testing.T) {
t.Parallel()
binaryPath := buildTestBinary(t)
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
// Test config init command
cmd := exec.Command(binaryPath, "config", "init") // #nosec G204 -- controlled test input
cmd.Dir = tmpDir
// Set XDG_CONFIG_HOME to temp directory
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() != 0 {
t.Errorf("config init failed: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
}
// Check if config file was created (note: uses .yaml extension, not .yml)
expectedConfigPath := filepath.Join(tmpDir, "gh-action-readme", "config.yaml")
testutil.AssertFileExists(t, expectedConfigPath)
}
// Unit Tests for Helper Functions
// These test the actual functions directly rather than through subprocess execution.
func TestCreateOutputManager(t *testing.T) {
t.Parallel()
tests := []struct {
name string
quiet bool
}{
{"normal mode", false},
{"quiet mode", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := createOutputManager(tt.quiet)
if output == nil {
t.Fatal("createOutputManager returned nil")
}
})
}
}
func TestFormatSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
size int64
expected string
}{
{"zero bytes", 0, "0 bytes"},
{"bytes", 500, "500 bytes"},
{"kilobyte boundary", 1024, "1.00 KB"},
{"kilobytes", 2048, "2.00 KB"},
{"megabyte boundary", 1024 * 1024, "1.00 MB"},
{"megabytes", 5 * 1024 * 1024, "5.00 MB"},
{"gigabyte boundary", 1024 * 1024 * 1024, "1.00 GB"},
{"gigabytes", 3 * 1024 * 1024 * 1024, "3.00 GB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatSize(tt.size)
if result != tt.expected {
t.Errorf("formatSize(%d) = %q, want %q", tt.size, result, tt.expected)
}
})
}
}
func TestResolveExportFormat(t *testing.T) {
t.Parallel()
tests := []struct {
name string
format string
expected wizard.ExportFormat
}{
{"json format", appconstants.OutputFormatJSON, wizard.FormatJSON},
{"toml format", appconstants.OutputFormatTOML, wizard.FormatTOML},
{"yaml format", appconstants.OutputFormatYAML, wizard.FormatYAML},
{"default format", "unknown", wizard.FormatYAML},
{"empty format", "", wizard.FormatYAML},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := resolveExportFormat(tt.format)
if result != tt.expected {
t.Errorf("resolveExportFormat(%q) = %v, want %v", tt.format, result, tt.expected)
}
})
}
}
func TestCreateErrorHandler(t *testing.T) {
t.Parallel()
output := internal.NewColoredOutput(false)
handler := createErrorHandler(output)
if handler == nil {
t.Fatal("createErrorHandler returned nil")
}
}
func TestSetupOutputAndErrorHandling(t *testing.T) {
// Note: This test cannot use t.Parallel() because it modifies globalConfig
// Setup globalConfig for the test
originalConfig := globalConfig
defer func() { globalConfig = originalConfig }()
globalConfig = &internal.AppConfig{Quiet: false}
output, errorHandler := setupOutputAndErrorHandling()
if output == nil {
t.Fatal("setupOutputAndErrorHandling returned nil output")
}
if errorHandler == nil {
t.Fatal("setupOutputAndErrorHandling returned nil errorHandler")
}
}
// Unit Tests for Command Creation Functions
func TestNewGenCmd(t *testing.T) {
t.Parallel()
cmd := newGenCmd()
if cmd.Use != "gen [directory_or_file]" {
t.Errorf("expected Use to be 'gen [directory_or_file]', got %q", cmd.Use)
}
if cmd.Short == "" {
t.Error("expected Short description to be non-empty")
}
if cmd.RunE == nil && cmd.Run == nil {
t.Error("expected command to have a Run or RunE function")
}
// Check that required flags exist
flags := []string{"output-format", "output-dir", "theme", "recursive"}
for _, flag := range flags {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("expected flag %q to exist", flag)
}
}
}
func TestNewValidateCmd(t *testing.T) {
t.Parallel()
cmd := newValidateCmd()
if cmd.Use != "validate" {
t.Errorf("expected Use to be 'validate', got %q", cmd.Use)
}
if cmd.Short == "" {
t.Error("expected Short description to be non-empty")
}
if cmd.RunE == nil && cmd.Run == nil {
t.Error("expected command to have a Run or RunE function")
}
}
func TestNewSchemaCmd(t *testing.T) {
t.Parallel()
cmd := newSchemaCmd()
if cmd.Use != "schema" {
t.Errorf("expected Use to be 'schema', got %q", cmd.Use)
}
if cmd.Short == "" {
t.Error("expected Short description to be non-empty")
}
if cmd.RunE == nil && cmd.Run == nil {
t.Error("expected command to have a Run or RunE function")
}
}
// cmdResult holds the results of a command execution.
type cmdResult struct {
stdout string
stderr string
exitCode int
}
// runTestCommand executes a command with the given args in the specified directory.
// It returns the stdout, stderr, and exit code.
func runTestCommand(binaryPath string, args []string, dir string) cmdResult {
cmd := exec.Command(binaryPath, args...) // #nosec G204 -- controlled test input
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
}
}
return cmdResult{
stdout: stdout.String(),
stderr: stderr.String(),
exitCode: exitCode,
}
}
// createTestActionFile is a helper that creates a test action file from a fixture.
// It writes the specified fixture to action.yml in the given temporary directory.
func createTestActionFile(t *testing.T, tmpDir, fixture string) {
t.Helper()
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(fixture))
}
// assertCommandResult is a helper that asserts the result of a command execution.
// It checks the exit code, and optionally checks for expected content in stdout and stderr.
func assertCommandResult(t *testing.T, result cmdResult, wantExit int, wantStdout, wantStderr string) {
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)
}
// Check stdout if specified
if wantStdout != "" {
if !strings.Contains(result.stdout, wantStdout) {
t.Errorf("expected stdout to contain %q, got: %s", wantStdout, result.stdout)
}
}
// Check stderr if specified
if wantStderr != "" {
if !strings.Contains(result.stderr, wantStderr) {
t.Errorf("expected stderr to contain %q, got: %s", wantStderr, result.stderr)
}
}
}