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