# TestFly Part 17: Cross-Browser Reliability — BiDi Console Capture, Edge/Safari on Grid & Guaranteed Driver Cleanup

URL: https://hakangul.lovable.app/blog/testfly-part-17-cross-browser-reliability-bidi-grid-safari-edge-2
Author: Hakan Gül — Team Lead Senior Test Automation Engineer
Published: 2026-10-12
Updated: 2026-10-12
Category: test-automation
Tags: TestFly, Selenium, Cross-Browser, Java, SDET

> TestFly 1.1.0 opens a real WebDriver BiDi connection to catch console errors Firefox used to miss, runs Edge and Safari on Selenium Grid under browser.matrix, and guarantees every tracked browser is quit when the JVM shuts down abnormally.

## English

**TL;DR:** TestFly 1.0.6 and 1.1.0 close three cross-browser reliability gaps: `browser.bidi` opens a real WebDriver BiDi connection to a locally launched Chrome, Edge or Firefox so `ConsoleErrorCollector` reads `console.error` calls and uncaught exceptions from BiDi log events instead of a JS shim — catching errors Firefox used to hide — and `ConsoleErrorCollector.isBiDiActive()` tells you which capture path ran. `RemoteDriverProvider` now resolves Edge and Safari on Selenium Grid and honors `browser.matrix`, where it previously only knew chrome/firefox and always read `browser.name`. And JVM shutdown hooks plus `DriverManager.quitAllTrackedDrivers()` guarantee every tracked browser — on every thread, not just the hook thread's own — is quit once when a run is aborted with Ctrl+C or `System.exit`.

---

## How Does BiDi Catch Console Errors Firefox Used to Miss?

TestFly's `captureConsoleErrors` has always read browser console errors, but on Firefox — which exposes no browser logs over classic WebDriver — that meant injecting a JavaScript shim that only sees errors *after* it runs on the current page. An error thrown while the page is still loading, or on a page reached by following a link, was invisible to the shim.

`browser.bidi` (default `false`) fixes this at the protocol level: when a Chrome, Edge or Firefox session is launched locally, TestFly asks for a real WebDriver BiDi connection and subscribes to BiDi log events from the moment the test gets its browser:

```yaml
# testfly.yml
browser:
  name: firefox
  bidi: true
  captureConsoleErrors: true
```

The capability request is one line, added only when BiDi is configured on:

```java
// BiDiCapability.java
static void applyIfConfigured(MutableCapabilities options, TestFlyConfig config) {
    if (config != null && config.getBrowser() != null && config.getBrowser().isBidi()) {
        options.setCapability("webSocketUrl", true);
    }
}
```

`ConsoleErrorCollector` then opens a `LogInspector` over that connection per session, keyed in a `WeakHashMap<WebDriver, BiDiCapture>` so sessions are never pinned in memory after the driver is gone:

```java
private static final Map<WebDriver, BiDiCapture> BIDI =
        Collections.synchronizedMap(new WeakHashMap<>());
```

Because the subscription starts at `beginTest(driver)`, errors from a previous test are forgotten even on a reused, per-suite browser — no JS shim, no page-load race.

Safari and remote drivers (Selenium Grid, BrowserStack, Sauce Labs) have no BiDi connection in TestFly today. In that case one warning is logged per session and the framework falls back to the usual capture path — nothing throws.

To know which path actually ran, without reading logs:

```java
if (ConsoleErrorCollector.isBiDiActive()) {
    // BiDi log events are driving capture for this session
}
```

```java
// ConsoleErrorCollector.java
@TestFlyApi(since = "1.1.0")
public static boolean isBiDiActive() {
    try {
        return BIDI.containsKey(DriverManager.getDriver());
    } catch (RuntimeException e) {
        return false;
    }
}
```

## How Do You Know Which Browser Features You're Trading Away?

A locally started Chrome or Edge gives TestFly the Chrome DevTools Protocol (CDP), which powers network mocking, device emulation, geolocation overrides, and the screencast-based video recorder. Firefox, Safari, Selenium Grid and cloud sessions don't expose CDP — even a remote Chrome doesn't — so those features degrade silently unless something says so.

`BrowserFeatures` (new in 1.1.0) names the four CDP-dependent features and what happens instead:

```java
public enum Feature {
    NETWORK_MOCK("Network interception requires a local Chrome/Edge session... "
            + "routes are ignored and requests reach the real network."),
    CDP_RECORDING("Video recording uses periodic screenshots instead of the "
            + "DevTools screencast, so frames are less frequent."),
    DEVICE_EMULATION("Device emulation only resizes the window and overrides "
            + "navigator.userAgent in the current page..."),
    GEOLOCATION("The location is overridden through JavaScript in the current "
            + "page only and is lost on navigation.");
}
```

`BrowserFeatures.supports(driver, feature)` answers the question directly (`driver instanceof ChromiumDriver` for three of the four, `HasDevTools` for recording), and `warnOnce(driver, feature)` logs the fallback exactly once per browser session per feature, naming the browser:

```java
BrowserFeatures.warnOnce(driver, BrowserFeatures.Feature.GEOLOCATION);
```

`GeoLocation` already calls this before falling back to its JS override on Firefox. The video recording fallback used to be silent; now every degraded feature says so.

## How Do You Run Edge and Safari on Selenium Grid?

Before this release, `RemoteDriverProvider` only built `ChromeOptions` or `FirefoxOptions` and always read `browser.name` directly — so a `browser.matrix` run on a Grid used whatever `browser.name` happened to say, ignoring the matrix browser entirely, and Edge/Safari on Grid wasn't supported at all.

Browser resolution now goes through one shared method that both the local and remote paths call, `BrowserContext` (the matrix override) winning over YAML:

```java
// DriverProviderFactory.java
public static String resolveBrowserName(TestFlyConfig config) {
    String contextBrowser = BrowserContext.get();
    if (contextBrowser != null && !contextBrowser.isBlank()) {
        return contextBrowser;
    }
    String name = config.getBrowser().getName();
    if (name == null || name.isBlank()) {
        throw new IllegalStateException(
                "No browser selected: set browser.name, or run through browser.matrix.");
    }
    return name;
}
```

`RemoteDriverProvider.buildOptions(config, browser)` is a public, independently testable method that now switches over all four Grid-supported browsers:

```java
// RemoteDriverProvider.java
switch (browser.toLowerCase(Locale.ROOT)) {
    case "chrome" -> options = chromium(new ChromeOptions(), browserConfig.isHeadless(), arguments);
    case "edge" -> options = chromium(new EdgeOptions(), browserConfig.isHeadless(), arguments);
    case "firefox" -> { /* FirefoxOptions, -headless */ }
    case "safari" -> {
        if (browserConfig.isHeadless()) {
            System.err.println("[TestFly] Safari does not support headless mode via Selenium; ignoring headless=true.");
        }
        options = new SafariOptions();
    }
    default -> throw new IllegalStateException(
            "Unsupported browser for remote execution: " + browser + ". Supported: chrome, edge, firefox, safari.");
}
options.setCapability("unhandledPromptBehavior", "ignore");
```

A matrix config that runs the same suite across Chrome, Edge and Safari on a Grid now works unmodified:

```yaml
# testfly.yml
execution:
  mode: remote
  gridUrl: http://grid.local:4444/wd/hub
browser:
  matrix: [chrome, edge, safari]
```

A companion fix (`do not reuse a per-suite driver for another matrix browser`) stops `DriverManager` from handing a Chrome `per-suite` driver to a thread that's supposed to be testing Edge — `DRIVER_BROWSER_CONTEXT` is now tracked per thread and checked before reuse. Another (`quit the browser when post-launch session setup fails`) makes every built-in provider quit the session before rethrowing when timeout configuration throws right after launch, so a setup failure no longer leaks a browser process.

## How Does TestFly Guarantee Browsers Are Never Left Running?

`DriverManager` has registered a JVM shutdown hook since early on, but until this release it only quit per-suite drivers and whatever happened to be bound to the shutdown hook's own thread — which is never the thread that actually ran a per-test driver. Abort a parallel `per-test` run with Ctrl+C or `System.exit`, and worker-thread browsers kept running after the JVM exited.

The hook now calls `quitAllTrackedDrivers()`, which unions every driver that still holds a session permit with every registered per-suite driver, across all threads, and quits each exactly once:

```java
static void quitAllTrackedDrivers() {
    java.util.Set<WebDriver> drivers =
            java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
    drivers.addAll(PERMIT_HOLDERS);
    drivers.addAll(SUITE_DRIVERS);
    for (WebDriver driver : drivers) {
        try {
            driver.quit();
        } catch (Throwable ignored) {
        } finally {
            SUITE_DRIVERS.remove(driver);
            releasePermitOf(driver);
        }
    }
}
```

`PERMIT_HOLDERS` is the set of drivers currently holding a session-slot permit — populated and cleared by exactly one code path per driver, which is also what makes `execution.maxActiveSessions` leak-proof. Normal suite teardown still calls `DriverManager.quitAllSuiteDrivers()` then `quitDriver()` from `SuiteExecutionListener`, `TestFlyExtension` (JUnit 5) and `CucumberHooks`; the shutdown hook is the safety net for abnormal exits, firing identical cleanup logic no matter which thread owned the browser.

This traces back to 1.0.6's original "Guaranteed Driver Cleanup" line (shutdown hooks plus `@AfterAll` listener execution): the hook existed from 1.0.6, this release makes it actually reach every thread's browser instead of only the suite-level ones.

## FAQ

### Does `browser.bidi` work with Selenium Grid or BrowserStack/Sauce Labs?

No. BiDi only applies to a locally launched Chrome, Edge or Firefox. On Safari or any remote driver, TestFly logs one warning and falls back to the previous capture path (CDP logs on Chrome, the JS shim elsewhere); nothing fails.

### How do I check which console-error capture path is active for the current test?

Call `ConsoleErrorCollector.isBiDiActive()`. It returns `true` only when the current thread's driver has an open BiDi log subscription; it never throws, returning `false` if there's no driver or the check fails.

### Can I run the same suite on Chrome, Edge and Safari against one Selenium Grid?

Yes, with `execution.mode: remote`, `execution.gridUrl` and `browser.matrix: [chrome, edge, safari]`. `RemoteDriverProvider` resolves the matrix browser ahead of `browser.name` and builds Grid-appropriate options for all four supported browsers, including Safari's headless restriction.

### If I kill a parallel run with Ctrl+C, are all browser processes definitely gone?

Yes. The JVM shutdown hook now collects every driver that holds a session permit plus every per-suite driver, on every thread, and quits each one exactly once, regardless of which thread the hook itself runs on.

## Türkçe

**Özet:** TestFly 1.0.6 ve 1.1.0, tarayıcılar arası güvenilirlikte üç boşluğu kapatıyor: `browser.bidi`, yerel olarak başlatılan bir Chrome, Edge veya Firefox'a gerçek bir WebDriver BiDi bağlantısı açıyor; böylece `ConsoleErrorCollector`, `console.error` çağrılarını ve yakalanmamış istisnaları bir JS shim yerine BiDi log olaylarından okuyor — Firefox'un daha önce gizlediği hataları yakalıyor — ve `ConsoleErrorCollector.isBiDiActive()` hangi yakalama yolunun çalıştığını söylüyor. `RemoteDriverProvider` artık Selenium Grid üzerinde Edge ve Safari'yi çözümlüyor ve `browser.matrix`'e saygı gösteriyor; daha önce yalnızca chrome/firefox biliyordu ve her zaman `browser.name`'i okuyordu. Ve JVM shutdown hook'ları ile `DriverManager.quitAllTrackedDrivers()`, Ctrl+C veya `System.exit` ile bir çalıştırma durdurulduğunda izlenen her tarayıcının — yalnızca hook thread'inin kendisine değil, her thread'e ait olanların — tam olarak bir kez kapatılmasını garanti ediyor.

---

## BiDi, Firefox'un Kaçırdığı Konsol Hatalarını Nasıl Yakalıyor?

TestFly'ın `captureConsoleErrors` özelliği her zaman tarayıcı konsol hatalarını okudu, ancak klasik WebDriver üzerinden hiçbir tarayıcı günlüğü sunmayan Firefox'ta bu, yalnızca mevcut sayfada çalıştıktan *sonra* hataları gören bir JavaScript shim'i enjekte etmek anlamına geliyordu. Sayfa hâlâ yüklenirken fırlatılan bir hata, ya da bir bağlantı takip edilerek ulaşılan bir sayfadaki hata, shim için görünmezdi.

`browser.bidi` (varsayılan `false`) bunu protokol seviyesinde çözüyor: yerel olarak bir Chrome, Edge veya Firefox oturumu başlatıldığında, TestFly gerçek bir WebDriver BiDi bağlantısı istiyor ve testin tarayıcısını aldığı andan itibaren BiDi log olaylarına abone oluyor:

```yaml
# testfly.yml
browser:
  name: firefox
  bidi: true
  captureConsoleErrors: true
```

Capability isteği tek satır, yalnızca BiDi açık yapılandırıldığında ekleniyor:

```java
// BiDiCapability.java
static void applyIfConfigured(MutableCapabilities options, TestFlyConfig config) {
    if (config != null && config.getBrowser() != null && config.getBrowser().isBidi()) {
        options.setCapability("webSocketUrl", true);
    }
}
```

`ConsoleErrorCollector`, bu bağlantı üzerinden oturum başına bir `LogInspector` açıyor; bunlar `WeakHashMap<WebDriver, BiDiCapture>` içinde tutuluyor, böylece driver kaybolduktan sonra oturumlar bellekte sabitlenmiyor:

```java
private static final Map<WebDriver, BiDiCapture> BIDI =
        Collections.synchronizedMap(new WeakHashMap<>());
```

Abonelik `beginTest(driver)` ile başladığından, bir önceki testin hataları yeniden kullanılan, per-suite bir tarayıcıda bile unutuluyor — JS shim yok, sayfa yükleme yarışı yok.

Safari ve uzak driver'lar (Selenium Grid, BrowserStack, Sauce Labs) TestFly'da bugün BiDi bağlantısına sahip değil. Bu durumda oturum başına bir uyarı loglanıyor ve çerçeve önceki yakalama yoluna geri dönüyor — hiçbir şey hata fırlatmıyor.

Logları okumadan hangi yolun gerçekten çalıştığını öğrenmek için:

```java
if (ConsoleErrorCollector.isBiDiActive()) {
    // Bu oturum için yakalamayı BiDi log olayları yürütüyor
}
```

```java
// ConsoleErrorCollector.java
@TestFlyApi(since = "1.1.0")
public static boolean isBiDiActive() {
    try {
        return BIDI.containsKey(DriverManager.getDriver());
    } catch (RuntimeException e) {
        return false;
    }
}
```

## Hangi Tarayıcı Özelliklerinden Ödün Verdiğinizi Nasıl Bilirsiniz?

Yerel olarak başlatılan bir Chrome veya Edge, TestFly'a Chrome DevTools Protocol'ünü (CDP) verir; bu da ağ mocking'ini, cihaz emülasyonunu, konum (geolocation) override'larını ve screencast tabanlı video kaydediciyi güçlendirir. Firefox, Safari, Selenium Grid ve bulut oturumları CDP'yi açığa çıkarmaz — hatta uzak bir Chrome bile — bu yüzden bir şey söylemedikçe bu özellikler sessizce bozulur.

1.1.0'da yeni olan `BrowserFeatures`, CDP'ye bağımlı dört özelliği ve bunun yerine ne olacağını adlandırıyor:

```java
public enum Feature {
    NETWORK_MOCK("Network interception requires a local Chrome/Edge session... "
            + "routes are ignored and requests reach the real network."),
    CDP_RECORDING("Video recording uses periodic screenshots instead of the "
            + "DevTools screencast, so frames are less frequent."),
    DEVICE_EMULATION("Device emulation only resizes the window and overrides "
            + "navigator.userAgent in the current page..."),
    GEOLOCATION("The location is overridden through JavaScript in the current "
            + "page only and is lost on navigation.");
}
```

`BrowserFeatures.supports(driver, feature)` soruyu doğrudan cevaplıyor (dördün üçü için `driver instanceof ChromiumDriver`, kayıt için `HasDevTools`), ve `warnOnce(driver, feature)` fallback'i her tarayıcı oturumu ve özellik başına tam olarak bir kez loglayıp tarayıcıyı adlandırıyor:

```java
BrowserFeatures.warnOnce(driver, BrowserFeatures.Feature.GEOLOCATION);
```

`GeoLocation` zaten Firefox'ta JS override'ına geri dönmeden önce bunu çağırıyor. Video kaydı fallback'i daha önce sessizdi; artık her bozulan özellik bunu söylüyor.

## Selenium Grid Üzerinde Edge ve Safari Nasıl Çalıştırılır?

Bu sürümden önce `RemoteDriverProvider` yalnızca `ChromeOptions` veya `FirefoxOptions` oluşturuyordu ve her zaman doğrudan `browser.name`'i okuyordu — böylece bir Grid üzerindeki `browser.matrix` çalıştırması, matrix tarayıcısını tamamen göz ardı ederek `browser.name`'in söylediği her neyse onu kullanıyordu; Grid üzerinde Edge/Safari ise hiç desteklenmiyordu.

Tarayıcı çözümlemesi artık hem yerel hem uzak yolun çağırdığı tek bir paylaşılan metottan geçiyor; `BrowserContext` (matrix override'ı) YAML'a karşı kazanıyor:

```java
// DriverProviderFactory.java
public static String resolveBrowserName(TestFlyConfig config) {
    String contextBrowser = BrowserContext.get();
    if (contextBrowser != null && !contextBrowser.isBlank()) {
        return contextBrowser;
    }
    String name = config.getBrowser().getName();
    if (name == null || name.isBlank()) {
        throw new IllegalStateException(
                "No browser selected: set browser.name, or run through browser.matrix.");
    }
    return name;
}
```

`RemoteDriverProvider.buildOptions(config, browser)` artık dört Grid'de desteklenen tarayıcının tümü üzerinde switch yapan, bağımsız olarak test edilebilir, public bir metot:

```java
// RemoteDriverProvider.java
switch (browser.toLowerCase(Locale.ROOT)) {
    case "chrome" -> options = chromium(new ChromeOptions(), browserConfig.isHeadless(), arguments);
    case "edge" -> options = chromium(new EdgeOptions(), browserConfig.isHeadless(), arguments);
    case "firefox" -> { /* FirefoxOptions, -headless */ }
    case "safari" -> {
        if (browserConfig.isHeadless()) {
            System.err.println("[TestFly] Safari does not support headless mode via Selenium; ignoring headless=true.");
        }
        options = new SafariOptions();
    }
    default -> throw new IllegalStateException(
            "Unsupported browser for remote execution: " + browser + ". Supported: chrome, edge, firefox, safari.");
}
options.setCapability("unhandledPromptBehavior", "ignore");
```

Aynı suite'i bir Grid üzerinde Chrome, Edge ve Safari'de çalıştıran bir matrix config artık değişiklik yapılmadan çalışıyor:

```yaml
# testfly.yml
execution:
  mode: remote
  gridUrl: http://grid.local:4444/wd/hub
browser:
  matrix: [chrome, edge, safari]
```

Yan bir düzeltme (`do not reuse a per-suite driver for another matrix browser`), `DriverManager`'ın bir Chrome `per-suite` driver'ını Edge test etmesi gereken bir thread'e vermesini durduruyor — `DRIVER_BROWSER_CONTEXT` artık thread başına izleniyor ve yeniden kullanımdan önce kontrol ediliyor. Bir diğeri (`quit the browser when post-launch session setup fails`), başlatma sonrası timeout yapılandırması hata fırlattığında her yerleşik provider'ın yeniden fırlatmadan önce oturumu kapatmasını sağlıyor, böylece bir kurulum hatası artık bir tarayıcı sürecini sızdırmıyor.

## TestFly Tarayıcıların Asla Çalışır Durumda Kalmamasını Nasıl Garanti Ediyor?

`DriverManager`, başından beri bir JVM shutdown hook'u kayıtlıydı, ancak bu sürüme kadar yalnızca per-suite driver'ları ve shutdown hook'unun kendi thread'ine bağlı olan her neyse onu kapatıyordu — ki bu, per-test bir driver'ı gerçekten çalıştıran thread asla değildi. Paralel bir `per-test` çalıştırmasını Ctrl+C veya `System.exit` ile durdurduğunuzda, worker-thread tarayıcıları JVM çıktıktan sonra çalışmaya devam ediyordu.

Hook artık `quitAllTrackedDrivers()`'ı çağırıyor; bu, hâlâ bir oturum izni (permit) tutan her driver'ı, kayıtlı her per-suite driver ile, tüm thread'ler genelinde birleştiriyor ve her birini tam olarak bir kez kapatıyor:

```java
static void quitAllTrackedDrivers() {
    java.util.Set<WebDriver> drivers =
            java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
    drivers.addAll(PERMIT_HOLDERS);
    drivers.addAll(SUITE_DRIVERS);
    for (WebDriver driver : drivers) {
        try {
            driver.quit();
        } catch (Throwable ignored) {
        } finally {
            SUITE_DRIVERS.remove(driver);
            releasePermitOf(driver);
        }
    }
}
```

`PERMIT_HOLDERS`, şu anda bir oturum-slotu izni tutan driver'ların kümesidir — driver başına tam olarak tek bir kod yolu tarafından doldurulur ve temizlenir, ki bu da `execution.maxActiveSessions`'ı sızdırmaz yapan şeydir. Normal suite teardown'ı hâlâ `SuiteExecutionListener`, `TestFlyExtension` (JUnit 5) ve `CucumberHooks`'tan `DriverManager.quitAllSuiteDrivers()` ardından `quitDriver()` çağırıyor; shutdown hook, hangi thread'in tarayıcıya sahip olduğundan bağımsız olarak aynı temizleme mantığını tetikleyen, anormal çıkışlar için güvenlik ağıdır.

Bu, 1.0.6'nın orijinal "Guaranteed Driver Cleanup" satırına (shutdown hook'ları artı `@AfterAll` listener yürütmesi) dayanıyor: hook 1.0.6'dan beri vardı, bu sürüm onu yalnızca suite seviyesindekilere değil, gerçekten her thread'in tarayıcısına ulaştırıyor.

## SSS

### `browser.bidi`, Selenium Grid veya BrowserStack/Sauce Labs ile çalışıyor mu?

Hayır. BiDi yalnızca yerel olarak başlatılan bir Chrome, Edge veya Firefox için geçerli. Safari'de veya herhangi bir uzak driver'da, TestFly bir uyarı loglar ve önceki yakalama yoluna (Chrome'da CDP logları, diğer yerlerde JS shim) geri döner; hiçbir şey başarısız olmaz.

### Mevcut test için hangi konsol-hatası yakalama yolunun aktif olduğunu nasıl kontrol ederim?

`ConsoleErrorCollector.isBiDiActive()`'i çağırın. Yalnızca mevcut thread'in driver'ının açık bir BiDi log aboneliği olduğunda `true` döner; hiçbir zaman hata fırlatmaz, driver yoksa veya kontrol başarısız olursa `false` döner.

### Aynı suite'i tek bir Selenium Grid'e karşı Chrome, Edge ve Safari'de çalıştırabilir miyim?

Evet, `execution.mode: remote`, `execution.gridUrl` ve `browser.matrix: [chrome, edge, safari]` ile. `RemoteDriverProvider`, matrix tarayıcısını `browser.name`'den önce çözümler ve Safari'nin headless kısıtlaması dahil, desteklenen dört tarayıcının tümü için Grid'e uygun seçenekler oluşturur.

### Paralel bir çalıştırmayı Ctrl+C ile öldürürsem, tüm tarayıcı süreçlerinin kesinlikle gittiğinden emin olabilir miyim?

Evet. JVM shutdown hook'u artık bir oturum izni tutan her driver'ı ve her per-suite driver'ı, hangi thread'de olursa olsun toplar ve her birini, hook'un kendisinin hangi thread'de çalıştığından bağımsız olarak, tam olarak bir kez kapatır.
