- 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>
423 lines
13 KiB
Go
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)
|
|
}
|
|
}
|