Skip to main content

eoka_protocol/
sidecar.rs

1use schemars::{JsonSchema, Schema, SchemaGenerator};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::collections::{BTreeMap, HashMap};
5
6pub use crate::{OperationIdArgs, OperationListArgs, OperationWaitArgs};
7
8pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
9pub const MAX_MESSAGE_BYTES: usize = 32 * 1024 * 1024;
10pub const MAX_IN_FLIGHT: usize = 128;
11pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 1, minor: 3 };
12pub const MAX_BOUNDED_FETCH_BYTES: usize = 1024 * 1024;
13pub const MAX_SCREENSHOT_BYTES: usize = 16 * 1024 * 1024;
14pub const MAX_RECORDING_FPS: u32 = 120;
15pub const CAPABILITY_SCREENSHOT_OPTIONS: &str = "screenshotOptions";
16pub const CAPABILITY_SCREENSHOT_FILE: &str = "screenshotFile";
17pub const CAPABILITY_NATIVE_RECORDING: &str = "nativeRecording";
18pub const CAPABILITY_CLOUDFLARE_AUTO_SOLVE: &str = "cloudflareAutoSolve";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21pub struct ProtocolVersion {
22    pub major: u32,
23    pub minor: u32,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct HelloParams {
28    pub protocol: ProtocolVersion,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "camelCase")]
33pub struct TransportLimits {
34    pub max_message_bytes: usize,
35    pub max_in_flight: usize,
36}
37
38impl Default for TransportLimits {
39    fn default() -> Self {
40        Self {
41            max_message_bytes: MAX_MESSAGE_BYTES,
42            max_in_flight: MAX_IN_FLIGHT,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "camelCase")]
49pub struct HelloResult {
50    pub protocol: ProtocolVersion,
51    pub server_version: String,
52    pub core_version: String,
53    pub methods: Vec<String>,
54    pub capabilities: Vec<String>,
55    pub limits: TransportLimits,
56}
57
58pub fn valid_request_id(id: &Value) -> bool {
59    id.as_u64()
60        .is_some_and(|id| id > 0 && id <= MAX_SAFE_INTEGER)
61}
62
63fn deserialize_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Value, D::Error> {
64    let id = Value::deserialize(deserializer)?;
65    if valid_request_id(&id) {
66        Ok(id)
67    } else {
68        Err(serde::de::Error::custom(
69            "id must be a positive integer no greater than 9007199254740991",
70        ))
71    }
72}
73
74fn request_id_schema(_: &mut SchemaGenerator) -> Schema {
75    json!({"type":"integer", "minimum":1, "maximum":MAX_SAFE_INTEGER})
76        .try_into()
77        .unwrap()
78}
79
80fn response_id_schema(_: &mut SchemaGenerator) -> Schema {
81    json!({"anyOf":[{"type":"integer","minimum":1,"maximum":MAX_SAFE_INTEGER},{"type":"null"}]})
82        .try_into()
83        .unwrap()
84}
85
86fn deserialize_response_id<'de, D: serde::Deserializer<'de>>(
87    deserializer: D,
88) -> Result<Value, D::Error> {
89    let id = Value::deserialize(deserializer)?;
90    if id.is_null() || valid_request_id(&id) {
91        Ok(id)
92    } else {
93        Err(serde::de::Error::custom(
94            "response id must be null or a safe positive integer",
95        ))
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100pub struct Request {
101    #[serde(deserialize_with = "deserialize_id")]
102    #[schemars(schema_with = "request_id_schema")]
103    pub id: Value,
104    pub method: String,
105    #[serde(default = "empty_params")]
106    pub params: Value,
107}
108
109fn empty_params() -> Value {
110    json!({})
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114#[serde(untagged, deny_unknown_fields)]
115pub enum Response {
116    Success {
117        #[schemars(schema_with = "response_id_schema")]
118        #[serde(deserialize_with = "deserialize_response_id")]
119        id: Value,
120        result: Value,
121    },
122    Failure {
123        #[schemars(schema_with = "response_id_schema")]
124        #[serde(deserialize_with = "deserialize_response_id")]
125        id: Value,
126        error: ServerError,
127    },
128}
129
130impl Response {
131    pub fn ok(id: impl Into<Value>, result: Value) -> Self {
132        Self::Success {
133            id: response_id(id.into()),
134            result,
135        }
136    }
137
138    pub fn err(id: impl Into<Value>, error: ServerError) -> Self {
139        Self::Failure {
140            id: response_id(id.into()),
141            error,
142        }
143    }
144}
145
146fn response_id(id: Value) -> Value {
147    if valid_request_id(&id) {
148        id
149    } else {
150        Value::Null
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub enum ErrorCode {
156    ElementNotFound,
157    ElementNotVisible,
158    Timeout,
159    RetryExhausted,
160    Cdp,
161    InvalidPage,
162    InvalidParams,
163    UnknownMethod,
164    Internal,
165    ProtocolMismatch,
166    MessageTooLarge,
167    ServerBusy,
168    ShuttingDown,
169    Cancelled,
170    Action,
171    Operation,
172    ScreenshotTooLarge,
173    FileOutput,
174    RecordingUnsupported,
175    RecordingNotFound,
176    RecordingConflict,
177    RecordingAlreadyActive,
178    RecordingInvalidState,
179    RecordingFailed,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
183pub struct CdpErrorDetail {
184    pub method: Option<String>,
185    pub code: Option<i64>,
186    pub message: String,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
190pub struct ErrorData {
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub operation_id: Option<String>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub receipt: Option<crate::ActionReceipt>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub detail: Option<crate::ErrorDetail>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub cdp: Option<CdpErrorDetail>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub source: Option<Box<ServerError>>,
201    #[serde(rename = "recordingId", skip_serializing_if = "Option::is_none")]
202    pub recording_id: Option<String>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub recording: Option<RecordingRecord>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub path: Option<String>,
207    #[serde(rename = "partialPath", skip_serializing_if = "Option::is_none")]
208    pub partial_path: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212pub struct ServerError {
213    pub code: ErrorCode,
214    pub message: String,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub data: Option<Box<ErrorData>>,
217}
218
219impl ServerError {
220    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
221        Self {
222            code,
223            message: message.into(),
224            data: None,
225        }
226    }
227    pub fn with_data(mut self, data: ErrorData) -> Self {
228        self.data = Some(Box::new(data));
229        self
230    }
231    pub fn from_detail(detail: crate::ErrorDetail) -> Self {
232        Self::new(ErrorCode::Operation, detail.message.clone()).with_data(ErrorData {
233            detail: Some(detail),
234            ..Default::default()
235        })
236    }
237    pub fn invalid_params(message: impl Into<String>) -> Self {
238        Self::new(ErrorCode::InvalidParams, message)
239    }
240    pub fn invalid_page(page_id: &str) -> Self {
241        Self::new(
242            ErrorCode::InvalidPage,
243            format!("unknown pageId \"{page_id}\""),
244        )
245    }
246    pub fn unknown_method(method: &str) -> Self {
247        Self::new(
248            ErrorCode::UnknownMethod,
249            format!("unknown method \"{method}\""),
250        )
251    }
252    pub fn internal(message: impl Into<String>) -> Self {
253        Self::new(ErrorCode::Internal, message)
254    }
255}
256
257impl std::fmt::Display for ServerError {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.write_str(&self.message)
260    }
261}
262impl std::error::Error for ServerError {}
263
264impl From<eoka::Error> for ServerError {
265    fn from(error: eoka::Error) -> Self {
266        let message = error.to_string();
267        match error {
268            eoka::Error::Action {
269                code,
270                receipt,
271                source,
272            } => Self::new(ErrorCode::Action, message).with_data(ErrorData {
273                detail: Some(crate::ErrorDetail::new(code, source.to_string())),
274                receipt: Some(*receipt),
275                source: Some(Box::new(Self::from(*source))),
276                ..Default::default()
277            }),
278            eoka::Error::Cdp {
279                method,
280                code,
281                message: cdp_message,
282            } => Self::new(ErrorCode::Cdp, message).with_data(ErrorData {
283                cdp: Some(CdpErrorDetail {
284                    method,
285                    code,
286                    message: cdp_message,
287                }),
288                ..Default::default()
289            }),
290            eoka::Error::ElementNotFound(_) => Self::new(ErrorCode::ElementNotFound, message),
291            eoka::Error::ElementNotVisible { .. } => {
292                Self::new(ErrorCode::ElementNotVisible, message)
293            }
294            eoka::Error::Timeout(_) => Self::new(ErrorCode::Timeout, message),
295            eoka::Error::RetryExhausted { .. } => Self::new(ErrorCode::RetryExhausted, message),
296            eoka::Error::InvalidScreenshotOptions(_) => {
297                Self::new(ErrorCode::InvalidParams, message)
298            }
299            _ => Self::internal(message),
300        }
301    }
302}
303
304macro_rules! dto {
305    ($name:ident { $($(#[$attr:meta])* $field:ident: $ty:ty),* $(,)? }) => {
306        #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
307        #[serde(rename_all = "camelCase")]
308        pub struct $name { $($(#[$attr])* pub $field: $ty),* }
309    };
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct Empty {}
315
316dto!(LaunchParams {
317    #[serde(default = "default_headless")] headless: bool,
318    user_agent: Option<String>, timezone: Option<String>,
319    #[schemars(range(min = 1, max = 2147483647))] viewport_width: Option<u32>,
320    #[schemars(range(min = 1, max = 2147483647))] viewport_height: Option<u32>,
321    user_data_dir: Option<String>, proxy: Option<ProxyParams>,
322    executable_path: Option<String>, runtime_dir: Option<String>,
323    #[serde(default)] sandbox: bool,
324    #[serde(default)] aggressive_cdp_evasion: bool,
325    #[serde(default)] geo_align: bool,
326    #[serde(default)] extra_args: Vec<String>,
327    #[schemars(schema_with = "optional_positive_scale_schema")]
328    device_scale_factor: Option<f64>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    auto_solve_cloudflare: Option<CloudflareAutoSolve>,
331});
332dto!(ProxyParams { server: String, username: Option<String>, password: Option<String> });
333fn default_headless() -> bool {
334    true
335}
336fn default_device_scale_factor() -> f64 {
337    1.0
338}
339
340fn positive_scale_schema(_: &mut SchemaGenerator) -> Schema {
341    json!({"type":"number","exclusiveMinimum":0})
342        .try_into()
343        .unwrap()
344}
345
346fn optional_positive_scale_schema(_: &mut SchemaGenerator) -> Schema {
347    json!({"type":["number","null"],"exclusiveMinimum":0})
348        .try_into()
349        .unwrap()
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ConnectParams {
355    pub ws_endpoint: String,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub auto_solve_cloudflare: Option<CloudflareAutoSolve>,
358}
359
360impl ConnectParams {
361    pub fn validate(&self) -> Result<(), ServerError> {
362        self.auto_solve_cloudflare
363            .as_ref()
364            .map_or(Ok(()), CloudflareAutoSolve::validate)
365    }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
369#[serde(rename_all = "lowercase")]
370pub enum BrowserOwnership {
371    Owned,
372    Borrowed,
373}
374
375dto!(BrowserSessionResult {
376    ownership: BrowserOwnership,
377    browser_version: String,
378});
379dto!(SetViewportParams {
380    page_id: String,
381    #[schemars(range(min = 1, max = 2147483647))]
382    width: u32,
383    #[schemars(range(min = 1, max = 2147483647))]
384    height: u32,
385    #[serde(default = "default_device_scale_factor")]
386    #[schemars(schema_with = "positive_scale_schema")]
387    device_scale_factor: f64,
388});
389
390pub fn validate_viewport(width: u32, height: u32, scale: f64) -> Result<(), ServerError> {
391    if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
392        return Err(ServerError::invalid_params(
393            "viewport dimensions must be positive and no greater than 2147483647",
394        ));
395    }
396    if !scale.is_finite() || scale <= 0.0 {
397        return Err(ServerError::invalid_params(
398            "deviceScaleFactor must be finite and positive",
399        ));
400    }
401    Ok(())
402}
403
404impl LaunchParams {
405    pub fn validate(&self) -> Result<(), ServerError> {
406        if self.viewport_width.is_some() != self.viewport_height.is_some() {
407            return Err(ServerError::invalid_params(
408                "viewportWidth and viewportHeight must be provided together",
409            ));
410        }
411        validate_viewport(
412            self.viewport_width.unwrap_or(1),
413            self.viewport_height.unwrap_or(1),
414            self.device_scale_factor.unwrap_or(1.0),
415        )?;
416        for (name, path) in [
417            ("userDataDir", self.user_data_dir.as_deref()),
418            ("executablePath", self.executable_path.as_deref()),
419            ("runtimeDir", self.runtime_dir.as_deref()),
420        ] {
421            if path.is_some_and(|path| !std::path::Path::new(path).is_absolute()) {
422                return Err(ServerError::invalid_params(format!(
423                    "{name} must be an absolute path",
424                )));
425            }
426        }
427        if let Some(auto_solve) = &self.auto_solve_cloudflare {
428            auto_solve.validate()?;
429        }
430        Ok(())
431    }
432}
433dto!(NewPageParams { url: Option<String> });
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436#[serde(rename_all = "lowercase")]
437pub enum ScreenshotFormat {
438    Png,
439    Jpeg,
440    Webp,
441}
442
443dto!(ScreenshotClip {
444    #[schemars(range(min = 0))]
445    x: f64,
446    #[schemars(range(min = 0))]
447    y: f64,
448    #[schemars(schema_with = "positive_scale_schema")]
449    width: f64,
450    #[schemars(schema_with = "positive_scale_schema")]
451    height: f64,
452});
453dto!(ScreenshotParams {
454    page_id: String,
455    #[serde(skip_serializing_if = "Option::is_none")] format: Option<ScreenshotFormat>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    #[schemars(range(max = 100))] quality: Option<u8>,
458    #[serde(skip_serializing_if = "Option::is_none")] clip: Option<ScreenshotClip>,
459    #[serde(skip_serializing_if = "Option::is_none")] full_page: Option<bool>,
460});
461dto!(ScreenshotToFileParams {
462    page_id: String,
463    path: String,
464    #[serde(skip_serializing_if = "Option::is_none")] format: Option<ScreenshotFormat>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    #[schemars(range(max = 100))] quality: Option<u8>,
467    #[serde(skip_serializing_if = "Option::is_none")] clip: Option<ScreenshotClip>,
468    #[serde(skip_serializing_if = "Option::is_none")] full_page: Option<bool>,
469});
470
471fn screenshot_options(
472    format: Option<ScreenshotFormat>,
473    quality: Option<u8>,
474    clip: Option<&ScreenshotClip>,
475    full_page: Option<bool>,
476) -> eoka::ScreenshotOptions {
477    eoka::ScreenshotOptions {
478        format: match format.unwrap_or(ScreenshotFormat::Png) {
479            ScreenshotFormat::Png => eoka::ScreenshotFormat::Png,
480            ScreenshotFormat::Jpeg => eoka::ScreenshotFormat::Jpeg,
481            ScreenshotFormat::Webp => eoka::ScreenshotFormat::Webp,
482        },
483        quality,
484        clip: clip.map(|clip| eoka::ScreenshotClip {
485            x: clip.x,
486            y: clip.y,
487            width: clip.width,
488            height: clip.height,
489        }),
490        full_page: full_page.unwrap_or(false),
491    }
492}
493
494fn validate_screenshot(
495    format: Option<ScreenshotFormat>,
496    quality: Option<u8>,
497    clip: Option<&ScreenshotClip>,
498    full_page: Option<bool>,
499) -> Result<(), ServerError> {
500    if quality.is_some_and(|quality| quality > 100)
501        || (quality.is_some() && format.unwrap_or(ScreenshotFormat::Png) == ScreenshotFormat::Png)
502    {
503        return Err(ServerError::invalid_params(
504            "quality must be between 0 and 100 and is only supported for JPEG or WebP",
505        ));
506    }
507    if let Some(clip) = clip {
508        if full_page.unwrap_or(false) {
509            return Err(ServerError::invalid_params(
510                "clip and fullPage cannot be combined",
511            ));
512        }
513        if !clip.x.is_finite()
514            || !clip.y.is_finite()
515            || clip.x < 0.0
516            || clip.y < 0.0
517            || !clip.width.is_finite()
518            || !clip.height.is_finite()
519            || clip.width <= 0.0
520            || clip.height <= 0.0
521        {
522            return Err(ServerError::invalid_params(
523                "clip requires finite nonnegative x/y and finite positive width/height",
524            ));
525        }
526    }
527    Ok(())
528}
529
530fn validate_output_path(path: &str) -> Result<(), ServerError> {
531    if !std::path::Path::new(path).is_absolute() || path.as_bytes().contains(&0) {
532        return Err(ServerError::invalid_params(
533            "path must be an absolute file path without NUL bytes",
534        ));
535    }
536    Ok(())
537}
538
539impl ScreenshotParams {
540    pub fn validate(&self) -> Result<(), ServerError> {
541        validate_screenshot(
542            self.format,
543            self.quality,
544            self.clip.as_ref(),
545            self.full_page,
546        )
547    }
548    pub fn options(&self) -> eoka::ScreenshotOptions {
549        screenshot_options(
550            self.format,
551            self.quality,
552            self.clip.as_ref(),
553            self.full_page,
554        )
555    }
556}
557
558impl ScreenshotToFileParams {
559    pub fn validate(&self) -> Result<(), ServerError> {
560        validate_screenshot(
561            self.format,
562            self.quality,
563            self.clip.as_ref(),
564            self.full_page,
565        )?;
566        validate_output_path(&self.path)
567    }
568    pub fn options(&self) -> eoka::ScreenshotOptions {
569        screenshot_options(
570            self.format,
571            self.quality,
572            self.clip.as_ref(),
573            self.full_page,
574        )
575    }
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
579#[serde(rename_all = "lowercase")]
580pub enum ArtifactContainer {
581    Mp4,
582}
583
584dto!(Artifact {
585    path: String,
586    bytes: u64,
587    mime_type: String,
588    #[serde(skip_serializing_if = "Option::is_none")] container: Option<ArtifactContainer>,
589});
590
591impl Artifact {
592    pub fn validate(&self) -> Result<(), ServerError> {
593        validate_output_path(&self.path)?;
594        if self.bytes > MAX_SAFE_INTEGER {
595            return Err(ServerError::invalid_params(
596                "artifact bytes exceed the JavaScript-safe integer limit",
597            ));
598        }
599        Ok(())
600    }
601}
602
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
604#[serde(rename_all = "camelCase", deny_unknown_fields)]
605pub struct RecordingStartParams {
606    pub page_id: String,
607    #[schemars(regex(
608        pattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
609    ))]
610    pub recording_id: String,
611    pub path: String,
612    #[serde(skip_serializing_if = "Option::is_none")]
613    #[schemars(range(min = 1, max = 2147483647))]
614    pub max_width: Option<u32>,
615    #[serde(skip_serializing_if = "Option::is_none")]
616    #[schemars(range(min = 1, max = 2147483647))]
617    pub max_height: Option<u32>,
618    #[serde(skip_serializing_if = "Option::is_none")]
619    #[schemars(range(min = 1, max = 120))]
620    pub fps: Option<u32>,
621}
622
623dto!(RecordingIdParams {
624    #[schemars(regex(
625        pattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
626    ))]
627    recording_id: String,
628});
629
630fn validate_recording_id(id: &str) -> Result<(), ServerError> {
631    if id.len() != 36
632        || !id.bytes().enumerate().all(|(index, byte)| {
633            if matches!(index, 8 | 13 | 18 | 23) {
634                byte == b'-'
635            } else {
636                byte.is_ascii_hexdigit()
637            }
638        })
639    {
640        return Err(ServerError::invalid_params("recordingId must be a UUID"));
641    }
642    Ok(())
643}
644
645impl RecordingStartParams {
646    pub fn validate(&self) -> Result<(), ServerError> {
647        validate_recording_id(&self.recording_id)?;
648        validate_output_path(&self.path)?;
649        if self
650            .max_width
651            .into_iter()
652            .chain(self.max_height)
653            .any(|dimension| dimension == 0 || dimension > i32::MAX as u32)
654        {
655            return Err(ServerError::invalid_params(
656                "maxWidth/maxHeight must be positive and no greater than 2147483647",
657            ));
658        }
659        if self
660            .fps
661            .is_some_and(|fps| fps == 0 || fps > MAX_RECORDING_FPS)
662        {
663            return Err(ServerError::invalid_params(
664                "fps must be an integer between 1 and 120",
665            ));
666        }
667        Ok(())
668    }
669}
670
671impl RecordingIdParams {
672    pub fn validate(&self) -> Result<(), ServerError> {
673        validate_recording_id(&self.recording_id)
674    }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
678#[serde(rename_all = "lowercase")]
679pub enum RecordingState {
680    Running,
681    Finalizing,
682    Completed,
683    Cancelled,
684    Failed,
685}
686
687impl RecordingState {
688    pub fn is_terminal(self) -> bool {
689        matches!(self, Self::Completed | Self::Cancelled | Self::Failed)
690    }
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
694#[serde(rename_all = "lowercase")]
695pub enum RecordingFailureStage {
696    Start,
697    Stop,
698    Read,
699    Write,
700    Flush,
701    Close,
702    Publish,
703    Cancel,
704    Disconnect,
705}
706
707dto!(RecordingFailure {
708    stage: RecordingFailureStage,
709    code: ErrorCode,
710    message: String,
711    #[serde(skip_serializing_if = "Option::is_none")] partial_path: Option<String>,
712    #[serde(skip_serializing_if = "Option::is_none")] cleanup_uncertain: Option<bool>,
713});
714dto!(RecordingRecord {
715    recording_id: String,
716    page_id: String,
717    state: RecordingState,
718    path: String,
719    #[serde(skip_serializing_if = "Option::is_none")] artifact: Option<Artifact>,
720    #[serde(skip_serializing_if = "Option::is_none")] failure: Option<RecordingFailure>,
721});
722
723impl RecordingRecord {
724    pub fn validate(&self) -> Result<(), ServerError> {
725        validate_recording_id(&self.recording_id)?;
726        validate_output_path(&self.path)?;
727        if self.state == RecordingState::Completed {
728            let artifact = self.artifact.as_ref().ok_or_else(|| {
729                ServerError::invalid_params("completed recording requires a published artifact")
730            })?;
731            artifact.validate()?;
732            if artifact.path != self.path
733                || artifact.container != Some(ArtifactContainer::Mp4)
734                || artifact.mime_type != "video/mp4"
735                || self.failure.is_some()
736            {
737                return Err(ServerError::invalid_params(
738                    "completed recording requires its destination MP4 artifact and no failure",
739                ));
740            }
741        } else if self.artifact.is_some() {
742            return Err(ServerError::invalid_params(
743                "only completed recordings may contain an artifact",
744            ));
745        }
746        if self.state == RecordingState::Failed && self.failure.is_none() {
747            return Err(ServerError::invalid_params(
748                "failed recording requires failure evidence",
749            ));
750        }
751        if self.failure.is_some() && !self.state.is_terminal() {
752            return Err(ServerError::invalid_params(
753                "nonterminal recording cannot contain failure evidence",
754            ));
755        }
756        if let Some(partial) = self
757            .failure
758            .as_ref()
759            .and_then(|failure| failure.partial_path.as_ref())
760        {
761            validate_output_path(partial)?;
762            if partial == &self.path {
763                return Err(ServerError::invalid_params(
764                    "partial artifact cannot use the destination success path",
765                ));
766            }
767        }
768        Ok(())
769    }
770}
771dto!(PageIdParams { page_id: String });
772dto!(FramesParams { page_id: String });
773dto!(FrameJsParams {
774    page_id: String,
775    frame_id: String,
776    #[schemars(
777        description = "JavaScript expression evaluated in the frame's isolated world. Frame IDs come from `page.frames`; detached or unrelated IDs are rejected."
778    )]
779    js: String,
780});
781dto!(FrameInfo {
782    id: String,
783    url: String,
784    #[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>,
785});
786dto!(ClickShadowParams {
787    page_id: String,
788    selector: String,
789    #[serde(default, skip_serializing_if = "Option::is_none")] frame_id: Option<String>,
790    #[serde(default = "default_shadow_timeout_ms")] timeout_ms: u64,
791});
792impl ClickShadowParams {
793    pub fn validate(&self) -> Result<(), ServerError> {
794        if self.selector.trim().is_empty() || self.selector.len() > 4096 {
795            return Err(ServerError::invalid_params(
796                "selector must be 1..=4096 characters",
797            ));
798        }
799        if self.timeout_ms == 0 || self.timeout_ms > 60_000 {
800            return Err(ServerError::invalid_params(
801                "timeoutMs must be between 1 and 60000",
802            ));
803        }
804        Ok(())
805    }
806}
807dto!(ClickShadowResult {
808    frame_id: String,
809    selector: String,
810    x: f64,
811    y: f64,
812});
813fn default_shadow_timeout_ms() -> u64 {
814    10_000
815}
816dto!(SolveTurnstileParams {
817    page_id: String,
818    #[serde(default = "default_turnstile_timeout_ms")]
819    #[schemars(range(min = 1, max = 120000))]
820    timeout_ms: u64,
821});
822impl SolveTurnstileParams {
823    pub fn validate(&self) -> Result<(), ServerError> {
824        validate_turnstile_timeout(self.timeout_ms)
825    }
826}
827dto!(SolveTurnstileResult {
828    clicks: u32,
829    elapsed_ms: u64
830});
831// Server-side watcher that solves full-page Cloudflare challenges in every tab.
832dto!(CloudflareAutoSolve {
833    #[serde(default = "default_cloudflare_interval_ms")]
834    #[schemars(range(min = 100, max = 60000))]
835    interval_ms: u64,
836    #[serde(default = "default_turnstile_timeout_ms")]
837    #[schemars(range(min = 1, max = 120000))]
838    timeout_ms: u64,
839});
840impl CloudflareAutoSolve {
841    pub fn validate(&self) -> Result<(), ServerError> {
842        if !(100..=60_000).contains(&self.interval_ms) {
843            return Err(ServerError::invalid_params(
844                "autoSolveCloudflare.intervalMs must be between 100 and 60000",
845            ));
846        }
847        validate_turnstile_timeout(self.timeout_ms)
848    }
849}
850fn validate_turnstile_timeout(timeout_ms: u64) -> Result<(), ServerError> {
851    if timeout_ms == 0 || timeout_ms > 120_000 {
852        return Err(ServerError::invalid_params(
853            "timeoutMs must be between 1 and 120000",
854        ));
855    }
856    Ok(())
857}
858fn default_turnstile_timeout_ms() -> u64 {
859    30_000
860}
861fn default_cloudflare_interval_ms() -> u64 {
862    500
863}
864dto!(SelectorParams {
865    page_id: String,
866    selector: String
867});
868dto!(WaitForParams {
869    page_id: String,
870    selector: String,
871    timeout_ms: u64
872});
873dto!(WaitForTextParams {
874    page_id: String,
875    text: String,
876    timeout_ms: u64
877});
878dto!(JsParams {
879    page_id: String,
880    #[schemars(
881        description = "JavaScript expression, awaited. Evaluation accepts only JSON data: null, booleans, strings, finite numbers, arrays and plain objects. Undefined, BigInt, functions, symbols, cycles and non-JSON object types are rejected. Execute discards its return value."
882    )]
883    js: String,
884});
885dto!(FetchParams {
886    page_id: String, url: String,
887    #[serde(default = "default_fetch_method")] method: String,
888    #[serde(default)] headers: BTreeMap<String, String>,
889    #[serde(default)] body: Option<String>,
890    #[serde(default = "default_fetch_redirect")]
891    #[schemars(regex(pattern = "^(follow|error|manual)$"))] redirect: String,
892});
893dto!(BoundedFetchParams {
894    #[serde(flatten)]
895    request: FetchParams,
896    #[serde(default = "default_bounded_fetch_bytes")]
897    #[schemars(range(max = 1048576))]
898    max_bytes: usize,
899});
900
901impl BoundedFetchParams {
902    pub fn validate(&self) -> Result<(), ServerError> {
903        if self.max_bytes > MAX_BOUNDED_FETCH_BYTES {
904            return Err(ServerError::invalid_params(
905                "maxBytes must not exceed 1048576 bytes",
906            ));
907        }
908        if !matches!(
909            self.request.redirect.as_str(),
910            "follow" | "manual" | "error"
911        ) {
912            return Err(ServerError::invalid_params(
913                "redirect must be follow, manual, or error",
914            ));
915        }
916        Ok(())
917    }
918}
919
920fn default_bounded_fetch_bytes() -> usize {
921    8192
922}
923
924fn default_fetch_method() -> String {
925    "GET".into()
926}
927fn default_fetch_redirect() -> String {
928    "follow".into()
929}
930dto!(MouseButtonParams {
931    page_id: String,
932    #[serde(flatten)]
933    mouse: crate::MouseButtonArgs
934});
935dto!(MouseMoveParams {
936    page_id: String,
937    #[serde(flatten)]
938    mouse: crate::MouseMoveArgs
939});
940dto!(HeldKeyParams {
941    page_id: String,
942    #[serde(flatten)]
943    key: crate::KeyArgs
944});
945dto!(RestoreStateParams {
946    page_id: String,
947    state: BrowserState
948});
949dto!(SolveCaptchaParams {
950    page_id: String, api_key: String,
951    #[schemars(regex(pattern = "^recaptcha_v2_enterprise$"))] captcha_type: String,
952    #[schemars(regex(pattern = "^anti_captcha_proxyless$"))] captcha_mode: String,
953    #[serde(rename = "websiteURL")] website_url: String,
954    website_key: String,
955    #[serde(default)] enterprise_payload: Option<Value>,
956    #[serde(default)] api_domain: Option<String>,
957    #[serde(default)] callback: Option<String>,
958});
959
960#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
961pub struct SessionCookie {
962    pub name: String,
963    pub value: String,
964    pub domain: String,
965    pub path: String,
966    pub secure: bool,
967    pub http_only: bool,
968    pub same_site: Option<String>,
969    pub expires: Option<f64>,
970}
971dto!(BrowserState {
972    cookies: Vec<SessionCookie>,
973    #[serde(default)] local_storage: HashMap<String, String>,
974    #[serde(default)] session_storage: HashMap<String, String>,
975    user_agent: String, url: String,
976});
977
978impl From<eoka::BrowserState> for BrowserState {
979    fn from(state: eoka::BrowserState) -> Self {
980        Self {
981            cookies: state
982                .cookies
983                .into_iter()
984                .map(|cookie| SessionCookie {
985                    name: cookie.name,
986                    value: cookie.value,
987                    domain: cookie.domain,
988                    path: cookie.path,
989                    secure: cookie.secure,
990                    http_only: cookie.http_only,
991                    same_site: cookie.same_site,
992                    expires: cookie.expires,
993                })
994                .collect(),
995            local_storage: state.local_storage,
996            session_storage: state.session_storage,
997            user_agent: state.user_agent,
998            url: state.url,
999        }
1000    }
1001}
1002impl From<BrowserState> for eoka::BrowserState {
1003    fn from(state: BrowserState) -> Self {
1004        Self {
1005            cookies: state
1006                .cookies
1007                .into_iter()
1008                .map(|cookie| eoka::SessionCookie {
1009                    name: cookie.name,
1010                    value: cookie.value,
1011                    domain: cookie.domain,
1012                    path: cookie.path,
1013                    secure: cookie.secure,
1014                    http_only: cookie.http_only,
1015                    same_site: cookie.same_site,
1016                    expires: cookie.expires,
1017                })
1018                .collect(),
1019            local_storage: state.local_storage,
1020            session_storage: state.session_storage,
1021            user_agent: state.user_agent,
1022            url: state.url,
1023        }
1024    }
1025}
1026
1027dto!(NewPageResult { page_id: String });
1028dto!(Tab {
1029    id: String,
1030    title: String,
1031    url: String
1032});
1033dto!(TabsResult { tabs: Vec<Tab> });
1034dto!(CloseTabResult { #[serde(skip_serializing_if = "Option::is_none")] cleanup_error: Option<String> });
1035dto!(TextResult { text: String });
1036dto!(ContentResult { html: String });
1037dto!(TitleResult { title: String });
1038dto!(UrlResult { url: String });
1039dto!(AttributeResult { value: Option<String> });
1040dto!(ExistsResult { exists: bool });
1041dto!(EvaluateResult { result: Value });
1042dto!(CaptureStateResult {
1043    state: BrowserState
1044});
1045dto!(ScreenshotResult {
1046    data_base64: String
1047});
1048dto!(FetchResult { url: String, status: u16, ok: bool, headers: BTreeMap<String, String>, body: String });
1049dto!(BoundedFetchResult {
1050    url: String,
1051    status: u16,
1052    status_text: String,
1053    r#type: String,
1054    redirected: bool,
1055    headers: HashMap<String, String>,
1056    body: String,
1057    truncated: bool,
1058    bytes_read: usize,
1059    body_omitted: Option<String>,
1060});
1061
1062#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1063pub struct CaptchaInjection {
1064    pub kind: String,
1065    pub updated_count: usize,
1066    pub created: Vec<String>,
1067    pub callbacks: Vec<String>,
1068    pub errors: Vec<String>,
1069    #[serde(rename = "solverUserAgent", skip_serializing_if = "Option::is_none")]
1070    pub solver_user_agent: Option<String>,
1071}
1072dto!(SolveCaptchaResult {
1073    injection: CaptchaInjection
1074});
1075
1076macro_rules! action_params {
1077    ($name:ident { $($field:ident: $ty:ty),* }) => {
1078        dto!($name {
1079            page_id: String,
1080            #[serde(rename = "operation_id")] operation_id: String,
1081            #[serde(default)] options: crate::ActionOptions,
1082            $($field: $ty),*
1083        });
1084    };
1085}
1086action_params!(ActionPerformParams {
1087    action: crate::Action
1088});
1089action_params!(GotoParams { url: String });
1090action_params!(ClickParams { selector: String });
1091action_params!(ClickTextParams { text: String });
1092action_params!(FillParams {
1093    selector: String,
1094    value: String
1095});
1096action_params!(TypeIntoParams {
1097    selector: String,
1098    text: String
1099});
1100action_params!(PressKeyParams { key: String });
1101dto!(AgentObserveParams {
1102    page_id: String,
1103    #[serde(flatten)]
1104    args: crate::AgentObserveArgs
1105});
1106dto!(AgentStepParams {
1107    page_id: String,
1108    #[serde(flatten)]
1109    args: crate::AgentStepArgs
1110});
1111
1112pub fn validate_safe_numbers(value: &Value) -> Result<(), ServerError> {
1113    match value {
1114        Value::Number(number) => {
1115            let unsafe_integer = number.as_u64().is_some_and(|n| n > MAX_SAFE_INTEGER)
1116                || number
1117                    .as_i64()
1118                    .is_some_and(|n| n < -(MAX_SAFE_INTEGER as i64))
1119                || number
1120                    .as_f64()
1121                    .is_some_and(|n| n.fract() == 0.0 && n.abs() > MAX_SAFE_INTEGER as f64);
1122            if unsafe_integer {
1123                return Err(ServerError::invalid_params(
1124                    "integer exceeds JavaScript's exact safe integer range",
1125                ));
1126            }
1127        }
1128        Value::Array(values) => {
1129            for value in values {
1130                validate_safe_numbers(value)?;
1131            }
1132        }
1133        Value::Object(values) => {
1134            for value in values.values() {
1135                validate_safe_numbers(value)?;
1136            }
1137        }
1138        _ => {}
1139    }
1140    Ok(())
1141}
1142
1143fn constrain_integers(value: &mut Value) {
1144    match value {
1145        Value::Object(object) => {
1146            let integer = object.get("type").is_some_and(|ty| {
1147                ty == "integer"
1148                    || ty
1149                        .as_array()
1150                        .is_some_and(|types| types.iter().any(|ty| ty == "integer"))
1151            });
1152            if integer {
1153                let (rust_minimum, rust_maximum) =
1154                    match object.get("format").and_then(Value::as_str) {
1155                        Some("int8") => (i8::MIN as f64, i8::MAX as f64),
1156                        Some("int16") => (i16::MIN as f64, i16::MAX as f64),
1157                        Some("int32") => (i32::MIN as f64, i32::MAX as f64),
1158                        Some("uint8") => (0.0, u8::MAX as f64),
1159                        Some("uint16") => (0.0, u16::MAX as f64),
1160                        Some("uint32") => (0.0, u32::MAX as f64),
1161                        Some("uint") => (0.0, (usize::MAX as f64).min(MAX_SAFE_INTEGER as f64)),
1162                        Some("uint64" | "uint128") => (0.0, MAX_SAFE_INTEGER as f64),
1163                        _ => (-(MAX_SAFE_INTEGER as f64), MAX_SAFE_INTEGER as f64),
1164                    };
1165                let maximum = object
1166                    .get("maximum")
1167                    .and_then(Value::as_f64)
1168                    .unwrap_or(f64::INFINITY)
1169                    .min(rust_maximum);
1170                let minimum = object
1171                    .get("minimum")
1172                    .and_then(Value::as_f64)
1173                    .unwrap_or(f64::NEG_INFINITY)
1174                    .max(rust_minimum);
1175                object.insert("maximum".into(), json!(maximum));
1176                object.insert("minimum".into(), json!(minimum));
1177            }
1178            for child in object.values_mut() {
1179                constrain_integers(child);
1180            }
1181        }
1182        Value::Array(values) => {
1183            for child in values {
1184                constrain_integers(child);
1185            }
1186        }
1187        _ => {}
1188    }
1189}
1190
1191fn schema<T: JsonSchema>() -> Value {
1192    let mut value =
1193        serde_json::to_value(schemars::schema_for!(T)).expect("JSON schemas are serializable");
1194    constrain_integers(&mut value);
1195    value
1196}
1197
1198#[derive(Debug, Clone, Copy)]
1199pub struct MethodSpec {
1200    pub name: &'static str,
1201    pub params_schema: fn() -> Value,
1202    pub result_schema: fn() -> Value,
1203    pub validate: fn(&Value) -> Result<(), ServerError>,
1204}
1205
1206macro_rules! methods {
1207    ($($name:literal => $params:ty $(, $check:expr)? => $result:ty),* $(,)?) => {
1208        pub const METHODS: &[MethodSpec] = &[$(MethodSpec {
1209            name: $name,
1210            params_schema: schema::<$params>,
1211            result_schema: schema::<$result>,
1212            validate: |params| {
1213                let _params = <$params>::deserialize(params)
1214                    .map_err(|error| ServerError::invalid_params(error.to_string()))?;
1215                $(($check)(&_params)?;)?
1216                Ok(())
1217            },
1218        }),*];
1219    };
1220}
1221
1222methods! {
1223    "server.hello" => HelloParams => HelloResult,
1224    "server.shutdown" => Empty => Empty,
1225    "browser.launch" => LaunchParams, LaunchParams::validate => BrowserSessionResult,
1226    "browser.connect" => ConnectParams, ConnectParams::validate => BrowserSessionResult,
1227    "browser.new_page" => NewPageParams => NewPageResult,
1228    "browser.tabs" => Empty => TabsResult,
1229    "browser.close_tab" => PageIdParams => CloseTabResult,
1230    "browser.close" => Empty => Empty,
1231    "actions.perform" => ActionPerformParams => crate::OperationRecord,
1232    "operations.get" => OperationIdArgs => crate::OperationRecord,
1233    "operations.list" => OperationListArgs => Vec<crate::OperationRecord>,
1234    "operations.wait" => OperationWaitArgs => crate::OperationRecord,
1235    "operations.cancel" => OperationIdArgs => crate::OperationRecord,
1236    "agent.observe" => AgentObserveParams => crate::AgentObservation,
1237    "agent.step" => AgentStepParams => crate::AgentStepResult,
1238    "page.goto" => GotoParams => crate::OperationRecord,
1239    "page.click" => ClickParams => crate::OperationRecord,
1240    "page.click_text" => ClickTextParams => crate::OperationRecord,
1241    "page.human_click" => ClickParams => crate::OperationRecord,
1242    "page.human_click_text" => ClickTextParams => crate::OperationRecord,
1243    "page.fill" => FillParams => crate::OperationRecord,
1244    "page.human_fill" => FillParams => crate::OperationRecord,
1245    "page.type_into" => TypeIntoParams => crate::OperationRecord,
1246    "page.select" => FillParams => crate::OperationRecord,
1247    "page.hover" => ClickParams => crate::OperationRecord,
1248    "page.press_key" => PressKeyParams => crate::OperationRecord,
1249    "page.text" => PageIdParams => TextResult,
1250    "page.content" => PageIdParams => ContentResult,
1251    "page.title" => PageIdParams => TitleResult,
1252    "page.url" => PageIdParams => UrlResult,
1253    "page.get_text" => SelectorParams => TextResult,
1254    "page.get_attribute" => GetAttributeParams => AttributeResult,
1255    "page.exists" => SelectorParams => ExistsResult,
1256    "page.wait_for" => WaitForParams => Empty,
1257    "page.wait_for_visible" => WaitForParams => Empty,
1258    "page.wait_for_text" => WaitForTextParams => Empty,
1259    "page.evaluate" => JsParams => EvaluateResult,
1260    "page.execute" => JsParams => Empty,
1261    "page.frames" => FramesParams => Vec<FrameInfo>,
1262    "page.evaluate_in_frame" => FrameJsParams => EvaluateResult,
1263    "page.click_shadow" => ClickShadowParams, ClickShadowParams::validate => ClickShadowResult,
1264    "page.solve_turnstile" => SolveTurnstileParams, SolveTurnstileParams::validate => SolveTurnstileResult,
1265    "page.fetch" => FetchParams => FetchResult,
1266    "page.fetch_bounded" => BoundedFetchParams, BoundedFetchParams::validate => BoundedFetchResult,
1267    "page.capture_state" => PageIdParams => CaptureStateResult,
1268    "page.restore_state" => RestoreStateParams => Empty,
1269    "page.screenshot" => ScreenshotParams, ScreenshotParams::validate => ScreenshotResult,
1270    "page.screenshot_to_file" => ScreenshotToFileParams, ScreenshotToFileParams::validate => Artifact,
1271    "recording.start" => RecordingStartParams, RecordingStartParams::validate => RecordingRecord,
1272    "recording.get" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1273    "recording.stop" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1274    "recording.cancel" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1275    "page.set_viewport" => SetViewportParams, |params: &SetViewportParams| {
1276        validate_viewport(params.width, params.height, params.device_scale_factor)
1277    } => Empty,
1278    "page.reset_viewport" => PageIdParams => Empty,
1279    "page.mouse_down" => MouseButtonParams => Empty,
1280    "page.mouse_move" => MouseMoveParams => Empty,
1281    "page.mouse_up" => MouseButtonParams => Empty,
1282    "page.key_down" => HeldKeyParams => Empty,
1283    "page.key_up" => HeldKeyParams => Empty,
1284    "page.release_all_inputs" => PageIdParams => Empty,
1285    "page.solve_captcha" => SolveCaptchaParams => SolveCaptchaResult,
1286    "page.close" => PageIdParams => Empty,
1287}
1288dto!(GetAttributeParams {
1289    page_id: String,
1290    selector: String,
1291    name: String
1292});
1293
1294pub fn method_names() -> Vec<&'static str> {
1295    METHODS.iter().map(|method| method.name).collect()
1296}
1297
1298pub fn validate_params(method: &str, params: &Value) -> Result<(), ServerError> {
1299    validate_safe_numbers(params)?;
1300    let spec = METHODS
1301        .iter()
1302        .find(|spec| spec.name == method)
1303        .ok_or_else(|| ServerError::unknown_method(method))?;
1304    (spec.validate)(params)
1305}
1306
1307pub fn schema_bundle() -> Value {
1308    let methods: BTreeMap<_, _> = METHODS
1309        .iter()
1310        .map(|method| {
1311            (
1312                method.name,
1313                json!({"params": (method.params_schema)(), "result": (method.result_schema)()}),
1314            )
1315        })
1316        .collect();
1317    let mut bundle = json!({
1318        "protocolVersion": PROTOCOL_VERSION,
1319        "methods": methods,
1320        "types": {
1321            "Request": schema::<Request>(), "Response": schema::<Response>(),
1322            "ServerError": schema::<ServerError>(), "ErrorData": schema::<ErrorData>(),
1323            "ErrorDetail": schema::<crate::ErrorDetail>(), "HelloResult": schema::<HelloResult>(),
1324            "ProtocolVersion": schema::<ProtocolVersion>(), "HelloParams": schema::<HelloParams>(),
1325            "TransportLimits": schema::<TransportLimits>(), "ErrorCode": schema::<ErrorCode>(),
1326            "CdpErrorDetail": schema::<CdpErrorDetail>(),
1327            "Action": schema::<crate::Action>(), "ActionOptions": schema::<crate::ActionOptions>(),
1328            "ActionReceipt": schema::<crate::ActionReceipt>(), "LocatorSpec": schema::<crate::LocatorSpec>(),
1329            "ActionOutcome": schema::<crate::ActionOutcome>(), "DispatchState": schema::<crate::DispatchState>(),
1330            "LocatorQuery": schema::<crate::LocatorQuery>(),
1331            "Expectation": schema::<crate::Expectation>(), "OperationRecord": schema::<crate::OperationRecord>(),
1332            "OperationState": schema::<crate::OperationState>(), "AgentObservation": schema::<crate::AgentObservation>(),
1333            "AgentStepResult": schema::<crate::AgentStepResult>(), "BrowserState": schema::<BrowserState>(),
1334            "AgentAction": schema::<crate::AgentAction>(), "AgentMode": schema::<crate::AgentMode>(),
1335            "ScreenshotFormat": schema::<ScreenshotFormat>(), "ScreenshotClip": schema::<ScreenshotClip>(),
1336            "ScreenshotParams": schema::<ScreenshotParams>(), "ScreenshotToFileParams": schema::<ScreenshotToFileParams>(),
1337            "Artifact": schema::<Artifact>(), "ArtifactContainer": schema::<ArtifactContainer>(),
1338            "RecordingStartParams": schema::<RecordingStartParams>(), "RecordingIdParams": schema::<RecordingIdParams>(),
1339            "RecordingRecord": schema::<RecordingRecord>(), "RecordingState": schema::<RecordingState>(),
1340            "RecordingFailure": schema::<RecordingFailure>(), "RecordingFailureStage": schema::<RecordingFailureStage>()
1341        }
1342    });
1343    // Added outside `json!` to stay under its macro recursion limit.
1344    bundle["types"]["CloudflareAutoSolve"] = schema::<CloudflareAutoSolve>();
1345    bundle
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    const RECORDING_ID: &str = "b9b87b7a-cc6d-4a25-b098-73271cc9c4cb";
1353
1354    #[test]
1355    fn capture_validation_preserves_defaults_and_rejects_conflicts() {
1356        assert!(validate_params("page.screenshot", &json!({"pageId":"p"})).is_ok());
1357        assert_eq!(
1358            serde_json::to_value(ScreenshotResult {
1359                data_base64: "AA==".into()
1360            })
1361            .unwrap(),
1362            json!({"dataBase64":"AA=="})
1363        );
1364        for format in ["jpeg", "webp"] {
1365            assert!(validate_params("page.screenshot", &json!({"pageId":"p","format":format,"quality":0,"clip":{"x":0,"y":1,"width":1.5,"height":2}})).is_ok());
1366        }
1367        for params in [
1368            json!({"pageId":"p","quality":10}),
1369            json!({"pageId":"p","format":"jpeg","quality":101}),
1370            json!({"pageId":"p","format":"gif"}),
1371            json!({"pageId":"p","clip":{"x":-1,"y":0,"width":1,"height":1}}),
1372            json!({"pageId":"p","clip":{"x":0,"y":0,"width":0,"height":1}}),
1373            json!({"pageId":"p","clip":{"x":0,"y":0,"width":1,"height":1},"fullPage":true}),
1374        ] {
1375            assert_eq!(
1376                validate_params("page.screenshot", &params)
1377                    .unwrap_err()
1378                    .code,
1379                ErrorCode::InvalidParams
1380            );
1381        }
1382        assert!(validate_params(
1383            "page.screenshot_to_file",
1384            &json!({"pageId":"p","path":"/tmp/capture.png","fullPage":true})
1385        )
1386        .is_ok());
1387        assert!(validate_params(
1388            "page.screenshot_to_file",
1389            &json!({"pageId":"p","path":"capture.png"})
1390        )
1391        .is_err());
1392        for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1393            let clip = ScreenshotClip {
1394                x: 0.0,
1395                y: 0.0,
1396                width: invalid,
1397                height: 1.0,
1398            };
1399            assert!(validate_screenshot(None, None, Some(&clip), None).is_err());
1400        }
1401    }
1402
1403    #[test]
1404    fn recording_ids_paths_and_native_bounds_are_validated_before_dispatch() {
1405        let valid = json!({"pageId":"p","recordingId":RECORDING_ID,"path":"/tmp/capture.mp4","fps":120,"maxWidth":2147483647_u32,"maxHeight":1});
1406        assert!(validate_params("recording.start", &valid).is_ok());
1407        for (field, value) in [
1408            ("recordingId", json!("unknown")),
1409            ("path", json!("capture.mp4")),
1410            ("fps", json!(0)),
1411            ("fps", json!(120.5)),
1412            ("fps", json!(1.5)),
1413            ("maxWidth", json!(0)),
1414            ("maxHeight", json!(2147483648_u32)),
1415        ] {
1416            let mut params = valid.clone();
1417            params[field] = value;
1418            assert_eq!(
1419                validate_params("recording.start", &params)
1420                    .unwrap_err()
1421                    .code,
1422                ErrorCode::InvalidParams
1423            );
1424        }
1425        for method in ["recording.get", "recording.stop", "recording.cancel"] {
1426            assert!(validate_params(method, &json!({"recordingId":RECORDING_ID})).is_ok());
1427            assert!(validate_params(method, &json!({"recordingId":"not-a-uuid"})).is_err());
1428        }
1429    }
1430
1431    #[test]
1432    fn recording_terminal_evidence_never_labels_partial_files_complete() {
1433        let mut record = RecordingRecord {
1434            recording_id: RECORDING_ID.into(),
1435            page_id: "p".into(),
1436            state: RecordingState::Running,
1437            path: "/tmp/capture.mp4".into(),
1438            artifact: None,
1439            failure: None,
1440        };
1441        assert!(record.validate().is_ok());
1442        record.state = RecordingState::Completed;
1443        assert!(record.validate().is_err());
1444        record.artifact = Some(Artifact {
1445            path: record.path.clone(),
1446            bytes: MAX_SAFE_INTEGER,
1447            mime_type: "video/mp4".into(),
1448            container: Some(ArtifactContainer::Mp4),
1449        });
1450        assert!(record.validate().is_ok());
1451        assert!(serde_json::to_value(&record).unwrap()["artifact"]
1452            .get("codec")
1453            .is_none());
1454        record.artifact.as_mut().unwrap().bytes += 1;
1455        assert!(record.validate().is_err());
1456        record.artifact = None;
1457        record.state = RecordingState::Failed;
1458        assert!(record.validate().is_err());
1459        record.failure = Some(RecordingFailure {
1460            stage: RecordingFailureStage::Write,
1461            code: ErrorCode::RecordingFailed,
1462            message: "disk full".into(),
1463            partial_path: Some("/tmp/.capture.partial".into()),
1464            cleanup_uncertain: Some(true),
1465        });
1466        assert!(record.validate().is_ok());
1467        let error =
1468            ServerError::new(ErrorCode::RecordingFailed, "disk full").with_data(ErrorData {
1469                recording_id: Some(RECORDING_ID.into()),
1470                recording: Some(record.clone()),
1471                ..Default::default()
1472            });
1473        let wire = serde_json::to_value(error).unwrap();
1474        assert_eq!(wire["data"]["recordingId"], RECORDING_ID);
1475        assert_eq!(
1476            wire["data"]["recording"]["failure"]["partialPath"],
1477            "/tmp/.capture.partial"
1478        );
1479        assert_eq!(
1480            wire["data"]["recording"]["failure"]["cleanupUncertain"],
1481            true
1482        );
1483        record.failure.as_mut().unwrap().partial_path = Some(record.path.clone());
1484        assert!(record.validate().is_err());
1485        assert!(RecordingState::Cancelled.is_terminal());
1486        assert!(!RecordingState::Finalizing.is_terminal());
1487    }
1488
1489    #[test]
1490    fn bounded_fetch_limits_reject_unsafe_retention_before_browser_work() {
1491        for max_bytes in [0, MAX_BOUNDED_FETCH_BYTES] {
1492            validate_params(
1493                "page.fetch_bounded",
1494                &json!({"pageId":"page","url":"/text","maxBytes":max_bytes}),
1495            )
1496            .unwrap();
1497        }
1498        for params in [
1499            json!({"pageId":"page","url":"/text","maxBytes":MAX_BOUNDED_FETCH_BYTES + 1}),
1500            json!({"pageId":"page","url":"/text","maxBytes":-1}),
1501            json!({"pageId":"page","url":"/text","maxBytes":1.5}),
1502            json!({"pageId":"page","url":"/text","redirect":"unbounded"}),
1503        ] {
1504            assert_eq!(
1505                validate_params("page.fetch_bounded", &params)
1506                    .unwrap_err()
1507                    .code,
1508                ErrorCode::InvalidParams
1509            );
1510        }
1511    }
1512
1513    #[test]
1514    fn request_ids_are_positive_exact_integers() {
1515        for id in [json!(1), json!(MAX_SAFE_INTEGER)] {
1516            let request: Request =
1517                serde_json::from_value(json!({"id":id,"method":"browser.tabs"})).unwrap();
1518            assert_eq!(request.id, id);
1519            assert_eq!(request.params, json!({}));
1520        }
1521        for id in [
1522            json!(0),
1523            json!(-1),
1524            json!(MAX_SAFE_INTEGER + 1),
1525            json!(1.5),
1526            json!(1.0),
1527            json!("1"),
1528            Value::Null,
1529        ] {
1530            assert!(
1531                serde_json::from_value::<Request>(json!({"id":id,"method":"browser.tabs"}))
1532                    .is_err()
1533            );
1534        }
1535    }
1536
1537    #[test]
1538    fn response_requires_exactly_one_outcome_and_preserves_null() {
1539        let success = serde_json::to_value(Response::ok(1_u64, Value::Null)).unwrap();
1540        assert_eq!(success, json!({"id":1,"result":null}));
1541        let decoded: Response = serde_json::from_value(success).unwrap();
1542        assert!(matches!(
1543            decoded,
1544            Response::Success {
1545                result: Value::Null,
1546                ..
1547            }
1548        ));
1549        for invalid in [
1550            json!({"id":1}),
1551            json!({"id":1,"result":null,"error":{"code":"Internal","message":"failure"}}),
1552        ] {
1553            assert!(serde_json::from_value::<Response>(invalid).is_err());
1554        }
1555        let failure = serde_json::to_value(Response::err(
1556            Value::Null,
1557            ServerError::invalid_params("bad input"),
1558        ))
1559        .unwrap();
1560        assert!(failure.get("result").is_none());
1561        assert_eq!(failure["error"]["code"], "InvalidParams");
1562    }
1563
1564    #[test]
1565    fn cdp_and_action_errors_preserve_evidence_through_wire_round_trip() {
1566        let receipt = crate::ActionReceipt {
1567            started_at_ms: 1,
1568            finished_at_ms: 2,
1569            download_guid: None,
1570            action: "click".into(),
1571            target_id: "target".into(),
1572            outcome: crate::ActionOutcome::OutcomeUnknown,
1573            dispatch: crate::DispatchState::MayHaveBeenSent,
1574            preparation_may_have_executed: true,
1575            verification: None,
1576            elapsed_ms: 1,
1577            already_satisfied: false,
1578            cleanup_pending: true,
1579        };
1580        let error = ServerError::from(eoka::Error::Action {
1581            code: "outcome_unknown".into(),
1582            receipt: Box::new(receipt),
1583            source: Box::new(eoka::Error::Cdp {
1584                method: Some("Input.dispatchMouseEvent".into()),
1585                code: Some(-32000),
1586                message: "connection interrupted".into(),
1587            }),
1588        });
1589        let wire = serde_json::to_value(Response::err(1_u64, error)).unwrap();
1590        let decoded: Response = serde_json::from_value(wire).unwrap();
1591        let Response::Failure { error, .. } = decoded else {
1592            panic!("expected error")
1593        };
1594        assert_eq!(error.code, ErrorCode::Action);
1595        let data = error.data.unwrap();
1596        assert_eq!(data.detail.unwrap().code, "outcome_unknown");
1597        let receipt = data.receipt.unwrap();
1598        assert_eq!(receipt.outcome, crate::ActionOutcome::OutcomeUnknown);
1599        assert_eq!(receipt.dispatch, crate::DispatchState::MayHaveBeenSent);
1600        assert!(receipt.cleanup_pending);
1601        let cdp = data.source.unwrap().data.unwrap().cdp.unwrap();
1602        assert_eq!(cdp.method.as_deref(), Some("Input.dispatchMouseEvent"));
1603        assert_eq!(cdp.code, Some(-32000));
1604    }
1605
1606    #[test]
1607    fn click_shadow_validates_selector_and_deadline() {
1608        assert!(validate_params(
1609            "page.click_shadow",
1610            &json!({"pageId":"p","selector":"input[type=\"checkbox\"]"})
1611        )
1612        .is_ok());
1613        assert!(validate_params(
1614            "page.click_shadow",
1615            &json!({"pageId":"p","selector":"button","frameId":"frame","timeoutMs":1000})
1616        )
1617        .is_ok());
1618        assert!(
1619            validate_params("page.click_shadow", &json!({"pageId":"p","selector":""})).is_err()
1620        );
1621        assert!(validate_params(
1622            "page.click_shadow",
1623            &json!({"pageId":"p","selector":"button","timeoutMs":0})
1624        )
1625        .is_err());
1626        assert!(validate_params(
1627            "page.click_shadow",
1628            &json!({"pageId":"p","selector":"button","timeoutMs":60001})
1629        )
1630        .is_err());
1631    }
1632
1633    #[test]
1634    fn canonical_params_reject_missing_actions_and_unsafe_nested_integers() {
1635        assert!(validate_params("page.click", &json!({"pageId":"p","selector":"button"})).is_err());
1636        let params = json!({"pageId":"p","selector":"button","operation_id":"operation","options":{"timeout_ms":30000}});
1637        assert!(validate_params("page.click", &params).is_ok());
1638        assert!(validate_params("actions.perform", &json!({"pageId":"p","operation_id":"operation","action":{"kind":"navigate","url":"https://example.test","referrer":null},"options":{"timeout_ms":MAX_SAFE_INTEGER + 1}})).is_err());
1639        assert!(
1640            validate_safe_numbers(&json!({"nested":[{"value":-(MAX_SAFE_INTEGER as i64)-1}]}))
1641                .is_err()
1642        );
1643        assert!(validate_safe_numbers(&json!({"value":9007199254740992.0})).is_err());
1644        assert!(validate_safe_numbers(
1645            &json!({"values":[MAX_SAFE_INTEGER, -(MAX_SAFE_INTEGER as i64), 1.25]})
1646        )
1647        .is_ok());
1648        assert!(validate_params(
1649            "operations.wait",
1650            &json!({"id":"operation","timeout_ms":MAX_SAFE_INTEGER + 1})
1651        )
1652        .is_err());
1653        assert_eq!(
1654            validate_params("unknown", &json!({})).unwrap_err().code,
1655            ErrorCode::UnknownMethod
1656        );
1657        for method in ["unknown", "browser.launch"] {
1658            let error = validate_params(
1659                method,
1660                &json!({"sandbox":"false","viewportWidth":MAX_SAFE_INTEGER + 1}),
1661            )
1662            .unwrap_err();
1663            assert_eq!(error.code, ErrorCode::InvalidParams);
1664            assert_eq!(
1665                error.message,
1666                "integer exceeds JavaScript's exact safe integer range"
1667            );
1668        }
1669    }
1670
1671    #[test]
1672    fn managed_params_validate_paths_dimensions_and_scale_before_browser_work() {
1673        for params in [
1674            json!({"executablePath":"relative/chrome"}),
1675            json!({"runtimeDir":"relative/runtime"}),
1676            json!({"viewportWidth":640}),
1677            json!({"viewportWidth":0,"viewportHeight":360}),
1678            json!({"viewportWidth":2147483648_u64,"viewportHeight":360}),
1679            json!({"deviceScaleFactor":0}),
1680            json!({"deviceScaleFactor":-1}),
1681            json!({"sandbox":"false"}),
1682        ] {
1683            assert_eq!(
1684                validate_params("browser.launch", &params).unwrap_err().code,
1685                ErrorCode::InvalidParams
1686            );
1687        }
1688        let params = json!({"viewportWidth":0,"sandbox":"false"});
1689        assert_eq!(
1690            validate_params("browser.launch", &params)
1691                .unwrap_err()
1692                .message,
1693            LaunchParams::deserialize(&params).unwrap_err().to_string()
1694        );
1695        assert!(validate_params(
1696            "browser.launch",
1697            &json!({
1698                "viewportWidth":640,"viewportHeight":360,"deviceScaleFactor":2.0,"sandbox":true
1699            })
1700        )
1701        .is_ok());
1702        assert!(validate_params("browser.launch", &json!({"deviceScaleFactor":1.5})).is_ok());
1703        for params in [
1704            json!({"pageId":"p","width":0,"height":360}),
1705            json!({"pageId":"p","width":640,"height":2147483648_u64}),
1706            json!({"pageId":"p","width":640,"height":360,"deviceScaleFactor":-1}),
1707        ] {
1708            assert_eq!(
1709                validate_params("page.set_viewport", &params)
1710                    .unwrap_err()
1711                    .code,
1712                ErrorCode::InvalidParams
1713            );
1714        }
1715        for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1716            assert_eq!(
1717                validate_viewport(640, 360, scale).unwrap_err().code,
1718                ErrorCode::InvalidParams
1719            );
1720        }
1721        assert!(validate_params(
1722            "page.set_viewport",
1723            &json!({"pageId":"p","width":640,"height":360})
1724        )
1725        .is_ok());
1726        assert!(validate_params(
1727            "browser.connect",
1728            &json!({"wsEndpoint":"ws://localhost/devtools/browser/id","sandbox":true})
1729        )
1730        .is_err());
1731    }
1732
1733    #[test]
1734    fn canonical_state_conversion_preserves_cookie_and_storage_shapes() {
1735        let state: BrowserState = serde_json::from_value(json!({
1736            "cookies":[{"name":"session","value":"private","domain":"example.test","path":"/","secure":true,"http_only":true,"same_site":"Lax","expires":null}],
1737            "localStorage":{"local":"value"},"sessionStorage":{"tab":"value"},
1738            "userAgent":"agent","url":"https://example.test/"
1739        })).unwrap();
1740        let before = serde_json::to_value(&state).unwrap();
1741        let core: eoka::BrowserState = state.into();
1742        assert_eq!(serde_json::to_value(&core).unwrap(), before);
1743        assert_eq!(
1744            serde_json::to_value(BrowserState::from(core)).unwrap(),
1745            before
1746        );
1747    }
1748
1749    #[test]
1750    fn actual_result_schemas_preserve_array_dynamic_json_and_numeric_bounds() {
1751        let bundle = schema_bundle();
1752        let list = &bundle["methods"]["operations.list"]["result"];
1753        assert_eq!(list["type"], "array");
1754        assert!(list["items"]["$ref"]
1755            .as_str()
1756            .unwrap()
1757            .ends_with("/OperationRecord"));
1758        let dynamic = &bundle["methods"]["page.evaluate"]["result"];
1759        let decoded: EvaluateResult =
1760            serde_json::from_value(json!({"result":[null,false,{"value":3}]})).unwrap();
1761        assert_eq!(decoded.result, json!([null,false,{"value":3}]));
1762        assert!(
1763            dynamic["properties"]["result"].is_boolean()
1764                || dynamic["properties"]["result"].is_object()
1765        );
1766        let launch = &bundle["methods"]["browser.launch"]["params"];
1767        assert_eq!(
1768            launch["properties"]["viewportWidth"]["maximum"].as_f64(),
1769            Some(i32::MAX as f64)
1770        );
1771        let wait = &bundle["methods"]["operations.wait"]["params"];
1772        assert_eq!(
1773            wait["properties"]["timeout_ms"]["maximum"].as_f64(),
1774            Some(86_400_000.0)
1775        );
1776        let methods = method_names();
1777        let unique: std::collections::BTreeSet<_> = methods.iter().collect();
1778        assert_eq!(unique.len(), methods.len());
1779        assert!(validate_params("browser.connect", &json!({})).is_err());
1780        assert!(validate_params("operation_get", &json!({"id":"operation"})).is_err());
1781    }
1782
1783    #[test]
1784    fn turnstile_and_cloudflare_auto_solve_validate_deadlines() {
1785        assert!(validate_params("page.solve_turnstile", &json!({"pageId":"p"})).is_ok());
1786        for timeout in [0, 120_001] {
1787            assert!(validate_params(
1788                "page.solve_turnstile",
1789                &json!({"pageId":"p","timeoutMs":timeout})
1790            )
1791            .is_err());
1792        }
1793        let launch: LaunchParams =
1794            serde_json::from_value(json!({"autoSolveCloudflare":{}})).unwrap();
1795        assert_eq!(
1796            launch
1797                .auto_solve_cloudflare
1798                .map(|o| (o.interval_ms, o.timeout_ms)),
1799            Some((500, 30_000))
1800        );
1801        assert!(validate_params(
1802            "browser.launch",
1803            &json!({"autoSolveCloudflare":{"intervalMs":99}})
1804        )
1805        .is_err());
1806        assert!(validate_params(
1807            "browser.connect",
1808            &json!({"wsEndpoint":"ws://localhost/devtools/browser/id","autoSolveCloudflare":{"timeoutMs":5000}})
1809        )
1810        .is_ok());
1811        assert!(validate_params(
1812            "browser.connect",
1813            &json!({"wsEndpoint":"ws://localhost/devtools/browser/id","autoSolveCloudflare":{"intervalMs":60001}})
1814        )
1815        .is_err());
1816    }
1817}