refactor: major codebase improvements and test framework overhaul

This commit represents a comprehensive refactoring of the codebase focused on
improving code quality, testability, and maintainability.

Key improvements:
- Implement dependency injection and interface-based architecture
- Add comprehensive test framework with fixtures and test suites
- Fix all linting issues (errcheck, gosec, staticcheck, goconst, etc.)
- Achieve full EditorConfig compliance across all files
- Replace hardcoded test data with proper fixture files
- Add configuration loader with hierarchical config support
- Improve error handling with contextual information
- Add progress indicators for better user feedback
- Enhance Makefile with help system and improved editorconfig commands
- Consolidate constants and remove deprecated code
- Strengthen validation logic for GitHub Actions
- Add focused consumer interfaces for better separation of concerns

Testing improvements:
- Add comprehensive integration tests
- Implement test executor pattern for better test organization
- Create extensive YAML fixture library for testing
- Fix all failing tests and improve test coverage
- Add validation test fixtures to avoid embedded YAML in Go files

Build and tooling:
- Update Makefile to show help by default
- Fix editorconfig commands to use eclint properly
- Add comprehensive help documentation to all make targets
- Improve file selection patterns to avoid glob errors

This refactoring maintains backward compatibility while significantly
improving the internal architecture and developer experience.
This commit is contained in:
2025-08-05 23:20:58 +03:00
parent f9823eef3e
commit f94967713a
93 changed files with 8845 additions and 1224 deletions

View File

@@ -1,6 +1,103 @@
// Package testutil provides testing fixtures for gh-action-readme.
// Package testutil provides testing fixtures and fixture management for gh-action-readme.
package testutil
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"gopkg.in/yaml.v3"
)
// MustReadFixture reads a YAML fixture file from testdata/yaml-fixtures.
func MustReadFixture(filename string) string {
return mustReadFixture(filename)
}
// mustReadFixture reads a YAML fixture file from testdata/yaml-fixtures.
func mustReadFixture(filename string) string {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
panic("failed to get current file path")
}
// 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)
content, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure
if err != nil {
panic("failed to read fixture " + filename + ": " + err.Error())
}
return string(content)
}
// 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"
// ActionTypeComposite represents composite GitHub Actions that combine multiple steps.
ActionTypeComposite ActionType = "composite"
// ActionTypeDocker represents Docker-based GitHub Actions that run in containers.
ActionTypeDocker ActionType = "docker"
// ActionTypeInvalid represents invalid or malformed GitHub Actions for testing error scenarios.
ActionTypeInvalid ActionType = "invalid"
// ActionTypeMinimal represents minimal GitHub Actions with basic configuration.
ActionTypeMinimal ActionType = "minimal"
)
// TestScenario represents a structured test scenario with metadata.
type TestScenario struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
ActionType ActionType `yaml:"action_type"`
Fixture string `yaml:"fixture"`
ExpectValid bool `yaml:"expect_valid"`
ExpectError bool `yaml:"expect_error"`
Tags []string `yaml:"tags"`
Metadata map[string]any `yaml:"metadata,omitempty"`
}
// ActionFixture represents a loaded action YAML fixture with metadata.
type ActionFixture struct {
Name string
Path string
Content string
ActionType ActionType
IsValid bool
Scenario *TestScenario
}
// ConfigFixture represents a loaded configuration YAML fixture.
type ConfigFixture struct {
Name string
Path string
Content string
Type string
IsValid bool
}
// FixtureManager manages test fixtures and scenarios.
type FixtureManager struct {
basePath string
scenarios map[string]*TestScenario
cache map[string]*ActionFixture
}
// GitHub API response fixtures for testing.
// GitHubReleaseResponse is a mock GitHub release API response.
@@ -182,124 +279,6 @@ func MockGitHubResponses() map[string]string {
}
}
// Sample action.yml files for testing.
// SimpleActionYML is a basic GitHub Action YAML.
const SimpleActionYML = `name: 'Simple Action'
description: 'A simple test action'
inputs:
input1:
description: 'First input'
required: true
input2:
description: 'Second input'
required: false
default: 'default-value'
outputs:
output1:
description: 'First output'
runs:
using: 'node20'
main: 'index.js'
branding:
icon: 'activity'
color: 'blue'
`
// CompositeActionYML is a composite GitHub Action with dependencies.
const CompositeActionYML = `name: 'Composite Action'
description: 'A composite action with dependencies'
inputs:
version:
description: 'Version to use'
required: true
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '${{ inputs.version }}'
- name: Run tests
run: npm test
shell: bash
`
// DockerActionYML is a Docker-based GitHub Action.
const DockerActionYML = `name: 'Docker Action'
description: 'A Docker-based action'
inputs:
dockerfile:
description: 'Path to Dockerfile'
required: false
default: 'Dockerfile'
outputs:
image:
description: 'Built image name'
runs:
using: 'docker'
image: 'Dockerfile'
env:
CUSTOM_VAR: 'value'
branding:
icon: 'package'
color: 'purple'
`
// InvalidActionYML is an invalid action.yml for error testing.
const InvalidActionYML = `name: 'Invalid Action'
# Missing required description field
inputs:
invalid_input:
# Missing required description
required: true
runs:
# Invalid using value
using: 'invalid-runtime'
`
// MinimalActionYML is a minimal valid action.yml.
const MinimalActionYML = `name: 'Minimal Action'
description: 'Minimal test action'
runs:
using: 'node20'
main: 'index.js'
`
// Configuration file fixtures.
// DefaultConfigYAML is a default configuration file.
const DefaultConfigYAML = `theme: github
output_format: md
output_dir: .
verbose: false
quiet: false
`
// CustomConfigYAML is a custom configuration file.
const CustomConfigYAML = `theme: professional
output_format: html
output_dir: docs
template: custom-template.tmpl
schema: custom-schema.json
verbose: true
quiet: false
github_token: test-token-from-config
`
// RepoSpecificConfigYAML is a repository-specific configuration.
const RepoSpecificConfigYAML = `theme: minimal
output_format: json
branding:
icon: star
color: green
dependencies:
pin_versions: true
auto_update: false
`
// GitIgnoreContent is a sample .gitignore file.
const GitIgnoreContent = `# Dependencies
node_modules/
@@ -315,22 +294,493 @@ Thumbs.db
`
// PackageJSONContent is a sample package.json file.
const PackageJSONContent = `{
"name": "test-action",
"version": "1.0.0",
"description": "Test GitHub Action",
"main": "index.js",
"scripts": {
"test": "jest",
"build": "webpack"
},
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1"
},
"devDependencies": {
"jest": "^29.0.0",
"webpack": "^5.0.0"
}
var PackageJSONContent = func() string {
var result string
result += "{\n"
result += " \"name\": \"test-action\",\n"
result += " \"version\": \"1.0.0\",\n"
result += " \"description\": \"Test GitHub Action\",\n"
result += " \"main\": \"index.js\",\n"
result += " \"scripts\": {\n"
result += " \"test\": \"jest\",\n"
result += " \"build\": \"webpack\"\n"
result += " },\n"
result += " \"dependencies\": {\n"
result += " \"@actions/core\": \"^1.10.0\",\n"
result += " \"@actions/github\": \"^5.1.1\"\n"
result += " },\n"
result += " \"devDependencies\": {\n"
result += " \"jest\": \"^29.0.0\",\n"
result += " \"webpack\": \"^5.0.0\"\n"
result += " }\n"
result += "}\n"
return result
}()
// NewFixtureManager creates a new fixture manager.
func NewFixtureManager() *FixtureManager {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
panic("failed to get current file path")
}
// 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")
return &FixtureManager{
basePath: basePath,
scenarios: make(map[string]*TestScenario),
cache: make(map[string]*ActionFixture),
}
}
// LoadScenarios loads test scenarios from the scenarios directory.
func (fm *FixtureManager) LoadScenarios() error {
scenarioFile := filepath.Join(fm.basePath, "scenarios", "test-scenarios.yml")
// Create default scenarios if file doesn't exist
if _, err := os.Stat(scenarioFile); os.IsNotExist(err) {
return fm.createDefaultScenarios(scenarioFile)
}
data, err := os.ReadFile(scenarioFile) // #nosec G304 -- test fixture path from project structure
if err != nil {
return fmt.Errorf("failed to read scenarios file: %w", err)
}
var scenarios struct {
Scenarios []TestScenario `yaml:"scenarios"`
}
if err := yaml.Unmarshal(data, &scenarios); err != nil {
return fmt.Errorf("failed to parse scenarios YAML: %w", err)
}
for i := range scenarios.Scenarios {
scenario := &scenarios.Scenarios[i]
fm.scenarios[scenario.ID] = scenario
}
return nil
}
// LoadActionFixture loads an action fixture with metadata.
func (fm *FixtureManager) LoadActionFixture(name string) (*ActionFixture, error) {
// Check cache first
if fixture, exists := fm.cache[name]; exists {
return fixture, nil
}
// Determine fixture path based on naming convention
fixturePath := fm.resolveFixturePath(name)
content, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path resolution
if err != nil {
return nil, fmt.Errorf("failed to read fixture %s: %w", name, err)
}
fixture := &ActionFixture{
Name: name,
Path: fixturePath,
Content: string(content),
ActionType: fm.determineActionType(name, string(content)),
IsValid: fm.validateFixtureContent(string(content)),
}
// Try to find associated scenario
if scenario, exists := fm.scenarios[name]; exists {
fixture.Scenario = scenario
}
// Cache the fixture
fm.cache[name] = fixture
return fixture, nil
}
// 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
}
content, err := os.ReadFile(configPath) // #nosec G304 -- test fixture path from project structure
if err != nil {
return nil, fmt.Errorf("failed to read config fixture %s: %w", name, err)
}
return &ConfigFixture{
Name: name,
Path: configPath,
Content: string(content),
Type: fm.determineConfigType(name),
IsValid: fm.validateConfigContent(string(content)),
}, nil
}
// GetFixturesByTag returns fixture names matching the specified tags.
func (fm *FixtureManager) GetFixturesByTag(tags ...string) []string {
var matches []string
for _, scenario := range fm.scenarios {
if fm.scenarioMatchesTags(scenario, tags) {
matches = append(matches, scenario.Fixture)
}
}
return matches
}
// GetFixturesByActionType returns fixtures of a specific action type.
func (fm *FixtureManager) GetFixturesByActionType(actionType ActionType) []string {
var matches []string
for _, scenario := range fm.scenarios {
if scenario.ActionType == actionType {
matches = append(matches, scenario.Fixture)
}
}
return matches
}
// GetValidFixtures returns all fixtures that should parse as valid actions.
func (fm *FixtureManager) GetValidFixtures() []string {
var matches []string
for _, scenario := range fm.scenarios {
if scenario.ExpectValid {
matches = append(matches, scenario.Fixture)
}
}
return matches
}
// GetInvalidFixtures returns all fixtures that should be invalid.
func (fm *FixtureManager) GetInvalidFixtures() []string {
var matches []string
for _, scenario := range fm.scenarios {
if !scenario.ExpectValid {
matches = append(matches, scenario.Fixture)
}
}
return matches
}
// resolveFixturePath determines the full path to a fixture file.
func (fm *FixtureManager) resolveFixturePath(name string) string {
// If it's a direct path, use it
if strings.Contains(name, "/") {
return fm.ensureYamlExtension(filepath.Join(fm.basePath, name))
}
// Try to find the fixture in search directories
if foundPath := fm.searchInDirectories(name); foundPath != "" {
return foundPath
}
// Default to root level if not found
return fm.ensureYamlExtension(filepath.Join(fm.basePath, name))
}
// 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
}
return path
}
// searchInDirectories searches for fixture in predefined directories.
func (fm *FixtureManager) searchInDirectories(name string) string {
searchDirs := []string{
"actions/javascript",
"actions/composite",
"actions/docker",
"actions/invalid",
"", // root level
}
for _, dir := range searchDirs {
path := fm.buildSearchPath(dir, name)
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// buildSearchPath constructs search path for a directory.
func (fm *FixtureManager) buildSearchPath(dir, name string) string {
var path string
if dir == "" {
path = filepath.Join(fm.basePath, name)
} else {
path = filepath.Join(fm.basePath, dir, name)
}
return fm.ensureYamlExtension(path)
}
// determineActionType infers action type from fixture name and content.
func (fm *FixtureManager) determineActionType(name, content string) ActionType {
// Check by name/path first
if actionType := fm.determineActionTypeByName(name); actionType != ActionTypeMinimal {
return actionType
}
// Fall back to content analysis
return fm.determineActionTypeByContent(content)
}
// determineActionTypeByName infers action type from fixture name or path.
func (fm *FixtureManager) determineActionTypeByName(name string) ActionType {
if strings.Contains(name, "javascript") || strings.Contains(name, "node") {
return ActionTypeJavaScript
}
if strings.Contains(name, "composite") {
return ActionTypeComposite
}
if strings.Contains(name, "docker") {
return ActionTypeDocker
}
if strings.Contains(name, "invalid") {
return ActionTypeInvalid
}
if strings.Contains(name, "minimal") {
return ActionTypeMinimal
}
return ActionTypeMinimal
}
// determineActionTypeByContent infers action type from YAML content.
func (fm *FixtureManager) determineActionTypeByContent(content string) ActionType {
if strings.Contains(content, `using: 'composite'`) || strings.Contains(content, `using: "composite"`) {
return ActionTypeComposite
}
if strings.Contains(content, `using: 'docker'`) || strings.Contains(content, `using: "docker"`) {
return ActionTypeDocker
}
if strings.Contains(content, `using: 'node`) {
return ActionTypeJavaScript
}
return ActionTypeMinimal
}
// determineConfigType determines the type of configuration fixture.
func (fm *FixtureManager) determineConfigType(name string) string {
if strings.Contains(name, "global") {
return "global"
}
if strings.Contains(name, "repo") {
return "repo-specific"
}
if strings.Contains(name, "user") {
return "user-specific"
}
return "generic"
}
// validateFixtureContent performs basic validation on fixture content.
func (fm *FixtureManager) validateFixtureContent(content string) bool {
// Basic YAML structure validation
var data map[string]any
if err := yaml.Unmarshal([]byte(content), &data); err != nil {
return false
}
// Check for required fields for valid actions
if _, hasName := data["name"]; !hasName {
return false
}
if _, hasDescription := data["description"]; !hasDescription {
return false
}
runs, hasRuns := data["runs"]
if !hasRuns {
return false
}
// Validate the runs section content more thoroughly
runsMap, ok := runs.(map[string]any)
if !ok {
return false // runs field exists but is not a map
}
using, hasUsing := runsMap["using"]
if !hasUsing {
return false // runs section exists but has no using field
}
usingStr, ok := using.(string)
if !ok {
return false // using field exists but is not a string
}
// Use the same validation logic as ValidateActionYML
if !isValidRuntime(usingStr) {
return false
}
return true
}
// isValidRuntime checks if the given runtime is valid for GitHub Actions.
// This is duplicated from internal/validator.go to avoid import cycle.
func isValidRuntime(runtime string) bool {
validRuntimes := []string{
"node12", // Legacy Node.js runtime (deprecated)
"node16", // Legacy Node.js runtime (deprecated)
"node20", // Current Node.js runtime
"docker", // Docker container runtime
"composite", // Composite action runtime
}
runtime = strings.TrimSpace(strings.ToLower(runtime))
for _, valid := range validRuntimes {
if runtime == valid {
return true
}
}
return false
}
// validateConfigContent validates configuration fixture content.
func (fm *FixtureManager) validateConfigContent(content string) bool {
var data map[string]any
return yaml.Unmarshal([]byte(content), &data) == nil
}
// scenarioMatchesTags checks if a scenario matches any of the provided tags.
func (fm *FixtureManager) scenarioMatchesTags(scenario *TestScenario, tags []string) bool {
if len(tags) == 0 {
return true
}
for _, tag := range tags {
for _, scenarioTag := range scenario.Tags {
if tag == scenarioTag {
return true
}
}
}
return false
}
// 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
return fmt.Errorf("failed to create scenarios directory: %w", err)
}
defaultScenarios := struct {
Scenarios []TestScenario `yaml:"scenarios"`
}{
Scenarios: []TestScenario{
{
ID: "simple-javascript",
Name: "Simple JavaScript Action",
Description: "Basic JavaScript action with minimal configuration",
ActionType: ActionTypeJavaScript,
Fixture: "actions/javascript/simple.yml",
ExpectValid: true,
ExpectError: false,
Tags: []string{"javascript", "basic", "valid"},
},
{
ID: "composite-basic",
Name: "Basic Composite Action",
Description: "Composite action with multiple steps",
ActionType: ActionTypeComposite,
Fixture: "actions/composite/basic.yml",
ExpectValid: true,
ExpectError: false,
Tags: []string{"composite", "basic", "valid"},
},
{
ID: "docker-basic",
Name: "Basic Docker Action",
Description: "Docker-based action with Dockerfile",
ActionType: ActionTypeDocker,
Fixture: "actions/docker/basic.yml",
ExpectValid: true,
ExpectError: false,
Tags: []string{"docker", "basic", "valid"},
},
{
ID: "invalid-missing-description",
Name: "Invalid Action - Missing Description",
Description: "Action missing required description field",
ActionType: ActionTypeInvalid,
Fixture: "actions/invalid/missing-description.yml",
ExpectValid: false,
ExpectError: true,
Tags: []string{"invalid", "validation", "error"},
},
},
}
data, err := yaml.Marshal(&defaultScenarios)
if err != nil {
return fmt.Errorf("failed to marshal default scenarios: %w", err)
}
if err := os.WriteFile(scenarioFile, data, 0600); err != nil {
return fmt.Errorf("failed to write scenarios file: %w", err)
}
// Load the scenarios we just created
return fm.LoadScenarios()
}
// Global fixture manager instance.
var defaultFixtureManager *FixtureManager
// GetFixtureManager returns the global fixture manager instance.
func GetFixtureManager() *FixtureManager {
if defaultFixtureManager == nil {
defaultFixtureManager = NewFixtureManager()
if err := defaultFixtureManager.LoadScenarios(); err != nil {
panic(fmt.Sprintf("failed to load test scenarios: %v", err))
}
}
return defaultFixtureManager
}
// Helper functions for backward compatibility and convenience
// LoadActionFixture loads an action fixture using the global fixture manager.
func LoadActionFixture(name string) (*ActionFixture, error) {
return GetFixtureManager().LoadActionFixture(name)
}
// LoadConfigFixture loads a config fixture using the global fixture manager.
func LoadConfigFixture(name string) (*ConfigFixture, error) {
return GetFixtureManager().LoadConfigFixture(name)
}
// GetFixturesByTag returns fixtures matching tags using the global fixture manager.
func GetFixturesByTag(tags ...string) []string {
return GetFixtureManager().GetFixturesByTag(tags...)
}
// GetFixturesByActionType returns fixtures by action type using the global fixture manager.
func GetFixturesByActionType(actionType ActionType) []string {
return GetFixtureManager().GetFixturesByActionType(actionType)
}
// GetValidFixtures returns all valid fixtures using the global fixture manager.
func GetValidFixtures() []string {
return GetFixtureManager().GetValidFixtures()
}
// GetInvalidFixtures returns all invalid fixtures using the global fixture manager.
func GetInvalidFixtures() []string {
return GetFixtureManager().GetInvalidFixtures()
}
`