Skip to main content

ferridriver_script/bindings/
bdd.rs

1//! Cucumber step-definition surface for the shared QuickJS engine.
2//!
3//! The same VM that runs `ferridriver run` scripts and MCP `run_script`
4//! also loads cucumber-js-shaped `.js` step files. `Given`/`When`/
5//! `Then`/`Before`/`After`/`defineParameterType`/... are native Rust
6//! functions (no JS glue); registrations land in a Rust `ExtensionRegistry`
7//! held as context userdata (the QuickJS context is single-threaded, so
8//! a `RefCell` is the right interior mutability — no `Arc`/`Mutex`).
9//! Step bodies are kept as `Persistent` functions and called back by
10//! the Rust `ferridriver-bdd` core. Every body receives the
11//! per-scenario World as its FIRST positional argument — arrow,
12//! classic `function`, async, all the same shape — followed by the
13//! cucumber-extracted parameters, an optional `DataTableJs`, and an
14//! optional doc-string. The World is also bound as `this` so
15//! `function (world) { this === world }` holds for callers who prefer
16//! that style.
17//!
18//! No business logic here: matching, outline expansion, tag filtering
19//! and hook ordering all stay in the `ferridriver-bdd` core.
20
21use std::cell::RefCell;
22use std::sync::Arc;
23
24use rquickjs::class::{Class, Trace};
25use rquickjs::function::{Args, Constructor, Func, Opt, Rest};
26use rquickjs::{
27  ArrayBuffer, AsyncContext, CatchResultExt, Ctx, Function, JsLifetime, Object, Persistent, TypedArray, Value,
28  async_with,
29};
30
31use crate::bindings::convert::{serde_from_js, serde_to_js};
32use crate::bindings::{install_browser_context_on, install_browser_on, install_page_on, install_request_on};
33use crate::engine::caught_to_script_error;
34use crate::error::ScriptError;
35
36/// Thrown by `this.skip()`; recognised in [`invoke_step`] and mapped to
37/// `StepOutcome::Skipped` (cucumber aborts the step on throw).
38const SKIP_SENTINEL: &str = "__ferri_skip__";
39
40/// Cucumber step keyword. `Step` is keyword-agnostic (`defineStep`,
41/// `And`, `But`); matching in the core is keyword-agnostic anyway.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum StepKind {
44  Given,
45  When,
46  Then,
47  Step,
48}
49
50impl StepKind {
51  #[must_use]
52  pub fn as_str(self) -> &'static str {
53    match self {
54      Self::Given => "Given",
55      Self::When => "When",
56      Self::Then => "Then",
57      Self::Step => "Step",
58    }
59  }
60}
61
62struct StepReg {
63  kind: StepKind,
64  pattern: String,
65  is_regex: bool,
66  func: Persistent<Function<'static>>,
67  /// Per-step `{ timeout }` (ms) from `Given(pat, { timeout }, fn)`.
68  /// `None` ⇒ the registry default. Enforced in [`invoke_step`].
69  timeout_ms: Option<u64>,
70}
71
72struct HookReg {
73  kind: String,
74  tags: Option<String>,
75  func: Persistent<Function<'static>>,
76  /// Per-hook `{ timeout }` (ms). `None` ⇒ registry default.
77  timeout_ms: Option<u64>,
78}
79
80struct ParamTypeReg {
81  name: String,
82  regexp: String,
83  /// Optional `transformer` fn from `defineParameterType`. Applied to
84  /// the matched text in [`invoke_step`] so the step receives a typed
85  /// value (cucumber-js parity).
86  transformer: Option<Persistent<Function<'static>>>,
87}
88
89/// One MCP tool contribution. The handler is kept as a `Persistent`
90/// function and called back natively by [`invoke_tool`] — exactly the
91/// mechanism BDD steps use, no synthesized JS dispatch.
92struct ToolReg {
93  name: String,
94  description: Option<String>,
95  input_schema: Option<serde_json::Value>,
96  expose_as_tool: bool,
97  allowed_commands: std::collections::BTreeMap<String, crate::command_spec::CommandSpec>,
98  allowed_net: Vec<String>,
99  /// Per-tool handler timeout (ms) from the manifest `timeoutMs`. `None`
100  /// ⇒ no independent bound (the session wall-clock still applies).
101  /// Enforced natively in `plugins::dispatch_tool`.
102  timeout_ms: Option<u64>,
103  handler: Persistent<Function<'static>>,
104}
105
106/// One Cucumber attachment produced by `this.attach(...)` /
107/// `this.log(...)` during a scenario. Drained by the BDD layer into the
108/// test result so the messages / HTML / Allure reporters surface it
109/// (screenshot- and text-on-failure).
110#[derive(Debug, Clone)]
111pub struct ScriptAttachment {
112  pub media_type: String,
113  pub bytes: Vec<u8>,
114}
115
116/// The argument cucumber-js passes to `Before`/`After` hooks. Built by
117/// the BDD layer and lowered to a JS object `{ pickle: { name, tags },
118/// result: { status, message? } }` in [`invoke_hook`] — enough for the
119/// screenshot-on-failure idiom (`After(s => { if (s.result.status ===
120/// 'FAILED') this.attach(...) })`).
121#[derive(Debug, Clone, Default)]
122pub struct HookArg {
123  pub name: String,
124  pub tags: Vec<String>,
125  /// Cucumber status: `PENDING` for `Before` (not yet run), `PASSED` /
126  /// `FAILED` for `After`.
127  pub status: String,
128  pub message: Option<String>,
129}
130
131/// Rust-side **extension registry**: the single context-owned table that
132/// every contribution kind lands in. Cucumber `Given`/`When`/`Then`/
133/// hooks/param-types AND MCP `defineTool`/legacy-`exports` tools register
134/// here while the user's bundled module evaluates. Hosts read back the
135/// kinds they care about (`collect_registry` for BDD, [`collect_tools`]
136/// for MCP) and dispatch handlers natively ([`invoke_step`],
137/// [`invoke_tool`]). No `globalThis.__*`, no synthesized JS.
138#[derive(Default)]
139struct ExtensionRegistry {
140  steps: Vec<StepReg>,
141  hooks: Vec<HookReg>,
142  param_types: Vec<ParamTypeReg>,
143  tools: Vec<ToolReg>,
144  /// Attachments queued by the running scenario's `this.attach`/`log`.
145  /// Drained per scenario by the BDD layer; cleared by `reset_world`.
146  attachments: Vec<ScriptAttachment>,
147  default_timeout_ms: u64,
148  /// `setDefinitionFunctionWrapper(fn)` — wraps every step body
149  /// (cross-cut: retry/trace/log). Applied in [`invoke_step`].
150  def_fn_wrapper: Option<Persistent<Function<'static>>>,
151  world_ctor: Option<Persistent<Constructor<'static>>>,
152  current_world: Option<Persistent<Object<'static>>>,
153}
154
155/// Read an optional `{ timeout }` (milliseconds) off an options object
156/// arg, mirroring cucumber-js `Given(pat, { timeout }, fn)` /
157/// `Before({ timeout }, fn)`.
158fn timeout_from_opts(args: &[Value<'_>]) -> Option<u64> {
159  args.iter().find_map(|v| {
160    let o = v.as_object()?;
161    if v.as_function().is_some() {
162      return None;
163    }
164    o.get::<_, f64>("timeout").ok().map(|ms| ms.max(0.0) as u64)
165  })
166}
167
168/// Context userdata holding the registry. Single-threaded VM ⇒
169/// `RefCell`, never `Arc`/`Mutex`.
170struct BddUserData(RefCell<ExtensionRegistry>);
171
172// SAFETY: holds only `'static` data (`Persistent<…>` handles and owned
173// values), so re-stating the unused `'js` lifetime is sound — same
174// rationale as `SessionAsyncCtx`.
175#[allow(unsafe_code)]
176unsafe impl JsLifetime<'_> for BddUserData {
177  type Changed<'to> = BddUserData;
178}
179
180fn with_registry<R>(ctx: &Ctx<'_>, f: impl FnOnce(&mut ExtensionRegistry) -> R) -> Result<R, ScriptError> {
181  let ud = ctx
182    .userdata::<BddUserData>()
183    .ok_or_else(|| ScriptError::internal("bdd registry not installed".to_string()))?;
184  let mut reg = ud.0.borrow_mut();
185  Ok(f(&mut reg))
186}
187
188/// A cucumber data table, passed to steps as the trailing argument.
189#[derive(Trace, JsLifetime)]
190#[rquickjs::class(rename = "DataTable")]
191pub struct DataTableJs {
192  #[qjs(skip_trace)]
193  rows: Vec<Vec<String>>,
194}
195
196#[rquickjs::methods]
197impl DataTableJs {
198  /// Every row including the header.
199  #[qjs(rename = "raw")]
200  pub fn raw(&self) -> Vec<Vec<String>> {
201    self.rows.clone()
202  }
203
204  /// All rows except the header.
205  #[qjs(rename = "rows")]
206  pub fn data_rows(&self) -> Vec<Vec<String>> {
207    self.rows.iter().skip(1).cloned().collect()
208  }
209
210  /// One object per data row keyed by the header row.
211  #[qjs(rename = "hashes")]
212  pub fn hashes<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
213    let header = self.rows.first().cloned().unwrap_or_default();
214    let out: Vec<serde_json::Map<String, serde_json::Value>> = self
215      .rows
216      .iter()
217      .skip(1)
218      .map(|row| {
219        header
220          .iter()
221          .zip(row.iter())
222          .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
223          .collect()
224      })
225      .collect();
226    serde_to_js(&ctx, &out)
227  }
228
229  /// First column as keys, second as values.
230  #[qjs(rename = "rowsHash")]
231  pub fn rows_hash<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
232    let map: serde_json::Map<String, serde_json::Value> = self
233      .rows
234      .iter()
235      .filter(|r| r.len() >= 2)
236      .map(|r| (r[0].clone(), serde_json::Value::String(r[1].clone())))
237      .collect();
238    serde_to_js(&ctx, &map)
239  }
240
241  /// Rows and columns swapped.
242  #[qjs(rename = "transpose")]
243  pub fn transpose<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Class<'js, DataTableJs>> {
244    let cols = self.rows.iter().map(Vec::len).max().unwrap_or(0);
245    let rows = (0..cols)
246      .map(|c| {
247        self
248          .rows
249          .iter()
250          .map(|r| r.get(c).cloned().unwrap_or_default())
251          .collect()
252      })
253      .collect();
254    Class::instance(ctx, DataTableJs { rows })
255  }
256}
257
258fn as_function<'js>(v: &Value<'js>) -> Option<Function<'js>> {
259  v.as_function().cloned()
260}
261
262fn pattern_of(a: &Value<'_>) -> Result<(String, bool), ScriptError> {
263  if let Some(s) = a.as_string() {
264    return Ok((s.to_string().map_err(|e| ScriptError::internal(e.to_string()))?, false));
265  }
266  if let Some(o) = a.as_object() {
267    if let Ok(src) = o.get::<_, String>("source") {
268      return Ok((src, true));
269    }
270  }
271  Err(ScriptError::internal(
272    "step pattern must be a string or RegExp".to_string(),
273  ))
274}
275
276fn rq(e: &ScriptError) -> rquickjs::Error {
277  rquickjs::Error::new_from_js_message("bdd", "Error", e.message.clone())
278}
279
280fn ctx_of<'js>(args: &[Value<'js>]) -> Result<Ctx<'js>, rquickjs::Error> {
281  args
282    .first()
283    .map(|v| v.ctx().clone())
284    .ok_or_else(|| rq(&ScriptError::internal("missing arguments".to_string())))
285}
286
287fn register_step(kind: StepKind, args: &[Value<'_>]) -> rquickjs::Result<()> {
288  let ctx = ctx_of(args)?;
289  let pattern = args
290    .first()
291    .ok_or_else(|| rq(&ScriptError::internal("step pattern missing".to_string())))?;
292  let (pat, is_regex) = pattern_of(pattern).map_err(|e| rq(&e))?;
293  // `Given(pattern, fn)` or `Given(pattern, options, fn)`: the body is
294  // the last function argument.
295  let func = args
296    .iter()
297    .skip(1)
298    .rev()
299    .find_map(as_function)
300    .ok_or_else(|| rq(&ScriptError::internal(format!("step `{pat}` has no function body"))))?;
301  let timeout_ms = timeout_from_opts(&args[1..]);
302  let saved = Persistent::save(&ctx, func);
303  with_registry(&ctx, |reg| {
304    reg.steps.push(StepReg {
305      kind,
306      pattern: pat,
307      is_regex,
308      func: saved,
309      timeout_ms,
310    });
311  })
312  .map_err(|e| rq(&e))
313}
314
315fn register_hook(kind: &str, args: &[Value<'_>]) -> rquickjs::Result<()> {
316  let ctx = ctx_of(args)?;
317  let first = args
318    .first()
319    .ok_or_else(|| rq(&ScriptError::internal(format!("{kind} hook missing"))))?;
320  let (tags, func) = if let Some(f) = as_function(first) {
321    (None, f)
322  } else {
323    let tags = if let Some(s) = first.as_string() {
324      Some(s.to_string().map_err(|e| rq(&ScriptError::internal(e.to_string())))?)
325    } else if let Some(o) = first.as_object() {
326      o.get::<_, String>("tags").ok()
327    } else {
328      None
329    };
330    let f = args
331      .iter()
332      .skip(1)
333      .find_map(as_function)
334      .ok_or_else(|| rq(&ScriptError::internal(format!("{kind} hook has no function body"))))?;
335    (tags, f)
336  };
337  let timeout_ms = timeout_from_opts(args);
338  let saved = Persistent::save(&ctx, func);
339  with_registry(&ctx, |reg| {
340    reg.hooks.push(HookReg {
341      kind: kind.to_string(),
342      tags,
343      func: saved,
344      timeout_ms,
345    });
346  })
347  .map_err(|e| rq(&e))
348}
349
350fn value_bytes(v: &Value<'_>) -> Option<Vec<u8>> {
351  if let Ok(ta) = TypedArray::<u8>::from_value(v.clone())
352    && let Some(b) = ta.as_bytes()
353  {
354    return Some(b.to_vec());
355  }
356  if let Some(obj) = v.as_object()
357    && let Some(buf) = ArrayBuffer::from_object(obj.clone())
358    && let Some(b) = buf.as_bytes()
359  {
360    return Some(b.to_vec());
361  }
362  None
363}
364
365/// `this.attach(data, mediaType?)` / `this.log(...)` adapter. Mirrors
366/// cucumber-js: a string attaches as `text/plain` (override via
367/// `mediaType`), a `Uint8Array`/`ArrayBuffer` as
368/// `application/octet-stream`, anything else JSON-encoded as
369/// `application/json`. `log` joins its args as
370/// `text/x.cucumber.log+plain`. Same `Rest`-derived single-`'js`
371/// pattern as `register_step`.
372fn register_attachment(args: &[Value<'_>], is_log: bool) -> rquickjs::Result<()> {
373  let ctx = ctx_of(args)?;
374  let media_arg = args.get(1).and_then(Value::as_string).and_then(|s| s.to_string().ok());
375
376  let (bytes, media): (Vec<u8>, String) = if is_log {
377    let text = args
378      .iter()
379      .map(|v| {
380        v.as_string().and_then(|s| s.to_string().ok()).unwrap_or_else(|| {
381          serde_from_js::<serde_json::Value>(&ctx, v.clone())
382            .map(|j| j.to_string())
383            .unwrap_or_default()
384        })
385      })
386      .collect::<Vec<_>>()
387      .join(" ");
388    (text.into_bytes(), "text/x.cucumber.log+plain".to_string())
389  } else {
390    let data = args
391      .first()
392      .cloned()
393      .unwrap_or_else(|| Value::new_undefined(ctx.clone()));
394    if let Some(s) = data.as_string() {
395      let s = s.to_string().map_err(|e| rq(&ScriptError::internal(e.to_string())))?;
396      (s.into_bytes(), media_arg.unwrap_or_else(|| "text/plain".to_string()))
397    } else if let Some(b) = value_bytes(&data) {
398      (b, media_arg.unwrap_or_else(|| "application/octet-stream".to_string()))
399    } else {
400      let j: serde_json::Value = serde_from_js(&ctx, data).map_err(|e| rq(&ScriptError::internal(e.to_string())))?;
401      (
402        serde_json::to_vec(&j).unwrap_or_default(),
403        media_arg.unwrap_or_else(|| "application/json".to_string()),
404      )
405    }
406  };
407
408  with_registry(&ctx, |reg| {
409    reg.attachments.push(ScriptAttachment {
410      media_type: media,
411      bytes,
412    });
413  })
414  .map_err(|e| rq(&e))
415}
416
417/// Drain the scenario's queued attachments (and clear the queue). The
418/// BDD layer calls this after each scenario and forwards them into the
419/// test result so the reporters surface them.
420pub async fn drain_attachments(actx: &AsyncContext) -> Result<Vec<ScriptAttachment>, ScriptError> {
421  async_with!(actx => |ctx| {
422    with_registry(&ctx, |reg| std::mem::take(&mut reg.attachments))
423  })
424  .await
425}
426
427/// Register one MCP tool from a manifest object + handler function.
428/// The single tool-registration path, behind the native `defineTool`
429/// contribution point.
430fn register_tool<'js>(ctx: &Ctx<'js>, m: &Object<'js>, handler: Function<'js>) -> Result<(), ScriptError> {
431  let name: String = m
432    .get("name")
433    .map_err(|e| ScriptError::internal(format!("tool manifest missing string `name`: {e}")))?;
434  if name.trim().is_empty() {
435    return Err(ScriptError::internal(
436      "defineTool: `name` must be a non-empty string".to_string(),
437    ));
438  }
439  let description = m
440    .get::<_, Value<'_>>("description")
441    .ok()
442    .and_then(|v| v.as_string().and_then(|s| s.to_string().ok()));
443  let input_schema = match m.get::<_, Value<'_>>("inputSchema") {
444    Ok(v) if !v.is_undefined() && !v.is_null() => {
445      Some(serde_from_js::<serde_json::Value>(ctx, v).map_err(|e| ScriptError::internal(e.to_string()))?)
446    },
447    _ => None,
448  };
449  let expose_as_tool = m.get::<_, bool>("exposeAsTool").unwrap_or(false);
450  let timeout_ms = m
451    .get::<_, f64>("timeoutMs")
452    .ok()
453    .map(|ms| ms.max(0.0) as u64)
454    .filter(|&v| v > 0);
455
456  let (allowed_commands, allowed_net) = match m.get::<_, Value<'_>>("allow") {
457    Ok(v) => {
458      if let Some(allow) = v.as_object() {
459        // `exec` is the canonical capability name; `commands` is the
460        // back-compat spelling. Either populates the exec allow-list.
461        let commands = ["exec", "commands"]
462          .into_iter()
463          .find_map(|k| match allow.get::<_, Value<'_>>(k) {
464            Ok(c) if c.is_object() => serde_from_js(ctx, c).ok(),
465            _ => None,
466          })
467          .unwrap_or_default();
468        let net = match allow.get::<_, Value<'_>>("net") {
469          Ok(n) if !n.is_undefined() && !n.is_null() => serde_from_js(ctx, n).unwrap_or_default(),
470          _ => Vec::new(),
471        };
472        (commands, net)
473      } else {
474        (std::collections::BTreeMap::new(), Vec::new())
475      }
476    },
477    Err(_) => (std::collections::BTreeMap::new(), Vec::new()),
478  };
479
480  let saved = Persistent::save(ctx, handler);
481  with_registry(ctx, |reg| {
482    if reg.tools.iter().any(|t| t.name == name) {
483      return Err(ScriptError::internal(format!(
484        "defineTool: duplicate tool name `{name}` — names must be unique across all loaded extensions"
485      )));
486    }
487    reg.tools.push(ToolReg {
488      name,
489      description,
490      input_schema,
491      expose_as_tool,
492      allowed_commands,
493      allowed_net,
494      timeout_ms,
495      handler: saved,
496    });
497    Ok(())
498  })?
499}
500
501/// `defineTool(...)` argument adapter. Two equivalent native forms:
502/// `defineTool(tool)` where `tool` carries an inline `handler`, or
503/// `defineTool(manifest, handlerFn)`. Uses the same `Rest`-derived
504/// single-`'js` pattern `register_step`/`register_hook` use so the
505/// `Persistent::save` lifetimes unify. There is no `globalThis.exports`
506/// path — `defineTool` is the only tool-registration surface.
507fn register_tool_args(args: &[Value<'_>]) -> rquickjs::Result<()> {
508  let ctx = ctx_of(args)?;
509  let manifest = args.first().and_then(Value::as_object).ok_or_else(|| {
510    rq(&ScriptError::internal(
511      "defineTool: first arg must be a tool/manifest object".to_string(),
512    ))
513  })?;
514  // Handler: an explicit 2nd-arg function wins; otherwise the tool
515  // object's own `handler` method.
516  let handler = args
517    .iter()
518    .skip(1)
519    .find_map(as_function)
520    .or_else(|| {
521      manifest
522        .get::<_, Value<'_>>("handler")
523        .ok()
524        .and_then(|v| v.as_function().cloned())
525    })
526    .ok_or_else(|| {
527      rq(&ScriptError::internal(
528        "defineTool: no handler — pass defineTool(tool) with a `handler` method or defineTool(manifest, fn)"
529          .to_string(),
530      ))
531    })?;
532  register_tool(&ctx, manifest, handler).map_err(|e| rq(&e))
533}
534
535/// Install the native cucumber + MCP-tool surface and the shared
536/// extension registry as context userdata. Idempotent; called once at
537/// `Session::create`.
538pub fn install_bdd(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
539  if ctx.userdata::<BddUserData>().is_some() {
540    return Ok(());
541  }
542  let _ = ctx.store_userdata(BddUserData(RefCell::new(ExtensionRegistry {
543    default_timeout_ms: 5000,
544    ..ExtensionRegistry::default()
545  })));
546
547  let g = ctx.globals();
548  Class::<DataTableJs>::define(&g)?;
549
550  for (name, kind) in [
551    ("Given", StepKind::Given),
552    ("When", StepKind::When),
553    ("Then", StepKind::Then),
554    ("defineStep", StepKind::Step),
555    ("And", StepKind::Step),
556    ("But", StepKind::Step),
557  ] {
558    g.set(
559      name,
560      Func::from(move |args: Rest<Value<'_>>| register_step(kind, &args.0)),
561    )?;
562  }
563
564  for hook in ["Before", "After", "BeforeAll", "AfterAll", "BeforeStep", "AfterStep"] {
565    g.set(
566      hook,
567      Func::from(move |args: Rest<Value<'_>>| register_hook(hook, &args.0)),
568    )?;
569  }
570
571  g.set(
572    "defineParameterType",
573    Func::from(|def: Object<'_>| -> rquickjs::Result<()> {
574      let ctx = def.ctx().clone();
575      let name: String = def.get("name").map_err(|e| rq(&ScriptError::internal(e.to_string())))?;
576      let rx_val: Value<'_> = def
577        .get("regexp")
578        .map_err(|e| rq(&ScriptError::internal(e.to_string())))?;
579      let regexp = if let Some(s) = rx_val.as_string() {
580        s.to_string().map_err(|e| rq(&ScriptError::internal(e.to_string())))?
581      } else if let Some(o) = rx_val.as_object() {
582        o.get::<_, String>("source")
583          .map_err(|e| rq(&ScriptError::internal(e.to_string())))?
584      } else {
585        return Err(rq(&ScriptError::internal(
586          "parameter type regexp must be string or RegExp".to_string(),
587        )));
588      };
589      let transformer = def
590        .get::<_, Value<'_>>("transformer")
591        .ok()
592        .and_then(|v| v.as_function().cloned())
593        .map(|f| Persistent::save(&ctx, f));
594      with_registry(&ctx, |reg| {
595        reg.param_types.push(ParamTypeReg {
596          name,
597          regexp,
598          transformer,
599        });
600      })
601      .map_err(|e| rq(&e))
602    }),
603  )?;
604
605  g.set(
606    "setDefaultTimeout",
607    Func::from(|ctx: Ctx<'_>, ms: f64| -> rquickjs::Result<()> {
608      with_registry(&ctx, |reg| reg.default_timeout_ms = ms.max(0.0) as u64).map_err(|e| rq(&e))
609    }),
610  )?;
611  g.set(
612    "setDefinitionFunctionWrapper",
613    Func::from(|w: Function<'_>| -> rquickjs::Result<()> {
614      let ctx = w.ctx().clone();
615      let saved = Persistent::save(&ctx, w);
616      with_registry(&ctx, |reg| reg.def_fn_wrapper = Some(saved)).map_err(|e| rq(&e))
617    }),
618  )?;
619  g.set(
620    "setWorldConstructor",
621    Func::from(|c: Constructor<'_>| -> rquickjs::Result<()> {
622      let ctx = c.ctx().clone();
623      let saved = Persistent::save(&ctx, c);
624      with_registry(&ctx, |reg| reg.world_ctor = Some(saved)).map_err(|e| rq(&e))
625    }),
626  )?;
627  // `setParallelCanAssign` is accepted (so cucumber-js suites that call
628  // it don't break) but intentionally inert: it governs cucumber-js's
629  // own pickle-level parallel scheduler, whereas ferridriver
630  // parallelises at the `ferridriver-test` worker level (one VM per
631  // worker) with no equivalent per-pickle assignment hook. A real
632  // implementation would be a cross-worker scheduler rework with no
633  // proportionate value — documented-inert, not a stub claiming to work.
634  g.set("setParallelCanAssign", Func::from(|_: Opt<Value<'_>>| {}))?;
635
636  // MCP tool contribution point — the only tool-registration surface.
637  // `defineTool(tool)` (inline `handler`) or `defineTool(manifest, fn)`.
638  g.set(
639    "defineTool",
640    Func::from(|args: Rest<Value<'_>>| register_tool_args(&args.0)),
641  )?;
642
643  Ok(())
644}
645
646/// Step metadata read back by the `ferridriver-bdd` core to build its
647/// Cucumber-Expression registry. Straight off the Rust registry — no JS
648/// round-trip.
649#[derive(Debug, Clone)]
650pub struct CollectedStep {
651  pub kind: String,
652  pub pattern: String,
653  pub is_regex: bool,
654}
655
656#[derive(Debug, Clone)]
657pub struct CollectedHook {
658  pub hook_type: String,
659  pub tags: Option<String>,
660}
661
662#[derive(Debug, Clone)]
663pub struct CollectedParamType {
664  pub name: String,
665  pub regexp: String,
666}
667
668#[derive(Debug, Clone)]
669pub struct CollectedRegistry {
670  pub default_timeout_ms: u64,
671  pub steps: Vec<CollectedStep>,
672  pub hooks: Vec<CollectedHook>,
673  pub param_types: Vec<CollectedParamType>,
674}
675
676/// Snapshot the registry after the step `.js` files evaluated.
677pub async fn collect_registry(actx: &AsyncContext) -> Result<CollectedRegistry, ScriptError> {
678  async_with!(actx => |ctx| {
679    with_registry(&ctx, |reg| CollectedRegistry {
680      default_timeout_ms: reg.default_timeout_ms,
681      steps: reg
682        .steps
683        .iter()
684        .map(|s| CollectedStep {
685          kind: s.kind.as_str().to_string(),
686          pattern: s.pattern.clone(),
687          is_regex: s.is_regex,
688        })
689        .collect(),
690      hooks: reg
691        .hooks
692        .iter()
693        .map(|h| CollectedHook {
694          hook_type: h.kind.clone(),
695          tags: h.tags.clone(),
696        })
697        .collect(),
698      param_types: reg
699        .param_types
700        .iter()
701        .map(|p| CollectedParamType {
702          name: p.name.clone(),
703          regexp: p.regexp.clone(),
704        })
705        .collect(),
706    })
707  })
708  .await
709}
710
711/// Capability allow-list snapshot. Serialises to the exact JSON the MCP
712/// `PluginAllow` deserialises (`commands` + `net`, camelCase) so the
713/// loader needs no JS round-trip to recover manifests.
714#[derive(Debug, Clone, serde::Serialize)]
715pub struct CollectedAllow {
716  pub commands: std::collections::BTreeMap<String, crate::command_spec::CommandSpec>,
717  pub net: Vec<String>,
718}
719
720/// One registered tool's manifest, read straight off the Rust registry.
721/// Field layout + `camelCase` match MCP `PluginManifest` so a
722/// `serde_json` round-trip reconstructs it without re-running the plugin.
723#[derive(Debug, Clone, serde::Serialize)]
724#[serde(rename_all = "camelCase")]
725pub struct CollectedTool {
726  pub name: String,
727  #[serde(skip_serializing_if = "Option::is_none")]
728  pub description: Option<String>,
729  #[serde(skip_serializing_if = "Option::is_none")]
730  pub input_schema: Option<serde_json::Value>,
731  pub allow: CollectedAllow,
732  pub expose_as_tool: bool,
733  #[serde(skip_serializing_if = "Option::is_none")]
734  pub timeout_ms: Option<u64>,
735}
736
737/// Snapshot every registered tool manifest, in registration order.
738/// Synchronous (`&Ctx`) so the bundle/extraction path can call it inside
739/// its own `async_with!` without a second context hop.
740pub fn tools_snapshot(ctx: &Ctx<'_>) -> Result<Vec<CollectedTool>, ScriptError> {
741  with_registry(ctx, |reg| {
742    reg
743      .tools
744      .iter()
745      .map(|t| CollectedTool {
746        name: t.name.clone(),
747        description: t.description.clone(),
748        input_schema: t.input_schema.clone(),
749        allow: CollectedAllow {
750          commands: t.allowed_commands.clone(),
751          net: t.allowed_net.clone(),
752        },
753        expose_as_tool: t.expose_as_tool,
754        timeout_ms: t.timeout_ms,
755      })
756      .collect()
757  })
758}
759
760/// Number of tools registered so far — lets the loader slice each
761/// bundled file's contributions out of the shared registry.
762pub fn tools_len(ctx: &Ctx<'_>) -> Result<usize, ScriptError> {
763  with_registry(ctx, |reg| reg.tools.len())
764}
765
766/// The ordered tool names — drives building the native `plugins.<name>`
767/// surface.
768pub fn tool_names(ctx: &Ctx<'_>) -> Result<Vec<String>, ScriptError> {
769  with_registry(ctx, |reg| reg.tools.iter().map(|t| t.name.clone()).collect())
770}
771
772/// A tool's restored handler + its capability allow-lists, looked up by
773/// registration index. Used by the native `plugins.<name>` dispatch in
774/// `plugins.rs` — the analogue of `invoke_step`'s registry lookup.
775pub(crate) struct ToolDispatch<'js> {
776  pub handler: Function<'js>,
777  pub allowed_commands: std::collections::BTreeMap<String, crate::command_spec::CommandSpec>,
778  pub allowed_net: Vec<String>,
779  pub timeout_ms: Option<u64>,
780}
781
782pub(crate) fn tool_dispatch<'js>(ctx: &Ctx<'js>, idx: usize) -> Result<ToolDispatch<'js>, ScriptError> {
783  let (saved, allowed_commands, allowed_net, timeout_ms) = with_registry(ctx, |reg| {
784    reg
785      .tools
786      .get(idx)
787      .map(|t| {
788        (
789          t.handler.clone(),
790          t.allowed_commands.clone(),
791          t.allowed_net.clone(),
792          t.timeout_ms,
793        )
794      })
795      .ok_or_else(|| ScriptError::internal(format!("tool index {idx} out of range")))
796  })??;
797  let handler = saved.restore(ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
798  Ok(ToolDispatch {
799    handler,
800    allowed_commands,
801    allowed_net,
802    timeout_ms,
803  })
804}
805
806/// Per-scenario fixtures the BDD core threads onto the JS World — the
807/// same handles `RunContext` carries for scripting, installed onto a
808/// per-scenario World object rather than `globalThis`.
809#[derive(Clone, Default)]
810pub struct ScenarioWorld {
811  pub page: Option<Arc<ferridriver::Page>>,
812  pub context: Option<Arc<ferridriver::context::ContextRef>>,
813  pub request: Option<Arc<ferridriver::http_client::HttpClient>>,
814  pub browser: Option<Arc<ferridriver::Browser>>,
815  /// Cucumber `--world-parameters` (top-level config / CLI). Exposed as
816  /// `this.parameters` and passed to a `setWorldConstructor` ctor as
817  /// `{ parameters }`. `None`/`Null` ⇒ `{}`.
818  pub parameters: Option<serde_json::Value>,
819}
820
821/// Build the per-scenario World and make it the `this` steps run
822/// against. If `setWorldConstructor` was used, that class is
823/// constructed and the fixtures are augmented onto the instance.
824pub async fn set_scenario_world(actx: &AsyncContext, world: &ScenarioWorld) -> Result<(), ScriptError> {
825  let world = world.clone();
826  let route_ctx = actx.clone();
827  async_with!(actx => |ctx| {
828    let ctor = with_registry(&ctx, |reg| reg.world_ctor.clone())?;
829
830    // `this.parameters` (cucumber `--world-parameters`). Built once;
831    // passed to a custom World ctor as `{ parameters }` and set on the
832    // instance regardless (cucumber-js always populates it).
833    let params_val: Value<'_> = match &world.parameters {
834      Some(v) if !v.is_null() => serde_to_js(&ctx, v).map_err(|e| ScriptError::internal(e.to_string()))?,
835      _ => Object::new(ctx.clone())
836        .map_err(|e| ScriptError::internal(e.to_string()))?
837        .into_value(),
838    };
839
840    let obj: Object<'_> = if let Some(ctor) = ctor {
841      let ctor = ctor.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
842      let opts = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
843      opts
844        .set("parameters", params_val.clone())
845        .map_err(|e| ScriptError::internal(e.to_string()))?;
846      ctor
847        .construct::<_, Object<'_>>((opts,))
848        .map_err(|e| ScriptError::internal(format!("World constructor: {e}")))?
849    } else {
850      Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?
851    };
852
853    obj
854      .set("parameters", params_val)
855      .map_err(|e| ScriptError::internal(e.to_string()))?;
856    // Native Cucumber `this.attach` / `this.log` — queue into the
857    // registry; the BDD layer drains them into the test result.
858    let attach = Function::new(ctx.clone(), |args: Rest<Value<'_>>| register_attachment(&args.0, false))
859      .map_err(|e| ScriptError::internal(e.to_string()))?;
860    let log = Function::new(ctx.clone(), |args: Rest<Value<'_>>| register_attachment(&args.0, true))
861      .map_err(|e| ScriptError::internal(e.to_string()))?;
862    obj.set("attach", attach).map_err(|e| ScriptError::internal(e.to_string()))?;
863    obj.set("log", log).map_err(|e| ScriptError::internal(e.to_string()))?;
864    // Cucumber `this.skip()` — throws a sentinel the step bridge maps
865    // to `Skipped` (cucumber aborts the step as skipped on throw).
866    let skip = Function::new(ctx.clone(), || -> rquickjs::Result<()> {
867      Err(rquickjs::Error::new_from_js_message("World", "Error", SKIP_SENTINEL.to_string()))
868    })
869    .map_err(|e| ScriptError::internal(e.to_string()))?;
870    obj.set("skip", skip).map_err(|e| ScriptError::internal(e.to_string()))?;
871
872    if let Some(page) = world.page {
873      install_page_on(&ctx, &obj, page, route_ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
874    }
875    if let Some(c) = world.context {
876      install_browser_context_on(&ctx, &obj, c).map_err(|e| ScriptError::internal(e.to_string()))?;
877    }
878    if let Some(r) = world.request {
879      install_request_on(&ctx, &obj, r).map_err(|e| ScriptError::internal(e.to_string()))?;
880    }
881    if let Some(b) = world.browser {
882      install_browser_on(&ctx, &obj, b).map_err(|e| ScriptError::internal(e.to_string()))?;
883    }
884
885    let saved = Persistent::save(&ctx, obj);
886    with_registry(&ctx, |reg| reg.current_world = Some(saved))
887  })
888  .await
889}
890
891/// Drop the per-scenario World (cucumber builds a fresh one per
892/// scenario). The next [`set_scenario_world`] installs a new one.
893pub async fn reset_world(actx: &AsyncContext) -> Result<(), ScriptError> {
894  async_with!(actx => |ctx| {
895    with_registry(&ctx, |reg| {
896      reg.current_world = None;
897      reg.attachments.clear();
898    })
899  })
900  .await
901}
902
903/// A cucumber-extracted step argument, lowered directly to a JS value
904/// (never through `serde_json` — a transitive dep may enable
905/// `serde_json/arbitrary_precision`, which would turn numbers into
906/// objects).
907#[derive(Debug, Clone)]
908pub enum JsArg {
909  Str(String),
910  Int(i64),
911  Float(f64),
912  /// A custom cucumber-expression parameter. If its parameter type was
913  /// defined with a `transformer`, that JS fn runs on `raw` at step
914  /// invocation and the result is passed to the step; otherwise `raw`
915  /// is passed as a string.
916  Custom {
917    type_name: String,
918    raw: String,
919  },
920}
921
922/// Outcome of a JS step/hook beyond plain pass (cucumber return
923/// protocol: returning the string `'pending'`/`'skipped'`).
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925pub enum StepOutcome {
926  Passed,
927  Pending,
928  Skipped,
929}
930
931/// Invoke step `idx` with cucumber-extracted args, the optional data
932/// table and doc string, against the current World. A thrown JS error
933/// becomes a [`ScriptError`] carrying the `.js` location.
934pub async fn invoke_step(
935  actx: &AsyncContext,
936  idx: usize,
937  params: &[JsArg],
938  data_table: Option<&[Vec<String>]>,
939  doc_string: Option<&str>,
940  source: &str,
941) -> Result<StepOutcome, ScriptError> {
942  let params = params.to_vec();
943  let data_table = data_table.map(<[Vec<String>]>::to_vec);
944  let doc_string = doc_string.map(str::to_string);
945  let source = source.to_string();
946
947  async_with!(actx => |ctx| {
948    let (func, world, wrapper, timeout_ms) = with_registry(&ctx, |reg| {
949      let step = reg
950        .steps
951        .get(idx)
952        .ok_or_else(|| ScriptError::internal(format!("step index {idx} out of range")))?;
953      let t = step.timeout_ms.or(Some(reg.default_timeout_ms)).filter(|&v| v > 0);
954      Ok::<_, ScriptError>((step.func.clone(), reg.current_world.clone(), reg.def_fn_wrapper.clone(), t))
955    })??;
956
957    let mut func = func.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
958    // `setDefinitionFunctionWrapper`: replace the step body with
959    // `wrapper(stepFn)` (cucumber-js cross-cut hook).
960    if let Some(w) = wrapper {
961      let w = w.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
962      func = w
963        .call::<_, Function<'_>>((func.clone(),))
964        .catch(&ctx)
965        .map_err(|e| caught_to_script_error(e, &source))?;
966    }
967    let world_obj = match world {
968      Some(w) => w.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?,
969      None => Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?,
970    };
971
972    // The per-scenario World is always the FIRST positional argument
973    // and is also bound as `this`. Same shape for every body — arrow,
974    // classic `function`, async, shorthand methods.
975    //
976    //   Given("I have {int} cukes", (world, n) => { world.count = n; })
977    //   Given("I have {int} cukes", function (world, n) { this.count = n; })
978    //
979    // Both work identically; the second form just chooses to use `this`
980    // instead of the first arg.
981    let n = 1 + params.len() + usize::from(data_table.is_some()) + usize::from(doc_string.is_some());
982    let mut args = Args::new(ctx.clone(), n);
983    args
984      .this(world_obj.clone())
985      .map_err(|e| ScriptError::internal(e.to_string()))?;
986    args
987      .push_arg(world_obj)
988      .map_err(|e| ScriptError::internal(e.to_string()))?;
989    for p in &params {
990      match p {
991        JsArg::Str(s) => args.push_arg(s.as_str()).map_err(|e| ScriptError::internal(e.to_string()))?,
992        JsArg::Int(i) => args.push_arg(*i).map_err(|e| ScriptError::internal(e.to_string()))?,
993        JsArg::Float(f) => args.push_arg(*f).map_err(|e| ScriptError::internal(e.to_string()))?,
994        JsArg::Custom { type_name, raw } => {
995          // Apply the parameter type's JS `transformer` (if any) here,
996          // in the live ctx, at step invocation — same place cucumber-js
997          // transforms. No transformer ⇒ pass the raw string.
998          let tx = with_registry(&ctx, |reg| {
999            reg
1000              .param_types
1001              .iter()
1002              .find(|pt| &pt.name == type_name)
1003              .and_then(|pt| pt.transformer.clone())
1004          })?;
1005          match tx {
1006            Some(saved) => {
1007              let f = saved.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
1008              let call: rquickjs::Result<rquickjs::promise::MaybePromise<'_>> = f.call((raw.as_str(),));
1009              let mp = call.catch(&ctx).map_err(|e| caught_to_script_error(e, &source))?;
1010              let v: Value<'_> = mp
1011                .into_future::<Value<'_>>()
1012                .await
1013                .catch(&ctx)
1014                .map_err(|e| caught_to_script_error(e, &source))?;
1015              args.push_arg(v).map_err(|e| ScriptError::internal(e.to_string()))?;
1016            },
1017            None => args
1018              .push_arg(raw.as_str())
1019              .map_err(|e| ScriptError::internal(e.to_string()))?,
1020          }
1021        },
1022      }
1023    }
1024    if let Some(rows) = data_table {
1025      let dt = Class::instance(ctx.clone(), DataTableJs { rows }).map_err(|e| ScriptError::internal(e.to_string()))?;
1026      args.push_arg(dt).map_err(|e| ScriptError::internal(e.to_string()))?;
1027    }
1028    if let Some(s) = doc_string {
1029      args.push_arg(s).map_err(|e| ScriptError::internal(e.to_string()))?;
1030    }
1031
1032    let called: rquickjs::Result<rquickjs::promise::MaybePromise<'_>> = args.apply(&func);
1033    let mp = match called.catch(&ctx) {
1034      Ok(v) => v,
1035      Err(e) => return Err(caught_to_script_error(e, &source)),
1036    };
1037    // Per-step (or registry-default) timeout — JS steps had none before.
1038    let fut = mp.into_future::<Value<'_>>();
1039    let awaited = match timeout_ms {
1040      Some(t) => match tokio::time::timeout(std::time::Duration::from_millis(t), fut).await {
1041        Ok(r) => r,
1042        Err(_) => return Err(ScriptError::timeout(t, t)),
1043      },
1044      None => fut.await,
1045    };
1046    let resolved: Value<'_> = match awaited.catch(&ctx) {
1047      Ok(v) => v,
1048      Err(e) => {
1049        let se = caught_to_script_error(e, &source);
1050        // `this.skip()` throws the sentinel → cucumber-style Skipped,
1051        // not a failure.
1052        if se.message.contains(SKIP_SENTINEL) {
1053          return Ok(StepOutcome::Skipped);
1054        }
1055        return Err(se);
1056      },
1057    };
1058    let marker = resolved.as_string().and_then(|s| s.to_string().ok());
1059    Ok(match marker.as_deref() {
1060      Some("pending") => StepOutcome::Pending,
1061      Some("skipped") => StepOutcome::Skipped,
1062      _ => StepOutcome::Passed,
1063    })
1064  })
1065  .await
1066}
1067
1068/// Invoke hook `idx`. Same bridge as [`invoke_step`].
1069pub async fn invoke_hook(
1070  actx: &AsyncContext,
1071  idx: usize,
1072  arg: Option<&HookArg>,
1073  source: &str,
1074) -> Result<StepOutcome, ScriptError> {
1075  let source = source.to_string();
1076  let arg = arg.cloned();
1077  async_with!(actx => |ctx| {
1078    let (func, world, timeout_ms) = with_registry(&ctx, |reg| {
1079      let hook = reg
1080        .hooks
1081        .get(idx)
1082        .ok_or_else(|| ScriptError::internal(format!("hook index {idx} out of range")))?;
1083      let t = hook.timeout_ms.or(Some(reg.default_timeout_ms)).filter(|&v| v > 0);
1084      Ok::<_, ScriptError>((hook.func.clone(), reg.current_world.clone(), t))
1085    })??;
1086    let func = func.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?;
1087    let world_obj = match world {
1088      Some(w) => w.restore(&ctx).map_err(|e| ScriptError::internal(e.to_string()))?,
1089      None => Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?,
1090    };
1091    // Hooks: World is always arg[0] and `this`. The cucumber-shaped
1092    // hook parameter (`{ pickle, result }`) follows as arg[1] when
1093    // present (`After(world, hookInfo)`).
1094    let n_args = 1 + usize::from(arg.is_some());
1095    let mut args = Args::new(ctx.clone(), n_args);
1096    args
1097      .this(world_obj.clone())
1098      .map_err(|e| ScriptError::internal(e.to_string()))?;
1099    args
1100      .push_arg(world_obj)
1101      .map_err(|e| ScriptError::internal(e.to_string()))?;
1102    if let Some(a) = arg {
1103      let param = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1104      let pickle = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1105      pickle.set("name", a.name).map_err(|e| ScriptError::internal(e.to_string()))?;
1106      let tags = rquickjs::Array::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1107      for (i, t) in a.tags.iter().enumerate() {
1108        let to = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1109        to.set("name", t.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1110        tags.set(i, to).map_err(|e| ScriptError::internal(e.to_string()))?;
1111      }
1112      pickle.set("tags", tags).map_err(|e| ScriptError::internal(e.to_string()))?;
1113      let result = Object::new(ctx.clone()).map_err(|e| ScriptError::internal(e.to_string()))?;
1114      result.set("status", a.status).map_err(|e| ScriptError::internal(e.to_string()))?;
1115      if let Some(m) = a.message {
1116        result.set("message", m).map_err(|e| ScriptError::internal(e.to_string()))?;
1117      }
1118      param.set("pickle", pickle).map_err(|e| ScriptError::internal(e.to_string()))?;
1119      param.set("result", result).map_err(|e| ScriptError::internal(e.to_string()))?;
1120      args.push_arg(param).map_err(|e| ScriptError::internal(e.to_string()))?;
1121    }
1122    let called: rquickjs::Result<rquickjs::promise::MaybePromise<'_>> = args.apply(&func);
1123    let mp = match called.catch(&ctx) {
1124      Ok(v) => v,
1125      Err(e) => return Err(caught_to_script_error(e, &source)),
1126    };
1127    let fut = mp.into_future::<Value<'_>>();
1128    let awaited = match timeout_ms {
1129      Some(t) => match tokio::time::timeout(std::time::Duration::from_millis(t), fut).await {
1130        Ok(r) => r,
1131        Err(_) => return Err(ScriptError::timeout(t, t)),
1132      },
1133      None => fut.await,
1134    };
1135    if let Err(e) = awaited.catch(&ctx) {
1136      return Err(caught_to_script_error(e, &source));
1137    }
1138    Ok(StepOutcome::Passed)
1139  })
1140  .await
1141}