11import logger from '@wdio/logger'
2+ import { join } from 'node:path'
3+ import { promises as fsPromises } from 'node:fs'
24import scrollToPosition from '../clientSideScripts/scrollToPosition.js'
35import getDocumentScrollHeight from '../clientSideScripts/getDocumentScrollHeight.js'
46import { calculateDprData , getBase64ScreenshotSize , waitFor } from '../helpers/utils.js'
@@ -13,6 +15,7 @@ import hideRemoveElements from '../clientSideScripts/hideRemoveElements.js'
1315import hideScrollBars from '../clientSideScripts/hideScrollbars.js'
1416import type { ElementRectanglesOptions , RectanglesOutput } from './rectangles.interfaces.js'
1517import { determineElementRectangles } from './rectangles.js'
18+ import { cropAndConvertToDataURL , saveBase64Image } from './images.js'
1619
1720const log = logger ( '@wdio/visual-service:@wdio/image-comparison-core:screenshots' )
1821
@@ -277,14 +280,42 @@ export async function getDesktopFullPageScreenshotsData(browserInstance:Webdrive
277280 const { devicePixelRatio, fullPageScrollTimeout, hideAfterFirstScroll, innerHeight } = options
278281 let actualInnerHeight = innerHeight
279282
283+ // Detect Safari desktop for debug saving and drop shadow cropping
284+ const { capabilities } = browserInstance
285+ const browserName = ( capabilities ?. browserName || '' ) . toLowerCase ( )
286+ const isSafariDesktop = browserName . includes ( 'safari' ) && ! browserInstance . isMobile
287+ const debugDir = isSafariDesktop ? join ( process . cwd ( ) , '.tmp' , 'debug' , 'safari-desktop-fullpage-screenshots' ) : null
288+
289+ // Safari desktop has a drop shadow at the top
290+ // The drop shadow is always 1 device pixel, which translates to 1 * DPR CSS pixels
291+ const safariTopDropShadowCssPixels = isSafariDesktop ? Math . round ( 1 * devicePixelRatio ) : 0
292+ // For Safari desktop, calculate effective scroll increment
293+ // First image: scroll by 0, use full height (716px)
294+ // Subsequent images: scroll by (actualInnerHeight - dropShadowOffset) = 715px, crop 1px from top
295+ const effectiveScrollIncrement = isSafariDesktop
296+ ? actualInnerHeight - safariTopDropShadowCssPixels
297+ : actualInnerHeight
298+
299+ // Create debug directory if needed
300+ if ( debugDir ) {
301+ await fsPromises . mkdir ( debugDir , { recursive : true } )
302+ }
303+
280304 // Start with an empty array, during the scroll it will be filled because a page could also have a lazy loading
281305 const amountOfScrollsArray = [ ]
282306 let scrollHeight : number | undefined
283307 let screenshotSize
284308
285309 for ( let i = 0 ; i <= amountOfScrollsArray . length ; i ++ ) {
286310 // Determine and start scrolling
287- const scrollY = actualInnerHeight * i
311+ // For Safari desktop: first image scrolls to 0, subsequent images scroll by effectiveScrollIncrement (715px)
312+ // Image 0: scrollY = 0
313+ // Image 1: scrollY = 715 (effectiveScrollIncrement)
314+ // Image 2: scrollY = 1430 (2 * effectiveScrollIncrement)
315+ // etc.
316+ const scrollY = isSafariDesktop
317+ ? ( i === 0 ? 0 : i * effectiveScrollIncrement )
318+ : actualInnerHeight * i
288319 await browserInstance . execute ( scrollToPosition , scrollY )
289320
290321 // Simply wait the amount of time specified for lazy-loading
@@ -316,27 +347,164 @@ export async function getDesktopFullPageScreenshotsData(browserInstance:Webdrive
316347 // Determine scroll height and check if we need to scroll again
317348 scrollHeight = await browserInstance . execute ( getDocumentScrollHeight )
318349
319- if ( scrollHeight && ( scrollY + actualInnerHeight < scrollHeight ) && screenshotSize . height === actualInnerHeight ) {
350+ // For Safari desktop, use effectiveScrollIncrement for the scroll check
351+ const scrollCheckHeight = isSafariDesktop ? effectiveScrollIncrement : actualInnerHeight
352+ if ( scrollHeight && ( scrollY + scrollCheckHeight < scrollHeight ) && screenshotSize . height === actualInnerHeight ) {
320353 amountOfScrollsArray . push ( amountOfScrollsArray . length )
321354 }
322355 // There is no else, Lazy load and large screenshots,
323356 // like with older drivers such as FF <= 47 and IE11, will not work
324357
325358 // The height of the image of the last 1 could be different
326- const imageHeight : number = scrollHeight && amountOfScrollsArray . length === i
327- ? scrollHeight - actualInnerHeight * viewportScreenshots . length
328- : screenshotSize . height
359+ // For Safari desktop, account for first image being full height and subsequent images being cropped
360+ const isFirstImage = i === 0
361+ const isLastImage = amountOfScrollsArray . length === i
362+ let imageHeight : number
363+ if ( scrollHeight && isLastImage ) {
364+ if ( isSafariDesktop ) {
365+ // Calculate remaining content: scrollHeight - (firstImageHeight + (numberOfPreviousImages - 1) * effectiveScrollIncrement)
366+ const numberOfPreviousImages = viewportScreenshots . length
367+ const totalPreviousHeight = numberOfPreviousImages === 0
368+ ? 0
369+ : actualInnerHeight + ( numberOfPreviousImages - 1 ) * effectiveScrollIncrement
370+ const remainingContent = scrollHeight - totalPreviousHeight
371+
372+ // For the last image, we need to be smart:
373+ // - If remainingContent >= actualInnerHeight: it's a full screenshot, treat it like a regular non-first image
374+ // (crop 1px from top, visible height = 715px)
375+ // - If remainingContent < actualInnerHeight: it's a partial screenshot
376+ // For partial screenshots, we're cropping from a position that doesn't include the drop shadow at pixel 0
377+ // So we don't need to add 1px - just use remainingContent directly
378+ imageHeight = remainingContent >= actualInnerHeight
379+ ? effectiveScrollIncrement // Full screenshot: treat like regular non-first image
380+ : remainingContent // Partial screenshot: use remainingContent directly (no drop shadow in cropped region)
381+ } else {
382+ imageHeight = scrollHeight - actualInnerHeight * viewportScreenshots . length
383+ }
384+ } else {
385+ // Non-last images: use full height for first, effectiveScrollIncrement for subsequent
386+ imageHeight = isSafariDesktop && ! isFirstImage
387+ ? effectiveScrollIncrement
388+ : screenshotSize . height
389+ }
390+
329391 // The starting position for cropping could be different for the last image (0 means no cropping)
330- const imageYPosition = amountOfScrollsArray . length === i && amountOfScrollsArray . length !== 0
331- ? actualInnerHeight - imageHeight
332- : 0
392+ // For Safari desktop, crop 1px from top for all images except first
393+ let imageYPosition : number
394+ if ( isSafariDesktop ) {
395+ if ( isLastImage && ! isFirstImage ) {
396+ // Last image: need to handle two cases
397+ const numberOfPreviousImages = viewportScreenshots . length
398+ const totalPreviousHeight = numberOfPreviousImages === 0
399+ ? 0
400+ : actualInnerHeight + ( numberOfPreviousImages - 1 ) * effectiveScrollIncrement
401+ const remainingContent = scrollHeight ? scrollHeight - totalPreviousHeight : 0
402+
403+ // Full screenshot: treat like regular non-first image (crop 1px from top)
404+ // Partial screenshot: we want to show the last remainingContent pixels
405+ // imageHeight = remainingContent, so we start at: 716 - remainingContent
406+ // For partial screenshots, the drop shadow is at pixel 0, but we're cropping from a different position
407+ // so we don't need to crop the drop shadow - just position to get the last remainingContent pixels
408+ imageYPosition = remainingContent >= actualInnerHeight
409+ ? safariTopDropShadowCssPixels
410+ : actualInnerHeight - remainingContent
411+ } else if ( ! isFirstImage ) {
412+ // Non-last, non-first images: crop 1px from top
413+ imageYPosition = safariTopDropShadowCssPixels
414+ } else {
415+ // First image: no crop
416+ imageYPosition = 0
417+ }
418+ } else {
419+ // Non-Safari: standard calculation
420+ imageYPosition = isLastImage && ! isFirstImage
421+ ? actualInnerHeight - imageHeight
422+ : 0
423+ }
424+
425+ // Debug logging for Safari desktop to see what's being cropped
426+ if ( isSafariDesktop ) {
427+ // For non-first images, imageHeight already accounts for the crop, so visible = imageHeight
428+ // For first image, visible = imageHeight (no crop)
429+ const visibleHeightAfterCrop = imageHeight
430+ console . log ( `Safari desktop screenshot ${ i } cropping details:` , {
431+ isFirstImage,
432+ isLastImage,
433+ scrollY,
434+ screenshotSize : {
435+ width : screenshotSize . width ,
436+ height : screenshotSize . height ,
437+ } ,
438+ imageHeight,
439+ imageYPosition,
440+ visibleHeightAfterCrop,
441+ actualInnerHeight,
442+ effectiveScrollIncrement,
443+ remainingContent : isLastImage && scrollHeight
444+ ? ( ( ) => {
445+ const numberOfPreviousImages = viewportScreenshots . length
446+ const totalPreviousHeight = numberOfPreviousImages === 0
447+ ? 0
448+ : actualInnerHeight + ( numberOfPreviousImages - 1 ) * effectiveScrollIncrement
449+ return scrollHeight - totalPreviousHeight
450+ } ) ( )
451+ : undefined ,
452+ cropFromOriginal : {
453+ startY : imageYPosition ,
454+ height : imageHeight ,
455+ endY : imageYPosition + imageHeight ,
456+ } ,
457+ } )
458+ }
459+
460+ // Calculate canvasYPosition for stitching
461+ // For Safari desktop: first image at 0, subsequent images start where previous image ends
462+ let canvasYPosition : number
463+ if ( isSafariDesktop && ! isFirstImage ) {
464+ // Calculate based on where the previous image ends
465+ // Previous image's canvasYPosition + previous image's height
466+ const previousImage = viewportScreenshots [ viewportScreenshots . length - 1 ]
467+ canvasYPosition = previousImage
468+ ? previousImage . canvasYPosition + previousImage . imageHeight
469+ : actualInnerHeight + ( i - 1 ) * effectiveScrollIncrement // Fallback (shouldn't happen)
470+ } else {
471+ canvasYPosition = isSafariDesktop ? 0 : scrollY
472+ }
473+
474+ // Calculate crop dimensions in device pixels for debug saving
475+ const imageXPositionDevicePixels = 0
476+ const imageYPositionDevicePixels = Math . round ( imageYPosition * devicePixelRatio )
477+ const imageWidthDevicePixels = Math . round ( screenshotSize . width * devicePixelRatio )
478+ const imageHeightDevicePixels = Math . round ( imageHeight * devicePixelRatio )
479+
480+ // Save cropped screenshot to debug folder for Safari desktop
481+ if ( isSafariDesktop && debugDir ) {
482+ try {
483+ const croppedBase64 = await cropAndConvertToDataURL ( {
484+ addIOSBezelCorners : false ,
485+ base64Image : screenshot ,
486+ deviceName : '' ,
487+ devicePixelRatio : 1 , // Already in device pixels
488+ height : imageHeightDevicePixels ,
489+ isIOS : false ,
490+ isLandscape : false ,
491+ sourceX : imageXPositionDevicePixels ,
492+ sourceY : imageYPositionDevicePixels ,
493+ width : imageWidthDevicePixels ,
494+ } )
495+ const debugFilePath = join ( debugDir , `step-${ i } -scrollY-${ scrollY } -height-${ imageHeight } -yPos-${ imageYPosition } .png` )
496+ await saveBase64Image ( croppedBase64 , debugFilePath )
497+ } catch ( error ) {
498+ log . warn ( `Failed to save debug screenshot for step ${ i } :` , error )
499+ }
500+ }
333501
334502 // Store all the screenshot data in the screenshot object
335503 viewportScreenshots . push ( {
336504 ...calculateDprData (
337505 {
338506 canvasWidth : screenshotSize . width ,
339- canvasYPosition : scrollY ,
507+ canvasYPosition : canvasYPosition ,
340508 imageHeight : imageHeight ,
341509 imageWidth : screenshotSize . width ,
342510 imageXPosition : 0 ,
0 commit comments