blob: a21753a208a1f7759a3f51ff69cc2f072aaf0662 [file] [log] [blame]
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 browserSize struct {
Width int `json:"width"`
Height int `json:"height"`
}
type browserSessionOptions struct {
Viewport browserSize
VideoSize browserSize
ReducedMotion bool
}
func defaultBrowserSessionOptions() browserSessionOptions {
return browserSessionOptions{
Viewport: browserSize{Width: 1280, Height: 720},
VideoSize: browserSize{Width: 1280, Height: 720},
}
}
func validateBrowserSessionOptions(options browserSessionOptions) error {
if options.Viewport.Width <= 0 || options.Viewport.Height <= 0 {
return fmt.Errorf("browser viewport must have positive dimensions")
}
if options.VideoSize.Width <= 0 || options.VideoSize.Height <= 0 {
return fmt.Errorf("browser video must have positive dimensions")
}
return nil
}
type browserSession struct {
Page playwright.Page
Context playwright.BrowserContext
t testReporter
dir string
video playwright.Video
started time.Time
viewport browserSize
videoSize browserSize
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
diagnosticsMu sync.Mutex
diagnostics []string
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) {
return newBrowserSessionWithOptions(t, browser, root, allowedOrigins, defaultBrowserSessionOptions())
}
func newBrowserSessionWithOptions(t testReporter, browser playwright.Browser, root string, allowedOrigins []string, options browserSessionOptions) (*browserSession, error) {
if err := validateBrowserSessionOptions(options); err != nil {
return nil, err
}
session, err := newBrowserSessionOwner(t, root, browser.Version())
if err != nil {
return nil, err
}
assignBrowserSessionMetadata(session, options)
// 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")
contextOptions := playwright.BrowserNewContextOptions{
Viewport: &playwright.Size{Width: options.Viewport.Width, Height: options.Viewport.Height},
RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: options.VideoSize.Width, Height: options.VideoSize.Height}},
ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
}
if options.ReducedMotion {
contextOptions.ReducedMotion = playwright.ReducedMotionReduce
}
context, err := browser.NewContext(contextOptions)
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())
})
page.OnConsole(func(message playwright.ConsoleMessage) {
if message.Type() == "error" && !isExpectedFormStatusConsoleError(message.Text()) {
session.recordDiagnostic("console error: " + message.Text())
}
})
page.OnPageError(func(err error) {
session.recordDiagnostic("page error: " + err.Error())
})
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 assignBrowserSessionMetadata(session *browserSession, options browserSessionOptions) {
session.viewport = options.Viewport
session.videoSize = options.VideoSize
}
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
}
defaults := defaultBrowserSessionOptions()
session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion, viewport: defaults.Viewport, videoSize: defaults.VideoSize}
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 isExpectedFormStatusConsoleError(message string) bool {
switch message {
case "Failed to load resource: the server responded with a status of 409 (Conflict)",
"Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)":
return true
default:
return false
}
}
func (s *browserSession) recordDiagnostic(message string) {
s.diagnosticsMu.Lock()
s.diagnostics = append(s.diagnostics, message)
s.diagnosticsMu.Unlock()
}
func (s *browserSession) BrowserDiagnostics() []string {
s.diagnosticsMu.Lock()
defer s.diagnosticsMu.Unlock()
return append([]string(nil), s.diagnostics...)
}
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, Viewport: s.viewport, VideoSize: s.videoSize, 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
}
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"`
Viewport browserSize `json:"viewport"`
VideoSize browserSize `json:"video_size"`
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
}