Skip to main content

earl_protocol_browser/
builder.rs

1use anyhow::{Result, bail};
2use earl_core::render::TemplateRenderer;
3use serde_json::Value;
4
5use crate::PreparedBrowserCommand;
6use crate::schema::{BrowserOperationTemplate, BrowserStep};
7
8const DEFAULT_SESSION_ID: &str = "default";
9
10/// Build a `PreparedBrowserCommand` from a `BrowserOperationTemplate` by
11/// rendering all Jinja template strings with the given context.
12pub fn build_browser_request(
13    op: &BrowserOperationTemplate,
14    context: &Value,
15    renderer: &dyn TemplateRenderer,
16) -> Result<PreparedBrowserCommand> {
17    let tmpl = &op.browser;
18
19    if tmpl.steps.is_empty() {
20        bail!("operation.browser.steps must not be empty");
21    }
22
23    let session_id = match tmpl.session_id.as_deref() {
24        None => Some(DEFAULT_SESSION_ID.to_string()),
25        Some(s) => {
26            let rendered = renderer.render_str(s, context)?;
27            if rendered.is_empty() {
28                None
29            } else {
30                Some(rendered)
31            }
32        }
33    };
34
35    // Render all string fields in each step via the renderer.
36    let steps: Vec<BrowserStep> = tmpl
37        .steps
38        .iter()
39        .map(|step| render_step(step, context, renderer))
40        .collect::<Result<_>>()?;
41
42    Ok(PreparedBrowserCommand {
43        session_id,
44        headless: tmpl.headless,
45        timeout_ms: tmpl.timeout_ms,
46        on_failure_screenshot: tmpl.on_failure_screenshot,
47        steps,
48    })
49}
50
51/// Render all Jinja template strings within a step by round-tripping through
52/// `serde_json::Value` and walking every string node through the renderer.
53fn render_step(step: &BrowserStep, ctx: &Value, r: &dyn TemplateRenderer) -> Result<BrowserStep> {
54    let mut val = serde_json::to_value(step)?;
55    render_strings_in_value(&mut val, ctx, r)?;
56    Ok(serde_json::from_value(val)?)
57}
58
59fn render_strings_in_value(v: &mut Value, ctx: &Value, r: &dyn TemplateRenderer) -> Result<()> {
60    match v {
61        Value::String(s) => *s = r.render_str(s, ctx)?,
62        Value::Object(map) => {
63            for (k, val) in map.iter_mut() {
64                if k == "action" {
65                    // Never render the serde tag discriminant - it must stay unchanged
66                    continue;
67                }
68                render_strings_in_value(val, ctx, r)?;
69            }
70        }
71        Value::Array(arr) => {
72            for val in arr.iter_mut() {
73                render_strings_in_value(val, ctx, r)?;
74            }
75        }
76        _ => {}
77    }
78    Ok(())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    struct PassthroughRenderer;
86
87    impl TemplateRenderer for PassthroughRenderer {
88        fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
89            Ok(t.to_string())
90        }
91
92        fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
93            Ok(v.clone())
94        }
95    }
96
97    #[test]
98    fn build_renders_session_id_and_url() {
99        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
100            r#"{
101                "browser": {
102                    "session_id": "my-session",
103                    "steps": [{"action":"navigate","url":"https://example.com"}]
104                }
105            }"#,
106        )
107        .unwrap();
108        let ctx = serde_json::json!({"args": {}, "secrets": {}});
109        let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
110        assert_eq!(cmd.session_id.as_deref(), Some("my-session"));
111        assert_eq!(cmd.steps.len(), 1);
112    }
113
114    #[test]
115    fn empty_steps_returns_error() {
116        let op: crate::schema::BrowserOperationTemplate =
117            serde_json::from_str(r#"{"browser": {"steps": []}}"#).unwrap();
118        let ctx = serde_json::json!({});
119        assert!(build_browser_request(&op, &ctx, &PassthroughRenderer).is_err());
120    }
121
122    #[test]
123    fn empty_session_id_becomes_none() {
124        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
125            r#"{
126                "browser": {
127                    "session_id": "",
128                    "steps": [{"action":"snapshot"}]
129                }
130            }"#,
131        )
132        .unwrap();
133        let ctx = serde_json::json!({});
134        let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
135        assert!(cmd.session_id.is_none());
136    }
137
138    #[test]
139    fn absent_session_id_defaults_to_default() {
140        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
141            r#"{
142                "browser": {
143                    "steps": [{"action":"snapshot"}]
144                }
145            }"#,
146        )
147        .unwrap();
148        let ctx = serde_json::json!({});
149        let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
150        assert_eq!(cmd.session_id.as_deref(), Some(super::DEFAULT_SESSION_ID));
151    }
152
153    // Setting session_id = "" is the explicit opt-out from the default session.
154    #[test]
155    fn explicit_empty_session_id_is_ephemeral() {
156        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
157            r#"{
158                "browser": {
159                    "session_id": "",
160                    "steps": [{"action":"snapshot"}]
161                }
162            }"#,
163        )
164        .unwrap();
165        let ctx = serde_json::json!({});
166        let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
167        assert!(cmd.session_id.is_none());
168    }
169
170    #[test]
171    fn build_preserves_headless_and_timeout() {
172        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
173            r#"{
174                "browser": {
175                    "headless": false,
176                    "timeout_ms": 60000,
177                    "on_failure_screenshot": false,
178                    "steps": [{"action":"snapshot"}]
179                }
180            }"#,
181        )
182        .unwrap();
183        let ctx = serde_json::json!({});
184        let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
185        assert!(!cmd.headless);
186        assert_eq!(cmd.timeout_ms, 60000);
187        assert!(!cmd.on_failure_screenshot);
188    }
189
190    #[test]
191    fn render_step_strings_are_passed_through_renderer() {
192        /// Renderer that only transforms non-action strings by uppercasing them.
193        /// We use a targeted substitution so we can distinguish rendered from
194        /// original values without corrupting the serde `action` discriminant.
195        struct UppercaseUrlRenderer;
196        impl TemplateRenderer for UppercaseUrlRenderer {
197            fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
198                // Only transform strings that look like URLs (contain "://").
199                if t.contains("://") {
200                    Ok(t.to_uppercase())
201                } else {
202                    Ok(t.to_string())
203                }
204            }
205            fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
206                Ok(v.clone())
207            }
208        }
209
210        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
211            r#"{
212                "browser": {
213                    "steps": [{"action":"navigate","url":"https://example.com"}]
214                }
215            }"#,
216        )
217        .unwrap();
218        let ctx = serde_json::json!({});
219        let cmd = build_browser_request(&op, &ctx, &UppercaseUrlRenderer).unwrap();
220        // The url field is a string containing "://" so it gets uppercased.
221        if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] {
222            assert_eq!(url, "HTTPS://EXAMPLE.COM");
223        } else {
224            panic!("expected Navigate step");
225        }
226    }
227
228    #[test]
229    fn render_does_not_corrupt_action_discriminant() {
230        struct UppercaseRenderer;
231        impl earl_core::TemplateRenderer for UppercaseRenderer {
232            fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
233                Ok(t.to_uppercase())
234            }
235            fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
236                Ok(v.clone())
237            }
238        }
239        let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
240            r#"{
241            "browser": {
242                "steps": [{"action":"navigate","url":"https://example.com"}]
243            }
244        }"#,
245        )
246        .unwrap();
247        let ctx = serde_json::json!({});
248        // This should NOT fail — action discriminant must be preserved
249        let cmd = build_browser_request(&op, &ctx, &UppercaseRenderer).unwrap();
250        assert_eq!(cmd.steps.len(), 1);
251        // URL should be uppercased
252        if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] {
253            assert_eq!(url, "HTTPS://EXAMPLE.COM");
254        } else {
255            panic!("expected Navigate step");
256        }
257    }
258}