1use super::*;
8use crate::browser::policy::PolicyCapability;
9use std::time::Duration;
10
11impl BrowserSession {
12 pub async fn run_batch(&self, steps: &[BatchStep]) -> BrowserResult<BatchOutcome> {
16 self.run_batch_with_options(steps, false).await
17 }
18
19 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 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}