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(`
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}}
Status: {{if .Passed}}✅ PASSED{{else}}❌ FAILED{{end}}
{{if .Assertions}}{{.Description}}: {{if .Passed}}✓ Passed{{else}}✗ Failed{{end}} {{if gt .Duration.Milliseconds 0}} ({{.Duration.Round 1000000}}) {{end}}
{{if .Error}}