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;