Skip to main content

earl_protocol_browser/
executor.rs

1use 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
13/// Browser protocol executor.
14///
15/// Supports two modes:
16/// - **Ephemeral** (`session_id` is `None`): launches Chrome, opens a fresh
17///   page, runs the steps, then closes Chrome.
18/// - **Session** (`session_id` is `Some`): acquires an advisory lock on the
19///   session file, reconnects to an existing Chrome instance if it is still
20///   alive, otherwise launches a fresh one; runs the steps; updates the session
21///   file.
22pub 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
43/// Core execution logic — runs the browser steps and returns a JSON `Value`.
44async 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
51/// Launch a fresh Chrome instance, run the steps on a new page, then close.
52async 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    // Close Chrome regardless of step outcome.
76    let _ = browser.close().await;
77
78    result
79}
80
81/// Connect to (or launch) a Chrome instance tracked by a session file, run the
82/// steps, then update the session file with the current state.
83async fn run_with_session(
84    data: &PreparedBrowserCommand,
85    session_id: &str,
86) -> Result<serde_json::Value> {
87    // Ensure the sessions directory exists before acquiring the lock.
88    let dir = sessions_dir()?;
89    ensure_sessions_dir(&dir)?;
90
91    // Advisory lock prevents concurrent earl invocations from clobbering the
92    // same session.
93    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    // Try to reconnect to an existing Chrome instance.
99    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                    // Stale session — launch a fresh Chrome.
105                    let (b, ws) = launch_chrome(data.headless).await?;
106                    (b, ws)
107                }
108            }
109        } else {
110            // PID no longer alive — launch a fresh Chrome.
111            let (b, ws) = launch_chrome(data.headless).await?;
112            (b, ws)
113        }
114    } else {
115        // No session file yet — launch a fresh Chrome.
116        let (b, ws) = launch_chrome(data.headless).await?;
117        (b, ws)
118    };
119
120    // Reuse the first existing page or open a new one.
121    // configure_page must be called in both branches so that security settings
122    // (e.g. download blocking) are always applied, even to reused pages.
123    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    // Prepare the session file (will be saved after steps run).
137    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        // Use 0 as a placeholder — chromiumoxide does not expose Chrome's PID
144        // through its public API.
145        pid: 0,
146        websocket_url: ws_url,
147        target_id,
148        started_at,
149        last_used_at: now,
150        interrupted: false,
151    };
152
153    // Run the steps.
154    let step_result = execute_steps(
155        &page,
156        &data.steps,
157        data.timeout_ms,
158        data.on_failure_screenshot,
159    )
160    .await;
161
162    // Save the session file after steps complete, recording whether they failed.
163    let mut updated_sf = sf_to_save;
164    updated_sf.last_used_at = Utc::now();
165    updated_sf.interrupted = step_result.is_err();
166    // best-effort — don't mask the step error, but warn so "session didn't persist" is debuggable
167    // Omit session_id and session file path from the log: the path encodes the session identifier
168    // which CodeQL treats as sensitive (CWE-532). The IO error itself provides enough context.
169    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}