Skip to main content

ferridriver_test/ct/
mod.rs

1//! Component testing core (ct-core).
2//!
3//! Architecture mirrors Playwright CT:
4//!
5//! 1. **Import rewriting**: Component imports in test files become `ImportRef`
6//!    descriptors (`{ type: 'importRef', id: 'src_Button_tsx' }`).
7//!
8//! 2. **Registry injection**: A Vite/Trunk plugin injects lazy `import()` calls
9//!    for every referenced component into a browser-side registry.
10//!
11//! 3. **Mount via evaluate**: `mount()` serializes the component + props, sends
12//!    them to the browser via `page.evaluate()`, which calls the framework's
13//!    `window.__ferriMount(component, rootElement)`.
14//!
15//! 4. **Framework adapters**: Each framework provides a `registerSource` that
16//!    implements `window.__ferriMount/Update/Unmount`.
17//!
18//! ## Rust (WASM) frameworks
19//!
20//! For Leptos/Dioxus/Yew, the flow is different — no Vite, no import rewriting.
21//! The adapter crate provides a proc macro that generates the WASM entry point,
22//! and `trunk serve` or `dx serve` handles building + serving.
23//!
24//! ## File layout
25//!
26//! ```text
27//! ct/
28//!   mod.rs        — this file (types + mount logic)
29//!   server.rs     — ComponentServer (static file HTTP server)
30//!   devserver.rs  — DevServer manager (spawns trunk/dx/vite, discovers URL)
31//!   injected.js   — Browser-side registry + deserializer (injected into page)
32//! ```
33
34pub mod devserver;
35pub mod server;
36
37use std::collections::HashMap;
38
39use crate::model::TestFailure;
40
41/// A component reference — serialized form sent from test to browser.
42/// The browser-side registry resolves this to the actual module via dynamic import().
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ComponentRef {
45  /// Import ID (e.g. "src_Counter_tsx" or "src_Counter_tsx_Counter").
46  pub id: String,
47  /// Props to pass to the component (JSON-serializable).
48  #[serde(default)]
49  pub props: serde_json::Value,
50  /// Children (nested ComponentRefs or strings).
51  #[serde(default)]
52  pub children: Vec<serde_json::Value>,
53}
54
55/// Options passed to mount().
56#[derive(Debug, Clone, Default, serde::Serialize)]
57pub struct MountOptions {
58  /// Props for the component.
59  #[serde(default)]
60  pub props: serde_json::Value,
61  /// Hook config passed to beforeMount/afterMount.
62  #[serde(default, skip_serializing_if = "HashMap::is_empty")]
63  pub hooks_config: HashMap<String, serde_json::Value>,
64}
65
66/// Mount a component in the browser.
67///
68/// This is the core mount operation. It:
69/// 1. Navigates to the dev server URL (if not already there)
70/// 2. Waits for the registry to be ready (`window.__ferriRegistry`)
71/// 3. Calls `page.evaluate()` to invoke `window.__ferriMount(componentRef, rootElement)`
72/// 4. Returns a locator pointing at the mounted component root
73///
74/// The framework adapter's `registerSource` must define `window.__ferriMount`.
75pub async fn mount(
76  page: &std::sync::Arc<ferridriver::Page>,
77  _base_url: &str,
78  component: &ComponentRef,
79  options: &MountOptions,
80) -> Result<ferridriver::Locator, TestFailure> {
81  // Serialize component + options and send to browser.
82  // The caller is responsible for navigating to the dev server URL first.
83  let payload = serde_json::json!({
84    "component": component,
85    "options": options,
86  });
87
88  let escaped_json = payload.to_string().replace('\\', "\\\\").replace('`', "\\`");
89  let js = format!(
90    r#"(() => {{
91      const data = JSON.parse(`{escaped_json}`);
92      const root = document.getElementById('root') || document.getElementById('app');
93      if (!root) throw new Error('No #root or #app element found');
94      window.__ferriMount(data.component, root, data.options);
95      return root.innerHTML;
96    }})()"#,
97  );
98
99  let eval_result = page
100    .evaluate(&js, ferridriver::protocol::SerializedArgument::default(), None)
101    .await;
102  eval_result.map_err(|e| TestFailure {
103    message: format!("mount failed: {e}"),
104    stack: None,
105    diff: None,
106    screenshot: None,
107  })?;
108
109  // Return a locator pointing at the component root.
110  Ok(page.locator("#root, #app", None))
111}
112
113/// Unmount the currently mounted component.
114pub async fn unmount(page: &std::sync::Arc<ferridriver::Page>) -> Result<(), TestFailure> {
115  page
116    .evaluate(
117      "() => { if (window.__ferriUnmount) window.__ferriUnmount(); }",
118      ferridriver::protocol::SerializedArgument::default(),
119      None,
120    )
121    .await
122    .map_err(|e| TestFailure {
123      message: format!("unmount failed: {e}"),
124      stack: None,
125      diff: None,
126      screenshot: None,
127    })?;
128  Ok(())
129}
130
131/// The browser-side JavaScript that sets up the import registry.
132/// Framework adapters append their `registerSource` after this.
133pub const INJECTED_REGISTRY_JS: &str = r#"
134// ferridriver CT: import registry + component deserializer.
135window.__ferriRegistry = {};
136
137window.__ferriRegister = function(id, importFn) {
138  window.__ferriRegistry[id] = importFn;
139};
140
141// Resolve an importRef to the actual module.
142window.__ferriResolve = async function(ref) {
143  const loader = window.__ferriRegistry[ref.id];
144  if (!loader) throw new Error(`Component not registered: ${ref.id}`);
145  return await loader();
146};
147"#;