1use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)]
6#[serde(deny_unknown_fields)]
7pub struct BrowserOperationTemplate {
8 pub browser: BrowserTemplate,
9}
10
11#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)]
12#[serde(deny_unknown_fields)]
13pub struct BrowserTemplate {
14 pub session_id: Option<String>,
15 #[serde(default = "default_headless")]
16 pub headless: bool,
17 #[serde(default = "default_timeout_ms")]
18 pub timeout_ms: u64,
19 #[serde(default = "default_true")]
20 pub on_failure_screenshot: bool,
21 pub steps: Vec<BrowserStep>,
22}
23
24fn default_headless() -> bool {
25 true
26}
27fn default_timeout_ms() -> u64 {
28 30_000
29}
30fn default_true() -> bool {
31 true
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)]
37#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
38pub enum BrowserStep {
39 Navigate {
41 url: String,
42 #[serde(default)]
43 expected_status: Option<u16>,
44 #[serde(default)]
45 timeout_ms: Option<u64>,
46 #[serde(default)]
47 optional: bool,
48 },
49 NavigateBack {
50 #[serde(default)]
51 timeout_ms: Option<u64>,
52 #[serde(default)]
53 optional: bool,
54 },
55 NavigateForward {
56 #[serde(default)]
57 timeout_ms: Option<u64>,
58 #[serde(default)]
59 optional: bool,
60 },
61 Reload {
62 #[serde(default)]
63 timeout_ms: Option<u64>,
64 #[serde(default)]
65 optional: bool,
66 },
67
68 Snapshot {
70 #[serde(default)]
71 timeout_ms: Option<u64>,
72 #[serde(default)]
73 optional: bool,
74 },
75 Screenshot {
76 #[serde(default)]
77 path: Option<String>,
78 #[serde(default, rename = "type")]
79 r#type: Option<String>,
80 #[serde(default)]
81 full_page: bool,
82 #[serde(default, rename = "ref")]
83 r#ref: Option<String>,
84 #[serde(default)]
85 timeout_ms: Option<u64>,
86 #[serde(default)]
87 optional: bool,
88 },
89 ConsoleMessages {
90 #[serde(default)]
91 level: Option<String>,
92 #[serde(default)]
93 timeout_ms: Option<u64>,
94 #[serde(default)]
95 optional: bool,
96 },
97 ConsoleClear {
98 #[serde(default)]
99 optional: bool,
100 },
101 NetworkRequests {
102 #[serde(default)]
103 include_static: bool,
104 #[serde(default)]
105 optional: bool,
106 },
107 NetworkClear {
108 #[serde(default)]
109 optional: bool,
110 },
111
112 Click {
114 #[serde(default, rename = "ref")]
115 r#ref: Option<String>,
116 #[serde(default)]
117 selector: Option<String>,
118 #[serde(default)]
119 double_click: bool,
120 #[serde(default)]
121 timeout_ms: Option<u64>,
122 #[serde(default)]
123 optional: bool,
124 },
125 Hover {
126 #[serde(default, rename = "ref")]
127 r#ref: Option<String>,
128 #[serde(default)]
129 selector: Option<String>,
130 #[serde(default)]
131 timeout_ms: Option<u64>,
132 #[serde(default)]
133 optional: bool,
134 },
135 Drag {
136 #[serde(default, rename = "start_ref")]
137 start_ref: Option<String>,
138 #[serde(default)]
139 start_selector: Option<String>,
140 #[serde(default, rename = "end_ref")]
141 end_ref: Option<String>,
142 #[serde(default)]
143 end_selector: Option<String>,
144 #[serde(default)]
145 timeout_ms: Option<u64>,
146 #[serde(default)]
147 optional: bool,
148 },
149 Fill {
150 #[serde(default, rename = "ref")]
151 r#ref: Option<String>,
152 #[serde(default)]
153 selector: Option<String>,
154 text: String,
155 #[serde(default)]
156 submit: Option<bool>,
157 #[serde(default)]
158 timeout_ms: Option<u64>,
159 #[serde(default)]
160 optional: bool,
161 },
162 FillForm {
163 #[rkyv(with = earl_core::with::AsJson)]
164 fields: Vec<Value>,
165 #[serde(default)]
166 timeout_ms: Option<u64>,
167 #[serde(default)]
168 optional: bool,
169 },
170 SelectOption {
171 #[serde(default, rename = "ref")]
172 r#ref: Option<String>,
173 #[serde(default)]
174 selector: Option<String>,
175 values: Vec<String>,
176 #[serde(default)]
177 timeout_ms: Option<u64>,
178 #[serde(default)]
179 optional: bool,
180 },
181 PressKey {
182 key: String,
183 #[serde(default)]
184 timeout_ms: Option<u64>,
185 #[serde(default)]
186 optional: bool,
187 },
188 Check {
189 #[serde(default, rename = "ref")]
190 r#ref: Option<String>,
191 #[serde(default)]
192 selector: Option<String>,
193 #[serde(default)]
194 timeout_ms: Option<u64>,
195 #[serde(default)]
196 optional: bool,
197 },
198 Uncheck {
199 #[serde(default, rename = "ref")]
200 r#ref: Option<String>,
201 #[serde(default)]
202 selector: Option<String>,
203 #[serde(default)]
204 timeout_ms: Option<u64>,
205 #[serde(default)]
206 optional: bool,
207 },
208
209 MouseMove {
211 x: f64,
212 y: f64,
213 #[serde(default)]
214 optional: bool,
215 },
216 MouseClick {
217 x: f64,
218 y: f64,
219 #[serde(default)]
220 button: Option<String>,
221 #[serde(default)]
222 optional: bool,
223 },
224 MouseDrag {
225 start_x: f64,
226 start_y: f64,
227 end_x: f64,
228 end_y: f64,
229 #[serde(default)]
230 optional: bool,
231 },
232 MouseDown {
233 #[serde(default)]
234 button: Option<String>,
235 #[serde(default)]
236 optional: bool,
237 },
238 MouseUp {
239 #[serde(default)]
240 button: Option<String>,
241 #[serde(default)]
242 optional: bool,
243 },
244 MouseWheel {
245 delta_x: f64,
246 delta_y: f64,
247 #[serde(default)]
248 optional: bool,
249 },
250
251 WaitFor {
253 #[serde(default)]
254 time: Option<f64>,
255 #[serde(default)]
256 text: Option<String>,
257 #[serde(default)]
258 text_gone: Option<String>,
259 #[serde(default)]
260 timeout_ms: Option<u64>,
261 #[serde(default)]
262 optional: bool,
263 },
264 VerifyElementVisible {
265 #[serde(default)]
266 role: Option<String>,
267 #[serde(default)]
268 accessible_name: Option<String>,
269 #[serde(default)]
270 optional: bool,
271 },
272 VerifyTextVisible {
273 text: String,
274 #[serde(default)]
275 optional: bool,
276 },
277 VerifyListVisible {
278 #[serde(default, rename = "ref")]
279 r#ref: Option<String>,
280 items: Vec<String>,
281 #[serde(default)]
282 optional: bool,
283 },
284 VerifyValue {
285 #[serde(default, rename = "ref")]
286 r#ref: Option<String>,
287 value: String,
288 #[serde(default)]
289 optional: bool,
290 },
291
292 Evaluate {
294 function: String,
295 #[serde(default, rename = "ref")]
296 r#ref: Option<String>,
297 #[serde(default)]
298 timeout_ms: Option<u64>,
299 #[serde(default)]
300 optional: bool,
301 },
302 RunCode {
303 code: String,
304 #[serde(default)]
305 timeout_ms: Option<u64>,
306 #[serde(default)]
307 optional: bool,
308 },
309
310 Tabs {
312 operation: String,
313 #[serde(default)]
314 index: Option<usize>,
315 #[serde(default)]
316 optional: bool,
317 },
318 Resize {
319 width: u32,
320 height: u32,
321 #[serde(default)]
322 optional: bool,
323 },
324 Close {
325 #[serde(default)]
326 optional: bool,
327 },
328
329 Route {
331 pattern: String,
332 #[serde(default)]
333 status: Option<u16>,
334 #[serde(default)]
335 body: Option<String>,
336 #[serde(default)]
337 content_type: Option<String>,
338 #[serde(default)]
339 optional: bool,
340 },
341 RouteList {
342 #[serde(default)]
343 optional: bool,
344 },
345 Unroute {
346 pattern: String,
347 #[serde(default)]
348 optional: bool,
349 },
350
351 CookieList {
353 #[serde(default)]
354 domain: Option<String>,
355 #[serde(default)]
356 optional: bool,
357 },
358 CookieGet {
359 name: String,
360 #[serde(default)]
361 optional: bool,
362 },
363 CookieSet {
364 name: String,
365 value: String,
366 #[serde(default)]
367 domain: Option<String>,
368 #[serde(default)]
369 path: Option<String>,
370 #[serde(default)]
371 expires: Option<f64>,
372 #[serde(default)]
373 http_only: bool,
374 #[serde(default)]
375 secure: bool,
376 #[serde(default)]
377 optional: bool,
378 },
379 CookieDelete {
380 name: String,
381 #[serde(default)]
382 optional: bool,
383 },
384 CookieClear {
385 #[serde(default)]
386 optional: bool,
387 },
388
389 LocalStorageGet {
391 key: String,
392 #[serde(default)]
393 optional: bool,
394 },
395 LocalStorageSet {
396 key: String,
397 value: String,
398 #[serde(default)]
399 optional: bool,
400 },
401 LocalStorageDelete {
402 key: String,
403 #[serde(default)]
404 optional: bool,
405 },
406 LocalStorageClear {
407 #[serde(default)]
408 optional: bool,
409 },
410 SessionStorageGet {
411 key: String,
412 #[serde(default)]
413 optional: bool,
414 },
415 SessionStorageSet {
416 key: String,
417 value: String,
418 #[serde(default)]
419 optional: bool,
420 },
421 SessionStorageDelete {
422 key: String,
423 #[serde(default)]
424 optional: bool,
425 },
426 SessionStorageClear {
427 #[serde(default)]
428 optional: bool,
429 },
430 StorageState {
431 #[serde(default)]
432 path: Option<String>,
433 #[serde(default)]
434 optional: bool,
435 },
436 SetStorageState {
437 path: String,
438 #[serde(default)]
439 optional: bool,
440 },
441
442 FileUpload {
444 paths: Vec<String>,
445 #[serde(default)]
446 timeout_ms: Option<u64>,
447 #[serde(default)]
448 optional: bool,
449 },
450 HandleDialog {
451 accept: bool,
452 #[serde(default)]
453 prompt_text: Option<String>,
454 #[serde(default)]
455 optional: bool,
456 },
457 Download {
458 save_to: String,
459 #[serde(default)]
460 timeout_ms: Option<u64>,
461 #[serde(default)]
462 optional: bool,
463 },
464
465 PdfSave {
467 #[serde(default)]
468 path: Option<String>,
469 #[serde(default)]
470 optional: bool,
471 },
472 StartVideo {
473 #[serde(default)]
474 width: Option<u32>,
475 #[serde(default)]
476 height: Option<u32>,
477 #[serde(default)]
478 optional: bool,
479 },
480 StopVideo {
481 #[serde(default)]
482 path: Option<String>,
483 #[serde(default)]
484 optional: bool,
485 },
486 StartTracing {
487 #[serde(default)]
488 optional: bool,
489 },
490 StopTracing {
491 #[serde(default)]
492 path: Option<String>,
493 #[serde(default)]
494 optional: bool,
495 },
496 GenerateLocator {
497 #[serde(rename = "ref")]
498 r#ref: String,
499 #[serde(default)]
500 optional: bool,
501 },
502}
503
504impl BrowserStep {
505 pub fn action_name(&self) -> &'static str {
506 match self {
507 Self::Navigate { .. } => "navigate",
508 Self::NavigateBack { .. } => "navigate_back",
509 Self::NavigateForward { .. } => "navigate_forward",
510 Self::Reload { .. } => "reload",
511 Self::Snapshot { .. } => "snapshot",
512 Self::Screenshot { .. } => "screenshot",
513 Self::ConsoleMessages { .. } => "console_messages",
514 Self::ConsoleClear { .. } => "console_clear",
515 Self::NetworkRequests { .. } => "network_requests",
516 Self::NetworkClear { .. } => "network_clear",
517 Self::Click { .. } => "click",
518 Self::Hover { .. } => "hover",
519 Self::Drag { .. } => "drag",
520 Self::Fill { .. } => "fill",
521 Self::FillForm { .. } => "fill_form",
522 Self::SelectOption { .. } => "select_option",
523 Self::PressKey { .. } => "press_key",
524 Self::Check { .. } => "check",
525 Self::Uncheck { .. } => "uncheck",
526 Self::MouseMove { .. } => "mouse_move",
527 Self::MouseClick { .. } => "mouse_click",
528 Self::MouseDrag { .. } => "mouse_drag",
529 Self::MouseDown { .. } => "mouse_down",
530 Self::MouseUp { .. } => "mouse_up",
531 Self::MouseWheel { .. } => "mouse_wheel",
532 Self::WaitFor { .. } => "wait_for",
533 Self::VerifyElementVisible { .. } => "verify_element_visible",
534 Self::VerifyTextVisible { .. } => "verify_text_visible",
535 Self::VerifyListVisible { .. } => "verify_list_visible",
536 Self::VerifyValue { .. } => "verify_value",
537 Self::Evaluate { .. } => "evaluate",
538 Self::RunCode { .. } => "run_code",
539 Self::Tabs { .. } => "tabs",
540 Self::Resize { .. } => "resize",
541 Self::Close { .. } => "close",
542 Self::Route { .. } => "route",
543 Self::RouteList { .. } => "route_list",
544 Self::Unroute { .. } => "unroute",
545 Self::CookieList { .. } => "cookie_list",
546 Self::CookieGet { .. } => "cookie_get",
547 Self::CookieSet { .. } => "cookie_set",
548 Self::CookieDelete { .. } => "cookie_delete",
549 Self::CookieClear { .. } => "cookie_clear",
550 Self::LocalStorageGet { .. } => "local_storage_get",
551 Self::LocalStorageSet { .. } => "local_storage_set",
552 Self::LocalStorageDelete { .. } => "local_storage_delete",
553 Self::LocalStorageClear { .. } => "local_storage_clear",
554 Self::SessionStorageGet { .. } => "session_storage_get",
555 Self::SessionStorageSet { .. } => "session_storage_set",
556 Self::SessionStorageDelete { .. } => "session_storage_delete",
557 Self::SessionStorageClear { .. } => "session_storage_clear",
558 Self::StorageState { .. } => "storage_state",
559 Self::SetStorageState { .. } => "set_storage_state",
560 Self::FileUpload { .. } => "file_upload",
561 Self::HandleDialog { .. } => "handle_dialog",
562 Self::Download { .. } => "download",
563 Self::PdfSave { .. } => "pdf_save",
564 Self::StartVideo { .. } => "start_video",
565 Self::StopVideo { .. } => "stop_video",
566 Self::StartTracing { .. } => "start_tracing",
567 Self::StopTracing { .. } => "stop_tracing",
568 Self::GenerateLocator { .. } => "generate_locator",
569 }
570 }
571
572 pub fn is_optional(&self) -> bool {
573 match self {
574 Self::Navigate { optional, .. } => *optional,
575 Self::NavigateBack { optional, .. } => *optional,
576 Self::NavigateForward { optional, .. } => *optional,
577 Self::Reload { optional, .. } => *optional,
578 Self::Snapshot { optional, .. } => *optional,
579 Self::Screenshot { optional, .. } => *optional,
580 Self::ConsoleMessages { optional, .. } => *optional,
581 Self::ConsoleClear { optional, .. } => *optional,
582 Self::NetworkRequests { optional, .. } => *optional,
583 Self::NetworkClear { optional, .. } => *optional,
584 Self::Click { optional, .. } => *optional,
585 Self::Hover { optional, .. } => *optional,
586 Self::Drag { optional, .. } => *optional,
587 Self::Fill { optional, .. } => *optional,
588 Self::FillForm { optional, .. } => *optional,
589 Self::SelectOption { optional, .. } => *optional,
590 Self::PressKey { optional, .. } => *optional,
591 Self::Check { optional, .. } => *optional,
592 Self::Uncheck { optional, .. } => *optional,
593 Self::MouseMove { optional, .. } => *optional,
594 Self::MouseClick { optional, .. } => *optional,
595 Self::MouseDrag { optional, .. } => *optional,
596 Self::MouseDown { optional, .. } => *optional,
597 Self::MouseUp { optional, .. } => *optional,
598 Self::MouseWheel { optional, .. } => *optional,
599 Self::WaitFor { optional, .. } => *optional,
600 Self::VerifyElementVisible { optional, .. } => *optional,
601 Self::VerifyTextVisible { optional, .. } => *optional,
602 Self::VerifyListVisible { optional, .. } => *optional,
603 Self::VerifyValue { optional, .. } => *optional,
604 Self::Evaluate { optional, .. } => *optional,
605 Self::RunCode { optional, .. } => *optional,
606 Self::Tabs { optional, .. } => *optional,
607 Self::Resize { optional, .. } => *optional,
608 Self::Close { optional, .. } => *optional,
609 Self::Route { optional, .. } => *optional,
610 Self::RouteList { optional, .. } => *optional,
611 Self::Unroute { optional, .. } => *optional,
612 Self::CookieList { optional, .. } => *optional,
613 Self::CookieGet { optional, .. } => *optional,
614 Self::CookieSet { optional, .. } => *optional,
615 Self::CookieDelete { optional, .. } => *optional,
616 Self::CookieClear { optional, .. } => *optional,
617 Self::LocalStorageGet { optional, .. } => *optional,
618 Self::LocalStorageSet { optional, .. } => *optional,
619 Self::LocalStorageDelete { optional, .. } => *optional,
620 Self::LocalStorageClear { optional, .. } => *optional,
621 Self::SessionStorageGet { optional, .. } => *optional,
622 Self::SessionStorageSet { optional, .. } => *optional,
623 Self::SessionStorageDelete { optional, .. } => *optional,
624 Self::SessionStorageClear { optional, .. } => *optional,
625 Self::StorageState { optional, .. } => *optional,
626 Self::SetStorageState { optional, .. } => *optional,
627 Self::FileUpload { optional, .. } => *optional,
628 Self::HandleDialog { optional, .. } => *optional,
629 Self::Download { optional, .. } => *optional,
630 Self::PdfSave { optional, .. } => *optional,
631 Self::StartVideo { optional, .. } => *optional,
632 Self::StopVideo { optional, .. } => *optional,
633 Self::StartTracing { optional, .. } => *optional,
634 Self::StopTracing { optional, .. } => *optional,
635 Self::GenerateLocator { optional, .. } => *optional,
636 }
637 }
638
639 pub fn timeout_ms(&self, global: u64) -> u64 {
640 match self {
641 Self::Navigate { timeout_ms, .. } => timeout_ms.unwrap_or(global),
642 Self::NavigateBack { timeout_ms, .. } => timeout_ms.unwrap_or(global),
643 Self::NavigateForward { timeout_ms, .. } => timeout_ms.unwrap_or(global),
644 Self::Reload { timeout_ms, .. } => timeout_ms.unwrap_or(global),
645 Self::Snapshot { timeout_ms, .. } => timeout_ms.unwrap_or(global),
646 Self::Screenshot { timeout_ms, .. } => timeout_ms.unwrap_or(global),
647 Self::ConsoleMessages { timeout_ms, .. } => timeout_ms.unwrap_or(global),
648 Self::ConsoleClear { .. } => global,
649 Self::NetworkRequests { .. } => global,
650 Self::NetworkClear { .. } => global,
651 Self::Click { timeout_ms, .. } => timeout_ms.unwrap_or(global),
652 Self::Hover { timeout_ms, .. } => timeout_ms.unwrap_or(global),
653 Self::Drag { timeout_ms, .. } => timeout_ms.unwrap_or(global),
654 Self::Fill { timeout_ms, .. } => timeout_ms.unwrap_or(global),
655 Self::FillForm { timeout_ms, .. } => timeout_ms.unwrap_or(global),
656 Self::SelectOption { timeout_ms, .. } => timeout_ms.unwrap_or(global),
657 Self::PressKey { timeout_ms, .. } => timeout_ms.unwrap_or(global),
658 Self::Check { timeout_ms, .. } => timeout_ms.unwrap_or(global),
659 Self::Uncheck { timeout_ms, .. } => timeout_ms.unwrap_or(global),
660 Self::MouseMove { .. } => global,
661 Self::MouseClick { .. } => global,
662 Self::MouseDrag { .. } => global,
663 Self::MouseDown { .. } => global,
664 Self::MouseUp { .. } => global,
665 Self::MouseWheel { .. } => global,
666 Self::WaitFor { timeout_ms, .. } => timeout_ms.unwrap_or(global),
667 Self::VerifyElementVisible { .. } => global,
668 Self::VerifyTextVisible { .. } => global,
669 Self::VerifyListVisible { .. } => global,
670 Self::VerifyValue { .. } => global,
671 Self::Evaluate { timeout_ms, .. } => timeout_ms.unwrap_or(global),
672 Self::RunCode { timeout_ms, .. } => timeout_ms.unwrap_or(global),
673 Self::Tabs { .. } => global,
674 Self::Resize { .. } => global,
675 Self::Close { .. } => global,
676 Self::Route { .. } => global,
677 Self::RouteList { .. } => global,
678 Self::Unroute { .. } => global,
679 Self::CookieList { .. } => global,
680 Self::CookieGet { .. } => global,
681 Self::CookieSet { .. } => global,
682 Self::CookieDelete { .. } => global,
683 Self::CookieClear { .. } => global,
684 Self::LocalStorageGet { .. } => global,
685 Self::LocalStorageSet { .. } => global,
686 Self::LocalStorageDelete { .. } => global,
687 Self::LocalStorageClear { .. } => global,
688 Self::SessionStorageGet { .. } => global,
689 Self::SessionStorageSet { .. } => global,
690 Self::SessionStorageDelete { .. } => global,
691 Self::SessionStorageClear { .. } => global,
692 Self::StorageState { .. } => global,
693 Self::SetStorageState { .. } => global,
694 Self::FileUpload { timeout_ms, .. } => timeout_ms.unwrap_or(global),
695 Self::HandleDialog { .. } => global,
696 Self::Download { timeout_ms, .. } => timeout_ms.unwrap_or(global),
697 Self::PdfSave { .. } => global,
698 Self::StartVideo { .. } => global,
699 Self::StopVideo { .. } => global,
700 Self::StartTracing { .. } => global,
701 Self::StopTracing { .. } => global,
702 Self::GenerateLocator { .. } => global,
703 }
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn deserialize_navigate_step() {
713 let json = r#"{"action":"navigate","url":"https://example.com"}"#;
714 let step: BrowserStep = serde_json::from_str(json).unwrap();
715 assert!(matches!(step, BrowserStep::Navigate { url, .. } if url == "https://example.com"));
716 }
717
718 #[test]
719 fn deserialize_click_step_with_selector() {
720 let json = "{\"action\":\"click\",\"selector\":\"#submit\"}";
721 let step: BrowserStep = serde_json::from_str(json).unwrap();
722 assert!(matches!(step, BrowserStep::Click { selector: Some(s), .. } if s == "#submit"));
723 }
724
725 #[test]
726 fn deserialize_fill_step() {
727 let json = r#"{"action":"fill","selector":"input[name=q]","text":"hello","submit":true}"#;
728 let step: BrowserStep = serde_json::from_str(json).unwrap();
729 assert!(
730 matches!(step, BrowserStep::Fill { text, submit: Some(true), .. } if text == "hello")
731 );
732 }
733
734 #[test]
735 fn deserialize_operation_template() {
736 let json = r#"{
737 "headless": true,
738 "steps": [
739 {"action":"navigate","url":"https://example.com"},
740 {"action":"snapshot"}
741 ]
742 }"#;
743 let tmpl: BrowserTemplate = serde_json::from_str(json).unwrap();
744 assert_eq!(tmpl.steps.len(), 2);
745 }
746
747 #[test]
748 fn unknown_field_rejected() {
749 let json = r#"{"action":"navigate","url":"https://x.com","bogus":true}"#;
750 assert!(serde_json::from_str::<BrowserStep>(json).is_err());
751 }
752
753 #[test]
754 fn optional_defaults_to_false() {
755 let json = r#"{"action":"navigate","url":"https://x.com"}"#;
756 let step: BrowserStep = serde_json::from_str(json).unwrap();
757 assert!(!step.is_optional());
758 }
759
760 #[test]
761 fn timeout_ms_falls_back_to_global() {
762 let json = r#"{"action":"navigate","url":"https://x.com"}"#;
763 let step: BrowserStep = serde_json::from_str(json).unwrap();
764 assert_eq!(step.timeout_ms(5000), 5000);
765 }
766}