diff --git a/adapters/fetchers/jshttp/jshttp.go b/adapters/fetchers/jshttp/jshttp.go index 2ced968..1fc7edc 100644 --- a/adapters/fetchers/jshttp/jshttp.go +++ b/adapters/fetchers/jshttp/jshttp.go @@ -3,6 +3,7 @@ package jshttp import ( "context" "errors" + "runtime" "time" "github.com/mxschmitt/playwright-go" @@ -289,36 +290,50 @@ func (o *browser) Close() { _ = o.browser.Close() } +// launchArgs returns the Chromium command line flags for the given GOOS. +// --single-process is not passed on Windows, where current Chromium builds +// exit on the first navigation ("Target page, context or browser has been +// closed") when it is set. +func launchArgs(goos string, disableImages bool) []string { + args := []string{ + `--start-maximized`, + `--no-default-browser-check`, + `--disable-dev-shm-usage`, + `--no-sandbox`, + `--disable-setuid-sandbox`, + `--no-zygote`, + `--disable-gpu`, + `--mute-audio`, + `--disable-extensions`, + `--disable-breakpad`, + `--disable-features=TranslateUI,BlinkGenPropertyTrees`, + `--disable-ipc-flooding-protection`, + `--enable-features=NetworkService,NetworkServiceInProcess`, + "--enable-features=NetworkService", + `--disable-default-apps`, + `--disable-notifications`, + `--disable-webgl`, + `--disable-blink-features=AutomationControlled`, + "--ignore-certificate-errors", + "--ignore-certificate-errors-spki-list", + "--disable-web-security", + } + + if goos != "windows" { + args = append(args, `--single-process`) + } + + if disableImages { + args = append(args, `--blink-settings=imagesEnabled=false`) + } + + return args +} + func newBrowser(pw *playwright.Playwright, headless, disableImages bool, proxyPool *ProxyPool, ua string) (*browser, error) { opts := playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(headless), - Args: []string{ - `--start-maximized`, - `--no-default-browser-check`, - `--disable-dev-shm-usage`, - `--no-sandbox`, - `--disable-setuid-sandbox`, - `--no-zygote`, - `--disable-gpu`, - `--mute-audio`, - `--disable-extensions`, - `--single-process`, - `--disable-breakpad`, - `--disable-features=TranslateUI,BlinkGenPropertyTrees`, - `--disable-ipc-flooding-protection`, - `--enable-features=NetworkService,NetworkServiceInProcess`, - "--enable-features=NetworkService", - `--disable-default-apps`, - `--disable-notifications`, - `--disable-webgl`, - `--disable-blink-features=AutomationControlled`, - "--ignore-certificate-errors", - "--ignore-certificate-errors-spki-list", - "--disable-web-security", - }, - } - if disableImages { - opts.Args = append(opts.Args, `--blink-settings=imagesEnabled=false`) + Args: launchArgs(runtime.GOOS, disableImages), } br, err := pw.Chromium.Launch(opts) diff --git a/adapters/fetchers/jshttp/launch_args_internal_test.go b/adapters/fetchers/jshttp/launch_args_internal_test.go new file mode 100644 index 0000000..79cca3f --- /dev/null +++ b/adapters/fetchers/jshttp/launch_args_internal_test.go @@ -0,0 +1,38 @@ +package jshttp + +import ( + "slices" + "testing" +) + +func TestLaunchArgsOmitSingleProcessOnWindows(t *testing.T) { + t.Parallel() + + if slices.Contains(launchArgs("windows", false), "--single-process") { + t.Fatal("--single-process must not be passed on Windows: Chromium exits on the first navigation") + } +} + +func TestLaunchArgsKeepSingleProcessOnOtherPlatforms(t *testing.T) { + t.Parallel() + + for _, goos := range []string{"linux", "darwin"} { + if !slices.Contains(launchArgs(goos, false), "--single-process") { + t.Errorf("expected --single-process on %s", goos) + } + } +} + +func TestLaunchArgsDisableImages(t *testing.T) { + t.Parallel() + + const flag = "--blink-settings=imagesEnabled=false" + + if slices.Contains(launchArgs("linux", false), flag) { + t.Errorf("did not expect %s when images are enabled", flag) + } + + if !slices.Contains(launchArgs("linux", true), flag) { + t.Errorf("expected %s when images are disabled", flag) + } +} diff --git a/adapters/fetchers/jshttp/session_slot.go b/adapters/fetchers/jshttp/session_slot.go index 7164c5a..f80bd78 100644 --- a/adapters/fetchers/jshttp/session_slot.go +++ b/adapters/fetchers/jshttp/session_slot.go @@ -80,7 +80,9 @@ func (s *sessionSlot) acquirePage(ctx context.Context) (page, error) { if err := s.runtime.recreatePage(); err != nil { if err := s.runtime.recreateContext(); err != nil { - return nil, s.runtime.recreateBrowser() + if err := s.runtime.recreateBrowser(); err != nil { + return nil, err + } } } diff --git a/adapters/fetchers/jshttp/session_slot_internal_test.go b/adapters/fetchers/jshttp/session_slot_internal_test.go index 6cb66bc..78f5467 100644 --- a/adapters/fetchers/jshttp/session_slot_internal_test.go +++ b/adapters/fetchers/jshttp/session_slot_internal_test.go @@ -2,6 +2,7 @@ package jshttp import ( "context" + "errors" "testing" ) @@ -217,3 +218,56 @@ func TestSessionSlotRecreatesPageWhenPrimaryPageIsClosed(t *testing.T) { t.Fatalf("expected closed page to be replaced") } } + +// brokenPageRuntime cannot recreate pages or contexts, which forces +// acquirePage to fall back to recreating the whole browser. +type brokenPageRuntime struct { + *fakeRuntime + browserErr error +} + +func (r *brokenPageRuntime) recreatePage() error { return errors.New("page closed") } + +func (r *brokenPageRuntime) recreateContext() error { return errors.New("context closed") } + +func (r *brokenPageRuntime) recreateBrowser() error { + if r.browserErr != nil { + return r.browserErr + } + + return r.fakeRuntime.recreateBrowser() +} + +func TestSessionSlotReturnsPageAfterBrowserRecreation(t *testing.T) { + t.Parallel() + + slot := &sessionSlot{runtime: &brokenPageRuntime{fakeRuntime: &fakeRuntime{nextPageID: 1}}} + + p, err := slot.acquirePage(context.Background()) + if err != nil { + t.Fatalf("acquirePage returned error: %v", err) + } + + if p == nil { + t.Fatalf("expected a page after browser recreation, got nil") + } +} + +func TestSessionSlotReturnsErrorWhenBrowserRecreationFails(t *testing.T) { + t.Parallel() + + errBrowser := errors.New("browser launch failed") + slot := &sessionSlot{runtime: &brokenPageRuntime{ + fakeRuntime: &fakeRuntime{nextPageID: 1}, + browserErr: errBrowser, + }} + + p, err := slot.acquirePage(context.Background()) + if !errors.Is(err, errBrowser) { + t.Fatalf("expected %v, got %v", errBrowser, err) + } + + if p != nil { + t.Fatalf("expected no page when browser recreation fails, got %v", p) + } +}