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(` E2E Test Results {{if and .IsLive (not .IsInterrupted)}} {{end}}

E2E Test Results

{{if .IsInterrupted}}

Status: ❌ Tests Interrupted (Timeout/Signal)

{{else if .IsLive}}

Status: ⚠️ Tests Running (Auto-updating)

{{else}}

Status: ✅ Tests Completed

{{end}}

Total Tests: {{len .Results}}

Passed: {{.PassedCount}} | Failed: {{.FailedCount}}

{{if gt .TotalDuration.Milliseconds 0}}

Total Duration: {{.TotalDuration.Round 1000000}}

{{end}}

Last Updated: {{.GeneratedAt}}

{{range .Results}}

{{.Name}}

Started: {{.StartTime.Format "15:04:05"}} {{if .EndTime}} | Ended: {{.EndTime.Format "15:04:05"}} | Duration: {{.Duration.Round 1000000}} {{else}} | ⏱️ Running... {{end}}

Status: {{if .Passed}}✅ PASSED{{else}}❌ FAILED{{end}}

{{if .Assertions}}

Assertions:

{{range .Assertions}}

{{.Description}}: {{if .Passed}}✓ Passed{{else}}✗ Failed{{end}} {{if gt .Duration.Milliseconds 0}} ({{.Duration.Round 1000000}}) {{end}}

{{if .Error}}
{{.Error}}
{{end}} {{if .Screenshot}}Screenshot{{end}}
{{end}}
{{end}}
{{end}} `)) // 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) } }