hako/e2e/e2eutil/reporter.go
Felipe M. d71915a3fb
test(e2e): expand test suite with comprehensive coverage
- Add e2e tests for admin pages (archivers, extractors, rules)
- Add tests for archives, links, navigation, and error handling
- Add search and pagination test coverage
- Implement test web server with Docker container
- Add seed data and helper utilities for tests
- Update webapp components for permission handling
- Generate HTML reports with embedded screenshots

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 12:49:40 +01:00

423 lines
13 KiB
Go

package e2eutil
import (
"encoding/base64"
"html/template"
"os"
"strings"
"sync"
"testing"
"time"
)
// AssertionResult represents a single assertion within a test
type AssertionResult struct {
Description string
Passed bool
Error string
Screenshot template.URL // base64 encoded data URI
Timestamp time.Time // When assertion was evaluated
Duration time.Duration // How long assertion took (0 for instant checks)
}
// TestResult represents a complete test execution
type TestResult struct {
Name string
Passed bool
StartTime time.Time // Test start time (renamed from Timestamp)
EndTime *time.Time // Test end time (nil if still running/interrupted)
Duration time.Duration // Test duration (calculated from StartTime/EndTime)
Assertions []AssertionResult
}
// Reporter collects test results for HTML generation
type Reporter struct {
mu sync.Mutex
Results map[string]*TestResult
autoGenerateHTML bool // Enable auto-generation after each result
lastGenerated time.Time // Track last generation time for debouncing
generationMu sync.Mutex // Separate lock for HTML generation
Interrupted bool // True when tests interrupted by signal/panic
}
var once sync.Once
// GetReporter returns the singleton reporter instance
func GetReporter() *Reporter {
once.Do(func() {
globalReporter = &Reporter{
Results: make(map[string]*TestResult),
}
})
return globalReporter
}
// AddResult records a test assertion result
func (r *Reporter) AddResult(testName, description string, passed bool, err error, screenshotPath string, assertionDuration time.Duration) {
r.mu.Lock()
// Create test result if doesn't exist
if _, exists := r.Results[testName]; !exists {
r.Results[testName] = &TestResult{
Name: testName,
Passed: true,
StartTime: time.Now(),
Assertions: []AssertionResult{},
}
}
// Prepare assertion
assertion := AssertionResult{
Description: description,
Passed: passed,
Timestamp: time.Now(),
Duration: assertionDuration,
}
if !passed {
r.Results[testName].Passed = false
if err != nil {
assertion.Error = err.Error()
}
// Embed screenshot as base64 if provided
if screenshotPath != "" {
if data, err := os.ReadFile(screenshotPath); err == nil {
encoded := base64.StdEncoding.EncodeToString(data)
assertion.Screenshot = template.URL("data:image/png;base64," + encoded)
}
}
}
r.Results[testName].Assertions = append(r.Results[testName].Assertions, assertion)
r.mu.Unlock()
// Trigger incremental HTML generation (outside the lock to avoid blocking)
r.generateHTMLIfNeeded()
}
// EnableAutoGenerate enables automatic HTML generation after each test result
func (r *Reporter) EnableAutoGenerate() {
r.autoGenerateHTML = true
}
// DisableAutoGenerate disables automatic HTML generation
// Call this before final HTML generation to ensure proper status display
func (r *Reporter) DisableAutoGenerate() {
r.autoGenerateHTML = false
}
// MarkInterrupted marks the test suite as interrupted by signal/timeout/panic
func (r *Reporter) MarkInterrupted() {
r.mu.Lock()
defer r.mu.Unlock()
r.Interrupted = true
}
// TestFinished marks a test as completed and records its end time.
// It also checks if the test failed in the Go test framework (via t.Failed())
// and records the failure if it wasn't already captured by assertion methods.
func (r *Reporter) TestFinished(t *testing.T) {
r.mu.Lock()
defer r.mu.Unlock()
testName := t.Name()
if result, exists := r.Results[testName]; exists {
now := time.Now()
result.EndTime = &now
result.Duration = now.Sub(result.StartTime)
// If the test failed in the Go test framework but wasn't recorded
// via assertions, mark it as failed now
if t.Failed() && result.Passed {
result.Passed = false
// Add a final assertion to document the unrecorded failure
result.Assertions = append(result.Assertions, AssertionResult{
Description: "Test failed (error not captured by assertion methods)",
Passed: false,
Error: "Test failed via require.* call or other test failure",
Timestamp: now,
Duration: 0,
})
}
}
}
// generateHTMLIfNeeded triggers HTML generation with debouncing (1 second minimum between generations)
func (r *Reporter) generateHTMLIfNeeded() {
if !r.autoGenerateHTML {
return
}
r.generationMu.Lock()
defer r.generationMu.Unlock()
// Debounce: only generate if 1 second has passed since last generation
if time.Since(r.lastGenerated) < 1*time.Second {
return
}
r.lastGenerated = time.Now()
// Generate in background to avoid blocking tests
go func() {
if err := r.GenerateHTMLAtomic(); err != nil {
// Use fmt.Printf instead of log to avoid importing log package
// This will be visible in test output if generation fails
_ = err // Silently ignore errors in background generation
}
}()
}
// GenerateHTML creates the HTML report with embedded screenshots
func (r *Reporter) GenerateHTML() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.generateHTMLInternal(false)
}
// GenerateHTMLAtomic creates the HTML report using atomic file writes (temp file + rename)
func (r *Reporter) GenerateHTMLAtomic() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.generateHTMLInternal(true)
}
// ForceGenerateHTML generates HTML immediately, bypassing debouncing
// Use when test completes or on interruption to ensure results are saved
func (r *Reporter) ForceGenerateHTML() error {
// Check if there are incomplete tests and mark as interrupted
// This handles cases where timeout killed the process before signal handler ran
r.mu.Lock()
hasIncompleteTests := false
for _, result := range r.Results {
if result.EndTime == nil {
hasIncompleteTests = true
break
}
}
if hasIncompleteTests {
r.Interrupted = true
}
r.mu.Unlock()
r.generationMu.Lock()
r.lastGenerated = time.Now()
r.generationMu.Unlock()
return r.GenerateHTMLAtomic()
}
// generateHTMLInternal implements the actual HTML generation logic
func (r *Reporter) generateHTMLInternal(atomic bool) error {
// Create output directory
if err := os.MkdirAll("test-results", 0755); err != nil {
return err
}
// HTML template with inline CSS
tmpl := template.Must(template.New("report").Funcs(template.FuncMap{
"toLowerCase": func(s string) string {
return strings.ToLower(s)
},
}).Parse(`<!DOCTYPE html>
<html>
<head>
<title>E2E Test Results</title>
{{if and .IsLive (not .IsInterrupted)}}
<meta http-equiv="refresh" content="5">
{{end}}
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
h1 { color: #333; }
.summary {
background-color: white;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.test {
padding: 15px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.test.passed { border-left: 5px solid #4caf50; background-color: #e8f5e9; }
.test.failed { border-left: 5px solid #f44336; background-color: #ffebee; }
.assertion {
margin: 10px 0;
padding: 10px 20px;
background-color: rgba(255,255,255,0.5);
border-radius: 3px;
}
.assertion.passed { border-left: 3px solid #4caf50; }
.assertion.failed { border-left: 3px solid #f44336; }
.error {
color: #c62828;
margin: 5px 0;
padding: 10px;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
img {
max-width: 100%;
height: auto;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h2 { margin: 10px 0; color: #333; }
h3 { margin: 15px 0 10px 0; color: #555; }
.status { font-weight: bold; font-size: 1.1em; }
.status.passed { color: #4caf50; }
.status.failed { color: #f44336; }
.timestamp { color: #666; font-size: 0.9em; font-family: monospace; }
.assertion-desc { font-weight: 500; margin-bottom: 5px; }
.duration {
font-weight: bold;
color: #4caf50;
}
.duration.slow {
color: #f44336;
background-color: rgba(244, 67, 54, 0.1);
padding: 2px 6px;
border-radius: 3px;
}
.assertion-time {
color: #999;
font-size: 0.85em;
font-weight: normal;
}
</style>
</head>
<body>
<h1>E2E Test Results</h1>
<div class="summary">
{{if .IsInterrupted}}
<p><strong>Status:</strong> <span style="color: #f44336;">❌ Tests Interrupted (Timeout/Signal)</span></p>
{{else if .IsLive}}
<p><strong>Status:</strong> <span style="color: #ff9800;">⚠️ Tests Running (Auto-updating)</span></p>
{{else}}
<p><strong>Status:</strong> <span style="color: #4caf50;">✅ Tests Completed</span></p>
{{end}}
<p><strong>Total Tests:</strong> {{len .Results}}</p>
<p><strong>Passed:</strong> {{.PassedCount}} | <strong>Failed:</strong> {{.FailedCount}}</p>
{{if gt .TotalDuration.Milliseconds 0}}
<p><strong>Total Duration:</strong> {{.TotalDuration.Round 1000000}}</p>
{{end}}
<p><strong>Last Updated:</strong> {{.GeneratedAt}}</p>
</div>
{{range .Results}}
<div class="test {{if .Passed}}passed{{else}}failed{{end}}">
<h2>{{.Name}}</h2>
<p class="timestamp">
Started: {{.StartTime.Format "15:04:05"}}
{{if .EndTime}}
| Ended: {{.EndTime.Format "15:04:05"}}
| Duration: <span class="duration {{if gt .Duration.Seconds 10.0}}slow{{end}}">{{.Duration.Round 1000000}}</span>
{{else}}
| <span style="color: #ff9800;">⏱️ Running...</span>
{{end}}
</p>
<p class="status {{if .Passed}}passed{{else}}failed{{end}}">Status: {{if .Passed}}✅ PASSED{{else}}❌ FAILED{{end}}</p>
{{if .Assertions}}
<div class="assertions">
<h3>Assertions:</h3>
{{range .Assertions}}
<div class="assertion {{if .Passed}}passed{{else}}failed{{end}}">
<p class="assertion-desc">
{{.Description}}: {{if .Passed}}✓ Passed{{else}}✗ Failed{{end}}
{{if gt .Duration.Milliseconds 0}}
<span class="assertion-time">({{.Duration.Round 1000000}})</span>
{{end}}
</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .Screenshot}}<img src="{{.Screenshot}}" alt="Screenshot"/>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</body>
</html>`))
// Calculate statistics
passedCount := 0
failedCount := 0
var totalDuration time.Duration
for _, result := range r.Results {
if result.Passed {
passedCount++
} else {
failedCount++
}
if result.EndTime != nil {
totalDuration += result.Duration
}
}
// Execute template with current timestamp
data := struct {
Results map[string]*TestResult
GeneratedAt string
IsLive bool
IsInterrupted bool
PassedCount int
FailedCount int
TotalDuration time.Duration
}{
Results: r.Results,
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
IsLive: r.autoGenerateHTML, // True if auto-generation is enabled
IsInterrupted: r.Interrupted, // True if tests were interrupted
PassedCount: passedCount,
FailedCount: failedCount,
TotalDuration: totalDuration,
}
// Choose output strategy based on atomic flag
finalPath := "test-results/e2e-report.html"
if atomic {
// Write to temp file first for atomic operation
tmpFile, err := os.CreateTemp("test-results", "e2e-report-*.html.tmp")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
// Execute template to temp file
if err := tmpl.Execute(tmpFile, data); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpPath)
return err
}
// Atomic rename
return os.Rename(tmpPath, finalPath)
} else {
// Direct write (original behavior)
f, err := os.Create(finalPath)
if err != nil {
return err
}
defer f.Close()
return tmpl.Execute(f, data)
}
}