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