mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-02-22 23:52:35 +00:00
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:
@@ -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
560
testutil/fixtures_test.go
Normal 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
1056
testutil/test_suites.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
998
testutil/testutil_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user