package perfsuite import ( "context" "encoding/json" "fmt" "os" "runtime" "strings" "testing" "time" "gitea.dockr.co/kev/go-jdenticon/jdenticon" ) // PerformanceBenchmark represents a single performance test case type PerformanceBenchmark struct { Name string BenchmarkFunc func(*testing.B) RegressionLimit float64 // Percentage threshold for regression detection Description string } // PerformanceMetrics holds performance metrics for comparison type PerformanceMetrics struct { NsPerOp int64 `json:"ns_per_op"` AllocsPerOp int64 `json:"allocs_per_op"` BytesPerOp int64 `json:"bytes_per_op"` Timestamp time.Time `json:"timestamp"` GoVersion string `json:"go_version"` OS string `json:"os"` Arch string `json:"arch"` } // RegressionReport holds the results of a regression check type RegressionReport struct { Summary string `json:"summary"` Failures []string `json:"failures"` Passed int `json:"passed"` Total int `json:"total"` Results map[string]string `json:"results"` } // PerformanceSuite manages the performance regression test suite type PerformanceSuite struct { Benchmarks []PerformanceBenchmark BaselineFile string ReportFile string EnableReports bool FailOnRegress bool } // NewPerformanceSuite creates a new performance regression test suite func NewPerformanceSuite() *PerformanceSuite { return &PerformanceSuite{ Benchmarks: []PerformanceBenchmark{ { Name: "CoreSVGGeneration", BenchmarkFunc: benchmarkCoreSVGGeneration, RegressionLimit: 15.0, Description: "Core SVG generation performance", }, { Name: "CorePNGGeneration", BenchmarkFunc: benchmarkCorePNGGeneration, RegressionLimit: 25.0, Description: "Core PNG generation performance", }, { Name: "CachedGeneration", BenchmarkFunc: benchmarkCachedGeneration, RegressionLimit: 10.0, Description: "Cached icon generation performance", }, { Name: "BatchProcessing", BenchmarkFunc: benchmarkBatchProcessing, RegressionLimit: 20.0, Description: "Batch icon generation performance", }, { Name: "LargeIcon256", BenchmarkFunc: benchmarkLargeIcon256, RegressionLimit: 30.0, Description: "Large icon (256px) generation performance", }, { Name: "LargeIcon512", BenchmarkFunc: benchmarkLargeIcon512, RegressionLimit: 30.0, Description: "Large icon (512px) generation performance", }, { Name: "ColorVariationSaturation", BenchmarkFunc: benchmarkColorVariationSaturation, RegressionLimit: 15.0, Description: "Color saturation variation performance", }, { Name: "ColorVariationPadding", BenchmarkFunc: benchmarkColorVariationPadding, RegressionLimit: 15.0, Description: "Padding variation performance", }, }, BaselineFile: ".performance_baselines.json", ReportFile: "performance_report.json", EnableReports: true, FailOnRegress: true, } } // Individual benchmark functions func benchmarkCoreSVGGeneration(b *testing.B) { testCases := []string{ "test@example.com", "user123", "performance-test", "unicode-üser", } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { input := testCases[i%len(testCases)] _, err := jdenticon.ToSVG(context.Background(), input, 64) if err != nil { b.Fatalf("SVG generation failed: %v", err) } } } func benchmarkCorePNGGeneration(b *testing.B) { testCases := []string{ "test@example.com", "user123", "performance-test", "unicode-üser", } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { input := testCases[i%len(testCases)] _, err := jdenticon.ToPNG(context.Background(), input, 64) if err != nil { b.Fatalf("PNG generation failed: %v", err) } } } func benchmarkCachedGeneration(b *testing.B) { generator, err := jdenticon.NewGeneratorWithConfig(jdenticon.DefaultConfig(), 100) if err != nil { b.Fatalf("NewGenerator failed: %v", err) } input := "cached-performance-test" // Warm up cache icon, err := generator.Generate(context.Background(), input, 64) if err != nil { b.Fatalf("Cache warmup failed: %v", err) } _, _ = icon.ToSVG() b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { icon, err := generator.Generate(context.Background(), input, 64) if err != nil { b.Fatalf("Cached generation failed: %v", err) } _, err = icon.ToSVG() if err != nil { b.Fatalf("Cached SVG failed: %v", err) } } } func benchmarkBatchProcessing(b *testing.B) { inputs := []string{ "batch1@test.com", "batch2@test.com", "batch3@test.com", "batch4@test.com", "batch5@test.com", } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { for _, input := range inputs { _, err := jdenticon.ToSVG(context.Background(), input, 64) if err != nil { b.Fatalf("Batch processing failed: %v", err) } } } } func benchmarkLargeIcon256(b *testing.B) { input := "large-icon-test-256" b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _, err := jdenticon.ToSVG(context.Background(), input, 256) if err != nil { b.Fatalf("Large icon (256px) generation failed: %v", err) } } } func benchmarkLargeIcon512(b *testing.B) { input := "large-icon-test-512" b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _, err := jdenticon.ToSVG(context.Background(), input, 512) if err != nil { b.Fatalf("Large icon (512px) generation failed: %v", err) } } } func benchmarkColorVariationSaturation(b *testing.B) { config := jdenticon.DefaultConfig() config.ColorSaturation = 0.9 input := "color-saturation-test" b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _, err := jdenticon.ToSVGWithConfig(context.Background(), input, 64, config) if err != nil { b.Fatalf("Color saturation variation failed: %v", err) } } } func benchmarkColorVariationPadding(b *testing.B) { config := jdenticon.DefaultConfig() config.Padding = 0.15 input := "color-padding-test" b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _, err := jdenticon.ToSVGWithConfig(context.Background(), input, 64, config) if err != nil { b.Fatalf("Padding variation failed: %v", err) } } } // calculateChange calculates percentage change between old and new values func calculateChange(oldVal, newVal int64) float64 { if oldVal == 0 { if newVal == 0 { return 0 } return 100.0 } return (float64(newVal-oldVal) / float64(oldVal)) * 100.0 } // RunBenchmark executes a benchmark and returns metrics func (ps *PerformanceSuite) RunBenchmark(bench PerformanceBenchmark) (PerformanceMetrics, error) { result := testing.Benchmark(bench.BenchmarkFunc) if result.N == 0 { return PerformanceMetrics{}, fmt.Errorf("benchmark %s failed to run", bench.Name) } return PerformanceMetrics{ NsPerOp: result.NsPerOp(), AllocsPerOp: result.AllocsPerOp(), BytesPerOp: result.AllocedBytesPerOp(), Timestamp: time.Now(), GoVersion: runtime.Version(), OS: runtime.GOOS, Arch: runtime.GOARCH, }, nil } // LoadBaselines loads performance baselines from file func (ps *PerformanceSuite) LoadBaselines() (map[string]PerformanceMetrics, error) { baselines := make(map[string]PerformanceMetrics) if _, err := os.Stat(ps.BaselineFile); os.IsNotExist(err) { return baselines, nil } data, err := os.ReadFile(ps.BaselineFile) if err != nil { return nil, fmt.Errorf("failed to read baselines: %w", err) } if err := json.Unmarshal(data, &baselines); err != nil { return nil, fmt.Errorf("failed to parse baselines: %w", err) } return baselines, nil } // SaveBaselines saves performance baselines to file func (ps *PerformanceSuite) SaveBaselines(baselines map[string]PerformanceMetrics) error { data, err := json.MarshalIndent(baselines, "", " ") if err != nil { return fmt.Errorf("failed to marshal baselines: %w", err) } // #nosec G306 -- 0644 is appropriate for benchmark data files return os.WriteFile(ps.BaselineFile, data, 0644) } // EstablishBaselines runs all benchmarks and saves them as baselines func (ps *PerformanceSuite) EstablishBaselines() error { fmt.Println("🔥 Establishing performance baselines...") baselines := make(map[string]PerformanceMetrics) for _, bench := range ps.Benchmarks { fmt.Printf(" Running %s...", bench.Name) metrics, err := ps.RunBenchmark(bench) if err != nil { return fmt.Errorf("failed to run benchmark %s: %w", bench.Name, err) } baselines[bench.Name] = metrics fmt.Printf(" ✓ %d ns/op, %d allocs/op\n", metrics.NsPerOp, metrics.AllocsPerOp) } if err := ps.SaveBaselines(baselines); err != nil { return fmt.Errorf("failed to save baselines: %w", err) } fmt.Printf("✅ Baselines established (%d benchmarks saved to %s)\n", len(baselines), ps.BaselineFile) return nil } // CheckForRegressions runs benchmarks and compares against baselines func (ps *PerformanceSuite) CheckForRegressions() error { fmt.Println("🔍 Checking for performance regressions...") baselines, err := ps.LoadBaselines() if err != nil { return fmt.Errorf("failed to load baselines: %w", err) } if len(baselines) == 0 { return fmt.Errorf("no baselines found - run EstablishBaselines() first") } var failures []string passed := 0 total := 0 results := make(map[string]string) for _, bench := range ps.Benchmarks { baseline, exists := baselines[bench.Name] if !exists { fmt.Printf("⚠️ %s: No baseline found, skipping\n", bench.Name) continue } fmt.Printf(" %s...", bench.Name) current, err := ps.RunBenchmark(bench) if err != nil { return fmt.Errorf("failed to run benchmark %s: %w", bench.Name, err) } total++ // Calculate changes timeChange := calculateChange(baseline.NsPerOp, current.NsPerOp) allocChange := calculateChange(baseline.AllocsPerOp, current.AllocsPerOp) memChange := calculateChange(baseline.BytesPerOp, current.BytesPerOp) // Check for regressions hasRegression := false var issues []string if timeChange > bench.RegressionLimit { hasRegression = true issues = append(issues, fmt.Sprintf("%.1f%% slower", timeChange)) } if allocChange > bench.RegressionLimit { hasRegression = true issues = append(issues, fmt.Sprintf("%.1f%% more allocs", allocChange)) } if memChange > bench.RegressionLimit { hasRegression = true issues = append(issues, fmt.Sprintf("%.1f%% more memory", memChange)) } if hasRegression { status := fmt.Sprintf(" ❌ REGRESSION: %s", strings.Join(issues, ", ")) failures = append(failures, fmt.Sprintf("%s: %s", bench.Name, strings.Join(issues, ", "))) fmt.Println(status) results[bench.Name] = "FAIL: " + strings.Join(issues, ", ") } else { status := " ✅ PASS" if timeChange != 0 || allocChange != 0 || memChange != 0 { status += fmt.Sprintf(" (%.1f%% time, %.1f%% allocs, %.1f%% mem)", timeChange, allocChange, memChange) } fmt.Println(status) passed++ results[bench.Name] = "PASS" } } // Report summary fmt.Printf("\n📊 Performance regression check completed:\n") fmt.Printf(" • %d tests passed\n", passed) fmt.Printf(" • %d tests failed\n", len(failures)) fmt.Printf(" • %d tests total\n", total) // Generate the report file if ps.EnableReports { summary := fmt.Sprintf("%d/%d tests passed.", passed, total) if len(failures) > 0 { summary = fmt.Sprintf("%d regressions detected.", len(failures)) } report := RegressionReport{ Summary: summary, Failures: failures, Passed: passed, Total: total, Results: results, } data, err := json.MarshalIndent(report, "", " ") if err != nil { return fmt.Errorf("failed to marshal report: %w", err) } // #nosec G306 -- 0644 is appropriate for benchmark report files if err := os.WriteFile(ps.ReportFile, data, 0644); err != nil { return fmt.Errorf("failed to write report file: %w", err) } } if len(failures) > 0 { fmt.Printf("\n❌ Performance regressions detected:\n") for _, failure := range failures { fmt.Printf(" • %s\n", failure) } if ps.FailOnRegress { return fmt.Errorf("performance regressions detected") } } else { fmt.Printf("\n✅ No performance regressions detected!\n") } return nil }