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"#;