mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-02-18 11:50:57 +00:00
Initial commit
This commit is contained in:
284
testutil/fixtures.go
Normal file
284
testutil/fixtures.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Package testutil provides testing fixtures for gh-action-readme.
|
||||
package testutil
|
||||
|
||||
// GitHub API response fixtures for testing.
|
||||
|
||||
// GitHubReleaseResponse is a mock GitHub release API response.
|
||||
const GitHubReleaseResponse = `{
|
||||
"id": 123456,
|
||||
"tag_name": "v4.1.1",
|
||||
"name": "v4.1.1",
|
||||
"body": "## What's Changed\n* Fix checkout bug\n* Improve performance",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2023-11-01T10:00:00Z",
|
||||
"published_at": "2023-11-01T10:00:00Z",
|
||||
"tarball_url": "https://api.github.com/repos/actions/checkout/tarball/v4.1.1",
|
||||
"zipball_url": "https://api.github.com/repos/actions/checkout/zipball/v4.1.1"
|
||||
}`
|
||||
|
||||
// GitHubTagResponse is a mock GitHub tag API response.
|
||||
const GitHubTagResponse = `{
|
||||
"name": "v4.1.1",
|
||||
"zipball_url": "https://github.com/actions/checkout/zipball/v4.1.1",
|
||||
"tarball_url": "https://github.com/actions/checkout/tarball/v4.1.1",
|
||||
"commit": {
|
||||
"sha": "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
"url": "https://api.github.com/repos/actions/checkout/commits/8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e"
|
||||
},
|
||||
"node_id": "REF_kwDOAJy2KM9yZXJlZnMvdGFncy92NC4xLjE"
|
||||
}`
|
||||
|
||||
// GitHubRepoResponse is a mock GitHub repository API response.
|
||||
const GitHubRepoResponse = `{
|
||||
"id": 216219028,
|
||||
"name": "checkout",
|
||||
"full_name": "actions/checkout",
|
||||
"description": "Action for checking out a repo",
|
||||
"private": false,
|
||||
"html_url": "https://github.com/actions/checkout",
|
||||
"clone_url": "https://github.com/actions/checkout.git",
|
||||
"git_url": "git://github.com/actions/checkout.git",
|
||||
"ssh_url": "git@github.com:actions/checkout.git",
|
||||
"default_branch": "main",
|
||||
"created_at": "2019-10-16T19:40:57Z",
|
||||
"updated_at": "2023-11-01T10:00:00Z",
|
||||
"pushed_at": "2023-11-01T09:30:00Z",
|
||||
"stargazers_count": 4521,
|
||||
"watchers_count": 4521,
|
||||
"forks_count": 1234,
|
||||
"open_issues_count": 42,
|
||||
"topics": ["github-actions", "checkout", "git"]
|
||||
}`
|
||||
|
||||
// GitHubCommitResponse is a mock GitHub commit API response.
|
||||
const GitHubCommitResponse = `{
|
||||
"sha": "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
"node_id": "C_kwDOAJy2KNoAKDhmNGI3Zjg0YmQ1NzliOTVkN2YwYjkwZjhkOGI2ZTVkOWI4YTdmNmU",
|
||||
"commit": {
|
||||
"message": "Fix checkout bug and improve performance",
|
||||
"author": {
|
||||
"name": "GitHub Actions",
|
||||
"email": "actions@github.com",
|
||||
"date": "2023-11-01T09:30:00Z"
|
||||
},
|
||||
"committer": {
|
||||
"name": "GitHub Actions",
|
||||
"email": "actions@github.com",
|
||||
"date": "2023-11-01T09:30:00Z"
|
||||
}
|
||||
},
|
||||
"html_url": "https://github.com/actions/checkout/commit/8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e"
|
||||
}`
|
||||
|
||||
// GitHubRateLimitResponse is a mock GitHub rate limit API response.
|
||||
const GitHubRateLimitResponse = `{
|
||||
"resources": {
|
||||
"core": {
|
||||
"limit": 5000,
|
||||
"used": 1,
|
||||
"remaining": 4999,
|
||||
"reset": 1699027200
|
||||
},
|
||||
"search": {
|
||||
"limit": 30,
|
||||
"used": 0,
|
||||
"remaining": 30,
|
||||
"reset": 1699027200
|
||||
}
|
||||
},
|
||||
"rate": {
|
||||
"limit": 5000,
|
||||
"used": 1,
|
||||
"remaining": 4999,
|
||||
"reset": 1699027200
|
||||
}
|
||||
}`
|
||||
|
||||
// GitHubErrorResponse is a mock GitHub error API response.
|
||||
const GitHubErrorResponse = `{
|
||||
"message": "Not Found",
|
||||
"documentation_url": "https://docs.github.com/rest"
|
||||
}`
|
||||
|
||||
// MockGitHubResponses returns a map of URL patterns to mock responses.
|
||||
func MockGitHubResponses() map[string]string {
|
||||
return map[string]string{
|
||||
"GET https://api.github.com/repos/actions/checkout/releases/latest": GitHubReleaseResponse,
|
||||
"GET https://api.github.com/repos/actions/checkout/tags": `[` + GitHubTagResponse + `]`,
|
||||
"GET https://api.github.com/repos/actions/checkout": GitHubRepoResponse,
|
||||
"GET https://api.github.com/repos/actions/checkout/commits/" +
|
||||
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e": GitHubCommitResponse,
|
||||
"GET https://api.github.com/rate_limit": GitHubRateLimitResponse,
|
||||
"GET https://api.github.com/repos/actions/setup-node/releases/latest": `{
|
||||
"id": 123457,
|
||||
"tag_name": "v4.0.0",
|
||||
"name": "v4.0.0",
|
||||
"body": "## What's Changed\n* Update Node.js versions\n* Fix compatibility issues",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"created_at": "2023-10-15T10:00:00Z",
|
||||
"published_at": "2023-10-15T10:00:00Z"
|
||||
}`,
|
||||
"GET https://api.github.com/repos/actions/setup-node/tags": `[{
|
||||
"name": "v4.0.0",
|
||||
"commit": {
|
||||
"sha": "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
"url": "https://api.github.com/repos/actions/setup-node/commits/1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b"
|
||||
}
|
||||
}]`,
|
||||
}
|
||||
}
|
||||
|
||||
// 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/
|
||||
*.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
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"
|
||||
}
|
||||
}
|
||||
`
|
||||
339
testutil/testutil.go
Normal file
339
testutil/testutil.go
Normal file
@@ -0,0 +1,339 @@
|
||||
// Package testutil provides testing utilities and mocks for gh-action-readme.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v57/github"
|
||||
)
|
||||
|
||||
// MockHTTPClient is a mock HTTP client for testing.
|
||||
type MockHTTPClient struct {
|
||||
Responses map[string]*http.Response
|
||||
Requests []*http.Request
|
||||
}
|
||||
|
||||
// Do implements the http.Client interface.
|
||||
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
m.Requests = append(m.Requests, req)
|
||||
|
||||
key := req.Method + " " + req.URL.String()
|
||||
if resp, ok := m.Responses[key]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Default 404 response
|
||||
return &http.Response{
|
||||
StatusCode: 404,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error": "not found"}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MockGitHubClient creates a GitHub client with mocked responses.
|
||||
func MockGitHubClient(responses map[string]string) *github.Client {
|
||||
mockClient := &MockHTTPClient{
|
||||
Responses: make(map[string]*http.Response),
|
||||
}
|
||||
|
||||
for key, body := range responses {
|
||||
mockClient.Responses[key] = &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
|
||||
return client
|
||||
}
|
||||
|
||||
type mockTransport struct {
|
||||
client *MockHTTPClient
|
||||
}
|
||||
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.client.Do(req)
|
||||
}
|
||||
|
||||
// TempDir creates a temporary directory for testing and returns cleanup function.
|
||||
func TempDir(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.MkdirTemp("", "gh-action-readme-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
return dir, func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTestFile writes a test file to the given path.
|
||||
func WriteTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("failed to create dir %s: %v", dir, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// MockColoredOutput captures output for testing.
|
||||
type MockColoredOutput struct {
|
||||
Messages []string
|
||||
Errors []string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
// NewMockColoredOutput creates a new mock colored output.
|
||||
func NewMockColoredOutput(quiet bool) *MockColoredOutput {
|
||||
return &MockColoredOutput{Quiet: quiet}
|
||||
}
|
||||
|
||||
// Info captures info messages.
|
||||
func (m *MockColoredOutput) Info(format string, args ...any) {
|
||||
if !m.Quiet {
|
||||
m.Messages = append(m.Messages, fmt.Sprintf("INFO: "+format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Success captures success messages.
|
||||
func (m *MockColoredOutput) Success(format string, args ...any) {
|
||||
if !m.Quiet {
|
||||
m.Messages = append(m.Messages, fmt.Sprintf("SUCCESS: "+format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Warning captures warning messages.
|
||||
func (m *MockColoredOutput) Warning(format string, args ...any) {
|
||||
if !m.Quiet {
|
||||
m.Messages = append(m.Messages, fmt.Sprintf("WARNING: "+format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Error captures error messages.
|
||||
func (m *MockColoredOutput) Error(format string, args ...any) {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf("ERROR: "+format, args...))
|
||||
}
|
||||
|
||||
// Bold captures bold messages.
|
||||
func (m *MockColoredOutput) Bold(format string, args ...any) {
|
||||
if !m.Quiet {
|
||||
m.Messages = append(m.Messages, fmt.Sprintf("BOLD: "+format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Printf captures printf messages.
|
||||
func (m *MockColoredOutput) Printf(format string, args ...any) {
|
||||
if !m.Quiet {
|
||||
m.Messages = append(m.Messages, fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all captured messages.
|
||||
func (m *MockColoredOutput) Reset() {
|
||||
m.Messages = nil
|
||||
m.Errors = nil
|
||||
}
|
||||
|
||||
// HasMessage checks if a message contains the given substring.
|
||||
func (m *MockColoredOutput) HasMessage(substring string) bool {
|
||||
for _, msg := range m.Messages {
|
||||
if strings.Contains(msg, substring) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasError checks if an error contains the given substring.
|
||||
func (m *MockColoredOutput) HasError(substring string) bool {
|
||||
for _, err := range m.Errors {
|
||||
if strings.Contains(err, substring) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateTestAction creates a test action.yml file content.
|
||||
func CreateTestAction(name, description string, inputs map[string]string) string {
|
||||
var inputsYAML bytes.Buffer
|
||||
for key, desc := range inputs {
|
||||
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())
|
||||
}
|
||||
|
||||
// CreateCompositeAction creates a test composite action with dependencies.
|
||||
func CreateCompositeAction(name, description string, steps []string) string {
|
||||
var stepsYAML bytes.Buffer
|
||||
for i, step := range steps {
|
||||
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())
|
||||
}
|
||||
|
||||
// TestAppConfig represents a test configuration structure.
|
||||
type TestAppConfig struct {
|
||||
Theme string
|
||||
OutputFormat string
|
||||
OutputDir string
|
||||
Template string
|
||||
Schema string
|
||||
Verbose bool
|
||||
Quiet bool
|
||||
GitHubToken string
|
||||
}
|
||||
|
||||
// MockAppConfig creates a test configuration.
|
||||
func MockAppConfig(overrides *TestAppConfig) *TestAppConfig {
|
||||
config := &TestAppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Template: "",
|
||||
Schema: "schemas/action.schema.json",
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
GitHubToken: "",
|
||||
}
|
||||
|
||||
if overrides != nil {
|
||||
if overrides.Theme != "" {
|
||||
config.Theme = overrides.Theme
|
||||
}
|
||||
if overrides.OutputFormat != "" {
|
||||
config.OutputFormat = overrides.OutputFormat
|
||||
}
|
||||
if overrides.OutputDir != "" {
|
||||
config.OutputDir = overrides.OutputDir
|
||||
}
|
||||
if overrides.Template != "" {
|
||||
config.Template = overrides.Template
|
||||
}
|
||||
if overrides.Schema != "" {
|
||||
config.Schema = overrides.Schema
|
||||
}
|
||||
config.Verbose = overrides.Verbose
|
||||
config.Quiet = overrides.Quiet
|
||||
if overrides.GitHubToken != "" {
|
||||
config.GitHubToken = overrides.GitHubToken
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SetEnv sets an environment variable for testing and returns cleanup function.
|
||||
func SetEnv(t *testing.T, key, value string) func() {
|
||||
t.Helper()
|
||||
|
||||
original := os.Getenv(key)
|
||||
_ = os.Setenv(key, value)
|
||||
|
||||
return func() {
|
||||
if original == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext creates a context with timeout for testing.
|
||||
func WithContext(timeout time.Duration) context.Context {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
_ = cancel // Avoid lostcancel - we're intentionally creating a context without cleanup for testing
|
||||
return ctx
|
||||
}
|
||||
|
||||
// AssertNoError fails the test if err is not nil.
|
||||
func AssertNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertError fails the test if err is nil.
|
||||
func AssertError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertStringContains fails the test if str doesn't contain substring.
|
||||
func AssertStringContains(t *testing.T, str, substring string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(str, substring) {
|
||||
t.Fatalf("expected string to contain %q, got: %s", substring, str)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertEqual fails the test if expected != actual.
|
||||
func AssertEqual(t *testing.T, expected, actual any) {
|
||||
t.Helper()
|
||||
|
||||
// Handle maps which can't be compared directly
|
||||
if expectedMap, ok := expected.(map[string]string); ok {
|
||||
actualMap, ok := actual.(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected map[string]string, got %T", actual)
|
||||
}
|
||||
|
||||
if len(expectedMap) != len(actualMap) {
|
||||
t.Fatalf("expected map with %d entries, got %d", len(expectedMap), len(actualMap))
|
||||
}
|
||||
|
||||
for k, v := range expectedMap {
|
||||
if actualMap[k] != v {
|
||||
t.Fatalf("expected map[%s] = %s, got %s", k, v, actualMap[k])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if expected != actual {
|
||||
t.Fatalf("expected %v, got %v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// NewStringReader creates an io.ReadCloser from a string.
|
||||
func NewStringReader(s string) io.ReadCloser {
|
||||
return io.NopCloser(strings.NewReader(s))
|
||||
}
|
||||
Reference in New Issue
Block a user