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