Skip to main content

earl_protocol_browser/
schema.rs

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// ── BrowserStep ───────────────────────────────────────────────────────────────
35
36#[derive(Debug, Clone, Deserialize, Serialize, Archive, RkyvSerialize, RkyvDeserialize)]
37#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
38pub enum BrowserStep {
39    // ── Navigation ──────────────────────────────────────────────────────────
40    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    // ── Observation ─────────────────────────────────────────────────────────
69    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    // ── Interaction ─────────────────────────────────────────────────────────
113    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    // ── Mouse ────────────────────────────────────────────────────────────────
210    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    // ── Wait & Assert ────────────────────────────────────────────────────────
252    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    // ── JavaScript ───────────────────────────────────────────────────────────
293    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 & Viewport ──────────────────────────────────────────────────────
311    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    // ── Network mocking ──────────────────────────────────────────────────────
330    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    // ── Cookies ──────────────────────────────────────────────────────────────
352    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    // ── Web Storage ──────────────────────────────────────────────────────────
390    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    // ── File, Dialog, Download ───────────────────────────────────────────────
443    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    // ── Output ───────────────────────────────────────────────────────────────
466    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}