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()
}
`

560
testutil/fixtures_test.go Normal file
View File

@@ -0,0 +1,560 @@
package testutil
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
const testVersion = "v4.1.1"
func TestMustReadFixture(t *testing.T) {
tests := []struct {
name string
filename string
wantErr bool
}{
{
name: "valid fixture file",
filename: "simple-action.yml",
wantErr: false,
},
{
name: "another valid fixture",
filename: "composite-action.yml",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantErr {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic but got none")
}
}()
}
content := mustReadFixture(tt.filename)
if !tt.wantErr {
if content == "" {
t.Error("expected non-empty content")
}
// Verify it's valid YAML
var yamlContent map[string]any
if err := yaml.Unmarshal([]byte(content), &yamlContent); err != nil {
t.Errorf("fixture content is not valid YAML: %v", err)
}
}
})
}
}
func TestMustReadFixture_Panic(t *testing.T) {
t.Run("missing file panics", func(t *testing.T) {
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")
})
}
func TestGitHubAPIResponses(t *testing.T) {
t.Run("GitHubReleaseResponse", func(t *testing.T) {
testGitHubReleaseResponse(t)
})
t.Run("GitHubTagResponse", func(t *testing.T) {
testGitHubTagResponse(t)
})
t.Run("GitHubRepoResponse", func(t *testing.T) {
testGitHubRepoResponse(t)
})
t.Run("GitHubCommitResponse", func(t *testing.T) {
testGitHubCommitResponse(t)
})
t.Run("GitHubRateLimitResponse", func(t *testing.T) {
testGitHubRateLimitResponse(t)
})
t.Run("GitHubErrorResponse", func(t *testing.T) {
testGitHubErrorResponse(t)
})
}
// testGitHubReleaseResponse validates the GitHub release response format.
func testGitHubReleaseResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubReleaseResponse)
if data["id"] == nil {
t.Error("expected id field")
}
if data["tag_name"] != testVersion {
t.Errorf("expected tag_name %s, got %v", testVersion, data["tag_name"])
}
if data["name"] != testVersion {
t.Errorf("expected name %s, got %v", testVersion, data["name"])
}
}
// testGitHubTagResponse validates the GitHub tag response format.
func testGitHubTagResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubTagResponse)
if data["name"] != testVersion {
t.Errorf("expected name %s, got %v", testVersion, data["name"])
}
if data["commit"] == nil {
t.Error("expected commit field")
}
}
// testGitHubRepoResponse validates the GitHub repository response format.
func testGitHubRepoResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubRepoResponse)
if data["name"] != "checkout" {
t.Errorf("expected name checkout, got %v", data["name"])
}
if data["full_name"] != "actions/checkout" {
t.Errorf("expected full_name actions/checkout, got %v", data["full_name"])
}
}
// testGitHubCommitResponse validates the GitHub commit response format.
func testGitHubCommitResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubCommitResponse)
if data["sha"] == nil {
t.Error("expected sha field")
}
if data["commit"] == nil {
t.Error("expected commit field")
}
}
// testGitHubRateLimitResponse validates the GitHub rate limit response format.
func testGitHubRateLimitResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubRateLimitResponse)
if data["resources"] == nil {
t.Error("expected resources field")
}
if data["rate"] == nil {
t.Error("expected rate field")
}
}
// testGitHubErrorResponse validates the GitHub error response format.
func testGitHubErrorResponse(t *testing.T) {
data := parseJSONResponse(t, GitHubErrorResponse)
if data["message"] != "Not Found" {
t.Errorf("expected message 'Not Found', got %v", data["message"])
}
}
// parseJSONResponse parses a JSON response string and returns the data map.
func parseJSONResponse(t *testing.T, response string) map[string]any {
var data map[string]any
if err := json.Unmarshal([]byte(response), &data); err != nil {
t.Fatalf("failed to parse JSON response: %v", err)
}
return data
}
func TestSimpleTemplate(t *testing.T) {
template := SimpleTemplate
// Check that template contains expected sections
expectedSections := []string{
"# {{ .Name }}",
"{{ .Description }}",
"## Installation",
"uses: {{ gitOrg . }}/{{ gitRepo . }}@{{ actionVersion . }}",
"## Inputs",
"## Outputs",
}
for _, section := range expectedSections {
if !strings.Contains(template, section) {
t.Errorf("template missing expected section: %s", section)
}
}
// Verify template has proper structure
if !strings.Contains(template, "```yaml") {
t.Error("template should contain YAML code blocks")
}
if !strings.Contains(template, "| Name | Description |") {
t.Error("template should contain table headers")
}
}
func TestMockGitHubResponses(t *testing.T) {
responses := MockGitHubResponses()
// Test that all expected endpoints are present
expectedEndpoints := []string{
"GET https://api.github.com/repos/actions/checkout/releases/latest",
"GET https://api.github.com/repos/actions/checkout/git/ref/tags/v4.1.1",
"GET https://api.github.com/repos/actions/checkout/tags",
"GET https://api.github.com/repos/actions/checkout",
"GET https://api.github.com/rate_limit",
"GET https://api.github.com/repos/actions/setup-node/releases/latest",
}
for _, endpoint := range expectedEndpoints {
if _, exists := responses[endpoint]; !exists {
t.Errorf("missing endpoint: %s", endpoint)
}
}
// Test that all responses are valid JSON
for endpoint, response := range responses {
var data any
if err := json.Unmarshal([]byte(response), &data); err != nil {
t.Errorf("invalid JSON for endpoint %s: %v", endpoint, err)
}
}
// Test specific response structures
t.Run("checkout releases response", func(t *testing.T) {
response := responses["GET https://api.github.com/repos/actions/checkout/releases/latest"]
var release map[string]any
if err := json.Unmarshal([]byte(response), &release); err != nil {
t.Fatalf("failed to parse release response: %v", err)
}
if release["tag_name"] == nil {
t.Error("release response missing tag_name")
}
})
}
func TestFixtureConstants(t *testing.T) {
// Test that all fixture variables are properly loaded
fixtures := map[string]string{
"SimpleActionYML": MustReadFixture("actions/javascript/simple.yml"),
"CompositeActionYML": MustReadFixture("actions/composite/basic.yml"),
"DockerActionYML": MustReadFixture("actions/docker/basic.yml"),
"InvalidActionYML": MustReadFixture("actions/invalid/missing-description.yml"),
"MinimalActionYML": MustReadFixture("minimal-action.yml"),
"TestProjectActionYML": MustReadFixture("test-project-action.yml"),
"RepoSpecificConfigYAML": MustReadFixture("repo-config.yml"),
"PackageJSONContent": PackageJSONContent,
}
for name, content := range fixtures {
t.Run(name, func(t *testing.T) {
if content == "" {
t.Errorf("%s is empty", name)
}
// For YAML fixtures, verify they're valid YAML (except InvalidActionYML)
if strings.HasSuffix(name, "YML") || strings.HasSuffix(name, "YAML") {
if name != "InvalidActionYML" {
var yamlContent map[string]any
if err := yaml.Unmarshal([]byte(content), &yamlContent); err != nil {
t.Errorf("%s contains invalid YAML: %v", name, err)
}
}
}
// For JSON fixtures, verify they're valid JSON
if strings.Contains(name, "JSON") {
var jsonContent any
if err := json.Unmarshal([]byte(content), &jsonContent); err != nil {
t.Errorf("%s contains invalid JSON: %v", name, err)
}
}
})
}
}
func TestGitIgnoreContent(t *testing.T) {
content := GitIgnoreContent
expectedPatterns := []string{
"node_modules/",
"*.log",
"dist/",
"build/",
".DS_Store",
"Thumbs.db",
}
for _, pattern := range expectedPatterns {
if !strings.Contains(content, pattern) {
t.Errorf("GitIgnoreContent missing pattern: %s", pattern)
}
}
// Verify it has comments
if !strings.Contains(content, "# Dependencies") {
t.Error("GitIgnoreContent should contain section comments")
}
}
// Test helper functions that interact with the filesystem.
func TestFixtureFileSystem(t *testing.T) {
// Verify that the fixture files actually exist
fixtureFiles := []string{
"simple-action.yml",
"composite-action.yml",
"docker-action.yml",
"invalid-action.yml",
"minimal-action.yml",
"test-project-action.yml",
"repo-config.yml",
"package.json",
"dynamic-action-template.yml",
"composite-template.yml",
}
// Get the testdata directory path
projectRoot := func() string {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
return filepath.Dir(wd) // Go up from testutil to project root
}()
fixturesDir := filepath.Join(projectRoot, "testdata", "yaml-fixtures")
for _, filename := range fixtureFiles {
t.Run(filename, func(t *testing.T) {
path := filepath.Join(fixturesDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("fixture file does not exist: %s", path)
}
})
}
}
// Tests for FixtureManager functionality (consolidated from scenarios.go tests)
func TestNewFixtureManager(t *testing.T) {
fm := NewFixtureManager()
if fm == nil {
t.Fatal("expected fixture manager to be created")
}
if fm.basePath == "" {
t.Error("expected basePath to be set")
}
if fm.scenarios == nil {
t.Error("expected scenarios map to be initialized")
}
if fm.cache == nil {
t.Error("expected cache map to be initialized")
}
}
func TestFixtureManagerLoadScenarios(t *testing.T) {
fm := NewFixtureManager()
// Test loading scenarios (will create default if none exist)
err := fm.LoadScenarios()
if err != nil {
t.Fatalf("failed to load scenarios: %v", err)
}
// Should have some default scenarios
if len(fm.scenarios) == 0 {
t.Error("expected default scenarios to be loaded")
}
}
func TestFixtureManagerActionTypes(t *testing.T) {
fm := NewFixtureManager()
tests := []struct {
name string
content string
expected ActionType
}{
{
name: "javascript action",
content: "using: 'node20'",
expected: ActionTypeJavaScript,
},
{
name: "composite action",
content: "using: 'composite'",
expected: ActionTypeComposite,
},
{
name: "docker action",
content: "using: 'docker'",
expected: ActionTypeDocker,
},
{
name: "minimal action",
content: "name: test",
expected: ActionTypeMinimal,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actualType := fm.determineActionTypeByContent(tt.content)
if actualType != tt.expected {
t.Errorf("expected action type %s, got %s", tt.expected, actualType)
}
})
}
}
func TestFixtureManagerValidation(t *testing.T) {
fm := NewFixtureManager()
tests := []struct {
name string
fixture string
expected bool
}{
{
name: "valid action",
fixture: "validation/valid-action.yml",
expected: true,
},
{
name: "missing name",
fixture: "validation/missing-name.yml",
expected: false,
},
{
name: "missing description",
fixture: "validation/missing-description.yml",
expected: false,
},
{
name: "missing runs",
fixture: "validation/missing-runs.yml",
expected: false,
},
{
name: "invalid yaml",
fixture: "validation/invalid-yaml.yml",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
content := MustReadFixture(tt.fixture)
isValid := fm.validateFixtureContent(content)
if isValid != tt.expected {
t.Errorf("expected validation result %v, got %v", tt.expected, isValid)
}
})
}
}
func TestGetFixtureManager(t *testing.T) {
// Test singleton behavior
fm1 := GetFixtureManager()
fm2 := GetFixtureManager()
if fm1 != fm2 {
t.Error("expected GetFixtureManager to return same instance")
}
if fm1 == nil {
t.Fatal("expected fixture manager to be created")
}
}
func TestActionFixtureLoading(t *testing.T) {
// Test loading a fixture that should exist
fixture, err := LoadActionFixture("simple-action.yml")
if err != nil {
t.Fatalf("failed to load simple action fixture: %v", err)
}
if fixture == nil {
t.Fatal("expected fixture to be loaded")
}
if fixture.Name == "" {
t.Error("expected fixture name to be set")
}
if fixture.Content == "" {
t.Error("expected fixture content to be loaded")
}
if fixture.ActionType == "" {
t.Error("expected action type to be determined")
}
}
// Test helper functions for other components
func TestHelperFunctions(t *testing.T) {
t.Run("GetValidFixtures", func(t *testing.T) {
validFixtures := GetValidFixtures()
if len(validFixtures) == 0 {
t.Skip("no valid fixtures available")
}
for _, fixture := range validFixtures {
if fixture == "" {
t.Error("fixture name should not be empty")
}
}
})
t.Run("GetInvalidFixtures", func(t *testing.T) {
invalidFixtures := GetInvalidFixtures()
// It's okay if there are no invalid fixtures for testing
for _, fixture := range invalidFixtures {
if fixture == "" {
t.Error("fixture name should not be empty")
}
}
})
t.Run("GetFixturesByActionType", func(_ *testing.T) {
javascriptFixtures := GetFixturesByActionType(ActionTypeJavaScript)
compositeFixtures := GetFixturesByActionType(ActionTypeComposite)
dockerFixtures := GetFixturesByActionType(ActionTypeDocker)
// We don't require specific fixtures to exist, just test the function works
_ = javascriptFixtures
_ = compositeFixtures
_ = dockerFixtures
})
t.Run("GetFixturesByTag", func(_ *testing.T) {
validTaggedFixtures := GetFixturesByTag("valid")
invalidTaggedFixtures := GetFixturesByTag("invalid")
basicTaggedFixtures := GetFixturesByTag("basic")
// We don't require specific fixtures to exist, just test the function works
_ = validTaggedFixtures
_ = invalidTaggedFixtures
_ = basicTaggedFixtures
})
}

1056
testutil/test_suites.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,21 @@ type MockHTTPClient struct {
Requests []*http.Request
}
// HTTPResponse represents a mock HTTP response.
type HTTPResponse struct {
StatusCode int
Body string
Headers map[string]string
}
// HTTPRequest represents a captured HTTP request.
type HTTPRequest struct {
Method string
URL string
Body string
Headers map[string]string
}
// Do implements the http.Client interface.
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
m.Requests = append(m.Requests, req)
@@ -83,11 +98,11 @@ func WriteTestFile(t *testing.T, path, content string) {
t.Helper()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir %s: %v", dir, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
if err := os.WriteFile(path, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
t.Fatalf("failed to write test file %s: %v", path, err)
}
}
@@ -177,19 +192,20 @@ func CreateTestAction(name, description string, inputs map[string]string) string
inputsYAML.WriteString(fmt.Sprintf(" %s:\n description: %s\n required: true\n", key, desc))
}
return fmt.Sprintf(`name: %s
description: %s
inputs:
%soutputs:
result:
description: 'The result'
runs:
using: 'node20'
main: 'index.js'
branding:
icon: 'zap'
color: 'yellow'
`, name, description, inputsYAML.String())
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result += "inputs:\n"
result += inputsYAML.String()
result += "outputs:\n"
result += " result:\n"
result += " description: 'The result'\n"
result += "runs:\n"
result += " using: 'node20'\n"
result += " main: 'index.js'\n"
result += "branding:\n"
result += " icon: 'zap'\n"
result += " color: 'yellow'\n"
return result
}
// SetupTestTemplates creates template files for testing.
@@ -203,7 +219,7 @@ 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, 0755); err != nil {
if err := os.MkdirAll(themeDir, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create theme dir %s: %v", themeDir, err)
}
// Write theme template
@@ -223,12 +239,13 @@ func CreateCompositeAction(name, description string, steps []string) string {
stepsYAML.WriteString(fmt.Sprintf(" - name: Step %d\n uses: %s\n", i+1, step))
}
return fmt.Sprintf(`name: %s
description: %s
runs:
using: 'composite'
steps:
%s`, name, description, stepsYAML.String())
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result += "runs:\n"
result += " using: 'composite'\n"
result += " steps:\n"
result += stepsYAML.String()
return result
}
// TestAppConfig represents a test configuration structure.

998
testutil/testutil_test.go Normal file
View File

@@ -0,0 +1,998 @@
package testutil
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestMockHTTPClient tests the MockHTTPClient implementation.
func TestMockHTTPClient(t *testing.T) {
t.Run("returns configured response", func(t *testing.T) {
testMockHTTPClientConfiguredResponse(t)
})
t.Run("returns 404 for unconfigured endpoints", func(t *testing.T) {
testMockHTTPClientUnconfiguredEndpoints(t)
})
t.Run("tracks requests", func(t *testing.T) {
testMockHTTPClientRequestTracking(t)
})
}
// testMockHTTPClientConfiguredResponse tests that configured responses are returned correctly.
func testMockHTTPClientConfiguredResponse(t *testing.T) {
client := createMockHTTPClientWithResponse("GET https://api.github.com/test", 200, `{"test": "response"}`)
req := createTestRequest(t, "GET", "https://api.github.com/test")
resp := executeRequest(t, client, req)
defer func() { _ = resp.Body.Close() }()
validateResponseStatus(t, resp, 200)
validateResponseBody(t, resp, `{"test": "response"}`)
}
// testMockHTTPClientUnconfiguredEndpoints tests that unconfigured endpoints return 404.
func testMockHTTPClientUnconfiguredEndpoints(t *testing.T) {
client := &MockHTTPClient{
Responses: make(map[string]*http.Response),
}
req := createTestRequest(t, "GET", "https://api.github.com/nonexistent")
resp := executeRequest(t, client, req)
defer func() { _ = resp.Body.Close() }()
validateResponseStatus(t, resp, 404)
}
// testMockHTTPClientRequestTracking tests that requests are tracked correctly.
func testMockHTTPClientRequestTracking(t *testing.T) {
client := &MockHTTPClient{
Responses: make(map[string]*http.Response),
}
req1 := createTestRequest(t, "GET", "https://api.github.com/test1")
req2 := createTestRequest(t, "POST", "https://api.github.com/test2")
executeAndCloseResponse(client, req1)
executeAndCloseResponse(client, req2)
validateRequestTracking(t, client, 2, "https://api.github.com/test1", "POST")
}
// createMockHTTPClientWithResponse creates a mock HTTP client with a single configured response.
func createMockHTTPClientWithResponse(key string, statusCode int, body string) *MockHTTPClient {
return &MockHTTPClient{
Responses: map[string]*http.Response{
key: {
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
},
},
}
}
// createTestRequest creates an HTTP request for testing purposes.
func createTestRequest(t *testing.T, method, url string) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
return req
}
// executeRequest executes an HTTP request and returns the response.
func executeRequest(t *testing.T, client *MockHTTPClient, req *http.Request) *http.Response {
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return resp
}
// executeAndCloseResponse executes a request and closes the response body.
func executeAndCloseResponse(client *MockHTTPClient, req *http.Request) {
if resp, _ := client.Do(req); resp != nil {
_ = resp.Body.Close()
}
}
// validateResponseStatus validates that the response has the expected status code.
func validateResponseStatus(t *testing.T, resp *http.Response, expectedStatus int) {
if resp.StatusCode != expectedStatus {
t.Errorf("expected status %d, got %d", expectedStatus, resp.StatusCode)
}
}
// validateResponseBody validates that the response body matches the expected content.
func validateResponseBody(t *testing.T, resp *http.Response, expected string) {
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
if string(body) != expected {
t.Errorf("expected body %s, got %s", expected, string(body))
}
}
// validateRequestTracking validates that requests are tracked correctly.
func validateRequestTracking(
t *testing.T,
client *MockHTTPClient,
expectedCount int,
expectedURL, expectedMethod string,
) {
if len(client.Requests) != expectedCount {
t.Errorf("expected %d tracked requests, got %d", expectedCount, len(client.Requests))
return
}
if client.Requests[0].URL.String() != expectedURL {
t.Errorf("unexpected first request URL: %s", client.Requests[0].URL.String())
}
if len(client.Requests) > 1 && client.Requests[1].Method != expectedMethod {
t.Errorf("unexpected second request method: %s", client.Requests[1].Method)
}
}
func TestMockGitHubClient(t *testing.T) {
t.Run("creates client with mocked responses", func(t *testing.T) {
responses := map[string]string{
"GET https://api.github.com/repos/test/repo": `{"name": "repo", "full_name": "test/repo"}`,
}
client := MockGitHubClient(responses)
if client == nil {
t.Fatal("expected client to be created")
}
// Test that we can make a request (this would normally hit the API)
// The mock transport should handle this
ctx := context.Background()
_, resp, err := client.Repositories.Get(ctx, "test", "repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
})
t.Run("uses MockGitHubResponses", func(t *testing.T) {
responses := MockGitHubResponses()
client := MockGitHubClient(responses)
// Test a specific endpoint that we know is mocked
ctx := context.Background()
_, resp, err := client.Repositories.Get(ctx, "actions", "checkout")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
})
}
func TestMockTransport(t *testing.T) {
client := &MockHTTPClient{
Responses: map[string]*http.Response{
"GET https://api.github.com/test": {
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"success": true}`)),
},
},
}
transport := &mockTransport{client: client}
req, err := http.NewRequest("GET", "https://api.github.com/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)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
func TestTempDir(t *testing.T) {
t.Run("creates temporary directory", func(t *testing.T) {
dir, cleanup := TempDir(t)
defer cleanup()
// Verify directory exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
t.Error("temporary directory was not created")
}
// Verify it's in temp location
if !strings.Contains(dir, os.TempDir()) && !strings.Contains(dir, "/tmp") {
t.Errorf("directory not in temp location: %s", dir)
}
// Verify directory name pattern
if !strings.Contains(filepath.Base(dir), "gh-action-readme-test-") {
t.Errorf("unexpected directory name pattern: %s", dir)
}
})
t.Run("cleanup removes directory", func(t *testing.T) {
dir, cleanup := TempDir(t)
// Verify directory exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
t.Error("temporary directory was not created")
}
// Clean up
cleanup()
// Verify directory is removed
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("temporary directory was not cleaned up")
}
})
}
func TestWriteTestFile(t *testing.T) {
tmpDir, cleanup := TempDir(t)
defer cleanup()
t.Run("writes file with content", func(t *testing.T) {
testPath := filepath.Join(tmpDir, "test.txt")
testContent := "Hello, World!"
WriteTestFile(t, testPath, testContent)
// Verify file exists
if _, err := os.Stat(testPath); os.IsNotExist(err) {
t.Error("file was not created")
}
// Verify content
content, err := os.ReadFile(testPath) // #nosec G304 -- test file path
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if string(content) != testContent {
t.Errorf("expected content %s, got %s", testContent, string(content))
}
})
t.Run("creates nested directories", func(t *testing.T) {
nestedPath := filepath.Join(tmpDir, "nested", "deep", "file.txt")
testContent := "nested content"
WriteTestFile(t, nestedPath, testContent)
// Verify file exists
if _, err := os.Stat(nestedPath); os.IsNotExist(err) {
t.Error("nested file was not created")
}
// Verify parent directories exist
parentDir := filepath.Dir(nestedPath)
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
t.Error("parent directories were not created")
}
})
t.Run("sets correct permissions", func(t *testing.T) {
testPath := filepath.Join(tmpDir, "perm-test.txt")
WriteTestFile(t, testPath, "test")
info, err := os.Stat(testPath)
if err != nil {
t.Fatalf("failed to stat file: %v", err)
}
// File should have 0600 permissions
expectedPerm := os.FileMode(0600)
if info.Mode().Perm() != expectedPerm {
t.Errorf("expected permissions %v, got %v", expectedPerm, info.Mode().Perm())
}
})
}
func TestSetupTestTemplates(t *testing.T) {
tmpDir, cleanup := TempDir(t)
defer cleanup()
SetupTestTemplates(t, tmpDir)
// Verify template directories exist
templatesDir := filepath.Join(tmpDir, "templates")
if _, err := os.Stat(templatesDir); os.IsNotExist(err) {
t.Error("templates directory was not created")
}
// Verify theme directories exist
themes := []string{"github", "gitlab", "minimal", "professional"}
for _, theme := range themes {
themeDir := filepath.Join(templatesDir, "themes", theme)
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
t.Errorf("theme directory %s was not created", theme)
}
// Verify theme template file exists
templateFile := filepath.Join(themeDir, "readme.tmpl")
if _, err := os.Stat(templateFile); os.IsNotExist(err) {
t.Errorf("template file for theme %s was not created", theme)
}
// Verify template content
content, err := os.ReadFile(templateFile) // #nosec G304 -- test file path
if err != nil {
t.Errorf("failed to read template file for theme %s: %v", theme, err)
}
if string(content) != SimpleTemplate {
t.Errorf("template content for theme %s doesn't match SimpleTemplate", theme)
}
}
// Verify default template exists
defaultTemplate := filepath.Join(templatesDir, "readme.tmpl")
if _, err := os.Stat(defaultTemplate); os.IsNotExist(err) {
t.Error("default template was not created")
}
}
func TestMockColoredOutput(t *testing.T) {
t.Run("creates mock output", func(t *testing.T) {
testMockColoredOutputCreation(t)
})
t.Run("creates quiet mock output", func(t *testing.T) {
testMockColoredOutputQuietCreation(t)
})
t.Run("captures info messages", func(t *testing.T) {
testMockColoredOutputInfoMessages(t)
})
t.Run("captures success messages", func(t *testing.T) {
testMockColoredOutputSuccessMessages(t)
})
t.Run("captures warning messages", func(t *testing.T) {
testMockColoredOutputWarningMessages(t)
})
t.Run("captures error messages", func(t *testing.T) {
testMockColoredOutputErrorMessages(t)
})
t.Run("captures bold messages", func(t *testing.T) {
testMockColoredOutputBoldMessages(t)
})
t.Run("captures printf messages", func(t *testing.T) {
testMockColoredOutputPrintfMessages(t)
})
t.Run("quiet mode suppresses non-error messages", func(t *testing.T) {
testMockColoredOutputQuietMode(t)
})
t.Run("HasMessage works correctly", func(t *testing.T) {
testMockColoredOutputHasMessage(t)
})
t.Run("HasError works correctly", func(t *testing.T) {
testMockColoredOutputHasError(t)
})
t.Run("Reset clears messages and errors", func(t *testing.T) {
testMockColoredOutputReset(t)
})
}
// testMockColoredOutputCreation tests basic mock output creation.
func testMockColoredOutputCreation(t *testing.T) {
output := NewMockColoredOutput(false)
validateMockOutputCreated(t, output)
validateQuietMode(t, output, false)
validateEmptyMessagesAndErrors(t, output)
}
// testMockColoredOutputQuietCreation tests quiet mock output creation.
func testMockColoredOutputQuietCreation(t *testing.T) {
output := NewMockColoredOutput(true)
validateQuietMode(t, output, true)
}
// testMockColoredOutputInfoMessages tests info message capture.
func testMockColoredOutputInfoMessages(t *testing.T) {
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) {
output := NewMockColoredOutput(false)
output.Success("operation completed")
validateSingleMessage(t, output, "SUCCESS: operation completed")
}
// testMockColoredOutputWarningMessages tests warning message capture.
func testMockColoredOutputWarningMessages(t *testing.T) {
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) {
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) {
output := NewMockColoredOutput(false)
output.Bold("bold text")
validateSingleMessage(t, output, "BOLD: bold text")
}
// testMockColoredOutputPrintfMessages tests printf message capture.
func testMockColoredOutputPrintfMessages(t *testing.T) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
validateMessageCount(t, output, 0)
validateErrorCount(t, output, 0)
}
// validateNonEmptyMessagesAndErrors validates that messages and errors are present.
func validateNonEmptyMessagesAndErrors(t *testing.T, output *MockColoredOutput) {
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) {
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) {
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) {
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) {
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) {
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) {
if output.HasError(keyword) != expected {
t.Errorf("expected HasError('%s') to return %v", keyword, expected)
}
}
func TestCreateTestAction(t *testing.T) {
t.Run("creates basic action", func(t *testing.T) {
name := "Test Action"
description := "A test action for testing"
inputs := map[string]string{
"input1": "First input",
"input2": "Second input",
}
action := CreateTestAction(name, description, inputs)
if action == "" {
t.Fatal("expected non-empty action content")
}
// Verify the action contains our values
if !strings.Contains(action, name) {
t.Errorf("action should contain name: %s", name)
}
if !strings.Contains(action, description) {
t.Errorf("action should contain description: %s", description)
}
for inputName, inputDesc := range inputs {
if !strings.Contains(action, inputName) {
t.Errorf("action should contain input name: %s", inputName)
}
if !strings.Contains(action, inputDesc) {
t.Errorf("action should contain input description: %s", inputDesc)
}
}
})
t.Run("creates action with no inputs", func(t *testing.T) {
action := CreateTestAction("Simple Action", "No inputs", nil)
if action == "" {
t.Fatal("expected non-empty action content")
}
if !strings.Contains(action, "Simple Action") {
t.Error("action should contain the name")
}
})
}
func TestCreateCompositeAction(t *testing.T) {
t.Run("creates composite action with steps", func(t *testing.T) {
name := "Composite Test"
description := "A composite action"
steps := []string{
"actions/checkout@v4",
"actions/setup-node@v4",
}
action := CreateCompositeAction(name, description, steps)
if action == "" {
t.Fatal("expected non-empty action content")
}
// Verify the action contains our values
if !strings.Contains(action, name) {
t.Errorf("action should contain name: %s", name)
}
if !strings.Contains(action, description) {
t.Errorf("action should contain description: %s", description)
}
for _, step := range steps {
if !strings.Contains(action, step) {
t.Errorf("action should contain step: %s", step)
}
}
})
t.Run("creates composite action with no steps", func(t *testing.T) {
action := CreateCompositeAction("Empty Composite", "No steps", nil)
if action == "" {
t.Fatal("expected non-empty action content")
}
if !strings.Contains(action, "Empty Composite") {
t.Error("action should contain the name")
}
})
}
func TestMockAppConfig(t *testing.T) {
t.Run("creates default config", func(t *testing.T) {
testMockAppConfigDefaults(t)
})
t.Run("applies overrides", func(t *testing.T) {
testMockAppConfigOverrides(t)
})
t.Run("partial overrides keep defaults", func(t *testing.T) {
testMockAppConfigPartialOverrides(t)
})
}
// testMockAppConfigDefaults tests default config creation.
func testMockAppConfigDefaults(t *testing.T) {
config := MockAppConfig(nil)
validateConfigCreated(t, config)
validateConfigDefaults(t, config)
}
// testMockAppConfigOverrides tests full override application.
func testMockAppConfigOverrides(t *testing.T) {
overrides := createFullOverrides()
config := MockAppConfig(overrides)
validateOverriddenValues(t, config)
}
// testMockAppConfigPartialOverrides tests partial override application.
func testMockAppConfigPartialOverrides(t *testing.T) {
overrides := createPartialOverrides()
config := MockAppConfig(overrides)
validatePartialOverrides(t, config)
validateRemainingDefaults(t, config)
}
// createFullOverrides creates a complete set of test overrides.
func createFullOverrides() *TestAppConfig {
return &TestAppConfig{
Theme: "github",
OutputFormat: "html",
OutputDir: "docs",
Template: "custom.tmpl",
Schema: "custom.schema.json",
Verbose: true,
Quiet: true,
GitHubToken: "test-token",
}
}
// createPartialOverrides creates a partial set of test overrides.
func createPartialOverrides() *TestAppConfig {
return &TestAppConfig{
Theme: "professional",
Verbose: true,
}
}
// validateConfigCreated validates that config was created successfully.
func validateConfigCreated(t *testing.T, config *TestAppConfig) {
if config == nil {
t.Fatal("expected config to be created")
}
}
// validateConfigDefaults validates all default configuration values.
func validateConfigDefaults(t *testing.T, config *TestAppConfig) {
validateStringField(t, config.Theme, "default", "theme")
validateStringField(t, config.OutputFormat, "md", "output format")
validateStringField(t, config.OutputDir, ".", "output dir")
validateStringField(t, config.Schema, "schemas/action.schema.json", "schema")
validateBoolField(t, config.Verbose, false, "verbose")
validateBoolField(t, config.Quiet, false, "quiet")
validateStringField(t, config.GitHubToken, "", "GitHub token")
}
// validateOverriddenValues validates all overridden configuration values.
func validateOverriddenValues(t *testing.T, config *TestAppConfig) {
validateStringField(t, config.Theme, "github", "theme")
validateStringField(t, config.OutputFormat, "html", "output format")
validateStringField(t, config.OutputDir, "docs", "output dir")
validateStringField(t, config.Template, "custom.tmpl", "template")
validateStringField(t, config.Schema, "custom.schema.json", "schema")
validateBoolField(t, config.Verbose, true, "verbose")
validateBoolField(t, config.Quiet, true, "quiet")
validateStringField(t, config.GitHubToken, "test-token", "GitHub token")
}
// validatePartialOverrides validates partially overridden values.
func validatePartialOverrides(t *testing.T, config *TestAppConfig) {
validateStringField(t, config.Theme, "professional", "theme")
validateBoolField(t, config.Verbose, true, "verbose")
}
// validateRemainingDefaults validates that non-overridden values remain default.
func validateRemainingDefaults(t *testing.T, config *TestAppConfig) {
validateStringField(t, config.OutputFormat, "md", "output format")
validateBoolField(t, config.Quiet, false, "quiet")
}
// validateStringField validates a string configuration field.
func validateStringField(t *testing.T, actual, expected, fieldName string) {
if actual != expected {
t.Errorf("expected %s %s, got %s", fieldName, expected, actual)
}
}
// validateBoolField validates a boolean configuration field.
func validateBoolField(t *testing.T, actual, expected bool, fieldName string) {
if actual != expected {
t.Errorf("expected %s to be %v, got %v", fieldName, expected, actual)
}
}
func TestSetEnv(t *testing.T) {
testKey := "TEST_TESTUTIL_VAR"
originalValue := "original"
newValue := "new"
// Ensure the test key is not set initially
_ = os.Unsetenv(testKey)
t.Run("sets new environment variable", func(t *testing.T) {
cleanup := SetEnv(t, testKey, newValue)
defer cleanup()
if os.Getenv(testKey) != newValue {
t.Errorf("expected env var to be %s, got %s", newValue, os.Getenv(testKey))
}
})
t.Run("cleanup unsets new variable", func(t *testing.T) {
cleanup := SetEnv(t, testKey, newValue)
cleanup()
if os.Getenv(testKey) != "" {
t.Errorf("expected env var to be unset, got %s", os.Getenv(testKey))
}
})
t.Run("overrides existing variable", func(t *testing.T) {
// Set original value
_ = os.Setenv(testKey, originalValue)
cleanup := SetEnv(t, testKey, newValue)
defer cleanup()
if os.Getenv(testKey) != newValue {
t.Errorf("expected env var to be %s, got %s", newValue, os.Getenv(testKey))
}
})
t.Run("cleanup restores original variable", func(t *testing.T) {
// Set original value
_ = os.Setenv(testKey, originalValue)
cleanup := SetEnv(t, testKey, newValue)
cleanup()
if os.Getenv(testKey) != originalValue {
t.Errorf("expected env var to be restored to %s, got %s", originalValue, os.Getenv(testKey))
}
})
// Clean up after test
_ = os.Unsetenv(testKey)
}
func TestWithContext(t *testing.T) {
t.Run("creates context with timeout", func(t *testing.T) {
timeout := 100 * time.Millisecond
ctx := WithContext(timeout)
if ctx == nil {
t.Fatal("expected context to be created")
}
// Check that the context has a deadline
deadline, ok := ctx.Deadline()
if !ok {
t.Error("expected context to have a deadline")
}
// The deadline should be approximately now + timeout
expectedDeadline := time.Now().Add(timeout)
timeDiff := deadline.Sub(expectedDeadline)
if timeDiff < -time.Second || timeDiff > time.Second {
t.Errorf("deadline too far from expected: diff = %v", timeDiff)
}
})
t.Run("context eventually times out", func(t *testing.T) {
ctx := WithContext(1 * time.Millisecond)
// Wait a bit longer than the timeout
time.Sleep(10 * time.Millisecond)
select {
case <-ctx.Done():
// Context should be done
if ctx.Err() != context.DeadlineExceeded {
t.Errorf("expected DeadlineExceeded error, got %v", ctx.Err())
}
default:
t.Error("expected context to be done after timeout")
}
})
}
func TestAssertNoError(t *testing.T) {
t.Run("passes with nil error", func(t *testing.T) {
// This should not fail
AssertNoError(t, nil)
})
// Testing the failure case is complex because AssertNoError calls t.Fatalf
// which causes the test to exit. We can't easily test this without
// complex mocking infrastructure, so we'll just test the success case
// The failure case is implicitly tested throughout the codebase where
// AssertNoError is used with actual errors.
}
func TestAssertError(t *testing.T) {
t.Run("passes with non-nil error", func(t *testing.T) {
// This should not fail
AssertError(t, io.EOF)
})
// Similar to AssertNoError, testing the failure case is complex
// The failure behavior is implicitly tested throughout the codebase
}
func TestAssertStringContains(t *testing.T) {
t.Run("passes when string contains substring", func(t *testing.T) {
AssertStringContains(t, "hello world", "world")
AssertStringContains(t, "test string", "test")
AssertStringContains(t, "exact match", "exact match")
})
// Failure case testing is complex due to t.Fatalf behavior
}
func TestAssertEqual(t *testing.T) {
t.Run("passes with equal basic types", func(t *testing.T) {
AssertEqual(t, 42, 42)
AssertEqual(t, "test", "test")
AssertEqual(t, true, true)
AssertEqual(t, 3.14, 3.14)
})
t.Run("passes with equal string maps", func(t *testing.T) {
map1 := map[string]string{"key1": "value1", "key2": "value2"}
map2 := map[string]string{"key1": "value1", "key2": "value2"}
AssertEqual(t, map1, map2)
})
t.Run("passes with empty string maps", func(t *testing.T) {
map1 := map[string]string{}
map2 := map[string]string{}
AssertEqual(t, map1, map2)
})
// Testing failure cases is complex due to t.Fatalf behavior
// The map comparison logic is tested implicitly throughout the codebase
}
func TestNewStringReader(t *testing.T) {
t.Run("creates reader from string", func(t *testing.T) {
testString := "Hello, World!"
reader := NewStringReader(testString)
if reader == nil {
t.Fatal("expected reader to be created")
}
// Read the content
content, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("failed to read from reader: %v", err)
}
if string(content) != testString {
t.Errorf("expected content %s, got %s", testString, string(content))
}
})
t.Run("creates reader from empty string", func(t *testing.T) {
reader := NewStringReader("")
content, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("failed to read from empty reader: %v", err)
}
if len(content) != 0 {
t.Errorf("expected empty content, got %d bytes", len(content))
}
})
t.Run("reader can be closed", func(t *testing.T) {
reader := NewStringReader("test")
err := reader.Close()
if err != nil {
t.Errorf("failed to close reader: %v", err)
}
})
t.Run("handles large strings", func(t *testing.T) {
largeString := strings.Repeat("test ", 10000)
reader := NewStringReader(largeString)
content, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("failed to read large string: %v", err)
}
if string(content) != largeString {
t.Error("large string content mismatch")
}
})
}