thirtyfour 0.37.0

Thirtyfour is a Selenium / WebDriver library for Rust, for automated website UI testing. Tested on Chrome and Firefox, but any webdriver-capable browser should work.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! End-to-end tests for the WebDriver manager.
//!
//! These tests download a real driver binary and spawn a real subprocess, so
//! they're gated behind the `manager-tests` feature and run in their own CI
//! job that does *not* pre-start chromedriver / geckodriver. Run locally with:
//!
//! ```text
//! cargo test -p thirtyfour --features manager-tests --test managed -- --test-threads=1
//! ```

#![cfg(feature = "manager-tests")]

use std::future::Future;
use std::time::Duration;

use thirtyfour::manager::WebDriverManager;
use thirtyfour::prelude::*;
use thirtyfour::{ChromeCapabilities, EdgeCapabilities, FirefoxCapabilities};

/// Per-test wall-clock budget. Real driver downloads on slow CI runners are
/// usually well under a minute; this is a defensive ceiling so a hung test
/// fails-fast rather than burning the runner's full 6h limit.
const TEST_TIMEOUT: Duration = Duration::from_secs(180);

/// Run `f` with a hard timeout. Returns an error rather than hanging.
async fn with_timeout<F, T>(f: F) -> WebDriverResult<T>
where
    F: Future<Output = WebDriverResult<T>>,
{
    tokio::time::timeout(TEST_TIMEOUT, f).await.unwrap_or_else(|_| {
        Err(WebDriverError::FatalError(format!("test exceeded {}s budget", TEST_TIMEOUT.as_secs())))
    })
}

/// Chromium-based browsers (Chrome, Edge) running headless on Windows runners
/// have known reliability issues with `goto`, even for `about:blank` — see
/// `managed_edge_smoke`'s comment on the same bug for Linux Edge. The
/// manager's job is done by the time we'd call `goto`; navigation is the
/// browser engine's responsibility, not ours, so we skip it on the affected
/// platform.
fn skip_navigation() -> bool {
    cfg!(target_os = "windows")
}

fn chrome_caps() -> ChromeCapabilities {
    let mut caps = DesiredCapabilities::chrome();
    caps.set_headless().unwrap();
    caps.set_no_sandbox().unwrap();
    caps.set_disable_gpu().unwrap();
    caps.set_disable_dev_shm_usage().unwrap();
    caps.add_arg("--no-sandbox").unwrap();
    caps
}

fn firefox_caps() -> FirefoxCapabilities {
    let mut caps = DesiredCapabilities::firefox();
    caps.set_headless().unwrap();
    caps
}

fn edge_caps() -> EdgeCapabilities {
    let mut caps = DesiredCapabilities::edge();
    // EdgeCapabilities is Chromium-based and accepts the same args via
    // `ms:edgeOptions.args`. Headless is `--headless=new` for modern Edge.
    caps.add_arg("--headless=new").unwrap();
    caps.add_arg("--no-sandbox").unwrap();
    caps.add_arg("--disable-gpu").unwrap();
    caps.add_arg("--disable-dev-shm-usage").unwrap();
    caps
}

#[tokio::test(flavor = "multi_thread")]
async fn managed_chrome_smoke() -> WebDriverResult<()> {
    with_timeout(async {
        let driver = WebDriver::managed(chrome_caps()).await?;
        if !skip_navigation() {
            driver.goto("about:blank").await?;
        }
        driver.quit().await?;
        Ok(())
    })
    .await
}

#[tokio::test(flavor = "multi_thread")]
async fn managed_firefox_smoke() -> WebDriverResult<()> {
    with_timeout(async {
        let driver = WebDriver::managed(firefox_caps()).await?;
        driver.goto("about:blank").await?;
        driver.quit().await?;
        Ok(())
    })
    .await
}

/// Edge headless on Ubuntu has a known issue where the renderer times out
/// even on `about:blank`. That's a quirk of Edge-for-Linux's headless mode,
/// not the manager — by the time we reach `goto` the manager has already done
/// its full job (download, spawn, readiness poll, session creation). So we
/// stop short of `goto` here: creating + quitting a session is the
/// manager-level smoke. The full session round-trip is exercised by the
/// existing chrome/firefox smokes.
#[tokio::test(flavor = "multi_thread")]
async fn managed_edge_smoke() -> WebDriverResult<()> {
    with_timeout(async {
        let driver = WebDriver::managed(edge_caps()).await?;
        driver.quit().await?;
        Ok(())
    })
    .await
}

/// Safari is macOS-only and uses the system `safaridriver`. The CI runner must
/// have run `safaridriver --enable` (and have Allow Remote Automation toggled).
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread")]
async fn managed_safari_smoke() -> WebDriverResult<()> {
    with_timeout(async {
        let mut caps = DesiredCapabilities::safari();
        let _ = &mut caps; // safari has no headless / no sandbox toggles
        let driver = WebDriver::managed(caps).await?;
        driver.goto("about:blank").await?;
        driver.quit().await?;
        Ok(())
    })
    .await
}

/// Exercises a few non-default options on Chrome:
///   - a custom cache dir
///   - a custom ready timeout
///   - two `launch()` calls share a single chromedriver process (refcount + dedup)
///
/// We deliberately do NOT exercise `DriverVersion::Latest` here. ChromeDriver's
/// major version must match Chrome's, and CI runners typically have a Chrome a
/// few days behind the absolute latest chromedriver release — so a
/// `Latest`-driven E2E test would be perpetually flaky. The `Latest` resolver
/// itself is covered by the wiremock-based unit tests.
#[tokio::test(flavor = "multi_thread")]
async fn managed_chrome_options_and_dedup() -> WebDriverResult<()> {
    with_timeout(async {
        let cache = tempfile::tempdir().expect("tempdir");
        let mgr = WebDriverManager::builder()
            .match_local() // default; explicit for clarity
            .cache_dir(cache.path().to_path_buf())
            .ready_timeout(Duration::from_secs(60))
            .build();

        let d1 = mgr.launch(chrome_caps()).await?;
        let d2 = mgr.launch(chrome_caps()).await?;

        // Two `launch()` calls keyed on the same (BrowserKind, version, host)
        // should reuse a single chromedriver subprocess — both `WebDriver`
        // instances should point at the same server URL.
        assert_eq!(
            d1.server_url(),
            d2.server_url(),
            "two managed sessions for the same browser must share a chromedriver process"
        );

        if !skip_navigation() {
            d1.goto("about:blank").await?;
            d2.goto("about:blank").await?;
        }

        d1.quit().await?;
        d2.quit().await?;
        Ok(())
    })
    .await
}

/// True if a TCP connect to the driver's `host:port` succeeds, i.e. the
/// chromedriver subprocess is still listening.
async fn driver_is_listening(server_url: &url::Url) -> bool {
    let host = server_url.host_str().unwrap_or("127.0.0.1");
    let port = server_url.port_or_known_default().unwrap_or(0);
    tokio::time::timeout(Duration::from_secs(1), tokio::net::TcpStream::connect((host, port)))
        .await
        .map(|r| r.is_ok())
        .unwrap_or(false)
}

/// Issue #281: dropping a `WebDriver` without calling `quit()` should still
/// close the session and (for managed drivers) kill the chromedriver
/// subprocess. `SessionHandle::Drop` spawns a dedicated OS thread that
/// runs `quit()` via `support::block_on` (process-wide `GLOBAL_RT`),
/// joining synchronously so Drop blocks until the DELETE /session call
/// completes. `ManagedDriverProcess::Drop` then SIGKILLs the subprocess.
///
/// Tested under both runtime flavors because the cleanup path must not
/// rely on the caller's runtime — the OS thread + GLOBAL_RT trick has to
/// work whether the caller is on `multi_thread` or `current_thread`.
#[tokio::test(flavor = "multi_thread")]
async fn dropping_managed_driver_kills_subprocess_multi_thread() -> WebDriverResult<()> {
    drop_kills_subprocess_inner().await
}

#[tokio::test(flavor = "current_thread")]
async fn dropping_managed_driver_kills_subprocess_current_thread() -> WebDriverResult<()> {
    drop_kills_subprocess_inner().await
}

async fn drop_kills_subprocess_inner() -> WebDriverResult<()> {
    with_timeout(async {
        let server_url = {
            let driver = WebDriver::managed(chrome_caps()).await?;
            let url = driver.server_url().clone();
            assert!(driver_is_listening(&url).await, "driver should be listening while alive");
            if !skip_navigation() {
                driver.goto("about:blank").await?;
            }
            url
            // No explicit `driver.quit().await` — drop fires here, which must
            // run quit() and then SIGKILL the subprocess.
        };

        // Give SIGKILL a moment to actually reap the process.
        tokio::time::sleep(Duration::from_millis(500)).await;

        assert!(
            !driver_is_listening(&server_url).await,
            "chromedriver at {server_url} should be gone after WebDriver was dropped without quit"
        );
        Ok(())
    })
    .await
}

/// Issue #281 specifically: panic unwind drops the in-scope `WebDriver`,
/// which must run the same `quit + kill subprocess` path as a clean drop.
/// Users should never need a custom `Drop` guard with `block_on` — that's
/// what `SessionHandle::Drop` already does internally.
#[tokio::test(flavor = "multi_thread")]
async fn panic_unwind_kills_managed_driver_subprocess() -> WebDriverResult<()> {
    use std::sync::{Arc, Mutex};

    with_timeout(async {
        let captured: Arc<Mutex<Option<url::Url>>> = Arc::new(Mutex::new(None));
        let captured_clone = Arc::clone(&captured);

        // Run the panicking code on a dedicated thread so the unwind happens
        // there and we can `join()` to observe it. The `WebDriver` is held
        // when `panic!` fires; the unwind must drop it cleanly.
        let join = std::thread::spawn(move || {
            thirtyfour::support::block_on(async move {
                let driver = WebDriver::managed(chrome_caps()).await.expect("managed launch");
                *captured_clone.lock().unwrap() = Some(driver.server_url().clone());
                if !skip_navigation() {
                    driver.goto("about:blank").await.expect("goto");
                }
                panic!("simulated user panic with WebDriver still in scope");
            });
        })
        .join();

        assert!(join.is_err(), "thread should have panicked");

        let url = captured
            .lock()
            .unwrap()
            .take()
            .expect("server URL should have been captured before panic");

        tokio::time::sleep(Duration::from_millis(500)).await;

        assert!(
            !driver_is_listening(&url).await,
            "chromedriver at {url} should be gone after panic unwind dropped the WebDriver"
        );
        Ok(())
    })
    .await
}

/// Several `WebDriver`s dropped at the same instant must all clean up.
///
/// Each `SessionHandle::Drop` spawns its own OS thread that builds a
/// fresh `current_thread` runtime to run the DELETE /session call. The
/// per-Drop runtime is intentional — a previous design routed all
/// cleanup through a shared `GLOBAL_RT` (a single-threaded runtime
/// driven by one parked thread), which serialised every concurrent
/// drop through one I/O driver and caused chromedriver-readiness
/// timeouts under cargo's parallel-test-binary harness. This test
/// fires N drops concurrently to guard against any regression that
/// re-introduces a shared-resource bottleneck (or a deadlock when
/// more than one drop is in flight).
///
/// Each `WebDriver::managed` call builds a fresh `WebDriverManager`,
/// so the N sessions back onto N independent chromedriver subprocesses
/// — we can verify each one is gone independently after its drop.
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_drops_kill_all_subprocesses() -> WebDriverResult<()> {
    const N: usize = 3;

    with_timeout(async {
        // Spin up N drivers in parallel. Wrap each builder's `IntoFuture` in
        // an explicit async block so `try_join_all` sees a concrete `Future`.
        let drivers: Vec<WebDriver> = futures_util::future::try_join_all(
            (0..N).map(|_| async { WebDriver::managed(chrome_caps()).await }),
        )
        .await?;

        let urls: Vec<url::Url> = drivers.iter().map(|d| d.server_url().clone()).collect();
        for url in &urls {
            assert!(driver_is_listening(url).await, "driver at {url} should be listening");
        }

        // Drop each driver on its own OS thread so the Drops run concurrently
        // (vs. `drop(Vec)` which would run them sequentially). All cleanup
        // threads run their fresh per-Drop runtimes simultaneously.
        let drop_threads: Vec<_> =
            drivers.into_iter().map(|d| std::thread::spawn(move || drop(d))).collect();
        for t in drop_threads {
            t.join().expect("drop thread panicked");
        }

        // Give SIGKILL a moment to actually reap the processes.
        tokio::time::sleep(Duration::from_millis(500)).await;

        for url in &urls {
            assert!(
                !driver_is_listening(url).await,
                "chromedriver at {url} should be gone after concurrent drop"
            );
        }
        Ok(())
    })
    .await
}

/// Many sessions created concurrently against a shared
/// `WebDriverManager` must all start and tear down cleanly.
///
/// Mirrors the integration-test-harness pattern (one
/// `Arc<WebDriverManager>` shared across many tests in a binary) and
/// pushes more parallelism than `--test-threads=1` allows: every test
/// binary in the suite shares a per-binary manager, but cargo runs
/// multiple test binaries in parallel and each manager may have
/// several sessions in flight when those binaries overlap.
///
/// What this catches:
/// * Regressions in `SessionHandle::Drop` that re-share a single
///   tokio runtime or single I/O driver across cleanups (the
///   pre-fix shape funnelled all DELETE /session HTTP calls through
///   one `current_thread` runtime, slowing concurrent teardown
///   enough to time out chromedriver readiness in subsequent
///   launches).
/// * Deadlocks between session-launch HTTP traffic and cleanup HTTP
///   traffic when both share the same runtime/driver.
///
/// Sessions are created in pairs: launch two, drop both, repeat.
/// This pattern interleaves launch and drop traffic — the symptom
/// case the user sees in CI.
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_sessions_against_shared_manager() -> WebDriverResult<()> {
    use std::sync::Arc;
    use thirtyfour::manager::WebDriverManager;

    const ROUNDS: usize = 3;
    const PARALLEL: usize = 3;

    with_timeout(async {
        let mgr: Arc<WebDriverManager> = WebDriverManager::builder().build();

        for _round in 0..ROUNDS {
            // Launch PARALLEL sessions concurrently against the same manager.
            // Without dedup-on-version they each get the same chromedriver
            // subprocess (the manager's `spawn_or_reuse` path), so this
            // exercises concurrent session-create traffic against one driver
            // while later iterations exercise concurrent drop+launch.
            let sessions: Vec<WebDriver> =
                futures_util::future::try_join_all((0..PARALLEL).map(|_| {
                    let mgr = Arc::clone(&mgr);
                    async move { mgr.launch(chrome_caps()).await }
                }))
                .await?;

            assert_eq!(sessions.len(), PARALLEL, "all sessions should have launched");

            // The shared manager dedups on (browser, version, host), so all
            // sessions in a round MUST point at the same chromedriver URL.
            let first_url = sessions[0].server_url().clone();
            for s in &sessions {
                assert_eq!(
                    s.server_url(),
                    &first_url,
                    "shared manager must dedup all sessions onto one chromedriver"
                );
            }

            // Cross-thread concurrent drop. Drop on per-session OS threads so
            // multiple cleanup futures run truly in parallel — the case the
            // shared-runtime regression broke.
            let drop_threads: Vec<_> =
                sessions.into_iter().map(|d| std::thread::spawn(move || drop(d))).collect();
            for t in drop_threads {
                t.join().expect("drop thread panicked");
            }
        }

        Ok(())
    })
    .await
}

/// Interleaved create/drop on a shared manager: while one session is
/// being torn down (its DELETE /session HTTP call is in flight),
/// another session-create HTTP call is also in flight against the
/// SAME chromedriver. Mirrors the worst-case CI shape where the
/// previous test's drop overlaps with the next test's launch.
///
/// Specifically targets the regression where DELETE /session and
/// New Session HTTP traffic both ran on the same `current_thread`
/// runtime, with the cleanup blocking the launch's I/O progress
/// (or vice versa).
#[tokio::test(flavor = "multi_thread")]
async fn interleaved_create_and_drop() -> WebDriverResult<()> {
    use std::sync::Arc;
    use thirtyfour::manager::WebDriverManager;

    const ROUNDS: usize = 5;

    with_timeout(async {
        let mgr: Arc<WebDriverManager> = WebDriverManager::builder().build();

        let mut prev: Option<WebDriver> = None;
        for _round in 0..ROUNDS {
            // Start a new session. This sends New Session against the shared
            // chromedriver while the previous round's drop (if any) may still
            // be in flight on its own OS thread.
            let next = mgr.launch(chrome_caps()).await?;

            // Now drop the previous session on a background thread (so its
            // DELETE /session fires concurrently with this round's other
            // work). For the very first round there's nothing to drop.
            if let Some(old) = prev.take() {
                let t = std::thread::spawn(move || drop(old));
                // Join it before the next iteration so we keep test failures
                // localised to the round that broke. Concurrency is real
                // because the launch already happened above, racing the drop.
                t.join().expect("drop thread panicked");
            }
            prev = Some(next);
        }

        // Final cleanup.
        if let Some(last) = prev.take() {
            last.quit().await?;
        }

        Ok(())
    })
    .await
}