auth-ui: add e2e tests

Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/watchdog.go b/core/auth/ui/e2e/watchdog.go
new file mode 100644
index 0000000..5705c63
--- /dev/null
+++ b/core/auth/ui/e2e/watchdog.go
@@ -0,0 +1,379 @@
+package e2e
+
+import (
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	// The harness deadline includes setup and leaves one minute before Make's
+	// exact 10-minute Go timeout for cleanup and the emergency exit bound.
+	defaultSuiteWatchdogTimeout = 9 * time.Minute
+	watchdogCleanupExitTimeout  = 45 * time.Second
+	emergencyCleanupExitGrace   = 5 * time.Second
+	suiteWatchdogTimeoutEnv     = "AUTH_UI_E2E_WATCHDOG_TIMEOUT"
+)
+
+type sessionClaim uint8
+
+const (
+	sessionUnclaimed sessionClaim = iota
+	sessionNormal
+	sessionTimeout
+)
+
+type sessionEntry struct {
+	claim sessionClaim
+	done  chan struct{}
+	err   error
+}
+
+type sessionRegistry struct {
+	mu       sync.Mutex
+	sessions map[*browserSession]*sessionEntry
+	timedOut bool
+}
+
+func newSessionRegistry() *sessionRegistry {
+	return &sessionRegistry{sessions: make(map[*browserSession]*sessionEntry)}
+}
+
+func (r *sessionRegistry) add(session *browserSession) bool {
+	if r == nil || session == nil {
+		return false
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.timedOut {
+		return false
+	}
+	r.sessions[session] = &sessionEntry{done: make(chan struct{})}
+	return true
+}
+
+func (r *sessionRegistry) finalizeNormal(session *browserSession) error {
+	if r == nil {
+		return session.finalizeClaimed(false)
+	}
+	r.mu.Lock()
+	entry := r.sessions[session]
+	if entry == nil {
+		r.mu.Unlock()
+		return session.finalizeClaimed(false)
+	}
+	owner := entry.claim == sessionUnclaimed
+	if owner {
+		entry.claim = sessionNormal
+	}
+	r.mu.Unlock()
+	if owner {
+		err := session.finalizeClaimed(false)
+		r.complete(session, entry, err)
+		return err
+	}
+	<-entry.done
+	return entry.err
+}
+
+func (r *sessionRegistry) complete(session *browserSession, entry *sessionEntry, err error) {
+	r.mu.Lock()
+	entry.err = err
+	delete(r.sessions, session)
+	close(entry.done)
+	r.mu.Unlock()
+}
+
+func (r *sessionRegistry) finalizeAfterTimeout() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	r.timedOut = true
+	type claimedSession struct {
+		session *browserSession
+		entry   *sessionEntry
+		owner   bool
+	}
+	claimed := make([]claimedSession, 0, len(r.sessions))
+	for session, entry := range r.sessions {
+		owner := entry.claim == sessionUnclaimed
+		if owner {
+			entry.claim = sessionTimeout
+		}
+		claimed = append(claimed, claimedSession{session: session, entry: entry, owner: owner})
+	}
+	r.mu.Unlock()
+	sort.Slice(claimed, func(i, j int) bool { return claimed[i].session.dir < claimed[j].session.dir })
+	var errs []string
+	for _, claim := range claimed {
+		if claim.owner {
+			err := claim.session.finalizeClaimed(true)
+			r.complete(claim.session, claim.entry, err)
+		}
+		<-claim.entry.done
+		if claim.entry.err != nil {
+			errs = append(errs, fmt.Sprintf("%s: %v", filepathBase(claim.session.dir), claim.entry.err))
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("finalize timed-out browser sessions: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+// filepathBase avoids putting full retained paths into aggregate errors.
+func filepathBase(path string) string {
+	for len(path) > 1 && os.IsPathSeparator(path[len(path)-1]) {
+		path = path[:len(path)-1]
+	}
+	if i := strings.LastIndexAny(path, `/\\`); i >= 0 {
+		return path[i+1:]
+	}
+	return path
+}
+
+type ownedCloser interface{ Close() error }
+
+type closerRegistry struct {
+	mu      sync.Mutex
+	closers map[ownedCloser]struct{}
+	closing bool
+}
+
+func newCloserRegistry() *closerRegistry {
+	return &closerRegistry{closers: make(map[ownedCloser]struct{})}
+}
+
+func (r *closerRegistry) add(closer ownedCloser) bool {
+	if r == nil || closer == nil {
+		return false
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.closing {
+		return false
+	}
+	r.closers[closer] = struct{}{}
+	return true
+}
+
+func (r *closerRegistry) remove(closer ownedCloser) {
+	if r == nil || closer == nil {
+		return
+	}
+	r.mu.Lock()
+	delete(r.closers, closer)
+	r.mu.Unlock()
+}
+
+func (r *closerRegistry) closeAll() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	r.closing = true
+	closers := make([]ownedCloser, 0, len(r.closers))
+	for closer := range r.closers {
+		closers = append(closers, closer)
+		delete(r.closers, closer)
+	}
+	r.mu.Unlock()
+	var errs []string
+	for _, closer := range closers {
+		if err := closer.Close(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("close owned listeners: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+type terminalReason uint8
+
+const (
+	terminalRunning terminalReason = iota
+	terminalNormal
+	terminalTimeout
+)
+
+type suiteLifecycle struct {
+	mu                 sync.Mutex
+	reason             terminalReason
+	timeoutExitClaimed bool
+	stack              *Stack
+	browser            *browserRuntime
+	sessions           *sessionRegistry
+	closers            *closerRegistry
+
+	finishOnce sync.Once
+	finishErr  error
+}
+
+// Installed before tagged-suite setup and retained until process exit.
+var activeSuiteLifecycle *suiteLifecycle
+
+func newSuiteLifecycle(stack *Stack, browser *browserRuntime) *suiteLifecycle {
+	return &suiteLifecycle{stack: stack, browser: browser, sessions: newSessionRegistry(), closers: newCloserRegistry()}
+}
+
+func (l *suiteLifecycle) setStack(stack *Stack) bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.stack = stack
+	return true
+}
+
+func (l *suiteLifecycle) setBrowser(browser *browserRuntime) bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.browser = browser
+	return true
+}
+
+func (l *suiteLifecycle) claimTimeout() bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return false
+	}
+	l.reason = terminalTimeout
+	return true
+}
+
+// claimAfterRun stops and claims normal cleanup atomically. If the timer is
+// already firing, this caller claims timeout completion so timeout status and
+// artifacts cannot be lost while the callback is waiting to run.
+func (l *suiteLifecycle) claimAfterRun(watchdog *suiteWatchdog) terminalReason {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalRunning {
+		return l.reason
+	}
+	if watchdog != nil && !watchdog.timer.Stop() {
+		l.reason = terminalTimeout
+	} else {
+		l.reason = terminalNormal
+	}
+	return l.reason
+}
+
+func (l *suiteLifecycle) claimTimeoutExit() bool {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.reason != terminalTimeout || l.timeoutExitClaimed {
+		return false
+	}
+	l.timeoutExitClaimed = true
+	return true
+}
+
+func (l *suiteLifecycle) currentReason() terminalReason {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	return l.reason
+}
+
+func (l *suiteLifecycle) diagnosticPaths() (workspace, artifacts string) {
+	l.mu.Lock()
+	stack := l.stack
+	l.mu.Unlock()
+	if stack != nil {
+		return stack.Workspace, stack.ArtifactDir
+	}
+	return "unavailable (setup incomplete)", "unavailable (setup incomplete)"
+}
+
+func (l *suiteLifecycle) finish(success bool) error {
+	if l == nil {
+		return nil
+	}
+	l.finishOnce.Do(func() {
+		l.mu.Lock()
+		reason, stack, browser := l.reason, l.stack, l.browser
+		l.mu.Unlock()
+		timedOut := reason == terminalTimeout
+		var errs []string
+		if timedOut && stack != nil {
+			stack.recordRun("watchdog_timeout", fmt.Errorf("internal E2E suite deadline exceeded"))
+		}
+		if timedOut {
+			if err := l.sessions.finalizeAfterTimeout(); err != nil {
+				errs = append(errs, err.Error())
+			}
+		}
+		if err := l.closers.closeAll(); err != nil {
+			errs = append(errs, err.Error())
+		}
+		if stack != nil {
+			if err := stack.StopServices(); err != nil {
+				errs = append(errs, "services: "+err.Error())
+			}
+		}
+		if browser != nil {
+			if err := browser.Close(); err != nil {
+				errs = append(errs, "browser: "+err.Error())
+			}
+		}
+		finalSuccess := success && !timedOut && len(errs) == 0
+		if stack != nil {
+			if err := stack.Finalize(finalSuccess); err != nil {
+				errs = append(errs, "artifacts: "+err.Error())
+			}
+		}
+		if len(errs) > 0 {
+			l.finishErr = fmt.Errorf("E2E suite cleanup: %s", strings.Join(errs, "; "))
+		}
+	})
+	return l.finishErr
+}
+
+func (l *suiteLifecycle) emergencyTerminate() error {
+	l.mu.Lock()
+	stack := l.stack
+	l.mu.Unlock()
+	var groups []int
+	if stack != nil {
+		groups = stack.emergencyProcessGroups()
+	}
+	return emergencyTerminateOwnedProcesses(os.Getpid(), groups)
+}
+
+func suiteWatchdogTimeout() (time.Duration, error) {
+	value := os.Getenv(suiteWatchdogTimeoutEnv)
+	if value == "" {
+		return defaultSuiteWatchdogTimeout, nil
+	}
+	duration, err := time.ParseDuration(value)
+	if err != nil || duration <= 0 {
+		return 0, fmt.Errorf("%s must be a positive Go duration", suiteWatchdogTimeoutEnv)
+	}
+	return duration, nil
+}
+
+type suiteWatchdog struct{ timer *time.Timer }
+
+func startSuiteWatchdog(timeout time.Duration, fire func()) *suiteWatchdog {
+	return &suiteWatchdog{timer: time.AfterFunc(timeout, fire)}
+}
+
+func (w *suiteWatchdog) stop() bool { return w == nil || w.timer.Stop() }
+
+func stopCleanupTimerOrWait(stop func() bool, wait func()) {
+	if !stop() {
+		wait()
+	}
+}
+
+func waitForEmergencyExit() { select {} }