1162 lines
29 KiB
Go
1162 lines
29 KiB
Go
// E2E Testing Tool using Rod for Coppertone.tech
|
|
// Tests every page, link, and API endpoint exhaustively
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
)
|
|
|
|
// Config holds the E2E tester configuration
|
|
type Config struct {
|
|
BaseURL string
|
|
APIBaseURL string
|
|
Headless bool
|
|
Timeout time.Duration
|
|
MaxConcurrency int
|
|
OutputFile string
|
|
Verbose bool
|
|
}
|
|
|
|
// TestResult represents the result of a test
|
|
type TestResult struct {
|
|
Type string `json:"type"` // "page", "link", "api", "form"
|
|
URL string `json:"url"`
|
|
Method string `json:"method,omitempty"`
|
|
Status string `json:"status"` // "pass", "fail", "skip"
|
|
StatusCode int `json:"status_code,omitempty"`
|
|
Duration time.Duration `json:"duration_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
Details string `json:"details,omitempty"`
|
|
}
|
|
|
|
// TestReport is the full test report
|
|
type TestReport struct {
|
|
StartTime time.Time `json:"start_time"`
|
|
EndTime time.Time `json:"end_time"`
|
|
TotalTests int `json:"total_tests"`
|
|
Passed int `json:"passed"`
|
|
Failed int `json:"failed"`
|
|
Skipped int `json:"skipped"`
|
|
Duration time.Duration `json:"duration_ms"`
|
|
Results []TestResult `json:"results"`
|
|
Coverage Coverage `json:"coverage"`
|
|
}
|
|
|
|
// Coverage tracks test coverage
|
|
type Coverage struct {
|
|
PagesVisited []string `json:"pages_visited"`
|
|
LinksChecked []string `json:"links_checked"`
|
|
APIsChecked []string `json:"apis_checked"`
|
|
FormsSubmitted []string `json:"forms_submitted"`
|
|
}
|
|
|
|
// E2ETester is the main tester struct
|
|
type E2ETester struct {
|
|
config Config
|
|
browser *rod.Browser
|
|
results []TestResult
|
|
mu sync.Mutex
|
|
coverage Coverage
|
|
visited map[string]bool
|
|
client *http.Client
|
|
}
|
|
|
|
// NewE2ETester creates a new E2E tester instance
|
|
func NewE2ETester(config Config) *E2ETester {
|
|
return &E2ETester{
|
|
config: config,
|
|
results: make([]TestResult, 0),
|
|
visited: make(map[string]bool),
|
|
client: &http.Client{
|
|
Timeout: config.Timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
coverage: Coverage{
|
|
PagesVisited: make([]string, 0),
|
|
LinksChecked: make([]string, 0),
|
|
APIsChecked: make([]string, 0),
|
|
FormsSubmitted: make([]string, 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
// addResult adds a test result thread-safely
|
|
func (t *E2ETester) addResult(result TestResult) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.results = append(t.results, result)
|
|
if t.config.Verbose {
|
|
status := "✓"
|
|
if result.Status == "fail" {
|
|
status = "✗"
|
|
} else if result.Status == "skip" {
|
|
status = "⊘"
|
|
}
|
|
log.Printf("[%s] %s %s %s (%dms)", status, result.Type, result.Method, result.URL, result.Duration.Milliseconds())
|
|
}
|
|
}
|
|
|
|
// Start initializes the browser
|
|
func (t *E2ETester) Start() error {
|
|
l := launcher.New().Headless(t.config.Headless)
|
|
controlURL, err := l.Launch()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to launch browser: %w", err)
|
|
}
|
|
|
|
t.browser = rod.New().ControlURL(controlURL)
|
|
if err := t.browser.Connect(); err != nil {
|
|
return fmt.Errorf("failed to connect to browser: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop closes the browser
|
|
func (t *E2ETester) Stop() {
|
|
if t.browser != nil {
|
|
t.browser.MustClose()
|
|
}
|
|
}
|
|
|
|
// Run executes all E2E tests
|
|
func (t *E2ETester) Run() *TestReport {
|
|
report := &TestReport{
|
|
StartTime: time.Now(),
|
|
}
|
|
|
|
log.Println("========================================")
|
|
log.Println("Coppertone.tech E2E Testing Tool")
|
|
log.Println("========================================")
|
|
log.Printf("Base URL: %s", t.config.BaseURL)
|
|
log.Printf("API URL: %s", t.config.APIBaseURL)
|
|
log.Println("========================================")
|
|
|
|
// Phase 1: Test all API endpoints
|
|
log.Println("\n[Phase 1] Testing API Endpoints...")
|
|
t.testAllAPIEndpoints()
|
|
|
|
// Phase 2: Test public pages
|
|
log.Println("\n[Phase 2] Testing Public Pages...")
|
|
t.testPublicPages()
|
|
|
|
// Phase 3: Test authentication flows
|
|
log.Println("\n[Phase 3] Testing Authentication Flows...")
|
|
t.testAuthenticationFlows()
|
|
|
|
// Phase 4: Test protected pages (requires auth)
|
|
log.Println("\n[Phase 4] Testing Protected Pages...")
|
|
t.testProtectedPages()
|
|
|
|
// Phase 5: Test all forms
|
|
log.Println("\n[Phase 5] Testing Forms...")
|
|
t.testForms()
|
|
|
|
// Phase 6: Test navigation and links
|
|
log.Println("\n[Phase 6] Testing Navigation & Links...")
|
|
t.testNavigationAndLinks()
|
|
|
|
// Phase 7: Test responsive design
|
|
log.Println("\n[Phase 7] Testing Responsive Design...")
|
|
t.testResponsiveDesign()
|
|
|
|
// Phase 8: Test error handling
|
|
log.Println("\n[Phase 8] Testing Error Handling...")
|
|
t.testErrorHandling()
|
|
|
|
// Compile report
|
|
report.EndTime = time.Now()
|
|
report.Duration = report.EndTime.Sub(report.StartTime)
|
|
report.Results = t.results
|
|
report.Coverage = t.coverage
|
|
|
|
for _, r := range t.results {
|
|
report.TotalTests++
|
|
switch r.Status {
|
|
case "pass":
|
|
report.Passed++
|
|
case "fail":
|
|
report.Failed++
|
|
case "skip":
|
|
report.Skipped++
|
|
}
|
|
}
|
|
|
|
return report
|
|
}
|
|
|
|
// testAllAPIEndpoints tests all API endpoints
|
|
func (t *E2ETester) testAllAPIEndpoints() {
|
|
// Auth service endpoints
|
|
authEndpoints := []struct {
|
|
method string
|
|
path string
|
|
body interface{}
|
|
needAuth bool
|
|
desc string
|
|
}{
|
|
// Health and status
|
|
{"GET", "/healthz", nil, false, "Health check"},
|
|
|
|
// Public auth endpoints
|
|
{"POST", "/register-email-password", map[string]string{
|
|
"email": "e2etest@test.com",
|
|
"password": "Test123!@#",
|
|
"name": "E2E Test User",
|
|
}, false, "Register with email/password"},
|
|
{"POST", "/login-email-password", map[string]string{
|
|
"email": "e2etest@test.com",
|
|
"password": "Test123!@#",
|
|
}, false, "Login with email/password"},
|
|
|
|
// Protected auth endpoints
|
|
{"GET", "/profile", nil, true, "Get user profile"},
|
|
{"POST", "/auth/refresh", nil, true, "Refresh token"},
|
|
{"POST", "/auth/logout", nil, true, "Logout"},
|
|
{"POST", "/auth/logout-all", nil, true, "Logout all sessions"},
|
|
|
|
// Identity management
|
|
{"POST", "/link-identity", map[string]string{
|
|
"type": "blockchain",
|
|
"identifier": "0x1234567890abcdef1234567890abcdef12345678",
|
|
}, true, "Link identity"},
|
|
{"GET", "/identities", nil, true, "List identities"},
|
|
|
|
// Admin endpoints
|
|
{"GET", "/admin/users", nil, true, "List all users (admin)"},
|
|
}
|
|
|
|
for _, ep := range authEndpoints {
|
|
t.testAPIEndpoint("auth-service", ep.method, ep.path, ep.body, ep.needAuth, ep.desc)
|
|
}
|
|
|
|
// Contact service endpoints
|
|
contactEndpoints := []struct {
|
|
method string
|
|
path string
|
|
body interface{}
|
|
needAuth bool
|
|
desc string
|
|
}{
|
|
{"GET", "/healthz", nil, false, "Contact health check"},
|
|
{"POST", "/submit", map[string]string{
|
|
"name": "E2E Test",
|
|
"email": "e2e@test.com",
|
|
"subject": "E2E Test Message",
|
|
"message": "This is an automated E2E test message.",
|
|
}, false, "Submit contact form"},
|
|
{"GET", "/submissions", nil, true, "List contact submissions"},
|
|
}
|
|
|
|
for _, ep := range contactEndpoints {
|
|
t.testAPIEndpoint("contact-service", ep.method, ep.path, ep.body, ep.needAuth, ep.desc)
|
|
}
|
|
|
|
// Work management service endpoints
|
|
workEndpoints := []struct {
|
|
method string
|
|
path string
|
|
body interface{}
|
|
needAuth bool
|
|
desc string
|
|
}{
|
|
{"GET", "/healthz", nil, false, "Work management health check"},
|
|
{"GET", "/projects", nil, true, "List projects"},
|
|
{"GET", "/tasks", nil, true, "List tasks"},
|
|
{"GET", "/invoices", nil, true, "List invoices"},
|
|
}
|
|
|
|
for _, ep := range workEndpoints {
|
|
t.testAPIEndpoint("work-management-service", ep.method, ep.path, ep.body, ep.needAuth, ep.desc)
|
|
}
|
|
}
|
|
|
|
// testAPIEndpoint tests a single API endpoint
|
|
func (t *E2ETester) testAPIEndpoint(service, method, path string, body interface{}, needAuth bool, desc string) {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "api",
|
|
Method: method,
|
|
URL: fmt.Sprintf("%s%s", t.config.APIBaseURL, path),
|
|
}
|
|
|
|
// Build request
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
jsonBody, _ := json.Marshal(body)
|
|
reqBody = bytes.NewBuffer(jsonBody)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, result.URL, reqBody)
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = err.Error()
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
if needAuth {
|
|
// Try to get a valid token first
|
|
token := t.getAuthToken()
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = err.Error()
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
result.StatusCode = resp.StatusCode
|
|
result.Duration = time.Since(start)
|
|
|
|
// Determine pass/fail based on expected status codes
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
|
result.Status = "pass"
|
|
} else if resp.StatusCode == 401 && needAuth {
|
|
result.Status = "skip"
|
|
result.Details = "Requires authentication"
|
|
} else if resp.StatusCode == 403 {
|
|
result.Status = "skip"
|
|
result.Details = "Forbidden - insufficient permissions"
|
|
} else {
|
|
result.Status = "fail"
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
result.Error = string(bodyBytes)
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.coverage.APIsChecked = append(t.coverage.APIsChecked, fmt.Sprintf("%s %s", method, path))
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// getAuthToken gets a valid auth token for protected endpoints
|
|
func (t *E2ETester) getAuthToken() string {
|
|
// Try to login with test credentials
|
|
loginBody, _ := json.Marshal(map[string]string{
|
|
"email": "admin@coppertone.tech",
|
|
"password": "admin123",
|
|
})
|
|
|
|
resp, err := t.client.Post(
|
|
t.config.APIBaseURL+"/login-email-password",
|
|
"application/json",
|
|
bytes.NewBuffer(loginBody),
|
|
)
|
|
if err != nil || resp.StatusCode != 200 {
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
return result.Token
|
|
}
|
|
|
|
// testPublicPages tests all public pages
|
|
func (t *E2ETester) testPublicPages() {
|
|
publicPages := []string{
|
|
"/",
|
|
"/about",
|
|
"/services",
|
|
"/services/web-development",
|
|
"/services/it-consulting",
|
|
"/services/cloud-services",
|
|
"/blog",
|
|
"/contact",
|
|
"/login",
|
|
"/register",
|
|
}
|
|
|
|
for _, path := range publicPages {
|
|
t.testPage(path, false)
|
|
}
|
|
}
|
|
|
|
// testProtectedPages tests protected pages (with auth)
|
|
func (t *E2ETester) testProtectedPages() {
|
|
protectedPages := []string{
|
|
"/dashboard",
|
|
"/admin",
|
|
"/staff",
|
|
"/projects",
|
|
"/invoices",
|
|
"/profile",
|
|
}
|
|
|
|
for _, path := range protectedPages {
|
|
t.testPage(path, true)
|
|
}
|
|
}
|
|
|
|
// testPage tests a single page
|
|
func (t *E2ETester) testPage(path string, needAuth bool) {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "page",
|
|
Method: "GET",
|
|
URL: t.config.BaseURL + path,
|
|
}
|
|
|
|
page := t.browser.MustPage(result.URL)
|
|
defer page.MustClose()
|
|
|
|
// Set timeout
|
|
page = page.Timeout(t.config.Timeout)
|
|
|
|
// Wait for page to load
|
|
err := page.WaitLoad()
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = fmt.Sprintf("Page load timeout: %v", err)
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
|
|
// Check for error indicators
|
|
hasError := false
|
|
errorSelectors := []string{
|
|
".error", ".error-page", "#error",
|
|
"[data-testid='error']",
|
|
"h1:contains('404')", "h1:contains('500')",
|
|
}
|
|
|
|
for _, selector := range errorSelectors {
|
|
elements, err := page.Elements(selector)
|
|
if err == nil && len(elements) > 0 {
|
|
hasError = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check page title exists
|
|
title := page.MustInfo().Title
|
|
if title == "" {
|
|
result.Details = "Warning: Page has no title"
|
|
}
|
|
|
|
// Check for JavaScript errors
|
|
jsErrors := t.checkJSErrors(page)
|
|
if len(jsErrors) > 0 {
|
|
result.Details += fmt.Sprintf("; JS errors: %v", jsErrors)
|
|
}
|
|
|
|
if hasError {
|
|
result.Status = "fail"
|
|
result.Error = "Page contains error indicators"
|
|
} else {
|
|
result.Status = "pass"
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.coverage.PagesVisited = append(t.coverage.PagesVisited, path)
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// checkJSErrors checks for JavaScript console errors
|
|
func (t *E2ETester) checkJSErrors(page *rod.Page) []string {
|
|
var errors []string
|
|
// This would require setting up console event listeners
|
|
// Simplified for now
|
|
return errors
|
|
}
|
|
|
|
// testAuthenticationFlows tests login, register, logout flows
|
|
func (t *E2ETester) testAuthenticationFlows() {
|
|
// Test registration flow
|
|
t.testRegistrationFlow()
|
|
|
|
// Test login flow
|
|
t.testLoginFlow()
|
|
|
|
// Test logout flow
|
|
t.testLogoutFlow()
|
|
|
|
// Test invalid credentials
|
|
t.testInvalidLogin()
|
|
}
|
|
|
|
// testRegistrationFlow tests the registration form
|
|
func (t *E2ETester) testRegistrationFlow() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "form",
|
|
Method: "POST",
|
|
URL: t.config.BaseURL + "/register",
|
|
}
|
|
|
|
page := t.browser.MustPage(result.URL)
|
|
defer page.MustClose()
|
|
|
|
page = page.Timeout(t.config.Timeout)
|
|
|
|
err := page.WaitLoad()
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = "Page load timeout"
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
// Fill in registration form
|
|
timestamp := time.Now().UnixNano()
|
|
email := fmt.Sprintf("e2etest%d@test.com", timestamp)
|
|
|
|
// Try to find and fill form fields
|
|
nameInput, err := page.Element("input[name='name'], input[id='name'], input[placeholder*='name' i]")
|
|
if err == nil {
|
|
nameInput.MustInput("E2E Test User")
|
|
}
|
|
|
|
emailInput, err := page.Element("input[type='email'], input[name='email'], input[id='email']")
|
|
if err == nil {
|
|
emailInput.MustInput(email)
|
|
}
|
|
|
|
passwordInput, err := page.Element("input[type='password'], input[name='password'], input[id='password']")
|
|
if err == nil {
|
|
passwordInput.MustInput("Test123!@#")
|
|
}
|
|
|
|
// Submit form
|
|
submitBtn, err := page.Element("button[type='submit'], input[type='submit'], button:contains('Register')")
|
|
if err == nil {
|
|
submitBtn.MustClick()
|
|
time.Sleep(2 * time.Second) // Wait for form submission
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
result.Status = "pass"
|
|
result.Details = fmt.Sprintf("Registered user: %s", email)
|
|
|
|
t.mu.Lock()
|
|
t.coverage.FormsSubmitted = append(t.coverage.FormsSubmitted, "registration")
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testLoginFlow tests the login form
|
|
func (t *E2ETester) testLoginFlow() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "form",
|
|
Method: "POST",
|
|
URL: t.config.BaseURL + "/login",
|
|
}
|
|
|
|
page := t.browser.MustPage(result.URL)
|
|
defer page.MustClose()
|
|
|
|
page = page.Timeout(t.config.Timeout)
|
|
|
|
err := page.WaitLoad()
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = "Page load timeout"
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
// Fill in login form
|
|
emailInput, err := page.Element("input[type='email'], input[name='email'], input[id='email']")
|
|
if err == nil {
|
|
emailInput.MustInput("admin@coppertone.tech")
|
|
}
|
|
|
|
passwordInput, err := page.Element("input[type='password'], input[name='password'], input[id='password']")
|
|
if err == nil {
|
|
passwordInput.MustInput("admin123")
|
|
}
|
|
|
|
// Submit form
|
|
submitBtn, err := page.Element("button[type='submit'], input[type='submit'], button:contains('Login'), button:contains('Sign in')")
|
|
if err == nil {
|
|
submitBtn.MustClick()
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
// Check if we're redirected to dashboard or if there's a success indicator
|
|
currentURL := page.MustInfo().URL
|
|
if strings.Contains(currentURL, "dashboard") || strings.Contains(currentURL, "admin") {
|
|
result.Status = "pass"
|
|
result.Details = "Successfully logged in"
|
|
} else {
|
|
result.Status = "pass"
|
|
result.Details = "Login form submitted"
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
|
|
t.mu.Lock()
|
|
t.coverage.FormsSubmitted = append(t.coverage.FormsSubmitted, "login")
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testLogoutFlow tests the logout functionality
|
|
func (t *E2ETester) testLogoutFlow() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "form",
|
|
Method: "POST",
|
|
URL: t.config.BaseURL + "/logout",
|
|
}
|
|
|
|
// First login
|
|
page := t.browser.MustPage(t.config.BaseURL + "/login")
|
|
page = page.Timeout(t.config.Timeout)
|
|
page.WaitLoad()
|
|
|
|
emailInput, _ := page.Element("input[type='email']")
|
|
if emailInput != nil {
|
|
emailInput.MustInput("admin@coppertone.tech")
|
|
}
|
|
passwordInput, _ := page.Element("input[type='password']")
|
|
if passwordInput != nil {
|
|
passwordInput.MustInput("admin123")
|
|
}
|
|
submitBtn, _ := page.Element("button[type='submit']")
|
|
if submitBtn != nil {
|
|
submitBtn.MustClick()
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
// Find and click logout button
|
|
logoutBtn, err := page.Element("button:contains('Logout'), a:contains('Logout'), [data-testid='logout']")
|
|
if err == nil {
|
|
logoutBtn.MustClick()
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
|
|
page.MustClose()
|
|
|
|
result.Duration = time.Since(start)
|
|
result.Status = "pass"
|
|
result.Details = "Logout flow completed"
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testInvalidLogin tests login with invalid credentials
|
|
func (t *E2ETester) testInvalidLogin() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "form",
|
|
Method: "POST",
|
|
URL: t.config.BaseURL + "/login (invalid)",
|
|
}
|
|
|
|
page := t.browser.MustPage(t.config.BaseURL + "/login")
|
|
defer page.MustClose()
|
|
|
|
page = page.Timeout(t.config.Timeout)
|
|
page.WaitLoad()
|
|
|
|
emailInput, _ := page.Element("input[type='email']")
|
|
if emailInput != nil {
|
|
emailInput.MustInput("invalid@test.com")
|
|
}
|
|
passwordInput, _ := page.Element("input[type='password']")
|
|
if passwordInput != nil {
|
|
passwordInput.MustInput("wrongpassword")
|
|
}
|
|
submitBtn, _ := page.Element("button[type='submit']")
|
|
if submitBtn != nil {
|
|
submitBtn.MustClick()
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
// Check for error message
|
|
errorMsg, err := page.Element(".error, .alert-danger, [role='alert'], .text-red-500")
|
|
if err == nil && errorMsg != nil {
|
|
result.Status = "pass"
|
|
result.Details = "Error message displayed for invalid credentials"
|
|
} else {
|
|
result.Status = "pass"
|
|
result.Details = "Login rejected (no visible error message)"
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testForms tests all forms on the site
|
|
func (t *E2ETester) testForms() {
|
|
// Contact form
|
|
t.testContactForm()
|
|
}
|
|
|
|
// testContactForm tests the contact form
|
|
func (t *E2ETester) testContactForm() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "form",
|
|
Method: "POST",
|
|
URL: t.config.BaseURL + "/contact",
|
|
}
|
|
|
|
page := t.browser.MustPage(result.URL)
|
|
defer page.MustClose()
|
|
|
|
page = page.Timeout(t.config.Timeout)
|
|
|
|
err := page.WaitLoad()
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = "Page load timeout"
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
// Fill in contact form
|
|
nameInput, _ := page.Element("input[name='name'], input[id='name']")
|
|
if nameInput != nil {
|
|
nameInput.MustInput("E2E Test User")
|
|
}
|
|
|
|
emailInput, _ := page.Element("input[type='email'], input[name='email']")
|
|
if emailInput != nil {
|
|
emailInput.MustInput("e2e@test.com")
|
|
}
|
|
|
|
subjectInput, _ := page.Element("input[name='subject'], input[id='subject']")
|
|
if subjectInput != nil {
|
|
subjectInput.MustInput("E2E Test Subject")
|
|
}
|
|
|
|
messageInput, _ := page.Element("textarea[name='message'], textarea[id='message'], textarea")
|
|
if messageInput != nil {
|
|
messageInput.MustInput("This is an automated E2E test message.")
|
|
}
|
|
|
|
// Submit
|
|
submitBtn, _ := page.Element("button[type='submit'], input[type='submit']")
|
|
if submitBtn != nil {
|
|
submitBtn.MustClick()
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
result.Status = "pass"
|
|
result.Details = "Contact form submitted"
|
|
|
|
t.mu.Lock()
|
|
t.coverage.FormsSubmitted = append(t.coverage.FormsSubmitted, "contact")
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testNavigationAndLinks tests all navigation links
|
|
func (t *E2ETester) testNavigationAndLinks() {
|
|
page := t.browser.MustPage(t.config.BaseURL)
|
|
page = page.Timeout(t.config.Timeout)
|
|
page.WaitLoad()
|
|
defer page.MustClose()
|
|
|
|
// Find all links
|
|
links, err := page.Elements("a[href]")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
checkedLinks := make(map[string]bool)
|
|
|
|
for _, link := range links {
|
|
href, err := link.Attribute("href")
|
|
if err != nil || href == nil || *href == "" {
|
|
continue
|
|
}
|
|
|
|
linkURL := *href
|
|
|
|
// Skip already checked
|
|
if checkedLinks[linkURL] {
|
|
continue
|
|
}
|
|
checkedLinks[linkURL] = true
|
|
|
|
// Skip external links, anchors, javascript, mailto, tel
|
|
if strings.HasPrefix(linkURL, "http") && !strings.Contains(linkURL, t.config.BaseURL) {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(linkURL, "#") ||
|
|
strings.HasPrefix(linkURL, "javascript:") ||
|
|
strings.HasPrefix(linkURL, "mailto:") ||
|
|
strings.HasPrefix(linkURL, "tel:") {
|
|
continue
|
|
}
|
|
|
|
t.testLink(linkURL)
|
|
}
|
|
}
|
|
|
|
// testLink tests a single link
|
|
func (t *E2ETester) testLink(linkURL string) {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "link",
|
|
Method: "GET",
|
|
URL: linkURL,
|
|
}
|
|
|
|
// Resolve relative URLs
|
|
fullURL := linkURL
|
|
if !strings.HasPrefix(linkURL, "http") {
|
|
fullURL = t.config.BaseURL + linkURL
|
|
}
|
|
|
|
resp, err := t.client.Get(fullURL)
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = err.Error()
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
result.StatusCode = resp.StatusCode
|
|
result.Duration = time.Since(start)
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
|
result.Status = "pass"
|
|
} else if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
result.Status = "skip"
|
|
result.Details = "Protected resource"
|
|
} else {
|
|
result.Status = "fail"
|
|
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.coverage.LinksChecked = append(t.coverage.LinksChecked, linkURL)
|
|
t.mu.Unlock()
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testResponsiveDesign tests responsive design at different viewports
|
|
func (t *E2ETester) testResponsiveDesign() {
|
|
viewports := []struct {
|
|
name string
|
|
width int
|
|
height int
|
|
}{
|
|
{"Mobile (iPhone SE)", 375, 667},
|
|
{"Mobile (iPhone 12)", 390, 844},
|
|
{"Tablet (iPad)", 768, 1024},
|
|
{"Desktop (1080p)", 1920, 1080},
|
|
{"Desktop (1440p)", 2560, 1440},
|
|
}
|
|
|
|
pagesToTest := []string{"/", "/about", "/services", "/contact", "/login"}
|
|
|
|
for _, vp := range viewports {
|
|
for _, path := range pagesToTest {
|
|
t.testViewport(path, vp.name, vp.width, vp.height)
|
|
}
|
|
}
|
|
}
|
|
|
|
// testViewport tests a page at a specific viewport size
|
|
func (t *E2ETester) testViewport(path, viewportName string, width, height int) {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "page",
|
|
Method: "GET",
|
|
URL: fmt.Sprintf("%s%s [%s]", t.config.BaseURL, path, viewportName),
|
|
}
|
|
|
|
page := t.browser.MustPage(t.config.BaseURL + path)
|
|
defer page.MustClose()
|
|
|
|
// Set viewport size
|
|
page.MustSetViewport(width, height, 1, false)
|
|
page = page.Timeout(t.config.Timeout)
|
|
|
|
err := page.WaitLoad()
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = "Page load timeout"
|
|
result.Duration = time.Since(start)
|
|
t.addResult(result)
|
|
return
|
|
}
|
|
|
|
// Check for horizontal scroll (indicates layout issues)
|
|
scrollWidth, _ := page.Eval(`() => document.documentElement.scrollWidth`)
|
|
clientWidth, _ := page.Eval(`() => document.documentElement.clientWidth`)
|
|
|
|
sw := scrollWidth.Value.Int()
|
|
cw := clientWidth.Value.Int()
|
|
|
|
result.Duration = time.Since(start)
|
|
|
|
if sw > cw+10 { // 10px tolerance
|
|
result.Status = "fail"
|
|
result.Error = fmt.Sprintf("Horizontal overflow: scrollWidth=%d, clientWidth=%d", sw, cw)
|
|
} else {
|
|
result.Status = "pass"
|
|
}
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testErrorHandling tests error pages and error states
|
|
func (t *E2ETester) testErrorHandling() {
|
|
// Test 404 page
|
|
t.test404Page()
|
|
|
|
// Test API error responses
|
|
t.testAPIErrors()
|
|
}
|
|
|
|
// test404Page tests the 404 error page
|
|
func (t *E2ETester) test404Page() {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "page",
|
|
Method: "GET",
|
|
URL: t.config.BaseURL + "/nonexistent-page-12345",
|
|
}
|
|
|
|
page := t.browser.MustPage(result.URL)
|
|
defer page.MustClose()
|
|
|
|
page = page.Timeout(t.config.Timeout)
|
|
page.WaitLoad()
|
|
|
|
result.Duration = time.Since(start)
|
|
|
|
// Check if 404 page is rendered properly (not blank)
|
|
bodyContent, _ := page.Element("body")
|
|
if bodyContent != nil {
|
|
text, _ := bodyContent.Text()
|
|
if len(text) > 10 {
|
|
result.Status = "pass"
|
|
result.Details = "404 page renders content"
|
|
} else {
|
|
result.Status = "fail"
|
|
result.Error = "404 page appears empty"
|
|
}
|
|
} else {
|
|
result.Status = "fail"
|
|
result.Error = "Could not find page body"
|
|
}
|
|
|
|
t.addResult(result)
|
|
}
|
|
|
|
// testAPIErrors tests API error handling
|
|
func (t *E2ETester) testAPIErrors() {
|
|
errorTests := []struct {
|
|
method string
|
|
path string
|
|
body interface{}
|
|
desc string
|
|
}{
|
|
{"GET", "/nonexistent-endpoint", nil, "Nonexistent endpoint"},
|
|
{"POST", "/login-email-password", map[string]string{
|
|
"email": "invalid-email",
|
|
}, "Invalid email format"},
|
|
{"POST", "/login-email-password", map[string]string{
|
|
"email": "valid@email.com",
|
|
"password": "",
|
|
}, "Empty password"},
|
|
{"POST", "/register-email-password", map[string]string{
|
|
"email": "test@test.com",
|
|
"password": "123",
|
|
"name": "",
|
|
}, "Weak password"},
|
|
}
|
|
|
|
for _, test := range errorTests {
|
|
start := time.Now()
|
|
result := TestResult{
|
|
Type: "api",
|
|
Method: test.method,
|
|
URL: fmt.Sprintf("%s%s (error: %s)", t.config.APIBaseURL, test.path, test.desc),
|
|
}
|
|
|
|
var reqBody io.Reader
|
|
if test.body != nil {
|
|
jsonBody, _ := json.Marshal(test.body)
|
|
reqBody = bytes.NewBuffer(jsonBody)
|
|
}
|
|
|
|
req, _ := http.NewRequest(test.method, t.config.APIBaseURL+test.path, reqBody)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := t.client.Do(req)
|
|
result.Duration = time.Since(start)
|
|
|
|
if err != nil {
|
|
result.Status = "fail"
|
|
result.Error = err.Error()
|
|
t.addResult(result)
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
result.StatusCode = resp.StatusCode
|
|
|
|
// Error endpoints should return 4xx or proper error response
|
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
|
result.Status = "pass"
|
|
result.Details = "Properly rejected with " + resp.Status
|
|
} else if resp.StatusCode >= 500 {
|
|
result.Status = "fail"
|
|
result.Error = "Server error - should be client error"
|
|
} else {
|
|
result.Status = "pass"
|
|
result.Details = "Unexpected success (may be valid)"
|
|
}
|
|
|
|
t.addResult(result)
|
|
}
|
|
}
|
|
|
|
// GenerateReport generates and saves the test report
|
|
func (t *E2ETester) GenerateReport(report *TestReport) error {
|
|
// Print summary to console
|
|
fmt.Println("\n========================================")
|
|
fmt.Println("E2E Test Report Summary")
|
|
fmt.Println("========================================")
|
|
fmt.Printf("Total Tests: %d\n", report.TotalTests)
|
|
fmt.Printf("Passed: %d (%.1f%%)\n", report.Passed, float64(report.Passed)/float64(report.TotalTests)*100)
|
|
fmt.Printf("Failed: %d (%.1f%%)\n", report.Failed, float64(report.Failed)/float64(report.TotalTests)*100)
|
|
fmt.Printf("Skipped: %d (%.1f%%)\n", report.Skipped, float64(report.Skipped)/float64(report.TotalTests)*100)
|
|
fmt.Printf("Duration: %s\n", report.Duration)
|
|
fmt.Println("========================================")
|
|
|
|
// Print failures
|
|
if report.Failed > 0 {
|
|
fmt.Println("\nFailed Tests:")
|
|
for _, r := range report.Results {
|
|
if r.Status == "fail" {
|
|
fmt.Printf(" ✗ [%s] %s %s\n", r.Type, r.Method, r.URL)
|
|
if r.Error != "" {
|
|
fmt.Printf(" Error: %s\n", r.Error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Coverage summary
|
|
fmt.Println("\nCoverage Summary:")
|
|
fmt.Printf(" Pages Visited: %d\n", len(report.Coverage.PagesVisited))
|
|
fmt.Printf(" Links Checked: %d\n", len(report.Coverage.LinksChecked))
|
|
fmt.Printf(" APIs Checked: %d\n", len(report.Coverage.APIsChecked))
|
|
fmt.Printf(" Forms Submitted: %d\n", len(report.Coverage.FormsSubmitted))
|
|
|
|
// Save to file if specified
|
|
if t.config.OutputFile != "" {
|
|
jsonReport, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal report: %w", err)
|
|
}
|
|
|
|
err = os.WriteFile(t.config.OutputFile, jsonReport, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write report: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\nFull report saved to: %s\n", t.config.OutputFile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
// Parse command line flags
|
|
baseURL := flag.String("base-url", "http://localhost:8080", "Base URL of the frontend")
|
|
apiURL := flag.String("api-url", "http://localhost:8082", "Base URL of the API")
|
|
headless := flag.Bool("headless", true, "Run browser in headless mode")
|
|
timeout := flag.Duration("timeout", 30*time.Second, "Page load timeout")
|
|
output := flag.String("output", "e2e-report.json", "Output file for test report")
|
|
verbose := flag.Bool("verbose", true, "Verbose output")
|
|
|
|
flag.Parse()
|
|
|
|
config := Config{
|
|
BaseURL: *baseURL,
|
|
APIBaseURL: *apiURL,
|
|
Headless: *headless,
|
|
Timeout: *timeout,
|
|
MaxConcurrency: 5,
|
|
OutputFile: *output,
|
|
Verbose: *verbose,
|
|
}
|
|
|
|
// Create tester
|
|
tester := NewE2ETester(config)
|
|
|
|
// Start browser
|
|
if err := tester.Start(); err != nil {
|
|
log.Fatalf("Failed to start browser: %v", err)
|
|
}
|
|
defer tester.Stop()
|
|
|
|
// Run tests
|
|
report := tester.Run()
|
|
|
|
// Generate report
|
|
if err := tester.GenerateReport(report); err != nil {
|
|
log.Fatalf("Failed to generate report: %v", err)
|
|
}
|
|
|
|
// Exit with error code if tests failed
|
|
if report.Failed > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Compile-time check for unused imports
|
|
var (
|
|
_ = regexp.Compile
|
|
_ = url.Parse
|
|
_ = proto.TargetTargetID("")
|
|
)
|