Skip to main content

bobby_browser_client/
commands.rs

1//! Command envelopes, primitives, intents, and session create/open requests.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7use crate::{
8    AttemptId, CommandId, FormControlTarget, PageId, SessionId, WorkflowId, MAX_FORM_REFERENCES,
9    MAX_FORM_VALUE_BYTES,
10};
11
12/// Maximum UTF-8 byte length for intent `purpose` strings.
13pub const MAX_INTENT_PURPOSE_BYTES: usize = 256;
14
15/// Envelope submitted to `POST /v1/commands`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(rename_all = "camelCase")]
19pub struct CommandEnvelope {
20    pub schema_version: u16,
21    pub command_id: CommandId,
22    pub workflow_id: WorkflowId,
23    pub attempt_id: AttemptId,
24    pub session_id: SessionId,
25    pub page_id: Option<PageId>,
26    pub deadline: DateTime<Utc>,
27    pub command: RuntimeCommand,
28}
29
30impl CommandEnvelope {
31    pub const SCHEMA_VERSION: u16 = 2;
32
33    /// Returns a copy safe for durable journals (URLs sanitized).
34    /// Keep the live envelope in memory for execution.
35    pub fn journal_safe(&self) -> Self {
36        let mut safe = self.clone();
37        safe.command.sanitize_urls();
38        safe
39    }
40}
41
42/// Nested command wire shape: `{ kind: "intent" | "primitive", input: … }`.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
46pub enum RuntimeCommand {
47    Primitive(PrimitiveCommand),
48    Intent(IntentCommand),
49}
50
51impl RuntimeCommand {
52    pub fn class(&self) -> CommandClass {
53        match self {
54            Self::Primitive(command) => command.class(),
55            Self::Intent(command) => command.class(),
56        }
57    }
58
59    pub fn sanitize_urls(&mut self) {
60        match self {
61            Self::Primitive(command) => command.sanitize_urls(),
62            Self::Intent(_) => {}
63        }
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
70pub enum IntentCommand {
71    Locate(LocateIntent),
72    Fill(FillIntent),
73    CompleteForm(CompleteFormIntent),
74    SubmitAndVerify(SubmitAndVerifyIntent),
75    WaitForState(WaitForStateIntent),
76    Follow(FollowIntent),
77    DismissObstruction(DismissObstructionIntent),
78    Extract(ExtractIntent),
79}
80
81impl IntentCommand {
82    pub fn class(&self) -> CommandClass {
83        match self {
84            Self::Locate(_) | Self::WaitForState(_) | Self::Extract(_) => CommandClass::Replayable,
85            Self::Fill(_) | Self::CompleteForm(_) | Self::DismissObstruction(_) => {
86                CommandClass::Reconciliable
87            }
88            Self::SubmitAndVerify(_) => CommandClass::Boundary,
89            Self::Follow(intent) => {
90                if intent.boundary {
91                    CommandClass::Boundary
92                } else {
93                    CommandClass::Reconciliable
94                }
95            }
96        }
97    }
98}
99
100/// Optional targeting hints for intents. All fields are optional on the wire
101/// (`#[serde(default)]`); a body like `{"role":"textbox"}` must deserialize.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "camelCase", default)]
105pub struct IntentHints {
106    pub role: Option<String>,
107    pub near_text: Option<TextMatch>,
108    /// Accessible name of the control, matched exactly. Accepts an
109    /// `a11y_snapshot` node's `target` verbatim, which carries
110    /// `{role, accessibleName, ordinal}` -- before this existed the field was
111    /// dropped silently and the intent resolved on role and ordinal alone.
112    /// Equivalent to `near_text: Exact`; setting both to different values is
113    /// refused rather than resolved.
114    pub accessible_name: Option<String>,
115    pub ordinal: Option<usize>,
116    pub frame_path: Vec<TargetSpec>,
117    pub shadow_path: Vec<TargetSpec>,
118    pub allow_best_match: bool,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123#[serde(rename_all = "camelCase")]
124pub struct LocateIntent {
125    pub purpose: String,
126    #[serde(default)]
127    pub hints: IntentHints,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[serde(rename_all = "camelCase")]
133pub struct FillIntent {
134    pub purpose: String,
135    #[serde(default)]
136    pub hints: IntentHints,
137    pub value: FillValue,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[serde(rename_all = "camelCase")]
143pub struct CompleteFormField {
144    pub name: String,
145    pub purpose: String,
146    #[serde(default)]
147    pub hints: IntentHints,
148    pub value: FillValue,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153#[serde(rename_all = "camelCase")]
154pub struct CompleteFormIntent {
155    pub purpose: String,
156    pub fields: Vec<CompleteFormField>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161#[serde(tag = "kind", rename_all = "camelCase")]
162pub enum FillValue {
163    Text {
164        text: String,
165        #[serde(default)]
166        #[serde(rename = "clearFirst")]
167        clear_first: bool,
168    },
169    Select {
170        option: String,
171    },
172    Checked {
173        checked: bool,
174    },
175    Files {
176        paths: Vec<String>,
177    },
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "camelCase")]
183pub struct SubmitAndVerifyIntent {
184    pub purpose: String,
185    #[serde(default)]
186    pub hints: IntentHints,
187    pub expected_state: WaitForCommand,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(rename_all = "camelCase")]
193pub struct WaitForStateIntent {
194    pub condition: WaitCondition,
195    pub timeout_ms: u64,
196}
197
198/// Follow a control and wait for the expected destination.
199///
200/// Set `boundary` when activation may mutate state or trigger a side effect
201/// (same meaning as [`ClickCommand::boundary`]).
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204#[serde(rename_all = "camelCase")]
205pub struct FollowIntent {
206    pub purpose: String,
207    #[serde(default)]
208    pub hints: IntentHints,
209    pub expected_destination: WaitForCommand,
210    #[serde(default)]
211    pub boundary: bool,
212}
213
214pub const DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS: u64 = 5_000;
215
216fn default_dismiss_timeout_ms() -> u64 {
217    DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS
218}
219
220/// Dismiss an obstruction (overlay, cookie banner, etc.).
221///
222/// Always reconciliable. Verification is built in: after acting, the same
223/// target must be detached or hidden.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226#[serde(rename_all = "camelCase")]
227pub struct DismissObstructionIntent {
228    pub purpose: String,
229    #[serde(default)]
230    pub hints: IntentHints,
231    #[serde(default = "default_dismiss_timeout_ms")]
232    pub timeout_ms: u64,
233}
234
235/// Value to extract from a resolved field. `Href` is shorthand for
236/// `attribute = "href"`; other attributes use [`ExtractValueKind::Attribute`].
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[serde(tag = "kind", rename_all = "camelCase")]
240pub enum ExtractValueKind {
241    Text,
242    Attribute { attribute: String },
243    Href,
244}
245
246/// One named field within an [`ExtractIntent`], resolved independently of siblings.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[serde(rename_all = "camelCase")]
250pub struct ExtractField {
251    pub name: String,
252    pub purpose: String,
253    #[serde(default)]
254    pub hints: IntentHints,
255    pub value: ExtractValueKind,
256}
257
258/// Structured extraction intent. Replayable (does not mutate the page).
259///
260/// Fields resolve independently: a missing field is reported in that field's
261/// evidence rather than failing the whole command.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264#[serde(rename_all = "camelCase")]
265pub struct ExtractIntent {
266    pub purpose: String,
267    pub fields: Vec<ExtractField>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
273pub enum PrimitiveCommand {
274    Navigate(NavigateCommand),
275    DownloadUrl(DownloadUrlCommand),
276    Inspect(InspectCommand),
277    Click(ClickCommand),
278    TypeText(TypeTextCommand),
279    UploadFiles(UploadFilesCommand),
280    OpenPage(OpenPageCommand),
281    ListPages(ListPagesCommand),
282    ClosePage(ClosePageCommand),
283    ActivatePage(ActivatePageCommand),
284    AccessibilitySnapshot(AccessibilitySnapshotCommand),
285    ExtractStructured(ExtractStructuredCommand),
286    GetCookies(GetCookiesCommand),
287    PrintToPdf(PrintToPdfCommand),
288    HandleDialog(HandleDialogCommand),
289    Emulate(EmulateCommand),
290    NetworkLog(NetworkLogCommand),
291    SetCookies(SetCookiesCommand),
292    DeleteCookies(DeleteCookiesCommand),
293    ClickAndWaitForPopup(ClickAndWaitForPopupCommand),
294    ClickAndWaitForDownload(ClickAndWaitForDownloadCommand),
295    WaitFor(WaitForCommand),
296    CaptureScreenshot(CaptureScreenshotCommand),
297    ControlAction(ControlActionCommand),
298    SetFocusEmulation(SetFocusEmulationCommand),
299    SetEmulatedMedia(SetEmulatedMediaCommand),
300    EvaluateJavaScript(EvaluateJavaScriptCommand),
301}
302
303impl PrimitiveCommand {
304    fn sanitize_urls(&mut self) {
305        fn sanitize(value: &mut String) {
306            let Ok(mut url) = url::Url::parse(value) else {
307                *value = "[redacted-invalid-url]".into();
308                return;
309            };
310            let _ = url.set_username("");
311            let _ = url.set_password(None);
312            url.set_query(None);
313            url.set_fragment(None);
314            *value = url.to_string();
315        }
316        match self {
317            Self::Navigate(command) => sanitize(&mut command.url),
318            Self::DownloadUrl(command) => sanitize(&mut command.url),
319            Self::UploadFiles(command) => {
320                for (index, path) in command.paths.iter_mut().enumerate() {
321                    *path = format!("upload://input/{index}");
322                }
323            }
324            Self::OpenPage(command) => {
325                if let Some(url) = &mut command.url {
326                    sanitize(url);
327                }
328            }
329            Self::Click(command) => {
330                if let Some(url) = &mut command.expected_url {
331                    sanitize(url);
332                }
333            }
334            Self::ControlAction(command) => {
335                if let ControlAction::SetFiles { paths } = &mut command.action {
336                    for (index, path) in paths.iter_mut().enumerate() {
337                        *path = format!("upload://input/{index}");
338                    }
339                }
340            }
341            _ => {}
342        }
343    }
344
345    pub fn class(&self) -> CommandClass {
346        match self {
347            Self::Navigate(_)
348            | Self::Inspect(_)
349            | Self::OpenPage(_)
350            | Self::ListPages(_)
351            | Self::WaitFor(_)
352            | Self::ActivatePage(_)
353            | Self::Emulate(_)
354            | Self::NetworkLog(_)
355            | Self::AccessibilitySnapshot(_)
356            | Self::ExtractStructured(_)
357            | Self::GetCookies(_)
358            | Self::PrintToPdf(_)
359            | Self::CaptureScreenshot(_) => CommandClass::Replayable,
360            Self::SetCookies(_) | Self::DeleteCookies(_) | Self::HandleDialog(_) => {
361                CommandClass::Reconciliable
362            }
363            Self::DownloadUrl(_)
364            | Self::TypeText(_)
365            | Self::UploadFiles(_)
366            | Self::ClosePage(_)
367            | Self::EvaluateJavaScript(_) => CommandClass::Reconciliable,
368            Self::ControlAction(_) => CommandClass::Reconciliable,
369            Self::ClickAndWaitForPopup(_) | Self::ClickAndWaitForDownload(_) => {
370                CommandClass::Boundary
371            }
372            Self::Click(command) if command.boundary => CommandClass::Boundary,
373            Self::Click(_) => CommandClass::Reconciliable,
374            Self::SetFocusEmulation(_) => CommandClass::Reconciliable,
375            Self::SetEmulatedMedia(_) => CommandClass::Reconciliable,
376        }
377    }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382#[serde(rename_all = "camelCase", deny_unknown_fields)]
383pub struct ControlActionCommand {
384    pub target: FormControlTarget,
385    pub action: ControlAction,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
390#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
391pub enum ControlAction {
392    SetText { value: String },
393    SetChecked { checked: bool },
394    SelectOne { value: String },
395    SelectMany { values: Vec<String> },
396    SetFiles { paths: Vec<String> },
397    Clear,
398    Activate,
399}
400
401impl ControlAction {
402    pub fn validate(&self) -> Result<(), String> {
403        fn bounded(value: &str, field: &str) -> Result<(), String> {
404            if value.len() > MAX_FORM_VALUE_BYTES {
405                return Err(format!("{field} exceeds {MAX_FORM_VALUE_BYTES} bytes"));
406            }
407            Ok(())
408        }
409
410        match self {
411            Self::SetText { value } | Self::SelectOne { value } => bounded(value, "value"),
412            Self::SelectMany { values } => {
413                if values.is_empty() || values.len() > MAX_FORM_REFERENCES {
414                    return Err(format!(
415                        "values must contain between 1 and {MAX_FORM_REFERENCES} items"
416                    ));
417                }
418                let mut unique = BTreeSet::new();
419                for value in values {
420                    bounded(value, "selection value")?;
421                    if !unique.insert(value) {
422                        return Err("selection values must be unique".into());
423                    }
424                }
425                Ok(())
426            }
427            Self::SetFiles { paths } => {
428                if paths.is_empty() || paths.len() > MAX_FORM_REFERENCES {
429                    return Err(format!(
430                        "paths must contain between 1 and {MAX_FORM_REFERENCES} items"
431                    ));
432                }
433                for path in paths {
434                    bounded(path, "file path")?;
435                }
436                Ok(())
437            }
438            Self::SetChecked { .. } | Self::Clear | Self::Activate => Ok(()),
439        }
440    }
441
442    pub fn operation(&self) -> crate::FormControlOperation {
443        match self {
444            Self::SetText { .. } => crate::FormControlOperation::SetText,
445            Self::SetChecked { .. } => crate::FormControlOperation::SetChecked,
446            Self::SelectOne { .. } => crate::FormControlOperation::SelectOne,
447            Self::SelectMany { .. } => crate::FormControlOperation::SelectMany,
448            Self::SetFiles { .. } => crate::FormControlOperation::SetFiles,
449            Self::Clear => crate::FormControlOperation::Clear,
450            Self::Activate => crate::FormControlOperation::Activate,
451        }
452    }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
457#[serde(rename_all = "camelCase")]
458pub struct DownloadUrlCommand {
459    pub url: String,
460    pub expected_content_type: Option<String>,
461    pub max_bytes: u64,
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
466#[serde(rename_all = "camelCase")]
467pub enum CommandClass {
468    Replayable,
469    Reconciliable,
470    Boundary,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
475#[serde(rename_all = "camelCase")]
476pub struct NavigateCommand {
477    pub url: String,
478    pub wait_until: WaitUntil,
479    pub timeout_ms: u64,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
484#[serde(rename_all = "camelCase")]
485pub enum WaitUntil {
486    Commit,
487    DomContentLoaded,
488    Interactive,
489    NetworkIdle,
490}
491
492#[derive(Debug, Clone, Default, Serialize, Deserialize)]
493#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
494#[serde(rename_all = "camelCase")]
495pub struct InspectCommand {
496    pub selector: Option<String>,
497    pub target: Option<TargetSpec>,
498    pub include_html: bool,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
503#[serde(rename_all = "camelCase")]
504pub struct ClickCommand {
505    pub selector: String,
506    pub target: Option<TargetSpec>,
507    pub boundary: bool,
508    pub expected_url: Option<String>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
513#[serde(rename_all = "camelCase")]
514pub struct TypeTextCommand {
515    pub selector: String,
516    pub target: Option<TargetSpec>,
517    pub value: String,
518    pub clear_first: bool,
519    /// When set, fail before typing unless the page URL matches
520    /// (same role as [`ClickCommand::expected_url`]).
521    #[serde(default)]
522    pub expected_url: Option<String>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
526#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
527#[serde(rename_all = "camelCase")]
528pub struct UploadFilesCommand {
529    pub selector: String,
530    pub target: Option<TargetSpec>,
531    pub paths: Vec<String>,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
535#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536#[serde(rename_all = "camelCase")]
537pub struct OpenPageCommand {
538    pub url: Option<String>,
539}
540
541#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
542#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
543pub struct ListPagesCommand;
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
547#[serde(rename_all = "camelCase")]
548pub struct ClosePageCommand {
549    pub page_id: PageId,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
553#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
554#[serde(rename_all = "camelCase")]
555pub struct ActivatePageCommand {
556    pub page_id: PageId,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
561#[serde(rename_all = "camelCase")]
562pub struct AccessibilitySnapshotCommand {
563    pub max_nodes: Option<u32>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
567#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
568#[serde(rename_all = "camelCase")]
569pub struct ExtractStructuredCommand {
570    pub schema: serde_json::Value,
571    pub purpose: Option<String>,
572}
573
574/// One cookie as returned by a cookie read on any engine.
575#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
576#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
577#[serde(rename_all = "camelCase", deny_unknown_fields)]
578pub struct CookieRecord {
579    pub name: String,
580    pub value: String,
581    pub domain: String,
582    pub path: String,
583    pub secure: bool,
584    pub http_only: bool,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub same_site: Option<String>,
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub expires_unix: Option<f64>,
589}
590
591/// One cookie to store. `url` anchors the cookie's origin; `path`, flags, and
592/// expiry are optional per the driver defaults.
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
595#[serde(rename_all = "camelCase", deny_unknown_fields)]
596pub struct SetCookieParam {
597    pub name: String,
598    pub value: String,
599    pub url: String,
600    #[serde(default)]
601    pub path: Option<String>,
602    #[serde(default)]
603    pub secure: bool,
604    #[serde(default)]
605    pub http_only: bool,
606    #[serde(default)]
607    pub same_site: Option<String>,
608    #[serde(default)]
609    pub expires_unix: Option<f64>,
610}
611
612#[derive(Debug, Clone, Default, Serialize, Deserialize)]
613#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
614#[serde(rename_all = "camelCase", deny_unknown_fields)]
615pub struct NetworkLogCommand {
616    /// Clear the recorded log after producing the artifact (default true).
617    #[serde(default = "default_true")]
618    pub clear: bool,
619}
620
621fn default_true() -> bool {
622    true
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627#[serde(rename_all = "camelCase", deny_unknown_fields)]
628pub struct ViewportSize {
629    pub width: u32,
630    pub height: u32,
631}
632
633#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
634#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
635#[serde(rename_all = "camelCase", deny_unknown_fields)]
636pub struct GeolocationCoordinates {
637    pub latitude: f64,
638    pub longitude: f64,
639    #[serde(default)]
640    pub accuracy: Option<f64>,
641}
642
643#[derive(Debug, Clone, Default, Serialize, Deserialize)]
644#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
645#[serde(rename_all = "camelCase", deny_unknown_fields)]
646pub struct EmulateCommand {
647    #[serde(default)]
648    pub viewport: Option<ViewportSize>,
649    #[serde(default)]
650    pub geolocation: Option<GeolocationCoordinates>,
651    /// Mobile device-metrics flag (Chromium); harmless elsewhere.
652    #[serde(default)]
653    pub mobile: Option<bool>,
654}
655
656#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
657#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
658#[serde(rename_all = "camelCase")]
659pub enum DialogAction {
660    Accept,
661    Dismiss,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize)]
665#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
666#[serde(rename_all = "camelCase", deny_unknown_fields)]
667pub struct HandleDialogCommand {
668    pub action: DialogAction,
669    #[serde(default)]
670    pub timeout_ms: Option<u64>,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
674#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
675#[serde(rename_all = "camelCase", deny_unknown_fields)]
676pub struct PrintToPdfCommand {
677    #[serde(default)]
678    pub landscape: bool,
679    #[serde(default = "default_print_background")]
680    pub print_background: bool,
681    #[serde(default)]
682    pub scale: Option<f64>,
683    #[serde(default)]
684    pub page_ranges: Option<String>,
685}
686
687fn default_print_background() -> bool {
688    true
689}
690
691#[derive(Debug, Clone, Default, Serialize, Deserialize)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693#[serde(rename_all = "camelCase", deny_unknown_fields)]
694pub struct GetCookiesCommand {
695    /// Restrict to these origin URLs; empty returns all cookies for the page's jar.
696    #[serde(default)]
697    pub urls: Vec<String>,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize)]
701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702#[serde(rename_all = "camelCase", deny_unknown_fields)]
703pub struct SetCookiesCommand {
704    pub cookies: Vec<SetCookieParam>,
705}
706
707#[derive(Debug, Clone, Default, Serialize, Deserialize)]
708#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
709#[serde(rename_all = "camelCase", deny_unknown_fields)]
710pub struct DeleteCookiesCommand {
711    /// Restrict deletion to these origin URLs; empty means every origin.
712    #[serde(default)]
713    pub urls: Vec<String>,
714    /// Restrict deletion to these cookie names; empty means every cookie.
715    #[serde(default)]
716    pub names: Vec<String>,
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721#[serde(rename_all = "camelCase")]
722pub struct ClickAndWaitForPopupCommand {
723    pub selector: String,
724    pub target: Option<TargetSpec>,
725    pub timeout_ms: u64,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
729#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
730#[serde(rename_all = "camelCase")]
731pub struct ClickAndWaitForDownloadCommand {
732    pub selector: String,
733    pub target: Option<TargetSpec>,
734    pub timeout_ms: u64,
735}
736
737#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
738#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
739#[serde(rename_all = "camelCase")]
740pub struct TargetSpec {
741    pub css: Option<String>,
742    pub test_id: Option<String>,
743    pub role: Option<String>,
744    pub accessible_name: Option<String>,
745    pub label: Option<String>,
746    pub text: Option<TextMatch>,
747    #[serde(default)]
748    pub attributes: BTreeMap<String, String>,
749    #[serde(default)]
750    pub frame_path: Vec<Box<TargetSpec>>,
751    #[serde(default)]
752    pub shadow_path: Vec<Box<TargetSpec>>,
753    pub ordinal: Option<usize>,
754    #[serde(default)]
755    pub allow_best_match: bool,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
760#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
761pub enum TextMatch {
762    Exact(String),
763    Contains(String),
764    Regex(String),
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
768#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
769#[serde(rename_all = "camelCase")]
770pub enum ElementState {
771    Attached,
772    Detached,
773    Visible,
774    Hidden,
775    Enabled,
776    Disabled,
777}
778
779#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
780#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
781#[serde(rename_all = "PascalCase")]
782pub enum NetworkResourceType {
783    Document,
784    Stylesheet,
785    Image,
786    Media,
787    Font,
788    Script,
789    TextTrack,
790    #[serde(rename = "XHR", alias = "Xhr")]
791    Xhr,
792    Fetch,
793    Prefetch,
794    EventSource,
795    WebSocket,
796    Manifest,
797    SignedExchange,
798    Ping,
799    #[serde(rename = "CSPViolationReport")]
800    CspViolationReport,
801    Preflight,
802    #[serde(rename = "FedCM")]
803    FedCm,
804    Other,
805}
806
807impl NetworkResourceType {
808    pub fn as_str(&self) -> &'static str {
809        match self {
810            Self::Document => "Document",
811            Self::Stylesheet => "Stylesheet",
812            Self::Image => "Image",
813            Self::Media => "Media",
814            Self::Font => "Font",
815            Self::Script => "Script",
816            Self::TextTrack => "TextTrack",
817            Self::Xhr => "XHR",
818            Self::Fetch => "Fetch",
819            Self::Prefetch => "Prefetch",
820            Self::EventSource => "EventSource",
821            Self::WebSocket => "WebSocket",
822            Self::Manifest => "Manifest",
823            Self::SignedExchange => "SignedExchange",
824            Self::Ping => "Ping",
825            Self::CspViolationReport => "CSPViolationReport",
826            Self::Preflight => "Preflight",
827            Self::FedCm => "FedCM",
828            Self::Other => "Other",
829        }
830    }
831}
832
833impl std::str::FromStr for NetworkResourceType {
834    type Err = String;
835
836    fn from_str(s: &str) -> Result<Self, Self::Err> {
837        match s {
838            "Document" | "document" => Ok(Self::Document),
839            "Stylesheet" | "stylesheet" => Ok(Self::Stylesheet),
840            "Image" | "image" => Ok(Self::Image),
841            "Media" | "media" => Ok(Self::Media),
842            "Font" | "font" => Ok(Self::Font),
843            "Script" | "script" => Ok(Self::Script),
844            "TextTrack" | "textTrack" | "texttrack" => Ok(Self::TextTrack),
845            "XHR" | "Xhr" | "xhr" => Ok(Self::Xhr),
846            "Fetch" | "fetch" => Ok(Self::Fetch),
847            "Prefetch" | "prefetch" => Ok(Self::Prefetch),
848            "EventSource" | "eventSource" | "eventsource" => Ok(Self::EventSource),
849            "WebSocket" | "webSocket" | "websocket" => Ok(Self::WebSocket),
850            "Manifest" | "manifest" => Ok(Self::Manifest),
851            "SignedExchange" | "signedExchange" => Ok(Self::SignedExchange),
852            "Ping" | "ping" => Ok(Self::Ping),
853            "CSPViolationReport" | "cspViolationReport" => Ok(Self::CspViolationReport),
854            "Preflight" | "preflight" => Ok(Self::Preflight),
855            "FedCM" | "FedCm" | "fedCM" => Ok(Self::FedCm),
856            "Other" | "other" => Ok(Self::Other),
857            other => Err(format!("unknown network resource type: {other}")),
858        }
859    }
860}
861
862#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
863#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
864#[serde(tag = "kind", rename_all = "camelCase")]
865pub enum WaitCondition {
866    Element {
867        target: Box<TargetSpec>,
868        state: ElementState,
869    },
870    Text {
871        target: Box<TargetSpec>,
872        matcher: TextMatch,
873    },
874    Value {
875        target: Box<TargetSpec>,
876        matcher: TextMatch,
877    },
878    Url {
879        matcher: TextMatch,
880    },
881    Document {
882        ready: WaitUntil,
883    },
884    NetworkQuiet {
885        #[serde(rename = "idleMs", alias = "idle_ms")]
886        idle_ms: u64,
887        #[serde(rename = "maxInFlight", alias = "max_in_flight")]
888        max_in_flight: usize,
889        #[serde(
890            default,
891            rename = "ignoreUrlSubstrings",
892            alias = "ignore_url_substrings",
893            skip_serializing_if = "Vec::is_empty"
894        )]
895        ignore_url_substrings: Vec<String>,
896        #[serde(
897            default,
898            rename = "ignoreResourceTypes",
899            alias = "ignore_resource_types",
900            skip_serializing_if = "Vec::is_empty"
901        )]
902        ignore_resource_types: Vec<NetworkResourceType>,
903        #[serde(
904            default,
905            rename = "ignoreLongLived",
906            alias = "ignore_long_lived",
907            skip_serializing_if = "std::ops::Not::not"
908        )]
909        ignore_long_lived: bool,
910    },
911}
912
913#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
914#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
915#[serde(rename_all = "camelCase")]
916pub struct WaitForCommand {
917    pub condition: WaitCondition,
918    pub timeout_ms: u64,
919}
920
921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
923#[serde(tag = "kind", rename_all = "camelCase")]
924pub enum ScreenshotMode {
925    Viewport,
926    FullPage,
927    Element {
928        target: Box<TargetSpec>,
929    },
930    Clip {
931        x: f64,
932        y: f64,
933        width: f64,
934        height: f64,
935    },
936}
937
938#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
940#[serde(rename_all = "camelCase")]
941pub struct CaptureScreenshotCommand {
942    pub mode: ScreenshotMode,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947#[serde(rename_all = "camelCase")]
948pub struct SetFocusEmulationCommand {
949    pub enabled: bool,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
954#[serde(rename_all = "camelCase")]
955pub struct SetEmulatedMediaCommand {
956    pub media: String,
957    pub features: BTreeMap<String, String>,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize)]
961#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
962#[serde(rename_all = "camelCase")]
963pub struct EvaluateJavaScriptCommand {
964    pub expression: String,
965    pub timeout_ms: u64,
966    #[serde(default)]
967    pub await_promise: bool,
968}
969
970/// Per-session gate for privileged execution. Defaults to deny; a session must
971/// opt in even when the bearer token holds the matching capability.
972#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
973#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
974#[serde(rename_all = "camelCase")]
975pub struct ExecutionPolicy {
976    #[serde(default)]
977    pub javascript_evaluation: bool,
978    #[serde(default)]
979    pub vision_assist: bool,
980    /// Whether workers leased for this session apply fingerprint spoofing.
981    ///
982    /// Per session, never process-wide: it changes what the browser presents to
983    /// the page, so the caller chooses it and the runtime records the choice.
984    #[serde(default)]
985    pub fingerprint: bool,
986    /// Whether workers leased for this session synthesize human-like input
987    /// timing (`behavioral-engine`) instead of driving the browser directly.
988    ///
989    /// Off by default: it changes observable timing on the intent execution
990    /// path. When on, the synthesized timing is carried in
991    /// `Evidence::Humanization` so intent verification still agrees.
992    #[serde(default)]
993    pub humanize: bool,
994    /// Name of the registered vision node this session escalates to.
995    ///
996    /// `None` means no escalation, and naming an unconfigured node declines, so
997    /// a session is never redirected to a provider it did not choose.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub vision_node: Option<String>,
1000}
1001
1002/// `POST /v1/sessions` body.
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1004#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1005#[serde(rename_all = "camelCase")]
1006pub struct CreateSessionRequest {
1007    pub profile: String,
1008    pub proxy: Option<String>,
1009    #[serde(default)]
1010    pub execution_policy: ExecutionPolicy,
1011}
1012
1013/// `POST /v1/pages` body.
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016pub struct OpenPageRequest {
1017    pub session_id: SessionId,
1018}
1019
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1022pub struct NavigationRequest {
1023    pub page_id: PageId,
1024    pub url: String,
1025    pub wait_until: Option<String>,
1026    pub timeout_ms: Option<u64>,
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1031pub struct ExtractRequest {
1032    pub page_id: PageId,
1033    pub fields: serde_json::Value,
1034}