Skip to main content

glass/browser/session/
batch.rs

1//! Ordered batch execution with policy pre-flight.
2//!
3//! Runs up to [`MAX_BATCH_STEPS`] typed operations as a single atomic unit.
4//! Every step is validated against the active [`BrowserPolicy`] before any
5//! side effects are applied.
6
7use super::*;
8use crate::browser::policy::PolicyCapability;
9use std::time::Duration;
10
11impl BrowserSession {
12    /// Execute an ordered batch of typed operations.
13    /// Policy is pre-flighted for every step before any step executes.
14    /// Fails fast on the first error; ambiguity always fails closed.
15    pub async fn run_batch(&self, steps: &[BatchStep]) -> BrowserResult<BatchOutcome> {
16        self.run_batch_with_options(steps, false).await
17    }
18
19    /// Execute a batch with optional atomic locator pre-resolution.
20    pub async fn run_batch_with_options(
21        &self,
22        steps: &[BatchStep],
23        atomic: bool,
24    ) -> BrowserResult<BatchOutcome> {
25        let total = steps.len();
26        if total > MAX_BATCH_STEPS {
27            return Err(format!(
28                "batch exceeds max {} steps (received {total})",
29                MAX_BATCH_STEPS
30            )
31            .into());
32        }
33
34        for (index, step) in steps.iter().enumerate() {
35            let action = step_action_name(step);
36            if let Err(policy_error) = check_batch_step_policy(self, step).await {
37                return Err(format!(
38                    "batch policy denial at step {index} ({action}): {policy_error}"
39                )
40                .into());
41            }
42        }
43
44        if atomic {
45            for (index, step) in steps.iter().enumerate() {
46                if let Some(target) = batch_target(step) {
47                    self.resolve_element(target).await.map_err(|error| {
48                        format!("atomic batch target resolution failed at step {index}: {error}")
49                    })?;
50                }
51            }
52        }
53
54        let mut outcomes: Vec<BatchStepOutcome> = Vec::with_capacity(total);
55        let mut completed = 0usize;
56        let mut failed = 0usize;
57
58        for (index, step) in steps.iter().enumerate() {
59            let action = step_action_name(step);
60            match execute_batch_step(self, step).await {
61                Ok(response_bytes) => {
62                    outcomes.push(BatchStepOutcome::Success {
63                        index,
64                        action,
65                        response_bytes,
66                    });
67                    completed += 1;
68                }
69                Err(error) => {
70                    let message = bounded_batch_text(&format!("{error:#}"), 512);
71                    outcomes.push(BatchStepOutcome::Error {
72                        index,
73                        action,
74                        message,
75                    });
76                    failed += 1;
77                    break;
78                }
79            }
80        }
81
82        Ok(BatchOutcome {
83            success: failed == 0,
84            completed,
85            failed,
86            total,
87            steps: outcomes,
88        })
89    }
90}
91
92async fn check_batch_step_policy(
93    session: &BrowserSession,
94    step: &BatchStep,
95) -> Result<(), PolicyError> {
96    match step {
97        BatchStep::Navigate { url, .. } => session.policy.require_url(url).await.map(|_| ()),
98        BatchStep::Evaluate { .. } => session.policy.require_for_batch(PolicyCapability::Evaluate),
99        BatchStep::Observe {
100            include_form_values,
101            ..
102        } => {
103            if *include_form_values {
104                session
105                    .policy
106                    .require_for_batch(PolicyCapability::ReadFormValues)
107            } else {
108                Ok(())
109            }
110        }
111        BatchStep::Screenshot => session
112            .policy
113            .require_for_batch(PolicyCapability::Screenshot),
114        _ => Ok(()),
115    }
116}
117
118fn batch_target(step: &BatchStep) -> Option<&str> {
119    match step {
120        BatchStep::Click { target }
121        | BatchStep::Check { target }
122        | BatchStep::Uncheck { target }
123        | BatchStep::Select { target, .. }
124        | BatchStep::Clear { target } => Some(target),
125        BatchStep::Type { target, .. } => target.as_deref(),
126        _ => None,
127    }
128}
129
130fn bounded_batch_text(value: &str, max_bytes: usize) -> String {
131    if value.len() <= max_bytes {
132        return value.to_string();
133    }
134    let mut end = max_bytes.saturating_sub(15);
135    while end > 0 && !value.is_char_boundary(end) {
136        end -= 1;
137    }
138    format!("{}…[truncated]", &value[..end])
139}
140
141async fn execute_batch_step(
142    session: &BrowserSession,
143    step: &BatchStep,
144) -> BrowserResult<Option<usize>> {
145    match step {
146        BatchStep::Navigate { url, timeout_ms } => {
147            let info = session
148                .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
149                .await?;
150            let bytes = serde_json::to_string(&info).ok().map(|s| s.len());
151            Ok(bytes)
152        }
153        BatchStep::Click { target } => {
154            let outcome = session.click(target).await?;
155            let bytes = serde_json::to_string(&outcome).ok().map(|s| s.len());
156            Ok(bytes)
157        }
158        BatchStep::Type { text, target } => {
159            session.type_text(text, target.as_deref()).await?;
160            Ok(None)
161        }
162        BatchStep::Check { target } => {
163            session.check(target).await?;
164            Ok(None)
165        }
166        BatchStep::Uncheck { target } => {
167            session.uncheck(target).await?;
168            Ok(None)
169        }
170        BatchStep::Select { target, value } => {
171            session.select_option(target, value).await?;
172            Ok(None)
173        }
174        BatchStep::Clear { target } => {
175            session.clear(target).await?;
176            Ok(None)
177        }
178        BatchStep::Scroll { dx, dy } => {
179            session.scroll(*dx, *dy).await?;
180            Ok(None)
181        }
182        BatchStep::Wait {
183            condition,
184            timeout_ms,
185        } => {
186            let cond = WaitCondition::parse(condition)?;
187            session
188                .wait(cond, Duration::from_millis(*timeout_ms))
189                .await?;
190            Ok(None)
191        }
192        BatchStep::Observe {
193            include_dom,
194            include_screenshot,
195            include_form_values,
196        } => {
197            let context = match (*include_dom, *include_screenshot, *include_form_values) {
198                (false, false, false) => session.observe().await?,
199                (true, false, false) => session.observe_with_dom().await?,
200                (false, true, false) => session.observe_with_screenshot().await?,
201                (true, true, false) => session.observe_with_dom_and_screenshot().await?,
202                (false, false, true) => session.observe_with_form_values().await?,
203                _ => {
204                    return Err(
205                        "form values may only be combined with default compact observe".into(),
206                    );
207                }
208            };
209            let bytes = serde_json::to_string(&context).ok().map(|s| s.len());
210            Ok(bytes)
211        }
212        BatchStep::Screenshot => {
213            let data = session.screenshot_base64().await?;
214            Ok(Some(data.len()))
215        }
216        BatchStep::Evaluate { expression } => {
217            let result = session.evaluate(expression).await?;
218            let bytes = serde_json::to_string(&result).ok().map(|s| s.len());
219            Ok(bytes)
220        }
221        BatchStep::AcceptDialog => {
222            session.accept_dialog().await?;
223            Ok(None)
224        }
225        BatchStep::DismissDialog => {
226            session.dismiss_dialog().await?;
227            Ok(None)
228        }
229    }
230}
231
232fn step_action_name(step: &BatchStep) -> String {
233    match step {
234        BatchStep::Navigate { .. } => "navigate".to_string(),
235        BatchStep::Click { .. } => "click".to_string(),
236        BatchStep::Type { .. } => "type".to_string(),
237        BatchStep::Check { .. } => "check".to_string(),
238        BatchStep::Uncheck { .. } => "uncheck".to_string(),
239        BatchStep::Select { .. } => "select".to_string(),
240        BatchStep::Clear { .. } => "clear".to_string(),
241        BatchStep::Scroll { .. } => "scroll".to_string(),
242        BatchStep::Wait { .. } => "wait".to_string(),
243        BatchStep::Observe { .. } => "observe".to_string(),
244        BatchStep::Screenshot => "screenshot".to_string(),
245        BatchStep::Evaluate { .. } => "evaluate".to_string(),
246        BatchStep::AcceptDialog => "acceptDialog".to_string(),
247        BatchStep::DismissDialog => "dismissDialog".to_string(),
248    }
249}