earl_protocol_browser/
executor.rs1use anyhow::Result;
2use chrono::Utc;
3use earl_core::{ExecutionContext, ProtocolExecutor, RawExecutionResult};
4
5use crate::PreparedBrowserCommand;
6use crate::launcher::{configure_page, connect_chrome, launch_chrome};
7use crate::session::{
8 SessionFile, acquire_session_lock, ensure_sessions_dir, is_pid_alive, session_file_path,
9 sessions_dir,
10};
11use crate::steps::execute_steps;
12
13pub struct BrowserExecutor;
23
24impl ProtocolExecutor for BrowserExecutor {
25 type PreparedData = PreparedBrowserCommand;
26
27 async fn execute(
28 &mut self,
29 data: &PreparedBrowserCommand,
30 _ctx: &ExecutionContext,
31 ) -> Result<RawExecutionResult> {
32 let result = run_browser_command(data).await?;
33 let body = serde_json::to_vec(&result)?;
34 Ok(RawExecutionResult {
35 status: 0,
36 url: "browser://command".into(),
37 body,
38 content_type: Some("application/json".into()),
39 })
40 }
41}
42
43async fn run_browser_command(data: &PreparedBrowserCommand) -> Result<serde_json::Value> {
45 match data.session_id.as_deref() {
46 None => run_ephemeral(data).await,
47 Some(session_id) => run_with_session(data, session_id).await,
48 }
49}
50
51async fn run_ephemeral(data: &PreparedBrowserCommand) -> Result<serde_json::Value> {
53 let (mut browser, _ws_url) = launch_chrome(data.headless).await?;
54
55 let page = match browser.new_page("about:blank").await {
56 Ok(p) => p,
57 Err(e) => {
58 let _ = browser.close().await;
59 return Err(e.into());
60 }
61 };
62 if let Err(e) = configure_page(&page).await {
63 let _ = browser.close().await;
64 return Err(e);
65 }
66
67 let result = execute_steps(
68 &page,
69 &data.steps,
70 data.timeout_ms,
71 data.on_failure_screenshot,
72 )
73 .await;
74
75 let _ = browser.close().await;
77
78 result
79}
80
81async fn run_with_session(
84 data: &PreparedBrowserCommand,
85 session_id: &str,
86) -> Result<serde_json::Value> {
87 let dir = sessions_dir()?;
89 ensure_sessions_dir(&dir)?;
90
91 let _lock = acquire_session_lock(session_id).await?;
94
95 let sf_path = session_file_path(session_id)?;
96 let existing = SessionFile::load_from(&sf_path)?;
97
98 let (browser, ws_url) = if let Some(ref sf) = existing {
100 if is_pid_alive(sf.pid, Some(sf.started_at)) {
101 match connect_chrome(&sf.websocket_url).await {
102 Ok(b) => (b, sf.websocket_url.clone()),
103 Err(_) => {
104 let (b, ws) = launch_chrome(data.headless).await?;
106 (b, ws)
107 }
108 }
109 } else {
110 let (b, ws) = launch_chrome(data.headless).await?;
112 (b, ws)
113 }
114 } else {
115 let (b, ws) = launch_chrome(data.headless).await?;
117 (b, ws)
118 };
119
120 let page = match browser.pages().await {
124 Ok(pages) if !pages.is_empty() => {
125 let p = pages.into_iter().next().unwrap();
126 configure_page(&p).await?;
127 p
128 }
129 _ => {
130 let p = browser.new_page("about:blank").await?;
131 configure_page(&p).await?;
132 p
133 }
134 };
135
136 let target_id = page.target_id().as_ref().to_string();
138
139 let now = Utc::now();
140 let started_at = existing.as_ref().map(|sf| sf.started_at).unwrap_or(now);
141
142 let sf_to_save = SessionFile {
143 pid: 0,
146 websocket_url: ws_url,
147 target_id,
148 started_at,
149 last_used_at: now,
150 interrupted: false,
151 };
152
153 let step_result = execute_steps(
155 &page,
156 &data.steps,
157 data.timeout_ms,
158 data.on_failure_screenshot,
159 )
160 .await;
161
162 let mut updated_sf = sf_to_save;
164 updated_sf.last_used_at = Utc::now();
165 updated_sf.interrupted = step_result.is_err();
166 if let Err(e) = updated_sf.save_to(&sf_path) {
170 tracing::warn!(error = %e, "failed to persist browser session file");
171 }
172
173 step_result
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn browser_executor_implements_protocol_executor() {
182 fn assert_impl<T: earl_core::ProtocolExecutor>() {}
183 assert_impl::<BrowserExecutor>();
184 }
185}