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