Skip to main content

browser_commander/traces/
page.rs

1//! What a trace recorder needs from the page it records (issue #108).
2//!
3//! JavaScript reaches into a Playwright or Puppeteer page directly; Rust asks
4//! through [`TracePage`] instead, so the recorder works the same over any
5//! [`EngineAdapter`] and over a fake page in tests. Every in-page function the
6//! recorder runs comes from the shared `assets.json`, so Rust evaluates the very
7//! code JavaScript does.
8
9use std::future::Future;
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use futures::stream::BoxStream;
15
16use super::jsonfmt::Json;
17use crate::core::engine::{EngineAdapter, TraceEngineEvent};
18
19/// The page a [`super::recorder::TraceRecorder`] records.
20///
21/// Errors are plain messages: a trace records them, it does not interpret
22/// them, except that one mentioning `closed` is reported as `page-closed`.
23#[async_trait]
24pub trait TracePage: Send + Sync {
25    fn require_feature(&self, _feature: &str) -> Result<(), crate::core::engine::EngineError> {
26        Ok(())
27    }
28    /// The engine's name, written to the manifest unless the options name one.
29    fn engine(&self) -> Option<String> {
30        None
31    }
32
33    /// A value that is the same for every page of one browser context.
34    fn context_key(&self) -> Option<usize> {
35        None
36    }
37
38    /// A value that is the same every time this page is traced.
39    fn page_key(&self) -> Option<usize> {
40        None
41    }
42
43    /// Whether the page belongs to a browser context a record can name.
44    fn has_context(&self) -> bool {
45        false
46    }
47
48    /// Call the JavaScript function `source` with `argument` in the page and
49    /// return what it resolved to, with object keys in the page's order.
50    async fn evaluate_function(&self, source: &str, argument: &Json) -> Result<Json, String>;
51
52    /// A PNG of the page, or `None` when the engine cannot take one.
53    async fn screenshot(&self) -> Result<Option<Vec<u8>>, String> {
54        Ok(None)
55    }
56
57    /// Run `source` with `argument` before the code of every future document.
58    ///
59    /// `Some(identifier)` can be passed to [`TracePage::remove_init_script`];
60    /// `None` means the registration stays (or the engine has no such hook).
61    async fn add_init_script(
62        &self,
63        _source: &str,
64        _argument: &Json,
65    ) -> Result<Option<String>, String> {
66        Ok(None)
67    }
68
69    /// Undo [`TracePage::add_init_script`].
70    async fn remove_init_script(&self, _identifier: &str) -> Result<(), String> {
71        Ok(())
72    }
73
74    /// Page activity the trace records as it happens, when the engine reports any.
75    async fn events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
76        None
77    }
78}
79
80/// `(source)(argument)`, as an expression.
81pub(crate) fn call_expression(source: &str, argument: &Json) -> String {
82    format!("({source})({})", argument.to_compact())
83}
84
85/// An expression that resolves to the call's result as JSON text.
86///
87/// Engines hand evaluation results back as `serde_json::Value`, which sorts
88/// object keys; serializing in the page keeps the order the page produced, which
89/// is the order JavaScript writes to the bundle.
90pub(crate) fn json_text_expression(source: &str, argument: &Json) -> String {
91    format!(
92        "Promise.resolve().then(async () => {{ const value = await {}; \
93         return value === undefined ? null : JSON.stringify(value); }})",
94        call_expression(source, argument)
95    )
96}
97
98/// A [`TracePage`] over any [`EngineAdapter`].
99#[derive(Clone)]
100pub struct AdapterTracePage {
101    adapter: Arc<dyn EngineAdapter>,
102}
103
104impl AdapterTracePage {
105    /// Trace the page behind `adapter`.
106    pub fn new(adapter: Arc<dyn EngineAdapter>) -> Self {
107        Self { adapter }
108    }
109
110    fn key(&self) -> usize {
111        Arc::as_ptr(&self.adapter).cast::<()>() as usize
112    }
113}
114
115#[async_trait]
116impl TracePage for AdapterTracePage {
117    fn require_feature(&self, feature: &str) -> Result<(), crate::core::engine::EngineError> {
118        self.adapter.require_feature(feature)
119    }
120    fn engine(&self) -> Option<String> {
121        Some(self.adapter.engine_type().to_string())
122    }
123
124    /// An adapter drives one page in its own context, so they share a key.
125    fn context_key(&self) -> Option<usize> {
126        Some(self.key())
127    }
128
129    fn page_key(&self) -> Option<usize> {
130        Some(self.key())
131    }
132
133    fn has_context(&self) -> bool {
134        true
135    }
136
137    async fn evaluate_function(&self, source: &str, argument: &Json) -> Result<Json, String> {
138        let value = self
139            .adapter
140            .evaluate(&json_text_expression(source, argument))
141            .await
142            .map_err(|error| error.to_string())?;
143        match value {
144            serde_json::Value::String(text) => {
145                Json::parse(&text).map_err(|error| format!("unreadable page result: {error}"))
146            }
147            serde_json::Value::Null => Ok(Json::Null),
148            other => Ok(Json::from(other)),
149        }
150    }
151
152    async fn screenshot(&self) -> Result<Option<Vec<u8>>, String> {
153        self.adapter
154            .screenshot()
155            .await
156            .map(Some)
157            .map_err(|error| error.to_string())
158    }
159
160    async fn add_init_script(
161        &self,
162        source: &str,
163        argument: &Json,
164    ) -> Result<Option<String>, String> {
165        self.adapter
166            .add_init_script(&call_expression(source, argument))
167            .await
168            .map_err(|error| error.to_string())
169    }
170
171    async fn remove_init_script(&self, identifier: &str) -> Result<(), String> {
172        self.adapter
173            .remove_init_script(identifier)
174            .await
175            .map_err(|error| error.to_string())
176    }
177
178    async fn events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
179        self.adapter.trace_events().await
180    }
181}
182
183/// `withDeadline` from `js/src/traces/deadline.js`: `0` means no deadline.
184pub(crate) async fn with_deadline<T>(
185    work: impl Future<Output = Result<T, String>>,
186    timeout_ms: u64,
187    what: &str,
188) -> Result<T, String> {
189    if timeout_ms == 0 {
190        return work.await;
191    }
192    match tokio::time::timeout(Duration::from_millis(timeout_ms), work).await {
193        Ok(outcome) => outcome,
194        Err(_) => Err(format!("{what} timed out after {timeout_ms}ms")),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traces::jsonfmt::JsonObject;
202
203    #[test]
204    fn calls_keep_the_argument_order() {
205        let argument = Json::from(JsonObject::new().with("b", 1).with("a", "x"));
206        assert_eq!(
207            call_expression("function f(o) {}", &argument),
208            r#"(function f(o) {})({"b":1,"a":"x"})"#
209        );
210        assert!(json_text_expression("function f() {}", &Json::Null)
211            .contains("await (function f() {})(null);"));
212    }
213
214    #[tokio::test]
215    async fn deadlines_name_what_timed_out() {
216        let slow = async {
217            tokio::time::sleep(Duration::from_secs(5)).await;
218            Ok::<_, String>(())
219        };
220        assert_eq!(
221            with_deadline(slow, 5, "trace screenshot").await,
222            Err("trace screenshot timed out after 5ms".to_string())
223        );
224        assert_eq!(
225            with_deadline(async { Ok::<_, String>(1) }, 0, "x").await,
226            Ok(1)
227        );
228    }
229}