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.emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
517        crate::reporter::StepStartedEvent {
518          test_id: self.test_id.clone(),
519          step_id: step_id.clone(),
520          parent_step_id: None,
521          title: title.clone(),
522          category: category.clone(),
523        },
524      )));
525    }
526
527    StepHandle {
528      step_id,
529      test_id: self.test_id.clone(),
530      title,
531      category,
532      parent_step_id: None,
533      start: Instant::now(),
534      metadata: None,
535      event_bus: self.event_bus.clone(),
536      steps: Arc::clone(&self.steps),
537    }
538  }
539
540  /// Begin a nested step (child of a parent step).
541  pub async fn begin_child_step(
542    &self,
543    title: impl Into<String>,
544    category: StepCategory,
545    parent_step_id: &str,
546  ) -> StepHandle {
547    let title = title.into();
548    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
549
550    if let Some(bus) = &self.event_bus {
551      bus.emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
552        crate::reporter::StepStartedEvent {
553          test_id: self.test_id.clone(),
554          step_id: step_id.clone(),
555          parent_step_id: Some(parent_step_id.to_string()),
556          title: title.clone(),
557          category: category.clone(),
558        },
559      )));
560    }
561
562    StepHandle {
563      step_id,
564      test_id: self.test_id.clone(),
565      title,
566      category,
567      parent_step_id: Some(parent_step_id.to_string()),
568      start: Instant::now(),
569      metadata: None,
570      event_bus: self.event_bus.clone(),
571      steps: Arc::clone(&self.steps),
572    }
573  }
574
575  /// Record a step that already executed elsewhere but still needs to flow
576  /// through reporter events and the stored step tree.
577  pub async fn record_step(
578    &self,
579    title: impl Into<String>,
580    category: StepCategory,
581    status: StepStatus,
582    duration: Duration,
583    error: Option<String>,
584    metadata: Option<serde_json::Value>,
585  ) {
586    let title = title.into();
587    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
588
589    if let Some(bus) = &self.event_bus {
590      bus.emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
591        crate::reporter::StepStartedEvent {
592          test_id: self.test_id.clone(),
593          step_id: step_id.clone(),
594          parent_step_id: None,
595          title: title.clone(),
596          category: category.clone(),
597        },
598      )));
599      bus.emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
600        crate::reporter::StepFinishedEvent {
601          test_id: self.test_id.clone(),
602          step_id: step_id.clone(),
603          title: title.clone(),
604          category: category.clone(),
605          duration,
606          error: error.clone(),
607          metadata: metadata.clone(),
608        },
609      )));
610    }
611
612    self.steps.lock().await.push(TestStep {
613      step_id,
614      title,
615      category,
616      duration,
617      status,
618      error,
619      location: None,
620      parent_step_id: None,
621      metadata,
622      steps: Vec::new(),
623    });
624  }
625}
626
627/// Global step ID counter for unique step identification.
628static STEP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
629
630/// Handle to an in-progress step. Must be completed via `end()`.
631///
632/// On `end()`:
633/// - Emits `ReporterEvent::StepFinished` for real-time reporting
634/// - Pushes a `TestStep` to the test's step list for batch reporting
635pub struct StepHandle {
636  pub step_id: String,
637  pub test_id: TestId,
638  pub title: String,
639  pub category: StepCategory,
640  pub parent_step_id: Option<String>,
641  pub start: Instant,
642  /// Arbitrary metadata attached to this step (set before calling `end()`).
643  pub metadata: Option<serde_json::Value>,
644  event_bus: Option<EventBus>,
645  steps: Arc<Mutex<Vec<TestStep>>>,
646}
647
648impl StepHandle {
649  /// Complete this step. Pass `None` for success, `Some(msg)` for failure.
650  pub async fn end(self, error: Option<String>) {
651    let duration = self.start.elapsed();
652    let status = if error.is_some() {
653      StepStatus::Failed
654    } else {
655      StepStatus::Passed
656    };
657
658    // Emit real-time event.
659    if let Some(bus) = &self.event_bus {
660      bus.emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
661        crate::reporter::StepFinishedEvent {
662          test_id: self.test_id.clone(),
663          step_id: self.step_id.clone(),
664          title: self.title.clone(),
665          category: self.category.clone(),
666          duration,
667          error: error.clone(),
668          metadata: self.metadata.clone(),
669        },
670      )));
671    }
672
673    // Push to batch step list (for TestOutcome.steps).
674    let step = TestStep {
675      step_id: self.step_id,
676      title: self.title,
677      category: self.category,
678      duration,
679      status,
680      error,
681      location: None,
682      parent_step_id: self.parent_step_id,
683      metadata: self.metadata.clone(),
684      steps: Vec::new(),
685    };
686    self.steps.lock().await.push(step);
687  }
688
689  /// Complete this step as skipped.
690  pub async fn skip(self, reason: Option<String>) {
691    self.finish_with_status(StepStatus::Skipped, reason).await;
692  }
693
694  /// Complete this step as pending (not yet implemented).
695  pub async fn pending(self, reason: Option<String>) {
696    self.finish_with_status(StepStatus::Pending, reason).await;
697  }
698
699  async fn finish_with_status(self, status: StepStatus, error: Option<String>) {
700    let duration = self.start.elapsed();
701
702    if let Some(bus) = &self.event_bus {
703      bus.emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
704        crate::reporter::StepFinishedEvent {
705          test_id: self.test_id.clone(),
706          step_id: self.step_id.clone(),
707          title: self.title.clone(),
708          category: self.category.clone(),
709          duration,
710          error: error.clone(),
711          metadata: self.metadata.clone(),
712        },
713      )));
714    }
715
716    let step = TestStep {
717      step_id: self.step_id,
718      title: self.title,
719      category: self.category,
720      duration,
721      status,
722      error,
723      location: None,
724      parent_step_id: self.parent_step_id,
725      metadata: self.metadata,
726      steps: Vec::new(),
727    };
728    self.steps.lock().await.push(step);
729  }
730}
731
732// ── Test Step ──
733
734/// A structured test step (maps to Playwright's `test.step()`).
735#[derive(Debug, Clone)]
736pub struct TestStep {
737  /// Unique step identifier (for parent/child tracking and reporter correlation).
738  pub step_id: String,
739  pub title: String,
740  pub category: StepCategory,
741  pub duration: Duration,
742  /// Step completion status.
743  pub status: StepStatus,
744  pub error: Option<String>,
745  /// Source location (e.g., "file.rs:42" or "feature.feature:10").
746  pub location: Option<String>,
747  /// Parent step ID for nesting.
748  pub parent_step_id: Option<String>,
749  /// Arbitrary metadata for domain-specific extensions (e.g., BDD keyword, tags).
750  /// Reporters can use this for custom rendering without the core needing domain knowledge.
751  pub metadata: Option<serde_json::Value>,
752  pub steps: Vec<TestStep>,
753}
754
755/// Status of a completed test step.
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum StepStatus {
758  Passed,
759  Failed,
760  Skipped,
761  /// Step exists but is not yet implemented.
762  Pending,
763}
764
765/// Category of a test step.
766#[derive(Debug, Clone, PartialEq, Eq)]
767pub enum StepCategory {
768  /// User-defined step via test.step().
769  TestStep,
770  /// Expect assertion.
771  Expect,
772  /// Fixture setup/teardown.
773  Fixture,
774  /// Hook execution.
775  Hook,
776  /// Playwright API call.
777  PwApi,
778}
779
780impl StepCategory {
781  /// Whether this step category is visible in standard reporter output.
782  /// TestStep and Hook are always shown. Expect, Fixture, PwApi are hidden
783  /// unless verbose mode is enabled.
784  pub fn is_visible(&self) -> bool {
785    matches!(self, Self::TestStep | Self::Hook)
786  }
787}
788
789impl fmt::Display for StepCategory {
790  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
791    match self {
792      Self::TestStep => write!(f, "test.step"),
793      Self::Expect => write!(f, "expect"),
794      Self::Fixture => write!(f, "fixture"),
795      Self::Hook => write!(f, "hook"),
796      Self::PwApi => write!(f, "pw:api"),
797    }
798  }
799}
800
801// ── Annotations ──
802
803#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
804#[serde(rename_all = "lowercase")]
805pub enum TestAnnotation {
806  /// Skip this test. Optional condition: `"firefox"`, `"chromium"`, `"linux"`, `"ci"`, `"!webkit"`.
807  /// When condition is None, always skips. When condition is Some, skips only if condition matches.
808  Skip {
809    reason: Option<String>,
810    condition: Option<String>,
811  },
812  /// Triple the timeout for this test (×3). Optional condition + description.
813  /// Matches Playwright's `test.slow()` / `test.slow(condition, description)`.
814  Slow {
815    reason: Option<String>,
816    condition: Option<String>,
817  },
818  /// Known bug — skip with intent to fix. Same condition semantics as Skip.
819  /// Matches Playwright's `test.fixme()` / `test.fixme(condition, description)`.
820  Fixme {
821    reason: Option<String>,
822    condition: Option<String>,
823  },
824  /// Expect this test to fail (inverts pass/fail). Optional condition + description.
825  /// Matches Playwright's `test.fail()` / `test.fail(condition, description)`.
826  Fail {
827    reason: Option<String>,
828    condition: Option<String>,
829  },
830  Only,
831  Tag(String),
832  /// Structured metadata: type + description (e.g., issue/JIRA-1234, severity/critical).
833  Info {
834    type_name: String,
835    description: String,
836  },
837}
838
839#[derive(Debug, Clone, Default, PartialEq, Eq)]
840pub enum ExpectedStatus {
841  #[default]
842  Pass,
843  Fail,
844}
845
846// ── Runtime Modifiers (shared between JS test body and Rust worker) ──
847
848/// Runtime test modifiers set by `test.skip()`, `test.fail()`, `test.slow()` inside
849/// a test body. Shared via `Arc` between the NAPI layer (JS thread writes) and the
850/// Rust worker (reads after callback returns).
851///
852/// Uses atomics and `std::sync::Mutex` for cross-thread safety. No actual race —
853/// the worker reads strictly after the TSFN callback completes.
854pub struct TestModifiers {
855  /// Set by `test.skip()` / `test.fixme()` inside test body.
856  pub skipped: AtomicBool,
857  /// Reason for runtime skip.
858  pub skip_reason: std::sync::Mutex<Option<String>>,
859  /// Set by `test.fail()` inside test body — inverts pass/fail.
860  pub expected_failure: AtomicBool,
861  /// Set by `test.slow()` inside test body.
862  pub slow: AtomicBool,
863  /// Set by `testInfo.setTimeout()` inside test body.
864  pub timeout_override: std::sync::Mutex<Option<u64>>,
865}
866
867impl Default for TestModifiers {
868  fn default() -> Self {
869    Self {
870      skipped: AtomicBool::new(false),
871      skip_reason: std::sync::Mutex::new(None),
872      expected_failure: AtomicBool::new(false),
873      slow: AtomicBool::new(false),
874      timeout_override: std::sync::Mutex::new(None),
875    }
876  }
877}
878
879impl std::fmt::Debug for TestModifiers {
880  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
881    f.debug_struct("TestModifiers")
882      .field("skipped", &self.skipped.load(Ordering::Relaxed))
883      .field("expected_failure", &self.expected_failure.load(Ordering::Relaxed))
884      .field("slow", &self.slow.load(Ordering::Relaxed))
885      .finish_non_exhaustive()
886  }
887}
888
889// ── Outcome ──
890
891/// Status of a completed test.
892#[derive(Debug, Clone, PartialEq, Eq)]
893pub enum TestStatus {
894  Passed,
895  Failed,
896  TimedOut,
897  Skipped,
898  /// Passed on retry (flaky).
899  Flaky,
900  /// Interrupted by signal/cancellation.
901  Interrupted,
902}
903
904impl fmt::Display for TestStatus {
905  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906    match self {
907      Self::Passed => write!(f, "passed"),
908      Self::Failed => write!(f, "failed"),
909      Self::TimedOut => write!(f, "timed out"),
910      Self::Skipped => write!(f, "skipped"),
911      Self::Flaky => write!(f, "flaky"),
912      Self::Interrupted => write!(f, "interrupted"),
913    }
914  }
915}
916
917/// Result of a single test attempt.
918#[derive(Debug, Clone)]
919pub struct TestOutcome {
920  pub test_id: TestId,
921  pub status: TestStatus,
922  pub duration: Duration,
923  pub attempt: u32,
924  pub max_attempts: u32,
925  pub error: Option<TestFailure>,
926  pub attachments: Vec<Attachment>,
927  pub steps: Vec<TestStep>,
928  pub stdout: String,
929  pub stderr: String,
930  /// Annotations from the test definition + runtime (tags, severity, issues, etc.).
931  pub annotations: Vec<TestAnnotation>,
932  /// Project/run metadata (from config). Available to reporters for JSON/HTML output.
933  pub metadata: serde_json::Value,
934}
935
936/// A test failure with diagnostic information.
937#[derive(Debug, Clone)]
938pub struct TestFailure {
939  pub message: String,
940  pub stack: Option<String>,
941  pub diff: Option<String>,
942  /// Screenshot on failure (auto-captured).
943  pub screenshot: Option<Vec<u8>>,
944}
945
946impl fmt::Display for TestFailure {
947  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948    write!(f, "{}", self.message)?;
949    if let Some(diff) = &self.diff {
950      write!(f, "\n{diff}")?;
951    }
952    Ok(())
953  }
954}
955
956impl std::error::Error for TestFailure {}
957
958impl TestFailure {
959  /// Wrap a [`ferridriver::FerriError`] with a contextual prefix while
960  /// preserving the Playwright-style typed class name. The resulting
961  /// message reads `"<prefix>: <Name>: <message>"` for distinguishable
962  /// variants and `"<prefix>: <message>"` for unnamed ones, so consumers
963  /// that match on `TimeoutError:` still see the marker after the prefix.
964  #[must_use]
965  pub fn wrap(prefix: impl std::fmt::Display, err: ferridriver::FerriError) -> Self {
966    Self {
967      message: format!("{prefix}: {}", err.display_named()),
968      stack: None,
969      diff: None,
970      screenshot: None,
971    }
972  }
973}
974
975/// Legacy bridge: kept so test bodies that hand-build a `String` error
976/// (logging helpers, manual panic messages) keep `?`-propagating through
977/// `TestFailure`. Locator methods now return `Result<T, FerriError>` and
978/// flow through the dedicated [`From<ferridriver::FerriError>`] impl below.
979impl From<String> for TestFailure {
980  fn from(message: String) -> Self {
981    Self {
982      message,
983      stack: None,
984      diff: None,
985      screenshot: None,
986    }
987  }
988}
989
990impl From<&str> for TestFailure {
991  fn from(message: &str) -> Self {
992    Self::from(message.to_string())
993  }
994}
995
996/// Enables `?` on any `Result<T, FerriError>` inside test functions after
997/// the migration to structured errors. Prepends the typed class name for
998/// the variants Playwright distinguishes (`TimeoutError` / `TargetClosedError`)
999/// so the TS bridge can re-hydrate a real class instance from the
1000/// `<Name>: <message>` shape — same convention `ferridriver-node::error::to_napi`
1001/// uses on the NAPI surface. Unnamed variants pass through verbatim.
1002impl From<ferridriver::FerriError> for TestFailure {
1003  fn from(err: ferridriver::FerriError) -> Self {
1004    Self {
1005      message: err.display_named(),
1006      stack: None,
1007      diff: None,
1008      screenshot: None,
1009    }
1010  }
1011}
1012
1013/// An artifact attached to a test result.
1014#[derive(Debug, Clone)]
1015pub struct Attachment {
1016  pub name: String,
1017  pub content_type: String,
1018  pub body: AttachmentBody,
1019}
1020
1021#[derive(Debug, Clone)]
1022pub enum AttachmentBody {
1023  Bytes(Vec<u8>),
1024  Path(PathBuf),
1025}
1026
1027// ── Unified Fixtures ──
1028
1029/// Unified fixture bag for test/step/hook callbacks.
1030///
1031/// E2E tests and hooks get browser/page/context/request/testInfo.
1032/// BDD steps additionally get args/data_table/doc_string.
1033/// BDD hooks get the E2E fields with BDD fields as None.
1034#[derive(Clone)]
1035pub struct TestFixtures {
1036  pub browser: Arc<ferridriver::Browser>,
1037  pub page: Arc<ferridriver::Page>,
1038  pub context: Arc<ferridriver::context::ContextRef>,
1039  pub request: Arc<ferridriver::http_client::HttpClient>,
1040  pub test_info: Arc<TestInfo>,
1041  pub modifiers: Arc<TestModifiers>,
1042  pub browser_config: crate::config::BrowserConfig,
1043  // BDD fields (None for E2E tests/hooks)
1044  pub bdd_args: Option<Vec<serde_json::Value>>,
1045  pub bdd_data_table: Option<Vec<Vec<String>>>,
1046  pub bdd_doc_string: Option<String>,
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051  use super::*;
1052  use ferridriver::FerriError;
1053
1054  #[test]
1055  fn testfailure_from_timeout_keeps_class_prefix() {
1056    let tf = TestFailure::from(FerriError::timeout("navigating", 30_000));
1057    assert_eq!(tf.message, "TimeoutError: Timeout 30000ms exceeded while navigating");
1058  }
1059
1060  #[test]
1061  fn testfailure_from_target_closed_keeps_class_prefix() {
1062    let tf = TestFailure::from(FerriError::target_closed(Some("crashed".into())));
1063    assert_eq!(
1064      tf.message,
1065      "TargetClosedError: Target page, context or browser has been closed: crashed"
1066    );
1067  }
1068
1069  #[test]
1070  fn testfailure_from_backend_has_no_prefix() {
1071    let tf = TestFailure::from(FerriError::backend("launch failed"));
1072    assert_eq!(tf.message, "backend error: launch failed");
1073  }
1074
1075  #[test]
1076  fn testfailure_wrap_preserves_timeout_class_after_prefix() {
1077    let tf = TestFailure::wrap("fixture 'browser' failed", FerriError::timeout("launch", 30_000));
1078    assert_eq!(
1079      tf.message,
1080      "fixture 'browser' failed: TimeoutError: Timeout 30000ms exceeded while launch"
1081    );
1082  }
1083
1084  #[test]
1085  fn testfailure_wrap_unnamed_keeps_message_only() {
1086    let tf = TestFailure::wrap("fixture 'page' failed", FerriError::backend("oops"));
1087    assert_eq!(tf.message, "fixture 'page' failed: backend error: oops");
1088  }
1089}