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