Skip to main content

browser_commander/browser/
raw_cdp.rs

1//! Sending a CDP command chromiumoxide has no generated type for.
2//!
3//! chromiumoxide's `Page::execute` takes a `Command`, and the crate generates
4//! one type per protocol method from the PDL. That is a good API until the
5//! command has to be chosen at runtime: the fingerprint module builds its
6//! command list as `(method, params)` pairs so that JavaScript, Python and Rust
7//! can be checked against the same expectations. `RawCdpCommand` closes the
8//! gap -- `Method::identifier` supplies the method name and `Serialize` the
9//! params object, which is all the connection needs to build a `MethodCall`.
10
11use std::borrow::Cow;
12
13use anyhow::{Context, Result};
14use async_trait::async_trait;
15use chromiumoxide::types::MethodId;
16use chromiumoxide::{Command, Method};
17use serde::{Serialize, Serializer};
18use serde_json::Value;
19
20use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
21use crate::fingerprint::apply::CdpTransport;
22
23/// A CDP command named at runtime.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct RawCdpCommand {
26    method: String,
27    params: Value,
28}
29
30impl RawCdpCommand {
31    /// Build a command for `method` with `params` as its payload.
32    pub fn new(method: impl Into<String>, params: Value) -> Self {
33        Self {
34            method: method.into(),
35            params,
36        }
37    }
38}
39
40impl Serialize for RawCdpCommand {
41    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
42        // Only the params are serialized: the method name travels in the
43        // `method` field of the enclosing MethodCall, which the connection
44        // fills from `identifier()`.
45        self.params.serialize(serializer)
46    }
47}
48
49impl Method for RawCdpCommand {
50    fn identifier(&self) -> MethodId {
51        Cow::Owned(self.method.clone())
52    }
53}
54
55impl Command for RawCdpCommand {
56    // Nothing here inspects the reply, and a runtime-named command has no type
57    // to deserialize into anyway, so the raw JSON is the response.
58    type Response = Value;
59}
60
61#[async_trait]
62impl CdpTransport for ChromiumoxidePage {
63    async fn send(&self, method: &str, params: Value) -> Result<Value> {
64        let response = self
65            .raw_page()
66            .execute(RawCdpCommand::new(method, params))
67            .await
68            .with_context(|| format!("{method} failed"))?;
69        Ok(response.result)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use serde_json::json;
77
78    #[test]
79    fn carries_the_method_name_out_of_band() {
80        let command = RawCdpCommand::new("Emulation.setLocaleOverride", json!({ "locale": "de" }));
81
82        assert_eq!(command.identifier(), "Emulation.setLocaleOverride");
83        assert_eq!(command.domain_name(), "Emulation");
84    }
85
86    #[test]
87    fn serializes_to_the_params_object_alone() {
88        // A method name inside the params would be sent to Chrome as an unknown
89        // argument, which is an error for most commands.
90        let command = RawCdpCommand::new("Page.enable", json!({}));
91
92        assert_eq!(
93            serde_json::to_value(&command).expect("serialize"),
94            json!({})
95        );
96    }
97
98    #[test]
99    fn keeps_the_params_exactly_as_given() {
100        let params = json!({ "source": "globalThis.x = 1;", "nested": { "a": [1, 2] } });
101        let command = RawCdpCommand::new("Page.addScriptToEvaluateOnNewDocument", params.clone());
102
103        assert_eq!(serde_json::to_value(&command).expect("serialize"), params);
104    }
105}