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!(SelectorParams {
771    page_id: String,
772    selector: String
773});
774dto!(WaitForParams {
775    page_id: String,
776    selector: String,
777    timeout_ms: u64
778});
779dto!(WaitForTextParams {
780    page_id: String,
781    text: String,
782    timeout_ms: u64
783});
784dto!(JsParams {
785    page_id: String,
786    #[schemars(
787        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."
788    )]
789    js: String,
790});
791dto!(FetchParams {
792    page_id: String, url: String,
793    #[serde(default = "default_fetch_method")] method: String,
794    #[serde(default)] headers: BTreeMap<String, String>,
795    #[serde(default)] body: Option<String>,
796    #[serde(default = "default_fetch_redirect")]
797    #[schemars(regex(pattern = "^(follow|error|manual)$"))] redirect: String,
798});
799dto!(BoundedFetchParams {
800    #[serde(flatten)]
801    request: FetchParams,
802    #[serde(default = "default_bounded_fetch_bytes")]
803    #[schemars(range(max = 1048576))]
804    max_bytes: usize,
805});
806
807impl BoundedFetchParams {
808    pub fn validate(&self) -> Result<(), ServerError> {
809        if self.max_bytes > MAX_BOUNDED_FETCH_BYTES {
810            return Err(ServerError::invalid_params(
811                "maxBytes must not exceed 1048576 bytes",
812            ));
813        }
814        if !matches!(
815            self.request.redirect.as_str(),
816            "follow" | "manual" | "error"
817        ) {
818            return Err(ServerError::invalid_params(
819                "redirect must be follow, manual, or error",
820            ));
821        }
822        Ok(())
823    }
824}
825
826fn default_bounded_fetch_bytes() -> usize {
827    8192
828}
829
830fn default_fetch_method() -> String {
831    "GET".into()
832}
833fn default_fetch_redirect() -> String {
834    "follow".into()
835}
836dto!(MouseButtonParams {
837    page_id: String,
838    #[serde(flatten)]
839    mouse: crate::MouseButtonArgs
840});
841dto!(MouseMoveParams {
842    page_id: String,
843    #[serde(flatten)]
844    mouse: crate::MouseMoveArgs
845});
846dto!(HeldKeyParams {
847    page_id: String,
848    #[serde(flatten)]
849    key: crate::KeyArgs
850});
851dto!(RestoreStateParams {
852    page_id: String,
853    state: BrowserState
854});
855dto!(SolveCaptchaParams {
856    page_id: String, api_key: String,
857    #[schemars(regex(pattern = "^recaptcha_v2_enterprise$"))] captcha_type: String,
858    #[schemars(regex(pattern = "^anti_captcha_proxyless$"))] captcha_mode: String,
859    #[serde(rename = "websiteURL")] website_url: String,
860    website_key: String,
861    #[serde(default)] enterprise_payload: Option<Value>,
862    #[serde(default)] api_domain: Option<String>,
863    #[serde(default)] callback: Option<String>,
864});
865
866#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
867pub struct SessionCookie {
868    pub name: String,
869    pub value: String,
870    pub domain: String,
871    pub path: String,
872    pub secure: bool,
873    pub http_only: bool,
874    pub same_site: Option<String>,
875    pub expires: Option<f64>,
876}
877dto!(BrowserState {
878    cookies: Vec<SessionCookie>,
879    #[serde(default)] local_storage: HashMap<String, String>,
880    #[serde(default)] session_storage: HashMap<String, String>,
881    user_agent: String, url: String,
882});
883
884impl From<eoka::BrowserState> for BrowserState {
885    fn from(state: eoka::BrowserState) -> Self {
886        Self {
887            cookies: state
888                .cookies
889                .into_iter()
890                .map(|cookie| SessionCookie {
891                    name: cookie.name,
892                    value: cookie.value,
893                    domain: cookie.domain,
894                    path: cookie.path,
895                    secure: cookie.secure,
896                    http_only: cookie.http_only,
897                    same_site: cookie.same_site,
898                    expires: cookie.expires,
899                })
900                .collect(),
901            local_storage: state.local_storage,
902            session_storage: state.session_storage,
903            user_agent: state.user_agent,
904            url: state.url,
905        }
906    }
907}
908impl From<BrowserState> for eoka::BrowserState {
909    fn from(state: BrowserState) -> Self {
910        Self {
911            cookies: state
912                .cookies
913                .into_iter()
914                .map(|cookie| eoka::SessionCookie {
915                    name: cookie.name,
916                    value: cookie.value,
917                    domain: cookie.domain,
918                    path: cookie.path,
919                    secure: cookie.secure,
920                    http_only: cookie.http_only,
921                    same_site: cookie.same_site,
922                    expires: cookie.expires,
923                })
924                .collect(),
925            local_storage: state.local_storage,
926            session_storage: state.session_storage,
927            user_agent: state.user_agent,
928            url: state.url,
929        }
930    }
931}
932
933dto!(NewPageResult { page_id: String });
934dto!(Tab {
935    id: String,
936    title: String,
937    url: String
938});
939dto!(TabsResult { tabs: Vec<Tab> });
940dto!(CloseTabResult { #[serde(skip_serializing_if = "Option::is_none")] cleanup_error: Option<String> });
941dto!(TextResult { text: String });
942dto!(ContentResult { html: String });
943dto!(TitleResult { title: String });
944dto!(UrlResult { url: String });
945dto!(AttributeResult { value: Option<String> });
946dto!(ExistsResult { exists: bool });
947dto!(EvaluateResult { result: Value });
948dto!(CaptureStateResult {
949    state: BrowserState
950});
951dto!(ScreenshotResult {
952    data_base64: String
953});
954dto!(FetchResult { url: String, status: u16, ok: bool, headers: BTreeMap<String, String>, body: String });
955dto!(BoundedFetchResult {
956    url: String,
957    status: u16,
958    status_text: String,
959    r#type: String,
960    redirected: bool,
961    headers: HashMap<String, String>,
962    body: String,
963    truncated: bool,
964    bytes_read: usize,
965    body_omitted: Option<String>,
966});
967
968#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
969pub struct CaptchaInjection {
970    pub kind: String,
971    pub updated_count: usize,
972    pub created: Vec<String>,
973    pub callbacks: Vec<String>,
974    pub errors: Vec<String>,
975    #[serde(rename = "solverUserAgent", skip_serializing_if = "Option::is_none")]
976    pub solver_user_agent: Option<String>,
977}
978dto!(SolveCaptchaResult {
979    injection: CaptchaInjection
980});
981
982macro_rules! action_params {
983    ($name:ident { $($field:ident: $ty:ty),* }) => {
984        dto!($name {
985            page_id: String,
986            #[serde(rename = "operation_id")] operation_id: String,
987            #[serde(default)] options: crate::ActionOptions,
988            $($field: $ty),*
989        });
990    };
991}
992action_params!(ActionPerformParams {
993    action: crate::Action
994});
995action_params!(GotoParams { url: String });
996action_params!(ClickParams { selector: String });
997action_params!(ClickTextParams { text: String });
998action_params!(FillParams {
999    selector: String,
1000    value: String
1001});
1002action_params!(TypeIntoParams {
1003    selector: String,
1004    text: String
1005});
1006action_params!(PressKeyParams { key: String });
1007dto!(AgentObserveParams {
1008    page_id: String,
1009    #[serde(flatten)]
1010    args: crate::AgentObserveArgs
1011});
1012dto!(AgentStepParams {
1013    page_id: String,
1014    #[serde(flatten)]
1015    args: crate::AgentStepArgs
1016});
1017
1018pub fn validate_safe_numbers(value: &Value) -> Result<(), ServerError> {
1019    match value {
1020        Value::Number(number) => {
1021            let unsafe_integer = number.as_u64().is_some_and(|n| n > MAX_SAFE_INTEGER)
1022                || number
1023                    .as_i64()
1024                    .is_some_and(|n| n < -(MAX_SAFE_INTEGER as i64))
1025                || number
1026                    .as_f64()
1027                    .is_some_and(|n| n.fract() == 0.0 && n.abs() > MAX_SAFE_INTEGER as f64);
1028            if unsafe_integer {
1029                return Err(ServerError::invalid_params(
1030                    "integer exceeds JavaScript's exact safe integer range",
1031                ));
1032            }
1033        }
1034        Value::Array(values) => {
1035            for value in values {
1036                validate_safe_numbers(value)?;
1037            }
1038        }
1039        Value::Object(values) => {
1040            for value in values.values() {
1041                validate_safe_numbers(value)?;
1042            }
1043        }
1044        _ => {}
1045    }
1046    Ok(())
1047}
1048
1049fn constrain_integers(value: &mut Value) {
1050    match value {
1051        Value::Object(object) => {
1052            let integer = object.get("type").is_some_and(|ty| {
1053                ty == "integer"
1054                    || ty
1055                        .as_array()
1056                        .is_some_and(|types| types.iter().any(|ty| ty == "integer"))
1057            });
1058            if integer {
1059                let (rust_minimum, rust_maximum) =
1060                    match object.get("format").and_then(Value::as_str) {
1061                        Some("int8") => (i8::MIN as f64, i8::MAX as f64),
1062                        Some("int16") => (i16::MIN as f64, i16::MAX as f64),
1063                        Some("int32") => (i32::MIN as f64, i32::MAX as f64),
1064                        Some("uint8") => (0.0, u8::MAX as f64),
1065                        Some("uint16") => (0.0, u16::MAX as f64),
1066                        Some("uint32") => (0.0, u32::MAX as f64),
1067                        Some("uint") => (0.0, (usize::MAX as f64).min(MAX_SAFE_INTEGER as f64)),
1068                        Some("uint64" | "uint128") => (0.0, MAX_SAFE_INTEGER as f64),
1069                        _ => (-(MAX_SAFE_INTEGER as f64), MAX_SAFE_INTEGER as f64),
1070                    };
1071                let maximum = object
1072                    .get("maximum")
1073                    .and_then(Value::as_f64)
1074                    .unwrap_or(f64::INFINITY)
1075                    .min(rust_maximum);
1076                let minimum = object
1077                    .get("minimum")
1078                    .and_then(Value::as_f64)
1079                    .unwrap_or(f64::NEG_INFINITY)
1080                    .max(rust_minimum);
1081                object.insert("maximum".into(), json!(maximum));
1082                object.insert("minimum".into(), json!(minimum));
1083            }
1084            for child in object.values_mut() {
1085                constrain_integers(child);
1086            }
1087        }
1088        Value::Array(values) => {
1089            for child in values {
1090                constrain_integers(child);
1091            }
1092        }
1093        _ => {}
1094    }
1095}
1096
1097fn schema<T: JsonSchema>() -> Value {
1098    let mut value =
1099        serde_json::to_value(schemars::schema_for!(T)).expect("JSON schemas are serializable");
1100    constrain_integers(&mut value);
1101    value
1102}
1103
1104#[derive(Debug, Clone, Copy)]
1105pub struct MethodSpec {
1106    pub name: &'static str,
1107    pub params_schema: fn() -> Value,
1108    pub result_schema: fn() -> Value,
1109    pub validate: fn(&Value) -> Result<(), ServerError>,
1110}
1111
1112macro_rules! methods {
1113    ($($name:literal => $params:ty $(, $check:expr)? => $result:ty),* $(,)?) => {
1114        pub const METHODS: &[MethodSpec] = &[$(MethodSpec {
1115            name: $name,
1116            params_schema: schema::<$params>,
1117            result_schema: schema::<$result>,
1118            validate: |params| {
1119                let _params = <$params>::deserialize(params)
1120                    .map_err(|error| ServerError::invalid_params(error.to_string()))?;
1121                $(($check)(&_params)?;)?
1122                Ok(())
1123            },
1124        }),*];
1125    };
1126}
1127
1128methods! {
1129    "server.hello" => HelloParams => HelloResult,
1130    "server.shutdown" => Empty => Empty,
1131    "browser.launch" => LaunchParams, LaunchParams::validate => BrowserSessionResult,
1132    "browser.connect" => ConnectParams => BrowserSessionResult,
1133    "browser.new_page" => NewPageParams => NewPageResult,
1134    "browser.tabs" => Empty => TabsResult,
1135    "browser.close_tab" => PageIdParams => CloseTabResult,
1136    "browser.close" => Empty => Empty,
1137    "actions.perform" => ActionPerformParams => crate::OperationRecord,
1138    "operations.get" => OperationIdArgs => crate::OperationRecord,
1139    "operations.list" => OperationListArgs => Vec<crate::OperationRecord>,
1140    "operations.wait" => OperationWaitArgs => crate::OperationRecord,
1141    "operations.cancel" => OperationIdArgs => crate::OperationRecord,
1142    "agent.observe" => AgentObserveParams => crate::AgentObservation,
1143    "agent.step" => AgentStepParams => crate::AgentStepResult,
1144    "page.goto" => GotoParams => crate::OperationRecord,
1145    "page.click" => ClickParams => crate::OperationRecord,
1146    "page.click_text" => ClickTextParams => crate::OperationRecord,
1147    "page.human_click" => ClickParams => crate::OperationRecord,
1148    "page.human_click_text" => ClickTextParams => crate::OperationRecord,
1149    "page.fill" => FillParams => crate::OperationRecord,
1150    "page.human_fill" => FillParams => crate::OperationRecord,
1151    "page.type_into" => TypeIntoParams => crate::OperationRecord,
1152    "page.select" => FillParams => crate::OperationRecord,
1153    "page.hover" => ClickParams => crate::OperationRecord,
1154    "page.press_key" => PressKeyParams => crate::OperationRecord,
1155    "page.text" => PageIdParams => TextResult,
1156    "page.content" => PageIdParams => ContentResult,
1157    "page.title" => PageIdParams => TitleResult,
1158    "page.url" => PageIdParams => UrlResult,
1159    "page.get_text" => SelectorParams => TextResult,
1160    "page.get_attribute" => GetAttributeParams => AttributeResult,
1161    "page.exists" => SelectorParams => ExistsResult,
1162    "page.wait_for" => WaitForParams => Empty,
1163    "page.wait_for_visible" => WaitForParams => Empty,
1164    "page.wait_for_text" => WaitForTextParams => Empty,
1165    "page.evaluate" => JsParams => EvaluateResult,
1166    "page.execute" => JsParams => Empty,
1167    "page.frames" => FramesParams => Vec<FrameInfo>,
1168    "page.evaluate_in_frame" => FrameJsParams => EvaluateResult,
1169    "page.fetch" => FetchParams => FetchResult,
1170    "page.fetch_bounded" => BoundedFetchParams, BoundedFetchParams::validate => BoundedFetchResult,
1171    "page.capture_state" => PageIdParams => CaptureStateResult,
1172    "page.restore_state" => RestoreStateParams => Empty,
1173    "page.screenshot" => ScreenshotParams, ScreenshotParams::validate => ScreenshotResult,
1174    "page.screenshot_to_file" => ScreenshotToFileParams, ScreenshotToFileParams::validate => Artifact,
1175    "recording.start" => RecordingStartParams, RecordingStartParams::validate => RecordingRecord,
1176    "recording.get" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1177    "recording.stop" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1178    "recording.cancel" => RecordingIdParams, RecordingIdParams::validate => RecordingRecord,
1179    "page.set_viewport" => SetViewportParams, |params: &SetViewportParams| {
1180        validate_viewport(params.width, params.height, params.device_scale_factor)
1181    } => Empty,
1182    "page.reset_viewport" => PageIdParams => Empty,
1183    "page.mouse_down" => MouseButtonParams => Empty,
1184    "page.mouse_move" => MouseMoveParams => Empty,
1185    "page.mouse_up" => MouseButtonParams => Empty,
1186    "page.key_down" => HeldKeyParams => Empty,
1187    "page.key_up" => HeldKeyParams => Empty,
1188    "page.release_all_inputs" => PageIdParams => Empty,
1189    "page.solve_captcha" => SolveCaptchaParams => SolveCaptchaResult,
1190    "page.close" => PageIdParams => Empty,
1191}
1192dto!(GetAttributeParams {
1193    page_id: String,
1194    selector: String,
1195    name: String
1196});
1197
1198pub fn method_names() -> Vec<&'static str> {
1199    METHODS.iter().map(|method| method.name).collect()
1200}
1201
1202pub fn validate_params(method: &str, params: &Value) -> Result<(), ServerError> {
1203    validate_safe_numbers(params)?;
1204    let spec = METHODS
1205        .iter()
1206        .find(|spec| spec.name == method)
1207        .ok_or_else(|| ServerError::unknown_method(method))?;
1208    (spec.validate)(params)
1209}
1210
1211pub fn schema_bundle() -> Value {
1212    let methods: BTreeMap<_, _> = METHODS
1213        .iter()
1214        .map(|method| {
1215            (
1216                method.name,
1217                json!({"params": (method.params_schema)(), "result": (method.result_schema)()}),
1218            )
1219        })
1220        .collect();
1221    json!({
1222        "protocolVersion": PROTOCOL_VERSION,
1223        "methods": methods,
1224        "types": {
1225            "Request": schema::<Request>(), "Response": schema::<Response>(),
1226            "ServerError": schema::<ServerError>(), "ErrorData": schema::<ErrorData>(),
1227            "ErrorDetail": schema::<crate::ErrorDetail>(), "HelloResult": schema::<HelloResult>(),
1228            "ProtocolVersion": schema::<ProtocolVersion>(), "HelloParams": schema::<HelloParams>(),
1229            "TransportLimits": schema::<TransportLimits>(), "ErrorCode": schema::<ErrorCode>(),
1230            "CdpErrorDetail": schema::<CdpErrorDetail>(),
1231            "Action": schema::<crate::Action>(), "ActionOptions": schema::<crate::ActionOptions>(),
1232            "ActionReceipt": schema::<crate::ActionReceipt>(), "LocatorSpec": schema::<crate::LocatorSpec>(),
1233            "ActionOutcome": schema::<crate::ActionOutcome>(), "DispatchState": schema::<crate::DispatchState>(),
1234            "LocatorQuery": schema::<crate::LocatorQuery>(),
1235            "Expectation": schema::<crate::Expectation>(), "OperationRecord": schema::<crate::OperationRecord>(),
1236            "OperationState": schema::<crate::OperationState>(), "AgentObservation": schema::<crate::AgentObservation>(),
1237            "AgentStepResult": schema::<crate::AgentStepResult>(), "BrowserState": schema::<BrowserState>(),
1238            "AgentAction": schema::<crate::AgentAction>(), "AgentMode": schema::<crate::AgentMode>(),
1239            "ScreenshotFormat": schema::<ScreenshotFormat>(), "ScreenshotClip": schema::<ScreenshotClip>(),
1240            "ScreenshotParams": schema::<ScreenshotParams>(), "ScreenshotToFileParams": schema::<ScreenshotToFileParams>(),
1241            "Artifact": schema::<Artifact>(), "ArtifactContainer": schema::<ArtifactContainer>(),
1242            "RecordingStartParams": schema::<RecordingStartParams>(), "RecordingIdParams": schema::<RecordingIdParams>(),
1243            "RecordingRecord": schema::<RecordingRecord>(), "RecordingState": schema::<RecordingState>(),
1244            "RecordingFailure": schema::<RecordingFailure>(), "RecordingFailureStage": schema::<RecordingFailureStage>()
1245        }
1246    })
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252
1253    const RECORDING_ID: &str = "b9b87b7a-cc6d-4a25-b098-73271cc9c4cb";
1254
1255    #[test]
1256    fn capture_validation_preserves_defaults_and_rejects_conflicts() {
1257        assert!(validate_params("page.screenshot", &json!({"pageId":"p"})).is_ok());
1258        assert_eq!(
1259            serde_json::to_value(ScreenshotResult {
1260                data_base64: "AA==".into()
1261            })
1262            .unwrap(),
1263            json!({"dataBase64":"AA=="})
1264        );
1265        for format in ["jpeg", "webp"] {
1266            assert!(validate_params("page.screenshot", &json!({"pageId":"p","format":format,"quality":0,"clip":{"x":0,"y":1,"width":1.5,"height":2}})).is_ok());
1267        }
1268        for params in [
1269            json!({"pageId":"p","quality":10}),
1270            json!({"pageId":"p","format":"jpeg","quality":101}),
1271            json!({"pageId":"p","format":"gif"}),
1272            json!({"pageId":"p","clip":{"x":-1,"y":0,"width":1,"height":1}}),
1273            json!({"pageId":"p","clip":{"x":0,"y":0,"width":0,"height":1}}),
1274            json!({"pageId":"p","clip":{"x":0,"y":0,"width":1,"height":1},"fullPage":true}),
1275        ] {
1276            assert_eq!(
1277                validate_params("page.screenshot", &params)
1278                    .unwrap_err()
1279                    .code,
1280                ErrorCode::InvalidParams
1281            );
1282        }
1283        assert!(validate_params(
1284            "page.screenshot_to_file",
1285            &json!({"pageId":"p","path":"/tmp/capture.png","fullPage":true})
1286        )
1287        .is_ok());
1288        assert!(validate_params(
1289            "page.screenshot_to_file",
1290            &json!({"pageId":"p","path":"capture.png"})
1291        )
1292        .is_err());
1293        for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1294            let clip = ScreenshotClip {
1295                x: 0.0,
1296                y: 0.0,
1297                width: invalid,
1298                height: 1.0,
1299            };
1300            assert!(validate_screenshot(None, None, Some(&clip), None).is_err());
1301        }
1302    }
1303
1304    #[test]
1305    fn recording_ids_paths_and_native_bounds_are_validated_before_dispatch() {
1306        let valid = json!({"pageId":"p","recordingId":RECORDING_ID,"path":"/tmp/capture.mp4","fps":120,"maxWidth":2147483647_u32,"maxHeight":1});
1307        assert!(validate_params("recording.start", &valid).is_ok());
1308        for (field, value) in [
1309            ("recordingId", json!("unknown")),
1310            ("path", json!("capture.mp4")),
1311            ("fps", json!(0)),
1312            ("fps", json!(120.5)),
1313            ("fps", json!(1.5)),
1314            ("maxWidth", json!(0)),
1315            ("maxHeight", json!(2147483648_u32)),
1316        ] {
1317            let mut params = valid.clone();
1318            params[field] = value;
1319            assert_eq!(
1320                validate_params("recording.start", &params)
1321                    .unwrap_err()
1322                    .code,
1323                ErrorCode::InvalidParams
1324            );
1325        }
1326        for method in ["recording.get", "recording.stop", "recording.cancel"] {
1327            assert!(validate_params(method, &json!({"recordingId":RECORDING_ID})).is_ok());
1328            assert!(validate_params(method, &json!({"recordingId":"not-a-uuid"})).is_err());
1329        }
1330    }
1331
1332    #[test]
1333    fn recording_terminal_evidence_never_labels_partial_files_complete() {
1334        let mut record = RecordingRecord {
1335            recording_id: RECORDING_ID.into(),
1336            page_id: "p".into(),
1337            state: RecordingState::Running,
1338            path: "/tmp/capture.mp4".into(),
1339            artifact: None,
1340            failure: None,
1341        };
1342        assert!(record.validate().is_ok());
1343        record.state = RecordingState::Completed;
1344        assert!(record.validate().is_err());
1345        record.artifact = Some(Artifact {
1346            path: record.path.clone(),
1347            bytes: MAX_SAFE_INTEGER,
1348            mime_type: "video/mp4".into(),
1349            container: Some(ArtifactContainer::Mp4),
1350        });
1351        assert!(record.validate().is_ok());
1352        assert!(serde_json::to_value(&record).unwrap()["artifact"]
1353            .get("codec")
1354            .is_none());
1355        record.artifact.as_mut().unwrap().bytes += 1;
1356        assert!(record.validate().is_err());
1357        record.artifact = None;
1358        record.state = RecordingState::Failed;
1359        assert!(record.validate().is_err());
1360        record.failure = Some(RecordingFailure {
1361            stage: RecordingFailureStage::Write,
1362            code: ErrorCode::RecordingFailed,
1363            message: "disk full".into(),
1364            partial_path: Some("/tmp/.capture.partial".into()),
1365            cleanup_uncertain: Some(true),
1366        });
1367        assert!(record.validate().is_ok());
1368        let error =
1369            ServerError::new(ErrorCode::RecordingFailed, "disk full").with_data(ErrorData {
1370                recording_id: Some(RECORDING_ID.into()),
1371                recording: Some(record.clone()),
1372                ..Default::default()
1373            });
1374        let wire = serde_json::to_value(error).unwrap();
1375        assert_eq!(wire["data"]["recordingId"], RECORDING_ID);
1376        assert_eq!(
1377            wire["data"]["recording"]["failure"]["partialPath"],
1378            "/tmp/.capture.partial"
1379        );
1380        assert_eq!(
1381            wire["data"]["recording"]["failure"]["cleanupUncertain"],
1382            true
1383        );
1384        record.failure.as_mut().unwrap().partial_path = Some(record.path.clone());
1385        assert!(record.validate().is_err());
1386        assert!(RecordingState::Cancelled.is_terminal());
1387        assert!(!RecordingState::Finalizing.is_terminal());
1388    }
1389
1390    #[test]
1391    fn bounded_fetch_limits_reject_unsafe_retention_before_browser_work() {
1392        for max_bytes in [0, MAX_BOUNDED_FETCH_BYTES] {
1393            validate_params(
1394                "page.fetch_bounded",
1395                &json!({"pageId":"page","url":"/text","maxBytes":max_bytes}),
1396            )
1397            .unwrap();
1398        }
1399        for params in [
1400            json!({"pageId":"page","url":"/text","maxBytes":MAX_BOUNDED_FETCH_BYTES + 1}),
1401            json!({"pageId":"page","url":"/text","maxBytes":-1}),
1402            json!({"pageId":"page","url":"/text","maxBytes":1.5}),
1403            json!({"pageId":"page","url":"/text","redirect":"unbounded"}),
1404        ] {
1405            assert_eq!(
1406                validate_params("page.fetch_bounded", &params)
1407                    .unwrap_err()
1408                    .code,
1409                ErrorCode::InvalidParams
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn request_ids_are_positive_exact_integers() {
1416        for id in [json!(1), json!(MAX_SAFE_INTEGER)] {
1417            let request: Request =
1418                serde_json::from_value(json!({"id":id,"method":"browser.tabs"})).unwrap();
1419            assert_eq!(request.id, id);
1420            assert_eq!(request.params, json!({}));
1421        }
1422        for id in [
1423            json!(0),
1424            json!(-1),
1425            json!(MAX_SAFE_INTEGER + 1),
1426            json!(1.5),
1427            json!(1.0),
1428            json!("1"),
1429            Value::Null,
1430        ] {
1431            assert!(
1432                serde_json::from_value::<Request>(json!({"id":id,"method":"browser.tabs"}))
1433                    .is_err()
1434            );
1435        }
1436    }
1437
1438    #[test]
1439    fn response_requires_exactly_one_outcome_and_preserves_null() {
1440        let success = serde_json::to_value(Response::ok(1_u64, Value::Null)).unwrap();
1441        assert_eq!(success, json!({"id":1,"result":null}));
1442        let decoded: Response = serde_json::from_value(success).unwrap();
1443        assert!(matches!(
1444            decoded,
1445            Response::Success {
1446                result: Value::Null,
1447                ..
1448            }
1449        ));
1450        for invalid in [
1451            json!({"id":1}),
1452            json!({"id":1,"result":null,"error":{"code":"Internal","message":"failure"}}),
1453        ] {
1454            assert!(serde_json::from_value::<Response>(invalid).is_err());
1455        }
1456        let failure = serde_json::to_value(Response::err(
1457            Value::Null,
1458            ServerError::invalid_params("bad input"),
1459        ))
1460        .unwrap();
1461        assert!(failure.get("result").is_none());
1462        assert_eq!(failure["error"]["code"], "InvalidParams");
1463    }
1464
1465    #[test]
1466    fn cdp_and_action_errors_preserve_evidence_through_wire_round_trip() {
1467        let receipt = crate::ActionReceipt {
1468            started_at_ms: 1,
1469            finished_at_ms: 2,
1470            download_guid: None,
1471            action: "click".into(),
1472            target_id: "target".into(),
1473            outcome: crate::ActionOutcome::OutcomeUnknown,
1474            dispatch: crate::DispatchState::MayHaveBeenSent,
1475            preparation_may_have_executed: true,
1476            verification: None,
1477            elapsed_ms: 1,
1478            already_satisfied: false,
1479            cleanup_pending: true,
1480        };
1481        let error = ServerError::from(eoka::Error::Action {
1482            code: "outcome_unknown".into(),
1483            receipt: Box::new(receipt),
1484            source: Box::new(eoka::Error::Cdp {
1485                method: Some("Input.dispatchMouseEvent".into()),
1486                code: Some(-32000),
1487                message: "connection interrupted".into(),
1488            }),
1489        });
1490        let wire = serde_json::to_value(Response::err(1_u64, error)).unwrap();
1491        let decoded: Response = serde_json::from_value(wire).unwrap();
1492        let Response::Failure { error, .. } = decoded else {
1493            panic!("expected error")
1494        };
1495        assert_eq!(error.code, ErrorCode::Action);
1496        let data = error.data.unwrap();
1497        assert_eq!(data.detail.unwrap().code, "outcome_unknown");
1498        let receipt = data.receipt.unwrap();
1499        assert_eq!(receipt.outcome, crate::ActionOutcome::OutcomeUnknown);
1500        assert_eq!(receipt.dispatch, crate::DispatchState::MayHaveBeenSent);
1501        assert!(receipt.cleanup_pending);
1502        let cdp = data.source.unwrap().data.unwrap().cdp.unwrap();
1503        assert_eq!(cdp.method.as_deref(), Some("Input.dispatchMouseEvent"));
1504        assert_eq!(cdp.code, Some(-32000));
1505    }
1506
1507    #[test]
1508    fn canonical_params_reject_missing_actions_and_unsafe_nested_integers() {
1509        assert!(validate_params("page.click", &json!({"pageId":"p","selector":"button"})).is_err());
1510        let params = json!({"pageId":"p","selector":"button","operation_id":"operation","options":{"timeout_ms":30000}});
1511        assert!(validate_params("page.click", &params).is_ok());
1512        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());
1513        assert!(
1514            validate_safe_numbers(&json!({"nested":[{"value":-(MAX_SAFE_INTEGER as i64)-1}]}))
1515                .is_err()
1516        );
1517        assert!(validate_safe_numbers(&json!({"value":9007199254740992.0})).is_err());
1518        assert!(validate_safe_numbers(
1519            &json!({"values":[MAX_SAFE_INTEGER, -(MAX_SAFE_INTEGER as i64), 1.25]})
1520        )
1521        .is_ok());
1522        assert!(validate_params(
1523            "operations.wait",
1524            &json!({"id":"operation","timeout_ms":MAX_SAFE_INTEGER + 1})
1525        )
1526        .is_err());
1527        assert_eq!(
1528            validate_params("unknown", &json!({})).unwrap_err().code,
1529            ErrorCode::UnknownMethod
1530        );
1531        for method in ["unknown", "browser.launch"] {
1532            let error = validate_params(
1533                method,
1534                &json!({"sandbox":"false","viewportWidth":MAX_SAFE_INTEGER + 1}),
1535            )
1536            .unwrap_err();
1537            assert_eq!(error.code, ErrorCode::InvalidParams);
1538            assert_eq!(
1539                error.message,
1540                "integer exceeds JavaScript's exact safe integer range"
1541            );
1542        }
1543    }
1544
1545    #[test]
1546    fn managed_params_validate_paths_dimensions_and_scale_before_browser_work() {
1547        for params in [
1548            json!({"executablePath":"relative/chrome"}),
1549            json!({"runtimeDir":"relative/runtime"}),
1550            json!({"viewportWidth":640}),
1551            json!({"viewportWidth":0,"viewportHeight":360}),
1552            json!({"viewportWidth":2147483648_u64,"viewportHeight":360}),
1553            json!({"deviceScaleFactor":0}),
1554            json!({"deviceScaleFactor":-1}),
1555            json!({"sandbox":"false"}),
1556        ] {
1557            assert_eq!(
1558                validate_params("browser.launch", &params).unwrap_err().code,
1559                ErrorCode::InvalidParams
1560            );
1561        }
1562        let params = json!({"viewportWidth":0,"sandbox":"false"});
1563        assert_eq!(
1564            validate_params("browser.launch", &params)
1565                .unwrap_err()
1566                .message,
1567            LaunchParams::deserialize(&params).unwrap_err().to_string()
1568        );
1569        assert!(validate_params(
1570            "browser.launch",
1571            &json!({
1572                "viewportWidth":640,"viewportHeight":360,"deviceScaleFactor":2.0,"sandbox":true
1573            })
1574        )
1575        .is_ok());
1576        assert!(validate_params("browser.launch", &json!({"deviceScaleFactor":1.5})).is_ok());
1577        for params in [
1578            json!({"pageId":"p","width":0,"height":360}),
1579            json!({"pageId":"p","width":640,"height":2147483648_u64}),
1580            json!({"pageId":"p","width":640,"height":360,"deviceScaleFactor":-1}),
1581        ] {
1582            assert_eq!(
1583                validate_params("page.set_viewport", &params)
1584                    .unwrap_err()
1585                    .code,
1586                ErrorCode::InvalidParams
1587            );
1588        }
1589        for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1590            assert_eq!(
1591                validate_viewport(640, 360, scale).unwrap_err().code,
1592                ErrorCode::InvalidParams
1593            );
1594        }
1595        assert!(validate_params(
1596            "page.set_viewport",
1597            &json!({"pageId":"p","width":640,"height":360})
1598        )
1599        .is_ok());
1600        assert!(validate_params(
1601            "browser.connect",
1602            &json!({"wsEndpoint":"ws://localhost/devtools/browser/id","sandbox":true})
1603        )
1604        .is_err());
1605    }
1606
1607    #[test]
1608    fn canonical_state_conversion_preserves_cookie_and_storage_shapes() {
1609        let state: BrowserState = serde_json::from_value(json!({
1610            "cookies":[{"name":"session","value":"private","domain":"example.test","path":"/","secure":true,"http_only":true,"same_site":"Lax","expires":null}],
1611            "localStorage":{"local":"value"},"sessionStorage":{"tab":"value"},
1612            "userAgent":"agent","url":"https://example.test/"
1613        })).unwrap();
1614        let before = serde_json::to_value(&state).unwrap();
1615        let core: eoka::BrowserState = state.into();
1616        assert_eq!(serde_json::to_value(&core).unwrap(), before);
1617        assert_eq!(
1618            serde_json::to_value(BrowserState::from(core)).unwrap(),
1619            before
1620        );
1621    }
1622
1623    #[test]
1624    fn actual_result_schemas_preserve_array_dynamic_json_and_numeric_bounds() {
1625        let bundle = schema_bundle();
1626        let list = &bundle["methods"]["operations.list"]["result"];
1627        assert_eq!(list["type"], "array");
1628        assert!(list["items"]["$ref"]
1629            .as_str()
1630            .unwrap()
1631            .ends_with("/OperationRecord"));
1632        let dynamic = &bundle["methods"]["page.evaluate"]["result"];
1633        let decoded: EvaluateResult =
1634            serde_json::from_value(json!({"result":[null,false,{"value":3}]})).unwrap();
1635        assert_eq!(decoded.result, json!([null,false,{"value":3}]));
1636        assert!(
1637            dynamic["properties"]["result"].is_boolean()
1638                || dynamic["properties"]["result"].is_object()
1639        );
1640        let launch = &bundle["methods"]["browser.launch"]["params"];
1641        assert_eq!(
1642            launch["properties"]["viewportWidth"]["maximum"].as_f64(),
1643            Some(i32::MAX as f64)
1644        );
1645        let wait = &bundle["methods"]["operations.wait"]["params"];
1646        assert_eq!(
1647            wait["properties"]["timeout_ms"]["maximum"].as_f64(),
1648            Some(86_400_000.0)
1649        );
1650        let methods = method_names();
1651        let unique: std::collections::BTreeSet<_> = methods.iter().collect();
1652        assert_eq!(unique.len(), methods.len());
1653        assert!(validate_params("browser.connect", &json!({})).is_err());
1654        assert!(validate_params("operation_get", &json!({"id":"operation"})).is_err());
1655    }
1656}