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