feat: go 1.25.5, dependency updates, renamed internal/errors (#129)

* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
This commit is contained in:
2026-01-01 23:17:29 +02:00
committed by GitHub
parent 85a439d804
commit 7f80105ff5
65 changed files with 2321 additions and 1710 deletions

View File

@@ -10,6 +10,8 @@ import (
"sync"
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// fixtureCache provides thread-safe caching of fixture content.
@@ -48,12 +50,12 @@ func mustReadFixture(filename string) string {
// Load from disk
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
panic("failed to get current file path")
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, "testdata", "yaml-fixtures", filename)
fixturePath := filepath.Join(projectRoot, appconstants.DirTestdata, appconstants.DirYAMLFixtures, filename)
contentBytes, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure
if err != nil {
@@ -68,28 +70,20 @@ func mustReadFixture(filename string) string {
return content
}
// Constants for fixture management.
const (
// YmlExtension represents the standard YAML file extension.
YmlExtension = ".yml"
// YamlExtension represents the alternative YAML file extension.
YamlExtension = ".yaml"
)
// ActionType represents the type of GitHub Action being tested.
type ActionType string
const (
// ActionTypeJavaScript represents JavaScript-based GitHub Actions that run on Node.js.
ActionTypeJavaScript ActionType = "javascript"
ActionTypeJavaScript ActionType = ActionType(appconstants.ActionTypeJavaScript)
// ActionTypeComposite represents composite GitHub Actions that combine multiple steps.
ActionTypeComposite ActionType = "composite"
ActionTypeComposite ActionType = ActionType(appconstants.ActionTypeComposite)
// ActionTypeDocker represents Docker-based GitHub Actions that run in containers.
ActionTypeDocker ActionType = "docker"
ActionTypeDocker ActionType = ActionType(appconstants.ActionTypeDocker)
// ActionTypeInvalid represents invalid or malformed GitHub Actions for testing error scenarios.
ActionTypeInvalid ActionType = "invalid"
ActionTypeInvalid ActionType = ActionType(appconstants.ActionTypeInvalid)
// ActionTypeMinimal represents minimal GitHub Actions with basic configuration.
ActionTypeMinimal ActionType = "minimal"
ActionTypeMinimal ActionType = ActionType(appconstants.ActionTypeMinimal)
)
// TestScenario represents a structured test scenario with metadata.
@@ -338,11 +332,11 @@ var PackageJSONContent = func() string {
result += " \"scripts\": {\n"
result += " \"test\": \"jest\",\n"
result += " \"build\": \"webpack\"\n"
result += " },\n"
result += appconstants.JSONCloseBrace
result += " \"dependencies\": {\n"
result += " \"@actions/core\": \"^1.10.0\",\n"
result += " \"@actions/github\": \"^5.1.1\"\n"
result += " },\n"
result += appconstants.JSONCloseBrace
result += " \"devDependencies\": {\n"
result += " \"jest\": \"^29.0.0\",\n"
result += " \"webpack\": \"^5.0.0\"\n"
@@ -356,12 +350,12 @@ var PackageJSONContent = func() string {
func NewFixtureManager() *FixtureManager {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
panic("failed to get current file path")
panic(appconstants.ErrFailedToGetCurrentFilePath)
}
// Get the project root (go up from testutil/fixtures.go to project root)
projectRoot := filepath.Dir(filepath.Dir(currentFile))
basePath := filepath.Join(projectRoot, "testdata", "yaml-fixtures")
basePath := filepath.Join(projectRoot, appconstants.DirTestdata, appconstants.DirYAMLFixtures)
return &FixtureManager{
basePath: basePath,
@@ -449,8 +443,10 @@ func (fm *FixtureManager) LoadActionFixture(name string) (*ActionFixture, error)
// LoadConfigFixture loads a configuration fixture.
func (fm *FixtureManager) LoadConfigFixture(name string) (*ConfigFixture, error) {
configPath := filepath.Join(fm.basePath, "configs", name)
if !strings.HasSuffix(configPath, YmlExtension) && !strings.HasSuffix(configPath, YamlExtension) {
configPath += YmlExtension
hasYMLExt := strings.HasSuffix(configPath, appconstants.ActionFileExtYML)
hasYAMLExt := strings.HasSuffix(configPath, appconstants.ActionFileExtYAML)
if !hasYMLExt && !hasYAMLExt {
configPath += appconstants.ActionFileExtYML
}
content, err := os.ReadFile(configPath) // #nosec G304 -- test fixture path from project structure
@@ -537,8 +533,10 @@ func (fm *FixtureManager) resolveFixturePath(name string) string {
// ensureYamlExtension adds YAML extension if not present.
func (fm *FixtureManager) ensureYamlExtension(path string) string {
if !strings.HasSuffix(path, YmlExtension) && !strings.HasSuffix(path, YamlExtension) {
path += YmlExtension
hasYMLExt := strings.HasSuffix(path, appconstants.ActionFileExtYML)
hasYAMLExt := strings.HasSuffix(path, appconstants.ActionFileExtYAML)
if !hasYMLExt && !hasYAMLExt {
path += appconstants.ActionFileExtYML
}
return path
@@ -626,7 +624,7 @@ func (fm *FixtureManager) determineActionTypeByContent(content string) ActionTyp
// determineConfigType determines the type of configuration fixture.
func (fm *FixtureManager) determineConfigType(name string) string {
if strings.Contains(name, "global") {
return "global"
return appconstants.ScopeGlobal
}
if strings.Contains(name, "repo") {
return "repo-specific"
@@ -730,7 +728,9 @@ func (fm *FixtureManager) scenarioMatchesTags(scenario *TestScenario, tags []str
// createDefaultScenarios creates a default scenarios file.
func (fm *FixtureManager) createDefaultScenarios(scenarioFile string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(scenarioFile), 0750); err != nil { // #nosec G301 -- test directory permissions
scenarioDir := filepath.Dir(scenarioFile)
// #nosec G301 -- test directory permissions
if err := os.MkdirAll(scenarioDir, appconstants.FilePermDir); err != nil {
return fmt.Errorf("failed to create scenarios directory: %w", err)
}

View File

@@ -61,23 +61,9 @@ func TestMustReadFixture_Panic(t *testing.T) {
t.Parallel()
t.Run("missing file panics", func(t *testing.T) {
t.Parallel()
defer func() {
if r := recover(); r == nil {
t.Error("expected panic but got none")
} else {
errStr, ok := r.(string)
if !ok {
t.Errorf("expected panic to contain string message, got: %T", r)
return
}
if !strings.Contains(errStr, "failed to read fixture") {
t.Errorf("expected panic message about fixture reading, got: %v", r)
}
}
}()
mustReadFixture("nonexistent-file.yml")
ExpectPanic(t, func() {
mustReadFixture("nonexistent-file.yml")
}, "failed to read fixture")
})
}

View File

@@ -10,11 +10,8 @@ import (
"testing"
"github.com/google/go-github/v74/github"
)
// File constants.
const (
readmeFilename = "README.md"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// TestExecutor is a function type for executing specific types of tests.
@@ -355,7 +352,7 @@ func executeTest(t *testing.T, testCase TestCase, ctx *TestContext) *TestResult
}
// Create temporary action file
actionPath := filepath.Join(ctx.TempDir, "action.yml")
actionPath := filepath.Join(ctx.TempDir, appconstants.ActionFileNameYML)
WriteTestFile(t, actionPath, fixture.Content)
}
@@ -571,23 +568,23 @@ func DetectGeneratedFiles(outputDir string, outputFormat string) []string {
if !entry.IsDir() {
name := entry.Name()
// Skip the action.yml we created for testing
if name == "action.yml" {
if name == appconstants.ActionFileNameYML {
continue
}
// Check if this file matches the expected output format
isGenerated := false
switch outputFormat {
case "md":
isGenerated = name == readmeFilename
case "html":
case appconstants.OutputFormatMarkdown:
isGenerated = name == appconstants.ReadmeMarkdown
case appconstants.OutputFormatHTML:
isGenerated = strings.HasSuffix(name, ".html")
case "json":
isGenerated = name == "action-docs.json"
case "asciidoc":
isGenerated = name == "README.adoc"
case appconstants.OutputFormatJSON:
isGenerated = name == appconstants.ActionDocsJSON
case appconstants.OutputFormatASCIIDoc:
isGenerated = name == appconstants.ReadmeASCIIDoc
default:
isGenerated = name == readmeFilename
isGenerated = name == appconstants.ReadmeMarkdown
}
if isGenerated {
@@ -603,7 +600,7 @@ func DetectGeneratedFiles(outputDir string, outputFormat string) []string {
func DefaultTestConfig() *TestConfig {
return &TestConfig{
Theme: "default",
OutputFormat: "md",
OutputFormat: appconstants.OutputFormatMarkdown,
OutputDir: ".",
Verbose: false,
Quiet: false,
@@ -652,7 +649,7 @@ func CreateTemporaryAction(t *testing.T, fixture string) string {
// Load the fixture
actionFixture, err := LoadActionFixture(fixture)
if err != nil {
t.Fatalf("failed to load action fixture %s: %v", fixture, err)
t.Fatalf(appconstants.ErrFailedToLoadActionFixture, fixture, err)
}
// Create temporary directory
@@ -660,7 +657,7 @@ func CreateTemporaryAction(t *testing.T, fixture string) string {
t.Cleanup(cleanup)
// Write action file
actionPath := filepath.Join(tempDir, "action.yml")
actionPath := filepath.Join(tempDir, appconstants.ActionFileNameYML)
WriteTestFile(t, actionPath, actionFixture.Content)
return actionPath
@@ -673,7 +670,7 @@ func CreateTemporaryActionDir(t *testing.T, fixture string) string {
// Load the fixture
actionFixture, err := LoadActionFixture(fixture)
if err != nil {
t.Fatalf("failed to load action fixture %s: %v", fixture, err)
t.Fatalf(appconstants.ErrFailedToLoadActionFixture, fixture, err)
}
// Create temporary directory
@@ -681,7 +678,7 @@ func CreateTemporaryActionDir(t *testing.T, fixture string) string {
t.Cleanup(cleanup)
// Write action file
actionPath := filepath.Join(tempDir, "action.yml")
actionPath := filepath.Join(tempDir, appconstants.ActionFileNameYML)
WriteTestFile(t, actionPath, actionFixture.Content)
return tempDir
@@ -841,7 +838,12 @@ func TestAllThemes(t *testing.T, testFunc func(*testing.T, string)) {
func TestAllFormats(t *testing.T, testFunc func(*testing.T, string)) {
t.Helper()
formats := []string{"md", "html", "json", "asciidoc"}
formats := []string{
appconstants.OutputFormatMarkdown,
appconstants.OutputFormatHTML,
appconstants.OutputFormatJSON,
appconstants.OutputFormatASCIIDoc,
}
for _, format := range formats {
format := format // capture loop variable
@@ -888,8 +890,7 @@ func CreateGitHubMockSuite(scenarios []string) *MockSuite {
func AssertFixtureValid(t *testing.T, fixtureName string) {
t.Helper()
fixture, err := LoadActionFixture(fixtureName)
AssertNoError(t, err)
fixture := MustLoadActionFixture(t, fixtureName)
if !fixture.IsValid {
t.Errorf("fixture %s should be valid but failed validation", fixtureName)
@@ -974,18 +975,18 @@ func CreateActionTestCases() []ActionTestCase {
// getExpectedFilename returns the expected filename for a given output format.
func getExpectedFilename(outputFormat string) string {
switch outputFormat {
case "md":
return "README.md"
case "html":
case appconstants.OutputFormatMarkdown:
return appconstants.ReadmeMarkdown
case appconstants.OutputFormatHTML:
// HTML files have variable names based on action name, so we'll use a pattern
// The DetectGeneratedFiles function will find any .html file
return "*.html"
case "json":
return "action-docs.json"
case "asciidoc":
return "README.adoc"
case appconstants.OutputFormatJSON:
return appconstants.ActionDocsJSON
case appconstants.OutputFormatASCIIDoc:
return appconstants.ReadmeASCIIDoc
default:
return "README.md"
return appconstants.ReadmeMarkdown
}
}
@@ -993,7 +994,12 @@ func getExpectedFilename(outputFormat string) string {
func CreateGeneratorTestCases() []GeneratorTestCase {
validFixtures := GetValidFixtures()
themes := []string{"default", "github", "minimal", "professional"}
formats := []string{"md", "html", "json", "asciidoc"}
formats := []string{
appconstants.OutputFormatMarkdown,
appconstants.OutputFormatHTML,
appconstants.OutputFormatJSON,
appconstants.OutputFormatASCIIDoc,
}
cases := make([]GeneratorTestCase, 0)

View File

@@ -14,6 +14,8 @@ import (
"time"
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// MockHTTPClient is a mock HTTP client for testing.
@@ -91,20 +93,155 @@ func TempDir(t *testing.T) (string, func()) {
}
}
// CleanupCache provides a standard cache cleanup helper for deferred cleanup.
// It returns a function that closes the cache and fails the test on errors.
func CleanupCache(tb testing.TB, cache interface{ Close() error }) func() {
tb.Helper()
return func() {
tb.Helper()
if err := cache.Close(); err != nil {
tb.Fatalf("failed to close cache: %v", err)
}
}
}
// ExpectPanic asserts that the provided function panics with a message containing the expected substring.
// This helper reduces panic recovery test boilerplate from 12-15 lines to 3-4 lines.
func ExpectPanic(t *testing.T, fn func(), expectedSubstring string) {
t.Helper()
defer func() {
if r := recover(); r == nil {
t.Error("expected panic but got none")
} else {
var errStr string
switch v := r.(type) {
case string:
errStr = v
case error:
errStr = v.Error()
default:
errStr = fmt.Sprintf("%v", v)
}
if !strings.Contains(errStr, expectedSubstring) {
t.Errorf("expected panic message containing %q, got: %v", expectedSubstring, r)
}
}
}()
fn()
}
// MustLoadActionFixture loads an action fixture and fails the test on error.
// This helper consolidates the load + assertion pattern.
func MustLoadActionFixture(t *testing.T, path string) *ActionFixture {
t.Helper()
fixture, err := LoadActionFixture(path)
AssertNoError(t, err)
return fixture
}
// LoadAndWriteFixture loads an action fixture and writes it to the specified path.
// This helper reduces the common 3-line pattern to a single line.
func LoadAndWriteFixture(t *testing.T, fixturePath, targetPath string) {
t.Helper()
fixture := MustLoadActionFixture(t, fixturePath)
WriteTestFile(t, targetPath, fixture.Content)
}
// WriteTestFile writes a test file to the given path.
func WriteTestFile(t *testing.T, path, content string) {
t.Helper()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil { // #nosec G301 -- test directory permissions
// #nosec G301 -- test directory permissions
if err := os.MkdirAll(dir, appconstants.FilePermDir); err != nil {
t.Fatalf("failed to create dir %s: %v", dir, err)
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
// #nosec G306 -- test file permissions
if err := os.WriteFile(path, []byte(content), appconstants.FilePermDefault); err != nil {
t.Fatalf("failed to write test file %s: %v", path, err)
}
}
// WriteActionFixture writes an action fixture to a standard action.yml file.
func WriteActionFixture(t *testing.T, dir, fixturePath string) string {
t.Helper()
actionPath := filepath.Join(dir, appconstants.TestPathActionYML)
fixture := MustLoadActionFixture(t, fixturePath)
WriteTestFile(t, actionPath, fixture.Content)
return actionPath
}
// WriteActionFixtureAs writes an action fixture with a custom filename.
func WriteActionFixtureAs(t *testing.T, dir, filename, fixturePath string) string {
t.Helper()
actionPath := filepath.Join(dir, filename)
fixture := MustLoadActionFixture(t, fixturePath)
WriteTestFile(t, actionPath, fixture.Content)
return actionPath
}
// 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)
// #nosec G301 -- test directory permissions
if err := os.MkdirAll(configDir, appconstants.FilePermDir); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
return configDir
}
// WriteConfigFile writes a config file to the standard location.
func WriteConfigFile(t *testing.T, baseDir, content string) string {
t.Helper()
configDir := CreateConfigDir(t, baseDir)
configPath := filepath.Join(configDir, appconstants.ConfigFileNameFull)
WriteTestFile(t, configPath, content)
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()
subDir := filepath.Join(baseDir, subdirName)
// #nosec G301 -- test directory permissions
if err := os.MkdirAll(subDir, appconstants.FilePermDir); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
return WriteActionFixture(t, subDir, fixturePath)
}
// AssertFileExists fails if the file does not exist.
func AssertFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected file to exist: %s", path)
}
}
// AssertFileNotExists fails if the file exists.
func AssertFileNotExists(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
if err == nil {
// File exists
t.Fatalf("expected file not to exist: %s", path)
}
if err != nil && !os.IsNotExist(err) {
// Error occurred but it's not a "does not exist" error
t.Fatalf("error checking file existence: %v", err)
}
// err != nil && os.IsNotExist(err) - this is the success case
}
// MockColoredOutput captures output for testing.
type MockColoredOutput struct {
Messages []string
@@ -192,14 +329,14 @@ func CreateTestAction(name, description string, inputs map[string]string) string
inputsYAML.WriteString(fmt.Sprintf(" %s:\n description: %s\n required: true\n", key, desc))
}
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result := fmt.Sprintf(appconstants.YAMLFieldName, name)
result += fmt.Sprintf(appconstants.YAMLFieldDescription, description)
result += "inputs:\n"
result += inputsYAML.String()
result += "outputs:\n"
result += " result:\n"
result += " description: 'The result'\n"
result += "runs:\n"
result += appconstants.YAMLFieldRuns
result += " using: 'node20'\n"
result += " main: 'index.js'\n"
result += "branding:\n"
@@ -220,16 +357,17 @@ func SetupTestTemplates(t *testing.T, dir string) {
// Create directories
for _, theme := range []string{"github", "gitlab", "minimal", "professional"} {
themeDir := filepath.Join(themesDir, theme)
if err := os.MkdirAll(themeDir, 0750); err != nil { // #nosec G301 -- test directory permissions
// #nosec G301 -- test directory permissions
if err := os.MkdirAll(themeDir, appconstants.FilePermDir); err != nil {
t.Fatalf("failed to create theme dir %s: %v", themeDir, err)
}
// Write theme template
templatePath := filepath.Join(themeDir, "readme.tmpl")
templatePath := filepath.Join(themeDir, appconstants.TemplateReadme)
WriteTestFile(t, templatePath, SimpleTemplate)
}
// Create default template
defaultTemplatePath := filepath.Join(templatesDir, "readme.tmpl")
defaultTemplatePath := filepath.Join(templatesDir, appconstants.TemplateReadme)
WriteTestFile(t, defaultTemplatePath, SimpleTemplate)
}
@@ -240,9 +378,9 @@ func CreateCompositeAction(name, description string, steps []string) string {
stepsYAML.WriteString(fmt.Sprintf(" - name: Step %d\n uses: %s\n", i+1, step))
}
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result += "runs:\n"
result := fmt.Sprintf(appconstants.YAMLFieldName, name)
result += fmt.Sprintf(appconstants.YAMLFieldDescription, description)
result += appconstants.YAMLFieldRuns
result += " using: 'composite'\n"
result += " steps:\n"
result += stepsYAML.String()
@@ -373,6 +511,27 @@ func AssertEqual(t *testing.T, expected, actual any) {
}
}
// AssertSliceContainsAll fails if any of expectedSubstrings is not found in any item of the slice.
// This is useful for checking that suggestions or messages contain expected content.
func AssertSliceContainsAll(t *testing.T, slice []string, expectedSubstrings []string) {
t.Helper()
if len(slice) == 0 {
t.Fatal("slice is empty")
}
allItems := strings.Join(slice, " ")
for _, expected := range expectedSubstrings {
if !strings.Contains(allItems, expected) {
t.Errorf(
"expected to find %q in slice, got:\n%s",
expected,
strings.Join(slice, "\n"),
)
}
}
}
// NewStringReader creates an io.ReadCloser from a string.
func NewStringReader(s string) io.ReadCloser {
return io.NopCloser(strings.NewReader(s))
@@ -392,8 +551,8 @@ func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase {
Name: "GH_README_GITHUB_TOKEN has highest priority",
SetupFunc: func(t *testing.T) func() {
t.Helper()
cleanup1 := SetEnv(t, "GH_README_GITHUB_TOKEN", "priority-token")
cleanup2 := SetEnv(t, "GITHUB_TOKEN", "fallback-token")
cleanup1 := SetEnv(t, appconstants.EnvGitHubToken, "priority-token")
cleanup2 := SetEnv(t, appconstants.EnvGitHubTokenStandard, appconstants.TokenFallback)
return func() {
cleanup1()
@@ -406,19 +565,19 @@ func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase {
Name: "GITHUB_TOKEN as fallback",
SetupFunc: func(t *testing.T) func() {
t.Helper()
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
cleanup := SetEnv(t, "GITHUB_TOKEN", "fallback-token")
_ = os.Unsetenv(appconstants.EnvGitHubToken)
cleanup := SetEnv(t, appconstants.EnvGitHubTokenStandard, appconstants.TokenFallback)
return cleanup
},
ExpectedToken: "fallback-token",
ExpectedToken: appconstants.TokenFallback,
},
{
Name: "no environment variables",
SetupFunc: func(t *testing.T) func() {
t.Helper()
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
_ = os.Unsetenv("GITHUB_TOKEN")
_ = os.Unsetenv(appconstants.EnvGitHubToken)
_ = os.Unsetenv(appconstants.EnvGitHubTokenStandard)
return func() {}
},