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// ── Test Plan ──
145
146/// The full test plan after discovery + filtering + sharding.
147#[derive(Clone)]
148pub struct TestPlan {
149  pub suites: Vec<TestSuite>,
150  /// Total test count (after filtering, before retry expansion).
151  pub total_tests: usize,
152  /// Shard info if sharding is active.
153  pub shard: Option<ShardInfo>,
154}
155
156#[derive(Debug, Clone)]
157pub struct ShardInfo {
158  pub current: u32,
159  pub total: u32,
160}
161
162// ── Plan Builder ──
163
164/// Suite metadata for plan building.
165pub struct SuiteDef {
166  /// Suite ID (e.g. `"file::SuiteName"`). Must match `TestCase.id.suite`.
167  pub id: String,
168  pub name: String,
169  pub file: String,
170  pub mode: SuiteMode,
171}
172
173/// Hook registration for plan building.
174pub struct HookDef {
175  /// Suite ID this hook belongs to. Empty string = root/default suite.
176  pub suite_id: String,
177  pub kind: HookKind,
178}
179
180/// Generic lifecycle phase shared across all front-end hook syntaxes.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum HookPhase {
183  Before,
184  After,
185}
186
187/// Generic lifecycle scope shared across E2E and BDD hooks.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum HookScope {
190  Suite,
191  Scenario,
192  Step,
193}
194
195/// Where a hook attaches in the runner model.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum HookOwner {
198  Root,
199  Suite(String),
200}
201
202/// Unified hook registration metadata used by adapters before execution hooks are built.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct HookRegistration {
205  pub phase: HookPhase,
206  pub scope: HookScope,
207  pub owner: HookOwner,
208  pub tags: Option<String>,
209  pub requested_fixtures: Vec<String>,
210}
211
212/// Hook kind with the associated callback.
213pub enum HookKind {
214  BeforeAll(SuiteHookFn),
215  AfterAll(SuiteHookFn),
216  BeforeEach(HookFn),
217  AfterEach(HookFn),
218}
219
220/// Builds a `TestPlan` from flat test cases, suite definitions, and hooks.
221///
222/// Groups tests by `TestCase.id.suite`, attaches hooks to matching suites,
223/// and respects suite mode (parallel/serial). This is the single place
224/// where suite→test→hook association happens — callers (NAPI, CLI, macros)
225/// just register flat data.
226pub struct TestPlanBuilder {
227  tests: Vec<TestCase>,
228  suites: Vec<SuiteDef>,
229  hooks: Vec<HookDef>,
230}
231
232impl Default for TestPlanBuilder {
233  fn default() -> Self {
234    Self::new()
235  }
236}
237
238impl TestPlanBuilder {
239  pub fn new() -> Self {
240    Self {
241      tests: Vec::new(),
242      suites: Vec::new(),
243      hooks: Vec::new(),
244    }
245  }
246
247  pub fn add_test(&mut self, test: TestCase) {
248    self.tests.push(test);
249  }
250
251  pub fn add_suite(&mut self, suite: SuiteDef) {
252    self.suites.push(suite);
253  }
254
255  pub fn add_hook(&mut self, hook: HookDef) {
256    self.hooks.push(hook);
257  }
258
259  /// Consume the builder and produce a `TestPlan`.
260  ///
261  /// Tests are grouped by `id.suite` (matching `SuiteDef.id`).
262  /// Tests without a suite go into a default parallel suite.
263  /// Hooks are attached to their matching suite by `suite_id`.
264  pub fn build(self) -> TestPlan {
265    use rustc_hash::FxHashMap;
266
267    // Index suite metadata by ID.
268    let suite_meta: FxHashMap<String, (String, String, SuiteMode)> = self
269      .suites
270      .into_iter()
271      .map(|s| (s.id, (s.name, s.file, s.mode)))
272      .collect();
273
274    // Group tests by suite key.
275    let mut grouped: FxHashMap<String, Vec<TestCase>> = FxHashMap::default();
276    for tc in self.tests {
277      let key = tc.id.suite.clone().unwrap_or_default();
278      grouped.entry(key).or_default().push(tc);
279    }
280
281    // Build hooks per suite.
282    let mut hook_map: FxHashMap<String, Hooks> = FxHashMap::default();
283    for h in self.hooks {
284      let hooks = hook_map.entry(h.suite_id).or_default();
285      match h.kind {
286        HookKind::BeforeAll(f) => hooks.before_all.push(f),
287        HookKind::AfterAll(f) => hooks.after_all.push(f),
288        HookKind::BeforeEach(f) => hooks.before_each.push(f),
289        HookKind::AfterEach(f) => hooks.after_each.push(f),
290      }
291    }
292
293    // Assemble suites.
294    let mut plan_suites: Vec<TestSuite> = Vec::new();
295    let mut total = 0usize;
296
297    for (suite_key, tests) in grouped {
298      total += tests.len();
299      let (name, file, mode) = if suite_key.is_empty() {
300        ("tests".to_string(), String::new(), SuiteMode::Parallel)
301      } else if let Some((n, f, m)) = suite_meta.get(&suite_key) {
302        (n.clone(), f.clone(), *m)
303      } else {
304        // Suite ID exists on tests but no SuiteDef was registered — use defaults.
305        (suite_key.clone(), String::new(), SuiteMode::Parallel)
306      };
307      let hooks = hook_map.remove(&suite_key).unwrap_or_default();
308      plan_suites.push(TestSuite {
309        name,
310        file,
311        tests,
312        hooks,
313        annotations: Vec::new(),
314        mode,
315      });
316    }
317
318    TestPlan {
319      suites: plan_suites,
320      total_tests: total,
321      shard: None,
322    }
323  }
324}
325
326// ── Test Info (runtime context available during test execution) ──
327
328/// Runtime test information accessible during test execution.
329/// Mirrors Playwright's `TestInfo` interface.
330#[derive(Clone)]
331pub struct TestInfo {
332  /// Test ID.
333  pub test_id: TestId,
334  /// Title path: ["suite", "subsuite", "test name"].
335  pub title_path: Vec<String>,
336  /// Current retry attempt (0-indexed).
337  pub retry: u32,
338  /// Worker index (0-based).
339  pub worker_index: u32,
340  /// Parallel index (same as worker_index for now).
341  pub parallel_index: u32,
342  /// repeatEach index (0-based).
343  pub repeat_each_index: u32,
344  /// Output directory for this test's artifacts.
345  pub output_dir: PathBuf,
346  /// Snapshot directory for this test.
347  pub snapshot_dir: PathBuf,
348  /// Snapshot path template (e.g. `{testDir}/__snapshots__/{testFilePath}/{arg}{ext}`).
349  pub snapshot_path_template: Option<String>,
350  /// Snapshot update mode.
351  pub update_snapshots: crate::config::UpdateSnapshotsMode,
352  /// Collected attachments.
353  pub attachments: Arc<Mutex<Vec<Attachment>>>,
354  /// Collected test steps.
355  pub steps: Arc<Mutex<Vec<TestStep>>>,
356  /// Soft assertion errors (collected, not thrown).
357  pub soft_errors: Arc<Mutex<Vec<TestFailure>>>,
358  /// Test timeout.
359  pub timeout: Duration,
360  /// Tags from annotations.
361  pub tags: Vec<String>,
362  /// Test start time.
363  pub start_time: Instant,
364  /// Event bus for real-time step event emission (set by worker).
365  pub event_bus: Option<EventBus>,
366  /// Runtime annotations added via `test_info.annotate()`.
367  pub annotations: Arc<Mutex<Vec<TestAnnotation>>>,
368}
369
370impl TestInfo {
371  /// Create a minimal TestInfo for non-test-runner contexts (MCP, standalone).
372  pub fn new_anonymous() -> Self {
373    Self {
374      test_id: TestId {
375        file: String::new(),
376        suite: None,
377        name: "anonymous".into(),
378        line: None,
379      },
380      title_path: Vec::new(),
381      retry: 0,
382      worker_index: 0,
383      parallel_index: 0,
384      repeat_each_index: 0,
385      output_dir: PathBuf::new(),
386      snapshot_dir: PathBuf::new(),
387      snapshot_path_template: None,
388      update_snapshots: crate::config::UpdateSnapshotsMode::default(),
389      attachments: Arc::new(Mutex::new(Vec::new())),
390      steps: Arc::new(Mutex::new(Vec::new())),
391      soft_errors: Arc::new(Mutex::new(Vec::new())),
392      timeout: Duration::from_secs(30),
393      tags: Vec::new(),
394      start_time: Instant::now(),
395      event_bus: None,
396      annotations: Arc::new(Mutex::new(Vec::new())),
397    }
398  }
399
400  /// Add a structured annotation at runtime.
401  pub async fn annotate(&self, type_name: impl Into<String>, description: impl Into<String>) {
402    let mut annotations = self.annotations.lock().await;
403    annotations.push(TestAnnotation::Info {
404      type_name: type_name.into(),
405      description: description.into(),
406    });
407  }
408
409  /// Get all runtime annotations.
410  pub async fn get_annotations(&self) -> Vec<TestAnnotation> {
411    let annotations = self.annotations.lock().await;
412    annotations.clone()
413  }
414  /// Add an attachment to this test.
415  pub async fn attach(&self, name: String, content_type: String, body: AttachmentBody) {
416    let mut attachments = self.attachments.lock().await;
417    attachments.push(Attachment {
418      name,
419      content_type,
420      body,
421    });
422  }
423
424  /// Record a soft assertion error (test continues, fails at end).
425  pub async fn add_soft_error(&self, error: TestFailure) {
426    let mut errors = self.soft_errors.lock().await;
427    errors.push(error);
428  }
429
430  /// Check if any soft errors have been collected.
431  pub async fn has_soft_errors(&self) -> bool {
432    let errors = self.soft_errors.lock().await;
433    !errors.is_empty()
434  }
435
436  /// Drain all soft errors for final reporting.
437  pub async fn drain_soft_errors(&self) -> Vec<TestFailure> {
438    let mut errors = self.soft_errors.lock().await;
439    errors.drain(..).collect()
440  }
441
442  /// Record a test step.
443  pub async fn push_step(&self, step: TestStep) {
444    let mut steps = self.steps.lock().await;
445    steps.push(step);
446  }
447
448  /// Get elapsed time since test start.
449  pub fn elapsed(&self) -> Duration {
450    self.start_time.elapsed()
451  }
452
453  /// Begin a new step with real-time event emission.
454  ///
455  /// Returns a `StepHandle` that must be completed via `handle.end()`.
456  /// Emits `ReporterEvent::StepStarted` immediately if an event bus is available.
457  pub async fn begin_step(&self, title: impl Into<String>, category: StepCategory) -> StepHandle {
458    let title = title.into();
459    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
460
461    if let Some(bus) = &self.event_bus {
462      bus
463        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
464          crate::reporter::StepStartedEvent {
465            test_id: self.test_id.clone(),
466            step_id: step_id.clone(),
467            parent_step_id: None,
468            title: title.clone(),
469            category: category.clone(),
470          },
471        )))
472        .await;
473    }
474
475    StepHandle {
476      step_id,
477      test_id: self.test_id.clone(),
478      title,
479      category,
480      parent_step_id: None,
481      start: Instant::now(),
482      metadata: None,
483      event_bus: self.event_bus.clone(),
484      steps: Arc::clone(&self.steps),
485    }
486  }
487
488  /// Begin a nested step (child of a parent step).
489  pub async fn begin_child_step(
490    &self,
491    title: impl Into<String>,
492    category: StepCategory,
493    parent_step_id: &str,
494  ) -> StepHandle {
495    let title = title.into();
496    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
497
498    if let Some(bus) = &self.event_bus {
499      bus
500        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
501          crate::reporter::StepStartedEvent {
502            test_id: self.test_id.clone(),
503            step_id: step_id.clone(),
504            parent_step_id: Some(parent_step_id.to_string()),
505            title: title.clone(),
506            category: category.clone(),
507          },
508        )))
509        .await;
510    }
511
512    StepHandle {
513      step_id,
514      test_id: self.test_id.clone(),
515      title,
516      category,
517      parent_step_id: Some(parent_step_id.to_string()),
518      start: Instant::now(),
519      metadata: None,
520      event_bus: self.event_bus.clone(),
521      steps: Arc::clone(&self.steps),
522    }
523  }
524
525  /// Record a step that already executed elsewhere but still needs to flow
526  /// through reporter events and the stored step tree.
527  pub async fn record_step(
528    &self,
529    title: impl Into<String>,
530    category: StepCategory,
531    status: StepStatus,
532    duration: Duration,
533    error: Option<String>,
534    metadata: Option<serde_json::Value>,
535  ) {
536    let title = title.into();
537    let step_id = format!("{}@{}", category, STEP_ID_COUNTER.fetch_add(1, Ordering::Relaxed));
538
539    if let Some(bus) = &self.event_bus {
540      bus
541        .emit(crate::reporter::ReporterEvent::StepStarted(Box::new(
542          crate::reporter::StepStartedEvent {
543            test_id: self.test_id.clone(),
544            step_id: step_id.clone(),
545            parent_step_id: None,
546            title: title.clone(),
547            category: category.clone(),
548          },
549        )))
550        .await;
551      bus
552        .emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
553          crate::reporter::StepFinishedEvent {
554            test_id: self.test_id.clone(),
555            step_id: step_id.clone(),
556            title: title.clone(),
557            category: category.clone(),
558            duration,
559            error: error.clone(),
560            metadata: metadata.clone(),
561          },
562        )))
563        .await;
564    }
565
566    self.steps.lock().await.push(TestStep {
567      step_id,
568      title,
569      category,
570      duration,
571      status,
572      error,
573      location: None,
574      parent_step_id: None,
575      metadata,
576      steps: Vec::new(),
577    });
578  }
579}
580
581/// Global step ID counter for unique step identification.
582static STEP_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
583
584/// Handle to an in-progress step. Must be completed via `end()`.
585///
586/// On `end()`:
587/// - Emits `ReporterEvent::StepFinished` for real-time reporting
588/// - Pushes a `TestStep` to the test's step list for batch reporting
589pub struct StepHandle {
590  pub step_id: String,
591  pub test_id: TestId,
592  pub title: String,
593  pub category: StepCategory,
594  pub parent_step_id: Option<String>,
595  pub start: Instant,
596  /// Arbitrary metadata attached to this step (set before calling `end()`).
597  pub metadata: Option<serde_json::Value>,
598  event_bus: Option<EventBus>,
599  steps: Arc<Mutex<Vec<TestStep>>>,
600}
601
602impl StepHandle {
603  /// Complete this step. Pass `None` for success, `Some(msg)` for failure.
604  pub async fn end(self, error: Option<String>) {
605    let duration = self.start.elapsed();
606    let status = if error.is_some() {
607      StepStatus::Failed
608    } else {
609      StepStatus::Passed
610    };
611
612    // Emit real-time event.
613    if let Some(bus) = &self.event_bus {
614      bus
615        .emit(crate::reporter::ReporterEvent::StepFinished(Box::new(
616          crate::reporter::StepFinishedEvent {
617            test_id: self.test_id.clone(),
618            step_id: self.step_id.clone(),
619            title: self.title.clone(),
620            category: self.category.clone(),
621            duration,
622            error: error.clone(),
623            metadata: self.metadata.clone(),
624          },
625        )))
626        .await;
627    }
628
629    // Push to batch step list (for TestOutcome.steps).
630    let step = TestStep {
631      step_id: self.step_id,
632      title: self.title,
633      category: self.category,
634      duration,
635      status,
636      error,
637      location: None,
638      parent_step_id: self.parent_step_id,
639      metadata: self.metadata.clone(),
640      steps: Vec::new(),
641    };
642    self.steps.lock().await.push(step);
643  }
644
645  /// Complete this step as skipped.
646  pub async fn skip(self, reason: Option<String>) {
647    self.finish_with_status(StepStatus::Skipped, reason).await;
648  }
649
650  /// Complete this step as pending (not yet implemented).
651  pub async fn pending(self, reason: Option<String>) {
652    self.finish_with_status(StepStatus::Pending, reason).await;
653  }
654
655  async fn finish_with_status(self, status: StepStatus, error: Option<String>) {
656    let duration = self.start.elapsed();
657
658    if let Some(bus) = &self.event_bus {
659      bus
660        .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        .await;
672    }
673
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,
684      steps: Vec::new(),
685    };
686    self.steps.lock().await.push(step);
687  }
688}
689
690// ── Test Step ──
691
692/// A structured test step (maps to Playwright's `test.step()`).
693#[derive(Debug, Clone)]
694pub struct TestStep {
695  /// Unique step identifier (for parent/child tracking and reporter correlation).
696  pub step_id: String,
697  pub title: String,
698  pub category: StepCategory,
699  pub duration: Duration,
700  /// Step completion status.
701  pub status: StepStatus,
702  pub error: Option<String>,
703  /// Source location (e.g., "file.rs:42" or "feature.feature:10").
704  pub location: Option<String>,
705  /// Parent step ID for nesting.
706  pub parent_step_id: Option<String>,
707  /// Arbitrary metadata for domain-specific extensions (e.g., BDD keyword, tags).
708  /// Reporters can use this for custom rendering without the core needing domain knowledge.
709  pub metadata: Option<serde_json::Value>,
710  pub steps: Vec<TestStep>,
711}
712
713/// Status of a completed test step.
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum StepStatus {
716  Passed,
717  Failed,
718  Skipped,
719  /// Step exists but is not yet implemented.
720  Pending,
721}
722
723/// Category of a test step.
724#[derive(Debug, Clone, PartialEq, Eq)]
725pub enum StepCategory {
726  /// User-defined step via test.step().
727  TestStep,
728  /// Expect assertion.
729  Expect,
730  /// Fixture setup/teardown.
731  Fixture,
732  /// Hook execution.
733  Hook,
734  /// Playwright API call.
735  PwApi,
736}
737
738impl StepCategory {
739  /// Whether this step category is visible in standard reporter output.
740  /// TestStep and Hook are always shown. Expect, Fixture, PwApi are hidden
741  /// unless verbose mode is enabled.
742  pub fn is_visible(&self) -> bool {
743    matches!(self, Self::TestStep | Self::Hook)
744  }
745}
746
747impl fmt::Display for StepCategory {
748  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
749    match self {
750      Self::TestStep => write!(f, "test.step"),
751      Self::Expect => write!(f, "expect"),
752      Self::Fixture => write!(f, "fixture"),
753      Self::Hook => write!(f, "hook"),
754      Self::PwApi => write!(f, "pw:api"),
755    }
756  }
757}
758
759// ── Annotations ──
760
761#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
762#[serde(rename_all = "lowercase")]
763pub enum TestAnnotation {
764  /// Skip this test. Optional condition: `"firefox"`, `"chromium"`, `"linux"`, `"ci"`, `"!webkit"`.
765  /// When condition is None, always skips. When condition is Some, skips only if condition matches.
766  Skip {
767    reason: Option<String>,
768    condition: Option<String>,
769  },
770  /// Triple the timeout for this test (×3). Optional condition + description.
771  /// Matches Playwright's `test.slow()` / `test.slow(condition, description)`.
772  Slow {
773    reason: Option<String>,
774    condition: Option<String>,
775  },
776  /// Known bug — skip with intent to fix. Same condition semantics as Skip.
777  /// Matches Playwright's `test.fixme()` / `test.fixme(condition, description)`.
778  Fixme {
779    reason: Option<String>,
780    condition: Option<String>,
781  },
782  /// Expect this test to fail (inverts pass/fail). Optional condition + description.
783  /// Matches Playwright's `test.fail()` / `test.fail(condition, description)`.
784  Fail {
785    reason: Option<String>,
786    condition: Option<String>,
787  },
788  Only,
789  Tag(String),
790  /// Structured metadata: type + description (e.g., issue/JIRA-1234, severity/critical).
791  Info {
792    type_name: String,
793    description: String,
794  },
795}
796
797#[derive(Debug, Clone, Default, PartialEq, Eq)]
798pub enum ExpectedStatus {
799  #[default]
800  Pass,
801  Fail,
802}
803
804// ── Runtime Modifiers (shared between JS test body and Rust worker) ──
805
806/// Runtime test modifiers set by `test.skip()`, `test.fail()`, `test.slow()` inside
807/// a test body. Shared via `Arc` between the NAPI layer (JS thread writes) and the
808/// Rust worker (reads after callback returns).
809///
810/// Uses atomics and `std::sync::Mutex` for cross-thread safety. No actual race —
811/// the worker reads strictly after the TSFN callback completes.
812pub struct TestModifiers {
813  /// Set by `test.skip()` / `test.fixme()` inside test body.
814  pub skipped: AtomicBool,
815  /// Reason for runtime skip.
816  pub skip_reason: std::sync::Mutex<Option<String>>,
817  /// Set by `test.fail()` inside test body — inverts pass/fail.
818  pub expected_failure: AtomicBool,
819  /// Set by `test.slow()` inside test body.
820  pub slow: AtomicBool,
821  /// Set by `testInfo.setTimeout()` inside test body.
822  pub timeout_override: std::sync::Mutex<Option<u64>>,
823}
824
825impl Default for TestModifiers {
826  fn default() -> Self {
827    Self {
828      skipped: AtomicBool::new(false),
829      skip_reason: std::sync::Mutex::new(None),
830      expected_failure: AtomicBool::new(false),
831      slow: AtomicBool::new(false),
832      timeout_override: std::sync::Mutex::new(None),
833    }
834  }
835}
836
837impl std::fmt::Debug for TestModifiers {
838  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
839    f.debug_struct("TestModifiers")
840      .field("skipped", &self.skipped.load(Ordering::Relaxed))
841      .field("expected_failure", &self.expected_failure.load(Ordering::Relaxed))
842      .field("slow", &self.slow.load(Ordering::Relaxed))
843      .finish_non_exhaustive()
844  }
845}
846
847// ── Outcome ──
848
849/// Status of a completed test.
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub enum TestStatus {
852  Passed,
853  Failed,
854  TimedOut,
855  Skipped,
856  /// Passed on retry (flaky).
857  Flaky,
858  /// Interrupted by signal/cancellation.
859  Interrupted,
860}
861
862impl fmt::Display for TestStatus {
863  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
864    match self {
865      Self::Passed => write!(f, "passed"),
866      Self::Failed => write!(f, "failed"),
867      Self::TimedOut => write!(f, "timed out"),
868      Self::Skipped => write!(f, "skipped"),
869      Self::Flaky => write!(f, "flaky"),
870      Self::Interrupted => write!(f, "interrupted"),
871    }
872  }
873}
874
875/// Result of a single test attempt.
876#[derive(Debug, Clone)]
877pub struct TestOutcome {
878  pub test_id: TestId,
879  pub status: TestStatus,
880  pub duration: Duration,
881  pub attempt: u32,
882  pub max_attempts: u32,
883  pub error: Option<TestFailure>,
884  pub attachments: Vec<Attachment>,
885  pub steps: Vec<TestStep>,
886  pub stdout: String,
887  pub stderr: String,
888  /// Annotations from the test definition + runtime (tags, severity, issues, etc.).
889  pub annotations: Vec<TestAnnotation>,
890  /// Project/run metadata (from config). Available to reporters for JSON/HTML output.
891  pub metadata: serde_json::Value,
892}
893
894/// A test failure with diagnostic information.
895#[derive(Debug, Clone)]
896pub struct TestFailure {
897  pub message: String,
898  pub stack: Option<String>,
899  pub diff: Option<String>,
900  /// Screenshot on failure (auto-captured).
901  pub screenshot: Option<Vec<u8>>,
902}
903
904impl fmt::Display for TestFailure {
905  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906    write!(f, "{}", self.message)?;
907    if let Some(diff) = &self.diff {
908      write!(f, "\n{diff}")?;
909    }
910    Ok(())
911  }
912}
913
914impl std::error::Error for TestFailure {}
915
916/// Enables `?` on any `Result<T, String>` inside test functions.
917/// Locator methods (click, fill, press, etc.) return `Result<T, String>`.
918impl From<String> for TestFailure {
919  fn from(message: String) -> Self {
920    Self {
921      message,
922      stack: None,
923      diff: None,
924      screenshot: None,
925    }
926  }
927}
928
929impl From<&str> for TestFailure {
930  fn from(message: &str) -> Self {
931    Self::from(message.to_string())
932  }
933}
934
935/// An artifact attached to a test result.
936#[derive(Debug, Clone)]
937pub struct Attachment {
938  pub name: String,
939  pub content_type: String,
940  pub body: AttachmentBody,
941}
942
943#[derive(Debug, Clone)]
944pub enum AttachmentBody {
945  Bytes(Vec<u8>),
946  Path(PathBuf),
947}
948
949// ── Unified Fixtures ──
950
951/// Unified fixture bag for test/step/hook callbacks.
952///
953/// E2E tests and hooks get browser/page/context/request/testInfo.
954/// BDD steps additionally get args/data_table/doc_string.
955/// BDD hooks get the E2E fields with BDD fields as None.
956#[derive(Clone)]
957pub struct TestFixtures {
958  pub browser: Arc<ferridriver::Browser>,
959  pub page: Arc<ferridriver::Page>,
960  pub context: Arc<ferridriver::context::ContextRef>,
961  pub request: Arc<ferridriver::api_request::APIRequestContext>,
962  pub test_info: Arc<TestInfo>,
963  pub modifiers: Arc<TestModifiers>,
964  pub browser_config: crate::config::BrowserConfig,
965  // BDD fields (None for E2E tests/hooks)
966  pub bdd_args: Option<Vec<serde_json::Value>>,
967  pub bdd_data_table: Option<Vec<Vec<String>>>,
968  pub bdd_doc_string: Option<String>,
969}