earl_protocol_browser/
builder.rs1use 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
10pub 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 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
51fn 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 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 #[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 struct UppercaseUrlRenderer;
196 impl TemplateRenderer for UppercaseUrlRenderer {
197 fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
198 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 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 let cmd = build_browser_request(&op, &ctx, &UppercaseRenderer).unwrap();
250 assert_eq!(cmd.steps.len(), 1);
251 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}