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::CdpConnection;
7use viewpoint_cdp::protocol::emulation::{
8    MediaFeature, SetDeviceMetricsOverrideParams, SetEmulatedMediaParams,
9    SetEmulatedVisionDeficiencyParams, ViewportSize, VisionDeficiency as CdpVisionDeficiency,
10};
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() {
207            None
208        } else {
209            Some(features)
210        };
211
212        debug!(
213            media = ?media,
214            features_count = features_opt.as_ref().map_or(0, std::vec::Vec::len),
215            "Applying media emulation"
216        );
217
218        self.connection
219            .send_command::<_, serde_json::Value>(
220                "Emulation.setEmulatedMedia",
221                Some(SetEmulatedMediaParams {
222                    media,
223                    features: features_opt,
224                }),
225                Some(self.session_id),
226            )
227            .await?;
228
229        Ok(())
230    }
231
232    /// Clear all media emulation.
233    ///
234    /// This resets media type and all media features to their defaults.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if the CDP command fails.
239    #[instrument(level = "debug", skip(self))]
240    pub async fn clear(self) -> Result<(), PageError> {
241        debug!("Clearing media emulation");
242
243        self.connection
244            .send_command::<_, serde_json::Value>(
245                "Emulation.setEmulatedMedia",
246                Some(SetEmulatedMediaParams {
247                    media: Some(String::new()), // Empty string clears media type
248                    features: Some(Vec::new()), // Empty array clears features
249                }),
250                Some(self.session_id),
251            )
252            .await?;
253
254        Ok(())
255    }
256}
257
258/// Emulate a vision deficiency on the page (implementation).
259///
260/// This is useful for accessibility testing to ensure your application
261/// is usable by people with various types of color blindness.
262///
263/// # Errors
264///
265/// Returns an error if the CDP command fails.
266#[instrument(level = "debug", skip(connection))]
267async fn emulate_vision_deficiency_impl(
268    connection: &Arc<CdpConnection>,
269    session_id: &str,
270    deficiency: VisionDeficiency,
271) -> Result<(), PageError> {
272    debug!(?deficiency, "Emulating vision deficiency");
273
274    connection
275        .send_command::<_, serde_json::Value>(
276            "Emulation.setEmulatedVisionDeficiency",
277            Some(SetEmulatedVisionDeficiencyParams::new(deficiency.into())),
278            Some(session_id),
279        )
280        .await?;
281
282    Ok(())
283}
284
285// =============================================================================
286// Page impl for viewport and emulation methods
287// =============================================================================
288
289impl Page {
290    // =========================================================================
291    // Viewport Methods
292    // =========================================================================
293
294    /// Get the current viewport size.
295    ///
296    /// Returns the width and height in pixels.
297    pub fn viewport_size(&self) -> Option<ViewportSize> {
298        // This would need to be tracked during page creation
299        // For now, return None to indicate it's not set
300        None
301    }
302
303    /// Set the viewport size.
304    ///
305    /// # Example
306    ///
307    /// ```no_run
308    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
309    /// page.set_viewport_size(1280, 720).await?;
310    /// # Ok(())
311    /// # }
312    /// ```
313    #[instrument(level = "info", skip(self), fields(width = width, height = height))]
314    pub async fn set_viewport_size(&self, width: i32, height: i32) -> Result<(), PageError> {
315        if self.closed {
316            return Err(PageError::Closed);
317        }
318
319        self.connection
320            .send_command::<_, serde_json::Value>(
321                "Emulation.setDeviceMetricsOverride",
322                Some(SetDeviceMetricsOverrideParams {
323                    width,
324                    height,
325                    device_scale_factor: 1.0,
326                    mobile: false,
327                    scale: None,
328                    screen_width: None,
329                    screen_height: None,
330                    position_x: None,
331                    position_y: None,
332                    dont_set_visible_size: None,
333                    screen_orientation: None,
334                    viewport: None,
335                    display_feature: None,
336                    device_posture: None,
337                }),
338                Some(&self.session_id),
339            )
340            .await?;
341
342        info!("Viewport size set to {}x{}", width, height);
343        Ok(())
344    }
345
346    /// Bring this page to the front (activate the tab).
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the page is closed or the CDP command fails.
351    #[instrument(level = "info", skip(self))]
352    pub async fn bring_to_front(&self) -> Result<(), PageError> {
353        if self.closed {
354            return Err(PageError::Closed);
355        }
356
357        self.connection
358            .send_command::<_, serde_json::Value>(
359                "Page.bringToFront",
360                None::<()>,
361                Some(&self.session_id),
362            )
363            .await?;
364
365        info!("Page brought to front");
366        Ok(())
367    }
368
369    // =========================================================================
370    // Emulation Methods
371    // =========================================================================
372
373    /// Create a builder for emulating CSS media features.
374    ///
375    /// Use this to test how your application responds to different media preferences
376    /// like dark mode, print media, reduced motion, and forced colors.
377    ///
378    /// # Example
379    ///
380    /// ```no_run
381    /// use viewpoint_core::{Page, page::MediaType};
382    /// use viewpoint_core::{ColorScheme, ReducedMotion};
383    ///
384    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
385    /// // Emulate dark mode
386    /// page.emulate_media()
387    ///     .color_scheme(ColorScheme::Dark)
388    ///     .apply()
389    ///     .await?;
390    ///
391    /// // Emulate print media
392    /// page.emulate_media()
393    ///     .media(MediaType::Print)
394    ///     .apply()
395    ///     .await?;
396    ///
397    /// // Combine multiple settings
398    /// page.emulate_media()
399    ///     .color_scheme(ColorScheme::Dark)
400    ///     .reduced_motion(ReducedMotion::Reduce)
401    ///     .apply()
402    ///     .await?;
403    ///
404    /// // Clear all media emulation
405    /// page.emulate_media()
406    ///     .clear()
407    ///     .await?;
408    /// # Ok(())
409    /// # }
410    /// ```
411    pub fn emulate_media(&self) -> EmulateMediaBuilder<'_> {
412        EmulateMediaBuilder::new(&self.connection, &self.session_id)
413    }
414
415    /// Emulate a vision deficiency on the page.
416    ///
417    /// This is useful for accessibility testing to ensure your application
418    /// is usable by people with various types of color blindness.
419    ///
420    /// # Example
421    ///
422    /// ```no_run
423    /// use viewpoint_core::{Page, page::VisionDeficiency};
424    ///
425    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
426    /// // Emulate deuteranopia (green-blind)
427    /// page.emulate_vision_deficiency(VisionDeficiency::Deuteranopia).await?;
428    ///
429    /// // Clear vision deficiency emulation
430    /// page.emulate_vision_deficiency(VisionDeficiency::None).await?;
431    /// # Ok(())
432    /// # }
433    /// ```
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the page is closed or the CDP command fails.
438    pub async fn emulate_vision_deficiency(
439        &self,
440        deficiency: VisionDeficiency,
441    ) -> Result<(), PageError> {
442        if self.closed {
443            return Err(PageError::Closed);
444        }
445        emulate_vision_deficiency_impl(&self.connection, &self.session_id, deficiency).await
446    }
447}
448
449#[cfg(test)]
450mod tests;