Skip to main content

ferridriver_test/
worker.rs

1//! Worker: owns a browser instance, executes hooks, creates fresh context+page per test.
2//!
3//! Hook execution model (matching Playwright):
4//! - beforeAll: once per suite PER WORKER, tracked in `active_suites` map
5//! - afterAll: when worker finishes, for every suite that had beforeAll run
6//! - beforeEach: before every test, gets the test's fixture pool
7//! - afterEach: after every test (even on failure), gets the test's fixture pool
8//!
9//! Serial batches: all tests run in order on this worker. On first failure, remaining
10//! tests are skipped but afterAll still runs.
11
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use rustc_hash::FxHashMap;
16use tokio::sync::{Mutex, mpsc};
17
18use crate::config::{ContextConfig, TestConfig, ViewportConfig};
19use crate::dispatcher::{SerialBatch, TestAssignment, WorkItem};
20use crate::fixture::{FixtureDef, FixturePool, FixtureScope};
21use crate::model::{
22  Attachment, AttachmentBody, ExpectedStatus, Hooks, StepCategory, TestAnnotation, TestFailure, TestInfo, TestOutcome,
23  TestStatus,
24};
25use crate::reporter::{EventBus, ReporterEvent};
26
27#[derive(Clone)]
28struct EffectiveContextConfig {
29  context: ContextConfig,
30  default_viewport: Option<ViewportConfig>,
31  viewport_override: Option<ViewportConfig>,
32  request_base_url: Option<String>,
33}
34
35enum TestBrowserState {
36  Empty,
37  Context(Arc<ferridriver::ContextRef>),
38  Page {
39    ctx: Arc<ferridriver::ContextRef>,
40    page: Arc<ferridriver::Page>,
41  },
42  Failed(ferridriver::FerriError),
43}
44
45struct TestBrowserResources {
46  handle: Arc<crate::runner::BrowserHandle>,
47  effective: EffectiveContextConfig,
48  output_dir: std::path::PathBuf,
49  state: Mutex<TestBrowserState>,
50}
51
52fn is_retryable_bidi_page_error(err: &ferridriver::FerriError) -> bool {
53  let s = err.to_string();
54  s.contains("DiscardedBrowsingContextError")
55    || s.contains("BrowsingContext does no longer exist")
56    || s.contains("BiDi error 'no such frame'")
57    || s.contains("BiDi error 'no such window'")
58}
59
60async fn ensure_page_alive(page: &Arc<ferridriver::Page>) -> ferridriver::Result<()> {
61  // Health check via raw `Runtime.evaluate("1")` — only fired when
62  // [`needs_alive_check`] returns true. CDP backends don't need it:
63  // `Target.attachedToTarget` only fires after the renderer's V8
64  // context is up, and the per-page `enable_domains` parallel batch
65  // (Page.enable + Runtime.enable) returns only when the V8 context
66  // is ready to accept commands. Keep the check for BiDi where the
67  // startup sequence is genuinely racy (Firefox occasionally returns
68  // `BrowsingContext` before its underlying `Window` is fully wired
69  // up — observed in `is_retryable_bidi_page_error`).
70  page.inner().evaluate("1").await.map(|_| ())
71}
72
73/// Returns true when [`ensure_page_alive`] should fire on a freshly
74/// created page. CDP-backed pages skip the check (~1 CDP RTT per
75/// test saved); WebKit shares the default context so per-test pages
76/// don't get created at all on that backend.
77fn needs_alive_check(backend: ferridriver::backend::BackendKind) -> bool {
78  matches!(backend, ferridriver::backend::BackendKind::Bidi)
79}
80
81async fn create_ready_page(
82  ctx: &ferridriver::ContextRef,
83  backend: ferridriver::backend::BackendKind,
84) -> ferridriver::error::Result<Arc<ferridriver::Page>> {
85  let page = ctx.new_page().await?;
86  if needs_alive_check(backend) {
87    ensure_page_alive(&page).await?;
88  }
89  Ok(page)
90}
91
92impl TestBrowserResources {
93  fn new(
94    handle: Arc<crate::runner::BrowserHandle>,
95    effective: EffectiveContextConfig,
96    output_dir: std::path::PathBuf,
97  ) -> Self {
98    Self {
99      handle,
100      effective,
101      output_dir,
102      state: Mutex::new(TestBrowserState::Empty),
103    }
104  }
105
106  async fn context(&self) -> ferridriver::error::Result<Arc<ferridriver::ContextRef>> {
107    let mut state = self.state.lock().await;
108    match &mut *state {
109      TestBrowserState::Context(ctx) => Ok(Arc::clone(ctx)),
110      TestBrowserState::Page { ctx, .. } => Ok(Arc::clone(ctx)),
111      TestBrowserState::Failed(err) => Err(err.clone()),
112      TestBrowserState::Empty => {
113        let browser = self.handle.get().await?;
114        let ctx = Arc::new(new_test_context(&browser));
115        *state = TestBrowserState::Context(Arc::clone(&ctx));
116        Ok(ctx)
117      },
118    }
119  }
120
121  #[tracing::instrument(skip_all, name = "page_fixture")]
122  async fn page(&self) -> ferridriver::error::Result<Arc<ferridriver::Page>> {
123    let mut state = self.state.lock().await;
124    match &mut *state {
125      TestBrowserState::Page { page, .. } => Ok(Arc::clone(page)),
126      TestBrowserState::Failed(err) => Err(err.clone()),
127      TestBrowserState::Context(ctx) => {
128        let browser = self.handle.get().await?;
129        let backend = browser.backend_kind();
130        let page = create_ready_page(ctx, backend).await?;
131        apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
132        let ctx = Arc::clone(ctx);
133        *state = TestBrowserState::Page {
134          ctx,
135          page: Arc::clone(&page),
136        };
137        Ok(page)
138      },
139      TestBrowserState::Empty => {
140        let browser = self.handle.get().await?;
141        let backend = browser.backend_kind();
142        let ctx = Arc::new(new_test_context(&browser));
143        match create_ready_page(&ctx, backend).await {
144          Ok(page) => {
145            apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
146            *state = TestBrowserState::Page {
147              ctx: Arc::clone(&ctx),
148              page: Arc::clone(&page),
149            };
150            Ok(page)
151          },
152          Err(err) => {
153            if is_retryable_bidi_page_error(&err) {
154              let _ = ctx.close().await;
155              let ctx = Arc::new(new_test_context(&browser));
156              let page = create_ready_page(&ctx, backend).await?;
157              apply_page_config(&page, &self.effective, &self.output_dir, backend).await?;
158              *state = TestBrowserState::Page {
159                ctx,
160                page: Arc::clone(&page),
161              };
162              return Ok(page);
163            }
164            *state = TestBrowserState::Failed(err.clone());
165            Err(err)
166          },
167        }
168      },
169    }
170  }
171
172  async fn close(&self) {
173    let mut state = self.state.lock().await;
174    match std::mem::replace(&mut *state, TestBrowserState::Empty) {
175      TestBrowserState::Context(ctx) => {
176        close_test_context(&ctx).await;
177      },
178      TestBrowserState::Page { ctx, page } => {
179        // For backends that share the default context (webkit) the
180        // page is the only per-test resource we own — closing the
181        // context itself would tear down the persistent default and
182        // break later tests. For isolated-context backends (CDP /
183        // BiDi) the context's `Target.disposeBrowserContext` already
184        // closes every page in it, so an explicit `page.close()`
185        // would only add a redundant `Target.closeTarget` round-trip
186        // per test (~3-5ms each on the bench's tight loop).
187        if ctx.name() == "default" {
188          let _ = page.close(None).await;
189        } else {
190          drop(page);
191        }
192        close_test_context(&ctx).await;
193      },
194      TestBrowserState::Empty | TestBrowserState::Failed(_) => {},
195    }
196  }
197}
198
199/// Open a per-test browsing container. Backends that support
200/// isolated contexts (CDP pipe, CDP raw, BiDi/Firefox) get a fresh
201/// `Browser::new_context(None)`. WebKit's stock `WKWebView` doesn't
202/// expose multiple contexts, so we share the persistent default —
203/// state will leak between tests on that backend.
204fn new_test_context(browser: &Arc<ferridriver::Browser>) -> ferridriver::ContextRef {
205  if browser.supports_isolated_contexts() {
206    browser.new_context(None)
207  } else {
208    browser.default_context()
209  }
210}
211
212/// Drop a per-test context. Skips `ctx.close()` when the context is
213/// the shared default container — closing it would tear down the
214/// only browsing context available on backends without isolated
215/// contexts (webkit).
216async fn close_test_context(ctx: &ferridriver::ContextRef) {
217  if ctx.name() == "default" {
218    return;
219  }
220  let _ = ctx.close().await;
221}
222
223fn build_effective_context_config(config: &TestConfig, test: &crate::model::TestCase) -> EffectiveContextConfig {
224  let mut ctx_config = config.browser.use_options.clone();
225  if let Some(ref opts) = test.use_options {
226    if let Some(v) = opts.get("locale").and_then(|v| v.as_str()) {
227      ctx_config.locale = Some(v.to_string());
228    }
229    if let Some(v) = opts.get("colorScheme").and_then(|v| v.as_str()) {
230      ctx_config.color_scheme = Some(v.to_string());
231    }
232    if let Some(v) = opts.get("timezoneId").and_then(|v| v.as_str()) {
233      ctx_config.timezone_id = Some(v.to_string());
234    }
235    if let Some(v) = opts.get("isMobile").and_then(|v| v.as_bool()) {
236      ctx_config.is_mobile = v;
237    }
238    if let Some(v) = opts.get("hasTouch").and_then(|v| v.as_bool()) {
239      ctx_config.has_touch = v;
240    }
241    if let Some(v) = opts.get("offline").and_then(|v| v.as_bool()) {
242      ctx_config.offline = v;
243    }
244    if let Some(v) = opts.get("javaScriptEnabled").and_then(|v| v.as_bool()) {
245      ctx_config.java_script_enabled = v;
246    }
247    if let Some(v) = opts.get("bypassCSP").and_then(|v| v.as_bool()) {
248      ctx_config.bypass_csp = v;
249    }
250    if let Some(v) = opts.get("userAgent").and_then(|v| v.as_str()) {
251      ctx_config.user_agent = Some(v.to_string());
252    }
253    if let Some(v) = opts.get("deviceScaleFactor").and_then(|v| v.as_f64()) {
254      ctx_config.device_scale_factor = Some(v);
255    }
256    if let Some(v) = opts.get("reducedMotion").and_then(|v| v.as_str()) {
257      ctx_config.reduced_motion = Some(v.to_string());
258    }
259    if let Some(v) = opts.get("forcedColors").and_then(|v| v.as_str()) {
260      ctx_config.forced_colors = Some(v.to_string());
261    }
262    if let Some(v) = opts.get("serviceWorkers").and_then(|v| v.as_str()) {
263      ctx_config.service_workers = Some(v.to_string());
264    }
265    if let Some(v) = opts.get("storageState").and_then(|v| v.as_str()) {
266      ctx_config.storage_state = Some(v.to_string());
267    }
268    if let Some(v) = opts.get("acceptDownloads").and_then(|v| v.as_bool()) {
269      ctx_config.accept_downloads = v;
270    }
271    if let Some(v) = opts.get("ignoreHTTPSErrors").and_then(|v| v.as_bool()) {
272      ctx_config.ignore_https_errors = v;
273    }
274    if let Some(geo) = opts.get("geolocation").and_then(|v| v.as_object()) {
275      if let (Some(lat), Some(lon)) = (
276        geo.get("latitude").and_then(|v| v.as_f64()),
277        geo.get("longitude").and_then(|v| v.as_f64()),
278      ) {
279        ctx_config.geolocation = Some(crate::config::GeolocationConfig {
280          latitude: lat,
281          longitude: lon,
282          accuracy: geo.get("accuracy").and_then(|v| v.as_f64()),
283        });
284      }
285    }
286    if let Some(arr) = opts.get("permissions").and_then(|v| v.as_array()) {
287      let perms: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
288      if !perms.is_empty() {
289        ctx_config.permissions = perms;
290      }
291    }
292    if let Some(obj) = opts.get("extraHTTPHeaders").and_then(|v| v.as_object()) {
293      let headers: std::collections::BTreeMap<String, String> = obj
294        .iter()
295        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
296        .collect();
297      if !headers.is_empty() {
298        ctx_config.extra_http_headers = headers;
299      }
300    }
301    if let Some(creds) = opts.get("httpCredentials").and_then(|v| v.as_object()) {
302      if let (Some(user), Some(pass)) = (
303        creds.get("username").and_then(|v| v.as_str()),
304        creds.get("password").and_then(|v| v.as_str()),
305      ) {
306        ctx_config.http_credentials = Some(crate::config::HttpCredentialsConfig {
307          username: user.to_string(),
308          password: pass.to_string(),
309          origin: creds.get("origin").and_then(|v| v.as_str()).map(String::from),
310        });
311      }
312    }
313  }
314
315  let viewport_override = test.use_options.as_ref().and_then(|opts| {
316    opts.get("viewport").and_then(|v| {
317      let w = v.get("width").and_then(|w| w.as_i64());
318      let h = v.get("height").and_then(|h| h.as_i64());
319      match (w, h) {
320        (Some(w), Some(h)) => Some(ViewportConfig { width: w, height: h }),
321        _ => None,
322      }
323    })
324  });
325
326  let request_base_url = test
327    .use_options
328    .as_ref()
329    .and_then(|opts| opts.get("baseURL").and_then(|v| v.as_str()).map(String::from))
330    .or_else(|| config.base_url.clone());
331
332  if ctx_config.storage_state.is_none() {
333    ctx_config.storage_state.clone_from(&config.storage_state);
334  }
335
336  EffectiveContextConfig {
337    context: ctx_config,
338    default_viewport: config.browser.viewport.clone(),
339    viewport_override,
340    request_base_url,
341  }
342}
343
344fn build_suite_effective_context_config(config: &TestConfig) -> EffectiveContextConfig {
345  let mut ctx_config = config.browser.use_options.clone();
346  if ctx_config.storage_state.is_none() {
347    ctx_config.storage_state.clone_from(&config.storage_state);
348  }
349
350  EffectiveContextConfig {
351    context: ctx_config,
352    default_viewport: config.browser.viewport.clone(),
353    viewport_override: None,
354    request_base_url: config.base_url.clone(),
355  }
356}
357
358async fn apply_page_config(
359  page: &Arc<ferridriver::Page>,
360  effective: &EffectiveContextConfig,
361  output_dir: &std::path::Path,
362  backend_kind: ferridriver::backend::BackendKind,
363) -> ferridriver::error::Result<()> {
364  let ctx_config = &effective.context;
365  let mut opts = ferridriver::options::BrowserContextOptions::default();
366  // Playwright WebKit rejects several context-options fields outright
367  // on launchPersistentContext; degrade silently when the user hasn't
368  // explicitly opted in.
369  let is_webkit = matches!(backend_kind, ferridriver::backend::BackendKind::WebKit);
370
371  let viewport = effective
372    .viewport_override
373    .as_ref()
374    .or(effective.default_viewport.as_ref());
375  if let Some(vp) = viewport {
376    opts.viewport = ferridriver::options::ViewportOption::Size {
377      width: vp.width,
378      height: vp.height,
379    };
380  }
381  opts.device_scale_factor = ctx_config.device_scale_factor;
382  if ctx_config.is_mobile {
383    opts.is_mobile = Some(true);
384  }
385  if ctx_config.has_touch {
386    opts.has_touch = Some(true);
387  }
388  opts.color_scheme = ctx_config.color_scheme.clone().into();
389  opts.reduced_motion = ctx_config.reduced_motion.clone().into();
390  opts.forced_colors = ctx_config.forced_colors.clone().into();
391  opts.locale = ctx_config.locale.clone();
392  opts.timezone_id = ctx_config.timezone_id.clone();
393  if let Some(ref geo) = ctx_config.geolocation {
394    opts.geolocation = Some(ferridriver::options::Geolocation {
395      latitude: geo.latitude,
396      longitude: geo.longitude,
397      accuracy: geo.accuracy.unwrap_or(0.0),
398    });
399  }
400  if ctx_config.offline {
401    opts.offline = Some(true);
402  }
403  if !ctx_config.permissions.is_empty() {
404    opts.permissions = Some(ctx_config.permissions.clone());
405  }
406  if !ctx_config.extra_http_headers.is_empty() {
407    opts.extra_http_headers = Some(
408      ctx_config
409        .extra_http_headers
410        .iter()
411        .map(|(k, v)| (k.clone(), v.clone()))
412        .collect(),
413    );
414  }
415  opts.user_agent = ctx_config.user_agent.clone();
416  // Plumb the test config's `baseURL` into the BrowserContext bag so
417  // `page.goto('/route')` resolves against it. Previously the value
418  // was only stored as `request_base_url` for the API-request
419  // fixture, leaving relative `page.goto` paths to fail with "Cannot
420  // navigate to invalid URL" — Playwright resolves these via the
421  // context's baseURL option, mirror that.
422  if opts.base_url.is_none() {
423    opts.base_url = effective.request_base_url.clone();
424  }
425  if !ctx_config.java_script_enabled {
426    opts.java_script_enabled = Some(false);
427  }
428  if ctx_config.bypass_csp && !is_webkit {
429    opts.bypass_csp = Some(true);
430  }
431  if ctx_config.ignore_https_errors && !is_webkit {
432    opts.ignore_https_errors = Some(true);
433  }
434  // Note: `ctx_config.accept_downloads` defaults to `true` (Playwright
435  // parity). We deliberately don't pass that through to
436  // `BrowserContextOptions.accept_downloads` here — doing so makes
437  // `apply_context_options` fire `Browser.setDownloadBehavior` on
438  // every per-test page, which is ~3-5ms per test on the bench's
439  // tight loop. The page-level lazy `enable_download_behavior` (fired
440  // on first `wait_for_download` / `page.on('download')`) handles the
441  // CDP command when a test actually needs it. Tests that opt OUT
442  // (`acceptDownloads: false`) still flow through, since opts.deny is
443  // an explicit decision the bag has to encode.
444  if !ctx_config.accept_downloads && !is_webkit {
445    opts.accept_downloads = Some(false);
446  }
447  if ctx_config.accept_downloads && !is_webkit {
448    let _ = std::fs::create_dir_all(output_dir.join("downloads"));
449  }
450  if let Some(ref creds) = ctx_config.http_credentials {
451    opts.http_credentials = Some(ferridriver::options::HttpCredentials {
452      username: creds.username.clone(),
453      password: creds.password.clone(),
454      origin: None,
455      send: None,
456    });
457  }
458  if ctx_config.service_workers.as_deref() == Some("block") {
459    opts.service_workers = Some(ferridriver::options::ServiceWorkerPolicy::Block);
460  }
461
462  // `storageState` is not part of the apply_context_options bag yet
463  // (needs IndexedDB capture — see §4.2/§4.3). Fall back to the
464  // legacy load path which hydrates cookies + localStorage via the
465  // page's backend storage helpers.
466  if let Some(ss_path) = ctx_config.storage_state.as_deref() {
467    let path = std::path::Path::new(ss_path);
468    match std::fs::read_to_string(path) {
469      Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
470        Ok(state) => tracing::warn!(
471          target: "ferridriver::worker",
472          "storage state not yet wired through apply_context_options — skipping hydration from {}: {state:?}",
473          path.display()
474        ),
475        Err(e) => tracing::warn!(target: "ferridriver::worker", "parse storage state {}: {e}", path.display()),
476      },
477      Err(e) => tracing::warn!(target: "ferridriver::worker", "read storage state {}: {e}", path.display()),
478    }
479  }
480
481  page.apply_context_options(&opts).await
482}
483
484/// Worker-scope `browser` fixture backed by `BrowserHandle`. Added to the
485/// custom_fixture_pool so every child suite/test pool can resolve it via
486/// the parent chain. Lazy: launches on first `get("browser")`.
487fn build_worker_browser_def(handle: Arc<crate::runner::BrowserHandle>) -> FixtureDef {
488  FixtureDef {
489    name: "browser".into(),
490    scope: FixtureScope::Worker,
491    dependencies: vec![],
492    setup: Arc::new(move |_pool| {
493      let handle = Arc::clone(&handle);
494      Box::pin(async move {
495        let browser = handle.get().await?;
496        Ok(browser as Arc<dyn std::any::Any + Send + Sync>)
497      })
498    }),
499    teardown: None,
500    timeout: Duration::from_secs(30),
501    auto: false,
502  }
503}
504
505fn build_browser_fixture_defs(
506  resources: Arc<TestBrowserResources>,
507  scope: FixtureScope,
508) -> FxHashMap<String, FixtureDef> {
509  let mut defs = FxHashMap::default();
510
511  defs.insert(
512    "context".into(),
513    FixtureDef {
514      name: "context".into(),
515      scope,
516      dependencies: vec![],
517      setup: Arc::new({
518        let resources = Arc::clone(&resources);
519        move |_pool| {
520          let resources = Arc::clone(&resources);
521          Box::pin(async move {
522            let ctx = resources.context().await?;
523            Ok(ctx as Arc<dyn std::any::Any + Send + Sync>)
524          })
525        }
526      }),
527      teardown: None,
528      timeout: Duration::from_secs(10),
529      auto: false,
530    },
531  );
532
533  defs.insert(
534    "page".into(),
535    FixtureDef {
536      name: "page".into(),
537      scope,
538      dependencies: vec![],
539      setup: Arc::new({
540        let resources = Arc::clone(&resources);
541        move |_pool| {
542          let resources = Arc::clone(&resources);
543          Box::pin(async move {
544            let page = resources.page().await?;
545            Ok(page as Arc<dyn std::any::Any + Send + Sync>)
546          })
547        }
548      }),
549      teardown: None,
550      timeout: Duration::from_secs(10),
551      auto: false,
552    },
553  );
554
555  defs
556}
557
558/// Worker-scope `request` fixture. Builds one [`HttpClient`] per worker
559/// so the underlying reqwest connection pool, TLS context, and cookie
560/// jar are reused across every test on this worker — saves the per-test
561/// `reqwest::Client::builder().build()` cost (~1-10ms each on the bench).
562///
563/// `base_url` is captured from the worker's config; per-test
564/// `use_options.base_url` overrides aren't honored at this scope. Tests
565/// that need a different base URL should construct an `HttpClient`
566/// inside the test body, or we expose a per-test override fixture
567/// later (Playwright's `request` fixture has the same worker-scoped
568/// shape — `playwright/types/test.d.ts` `APIRequestContext`).
569fn build_worker_request_def(base_url: Option<String>) -> FixtureDef {
570  FixtureDef {
571    name: "request".into(),
572    scope: FixtureScope::Worker,
573    dependencies: vec![],
574    setup: Arc::new(move |_pool| {
575      let base_url = base_url.clone();
576      Box::pin(async move {
577        Ok(Arc::new(ferridriver::http_client::HttpClient::new(
578          ferridriver::http_client::HttpClientOptions {
579            base_url,
580            ..Default::default()
581          },
582        )) as Arc<dyn std::any::Any + Send + Sync>)
583      })
584    }),
585    teardown: None,
586    timeout: Duration::from_secs(10),
587    auto: false,
588  }
589}
590
591fn build_test_fixture_defs(resources: Arc<TestBrowserResources>) -> FxHashMap<String, FixtureDef> {
592  build_browser_fixture_defs(resources, FixtureScope::Test)
593}
594
595fn build_suite_fixture_defs(resources: Arc<TestBrowserResources>) -> FxHashMap<String, FixtureDef> {
596  build_browser_fixture_defs(resources, FixtureScope::Worker)
597}
598
599/// Result of a single test execution within a worker.
600pub struct WorkerTestResult {
601  pub outcome: TestOutcome,
602  pub should_retry: bool,
603  pub test_fn: crate::model::TestFn,
604  pub test_id: crate::model::TestId,
605  pub fixture_requests: Vec<String>,
606  pub suite_key: String,
607  pub hooks: Arc<Hooks>,
608}
609
610/// Per-suite state tracked on this worker.
611struct SuiteState {
612  before_all_ran: bool,
613  before_all_failed: bool,
614  hooks: Arc<Hooks>,
615  fixture_pool: FixturePool,
616}
617
618/// A worker that owns a browser and processes tests sequentially.
619pub struct Worker {
620  pub id: u32,
621  config: Arc<TestConfig>,
622  event_bus: EventBus,
623}
624
625impl Worker {
626  pub fn new(id: u32, config: Arc<TestConfig>, event_bus: EventBus) -> Self {
627    Self { id, config, event_bus }
628  }
629
630  fn create_suite_test_info(&self, suite_key: &str) -> Arc<TestInfo> {
631    Arc::new(TestInfo {
632      test_id: crate::model::TestId {
633        file: suite_key.to_string(),
634        suite: None,
635        name: "suite hooks".to_string(),
636        line: None,
637      },
638      title_path: vec![suite_key.to_string(), "suite hooks".to_string()],
639      retry: 0,
640      worker_index: self.id,
641      parallel_index: self.id,
642      repeat_each_index: 0,
643      output_dir: self
644        .config
645        .output_dir
646        .join("__suite_hooks__")
647        .join(sanitize_filename(suite_key)),
648      snapshot_dir: self
649        .config
650        .snapshot_dir
651        .as_ref()
652        .map(std::path::PathBuf::from)
653        .unwrap_or_else(|| std::path::PathBuf::from("__snapshots__")),
654      snapshot_path_template: self.config.snapshot_path_template.clone(),
655      update_snapshots: self.config.update_snapshots,
656      ignore_snapshots: self.config.ignore_snapshots,
657      attachments: Arc::new(Mutex::new(Vec::new())),
658      steps: Arc::new(Mutex::new(Vec::new())),
659      soft_errors: Arc::new(Mutex::new(Vec::new())),
660      errors: Arc::new(Mutex::new(Vec::new())),
661      snapshot_suffix: Arc::new(Mutex::new(String::new())),
662      column: None,
663      project: None,
664      config_snapshot: Some(Arc::clone(&self.config)),
665      timeout: Duration::from_millis(self.config.timeout),
666      tags: Vec::new(),
667      start_time: Instant::now(),
668      event_bus: Some(self.event_bus.clone()),
669      annotations: Arc::new(Mutex::new(Vec::new())),
670    })
671  }
672
673  #[tracing::instrument(skip_all, fields(worker_id = self.id))]
674  pub async fn run(
675    &self,
676    browser_handle: Arc<crate::runner::BrowserHandle>,
677    custom_fixture_pool: FixturePool,
678    rx: async_channel::Receiver<WorkItem>,
679    result_tx: mpsc::Sender<WorkerTestResult>,
680    stop_flag: Arc<std::sync::atomic::AtomicBool>,
681  ) {
682    self
683      .event_bus
684      .emit(ReporterEvent::WorkerStarted { worker_id: self.id })
685      .await;
686
687    // Register the worker-scope `browser` + `request` fixtures on the
688    // custom pool so child suite/test pools resolve them via the parent
689    // chain. The backing `BrowserHandle` makes the browser launch lazy;
690    // the `HttpClient` is built once per worker so its reqwest pool,
691    // TLS context, and cookie jar are reused across every test on this
692    // worker.
693    let mut worker_defs: FxHashMap<String, FixtureDef> = FxHashMap::default();
694    worker_defs.insert("browser".into(), build_worker_browser_def(Arc::clone(&browser_handle)));
695    worker_defs.insert("request".into(), build_worker_request_def(self.config.base_url.clone()));
696    let custom_fixture_pool = custom_fixture_pool.child_with_defs(worker_defs, FixtureScope::Worker);
697
698    // Pre-warm: fire chromium launch on a background task so the first
699    // test that resolves the `browser` fixture can skip the ~20-30ms
700    // launch wait. `BrowserHandle::get` is a `tokio::sync::OnceCell`
701    // so a concurrent in-flight init folds together — the first test's
702    // `.await` either sees the launch already done or waits for the
703    // same future the pre-warm started.
704    {
705      let handle = Arc::clone(&browser_handle);
706      tokio::spawn(async move {
707        let _ = handle.get().await;
708      });
709    }
710
711    let mut active_suites: FxHashMap<String, SuiteState> = FxHashMap::default();
712
713    while let Ok(item) = rx.recv().await {
714      // `--max-failures` / `-x` flips this flag; drop any items that were
715      // already buffered in the channel rather than processing them.
716      if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
717        break;
718      }
719      match item {
720        WorkItem::Single(assignment) => {
721          let result =
722            Box::pin(self.run_single(&browser_handle, &custom_fixture_pool, &mut active_suites, assignment)).await;
723          if result_tx.send(result).await.is_err() {
724            break;
725          }
726        },
727        WorkItem::Serial(batch) => {
728          let results =
729            Box::pin(self.run_serial_batch(&browser_handle, &custom_fixture_pool, &mut active_suites, batch)).await;
730          for result in results {
731            if result_tx.send(result).await.is_err() {
732              break;
733            }
734          }
735        },
736      }
737      // Yield so the runner can observe the just-sent result and trip the
738      // stop flag (for `--max-failures` / `-x`) before this worker races
739      // to pull the next item out of the buffered channel.
740      tokio::task::yield_now().await;
741    }
742
743    // Run afterAll for every suite that had beforeAll on this worker.
744    for (suite_key, state) in &active_suites {
745      if state.before_all_ran {
746        for (i, hook) in state.hooks.after_all.iter().enumerate() {
747          let step_title = if state.hooks.after_all.len() == 1 {
748            "afterAll".to_string()
749          } else {
750            format!("afterAll [{i}]")
751          };
752          // afterAll has no test context — emit synthetic step events.
753          let step_id = format!("hook:afterAll:{suite_key}:{i}");
754          // Use a synthetic TestId for the suite.
755          let synthetic_id = crate::model::TestId {
756            file: suite_key.clone(),
757            suite: None,
758            name: step_title.clone(),
759            line: None,
760          };
761          self
762            .event_bus
763            .emit(ReporterEvent::StepStarted(Box::new(
764              crate::reporter::StepStartedEvent {
765                test_id: synthetic_id.clone(),
766                step_id: step_id.clone(),
767                parent_step_id: None,
768                title: step_title.clone(),
769                category: StepCategory::Hook,
770              },
771            )))
772            .await;
773          let start = Instant::now();
774          let result = hook(state.fixture_pool.clone()).await;
775          let duration = start.elapsed();
776          let error = result.as_ref().err().map(|e| format!("{e}"));
777          self
778            .event_bus
779            .emit(ReporterEvent::StepFinished(Box::new(
780              crate::reporter::StepFinishedEvent {
781                test_id: synthetic_id,
782                step_id,
783                title: step_title,
784                category: StepCategory::Hook,
785                duration,
786                error: error.clone(),
787                metadata: None,
788              },
789            )))
790            .await;
791          if let Err(e) = result {
792            tracing::warn!(target: "ferridriver::worker", "afterAll error: {e}");
793          }
794        }
795      }
796    }
797
798    for state in active_suites.values() {
799      state.fixture_pool.teardown_all().await;
800    }
801    custom_fixture_pool.teardown_all().await;
802
803    // Graceful browser close — only fires when the worker actually
804    // launched a browser via `BrowserHandle::get`. Tests that never
805    // touched a browser-dependent fixture skip the close handshake
806    // because no browser was launched in the first place.
807    browser_handle.close().await;
808
809    self
810      .event_bus
811      .emit(ReporterEvent::WorkerFinished { worker_id: self.id })
812      .await;
813  }
814
815  /// Run a serial batch: all tests in order, skip rest on failure.
816  async fn run_serial_batch(
817    &self,
818    browser: &Arc<crate::runner::BrowserHandle>,
819    custom_pool: &FixturePool,
820    active_suites: &mut FxHashMap<String, SuiteState>,
821    batch: SerialBatch,
822  ) -> Vec<WorkerTestResult> {
823    let mut results = Vec::with_capacity(batch.assignments.len());
824    let mut serial_failed = false;
825
826    for assignment in batch.assignments {
827      if serial_failed {
828        // Skip remaining tests in the serial suite.
829        let test = &assignment.test;
830        let outcome = TestOutcome {
831          test_id: test.id.clone(),
832          status: TestStatus::Skipped,
833          duration: Duration::ZERO,
834          attempt: assignment.attempt,
835          max_attempts: test.retries.unwrap_or(self.config.retries) + 1,
836          error: Some(TestFailure {
837            message: "skipped due to previous failure in serial suite".into(),
838            stack: None,
839            diff: None,
840            screenshot: None,
841          }),
842          attachments: Vec::new(),
843          steps: Vec::new(),
844          stdout: String::new(),
845          stderr: String::new(),
846          annotations: test.annotations.clone(),
847          metadata: self.config.metadata.clone(),
848        };
849        self
850          .event_bus
851          .emit(ReporterEvent::TestFinished {
852            test_id: test.id.clone(),
853            outcome: outcome.clone(),
854          })
855          .await;
856        results.push(WorkerTestResult {
857          outcome,
858          should_retry: false,
859          test_fn: Arc::clone(&test.test_fn),
860          test_id: test.id.clone(),
861          fixture_requests: test.fixture_requests.clone(),
862          suite_key: assignment.suite_key,
863          hooks: assignment.hooks,
864        });
865        continue;
866      }
867
868      let result = Box::pin(self.run_single(browser, custom_pool, active_suites, assignment)).await;
869      if result.outcome.status == TestStatus::Failed || result.outcome.status == TestStatus::TimedOut {
870        serial_failed = true;
871      }
872      results.push(result);
873    }
874
875    results
876  }
877
878  /// Run a single test with full hook lifecycle.
879  #[tracing::instrument(skip_all, fields(worker_id = self.id, test, attempt = assignment.attempt))]
880  async fn run_single(
881    &self,
882    browser: &Arc<crate::runner::BrowserHandle>,
883    custom_pool: &FixturePool,
884    active_suites: &mut FxHashMap<String, SuiteState>,
885    assignment: TestAssignment,
886  ) -> WorkerTestResult {
887    let test = &assignment.test;
888    let test_id = test.id.clone();
889    tracing::Span::current().record("test", test_id.full_name().as_str());
890    let test_fn = Arc::clone(&test.test_fn);
891    let fixture_requests = test.fixture_requests.clone();
892    let attempt = assignment.attempt;
893    let max_retries = test.retries.unwrap_or(self.config.retries);
894    let max_attempts = max_retries + 1;
895    let suite_key = assignment.suite_key.clone();
896
897    tracing::debug!(
898      target: "ferridriver::worker",
899      worker = self.id,
900      test = test_id.full_name(),
901      attempt,
902      max_attempts,
903      "dispatching test",
904    );
905    let hooks = Arc::clone(&assignment.hooks);
906
907    // ── beforeAll (once per suite on this worker) ──
908    let suite_state = active_suites.entry(suite_key.clone()).or_insert_with(|| {
909      let suite_test_info = self.create_suite_test_info(&suite_key);
910      let suite_resources = Arc::new(TestBrowserResources::new(
911        Arc::clone(browser),
912        build_suite_effective_context_config(&self.config),
913        suite_test_info.output_dir.clone(),
914      ));
915      let suite_pool = custom_pool.child_with_defs(build_suite_fixture_defs(suite_resources), FixtureScope::Worker);
916      suite_pool.inject("test_info", suite_test_info);
917
918      SuiteState {
919        before_all_ran: false,
920        before_all_failed: false,
921        hooks: Arc::clone(&hooks),
922        fixture_pool: suite_pool,
923      }
924    });
925
926    // Worker-scope `auto: true` fixtures resolve once before beforeAll runs.
927    for name in suite_state.fixture_pool.auto_fixture_names_for(FixtureScope::Worker) {
928      if let Err(e) = suite_state.fixture_pool.resolve(&name).await {
929        tracing::warn!(target: "ferridriver::worker", "auto fixture '{name}' (suite) failed: {e}");
930      }
931    }
932
933    if !suite_state.before_all_ran && !hooks.before_all.is_empty() {
934      for (i, hook) in hooks.before_all.iter().enumerate() {
935        let step_title = if hooks.before_all.len() == 1 {
936          "beforeAll".to_string()
937        } else {
938          format!("beforeAll [{i}]")
939        };
940        self
941          .event_bus
942          .emit(ReporterEvent::StepStarted(Box::new(
943            crate::reporter::StepStartedEvent {
944              test_id: test_id.clone(),
945              step_id: format!("hook:beforeAll:{suite_key}:{i}"),
946              parent_step_id: None,
947              title: step_title.clone(),
948              category: StepCategory::Hook,
949            },
950          )))
951          .await;
952        let start = Instant::now();
953        let result = hook(suite_state.fixture_pool.clone()).await;
954        let duration = start.elapsed();
955        let error = result.as_ref().err().map(|e| e.message.clone());
956        self
957          .event_bus
958          .emit(ReporterEvent::StepFinished(Box::new(
959            crate::reporter::StepFinishedEvent {
960              test_id: test_id.clone(),
961              step_id: format!("hook:beforeAll:{suite_key}:{i}"),
962              title: step_title,
963              category: StepCategory::Hook,
964              duration,
965              error: error.clone(),
966              metadata: None,
967            },
968          )))
969          .await;
970        if let Err(e) = result {
971          tracing::error!(target: "ferridriver::worker", "beforeAll failed for {suite_key}: {e}");
972          suite_state.before_all_failed = true;
973          break;
974        }
975      }
976      suite_state.before_all_ran = true;
977    }
978
979    // If beforeAll failed, skip this test.
980    if suite_state.before_all_failed {
981      let outcome = TestOutcome {
982        test_id: test_id.clone(),
983        status: TestStatus::Skipped,
984        duration: Duration::ZERO,
985        attempt,
986        max_attempts,
987        error: Some(TestFailure {
988          message: format!("skipped: beforeAll failed for suite '{suite_key}'"),
989          stack: None,
990          diff: None,
991          screenshot: None,
992        }),
993        attachments: Vec::new(),
994        steps: Vec::new(),
995        stdout: String::new(),
996        stderr: String::new(),
997        annotations: test.annotations.clone(),
998        metadata: self.config.metadata.clone(),
999      };
1000      self
1001        .event_bus
1002        .emit(ReporterEvent::TestFinished {
1003          test_id: test_id.clone(),
1004          outcome: outcome.clone(),
1005        })
1006        .await;
1007      return WorkerTestResult {
1008        outcome,
1009        should_retry: false,
1010        test_fn,
1011        test_id,
1012        fixture_requests,
1013        suite_key,
1014        hooks,
1015      };
1016    }
1017
1018    // Check for skip/fixme (with conditional evaluation).
1019    let browser_config = &self.config.browser;
1020    let should_skip = test.annotations.iter().any(|a| match a {
1021      TestAnnotation::Skip { condition: None, .. } => true,
1022      TestAnnotation::Skip {
1023        condition: Some(cond), ..
1024      } => evaluate_condition(cond, browser_config),
1025      TestAnnotation::Fixme { condition: None, .. } => true,
1026      TestAnnotation::Fixme {
1027        condition: Some(cond), ..
1028      } => evaluate_condition(cond, browser_config),
1029      _ => false,
1030    });
1031    if should_skip {
1032      let outcome = TestOutcome {
1033        test_id: test_id.clone(),
1034        status: TestStatus::Skipped,
1035        duration: Duration::ZERO,
1036        attempt,
1037        max_attempts,
1038        error: None,
1039        attachments: Vec::new(),
1040        steps: Vec::new(),
1041        stdout: String::new(),
1042        stderr: String::new(),
1043        annotations: test.annotations.clone(),
1044        metadata: self.config.metadata.clone(),
1045      };
1046      self
1047        .event_bus
1048        .emit(ReporterEvent::TestFinished {
1049          test_id: test_id.clone(),
1050          outcome: outcome.clone(),
1051        })
1052        .await;
1053      return WorkerTestResult {
1054        outcome,
1055        should_retry: false,
1056        test_fn,
1057        test_id,
1058        fixture_requests,
1059        suite_key,
1060        hooks,
1061      };
1062    }
1063
1064    self
1065      .event_bus
1066      .emit(ReporterEvent::TestStarted {
1067        test_id: test_id.clone(),
1068        attempt,
1069      })
1070      .await;
1071
1072    // Evaluate Fail condition: if condition matches, expect failure (invert pass/fail).
1073    let mut expected_status = test.expected_status.clone();
1074    for ann in &test.annotations {
1075      if let TestAnnotation::Fail { condition, .. } = ann {
1076        let applies = match condition {
1077          None => true,
1078          Some(cond) => evaluate_condition(cond, browser_config),
1079        };
1080        if applies {
1081          expected_status = ExpectedStatus::Fail;
1082        }
1083      }
1084    }
1085
1086    // Timeout with slow multiplier (conditional).
1087    let mut timeout_dur = test.timeout.unwrap_or(Duration::from_millis(self.config.timeout));
1088    let is_slow = test.annotations.iter().any(|a| match a {
1089      TestAnnotation::Slow { condition: None, .. } => true,
1090      TestAnnotation::Slow {
1091        condition: Some(cond), ..
1092      } => evaluate_condition(cond, browser_config),
1093      _ => false,
1094    });
1095    if is_slow {
1096      timeout_dur *= 3;
1097    }
1098
1099    let start = Instant::now();
1100    let effective_config = build_effective_context_config(&self.config, test);
1101
1102    // Create TestInfo for this test execution.
1103    let test_info = Arc::new(TestInfo {
1104      test_id: test_id.clone(),
1105      title_path: {
1106        let mut path = Vec::new();
1107        path.push(test_id.file.clone());
1108        if let Some(ref s) = test_id.suite {
1109          path.push(s.clone());
1110        }
1111        path.push(test_id.name.clone());
1112        path
1113      },
1114      retry: attempt.saturating_sub(1),
1115      worker_index: self.id,
1116      parallel_index: self.id,
1117      repeat_each_index: 0,
1118      output_dir: self.config.output_dir.join(test_id.full_name()),
1119      snapshot_dir: self
1120        .config
1121        .snapshot_dir
1122        .as_ref()
1123        .map(std::path::PathBuf::from)
1124        .unwrap_or_else(|| std::path::PathBuf::from("__snapshots__")),
1125      snapshot_path_template: self.config.snapshot_path_template.clone(),
1126      update_snapshots: self.config.update_snapshots,
1127      ignore_snapshots: self.config.ignore_snapshots,
1128      attachments: Arc::new(Mutex::new(Vec::new())),
1129      steps: Arc::new(Mutex::new(Vec::new())),
1130      soft_errors: Arc::new(Mutex::new(Vec::new())),
1131      errors: Arc::new(Mutex::new(Vec::new())),
1132      snapshot_suffix: Arc::new(Mutex::new(String::new())),
1133      column: None,
1134      project: None,
1135      config_snapshot: Some(Arc::clone(&self.config)),
1136      timeout: timeout_dur,
1137      tags: test
1138        .annotations
1139        .iter()
1140        .filter_map(|a| match a {
1141          TestAnnotation::Tag(t) => Some(t.clone()),
1142          _ => None,
1143        })
1144        .collect(),
1145      start_time: start,
1146      event_bus: Some(self.event_bus.clone()),
1147      annotations: Arc::new(Mutex::new(Vec::new())),
1148    });
1149    let resources = Arc::new(TestBrowserResources::new(
1150      Arc::clone(browser),
1151      effective_config,
1152      test_info.output_dir.clone(),
1153    ));
1154    let test_pool = custom_pool.child_with_defs(build_test_fixture_defs(Arc::clone(&resources)), FixtureScope::Test);
1155    test_pool.inject("test_info", Arc::clone(&test_info));
1156
1157    // Playwright `auto: true` fixtures resolve regardless of whether
1158    // the test body destructured them. Walk the full def graph for
1159    // this scope (and any narrower parents) and pre-resolve.
1160    for name in test_pool.auto_fixture_names_for(FixtureScope::Test) {
1161      if let Err(e) = test_pool.resolve(&name).await {
1162        tracing::warn!(target: "ferridriver::worker", "auto fixture '{name}' failed: {e}");
1163      }
1164    }
1165
1166    enum VideoHandle {
1167      Eager(ferridriver::video::VideoRecordingHandle),
1168      Buffered(ferridriver::video::BufferedRecordingHandle),
1169    }
1170
1171    let mut page_for_artifacts = None;
1172    let video_handle: Option<VideoHandle> = match self.config.video.mode {
1173      crate::config::VideoMode::Off => None,
1174      crate::config::VideoMode::On | crate::config::VideoMode::RetainOnFailure => {
1175        match test_pool.get::<ferridriver::Page>("page").await {
1176          Ok(page) => {
1177            page_for_artifacts = Some(Arc::clone(&page));
1178            let _ = std::fs::create_dir_all(&test_info.output_dir);
1179            match self.config.video.mode {
1180              crate::config::VideoMode::On => {
1181                let ext = ferridriver::video::video_extension();
1182                let video_path =
1183                  test_info
1184                    .output_dir
1185                    .join(format!("{}-attempt{}.{ext}", sanitize_filename(&test_id.name), attempt));
1186                match ferridriver::video::start_recording(
1187                  &page,
1188                  video_path,
1189                  self.config.video.width,
1190                  self.config.video.height,
1191                  80,
1192                )
1193                .await
1194                {
1195                  Ok(h) => Some(VideoHandle::Eager(h)),
1196                  Err(e) => {
1197                    tracing::warn!(target: "ferridriver::worker", "video start failed: {e}");
1198                    None
1199                  },
1200                }
1201              },
1202              crate::config::VideoMode::RetainOnFailure => {
1203                match ferridriver::video::start_buffered_recording(
1204                  &page,
1205                  self.config.video.width,
1206                  self.config.video.height,
1207                  80,
1208                )
1209                .await
1210                {
1211                  Ok(h) => Some(VideoHandle::Buffered(h)),
1212                  Err(e) => {
1213                    tracing::warn!(target: "ferridriver::worker", "video start failed: {e}");
1214                    None
1215                  },
1216                }
1217              },
1218              crate::config::VideoMode::Off => None,
1219            }
1220          },
1221          Err(e) => {
1222            let () = resources.close().await;
1223            let duration = start.elapsed();
1224            let outcome = TestOutcome {
1225              test_id: test_id.clone(),
1226              status: TestStatus::Failed,
1227              duration,
1228              attempt,
1229              max_attempts,
1230              error: Some(TestFailure::wrap("failed to create page", e)),
1231              attachments: Vec::new(),
1232              steps: Vec::new(),
1233              stdout: String::new(),
1234              stderr: String::new(),
1235              annotations: test.annotations.clone(),
1236              metadata: self.config.metadata.clone(),
1237            };
1238            self
1239              .event_bus
1240              .emit(ReporterEvent::TestFinished {
1241                test_id: test_id.clone(),
1242                outcome: outcome.clone(),
1243              })
1244              .await;
1245            return WorkerTestResult {
1246              outcome,
1247              should_retry: attempt <= max_retries,
1248              test_fn,
1249              test_id,
1250              fixture_requests,
1251              suite_key,
1252              hooks,
1253            };
1254          },
1255        }
1256      },
1257    };
1258
1259    let mut before_each_err = None;
1260    for (i, hook) in hooks.before_each.iter().enumerate() {
1261      let title = if hooks.before_each.len() == 1 {
1262        "beforeEach".to_string()
1263      } else {
1264        format!("beforeEach [{i}]")
1265      };
1266      let step_handle = test_info.begin_step(&title, StepCategory::Hook).await;
1267      let result = hook(test_pool.clone(), Arc::clone(&test_info)).await;
1268      let err_msg = result.as_ref().err().map(|e| e.message.clone());
1269      step_handle.end(err_msg).await;
1270      if let Err(e) = result {
1271        before_each_err = Some(e);
1272        break;
1273      }
1274    }
1275
1276    let timeout_result = if let Some(err) = before_each_err {
1277      Ok(Err(err))
1278    } else {
1279      tokio::time::timeout(timeout_dur, (test.test_fn)(test_pool.clone())).await
1280    };
1281
1282    for (i, hook) in hooks.after_each.iter().enumerate() {
1283      let title = if hooks.after_each.len() == 1 {
1284        "afterEach".to_string()
1285      } else {
1286        format!("afterEach [{i}]")
1287      };
1288      let step_handle = test_info.begin_step(&title, StepCategory::Hook).await;
1289      let result = hook(test_pool.clone(), Arc::clone(&test_info)).await;
1290      let err_msg = result.as_ref().err().map(|e| e.message.clone());
1291      step_handle.end(err_msg).await;
1292      if let Err(e) = result {
1293        tracing::warn!(target: "ferridriver::worker", "afterEach error: {e}");
1294      }
1295    }
1296
1297    if page_for_artifacts.is_none() {
1298      page_for_artifacts = test_pool.try_get_cached::<ferridriver::Page>("page");
1299    }
1300    let test_failed = timeout_result.as_ref().is_err() || timeout_result.as_ref().is_ok_and(|r| r.is_err());
1301    let screenshot = if test_failed {
1302      if let Some(ref page) = page_for_artifacts {
1303        capture_screenshot(page).await
1304      } else {
1305        None
1306      }
1307    } else {
1308      None
1309    };
1310    let video_path = match (video_handle, page_for_artifacts.as_ref()) {
1311      (Some(VideoHandle::Eager(handle)), Some(page)) => match handle.stop(page).await {
1312        Ok(path) => Some(path),
1313        Err(e) => {
1314          tracing::warn!(target: "ferridriver::worker", "video stop failed: {e}");
1315          None
1316        },
1317      },
1318      (Some(VideoHandle::Buffered(handle)), Some(page)) => {
1319        if test_failed {
1320          let ext = ferridriver::video::video_extension();
1321          let video_path =
1322            test_info
1323              .output_dir
1324              .join(format!("{}-attempt{}.{ext}", sanitize_filename(&test_id.name), attempt));
1325          let _ = std::fs::create_dir_all(&test_info.output_dir);
1326          match handle.encode(page, video_path).await {
1327            Ok(path) => Some(path),
1328            Err(e) => {
1329              tracing::warn!(target: "ferridriver::worker", "video encode failed: {e}");
1330              None
1331            },
1332          }
1333        } else {
1334          handle.discard(page).await;
1335          None
1336        }
1337      },
1338      _ => None,
1339    };
1340    resources.close().await;
1341
1342    let duration = start.elapsed();
1343    let result = (timeout_result, screenshot, video_path, Some(test_pool));
1344    let (timeout_result, screenshot, video_path, test_pool) = result;
1345
1346    let mut attachments = Vec::new();
1347    if let Some(ref png) = screenshot {
1348      attachments.push(Attachment {
1349        name: "screenshot-on-failure".into(),
1350        content_type: "image/png".into(),
1351        body: AttachmentBody::Bytes(png.clone()),
1352      });
1353    }
1354
1355    let (raw_status, raw_error) = match timeout_result {
1356      Ok(Ok(())) => (TestStatus::Passed, None),
1357      Ok(Err(failure)) => {
1358        // Runtime skip: test body called test.skip() — treat as skip, not failure.
1359        // This mirrors Playwright's TestSkipError thrown by test.skip() inside body.
1360        if failure.message.contains("__FERRIDRIVER_SKIP__:") {
1361          let reason = failure.message.split("__FERRIDRIVER_SKIP__:").nth(1).unwrap_or("");
1362          tracing::debug!(target: "ferridriver::worker", "test skipped at runtime: {reason}");
1363          let outcome = TestOutcome {
1364            test_id: test_id.clone(),
1365            status: TestStatus::Skipped,
1366            duration: start.elapsed(),
1367            attempt,
1368            max_attempts,
1369            error: None,
1370            attachments: Vec::new(),
1371            steps: Vec::new(),
1372            stdout: String::new(),
1373            stderr: String::new(),
1374            annotations: test.annotations.clone(),
1375            metadata: self.config.metadata.clone(),
1376          };
1377          self
1378            .event_bus
1379            .emit(ReporterEvent::TestFinished {
1380              test_id: test_id.clone(),
1381              outcome: outcome.clone(),
1382            })
1383            .await;
1384          return WorkerTestResult {
1385            outcome,
1386            should_retry: false,
1387            test_fn,
1388            test_id,
1389            fixture_requests,
1390            suite_key,
1391            hooks,
1392          };
1393        }
1394
1395        let mut failure = failure;
1396        if failure.screenshot.is_none() {
1397          failure.screenshot = screenshot;
1398        }
1399        (TestStatus::Failed, Some(failure))
1400      },
1401      Err(_) => (
1402        TestStatus::TimedOut,
1403        Some(TestFailure {
1404          message: format!("test timed out after {timeout_dur:?}"),
1405          stack: None,
1406          diff: None,
1407          screenshot,
1408        }),
1409      ),
1410    };
1411
1412    // Read runtime modifiers set by test body (via NAPI TestInfo.skip/fail/slow/setTimeout).
1413    // These are injected into the fixture pool by the NAPI test_fn closure.
1414    if let Some(ref pool) = test_pool {
1415      if let Ok(modifiers) = pool.get::<crate::TestModifiers>("__test_modifiers").await {
1416        if modifiers.expected_failure.load(std::sync::atomic::Ordering::Relaxed) {
1417          expected_status = ExpectedStatus::Fail;
1418        }
1419        // Runtime slow: annotate via test_info for reporters.
1420        if modifiers.slow.load(std::sync::atomic::Ordering::Relaxed) {
1421          test_info.annotate("slow", "test.slow() called at runtime").await;
1422        }
1423        // timeout_override: already elapsed for this attempt, but log for debugging.
1424        if let Ok(guard) = modifiers.timeout_override.lock() {
1425          if let Some(ms) = *guard {
1426            tracing::debug!(target: "ferridriver::worker", "test.setTimeout({ms}ms) called at runtime");
1427          }
1428        }
1429      }
1430    }
1431
1432    // Expected failure inversion (test.fail() annotation OR runtime test.fail()).
1433    let (status, error) = match (&raw_status, &expected_status) {
1434      (TestStatus::Failed | TestStatus::TimedOut, ExpectedStatus::Fail) => (TestStatus::Passed, None),
1435      (TestStatus::Passed, ExpectedStatus::Fail) => (
1436        TestStatus::Failed,
1437        Some(TestFailure {
1438          message: "expected test to fail, but it passed".into(),
1439          stack: None,
1440          diff: None,
1441          screenshot: None,
1442        }),
1443      ),
1444      _ => (raw_status, raw_error),
1445    };
1446
1447    // Collect soft assertion errors.
1448    let soft_errs = test_info.drain_soft_errors().await;
1449    let (status, error) = if !soft_errs.is_empty() && status == TestStatus::Passed {
1450      let msg = soft_errs
1451        .iter()
1452        .map(|e| format!("  - {}", e.message))
1453        .collect::<Vec<_>>()
1454        .join("\n");
1455      (
1456        TestStatus::Failed,
1457        Some(TestFailure {
1458          message: format!("{} soft assertion(s) failed:\n{msg}", soft_errs.len()),
1459          stack: None,
1460          diff: None,
1461          screenshot: None,
1462        }),
1463      )
1464    } else {
1465      (status, error)
1466    };
1467
1468    // Collect tracked test steps and attachments.
1469    let steps = test_info.steps.lock().await.clone();
1470    let info_attachments = test_info.attachments.lock().await.clone();
1471    attachments.extend(info_attachments);
1472
1473    // ── Trace recording ──
1474    // Uses should_write() to skip entirely for RetainOnFailure + passed tests
1475    // (no wasted ZIP write + delete). Serialization happens in-memory (borrows
1476    // steps, zero-copy for titles/errors), file I/O on spawn_blocking.
1477    let trace_mode = self.config.trace;
1478    let test_failed = status == TestStatus::Failed || status == TestStatus::TimedOut;
1479    if trace_mode.should_write(attempt, test_failed) {
1480      let mut recorder = crate::tracing::TraceRecorder::for_steps(&steps);
1481      recorder.record_steps(&steps);
1482      // Serialize to in-memory ZIP bytes (fast, no file I/O).
1483      match recorder.into_zip_bytes() {
1484        Ok(zip_bytes) => {
1485          let trace_path = test_info.output_dir.join(format!(
1486            "{}-attempt{}.trace.zip",
1487            sanitize_filename(&test_id.name),
1488            attempt
1489          ));
1490          // Offload file write to blocking thread so the async worker isn't stalled.
1491          let write_path = trace_path.clone();
1492          let write_result =
1493            tokio::task::spawn_blocking(move || crate::tracing::write_trace_file(&write_path, &zip_bytes)).await;
1494          match write_result {
1495            Ok(Ok(())) => {
1496              attachments.push(Attachment {
1497                name: "trace".into(),
1498                content_type: "application/zip".into(),
1499                body: AttachmentBody::Path(trace_path),
1500              });
1501            },
1502            Ok(Err(e)) => tracing::warn!(target: "ferridriver::worker", "trace write failed: {e}"),
1503            Err(e) => tracing::warn!(target: "ferridriver::worker", "trace task panicked: {e}"),
1504          }
1505        },
1506        Err(e) => tracing::warn!(target: "ferridriver::worker", "trace serialize failed: {e}"),
1507      }
1508    }
1509
1510    // Attach or clean up video recording.
1511    // For buffered mode, video_path is only Some when the test failed (already filtered).
1512    // For eager mode, we keep or delete based on the mode.
1513    if let Some(ref path) = video_path {
1514      let keep = match self.config.video.mode {
1515        crate::config::VideoMode::On => true,
1516        crate::config::VideoMode::RetainOnFailure => true, // buffered mode already filtered
1517        crate::config::VideoMode::Off => false,
1518      };
1519      if keep && path.exists() {
1520        attachments.push(Attachment {
1521          name: "video".into(),
1522          content_type: ferridriver::video::video_content_type().into(),
1523          body: AttachmentBody::Path(path.clone()),
1524        });
1525      } else {
1526        let _ = std::fs::remove_file(path);
1527      }
1528    }
1529
1530    // Merge compile-time annotations with runtime annotations.
1531    let mut annotations = test.annotations.clone();
1532    annotations.extend(test_info.get_annotations().await);
1533
1534    let outcome = TestOutcome {
1535      test_id: test_id.clone(),
1536      status,
1537      duration,
1538      attempt,
1539      max_attempts,
1540      error,
1541      attachments,
1542      steps,
1543      stdout: String::new(),
1544      stderr: String::new(),
1545      annotations,
1546      metadata: self.config.metadata.clone(),
1547    };
1548
1549    self
1550      .event_bus
1551      .emit(ReporterEvent::TestFinished {
1552        test_id: test_id.clone(),
1553        outcome: outcome.clone(),
1554      })
1555      .await;
1556
1557    let should_retry =
1558      outcome.status != TestStatus::Passed && outcome.status != TestStatus::Skipped && attempt < max_attempts;
1559
1560    WorkerTestResult {
1561      outcome,
1562      should_retry,
1563      test_fn,
1564      test_id,
1565      fixture_requests,
1566      suite_key,
1567      hooks,
1568    }
1569  }
1570}
1571
1572/// Sanitize a test name for use as a filename.
1573fn sanitize_filename(name: &str) -> String {
1574  name
1575    .chars()
1576    .map(|c| {
1577      if c.is_alphanumeric() || c == '-' || c == '_' {
1578        c
1579      } else {
1580        '_'
1581      }
1582    })
1583    .collect()
1584}
1585
1586async fn capture_screenshot(page: &ferridriver::Page) -> Option<Vec<u8>> {
1587  let opts = ferridriver::options::ScreenshotOptions {
1588    full_page: Some(true),
1589    format: Some("png".into()),
1590    ..Default::default()
1591  };
1592  page.screenshot(opts).await.ok()
1593}
1594
1595/// Evaluate an annotation condition string against the current environment.
1596///
1597/// Mirrors Playwright's fixture-based condition system. Conditions match against
1598/// the browser config (equivalent to Playwright's `browserName`, `headless`,
1599/// `isMobile`, etc. fixtures from the `use` block).
1600///
1601/// ## Supported conditions
1602///
1603/// **Browser name** (Playwright's `browserName` fixture):
1604/// - `"chromium"`, `"chrome"` — matches browser name "chromium"
1605/// - `"firefox"` — matches browser name "firefox"
1606/// - `"webkit"` — matches browser name "webkit"
1607///
1608/// **Browser channel** (Playwright's `channel` fixture):
1609/// - `"msedge"`, `"chrome-beta"`, `"chrome-canary"`
1610///
1611/// **OS / platform:**
1612/// - `"linux"`, `"macos"` / `"darwin"`, `"windows"` / `"win32"`
1613///
1614/// **Browser mode** (Playwright's `headless` fixture):
1615/// - `"headed"`, `"headless"`
1616///
1617/// **Context options** (Playwright's `use` block fixtures):
1618/// - `"mobile"` — `isMobile` is true
1619/// - `"touch"` — `hasTouch` is true
1620/// - `"dark"` — `colorScheme` is "dark"
1621/// - `"light"` — `colorScheme` is "light"
1622/// - `"offline"` — offline network mode
1623/// - `"bypass-csp"` — CSP bypass enabled
1624///
1625/// **Environment:**
1626/// - `"ci"` — `CI` env var is set
1627/// - `"debug"` — debug build (`cfg!(debug_assertions)`)
1628/// - `"env:VAR_NAME"` — generic env var check, true if set and non-empty
1629///
1630/// **Operators:**
1631/// - `"!condition"` — negation (invert any condition)
1632/// - `"cond1+cond2"` — conjunction (AND), all must match
1633fn evaluate_condition(condition: &str, browser: &crate::config::BrowserConfig) -> bool {
1634  let condition = condition.trim();
1635
1636  // Negation: !condition
1637  if let Some(inner) = condition.strip_prefix('!') {
1638    return !evaluate_condition(inner, browser);
1639  }
1640
1641  // Conjunction: condition1+condition2+...
1642  if condition.contains('+') {
1643    return condition.split('+').all(|part| evaluate_condition(part, browser));
1644  }
1645
1646  match condition {
1647    // OS conditions.
1648    "linux" => cfg!(target_os = "linux"),
1649    "macos" | "darwin" => cfg!(target_os = "macos"),
1650    "windows" | "win32" => cfg!(target_os = "windows"),
1651
1652    // Browser name (Playwright's browserName fixture).
1653    "chromium" | "chrome" => browser.browser == "chromium",
1654    "webkit" => browser.browser == "webkit",
1655    "firefox" => browser.browser == "firefox",
1656
1657    // Browser channel (Playwright's channel fixture).
1658    "msedge" => browser.channel.as_deref() == Some("msedge"),
1659    "chrome-beta" => browser.channel.as_deref() == Some("chrome-beta"),
1660    "chrome-canary" => browser.channel.as_deref() == Some("chrome-canary"),
1661
1662    // Browser mode.
1663    "headed" => !browser.headless,
1664    "headless" => browser.headless,
1665
1666    // Context options (Playwright's use block fixtures).
1667    "mobile" => browser.use_options.is_mobile,
1668    "touch" => browser.use_options.has_touch,
1669    "dark" => browser.use_options.color_scheme.as_deref() == Some("dark"),
1670    "light" => browser.use_options.color_scheme.as_deref() == Some("light"),
1671    "offline" => browser.use_options.offline,
1672    "bypass-csp" => browser.use_options.bypass_csp,
1673
1674    // Environment.
1675    "ci" => std::env::var("CI").is_ok(),
1676    "debug" => cfg!(debug_assertions),
1677
1678    // Generic env var: `env:VAR_NAME` — true if the env var is set and non-empty.
1679    // Example: `@skip(env:SKIP_SLOW_TESTS)`, `#[ferritest(skip = "env:NO_GPU")]`
1680    other if other.starts_with("env:") => {
1681      let var_name = &other[4..];
1682      std::env::var(var_name).is_ok_and(|v| !v.is_empty())
1683    },
1684
1685    // Unknown condition: don't match.
1686    _ => false,
1687  }
1688}