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 {} }