mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Implements end-to-end testing infrastructure for the Pulse update flow, validating the entire path from UI to backend with controllable test scenarios. ## What's Included ### Test Infrastructure - Mock GitHub release server (Go) with controllable failure modes - Docker Compose test environment (isolated services) - Playwright test framework with TypeScript - 60+ test cases across 6 test suites - Helper library with 20+ reusable test utilities ### Test Scenarios 1. Happy Path (8 tests) - Valid checksums, successful update flow - Modal appears exactly once - Complete end-to-end validation 2. Bad Checksums (8 tests) - Server rejects invalid checksums - Error shown ONCE (not twice) - fixes v4.28.0 issue type - User-friendly error messages 3. Rate Limiting (9 tests) - Multiple rapid requests throttled gracefully - Proper rate limit headers - Clear error messages 4. Network Failure (10 tests) - Exponential backoff retry logic - Timeout handling - Graceful degradation 5. Stale Release (10 tests) - Backend refuses flagged releases - Informative error messages - Proper rejection logging 6. Frontend Validation (15 tests) - UpdateProgressModal appears exactly once - No duplicate modals on error - User-friendly error messages - Proper accessibility attributes ### CI/CD Integration - GitHub Actions workflow (.github/workflows/test-updates.yml) - Runs on PRs touching update-related code - Separate test runs for each scenario - Regression test to verify v4.28.0 issue prevention - Automatic artifact uploads ### Documentation - README.md: Architecture and overview - QUICK_START.md: Getting started guide - IMPLEMENTATION_SUMMARY.md: Complete implementation details - Helper scripts for setup and test execution ## Success Criteria Met ✅ Tests run in CI on every PR touching update code ✅ All scenarios pass reliably ✅ Tests catch v4.28.0 checksum issue type automatically ✅ Frontend UX regressions are blocked ## Usage ```bash cd tests/integration ./scripts/setup.sh # One-time setup npm test # Run all tests ``` See QUICK_START.md for detailed instructions. Addresses requirements from issue for comprehensive update flow testing with specific focus on preventing duplicate error modals and ensuring checksum validation works correctly.
308 lines
7.4 KiB
Go
308 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ReleaseInfo matches the GitHub API release structure
|
|
type ReleaseInfo struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Prerelease bool `json:"prerelease"`
|
|
PublishedAt string `json:"published_at"`
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
} `json:"assets"`
|
|
}
|
|
|
|
// Rate limiting tracker
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
requests map[string][]time.Time
|
|
}
|
|
|
|
func newRateLimiter() *rateLimiter {
|
|
rl := &rateLimiter{
|
|
requests: make(map[string][]time.Time),
|
|
}
|
|
// Cleanup old entries every minute
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
rl.cleanup()
|
|
}
|
|
}()
|
|
return rl
|
|
}
|
|
|
|
func (rl *rateLimiter) cleanup() {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
cutoff := time.Now().Add(-1 * time.Minute)
|
|
for ip := range rl.requests {
|
|
filtered := []time.Time{}
|
|
for _, t := range rl.requests[ip] {
|
|
if t.After(cutoff) {
|
|
filtered = append(filtered, t)
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
delete(rl.requests, ip)
|
|
} else {
|
|
rl.requests[ip] = filtered
|
|
}
|
|
}
|
|
}
|
|
|
|
func (rl *rateLimiter) check(ip string, limit int) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-1 * time.Minute)
|
|
|
|
// Filter to last minute
|
|
recent := []time.Time{}
|
|
for _, t := range rl.requests[ip] {
|
|
if t.After(cutoff) {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
|
|
if len(recent) >= limit {
|
|
return false
|
|
}
|
|
|
|
recent = append(recent, now)
|
|
rl.requests[ip] = recent
|
|
return true
|
|
}
|
|
|
|
func main() {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
limiter := newRateLimiter()
|
|
baseURL := fmt.Sprintf("http://localhost:%s", port)
|
|
|
|
// Environment-controlled behavior
|
|
checksumError := os.Getenv("MOCK_CHECKSUM_ERROR") == "true"
|
|
networkError := os.Getenv("MOCK_NETWORK_ERROR") == "true"
|
|
enableRateLimit := os.Getenv("MOCK_RATE_LIMIT") == "true"
|
|
staleRelease := os.Getenv("MOCK_STALE_RELEASE") == "true"
|
|
|
|
log.Printf("Mock GitHub Server starting on port %s", port)
|
|
log.Printf("Config: checksumError=%v networkError=%v rateLimit=%v staleRelease=%v",
|
|
checksumError, networkError, enableRateLimit, staleRelease)
|
|
|
|
// In-memory storage for tarballs and checksums
|
|
tarballs := make(map[string][]byte)
|
|
checksums := make(map[string]string)
|
|
|
|
// Generate test releases
|
|
releases := []ReleaseInfo{
|
|
{
|
|
TagName: "v4.28.1",
|
|
Name: "Pulse v4.28.1",
|
|
Prerelease: false,
|
|
PublishedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
{
|
|
TagName: "v4.28.0",
|
|
Name: "Pulse v4.28.0",
|
|
Prerelease: false,
|
|
PublishedAt: time.Now().Add(-48 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
{
|
|
TagName: "v4.29.0-rc.1",
|
|
Name: "Pulse v4.29.0 RC1",
|
|
Prerelease: true,
|
|
PublishedAt: time.Now().Add(-12 * time.Hour).Format(time.RFC3339),
|
|
},
|
|
}
|
|
|
|
// Generate tarballs and checksums for each release
|
|
for _, rel := range releases {
|
|
version := strings.TrimPrefix(rel.TagName, "v")
|
|
filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", version)
|
|
|
|
// Create dummy tarball
|
|
tarball := createDummyTarball(version)
|
|
tarballs[filename] = tarball
|
|
|
|
// Calculate checksum
|
|
hash := sha256.Sum256(tarball)
|
|
checksum := hex.EncodeToString(hash[:])
|
|
|
|
// Optionally corrupt checksum for testing
|
|
if checksumError {
|
|
checksum = "0000000000000000000000000000000000000000000000000000000000000000"
|
|
}
|
|
|
|
checksums[filename] = checksum
|
|
|
|
// Add download URLs to release
|
|
rel.Assets = []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
}{
|
|
{
|
|
Name: filename,
|
|
BrowserDownloadURL: fmt.Sprintf("%s/download/%s/%s", baseURL, version, filename),
|
|
},
|
|
{
|
|
Name: "checksums.txt",
|
|
BrowserDownloadURL: fmt.Sprintf("%s/download/%s/checksums.txt", baseURL, version),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Releases endpoint
|
|
http.HandleFunc("/repos/rcourtman/Pulse/releases", func(w http.ResponseWriter, r *http.Request) {
|
|
// Rate limiting
|
|
if enableRateLimit {
|
|
ip := r.RemoteAddr
|
|
if !limiter.check(ip, 3) { // Very aggressive: 3 requests per minute
|
|
w.Header().Set("X-RateLimit-Limit", "3")
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"message": "API rate limit exceeded",
|
|
})
|
|
log.Printf("Rate limited: %s", ip)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Network error simulation
|
|
if networkError {
|
|
time.Sleep(5 * time.Second)
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(releases)
|
|
log.Printf("Served releases list")
|
|
})
|
|
|
|
// Latest release endpoint
|
|
http.HandleFunc("/repos/rcourtman/Pulse/releases/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Return first non-prerelease
|
|
for _, rel := range releases {
|
|
if !rel.Prerelease {
|
|
json.NewEncoder(w).Encode(rel)
|
|
log.Printf("Served latest release: %s", rel.TagName)
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
})
|
|
|
|
// Download tarball
|
|
http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/download/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
version := parts[0]
|
|
file := parts[1]
|
|
|
|
if file == "checksums.txt" {
|
|
// Generate checksums.txt
|
|
var buf bytes.Buffer
|
|
for fname, chksum := range checksums {
|
|
if strings.Contains(fname, version) {
|
|
buf.WriteString(fmt.Sprintf("%s %s\n", chksum, fname))
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write(buf.Bytes())
|
|
log.Printf("Served checksums for version %s", version)
|
|
return
|
|
}
|
|
|
|
// Serve tarball
|
|
filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", version)
|
|
tarball, ok := tarballs[filename]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
log.Printf("Tarball not found: %s", filename)
|
|
return
|
|
}
|
|
|
|
// Mark as stale if requested
|
|
if staleRelease {
|
|
w.Header().Set("X-Release-Status", "stale")
|
|
w.Header().Set("X-Release-Warning", "This release has known issues and should not be installed")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(tarball)))
|
|
w.Write(tarball)
|
|
log.Printf("Served tarball: %s", filename)
|
|
})
|
|
|
|
// Health check
|
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
})
|
|
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|
|
|
|
func createDummyTarball(version string) []byte {
|
|
var buf bytes.Buffer
|
|
gw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
// Create a dummy binary with version info
|
|
content := []byte(fmt.Sprintf("#!/bin/sh\necho 'Pulse version %s'\n", version))
|
|
|
|
hdr := &tar.Header{
|
|
Name: "pulse",
|
|
Mode: 0755,
|
|
Size: int64(len(content)),
|
|
}
|
|
|
|
tw.WriteHeader(hdr)
|
|
tw.Write(content)
|
|
|
|
// Add a VERSION file
|
|
versionContent := []byte(version)
|
|
versionHdr := &tar.Header{
|
|
Name: "VERSION",
|
|
Mode: 0644,
|
|
Size: int64(len(versionContent)),
|
|
}
|
|
tw.WriteHeader(versionHdr)
|
|
tw.Write(versionContent)
|
|
|
|
tw.Close()
|
|
gw.Close()
|
|
|
|
return buf.Bytes()
|
|
}
|