Skip to main content

ferridriver_test/
model.rs

1//! Core test model types: `TestId`, `TestCase`, `TestSuite`, `TestPlan`, `TestOutcome`,
2//! `TestInfo`, `TestStep`, `SuiteMode`.
3
4use std::fmt;
5use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12use tokio::sync::Mutex;
13
14use crate::fixture::FixturePool;
15use crate::reporter::EventBus;
16
17// ── Test Identity ──
18
19/// Globally unique test identifier.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct TestId {
22  pub file: String,
23  pub suite: Option<String>,
24  pub name: String,
25  /// Source line number (used by rerun reporter for `file:line` output).
26  pub line: Option<usize>,
27}
28
29impl TestId {
30  /// Stable full name for display and hashing.
31  #[must_use]
32  pub fn full_name(&self) -> String {
33    match &self.suite {
34      Some(s) => format!("{} > {} > {}", self.file, s, self.name),
35      None => format!("{} > {}", self.file, self.name),
36    }
37  }
38
39  /// File path with optional line number (e.g., `features/login.feature:15`).
40  #[must_use]
41  pub fn file_location(&self) -> String {
42    match self.line {
43      Some(line) => format!("{}:{}", self.file, line),
44      None => self.file.clone(),
45    }
46  }
47}
48
49impl fmt::Display for TestId {
50  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51    f.write_str(&self.full_name())
52  }
53}
54
55// ── Test Function ──
56
57/// The async test body: takes a fixture pool, returns success or failure.
58/// Uses `Arc` so tests can be re-dispatched for retries and repeatEach.
59pub type TestFn =
60  Arc<dyn Fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>> + Send + Sync>;
61
62// ── Test Case ──
63
64/// A single test case with metadata and body.
65#[derive(Clone)]
66pub struct TestCase {
67  pub id: TestId,
68  pub test_fn: TestFn,
69  /// Fixture names this test requests (drives DAG resolution).
70  pub fixture_requests: Vec<String>,
71  /// Annotations: skip, slow, fixme, tags.
72  pub annotations: Vec<TestAnnotation>,
73  /// Per-test timeout override.
74  pub timeout: Option<Duration>,
75  /// Per-test retry override.
76  pub retries: Option<u32>,
77  /// Expected status (for `test.fail()` annotation).
78  pub expected_status: ExpectedStatus,
79  /// Per-test fixture overrides from `test.use()`. Merged with global config by the worker.
80  pub use_options: Option<serde_json::Value>,
81}
82
83// ── Test Suite ──
84
85/// How tests within a suite are scheduled.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum SuiteMode {
88  /// Tests run in parallel (default for fullyParallel, or `test.describe.parallel()`).
89  #[default]
90  Parallel,
91  /// Tests run sequentially in one worker. If one fails, rest are skipped.
92  /// Maps to `test.describe.serial()`.
93  Serial,
94}
95
96/// A group of tests (maps to `test.describe` / `#[cfg(test)] mod`).
97#[derive(Clone)]
98pub struct TestSuite {
99  pub name: String,
100  pub file: String,
101  pub tests: Vec<TestCase>,
102  pub hooks: Hooks,
103  /// Suite-level annotations (applied to all children).
104  pub annotations: Vec<TestAnnotation>,
105  /// Execution mode for this suite.
106  pub mode: SuiteMode,
107}
108
109/// Lifecycle hooks attached to a suite.
110#[derive(Clone)]
111pub struct Hooks {
112  /// Runs once per suite per worker (no test context).
113  pub before_all: Vec<SuiteHookFn>,
114  /// Runs once per suite per worker on teardown (no test context).
115  pub after_all: Vec<SuiteHookFn>,
116  /// Runs before each test (receives test info with tags, name, step API).
117  pub before_each: Vec<HookFn>,
118  /// Runs after each test, even on failure (receives test info).
119  pub after_each: Vec<HookFn>,
120}
121
122impl Default for Hooks {
123  fn default() -> Self {
124    Self {
125      before_all: Vec::new(),
126      after_all: Vec::new(),
127      before_each: Vec::new(),
128      after_each: Vec::new(),
129    }
130  }
131}
132
133/// Suite-scoped hook (before_all / after_all). Receives only the fixture pool.
134/// Runs once per suite per worker, no test context available.
135pub type SuiteHookFn =
136  Arc<dyn Fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>> + Send + Sync>;
137
138/// Test-scoped hook (before_each / after_each). Receives fixture pool + `TestInfo`.
139/// `TestInfo` provides access to test tags, name, step API, and event bus.
140pub type HookFn = Arc<
141  dyn Fn(FixturePool, Arc<TestInfo>) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>> + Send + Sync,
142>;
143
144/// Programmatic suite-level hooks supplied by the test author at runtime.
145///
146/// Lives separately from `TestConfig` because the closure types close over
147/// runtime fixture and failure values defined in this crate, which cannot be
148/// expressed in the data-only `ferridriver-config` schema.
149#[derive(Clone, Default)]
150pub struct TestHooks {
151  /// Hooks invoked once before any tests run, per worker.
152  pub global_setup_fns: Vec<SuiteHookFn>,
153  /// Hooks invoked once after all tests finish, per worker.
154  pub global_teardown_fns: Vec<SuiteHookFn>,
155}
156
157impl std::fmt::Debug for TestHooks {
158  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159    f.debug_struct("TestHooks")
160      .field("global_setup_fns", &format!("[{} fn(s)]", self.global_setup_fns.len()))
161      .field(
162        "global_teardown_fns",
163        &format!("[{} fn(s)]", self.global_teardown_fns.len()),
164      )
165      .finish()
166  }
167}
168
169// ── Test Plan ──
170
171/// The full test plan after discovery + filtering + sharding.
172#[derive(Clone)]
173pub struct TestPlan {
174  pub suites: Vec<TestSuite>,
175  /// Total test count (after filtering, before retry expansion).
176  pub total_tests: usize,
177  /// Shard info if sharding is active.
178  pub shard: Option<ShardInfo>,
179}
180
181#[derive(Debug, Clone)]
182pub struct ShardInfo {
183  pub current: u32,
184  pub total: u32,
185}
186
187// ── Plan Builder ──
188
189/// Suite metadata for plan building.
190pub struct SuiteDef {
191  /// Suite ID (e.g. `"file::SuiteName"`). Must match `TestCase.id.suite`.
192  pub id: String,
193  pub name: String,
194  pub file: String,
195  pub mode: SuiteMode,
196}
197
198/// Hook registration for plan building.
199pub struct HookDef {
200  /// Suite ID this hook belongs to. Empty string = root/default suite.
201  pub suite_id: String,
202  pub kind: HookKind,
203}
204
205/// Generic lifecycle phase shared across all front-end hook syntaxes.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum HookPhase {
208  Before,
209  After,
210}
211
212/// Generic lifecycle scope shared across E2E and BDD hooks.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum HookScope {
215  Suite,
216  Scenario,
217  Step,
218}
219
220/// Where a hook attaches in the runner model.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum HookOwner {
223  Root,
224  Suite(String),
225}
226
227/// Unified hook registration metadata used by adapters before execution hooks are built.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct HookRegistration {
230  pub phase: HookPhase,
231  pub scope: HookScope,
232  pub owner: HookOwner,
233  pub tags: Option<String>,
234  pub requested_fixtures: Vec<String>,
235}
236
237/// Hook kind with the associated callback.
238pub enum HookKind {
239  BeforeAll(SuiteHookFn),
240  AfterAll(SuiteHookFn),
241  BeforeEach(HookFn),
242  AfterEach(HookFn),
243}
244
245/// Builds a `TestPlan` from flat test cases, suite definitions, and hooks.
246///
247/// Groups tests by `TestCase.id.suite`, attaches hooks to matching suites,
248/// and respects suite mode (parallel/serial). This is the single place
249/// where suite→test→hook association happens — callers (NAPI, CLI, macros)
250/// just register flat data.
251pub struct TestPlanBuilder {
252  tests: Vec<TestCase>,
253  suites: Vec<SuiteDef>,
254  hooks: Vec<HookDef>,
255}
256
257impl Default for TestPlanBuilder {
258  fn default() -> Self {
259    Self::new()
260  }
261}
262
263impl TestPlanBuilder {
264  pub fn new() -> Self {
265    Self {
266      tests: Vec::new(),
267      suites: Vec::new(),
268      hooks: Vec::new(),
269    }
270  }
271
272  pub fn add_test(&mut self, test: TestCase) {
273    self.tests.push(test);
274  }
275
276  pub fn add_suite(&mut self, suite: SuiteDef) {
277    self.suites.push(suite);
278  }
279
280  pub fn add_hook(&mut self, hook: HookDef) {
281    self.hooks.push(hook);
282  }
283
284  /// Consume the builder and produce a `TestPlan`.
285  ///
286  /// Tests are grouped by `id.suite` (matching `SuiteDef.id`).
287  /// Tests without a suite go into a default parallel suite.
288  /// Hooks are attached to their matching suite by `suite_id`.
289  pub fn build(self) -> TestPlan {
290    use rustc_hash::FxHashMap;
291
292    // Index suite metadata by ID.
293    let suite_meta: FxHashMap<String, (String, String, SuiteMode)> = self
294      .suites
295      .into_iter()
296      .map(|s| (s.id, (s.name, s.file, s.mode)))
297      .collect();
298
299    // Group tests by suite key.
300    let mut grouped: FxHashMap<String, Vec<TestCase>> = FxHashMap::default();
301    for tc in self.tests {
302      let key = tc.id.suite.clone().unwrap_or_default();
303      grouped.entry(key).or_default().push(tc);
304    }
305
306    // Build hooks per suite.
307    let mut hook_map: FxHashMap<String, Hooks> = FxHashMap::default();
308    for h in self.hooks {
309      let hooks = hook_map.entry(h.suite_id).or_default();
310      match h.kind {
311        HookKind::BeforeAll(f) => hooks.before_all.push(f),
312        HookKind::AfterAll(f) => hooks.after_all.push(f),
313        HookKind::BeforeEach(f) => hooks.before_each.push(f),
314        HookKind::AfterEach(f) => hooks.after_each.push(f),
315      }
316    }
317
318    // Assemble suites.
319    let mut plan_suites: Vec<TestSuite> = Vec::new();
320    let mut total = 0usize;
321
322    for (suite_key, tests) in grouped {
323      total += tests.len();
324      let (name, file, mode) = if suite_key.is_empty() {
325        ("tests".to_string(), String::new(), SuiteMode::Parallel)
326      } else if let Some((n, f, m)) = suite_meta.get(&suite_key) {
327        (n.clone(), f.clone(), *m)
328      } else {
329        // Suite ID exists on tests but no SuiteDef was registered — use defaults.
330        (suite_key.clone(), String::new(), SuiteMode::Parallel)
331      };
332      let hooks = hook_map.remove(&suite_key).unwrap_or_default();
333      plan_suites.push(TestSuite {
334        name,
335        file,
336        tests,
337        hooks,
338        annotations: Vec::new(),
339        mode,
340      });
341    }
342
343    TestPlan {
344      suites: plan_suites,
345      total_tests: total,
346      shard: None,
347    }
348  }
349}
350
351// ── Test Info (runtime context available during test execution) ──
352
353/// Runtime test information accessible during test execution.
354/// Mirrors Playwright's `TestInfo` interface.
355#[derive(Clone)]
356pub struct TestInfo {
357  /// Test ID.
358  pub test_id: TestId,
359  /// Title path: ["suite", "subsuite", "test name"].
360  pub title_path: Vec<String>,
361  /// Current retry attempt (0-indexed).
362  pub retry: u32,
363  /// Worker index (0-based).
364  pub worker_index: u32,
365  /// Parallel index (same as worker_index for now).
366  pub parallel_index: u32,
367  /// repeatEach index (0-based).
368  pub repeat_each_index: u32,
369  /// Output directory for this test's artifacts.
370  pub output_dir: PathBuf,
371  /// Snapshot directory for this test.
372  pub snapshot_dir: PathBuf,
373  /// Snapshot path template (e.g. `{testDir}/__snapshots__/{testFilePath}/{arg}{ext}`).
374  pub snapshot_path_template: Option<String>,
375  /// Snapshot update mode.
376  pub update_snapshots: crate::config::UpdateSnapshotsMode,
377  /// When true, every snapshot comparison short-circuits to a pass.
378  /// Mirrors Playwright's `--ignore-snapshots` CLI flag.
379  pub ignore_snapshots: bool,
380  /// Collected attachments.
381  pub attachments: Arc<Mutex<Vec<Attachment>>>,
382  /// Collected test steps.
383  pub steps: Arc<Mutex<Vec<TestStep>>>,
384  /// Soft assertion errors (collected, not thrown).
385  pub soft_errors: Arc<Mutex<Vec<TestFailure>>>,
386  /// Hard errors collected during test execution. The worker pushes
387  /// the primary failure here after the test body returns; afterEach
388  /// hooks observe the full list. Mirrors Playwright's
389  /// `testInfo.errors`.
390  pub errors: Arc<Mutex<Vec<TestFailure>>>,
391  /// Optional suffix used to differentiate snapshot files between
392  /// configurations. Mirrors Playwright's `testInfo.snapshotSuffix`.
393  pub snapshot_suffix: Arc<Mutex<String>>,
394  /// Source column number where the test is declared. The TS / Rust
395  /// discovery layers don't parse columns yet, so this is `None` in
396  /// practice; surfaced for parity with Playwright's
397  /// `testInfo.column`.
398  pub column: Option<u32>,
399  /// Snapshot of the project entry the test belongs to. Each test in
400  /// a multi-project run sees its own project; single-project runs
401  /// see `None` since there's no per-project context.
402  pub project: Option<crate::config::ProjectConfig>,
403  /// Snapshot of the active `TestConfig`. Cloned at test-info
404  /// construction time so the `testInfo.config` accessor is cheap.
405  pub config_snapshot: Option<Arc<crate::config::TestConfig>>,
406  /// Test timeout.
407  pub timeout: Duration,
408  /// Tags from annotations.
409  pub tags: Vec<String>,
410  /// Test start time.
411  pub start_time: Instant,
412  /// Event bus for real-time step event emission (set by worker).
413  pub event_bus: Option<EventBus>,
414  /// Runtime annotations added via `test_info.annotate()`.
415  pub annotations: Arc<Mutex<Vec<TestAnnotation>>>,
416}
417
418impl TestInfo {
419  /// Create a minimal TestInfo for non-test-runner contexts (MCP, standalone).
420  pub fn new_anonymous() -> Self {
421    Self {
422      test_id: TestId {
423        file: String::new(),
424        suite: None,
425        name: "anonymous".into(),
426        line: None,
427      },
428      title_path: Vec::new(),
429      retry: 0,
430      worker_index: 0,
431      parallel_index: 0,
432      repeat_each_index: 0,
433      output_dir: PathBuf::new(),
434      snapshot_dir: PathBuf::new(),
435      snapshot_path_template: None,
436      update_snapshots: crate::config::UpdateSnapshotsMode::default(),
437      ignore_snapshots: false,
438      attachments: Arc::new(Mutex::new(Vec::new())),
439      steps: Arc::new(Mutex::new(Vec::new())),
440      soft_errors: Arc::new(Mutex::new(Vec::new())),
441      errors: Arc::new(Mutex::new(Vec::new())),
442      snapshot_suffix: Arc::new(Mutex::new(String::new())),
443      column: None,
444      project: None,
445      config_snapshot: None,
446      timeout: Duration::from_secs(30),
447      tags: Vec::new(),
448      start_time: Instant::now(),
449      event_bus: None,
450      annotations: Arc::new(Mutex::new(Vec::new())),
451    }
452  }
453
454  /// Add a structured annotation at runtime.
455  pub async fn annotate(&self, type_name: impl Into<String>, description: impl Into<String>) {
456    let mut annotations = self.annotations.lock().await;
457    annotations.push(TestAnnotation::Info {
458      type_name: type_name.into(),
459      description: description.into(),
460    });
461  }
462
463  /// Get all runtime annotations.
464  pub async fn get_annotations(&self) -> Vec<TestAnnotation> {
465    let annotations = self.annotations.lock().await;
466    annotations.clone()
467  }
468  /// Add an attachment to this test.
469  pub async fn attach(&self, name: String, content_type: String, body: AttachmentBody) {
470    let mut attachments = self.attachments.lock().await;
471    attachments.push(Attachment {
472      name,
473      content_type,
474      body,
475    });
476  }
477
478  /// Record a soft assertion error (test continues, fails at end).
479  pub async fn add_soft_error(&self, error: TestFailure) {
480    let mut errors = self.soft_errors.lock().await;
481    errors.push(error);
482  }
483
484  /// Check if any soft errors have been collected.
485  pub async fn has_soft_errors(&self) -> bool {
486    let errors = self.soft_errors.lock().await;
487    !errors.is_empty()
488  }
489
490  /// Drain all soft errors for final reporting.
491  pub async fn drain_soft_errors(&self) -> Vec<TestFailure> {
492    let mut errors = self.soft_errors.lock().await;
493    errors.drain(..).collect()
494  }
495
496  /// Record a test step.
497  pub async fn push_step(&self, step: TestStep) {
498    let mut steps = self.steps.lock().await;
499    steps.push(step);
500  }
501
502  /// Get elapsed time since test start.
503  pub fn elapsed(&self) -> Duration {
504    self.start_time.elapsed()
505  }
506
507  /// Begin a new step with real-time event emission.
508  ///
509  /// Returns a `StepHandle` that must be completed via `handle.end()`.
510  /// Emits `ReporterEvent::StepStarted` immediately if an event bus is available.
511  pub async fn begin_step(&self, title: impl Into<String>, category: StepCategory) -> StepHandle {
512    let title = title.into();
513    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
514
515    if let Some(bus) = &self.event_bus {
516      bus
517        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
518          crate::reporter::StepStartedEvent {
519            test_id: self.test_id.clone(),
520            step_id: step_id.clone(),
521            parent_step_id: None,
522            title: title.clone(),
523            category: category.clone(),
524          },
525        )))
526        .await;
527    }
528
529    StepHandle {
530      step_id,
531      test_id: self.test_id.clone(),
532      title,
533      category,
534      parent_step_id: None,
535      start: Instant::now(),
536      metadata: None,
537      event_bus: self.event_bus.clone(),
538      steps: Arc::clone(&self.steps),
539    }
540  }
541
542  /// Begin a nested step (child of a parent step).
543  pub async fn begin_child_step(
544    &self,
545    title: impl Into<String>,
546    category: StepCategory,
547    parent_step_id: &str,
548  ) -> StepHandle {
549    let title = title.into();
550    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
551
552    if let Some(bus) = &self.event_bus {
553      bus
554        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
555          crate::reporter::StepStartedEvent {
556            test_id: self.test_id.clone(),
557            step_id: step_id.clone(),
558            parent_step_id: Some(parent_step_id.to_string()),
559            title: title.clone(),
560            category: category.clone(),
561          },
562        )))
563        .await;
564    }
565
566    StepHandle {
567      step_id,
568      test_id: self.test_id.clone(),
569      title,
570      category,
571      parent_step_id: Some(parent_step_id.to_string()),
572      start: Instant::now(),
573      metadata: None,
574      event_bus: self.event_bus.clone(),
575      steps: Arc::clone(&self.steps),
576    }
577  }
578
579  /// Record a step that already executed elsewhere but still needs to flow
580  /// through reporter events and the stored step tree.
581  pub async fn record_step(
582    &self,
583    title: impl Into<String>,
584    category: StepCategory,
585    status: StepStatus,
586    duration: Duration,
587    error: Option<String>,
588    metadata: Option<serde_json::Value>,
589  ) {
590    let title = title.into();
591    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
592
593    if let Some(bus) = &self.event_bus {
594      bus
595        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
596          crate::reporter::StepStartedEvent {
597            test_id: self.test_id.clone(),
598            step_id: step_id.clone(),
599            parent_step_id: None,
600            title: title.clone(),
601            category: category.clone(),
602          },
603        )))
604        .await;
605      bus
606        .emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
607          crate::reporter::StepFinishedEvent {
608            test_id: self.test_id.clone(),
609            step_id: step_id.clone(),
610            title: title.clone(),
611            category: category.clone(),
612            duration,
613            error: error.clone(),
614            metadata: metadata.clone(),
615          },
616        )))
617        .await;
618    }
619
620    self.steps.lock().await.push(TestStep {
621      step_id,
622      title,
623      category,
624      duration,
625      status,
626      error,
627      location: None,
628      parent_step_id: None,
629      metadata,
630      steps: Vec::new(),
631    });
632  }
633}
634
635/// Global step ID counter for unique step identification.
636static STEP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
637
638/// Handle to an in-progress step. Must be completed via `end()`.
639///
640/// On `end()`:
641/// - Emits `ReporterEvent::StepFinished` for real-time reporting
642/// - Pushes a `TestStep` to the test's step list for batch reporting
643pub struct StepHandle {
644  pub step_id: String,
645  pub test_id: TestId,
646  pub title: String,
647  pub category: StepCategory,
648  pub parent_step_id: Option<String>,
649  pub start: Instant,
650  /// Arbitrary metadata attached to this step (set before calling `end()`).
651  pub metadata: Option<serde_json::Value>,
652  event_bus: Option<EventBus>,
653  steps: Arc<Mutex<Vec<TestStep>>>,
654}
655
656impl StepHandle {
657  /// Complete this step. Pass `None` for success, `Some(msg)` for failure.
658  pub async fn end(self, error: Option<String>) {
659    let duration = self.start.elapsed();
660    let status = if error.is_some() {
661      StepStatus::Failed
662    } else {
663      StepStatus::Passed
664    };
665
666    // Emit real-time event.
667    if let Some(bus) = &self.event_bus {
668      bus
669        .emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
670          crate::reporter::StepFinishedEvent {
671            test_id: self.test_id.clone(),
672            step_id: self.step_id.clone(),
673            title: self.title.clone(),
674            category: self.category.clone(),
675            duration,
676            error: error.clone(),
677            metadata: self.metadata.clone(),
678          },
679        )))
680        .await;
681    }
682
683    // Push to batch step list (for TestOutcome.steps).
684    let step = TestStep {
685      step_id: self.step_id,
686      title: self.title,
687      category: self.category,
688      duration,
689      status,
690      error,
691      location: None,
692      parent_step_id: self.parent_step_id,
693      metadata: self.metadata.clone(),
694      steps: Vec::new(),
695    };
696    self.steps.lock().await.push(step);
697  }
698
699  /// Complete this step as skipped.
700  pub async fn skip(self, reason: Option<String>) {
701    self.finish_with_status(StepStatus::Skipped, reason).await;
702  }
703
704  /// Complete this step as pending (not yet implemented).
705  pub async fn pending(self, reason: Option<String>) {
706    self.finish_with_status(StepStatus::Pending, reason).await;
707  }
708
709  async fn finish_with_status(self, status: StepStatus, error: Option<String>) {
710    let duration = self.start.elapsed();
711
712    if let Some(bus) = &self.event_bus {
713      bus
714        .emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
715          crate::reporter::StepFinishedEvent {
716            test_id: self.test_id.clone(),
717            step_id: self.step_id.clone(),
718            title: self.title.clone(),
719            category: self.category.clone(),
720            duration,
721            error: error.clone(),
722            metadata: self.metadata.clone(),
723          },
724        )))
725        .await;
726    }
727
728    let step = TestStep {
729      step_id: self.step_id,
730      title: self.title,
731      category: self.category,
732      duration,
733      status,
734      error,
735      location: None,
736      parent_step_id: self.parent_step_id,
737      metadata: self.metadata,
738      steps: Vec::new(),
739    };
740    self.steps.lock().await.push(step);
741  }
742}
743
744// ── Test Step ──
745
746/// A structured test step (maps to Playwright's `test.step()`).
747#[derive(Debug, Clone)]
748pub struct TestStep {
749  /// Unique step identifier (for parent/child tracking and reporter correlation).
750  pub step_id: String,
751  pub title: String,
752  pub category: StepCategory,
753  pub duration: Duration,
754  /// Step completion status.
755  pub status: StepStatus,
756  pub error: Option<String>,
757  /// Source location (e.g., "file.rs:42" or "feature.feature:10").
758  pub location: Option<String>,
759  /// Parent step ID for nesting.
760  pub parent_step_id: Option<String>,
761  /// Arbitrary metadata for domain-specific extensions (e.g., BDD keyword, tags).
762  /// Reporters can use this for custom rendering without the core needing domain knowledge.
763  pub metadata: Option<serde_json::Value>,
764  pub steps: Vec<TestStep>,
765}
766
767/// Status of a completed test step.
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769pub enum StepStatus {
770  Passed,
771  Failed,
772  Skipped,
773  /// Step exists but is not yet implemented.
774  Pending,
775}
776
777/// Category of a test step.
778#[derive(Debug, Clone, PartialEq, Eq)]
779pub enum StepCategory {
780  /// User-defined step via test.step().
781  TestStep,
782  /// Expect assertion.
783  Expect,
784  /// Fixture setup/teardown.
785  Fixture,
786  /// Hook execution.
787  Hook,
788  /// Playwright API call.
789  PwApi,
790}
791
792impl StepCategory {
793  /// Whether this step category is visible in standard reporter output.
794  /// TestStep and Hook are always shown. Expect, Fixture, PwApi are hidden
795  /// unless verbose mode is enabled.
796  pub fn is_visible(&self) -> bool {
797    matches!(self, Self::TestStep | Self::Hook)
798  }
799}
800
801impl fmt::Display for StepCategory {
802  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
803    match self {
804      Self::TestStep => write!(f, "test.step"),
805      Self::Expect => write!(f, "expect"),
806      Self::Fixture => write!(f, "fixture"),
807      Self::Hook => write!(f, "hook"),
808      Self::PwApi => write!(f, "pw:api"),
809    }
810  }
811}
812
813// ── Annotations ──
814
815#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
816#[serde(rename_all = "lowercase")]
817pub enum TestAnnotation {
818  /// Skip this test. Optional condition: `"firefox"`, `"chromium"`, `"linux"`, `"ci"`, `"!webkit"`.
819  /// When condition is None, always skips. When condition is Some, skips only if condition matches.
820  Skip {
821    reason: Option<String>,
822    condition: Option<String>,
823  },
824  /// Triple the timeout for this test (×3). Optional condition + description.
825  /// Matches Playwright's `test.slow()` / `test.slow(condition, description)`.
826  Slow {
827    reason: Option<String>,
828    condition: Option<String>,
829  },
830  /// Known bug — skip with intent to fix. Same condition semantics as Skip.
831  /// Matches Playwright's `test.fixme()` / `test.fixme(condition, description)`.
832  Fixme {
833    reason: Option<String>,
834    condition: Option<String>,
835  },
836  /// Expect this test to fail (inverts pass/fail). Optional condition + description.
837  /// Matches Playwright's `test.fail()` / `test.fail(condition, description)`.
838  Fail {
839    reason: Option<String>,
840    condition: Option<String>,
841  },
842  Only,
843  Tag(String),
844  /// Structured metadata: type + description (e.g., issue/JIRA-1234, severity/critical).
845  Info {
846    type_name: String,
847    description: String,
848  },
849}
850
851#[derive(Debug, Clone, Default, PartialEq, Eq)]
852pub enum ExpectedStatus {
853  #[default]
854  Pass,
855  Fail,
856}
857
858// ── Runtime Modifiers (shared between JS test body and Rust worker) ──
859
860/// Runtime test modifiers set by `test.skip()`, `test.fail()`, `test.slow()` inside
861/// a test body. Shared via `Arc` between the NAPI layer (JS thread writes) and the
862/// Rust worker (reads after callback returns).
863///
864/// Uses atomics and `std::sync::Mutex` for cross-thread safety. No actual race —
865/// the worker reads strictly after the TSFN callback completes.
866pub struct TestModifiers {
867  /// Set by `test.skip()` / `test.fixme()` inside test body.
868  pub skipped: AtomicBool,
869  /// Reason for runtime skip.
870  pub skip_reason: std::sync::Mutex<Option<String>>,
871  /// Set by `test.fail()` inside test body — inverts pass/fail.
872  pub expected_failure: AtomicBool,
873  /// Set by `test.slow()` inside test body.
874  pub slow: AtomicBool,
875  /// Set by `testInfo.setTimeout()` inside test body.
876  pub timeout_override: std::sync::Mutex<Option<u64>>,
877}
878
879impl Default for TestModifiers {
880  fn default() -> Self {
881    Self {
882      skipped: AtomicBool::new(false),
883      skip_reason: std::sync::Mutex::new(None),
884      expected_failure: AtomicBool::new(false),
885      slow: AtomicBool::new(false),
886      timeout_override: std::sync::Mutex::new(None),
887    }
888  }
889}
890
891impl std::fmt::Debug for TestModifiers {
892  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
893    f.debug_struct("TestModifiers")
894      .field("skipped", &self.skipped.load(Ordering::Relaxed))
895      .field("expected_failure", &self.expected_failure.load(Ordering::Relaxed))
896      .field("slow", &self.slow.load(Ordering::Relaxed))
897      .finish_non_exhaustive()
898  }
899}
900
901// ── Outcome ──
902
903/// Status of a completed test.
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub enum TestStatus {
906  Passed,
907  Failed,
908  TimedOut,
909  Skipped,
910  /// Passed on retry (flaky).
911  Flaky,
912  /// Interrupted by signal/cancellation.
913  Interrupted,
914}
915
916impl fmt::Display for TestStatus {
917  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
918    match self {
919      Self::Passed => write!(f, "passed"),
920      Self::Failed => write!(f, "failed"),
921      Self::TimedOut => write!(f, "timed out"),
922      Self::Skipped => write!(f, "skipped"),
923      Self::Flaky => write!(f, "flaky"),
924      Self::Interrupted => write!(f, "interrupted"),
925    }
926  }
927}
928
929/// Result of a single test attempt.
930#[derive(Debug, Clone)]
931pub struct TestOutcome {
932  pub test_id: TestId,
933  pub status: TestStatus,
934  pub duration: Duration,
935  pub attempt: u32,
936  pub max_attempts: u32,
937  pub error: Option<TestFailure>,
938  pub attachments: Vec<Attachment>,
939  pub steps: Vec<TestStep>,
940  pub stdout: String,
941  pub stderr: String,
942  /// Annotations from the test definition + runtime (tags, severity, issues, etc.).
943  pub annotations: Vec<TestAnnotation>,
944  /// Project/run metadata (from config). Available to reporters for JSON/HTML output.
945  pub metadata: serde_json::Value,
946}
947
948/// A test failure with diagnostic information.
949#[derive(Debug, Clone)]
950pub struct TestFailure {
951  pub message: String,
952  pub stack: Option<String>,
953  pub diff: Option<String>,
954  /// Screenshot on failure (auto-captured).
955  pub screenshot: Option<Vec<u8>>,
956}
957
958impl fmt::Display for TestFailure {
959  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
960    write!(f, "{}", self.message)?;
961    if let Some(diff) = &self.diff {
962      write!(f, "\n{diff}")?;
963    }
964    Ok(())
965  }
966}
967
968impl std::error::Error for TestFailure {}
969
970impl TestFailure {
971  /// Wrap a [`ferridriver::FerriError`] with a contextual prefix while
972  /// preserving the Playwright-style typed class name. The resulting
973  /// message reads `"<prefix>: <Name>: <message>"` for distinguishable
974  /// variants and `"<prefix>: <message>"` for unnamed ones, so consumers
975  /// that match on `TimeoutError:` still see the marker after the prefix.
976  #[must_use]
977  pub fn wrap(prefix: impl std::fmt::Display, err: ferridriver::FerriError) -> Self {
978    Self {
979      message: format!("{prefix}: {}", err.display_named()),
980      stack: None,
981      diff: None,
982      screenshot: None,
983    }
984  }
985}
986
987/// Legacy bridge: kept so test bodies that hand-build a `String` error
988/// (logging helpers, manual panic messages) keep `?`-propagating through
989/// `TestFailure`. Locator methods now return `Result<T, FerriError>` and
990/// flow through the dedicated [`From<ferridriver::FerriError>`] impl below.
991impl From<String> for TestFailure {
992  fn from(message: String) -> Self {
993    Self {
994      message,
995      stack: None,
996      diff: None,
997      screenshot: None,
998    }
999  }
1000}
1001
1002impl From<&str> for TestFailure {
1003  fn from(message: &str) -> Self {
1004    Self::from(message.to_string())
1005  }
1006}
1007
1008/// Enables `?` on any `Result<T, FerriError>` inside test functions after
1009/// the migration to structured errors. Prepends the typed class name for
1010/// the variants Playwright distinguishes (`TimeoutError` / `TargetClosedError`)
1011/// so the TS bridge can re-hydrate a real class instance from the
1012/// `<Name>: <message>` shape — same convention `ferridriver-node::error::to_napi`
1013/// uses on the NAPI surface. Unnamed variants pass through verbatim.
1014impl From<ferridriver::FerriError> for TestFailure {
1015  fn from(err: ferridriver::FerriError) -> Self {
1016    Self {
1017      message: err.display_named(),
1018      stack: None,
1019      diff: None,
1020      screenshot: None,
1021    }
1022  }
1023}
1024
1025/// An artifact attached to a test result.
1026#[derive(Debug, Clone)]
1027pub struct Attachment {
1028  pub name: String,
1029  pub content_type: String,
1030  pub body: AttachmentBody,
1031}
1032
1033#[derive(Debug, Clone)]
1034pub enum AttachmentBody {
1035  Bytes(Vec<u8>),
1036  Path(PathBuf),
1037}
1038
1039// ── Unified Fixtures ──
1040
1041/// Unified fixture bag for test/step/hook callbacks.
1042///
1043/// E2E tests and hooks get browser/page/context/request/testInfo.
1044/// BDD steps additionally get args/data_table/doc_string.
1045/// BDD hooks get the E2E fields with BDD fields as None.
1046#[derive(Clone)]
1047pub struct TestFixtures {
1048  pub browser: Arc<ferridriver::Browser>,
1049  pub page: Arc<ferridriver::Page>,
1050  pub context: Arc<ferridriver::context::ContextRef>,
1051  pub request: Arc<ferridriver::http_client::HttpClient>,
1052  pub test_info: Arc<TestInfo>,
1053  pub modifiers: Arc<TestModifiers>,
1054  pub browser_config: crate::config::BrowserConfig,
1055  // BDD fields (None for E2E tests/hooks)
1056  pub bdd_args: Option<Vec<serde_json::Value>>,
1057  pub bdd_data_table: Option<Vec<Vec<String>>>,
1058  pub bdd_doc_string: Option<String>,
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063  use super::*;
1064  use ferridriver::FerriError;
1065
1066  #[test]
1067  fn testfailure_from_timeout_keeps_class_prefix() {
1068    let tf = TestFailure::from(FerriError::timeout("navigating", 30_000));
1069    assert_eq!(tf.message, "TimeoutError: Timeout 30000ms exceeded while navigating");
1070  }
1071
1072  #[test]
1073  fn testfailure_from_target_closed_keeps_class_prefix() {
1074    let tf = TestFailure::from(FerriError::target_closed(Some("crashed".into())));
1075    assert_eq!(
1076      tf.message,
1077      "TargetClosedError: Target page, context or browser has been closed: crashed"
1078    );
1079  }
1080
1081  #[test]
1082  fn testfailure_from_backend_has_no_prefix() {
1083    let tf = TestFailure::from(FerriError::backend("launch failed"));
1084    assert_eq!(tf.message, "backend error: launch failed");
1085  }
1086
1087  #[test]
1088  fn testfailure_wrap_preserves_timeout_class_after_prefix() {
1089    let tf = TestFailure::wrap("fixture 'browser' failed", FerriError::timeout("launch", 30_000));
1090    assert_eq!(
1091      tf.message,
1092      "fixture 'browser' failed: TimeoutError: Timeout 30000ms exceeded while launch"
1093    );
1094  }
1095
1096  #[test]
1097  fn testfailure_wrap_unnamed_keeps_message_only() {
1098    let tf = TestFailure::wrap("fixture 'page' failed", FerriError::backend("oops"));
1099    assert_eq!(tf.message, "fixture 'page' failed: backend error: oops");
1100  }
1101}