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;