auth-ui: add e2e tests
Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
new file mode 100644
index 0000000..19949f5
--- /dev/null
+++ b/core/auth/ui/e2e/browser.go
@@ -0,0 +1,444 @@
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ playwright "github.com/mxschmitt/playwright-go"
+)
+
+type browserRuntime struct {
+ Playwright *playwright.Playwright
+ Browser playwright.Browser
+ mu sync.Mutex
+ closed bool
+}
+
+func startBrowserRuntime() (*browserRuntime, error) {
+ return startBrowserRuntimeOwned(nil)
+}
+
+func startBrowserRuntimeOwned(owner *suiteLifecycle) (*browserRuntime, error) {
+ runtime := &browserRuntime{}
+ if owner != nil && !owner.setBrowser(runtime) {
+ return nil, fmt.Errorf("E2E watchdog fired before browser ownership registration")
+ }
+ pw, err := playwright.Run()
+ if err != nil {
+ return runtime, fmt.Errorf("start Playwright %s: %w; run `make install-e2e-browser` (go run github.com/mxschmitt/playwright-go/cmd/playwright@%s install chromium)", playwrightVersion, err, playwrightVersion)
+ }
+ runtime.mu.Lock()
+ if runtime.closed {
+ runtime.mu.Unlock()
+ _ = pw.Stop()
+ return runtime, fmt.Errorf("E2E cleanup started while launching Playwright")
+ }
+ runtime.Playwright = pw
+ runtime.mu.Unlock()
+ browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{Headless: playwright.Bool(true)})
+ if err != nil {
+ _ = runtime.Close()
+ return runtime, fmt.Errorf("launch Playwright-managed Chromium: %w; run `make install-e2e-browser`", err)
+ }
+ runtime.mu.Lock()
+ if runtime.closed {
+ runtime.mu.Unlock()
+ _ = browser.Close()
+ return runtime, fmt.Errorf("E2E cleanup started while launching Chromium")
+ }
+ runtime.Browser = browser
+ runtime.mu.Unlock()
+ if got := browser.Version(); got != chromiumVersion {
+ _ = runtime.Close()
+ return runtime, fmt.Errorf("unexpected managed Chromium version %q, want %s (revision %s, Playwright CLI %s)", got, chromiumVersion, chromiumRevision, playwrightCLIVersion)
+ }
+ return runtime, nil
+}
+
+func (r *browserRuntime) Close() error {
+ if r == nil {
+ return nil
+ }
+ r.mu.Lock()
+ if r.closed {
+ r.mu.Unlock()
+ return nil
+ }
+ r.closed = true
+ browser, pw := r.Browser, r.Playwright
+ r.mu.Unlock()
+ var errs []string
+ if browser != nil {
+ if err := browser.Close(); err != nil {
+ errs = append(errs, err.Error())
+ }
+ }
+ if pw != nil {
+ if err := pw.Stop(); err != nil {
+ errs = append(errs, err.Error())
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("close browser runtime: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+type testReporter interface {
+ Name() string
+ Failed() bool
+ Errorf(string, ...any)
+ Cleanup(func())
+}
+
+type browserSession struct {
+ Page playwright.Page
+ Context playwright.BrowserContext
+ t testReporter
+ dir string
+ video playwright.Video
+ started time.Time
+ screenshots []string
+ checkpoint int
+ finalize sync.Once
+ lifecycleMu sync.Mutex
+ forcedFailure bool
+ registry *sessionRegistry
+ browserVer string
+ blockedMu sync.Mutex
+ blocked []string
+ requestsMu sync.Mutex
+ requests []requestMetadata
+ tracingStarted bool
+ screenshotOp func(string) error
+ stopTraceOp func(string) error
+ closeContextOp func() error
+ saveVideoOp func(string) error
+ finalURLOp func() string
+}
+
+func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+ session, err := newBrowserSessionOwner(t, root, browser.Version())
+ if err != nil {
+ return nil, err
+ }
+ // Register while holding the construction/finalization lock. A watchdog
+ // either snapshots this ownership and waits here, or rejects construction
+ // before any external Playwright context exists.
+ session.lifecycleMu.Lock()
+ if activeSuiteLifecycle != nil {
+ session.registry = activeSuiteLifecycle.sessions
+ if !session.registry.add(session) {
+ session.lifecycleMu.Unlock()
+ _ = session.finalizeAfterTimeout()
+ return nil, fmt.Errorf("create browser session after E2E watchdog cleanup started")
+ }
+ }
+ defer session.lifecycleMu.Unlock()
+ videoDir := filepath.Join(session.dir, ".video")
+ context, err := browser.NewContext(playwright.BrowserNewContextOptions{
+ Viewport: &playwright.Size{Width: 1280, Height: 720},
+ RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+ ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
+ })
+ if err != nil {
+ return nil, err
+ }
+ session.Context = context
+ if err := context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true), Sources: playwright.Bool(true)}); err != nil {
+ return nil, err
+ }
+ session.tracingStarted = true
+ page, err := context.NewPage()
+ if err != nil {
+ return nil, err
+ }
+ page.SetDefaultTimeout(15_000)
+ page.SetDefaultNavigationTimeout(30_000)
+ page.OnRequest(func(request playwright.Request) {
+ session.recordRequest(request.Method(), 0, request.URL())
+ })
+ page.OnResponse(func(response playwright.Response) {
+ session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
+ })
+ s := session
+ s.Page = page
+ s.video = page.Video()
+ policy := newRoutePolicy(allowedOrigins)
+ if err := context.Route("**/*", func(route playwright.Route) {
+ if policy(route.Request().URL()) {
+ _ = route.Continue()
+ } else {
+ session.recordBlocked(route.Request().URL())
+ _ = route.Abort("blockedbyclient")
+ }
+ }); err != nil {
+ return nil, err
+ }
+ if err := s.screenshot("00-initial.png"); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
+ dir := filepath.Join(root, sanitizeName(t.Name()))
+ if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
+ return nil, err
+ }
+ session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+ session.installDefaultArtifactOps()
+ // Own metadata and all available partial artifacts before Playwright context
+ // construction. If Playwright cannot create a context/page/trace, cleanup
+ // records the failure without pretending the unavailable artifacts succeeded.
+ t.Cleanup(func() {
+ if err := session.Finalize(); err != nil {
+ t.Errorf("finalize browser artifacts: %v", err)
+ }
+ })
+ return session, nil
+}
+
+func newRoutePolicy(allowedOrigins []string) func(string) bool {
+ allowed := make(map[string]struct{}, len(allowedOrigins))
+ for _, origin := range allowedOrigins {
+ u, err := url.Parse(origin)
+ if err == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.Path == "" {
+ allowed[u.Scheme+"://"+u.Host] = struct{}{}
+ }
+ }
+ return func(raw string) bool {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "http" || u.Hostname() != "127.0.0.1" || u.Port() == "" || u.User != nil {
+ return false
+ }
+ _, ok := allowed[u.Scheme+"://"+u.Host]
+ return ok
+ }
+}
+
+type requestMetadata struct {
+ Method string `json:"method"`
+ Status int `json:"status,omitempty"`
+ Origin string `json:"origin"`
+ Path string `json:"path"`
+}
+
+func sanitizedRequest(method string, status int, raw string) (requestMetadata, bool) {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return requestMetadata{}, false
+ }
+ return requestMetadata{Method: method, Status: status, Origin: u.Scheme + "://" + u.Host, Path: u.EscapedPath()}, true
+}
+
+func (s *browserSession) recordRequest(method string, status int, raw string) {
+ metadata, ok := sanitizedRequest(method, status, raw)
+ if !ok {
+ return
+ }
+ s.requestsMu.Lock()
+ s.requests = append(s.requests, metadata)
+ s.requestsMu.Unlock()
+}
+
+func (s *browserSession) RequestMetadata() []requestMetadata {
+ s.requestsMu.Lock()
+ defer s.requestsMu.Unlock()
+ return append([]requestMetadata(nil), s.requests...)
+}
+
+func (s *browserSession) recordBlocked(raw string) {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return
+ }
+ s.blockedMu.Lock()
+ s.blocked = append(s.blocked, u.Scheme+"://"+u.Host+u.Path)
+ s.blockedMu.Unlock()
+}
+
+func (s *browserSession) BlockedRequests() []string {
+ s.blockedMu.Lock()
+ defer s.blockedMu.Unlock()
+ return sortedStrings(s.blocked)
+}
+
+func (s *browserSession) Checkpoint(name string) error {
+ s.lifecycleMu.Lock()
+ defer s.lifecycleMu.Unlock()
+ s.checkpoint++
+ return s.screenshot(fmt.Sprintf("%02d-%s.png", s.checkpoint, sanitizeName(name)))
+}
+
+func (s *browserSession) installDefaultArtifactOps() {
+ s.screenshotOp = func(path string) error {
+ if s.Page == nil {
+ return fmt.Errorf("page unavailable")
+ }
+ _, err := s.Page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path), FullPage: playwright.Bool(true)})
+ return err
+ }
+ s.stopTraceOp = func(path string) error {
+ if s.Context == nil || !s.tracingStarted {
+ return fmt.Errorf("tracing unavailable")
+ }
+ return s.Context.Tracing().Stop(path)
+ }
+ s.closeContextOp = func() error {
+ if s.Context == nil {
+ return nil
+ }
+ return s.Context.Close()
+ }
+ s.saveVideoOp = func(path string) error {
+ if s.video == nil {
+ return fmt.Errorf("video unavailable")
+ }
+ return s.video.SaveAs(path)
+ }
+ s.finalURLOp = func() string {
+ if s.Page == nil {
+ return ""
+ }
+ return s.Page.URL()
+ }
+}
+
+func (s *browserSession) screenshot(name string) error {
+ path := filepath.Join(s.dir, "screenshots", name)
+ if err := s.screenshotOp(path); err != nil {
+ return err
+ }
+ s.screenshots = append(s.screenshots, name)
+ return nil
+}
+
+func (s *browserSession) Finalize() error {
+ if s.registry != nil {
+ return s.registry.finalizeNormal(s)
+ }
+ return s.finalizeClaimed(false)
+}
+
+func (s *browserSession) finalizeAfterTimeout() error {
+ return s.finalizeClaimed(true)
+}
+
+func (s *browserSession) finalizeClaimed(forcedFailure bool) error {
+ s.lifecycleMu.Lock()
+ defer s.lifecycleMu.Unlock()
+ if forcedFailure {
+ s.forcedFailure = true
+ }
+ var finalErr error
+ s.finalize.Do(func() {
+ var errs []string
+ if err := s.screenshot("99-final.png"); err != nil {
+ errs = append(errs, "final screenshot: "+err.Error())
+ }
+ if s.t.Failed() || s.forcedFailure {
+ if err := s.screenshot("failure.png"); err != nil {
+ errs = append(errs, "failure screenshot: "+err.Error())
+ }
+ }
+ if err := s.stopTraceOp(filepath.Join(s.dir, "trace.zip")); err != nil {
+ errs = append(errs, "trace: "+err.Error())
+ }
+ finalURL := sanitizeFinalURL(s.finalURLOp())
+ if err := s.closeContextOp(); err != nil {
+ errs = append(errs, "context: "+err.Error())
+ }
+ if err := s.saveVideoOp(filepath.Join(s.dir, "video.webm")); err != nil {
+ errs = append(errs, "video: "+err.Error())
+ } else {
+ _ = os.RemoveAll(filepath.Join(s.dir, ".video"))
+ }
+ outcome := "passed"
+ if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
+ outcome = "failed"
+ }
+ metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+ data, err := json.MarshalIndent(metadata, "", " ")
+ if err == nil {
+ err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
+ }
+ if err != nil {
+ errs = append(errs, "metadata: "+err.Error())
+ }
+ if len(errs) > 0 {
+ finalErr = fmt.Errorf("%s", strings.Join(errs, "; "))
+ }
+ })
+ return finalErr
+}
+
+func artifactOutcomeExpectation(failedBeforeFinalize, failedAfterFinalize bool) (outcome string, requireFailureScreenshot bool) {
+ if failedBeforeFinalize {
+ return "failed", true
+ }
+ if failedAfterFinalize {
+ return "failed", false
+ }
+ return "passed", false
+}
+
+const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
+
+func isExpectedBlockedBrowserRequest(request string) bool {
+ return request == expectedExternalFontRequest
+}
+
+type sessionMetadata struct {
+ TestName string `json:"test_name"`
+ StartedAt time.Time `json:"started_at"`
+ FinishedAt time.Time `json:"finished_at"`
+ Outcome string `json:"outcome"`
+ BindingVersion string `json:"binding_version"`
+ CLIVersion string `json:"playwright_cli_version"`
+ ChromiumRevision string `json:"chromium_revision"`
+ BrowserVersion string `json:"browser_version"`
+ Screenshots []string `json:"screenshots"`
+ FinalURL string `json:"final_url"`
+}
+
+var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
+
+func sanitizeName(name string) string {
+ name = strings.Trim(unsafeName.ReplaceAllString(name, "-"), "-.")
+ if name == "" {
+ return "unnamed"
+ }
+ if len(name) > 100 {
+ name = name[:100]
+ }
+ return name
+}
+
+func sanitizeFinalURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return ""
+ }
+ u.RawQuery = ""
+ u.Fragment = ""
+ u.User = nil
+ return u.String()
+}
+
+func sortedStrings(values []string) []string {
+ out := append([]string(nil), values...)
+ sort.Strings(out)
+ return out
+}