viewpoint_core/page/emulation/
mod.rs

1//! Page emulation features for media and vision deficiency emulation.
2
3use std::sync::Arc;
4use tracing::{debug, info, instrument};
5
6use viewpoint_cdp::protocol::emulation::{
7    MediaFeature, SetDeviceMetricsOverrideParams, SetEmulatedMediaParams,
8    SetEmulatedVisionDeficiencyParams, ViewportSize, VisionDeficiency as CdpVisionDeficiency,
9};
10use viewpoint_cdp::CdpConnection;
11
12use super::Page;
13use crate::context::{ColorScheme, ForcedColors, ReducedMotion};
14use crate::error::PageError;
15
16/// Media type for CSS media emulation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum MediaType {
19    /// Screen media type.
20    Screen,
21    /// Print media type.
22    Print,
23}
24
25impl MediaType {
26    /// Convert to CSS media type string.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            Self::Screen => "screen",
30            Self::Print => "print",
31        }
32    }
33}
34
35/// Vision deficiency types for accessibility testing.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum VisionDeficiency {
38    /// No vision deficiency emulation (normal vision).
39    #[default]
40    None,
41    /// Achromatopsia - complete color blindness.
42    Achromatopsia,
43    /// Blurred vision.
44    BlurredVision,
45    /// Deuteranopia - green-blind (red-green color blindness).
46    Deuteranopia,
47    /// Protanopia - red-blind (red-green color blindness).
48    Protanopia,
49    /// Tritanopia - blue-blind (blue-yellow color blindness).
50    Tritanopia,
51}
52
53impl From<VisionDeficiency> for CdpVisionDeficiency {
54    fn from(deficiency: VisionDeficiency) -> Self {
55        match deficiency {
56            VisionDeficiency::None => CdpVisionDeficiency::None,
57            VisionDeficiency::Achromatopsia => CdpVisionDeficiency::Achromatopsia,
58            VisionDeficiency::BlurredVision => CdpVisionDeficiency::BlurredVision,
59            VisionDeficiency::Deuteranopia => CdpVisionDeficiency::Deuteranopia,
60            VisionDeficiency::Protanopia => CdpVisionDeficiency::Protanopia,
61            VisionDeficiency::Tritanopia => CdpVisionDeficiency::Tritanopia,
62        }
63    }
64}
65
66/// Builder for emulating CSS media features on a page.
67///
68/// Use this to test how your application responds to different media preferences
69/// like dark mode, print media, reduced motion, and forced colors.
70///
71/// # Example
72///
73/// ```
74/// # #[cfg(feature = "integration")]
75/// # tokio_test::block_on(async {
76/// # use viewpoint_core::Browser;
77/// use viewpoint_core::{ColorScheme, ReducedMotion};
78/// use viewpoint_core::page::MediaType;
79/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
80/// # let context = browser.new_context().await.unwrap();
81/// # let page = context.new_page().await.unwrap();
82///
83/// // Emulate dark mode
84/// page.emulate_media()
85///     .color_scheme(ColorScheme::Dark)
86///     .apply()
87///     .await.unwrap();
88///
89/// // Emulate print media
90/// page.emulate_media()
91///     .media(MediaType::Print)
92///     .apply()
93///     .await.unwrap();
94///
95/// // Combine multiple settings
96/// page.emulate_media()
97///     .color_scheme(ColorScheme::Dark)
98///     .reduced_motion(ReducedMotion::Reduce)
99///     .apply()
100///     .await.unwrap();
101///
102/// // Clear all media emulation
103/// page.emulate_media()
104///     .clear()
105///     .await.unwrap();
106/// # });
107/// ```
108#[derive(Debug)]
109pub struct EmulateMediaBuilder<'a> {
110    connection: &'a Arc<CdpConnection>,
111    session_id: &'a str,
112    media: Option<MediaType>,
113    color_scheme: Option<ColorScheme>,
114    reduced_motion: Option<ReducedMotion>,
115    forced_colors: Option<ForcedColors>,
116}
117
118impl<'a> EmulateMediaBuilder<'a> {
119    /// Create a new emulate media builder.
120    pub(crate) fn new(connection: &'a Arc<CdpConnection>, session_id: &'a str) -> Self {
121        Self {
122            connection,
123            session_id,
124            media: None,
125            color_scheme: None,
126            reduced_motion: None,
127            forced_colors: None,
128        }
129    }
130
131    /// Set the CSS media type (screen or print).
132    #[must_use]
133    pub fn media(mut self, media: MediaType) -> Self {
134        self.media = Some(media);
135        self
136    }
137
138    /// Set the color scheme preference.
139    ///
140    /// This affects CSS `prefers-color-scheme` media queries.
141    #[must_use]
142    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
143        self.color_scheme = Some(color_scheme);
144        self
145    }
146
147    /// Set the reduced motion preference.
148    ///
149    /// This affects CSS `prefers-reduced-motion` media queries.
150    #[must_use]
151    pub fn reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
152        self.reduced_motion = Some(reduced_motion);
153        self
154    }
155
156    /// Set the forced colors preference.
157    ///
158    /// This affects CSS `forced-colors` media queries.
159    #[must_use]
160    pub fn forced_colors(mut self, forced_colors: ForcedColors) -> Self {
161        self.forced_colors = Some(forced_colors);
162        self
163    }
164
165    /// Apply the media emulation settings.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the CDP command fails.
170    #[instrument(level = "debug", skip(self))]
171    pub async fn apply(self) -> Result<(), PageError> {
172        let mut features = Vec::new();
173
174        if let Some(color_scheme) = self.color_scheme {
175            features.push(MediaFeature {
176                name: "prefers-color-scheme".to_string(),
177                value: match color_scheme {
178                    ColorScheme::Light => "light".to_string(),
179                    ColorScheme::Dark => "dark".to_string(),
180                    ColorScheme::NoPreference => "no-preference".to_string(),
181                },
182            });
183        }
184
185        if let Some(reduced_motion) = self.reduced_motion {
186            features.push(MediaFeature {
187                name: "prefers-reduced-motion".to_string(),
188                value: match reduced_motion {
189                    ReducedMotion::Reduce => "reduce".to_string(),
190                    ReducedMotion::NoPreference => "no-preference".to_string(),
191                },
192            });
193        }
194
195        if let Some(forced_colors) = self.forced_colors {
196            features.push(MediaFeature {
197                name: "forced-colors".to_string(),
198                value: match forced_colors {
199                    ForcedColors::Active => "active".to_string(),
200                    ForcedColors::None => "none".to_string(),
201                },
202            });
203        }
204
205        let media = self.media.map(|m| m.as_str().to_string());
206        let features_opt = if features.is_empty() { None } else { Some(features) };
207
208        debug!(
209            media = ?media,
210            features_count = features_opt.as_ref().map_or(0, std::vec::Vec::len),
211            "Applying media emulation"
212        );
213
214        self.connection
215            .send_command::<_, serde_json::Value>(
216                "Emulation.setEmulatedMedia",
217                Some(SetEmulatedMediaParams {
218                    media,
219                    features: features_opt,
220                }),
221                Some(self.session_id),
222            )
223            .await?;
224
225        Ok(())
226    }
227
228    /// Clear all media emulation.
229    ///
230    /// This resets media type and all media features to their defaults.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the CDP command fails.
235    #[instrument(level = "debug", skip(self))]
236    pub async fn clear(self) -> Result<(), PageError> {
237        debug!("Clearing media emulation");
238
239        self.connection
240            .send_command::<_, serde_json::Value>(
241                "Emulation.setEmulatedMedia",
242                Some(SetEmulatedMediaParams {
243                    media: Some(String::new()),  // Empty string clears media type
244                    features: Some(Vec::new()),  // Empty array clears features
245                }),
246                Some(self.session_id),
247            )
248            .await?;
249
250        Ok(())
251    }
252}
253
254/// Emulate a vision deficiency on the page (implementation).
255///
256/// This is useful for accessibility testing to ensure your application
257/// is usable by people with various types of color blindness.
258///
259/// # Errors
260///
261/// Returns an error if the CDP command fails.
262#[instrument(level = "debug", skip(connection))]
263async fn emulate_vision_deficiency_impl(
264    connection: &Arc<CdpConnection>,
265    session_id: &str,
266    deficiency: VisionDeficiency,
267) -> Result<(), PageError> {
268    debug!(?deficiency, "Emulating vision deficiency");
269
270    connection
271        .send_command::<_, serde_json::Value>(
272            "Emulation.setEmulatedVisionDeficiency",
273            Some(SetEmulatedVisionDeficiencyParams::new(deficiency.into())),
274            Some(session_id),
275        )
276        .await?;
277
278    Ok(())
279}
280
281// =============================================================================
282// Page impl for viewport and emulation methods
283// =============================================================================
284
285impl Page {
286    // =========================================================================
287    // Viewport Methods
288    // =========================================================================
289
290    /// Get the current viewport size.
291    ///
292    /// Returns the width and height in pixels.
293    pub fn viewport_size(&self) -> Option<ViewportSize> {
294        // This would need to be tracked during page creation
295        // For now, return None to indicate it's not set
296        None
297    }
298
299    /// Set the viewport size.
300    ///
301    /// # Example
302    ///
303    /// ```no_run
304    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
305    /// page.set_viewport_size(1280, 720).await?;
306    /// # Ok(())
307    /// # }
308    /// ```
309    #[instrument(level = "info", skip(self), fields(width = width, height = height))]
310    pub async fn set_viewport_size(&self, width: i32, height: i32) -> Result<(), PageError> {
311        if self.closed {
312            return Err(PageError::Closed);
313        }
314
315        self.connection
316            .send_command::<_, serde_json::Value>(
317                "Emulation.setDeviceMetricsOverride",
318                Some(SetDeviceMetricsOverrideParams {
319                    width,
320                    height,
321                    device_scale_factor: 1.0,
322                    mobile: false,
323                    scale: None,
324                    screen_width: None,
325                    screen_height: None,
326                    position_x: None,
327                    position_y: None,
328                    dont_set_visible_size: None,
329                    screen_orientation: None,
330                    viewport: None,
331                    display_feature: None,
332                    device_posture: None,
333                }),
334                Some(&self.session_id),
335            )
336            .await?;
337
338        info!("Viewport size set to {}x{}", width, height);
339        Ok(())
340    }
341
342    /// Bring this page to the front (activate the tab).
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if the page is closed or the CDP command fails.
347    #[instrument(level = "info", skip(self))]
348    pub async fn bring_to_front(&self) -> Result<(), PageError> {
349        if self.closed {
350            return Err(PageError::Closed);
351        }
352
353        self.connection
354            .send_command::<_, serde_json::Value>(
355                "Page.bringToFront",
356                None::<()>,
357                Some(&self.session_id),
358            )
359            .await?;
360
361        info!("Page brought to front");
362        Ok(())
363    }
364
365    // =========================================================================
366    // Emulation Methods
367    // =========================================================================
368
369    /// Create a builder for emulating CSS media features.
370    ///
371    /// Use this to test how your application responds to different media preferences
372    /// like dark mode, print media, reduced motion, and forced colors.
373    ///
374    /// # Example
375    ///
376    /// ```no_run
377    /// use viewpoint_core::{Page, page::MediaType};
378    /// use viewpoint_core::{ColorScheme, ReducedMotion};
379    ///
380    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
381    /// // Emulate dark mode
382    /// page.emulate_media()
383    ///     .color_scheme(ColorScheme::Dark)
384    ///     .apply()
385    ///     .await?;
386    ///
387    /// // Emulate print media
388    /// page.emulate_media()
389    ///     .media(MediaType::Print)
390    ///     .apply()
391    ///     .await?;
392    ///
393    /// // Combine multiple settings
394    /// page.emulate_media()
395    ///     .color_scheme(ColorScheme::Dark)
396    ///     .reduced_motion(ReducedMotion::Reduce)
397    ///     .apply()
398    ///     .await?;
399    ///
400    /// // Clear all media emulation
401    /// page.emulate_media()
402    ///     .clear()
403    ///     .await?;
404    /// # Ok(())
405    /// # }
406    /// ```
407    pub fn emulate_media(&self) -> EmulateMediaBuilder<'_> {
408        EmulateMediaBuilder::new(&self.connection, &self.session_id)
409    }
410
411    /// Emulate a vision deficiency on the page.
412    ///
413    /// This is useful for accessibility testing to ensure your application
414    /// is usable by people with various types of color blindness.
415    ///
416    /// # Example
417    ///
418    /// ```no_run
419    /// use viewpoint_core::{Page, page::VisionDeficiency};
420    ///
421    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
422    /// // Emulate deuteranopia (green-blind)
423    /// page.emulate_vision_deficiency(VisionDeficiency::Deuteranopia).await?;
424    ///
425    /// // Clear vision deficiency emulation
426    /// page.emulate_vision_deficiency(VisionDeficiency::None).await?;
427    /// # Ok(())
428    /// # }
429    /// ```
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the page is closed or the CDP command fails.
434    pub async fn emulate_vision_deficiency(
435        &self,
436        deficiency: VisionDeficiency,
437    ) -> Result<(), PageError> {
438        if self.closed {
439            return Err(PageError::Closed);
440        }
441        emulate_vision_deficiency_impl(&self.connection, &self.session_id, deficiency).await
442    }
443}
444
445#[cfg(test)]
446mod tests;