use anyhow::{Result, bail};
use earl_core::render::TemplateRenderer;
use serde_json::Value;
use crate::PreparedBrowserCommand;
use crate::schema::{BrowserOperationTemplate, BrowserStep};
const DEFAULT_SESSION_ID: &str = "default";
pub fn build_browser_request(
op: &BrowserOperationTemplate,
context: &Value,
renderer: &dyn TemplateRenderer,
) -> Result<PreparedBrowserCommand> {
let tmpl = &op.browser;
if tmpl.steps.is_empty() {
bail!("operation.browser.steps must not be empty");
}
let session_id = match tmpl.session_id.as_deref() {
None => Some(DEFAULT_SESSION_ID.to_string()),
Some(s) => {
let rendered = renderer.render_str(s, context)?;
if rendered.is_empty() {
None
} else {
Some(rendered)
}
}
};
let steps: Vec<BrowserStep> = tmpl
.steps
.iter()
.map(|step| render_step(step, context, renderer))
.collect::<Result<_>>()?;
Ok(PreparedBrowserCommand {
session_id,
headless: tmpl.headless,
timeout_ms: tmpl.timeout_ms,
on_failure_screenshot: tmpl.on_failure_screenshot,
steps,
})
}
fn render_step(step: &BrowserStep, ctx: &Value, r: &dyn TemplateRenderer) -> Result<BrowserStep> {
let mut val = serde_json::to_value(step)?;
render_strings_in_value(&mut val, ctx, r)?;
Ok(serde_json::from_value(val)?)
}
fn render_strings_in_value(v: &mut Value, ctx: &Value, r: &dyn TemplateRenderer) -> Result<()> {
match v {
Value::String(s) => *s = r.render_str(s, ctx)?,
Value::Object(map) => {
for (k, val) in map.iter_mut() {
if k == "action" {
continue;
}
render_strings_in_value(val, ctx, r)?;
}
}
Value::Array(arr) => {
for val in arr.iter_mut() {
render_strings_in_value(val, ctx, r)?;
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct PassthroughRenderer;
impl TemplateRenderer for PassthroughRenderer {
fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
Ok(t.to_string())
}
fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
Ok(v.clone())
}
}
#[test]
fn build_renders_session_id_and_url() {
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"session_id": "my-session",
"steps": [{"action":"navigate","url":"https://example.com"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({"args": {}, "secrets": {}});
let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
assert_eq!(cmd.session_id.as_deref(), Some("my-session"));
assert_eq!(cmd.steps.len(), 1);
}
#[test]
fn empty_steps_returns_error() {
let op: crate::schema::BrowserOperationTemplate =
serde_json::from_str(r#"{"browser": {"steps": []}}"#).unwrap();
let ctx = serde_json::json!({});
assert!(build_browser_request(&op, &ctx, &PassthroughRenderer).is_err());
}
#[test]
fn empty_session_id_becomes_none() {
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"session_id": "",
"steps": [{"action":"snapshot"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
assert!(cmd.session_id.is_none());
}
#[test]
fn absent_session_id_defaults_to_default() {
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"steps": [{"action":"snapshot"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
assert_eq!(cmd.session_id.as_deref(), Some(super::DEFAULT_SESSION_ID));
}
#[test]
fn explicit_empty_session_id_is_ephemeral() {
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"session_id": "",
"steps": [{"action":"snapshot"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
assert!(cmd.session_id.is_none());
}
#[test]
fn build_preserves_headless_and_timeout() {
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"headless": false,
"timeout_ms": 60000,
"on_failure_screenshot": false,
"steps": [{"action":"snapshot"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &PassthroughRenderer).unwrap();
assert!(!cmd.headless);
assert_eq!(cmd.timeout_ms, 60000);
assert!(!cmd.on_failure_screenshot);
}
#[test]
fn render_step_strings_are_passed_through_renderer() {
struct UppercaseUrlRenderer;
impl TemplateRenderer for UppercaseUrlRenderer {
fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
if t.contains("://") {
Ok(t.to_uppercase())
} else {
Ok(t.to_string())
}
}
fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
Ok(v.clone())
}
}
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"steps": [{"action":"navigate","url":"https://example.com"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &UppercaseUrlRenderer).unwrap();
if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] {
assert_eq!(url, "HTTPS://EXAMPLE.COM");
} else {
panic!("expected Navigate step");
}
}
#[test]
fn render_does_not_corrupt_action_discriminant() {
struct UppercaseRenderer;
impl earl_core::TemplateRenderer for UppercaseRenderer {
fn render_str(&self, t: &str, _ctx: &Value) -> anyhow::Result<String> {
Ok(t.to_uppercase())
}
fn render_value(&self, v: &Value, _ctx: &Value) -> anyhow::Result<Value> {
Ok(v.clone())
}
}
let op: crate::schema::BrowserOperationTemplate = serde_json::from_str(
r#"{
"browser": {
"steps": [{"action":"navigate","url":"https://example.com"}]
}
}"#,
)
.unwrap();
let ctx = serde_json::json!({});
let cmd = build_browser_request(&op, &ctx, &UppercaseRenderer).unwrap();
assert_eq!(cmd.steps.len(), 1);
if let crate::schema::BrowserStep::Navigate { url, .. } = &cmd.steps[0] {
assert_eq!(url, "HTTPS://EXAMPLE.COM");
} else {
panic!("expected Navigate step");
}
}
}