blob: 19949f512012dccdcd4dd1f2fd32c98c3352981f [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
102type browserSession struct {
103 Page playwright.Page
104 Context playwright.BrowserContext
105 t testReporter
106 dir string
107 video playwright.Video
108 started time.Time
109 screenshots []string
110 checkpoint int
111 finalize sync.Once
112 lifecycleMu sync.Mutex
113 forcedFailure bool
114 registry *sessionRegistry
115 browserVer string
116 blockedMu sync.Mutex
117 blocked []string
118 requestsMu sync.Mutex
119 requests []requestMetadata
120 tracingStarted bool
121 screenshotOp func(string) error
122 stopTraceOp func(string) error
123 closeContextOp func() error
124 saveVideoOp func(string) error
125 finalURLOp func() string
126}
127
128func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
129 session, err := newBrowserSessionOwner(t, root, browser.Version())
130 if err != nil {
131 return nil, err
132 }
133 // Register while holding the construction/finalization lock. A watchdog
134 // either snapshots this ownership and waits here, or rejects construction
135 // before any external Playwright context exists.
136 session.lifecycleMu.Lock()
137 if activeSuiteLifecycle != nil {
138 session.registry = activeSuiteLifecycle.sessions
139 if !session.registry.add(session) {
140 session.lifecycleMu.Unlock()
141 _ = session.finalizeAfterTimeout()
142 return nil, fmt.Errorf("create browser session after E2E watchdog cleanup started")
143 }
144 }
145 defer session.lifecycleMu.Unlock()
146 videoDir := filepath.Join(session.dir, ".video")
147 context, err := browser.NewContext(playwright.BrowserNewContextOptions{
148 Viewport: &playwright.Size{Width: 1280, Height: 720},
149 RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
150 ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
151 })
152 if err != nil {
153 return nil, err
154 }
155 session.Context = context
156 if err := context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true), Sources: playwright.Bool(true)}); err != nil {
157 return nil, err
158 }
159 session.tracingStarted = true
160 page, err := context.NewPage()
161 if err != nil {
162 return nil, err
163 }
164 page.SetDefaultTimeout(15_000)
165 page.SetDefaultNavigationTimeout(30_000)
166 page.OnRequest(func(request playwright.Request) {
167 session.recordRequest(request.Method(), 0, request.URL())
168 })
169 page.OnResponse(func(response playwright.Response) {
170 session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
171 })
172 s := session
173 s.Page = page
174 s.video = page.Video()
175 policy := newRoutePolicy(allowedOrigins)
176 if err := context.Route("**/*", func(route playwright.Route) {
177 if policy(route.Request().URL()) {
178 _ = route.Continue()
179 } else {
180 session.recordBlocked(route.Request().URL())
181 _ = route.Abort("blockedbyclient")
182 }
183 }); err != nil {
184 return nil, err
185 }
186 if err := s.screenshot("00-initial.png"); err != nil {
187 return nil, err
188 }
189 return s, nil
190}
191
192func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
193 dir := filepath.Join(root, sanitizeName(t.Name()))
194 if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
195 return nil, err
196 }
197 if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
198 return nil, err
199 }
200 session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
201 session.installDefaultArtifactOps()
202 // Own metadata and all available partial artifacts before Playwright context
203 // construction. If Playwright cannot create a context/page/trace, cleanup
204 // records the failure without pretending the unavailable artifacts succeeded.
205 t.Cleanup(func() {
206 if err := session.Finalize(); err != nil {
207 t.Errorf("finalize browser artifacts: %v", err)
208 }
209 })
210 return session, nil
211}
212
213func newRoutePolicy(allowedOrigins []string) func(string) bool {
214 allowed := make(map[string]struct{}, len(allowedOrigins))
215 for _, origin := range allowedOrigins {
216 u, err := url.Parse(origin)
217 if err == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.Path == "" {
218 allowed[u.Scheme+"://"+u.Host] = struct{}{}
219 }
220 }
221 return func(raw string) bool {
222 u, err := url.Parse(raw)
223 if err != nil || u.Scheme != "http" || u.Hostname() != "127.0.0.1" || u.Port() == "" || u.User != nil {
224 return false
225 }
226 _, ok := allowed[u.Scheme+"://"+u.Host]
227 return ok
228 }
229}
230
231type requestMetadata struct {
232 Method string `json:"method"`
233 Status int `json:"status,omitempty"`
234 Origin string `json:"origin"`
235 Path string `json:"path"`
236}
237
238func sanitizedRequest(method string, status int, raw string) (requestMetadata, bool) {
239 u, err := url.Parse(raw)
240 if err != nil || u.Scheme == "" || u.Host == "" {
241 return requestMetadata{}, false
242 }
243 return requestMetadata{Method: method, Status: status, Origin: u.Scheme + "://" + u.Host, Path: u.EscapedPath()}, true
244}
245
246func (s *browserSession) recordRequest(method string, status int, raw string) {
247 metadata, ok := sanitizedRequest(method, status, raw)
248 if !ok {
249 return
250 }
251 s.requestsMu.Lock()
252 s.requests = append(s.requests, metadata)
253 s.requestsMu.Unlock()
254}
255
256func (s *browserSession) RequestMetadata() []requestMetadata {
257 s.requestsMu.Lock()
258 defer s.requestsMu.Unlock()
259 return append([]requestMetadata(nil), s.requests...)
260}
261
262func (s *browserSession) recordBlocked(raw string) {
263 u, err := url.Parse(raw)
264 if err != nil {
265 return
266 }
267 s.blockedMu.Lock()
268 s.blocked = append(s.blocked, u.Scheme+"://"+u.Host+u.Path)
269 s.blockedMu.Unlock()
270}
271
272func (s *browserSession) BlockedRequests() []string {
273 s.blockedMu.Lock()
274 defer s.blockedMu.Unlock()
275 return sortedStrings(s.blocked)
276}
277
278func (s *browserSession) Checkpoint(name string) error {
279 s.lifecycleMu.Lock()
280 defer s.lifecycleMu.Unlock()
281 s.checkpoint++
282 return s.screenshot(fmt.Sprintf("%02d-%s.png", s.checkpoint, sanitizeName(name)))
283}
284
285func (s *browserSession) installDefaultArtifactOps() {
286 s.screenshotOp = func(path string) error {
287 if s.Page == nil {
288 return fmt.Errorf("page unavailable")
289 }
290 _, err := s.Page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path), FullPage: playwright.Bool(true)})
291 return err
292 }
293 s.stopTraceOp = func(path string) error {
294 if s.Context == nil || !s.tracingStarted {
295 return fmt.Errorf("tracing unavailable")
296 }
297 return s.Context.Tracing().Stop(path)
298 }
299 s.closeContextOp = func() error {
300 if s.Context == nil {
301 return nil
302 }
303 return s.Context.Close()
304 }
305 s.saveVideoOp = func(path string) error {
306 if s.video == nil {
307 return fmt.Errorf("video unavailable")
308 }
309 return s.video.SaveAs(path)
310 }
311 s.finalURLOp = func() string {
312 if s.Page == nil {
313 return ""
314 }
315 return s.Page.URL()
316 }
317}
318
319func (s *browserSession) screenshot(name string) error {
320 path := filepath.Join(s.dir, "screenshots", name)
321 if err := s.screenshotOp(path); err != nil {
322 return err
323 }
324 s.screenshots = append(s.screenshots, name)
325 return nil
326}
327
328func (s *browserSession) Finalize() error {
329 if s.registry != nil {
330 return s.registry.finalizeNormal(s)
331 }
332 return s.finalizeClaimed(false)
333}
334
335func (s *browserSession) finalizeAfterTimeout() error {
336 return s.finalizeClaimed(true)
337}
338
339func (s *browserSession) finalizeClaimed(forcedFailure bool) error {
340 s.lifecycleMu.Lock()
341 defer s.lifecycleMu.Unlock()
342 if forcedFailure {
343 s.forcedFailure = true
344 }
345 var finalErr error
346 s.finalize.Do(func() {
347 var errs []string
348 if err := s.screenshot("99-final.png"); err != nil {
349 errs = append(errs, "final screenshot: "+err.Error())
350 }
351 if s.t.Failed() || s.forcedFailure {
352 if err := s.screenshot("failure.png"); err != nil {
353 errs = append(errs, "failure screenshot: "+err.Error())
354 }
355 }
356 if err := s.stopTraceOp(filepath.Join(s.dir, "trace.zip")); err != nil {
357 errs = append(errs, "trace: "+err.Error())
358 }
359 finalURL := sanitizeFinalURL(s.finalURLOp())
360 if err := s.closeContextOp(); err != nil {
361 errs = append(errs, "context: "+err.Error())
362 }
363 if err := s.saveVideoOp(filepath.Join(s.dir, "video.webm")); err != nil {
364 errs = append(errs, "video: "+err.Error())
365 } else {
366 _ = os.RemoveAll(filepath.Join(s.dir, ".video"))
367 }
368 outcome := "passed"
369 if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
370 outcome = "failed"
371 }
372 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}
373 data, err := json.MarshalIndent(metadata, "", " ")
374 if err == nil {
375 err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
376 }
377 if err != nil {
378 errs = append(errs, "metadata: "+err.Error())
379 }
380 if len(errs) > 0 {
381 finalErr = fmt.Errorf("%s", strings.Join(errs, "; "))
382 }
383 })
384 return finalErr
385}
386
387func artifactOutcomeExpectation(failedBeforeFinalize, failedAfterFinalize bool) (outcome string, requireFailureScreenshot bool) {
388 if failedBeforeFinalize {
389 return "failed", true
390 }
391 if failedAfterFinalize {
392 return "failed", false
393 }
394 return "passed", false
395}
396
397const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
398
399func isExpectedBlockedBrowserRequest(request string) bool {
400 return request == expectedExternalFontRequest
401}
402
403type sessionMetadata struct {
404 TestName string `json:"test_name"`
405 StartedAt time.Time `json:"started_at"`
406 FinishedAt time.Time `json:"finished_at"`
407 Outcome string `json:"outcome"`
408 BindingVersion string `json:"binding_version"`
409 CLIVersion string `json:"playwright_cli_version"`
410 ChromiumRevision string `json:"chromium_revision"`
411 BrowserVersion string `json:"browser_version"`
412 Screenshots []string `json:"screenshots"`
413 FinalURL string `json:"final_url"`
414}
415
416var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
417
418func sanitizeName(name string) string {
419 name = strings.Trim(unsafeName.ReplaceAllString(name, "-"), "-.")
420 if name == "" {
421 return "unnamed"
422 }
423 if len(name) > 100 {
424 name = name[:100]
425 }
426 return name
427}
428
429func sanitizeFinalURL(raw string) string {
430 u, err := url.Parse(raw)
431 if err != nil || u.Scheme == "" || u.Host == "" {
432 return ""
433 }
434 u.RawQuery = ""
435 u.Fragment = ""
436 u.User = nil
437 return u.String()
438}
439
440func sortedStrings(values []string) []string {
441 out := append([]string(nil), values...)
442 sort.Strings(out)
443 return out
444}