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        self.run_batch_with_mode(steps, atomic, BatchMode::Unguarded, None)
26            .await
27    }
28
29    /// Execute a batch with an explicit revision policy.
30    pub async fn run_batch_with_mode(
31        &self,
32        steps: &[BatchStep],
33        atomic: bool,
34        mode: BatchMode,
35        expected_revision: Option<u64>,
36    ) -> BrowserResult<BatchOutcome> {
37        let total = steps.len();
38        if total > MAX_BATCH_STEPS {
39            return Err(format!(
40                "batch exceeds max {} steps (received {total})",
41                MAX_BATCH_STEPS
42            )
43            .into());
44        }
45
46        for (index, step) in steps.iter().enumerate() {
47            let action = step_action_name(step);
48            if let Err(policy_error) = check_batch_step_policy(self, step).await {
49                return Err(format!(
50                    "batch policy denial at step {index} ({action}): {policy_error}"
51                )
52                .into());
53            }
54        }
55
56        if atomic {
57            for (index, step) in steps.iter().enumerate() {
58                if let Some(target) = batch_target(step) {
59                    self.resolve_element(target).await.map_err(|error| {
60                        format!("atomic batch target resolution failed at step {index}: {error}")
61                    })?;
62                }
63            }
64        }
65
66        let initial_revision = self.page_revision.load(Ordering::Relaxed);
67        let mut chained_revision = match mode {
68            BatchMode::Unguarded => None,
69            BatchMode::Fixed | BatchMode::Chain => Some(
70                expected_revision.ok_or("batch mode fixed or chain requires expectedRevision")?,
71            ),
72        };
73
74        let mut outcomes: Vec<BatchStepOutcome> = Vec::with_capacity(total);
75        let mut completed = 0usize;
76        let mut failed = 0usize;
77
78        for (index, step) in steps.iter().enumerate() {
79            let action = step_action_name(step);
80            let step_revision = chained_revision;
81            match execute_batch_step(self, step, step_revision).await {
82                Ok((response_bytes, resulting_revision, execution_id)) => {
83                    outcomes.push(BatchStepOutcome::Success {
84                        index,
85                        action,
86                        response_bytes,
87                        execution_id,
88                    });
89                    completed += 1;
90                    if mode == BatchMode::Chain {
91                        chained_revision = resulting_revision
92                            .or_else(|| Some(self.page_revision.load(Ordering::Relaxed)));
93                    }
94                }
95                Err(error) => {
96                    let message = bounded_batch_text(&format!("{error:#}"), 512);
97                    outcomes.push(BatchStepOutcome::Error {
98                        index,
99                        action,
100                        message,
101                        execution_id: None,
102                    });
103                    failed += 1;
104                    break;
105                }
106            }
107        }
108
109        Ok(BatchOutcome {
110            mode,
111            initial_revision,
112            final_revision: self.page_revision.load(Ordering::Relaxed),
113            success: failed == 0,
114            completed,
115            failed,
116            total,
117            steps: outcomes,
118        })
119    }
120}
121
122async fn check_batch_step_policy(
123    session: &BrowserSession,
124    step: &BatchStep,
125) -> Result<(), PolicyError> {
126    match step {
127        BatchStep::Navigate { url, .. } => session.policy.require_url(url).await.map(|_| ()),
128        BatchStep::Evaluate { .. } => session.policy.require_for_batch(PolicyCapability::Evaluate),
129        BatchStep::Observe {
130            include_form_values,
131            ..
132        } => {
133            if *include_form_values {
134                session
135                    .policy
136                    .require_for_batch(PolicyCapability::ReadFormValues)
137            } else {
138                Ok(())
139            }
140        }
141        BatchStep::Screenshot => session
142            .policy
143            .require_for_batch(PolicyCapability::Screenshot),
144        _ => Ok(()),
145    }
146}
147
148fn batch_target(step: &BatchStep) -> Option<&str> {
149    match step {
150        BatchStep::Click { target }
151        | BatchStep::Check { target }
152        | BatchStep::Uncheck { target }
153        | BatchStep::Select { target, .. }
154        | BatchStep::Clear { target } => Some(target),
155        BatchStep::Type { target, .. } => target.as_deref(),
156        _ => None,
157    }
158}
159
160fn bounded_batch_text(value: &str, max_bytes: usize) -> String {
161    if value.len() <= max_bytes {
162        return value.to_string();
163    }
164    let mut end = max_bytes.saturating_sub(15);
165    while end > 0 && !value.is_char_boundary(end) {
166        end -= 1;
167    }
168    format!("{}…[truncated]", &value[..end])
169}
170
171async fn execute_batch_step(
172    session: &BrowserSession,
173    step: &BatchStep,
174    expected_revision: Option<u64>,
175) -> BrowserResult<(Option<usize>, Option<u64>, Option<String>)> {
176    match step {
177        BatchStep::Navigate { url, timeout_ms } => {
178            if let Some(revision) = expected_revision {
179                let info = session
180                    .navigate_with_revision(url, Duration::from_millis(*timeout_ms), revision)
181                    .await?;
182                let bytes = serde_json::to_string(&info).ok().map(|s| s.len());
183                Ok((bytes, Some(info.current_revision), Some(info.execution_id)))
184            } else {
185                let info = session
186                    .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
187                    .await?;
188                let bytes = serde_json::to_string(&info).ok().map(|s| s.len());
189                Ok((
190                    bytes,
191                    Some(session.page_revision.load(Ordering::Relaxed)),
192                    None,
193                ))
194            }
195        }
196        BatchStep::Click { target } => {
197            let outcome = match expected_revision {
198                Some(revision) => session.click_with_revision(target, revision).await?,
199                None => session.click(target).await?,
200            };
201            let bytes = serde_json::to_string(&outcome).ok().map(|s| s.len());
202            Ok((
203                bytes,
204                Some(outcome.current_revision),
205                Some(outcome.execution_id),
206            ))
207        }
208        BatchStep::Type { text, target } => {
209            let outcome = session
210                .type_text_with_expected_revision(text, target.as_deref(), expected_revision)
211                .await?;
212            Ok((
213                serde_json::to_string(&outcome).ok().map(|s| s.len()),
214                Some(outcome.current_revision),
215                Some(outcome.execution_id),
216            ))
217        }
218        BatchStep::Check { target } => {
219            let outcome = session
220                .check_with_revision(target, expected_revision)
221                .await?;
222            Ok((
223                serde_json::to_string(&outcome).ok().map(|s| s.len()),
224                Some(outcome.current_revision),
225                Some(outcome.execution_id),
226            ))
227        }
228        BatchStep::Uncheck { target } => {
229            let outcome = session
230                .uncheck_with_revision(target, expected_revision)
231                .await?;
232            Ok((
233                serde_json::to_string(&outcome).ok().map(|s| s.len()),
234                Some(outcome.current_revision),
235                Some(outcome.execution_id),
236            ))
237        }
238        BatchStep::Select { target, value } => {
239            let outcome = session
240                .select_option_with_revision(target, value, expected_revision)
241                .await?;
242            Ok((
243                serde_json::to_string(&outcome).ok().map(|s| s.len()),
244                Some(outcome.current_revision),
245                Some(outcome.execution_id),
246            ))
247        }
248        BatchStep::Clear { target } => {
249            let outcome = session
250                .clear_with_revision(target, expected_revision)
251                .await?;
252            Ok((
253                serde_json::to_string(&outcome).ok().map(|s| s.len()),
254                Some(outcome.current_revision),
255                Some(outcome.execution_id),
256            ))
257        }
258        BatchStep::Scroll { dx, dy } => {
259            let outcome = session
260                .scroll_with_revision(*dx, *dy, expected_revision)
261                .await?;
262            Ok((
263                serde_json::to_string(&outcome).ok().map(|s| s.len()),
264                Some(outcome.current_revision),
265                Some(outcome.execution_id),
266            ))
267        }
268        BatchStep::Wait {
269            condition,
270            timeout_ms,
271        } => {
272            let cond = WaitCondition::parse(condition)?;
273            session
274                .wait(cond, Duration::from_millis(*timeout_ms))
275                .await?;
276            Ok((None, None, None))
277        }
278        BatchStep::Observe {
279            include_dom,
280            include_screenshot,
281            include_form_values,
282        } => {
283            let context = match (*include_dom, *include_screenshot, *include_form_values) {
284                (false, false, false) => session.observe().await?,
285                (true, false, false) => session.observe_with_dom().await?,
286                (false, true, false) => session.observe_with_screenshot().await?,
287                (true, true, false) => session.observe_with_dom_and_screenshot().await?,
288                (false, false, true) => session.observe_with_form_values().await?,
289                _ => {
290                    return Err(
291                        "form values may only be combined with default compact observe".into(),
292                    );
293                }
294            };
295            let bytes = serde_json::to_string(&context).ok().map(|s| s.len());
296            Ok((
297                bytes,
298                Some(session.page_revision.load(Ordering::Relaxed)),
299                None,
300            ))
301        }
302        BatchStep::Screenshot => {
303            let data = session.screenshot_base64().await?;
304            Ok((Some(data.len()), None, None))
305        }
306        BatchStep::Evaluate { expression } => {
307            let result = session.evaluate(expression).await?;
308            let bytes = serde_json::to_string(&result).ok().map(|s| s.len());
309            Ok((bytes, None, None))
310        }
311        BatchStep::AcceptDialog => {
312            session.accept_dialog().await?;
313            Ok((None, None, None))
314        }
315        BatchStep::DismissDialog => {
316            session.dismiss_dialog().await?;
317            Ok((None, None, None))
318        }
319    }
320}
321
322fn step_action_name(step: &BatchStep) -> String {
323    match step {
324        BatchStep::Navigate { .. } => "navigate".to_string(),
325        BatchStep::Click { .. } => "click".to_string(),
326        BatchStep::Type { .. } => "type".to_string(),
327        BatchStep::Check { .. } => "check".to_string(),
328        BatchStep::Uncheck { .. } => "uncheck".to_string(),
329        BatchStep::Select { .. } => "select".to_string(),
330        BatchStep::Clear { .. } => "clear".to_string(),
331        BatchStep::Scroll { .. } => "scroll".to_string(),
332        BatchStep::Wait { .. } => "wait".to_string(),
333        BatchStep::Observe { .. } => "observe".to_string(),
334        BatchStep::Screenshot => "screenshot".to_string(),
335        BatchStep::Evaluate { .. } => "evaluate".to_string(),
336        BatchStep::AcceptDialog => "acceptDialog".to_string(),
337        BatchStep::DismissDialog => "dismissDialog".to_string(),
338    }
339}