mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-02-08 03:47:05 +00:00
chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling - Upgrade to Go 1.25 - Add benchmark targets to Makefile - Implement parallel gosec execution - Lock tool versions for reproducibility - Add shellcheck directives to scripts - Update CI workflows with improved caching * refactor: migrate from golangci-lint to revive - Replace golangci-lint with revive for linting - Configure comprehensive revive rules - Fix all EditorConfig violations - Add yamllint and yamlfmt support - Remove deprecated .golangci.yml * refactor: rename utils to shared and deduplicate code - Rename utils package to shared - Add shared constants package - Deduplicate constants across packages - Address CodeRabbit review feedback * fix: resolve SonarQube issues and add safety guards - Fix all 73 SonarQube OPEN issues - Add nil guards for resourceMonitor, backpressure, metricsCollector - Implement io.Closer for headerFileReader - Propagate errors from processing helpers - Add metrics and templates packages - Improve error handling across codebase * test: improve test infrastructure and coverage - Add benchmarks for cli, fileproc, metrics - Improve test coverage for cli, fileproc, config - Refactor tests with helper functions - Add shared test constants - Fix test function naming conventions - Reduce cognitive complexity in benchmark tests * docs: update documentation and configuration examples - Update CLAUDE.md with current project state - Refresh README with new features - Add usage and configuration examples - Add SonarQube project configuration - Consolidate config.example.yaml * fix: resolve shellcheck warnings in scripts - Use ./*.go instead of *.go to prevent dash-prefixed filenames from being interpreted as options (SC2035) - Remove unreachable return statement after exit (SC2317) - Remove obsolete gibidiutils/ directory reference * chore(deps): upgrade go dependencies * chore(lint): megalinter fixes * fix: improve test coverage and fix file descriptor leaks - Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests - Refactor TestProcessorConfigureFileTypes with helper functions and assertions - Refactor TestProcessorLogFinalStats with output capture and keyword verification - Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.) - Reduce cognitive complexity by extracting helper functions * fix: align test comments with function names Remove underscores from test comments to match actual function names: - benchmark/benchmark_test.go (2 fixes) - fileproc/filetypes_config_test.go (4 fixes) - fileproc/filetypes_registry_test.go (6 fixes) - fileproc/processor_test.go (6 fixes) - fileproc/resource_monitor_types_test.go (4 fixes) - fileproc/writer_test.go (3 fixes) * fix: various test improvements and bug fixes - Remove duplicate maxCacheSize check in filetypes_registry_test.go - Shorten long comment in processor_test.go to stay under 120 chars - Remove flaky time.Sleep in collector_test.go, use >= 0 assertion - Close pipe reader in benchmark_test.go to fix file descriptor leak - Use ContinueOnError in flags_test.go to match ResetFlags behavior - Add nil check for p.ui in processor_workers.go before UpdateProgress - Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly * chore(yaml): add missing document start markers Add --- document start to YAML files to satisfy yamllint: - .github/workflows/codeql.yml - .github/workflows/build-test-publish.yml - .github/workflows/security.yml - .github/actions/setup/action.yml * fix: guard nil resourceMonitor and fix test deadlock - Guard resourceMonitor before CreateFileProcessingContext call - Add ui.UpdateProgress on emergency stop and path error returns - Fix potential deadlock in TestProcessFile using wg.Go with defer close
This commit is contained in:
@@ -1,7 +1,34 @@
|
||||
// Package testutil provides common testing utilities and helper functions.
|
||||
//
|
||||
// Testing Patterns and Conventions:
|
||||
//
|
||||
// File Setup:
|
||||
// - Use CreateTestFile() for individual files
|
||||
// - Use CreateTestFiles() for multiple files from FileSpec
|
||||
// - Use CreateTestDirectoryStructure() for complex directory trees
|
||||
// - Use SetupTempDirWithStructure() for complete test environments
|
||||
//
|
||||
// Error Assertions:
|
||||
// - Use AssertError() for conditional error checking
|
||||
// - Use AssertNoError() when expecting success
|
||||
// - Use AssertExpectedError() when expecting failure
|
||||
// - Use AssertErrorContains() for substring validation
|
||||
//
|
||||
// Configuration:
|
||||
// - Use ResetViperConfig() to reset between tests
|
||||
// - Remember to call config.LoadConfig() after ResetViperConfig()
|
||||
//
|
||||
// Best Practices:
|
||||
// - Always use t.Helper() in test helper functions
|
||||
// - Use descriptive operation names in assertions
|
||||
// - Prefer table-driven tests for multiple scenarios
|
||||
// - Use testutil.ErrTestError for standard test errors
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -11,22 +38,143 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
// FilePermission is the default file permission for test files.
|
||||
FilePermission = 0o644
|
||||
// DirPermission is the default directory permission for test directories.
|
||||
DirPermission = 0o755
|
||||
)
|
||||
// SuppressLogs suppresses logger output during testing to keep test output clean.
|
||||
// Returns a function that should be called to restore the original log output.
|
||||
func SuppressLogs(t *testing.T) func() {
|
||||
t.Helper()
|
||||
logger := shared.GetLogger()
|
||||
|
||||
// Capture original output by temporarily setting it to discard
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
// Return function to restore original settings (stderr)
|
||||
return func() {
|
||||
logger.SetOutput(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// OutputRestoreFunc represents a function that restores output after suppression.
|
||||
type OutputRestoreFunc func()
|
||||
|
||||
// SuppressAllOutput suppresses both stdout and stderr during testing.
|
||||
// This captures all output including UI messages, progress bars, and direct prints.
|
||||
// Returns a function that should be called to restore original output.
|
||||
func SuppressAllOutput(t *testing.T) OutputRestoreFunc {
|
||||
t.Helper()
|
||||
|
||||
// Save original stdout and stderr
|
||||
originalStdout := os.Stdout
|
||||
originalStderr := os.Stderr
|
||||
|
||||
// Suppress logger output as well
|
||||
logger := shared.GetLogger()
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
// Open /dev/null for safe redirection
|
||||
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open devnull: %v", err)
|
||||
}
|
||||
|
||||
// Redirect both stdout and stderr to /dev/null
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
// Return restore function
|
||||
return func() {
|
||||
// Close devNull first
|
||||
if devNull != nil {
|
||||
_ = devNull.Close() // Ignore close errors in cleanup
|
||||
}
|
||||
|
||||
// Restore original outputs
|
||||
os.Stdout = originalStdout
|
||||
os.Stderr = originalStderr
|
||||
logger.SetOutput(originalStderr)
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureOutput captures both stdout and stderr during test execution.
|
||||
// Returns the captured output as strings and a restore function.
|
||||
func CaptureOutput(t *testing.T) (getStdout func() string, getStderr func() string, restore OutputRestoreFunc) {
|
||||
t.Helper()
|
||||
|
||||
// Save original outputs
|
||||
originalStdout := os.Stdout
|
||||
originalStderr := os.Stderr
|
||||
|
||||
// Create pipes for stdout
|
||||
stdoutReader, stdoutWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create stdout pipe: %v", err)
|
||||
}
|
||||
|
||||
// Create pipes for stderr
|
||||
stderrReader, stderrWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
// Redirect outputs
|
||||
os.Stdout = stdoutWriter
|
||||
os.Stderr = stderrWriter
|
||||
|
||||
// Suppress logger output to stderr
|
||||
logger := shared.GetLogger()
|
||||
logger.SetOutput(stderrWriter)
|
||||
|
||||
// Buffers to collect output
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
|
||||
// Start goroutines to read from pipes
|
||||
stdoutDone := make(chan struct{})
|
||||
stderrDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(stdoutDone)
|
||||
_, _ = io.Copy(&stdoutBuf, stdoutReader) //nolint:errcheck // Ignore errors during test output capture shutdown
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer close(stderrDone)
|
||||
_, _ = io.Copy(&stderrBuf, stderrReader) //nolint:errcheck // Ignore errors during test output capture shutdown
|
||||
}()
|
||||
|
||||
return func() string {
|
||||
return stdoutBuf.String()
|
||||
}, func() string {
|
||||
return stderrBuf.String()
|
||||
}, func() {
|
||||
// Close writers first to signal EOF
|
||||
_ = stdoutWriter.Close() // Ignore close errors in cleanup
|
||||
_ = stderrWriter.Close() // Ignore close errors in cleanup
|
||||
|
||||
// Wait for readers to finish
|
||||
<-stdoutDone
|
||||
<-stderrDone
|
||||
|
||||
// Close readers
|
||||
_ = stdoutReader.Close() // Ignore close errors in cleanup
|
||||
_ = stderrReader.Close() // Ignore close errors in cleanup
|
||||
|
||||
// Restore original outputs
|
||||
os.Stdout = originalStdout
|
||||
os.Stderr = originalStderr
|
||||
logger.SetOutput(originalStderr)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTestFile creates a test file with the given content and returns its path.
|
||||
func CreateTestFile(t *testing.T, dir, filename string, content []byte) string {
|
||||
t.Helper()
|
||||
filePath := filepath.Join(dir, filename)
|
||||
if err := os.WriteFile(filePath, content, FilePermission); err != nil {
|
||||
if err := os.WriteFile(filePath, content, shared.TestFilePermission); err != nil {
|
||||
t.Fatalf("Failed to write file %s: %v", filePath, err)
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
@@ -38,6 +186,7 @@ func CreateTempOutputFile(t *testing.T, pattern string) (file *os.File, path str
|
||||
t.Fatalf("Failed to create temp output file: %v", err)
|
||||
}
|
||||
path = outFile.Name()
|
||||
|
||||
return outFile, path
|
||||
}
|
||||
|
||||
@@ -45,9 +194,10 @@ func CreateTempOutputFile(t *testing.T, pattern string) (file *os.File, path str
|
||||
func CreateTestDirectory(t *testing.T, parent, name string) string {
|
||||
t.Helper()
|
||||
dirPath := filepath.Join(parent, name)
|
||||
if err := os.Mkdir(dirPath, DirPermission); err != nil {
|
||||
if err := os.Mkdir(dirPath, shared.TestDirPermission); err != nil {
|
||||
t.Fatalf("Failed to create directory %s: %v", dirPath, err)
|
||||
}
|
||||
|
||||
return dirPath
|
||||
}
|
||||
|
||||
@@ -65,6 +215,7 @@ func CreateTestFiles(t *testing.T, rootDir string, fileSpecs []FileSpec) []strin
|
||||
filePath := CreateTestFile(t, rootDir, spec.Name, []byte(spec.Content))
|
||||
createdFiles = append(createdFiles, filePath)
|
||||
}
|
||||
|
||||
return createdFiles
|
||||
}
|
||||
|
||||
@@ -78,6 +229,23 @@ func ResetViperConfig(t *testing.T, configPath string) {
|
||||
config.LoadConfig()
|
||||
}
|
||||
|
||||
// SetViperKeys sets specific configuration keys for testing.
|
||||
func SetViperKeys(t *testing.T, keyValues map[string]any) {
|
||||
t.Helper()
|
||||
viper.Reset()
|
||||
for key, value := range keyValues {
|
||||
viper.Set(key, value)
|
||||
}
|
||||
config.LoadConfig()
|
||||
}
|
||||
|
||||
// ApplyBackpressureOverrides applies backpressure configuration overrides for testing.
|
||||
// This is a convenience wrapper around SetViperKeys specifically for backpressure tests.
|
||||
func ApplyBackpressureOverrides(t *testing.T, overrides map[string]any) {
|
||||
t.Helper()
|
||||
SetViperKeys(t, overrides)
|
||||
}
|
||||
|
||||
// SetupCLIArgs configures os.Args for CLI testing.
|
||||
func SetupCLIArgs(srcDir, outFilePath, prefix, suffix string, concurrency int) {
|
||||
os.Args = []string{
|
||||
@@ -87,6 +255,7 @@ func SetupCLIArgs(srcDir, outFilePath, prefix, suffix string, concurrency int) {
|
||||
"-prefix", prefix,
|
||||
"-suffix", suffix,
|
||||
"-concurrency", strconv.Itoa(concurrency),
|
||||
"-no-ui", // Suppress UI output during tests
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +273,7 @@ func VerifyContentContains(t *testing.T, content string, expectedSubstrings []st
|
||||
func MustSucceed(t *testing.T, err error, operation string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("Operation %s failed: %v", operation, err)
|
||||
t.Fatalf(shared.TestMsgOperationFailed, operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,3 +284,130 @@ func CloseFile(t *testing.T, file *os.File) {
|
||||
t.Errorf("Failed to close file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BaseName returns the base name of a file path (filename without directory).
|
||||
func BaseName(path string) string {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
|
||||
// Advanced directory setup patterns.
|
||||
|
||||
// DirSpec represents a directory specification for creating test directory structures.
|
||||
type DirSpec struct {
|
||||
Path string
|
||||
Files []FileSpec
|
||||
}
|
||||
|
||||
// CreateTestDirectoryStructure creates multiple directories with files.
|
||||
func CreateTestDirectoryStructure(t *testing.T, rootDir string, dirSpecs []DirSpec) []string {
|
||||
t.Helper()
|
||||
createdPaths := make([]string, 0)
|
||||
|
||||
for _, dirSpec := range dirSpecs {
|
||||
dirPath := filepath.Join(rootDir, dirSpec.Path)
|
||||
if err := os.MkdirAll(dirPath, shared.TestDirPermission); err != nil {
|
||||
t.Fatalf("Failed to create directory structure %s: %v", dirPath, err)
|
||||
}
|
||||
createdPaths = append(createdPaths, dirPath)
|
||||
|
||||
// Create files in the directory
|
||||
for _, fileSpec := range dirSpec.Files {
|
||||
filePath := CreateTestFile(t, dirPath, fileSpec.Name, []byte(fileSpec.Content))
|
||||
createdPaths = append(createdPaths, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return createdPaths
|
||||
}
|
||||
|
||||
// SetupTempDirWithStructure creates a temp directory with a structured layout.
|
||||
func SetupTempDirWithStructure(t *testing.T, dirSpecs []DirSpec) string {
|
||||
t.Helper()
|
||||
rootDir := t.TempDir()
|
||||
CreateTestDirectoryStructure(t, rootDir, dirSpecs)
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
// Error assertion helpers - safe to use across packages.
|
||||
|
||||
// AssertError checks if an error matches the expected state.
|
||||
// If wantErr is true, expects err to be non-nil.
|
||||
// If wantErr is false, expects err to be nil and fails if it's not.
|
||||
func AssertError(t *testing.T, err error, wantErr bool, operation string) {
|
||||
t.Helper()
|
||||
if (err != nil) != wantErr {
|
||||
if wantErr {
|
||||
t.Errorf(shared.TestMsgOperationNoError, operation)
|
||||
} else {
|
||||
t.Errorf("Operation %s unexpected error: %v", operation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoError fails the test if err is not nil.
|
||||
func AssertNoError(t *testing.T, err error, operation string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf(shared.TestMsgOperationFailed, operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertExpectedError fails the test if err is nil when an error is expected.
|
||||
func AssertExpectedError(t *testing.T, err error, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf(shared.TestMsgOperationNoError, operation)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertErrorContains checks that error contains the expected substring.
|
||||
func AssertErrorContains(t *testing.T, err error, expectedSubstring, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf("Operation %s expected error containing %q but got none", operation, expectedSubstring)
|
||||
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), expectedSubstring) {
|
||||
t.Errorf("Operation %s error %q should contain %q", operation, err.Error(), expectedSubstring)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateErrorCase checks error expectations and optionally validates error message content.
|
||||
// This is a comprehensive helper that combines error checking with substring matching.
|
||||
func ValidateErrorCase(t *testing.T, err error, wantErr bool, errContains string, operation string) {
|
||||
t.Helper()
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error but got none", operation)
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("%s: expected error containing %q, got: %v", operation, errContains, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error: %v", operation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyStructuredError validates StructuredError properties.
|
||||
// This helper ensures structured errors have the expected Type and Code values.
|
||||
func VerifyStructuredError(t *testing.T, err error, expectedType shared.ErrorType, expectedCode string) {
|
||||
t.Helper()
|
||||
var structErr *shared.StructuredError
|
||||
if !errors.As(err, &structErr) {
|
||||
t.Errorf("expected StructuredError, got: %T", err)
|
||||
|
||||
return
|
||||
}
|
||||
if structErr.Type != expectedType {
|
||||
t.Errorf("expected Type %v, got %v", expectedType, structErr.Type)
|
||||
}
|
||||
if structErr.Code != expectedCode {
|
||||
t.Errorf("expected Code %q, got %q", expectedCode, structErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user