blob: a21753a208a1f7759a3f51ff69cc2f072aaf0662 [file] [log] [blame]
giob7df27f2026-07-28 10:36:17 +04001package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/url"
7 "os"
8 "path/filepath"
9 "regexp"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 playwright "github.com/mxschmitt/playwright-go"
16)
17
18type browserRuntime struct {
19 Playwright *playwright.Playwright
20 Browser playwright.Browser
21 mu sync.Mutex
22 closed bool
23}
24
25func startBrowserRuntime() (*browserRuntime, error) {
26 return startBrowserRuntimeOwned(nil)
27}
28
29func startBrowserRuntimeOwned(owner *suiteLifecycle) (*browserRuntime, error) {
30 runtime := &browserRuntime{}
31 if owner != nil && !owner.setBrowser(runtime) {
32 return nil, fmt.Errorf("E2E watchdog fired before browser ownership registration")
33 }
34 pw, err := playwright.Run()
35 if err != nil {
36 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)
37 }
38 runtime.mu.Lock()
39 if runtime.closed {
40 runtime.mu.Unlock()
41 _ = pw.Stop()
42 return runtime, fmt.Errorf("E2E cleanup started while launching Playwright")
43 }
44 runtime.Playwright = pw
45 runtime.mu.Unlock()
46 browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{Headless: playwright.Bool(true)})
47 if err != nil {
48 _ = runtime.Close()
49 return runtime, fmt.Errorf("launch Playwright-managed Chromium: %w; run `make install-e2e-browser`", err)
50 }
51 runtime.mu.Lock()
52 if runtime.closed {
53 runtime.mu.Unlock()
54 _ = browser.Close()
55 return runtime, fmt.Errorf("E2E cleanup started while launching Chromium")
56 }
57 runtime.Browser = browser
58 runtime.mu.Unlock()
59 if got := browser.Version(); got != chromiumVersion {
60 _ = runtime.Close()
61 return runtime, fmt.Errorf("unexpected managed Chromium version %q, want %s (revision %s, Playwright CLI %s)", got, chromiumVersion, chromiumRevision, playwrightCLIVersion)
62 }
63 return runtime, nil
64}
65
66func (r *browserRuntime) Close() error {
67 if r == nil {
68 return nil
69 }
70 r.mu.Lock()
71 if r.closed {
72 r.mu.Unlock()
73 return nil
74 }
75 r.closed = true
76 browser, pw := r.Browser, r.Playwright
77 r.mu.Unlock()
78 var errs []string
79 if browser != nil {
80 if err := browser.Close(); err != nil {
81 errs = append(errs, err.Error())
82 }
83 }
84 if pw != nil {
85 if err := pw.Stop(); err != nil {
86 errs = append(errs, err.Error())
87 }
88 }
89 if len(errs) > 0 {
90 return fmt.Errorf("close browser runtime: %s", strings.Join(errs, "; "))
91 }
92 return nil
93}
94
95type testReporter interface {
96 Name() string
97 Failed() bool
98 Errorf(string, ...any)
99 Cleanup(func())
100}
101
gioe71b12b2026-07-29 10:02:37 +0400102type browserSize struct {
103 Width int `json:"width"`
104 Height int `json:"height"`
105}
106
107type browserSessionOptions struct {
108 Viewport browserSize
109 VideoSize browserSize
110 ReducedMotion bool
111}
112
113func defaultBrowserSessionOptions() browserSessionOptions {
114 return browserSessionOptions{
115 Viewport: browserSize{Width: 1280, Height: 720},
116 VideoSize: browserSize{Width: 1280, Height: 720},
117 }
118}
119
120func validateBrowserSessionOptions(options browserSessionOptions) error {
121 if options.Viewport.Width <= 0 || options.Viewport.Height <= 0 {
122 return fmt.Errorf("browser viewport must have positive dimensions")
123 }
124 if options.VideoSize.Width <= 0 || options.VideoSize.Height <= 0 {
125 return fmt.Errorf("browser video must have positive dimensions")
126 }
127 return nil
128}
129
giob7df27f2026-07-28 10:36:17 +0400130type browserSession struct {
131 Page playwright.Page
132 Context playwright.BrowserContext
133 t testReporter
134 dir string
135 video playwright.Video
136 started time.Time
gioe71b12b2026-07-29 10:02:37 +0400137 viewport browserSize
138 videoSize browserSize
giob7df27f2026-07-28 10:36:17 +0400139 screenshots []string
140 checkpoint int
141 finalize sync.Once
142 lifecycleMu sync.Mutex
143 forcedFailure bool
144 registry *sessionRegistry
145 browserVer string
146 blockedMu sync.Mutex
147 blocked []string
148 requestsMu sync.Mutex
149 requests []requestMetadata
gioe71b12b2026-07-29 10:02:37 +0400150 diagnosticsMu sync.Mutex
151 diagnostics []string
giob7df27f2026-07-28 10:36:17 +0400152 tracingStarted bool
153 screenshotOp func(string) error
154 stopTraceOp func(string) error
155 closeContextOp func() error
156 saveVideoOp func(string) error
157 finalURLOp func() string
158}
159
160func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
gioe71b12b2026-07-29 10:02:37 +0400161 return newBrowserSessionWithOptions(t, browser, root, allowedOrigins, defaultBrowserSessionOptions())
162}
163
164func newBrowserSessionWithOptions(t testReporter, browser playwright.Browser, root string, allowedOrigins []string, options browserSessionOptions) (*browserSession, error) {
165 if err := validateBrowserSessionOptions(options); err != nil {
166 return nil, err
167 }
giob7df27f2026-07-28 10:36:17 +0400168 session, err := newBrowserSessionOwner(t, root, browser.Version())
169 if err != nil {
170 return nil, err
171 }
gioe71b12b2026-07-29 10:02:37 +0400172 assignBrowserSessionMetadata(session, options)
giob7df27f2026-07-28 10:36:17 +0400173 // Register while holding the construction/finalization lock. A watchdog
174 // either snapshots this ownership and waits here, or rejects construction
175 // before any external Playwright context exists.
176 session.lifecycleMu.Lock()
177 if activeSuiteLifecycle != nil {
178 session.registry = activeSuiteLifecycle.sessions
179 if !session.registry.add(session) {
180 session.lifecycleMu.Unlock()
181 _ = session.finalizeAfterTimeout()
182 return nil, fmt.Errorf("create browser session after E2E watchdog cleanup started")
183 }
184 }
185 defer session.lifecycleMu.Unlock()
186 videoDir := filepath.Join(session.dir, ".video")
gioe71b12b2026-07-29 10:02:37 +0400187 contextOptions := playwright.BrowserNewContextOptions{
188 Viewport: &playwright.Size{Width: options.Viewport.Width, Height: options.Viewport.Height},
189 RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: options.VideoSize.Width, Height: options.VideoSize.Height}},
giob7df27f2026-07-28 10:36:17 +0400190 ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
gioe71b12b2026-07-29 10:02:37 +0400191 }
192 if options.ReducedMotion {
193 contextOptions.ReducedMotion = playwright.ReducedMotionReduce
194 }
195 context, err := browser.NewContext(contextOptions)
giob7df27f2026-07-28 10:36:17 +0400196 if err != nil {
197 return nil, err
198 }
199 session.Context = context
200 if err := context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true), Sources: playwright.Bool(true)}); err != nil {
201 return nil, err
202 }
203 session.tracingStarted = true
204 page, err := context.NewPage()
205 if err != nil {
206 return nil, err
207 }
208 page.SetDefaultTimeout(15_000)
209 page.SetDefaultNavigationTimeout(30_000)
210 page.OnRequest(func(request playwright.Request) {
211 session.recordRequest(request.Method(), 0, request.URL())
212 })
213 page.OnResponse(func(response playwright.Response) {
214 session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
215 })
gioe71b12b2026-07-29 10:02:37 +0400216 page.OnConsole(func(message playwright.ConsoleMessage) {
217 if message.Type() == "error" && !isExpectedFormStatusConsoleError(message.Text()) {
218 session.recordDiagnostic("console error: " + message.Text())
219 }
220 })
221 page.OnPageError(func(err error) {
222 session.recordDiagnostic("page error: " + err.Error())
223 })
giob7df27f2026-07-28 10:36:17 +0400224 s := session
225 s.Page = page
226 s.video = page.Video()
227 policy := newRoutePolicy(allowedOrigins)
228 if err := context.Route("**/*", func(route playwright.Route) {
229 if policy(route.Request().URL()) {
230 _ = route.Continue()
231 } else {
232 session.recordBlocked(route.Request().URL())
233 _ = route.Abort("blockedbyclient")
234 }
235 }); err != nil {
236 return nil, err
237 }
238 if err := s.screenshot("00-initial.png"); err != nil {
239 return nil, err
240 }
241 return s, nil
242}
243
gioe71b12b2026-07-29 10:02:37 +0400244func assignBrowserSessionMetadata(session *browserSession, options browserSessionOptions) {
245 session.viewport = options.Viewport
246 session.videoSize = options.VideoSize
247}
248
giob7df27f2026-07-28 10:36:17 +0400249func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
250 dir := filepath.Join(root, sanitizeName(t.Name()))
251 if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
252 return nil, err
253 }
254 if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
255 return nil, err
256 }
gioe71b12b2026-07-29 10:02:37 +0400257 defaults := defaultBrowserSessionOptions()
258 session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion, viewport: defaults.Viewport, videoSize: defaults.VideoSize}
giob7df27f2026-07-28 10:36:17 +0400259 session.installDefaultArtifactOps()
260 // Own metadata and all available partial artifacts before Playwright context
261 // construction. If Playwright cannot create a context/page/trace, cleanup
262 // records the failure without pretending the unavailable artifacts succeeded.
263 t.Cleanup(func() {
264 if err := session.Finalize(); err != nil {
265 t.Errorf("finalize browser artifacts: %v", err)
266 }
267 })
268 return session, nil
269}
270
271func newRoutePolicy(allowedOrigins []string) func(string) bool {
272 allowed := make(map[string]struct{}, len(allowedOrigins))
273 for _, origin := range allowedOrigins {
274 u, err := url.Parse(origin)
275 if err == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.Path == "" {
276 allowed[u.Scheme+"://"+u.Host] = struct{}{}
277 }
278 }
279 return func(raw string) bool {
280 u, err := url.Parse(raw)
281 if err != nil || u.Scheme != "http" || u.Hostname() != "127.0.0.1" || u.Port() == "" || u.User != nil {
282 return false
283 }
284 _, ok := allowed[u.Scheme+"://"+u.Host]
285 return ok
286 }
287}
288
289type requestMetadata struct {
290 Method string `json:"method"`
291 Status int `json:"status,omitempty"`
292 Origin string `json:"origin"`
293 Path string `json:"path"`
294}
295
296func sanitizedRequest(method string, status int, raw string) (requestMetadata, bool) {
297 u, err := url.Parse(raw)
298 if err != nil || u.Scheme == "" || u.Host == "" {
299 return requestMetadata{}, false
300 }
301 return requestMetadata{Method: method, Status: status, Origin: u.Scheme + "://" + u.Host, Path: u.EscapedPath()}, true
302}
303
304func (s *browserSession) recordRequest(method string, status int, raw string) {
305 metadata, ok := sanitizedRequest(method, status, raw)
306 if !ok {
307 return
308 }
309 s.requestsMu.Lock()
310 s.requests = append(s.requests, metadata)
311 s.requestsMu.Unlock()
312}
313
314func (s *browserSession) RequestMetadata() []requestMetadata {
315 s.requestsMu.Lock()
316 defer s.requestsMu.Unlock()
317 return append([]requestMetadata(nil), s.requests...)
318}
319
320func (s *browserSession) recordBlocked(raw string) {
321 u, err := url.Parse(raw)
322 if err != nil {
323 return
324 }
325 s.blockedMu.Lock()
326 s.blocked = append(s.blocked, u.Scheme+"://"+u.Host+u.Path)
327 s.blockedMu.Unlock()
328}
329
330func (s *browserSession) BlockedRequests() []string {
331 s.blockedMu.Lock()
332 defer s.blockedMu.Unlock()
333 return sortedStrings(s.blocked)
334}
335
gioe71b12b2026-07-29 10:02:37 +0400336func isExpectedFormStatusConsoleError(message string) bool {
337 switch message {
338 case "Failed to load resource: the server responded with a status of 409 (Conflict)",
339 "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)":
340 return true
341 default:
342 return false
343 }
344}
345
346func (s *browserSession) recordDiagnostic(message string) {
347 s.diagnosticsMu.Lock()
348 s.diagnostics = append(s.diagnostics, message)
349 s.diagnosticsMu.Unlock()
350}
351
352func (s *browserSession) BrowserDiagnostics() []string {
353 s.diagnosticsMu.Lock()
354 defer s.diagnosticsMu.Unlock()
355 return append([]string(nil), s.diagnostics...)
356}
357
giob7df27f2026-07-28 10:36:17 +0400358func (s *browserSession) Checkpoint(name string) error {
359 s.lifecycleMu.Lock()
360 defer s.lifecycleMu.Unlock()
361 s.checkpoint++
362 return s.screenshot(fmt.Sprintf("%02d-%s.png", s.checkpoint, sanitizeName(name)))
363}
364
365func (s *browserSession) installDefaultArtifactOps() {
366 s.screenshotOp = func(path string) error {
367 if s.Page == nil {
368 return fmt.Errorf("page unavailable")
369 }
370 _, err := s.Page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path), FullPage: playwright.Bool(true)})
371 return err
372 }
373 s.stopTraceOp = func(path string) error {
374 if s.Context == nil || !s.tracingStarted {
375 return fmt.Errorf("tracing unavailable")
376 }
377 return s.Context.Tracing().Stop(path)
378 }
379 s.closeContextOp = func() error {
380 if s.Context == nil {
381 return nil
382 }
383 return s.Context.Close()
384 }
385 s.saveVideoOp = func(path string) error {
386 if s.video == nil {
387 return fmt.Errorf("video unavailable")
388 }
389 return s.video.SaveAs(path)
390 }
391 s.finalURLOp = func() string {
392 if s.Page == nil {
393 return ""
394 }
395 return s.Page.URL()
396 }
397}
398
399func (s *browserSession) screenshot(name string) error {
400 path := filepath.Join(s.dir, "screenshots", name)
401 if err := s.screenshotOp(path); err != nil {
402 return err
403 }
404 s.screenshots = append(s.screenshots, name)
405 return nil
406}
407
408func (s *browserSession) Finalize() error {
409 if s.registry != nil {
410 return s.registry.finalizeNormal(s)
411 }
412 return s.finalizeClaimed(false)
413}
414
415func (s *browserSession) finalizeAfterTimeout() error {
416 return s.finalizeClaimed(true)
417}
418
419func (s *browserSession) finalizeClaimed(forcedFailure bool) error {
420 s.lifecycleMu.Lock()
421 defer s.lifecycleMu.Unlock()
422 if forcedFailure {
423 s.forcedFailure = true
424 }
425 var finalErr error
426 s.finalize.Do(func() {
427 var errs []string
428 if err := s.screenshot("99-final.png"); err != nil {
429 errs = append(errs, "final screenshot: "+err.Error())
430 }
431 if s.t.Failed() || s.forcedFailure {
432 if err := s.screenshot("failure.png"); err != nil {
433 errs = append(errs, "failure screenshot: "+err.Error())
434 }
435 }
436 if err := s.stopTraceOp(filepath.Join(s.dir, "trace.zip")); err != nil {
437 errs = append(errs, "trace: "+err.Error())
438 }
439 finalURL := sanitizeFinalURL(s.finalURLOp())
440 if err := s.closeContextOp(); err != nil {
441 errs = append(errs, "context: "+err.Error())
442 }
443 if err := s.saveVideoOp(filepath.Join(s.dir, "video.webm")); err != nil {
444 errs = append(errs, "video: "+err.Error())
445 } else {
446 _ = os.RemoveAll(filepath.Join(s.dir, ".video"))
447 }
448 outcome := "passed"
449 if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
450 outcome = "failed"
451 }
gioe71b12b2026-07-29 10:02:37 +0400452 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}
giob7df27f2026-07-28 10:36:17 +0400453 data, err := json.MarshalIndent(metadata, "", " ")
454 if err == nil {
455 err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
456 }
457 if err != nil {
458 errs = append(errs, "metadata: "+err.Error())
459 }
460 if len(errs) > 0 {
461 finalErr = fmt.Errorf("%s", strings.Join(errs, "; "))
462 }
463 })
464 return finalErr
465}
466
467func artifactOutcomeExpectation(failedBeforeFinalize, failedAfterFinalize bool) (outcome string, requireFailureScreenshot bool) {
468 if failedBeforeFinalize {
469 return "failed", true
470 }
471 if failedAfterFinalize {
472 return "failed", false
473 }
474 return "passed", false
475}
476
giob7df27f2026-07-28 10:36:17 +0400477type sessionMetadata struct {
gioe71b12b2026-07-29 10:02:37 +0400478 TestName string `json:"test_name"`
479 StartedAt time.Time `json:"started_at"`
480 FinishedAt time.Time `json:"finished_at"`
481 Outcome string `json:"outcome"`
482 BindingVersion string `json:"binding_version"`
483 CLIVersion string `json:"playwright_cli_version"`
484 ChromiumRevision string `json:"chromium_revision"`
485 BrowserVersion string `json:"browser_version"`
486 Viewport browserSize `json:"viewport"`
487 VideoSize browserSize `json:"video_size"`
488 Screenshots []string `json:"screenshots"`
489 FinalURL string `json:"final_url"`
giob7df27f2026-07-28 10:36:17 +0400490}
491
492var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
493
494func sanitizeName(name string) string {
495 name = strings.Trim(unsafeName.ReplaceAllString(name, "-"), "-.")
496 if name == "" {
497 return "unnamed"
498 }
499 if len(name) > 100 {
500 name = name[:100]
501 }
502 return name
503}
504
505func sanitizeFinalURL(raw string) string {
506 u, err := url.Parse(raw)
507 if err != nil || u.Scheme == "" || u.Host == "" {
508 return ""
509 }
510 u.RawQuery = ""
511 u.Fragment = ""
512 u.User = nil
513 return u.String()
514}
515
516func sortedStrings(values []string) []string {
517 out := append([]string(nil), values...)
518 sort.Strings(out)
519 return out
520}