Skip to content
8 changes: 8 additions & 0 deletions .changeset/full-page-scroll-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@wdio/image-comparison-core": minor
"@wdio/visual-service": minor
---

feat: full page screenshots of apps where a container scrolls, with the new `scrollContainer` option

In many apps the page itself does not scroll, a container does (for example a `main` element under a fixed header). A full page screenshot of such an app was only the viewport (WebDriver BiDi), or the top of the page again and again (scroll and stitch). The new `scrollContainer` option of `checkFullPageScreen()`, `saveFullPageScreen()` and `toMatchFullPageSnapshot()` gives the element that scrolls. The image is then the viewport with this container expanded: the part above the container, the full content of the container, and the part below it. Columns next to the container (for example a sidebar) are only in the image for the first viewport. The screenshot scrolls and stitches (also in a WebDriver BiDi session), turns off scroll snap and smooth scrolling of the container while it takes the screenshots, and scrolls the container back to its start position. When content above the visible rows changes while the screenshots are taken, it starts again from the top. The ignore elements are measured at each screenshot, so an element is ignored at each place where it is in the image (for example a sticky title in each screenshot). The container must be fully in the viewport: a container that goes above or below the viewport gives an error, because its first or last rows can not be in the image. `checkTabbablePage()` and `saveTabbablePage()` do not support the option: they log a warning and do not use it. Fixes #125.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Get the visible content box of a scroll container in the viewport (CSS pixels, without borders and scrollbars)
* and its scroll data, for a full page screenshot of a page where a container scrolls (#125)
*/
export default function getScrollContainerData(container: HTMLElement): {
top: number
left: number
width: number
height: number
scrollTop: number
scrollHeight: number
viewportWidth: number
viewportHeight: number
} {
const rect = container.getBoundingClientRect()
// `clientTop`, `clientHeight` and the other client values are rounded to whole pixels, the bounding box and the
// computed borders are not (at DPR 2.625 a 6px border is 5.714px): use them for the exact content box
const style = getComputedStyle(container)
const borderTop = parseFloat(style.borderTopWidth) || 0
const borderRight = parseFloat(style.borderRightWidth) || 0
const borderBottom = parseFloat(style.borderBottomWidth) || 0
const borderLeft = parseFloat(style.borderLeftWidth) || 0
// A scrollbar that takes space (not an overlay scrollbar) has whole pixels; at the left side in a right to left page
const scrollbarWidth = Math.max(0, container.offsetWidth - container.clientWidth - Math.round(borderLeft) - Math.round(borderRight))
const scrollbarHeight = Math.max(0, container.offsetHeight - container.clientHeight - Math.round(borderTop) - Math.round(borderBottom))
const leftScrollbar = Math.max(0, container.clientLeft - Math.round(borderLeft))

return {
top: rect.top + borderTop,
left: rect.left + borderLeft + leftScrollbar,
width: rect.width - borderLeft - borderRight - scrollbarWidth,
height: rect.height - borderTop - borderBottom - scrollbarHeight,
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
viewportWidth: document.documentElement.clientWidth || window.innerWidth,
viewportHeight: window.innerHeight,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Get the position and size of a visible element in the viewport, in CSS pixels, or null when it is not rendered or
* hidden (for example a `hideAfterFirstScroll` element). For the ignore regions of a full page screenshot of a page
* where a container scrolls (#125)
*/
export default function getVisibleElementViewportRect(element: HTMLElement): { x: number, y: number, width: number, height: number } | null {
const isVisible = typeof element.checkVisibility === 'function'
? element.checkVisibility({ visibilityProperty: true, checkVisibilityCSS: true })
: element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden'
if (!isVisible) {
return null
}
let { left, top, right, bottom } = element.getBoundingClientRect()
// The parts of the element outside an ancestor that clips its overflow (the scroll container) are not in the
// screenshot. An ancestor clips the element only when it is in the chain of containing blocks of the element: an
// absolute element escapes the static ancestors below its positioned ancestor, a fixed element escapes all
// ancestors below one with a transform (or a filter, contain, ...). The body and the root element pass their
// overflow to the viewport, so they are not used
const isNone = (value: string) => !value || value === 'none' || value === 'normal'
// The properties that make an element the containing block of its absolute and fixed descendants
const containsFixed = (style: CSSStyleDeclaration) => ['transform', 'translate', 'rotate', 'scale', 'perspective', 'filter', 'backdrop-filter', 'container-type']
.some((name) => !isNone(style.getPropertyValue(name)))
|| /paint|layout|strict|content/.test(style.getPropertyValue('contain'))
|| /auto|hidden/.test(style.getPropertyValue('content-visibility'))
|| /transform|translate|rotate|scale|perspective|filter/.test(style.getPropertyValue('will-change'))
let position = getComputedStyle(element).position
for (let ancestor = element.parentElement; ancestor && ancestor !== document.body && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
const style = getComputedStyle(ancestor)
const isContainingBlock = position === 'fixed'
? containsFixed(style)
: position === 'absolute'
? style.position !== 'static' || containsFixed(style)
: true
if (!isContainingBlock) {
continue
}
if (style.overflowX !== 'visible' || style.overflowY !== 'visible') {
const box = ancestor.getBoundingClientRect()
if (style.overflowX !== 'visible') {
left = Math.max(left, box.left + (parseFloat(style.borderLeftWidth) || 0))
right = Math.min(right, box.right - (parseFloat(style.borderRightWidth) || 0))
}
if (style.overflowY !== 'visible') {
top = Math.max(top, box.top + (parseFloat(style.borderTopWidth) || 0))
bottom = Math.min(bottom, box.bottom - (parseFloat(style.borderBottomWidth) || 0))
}
}
position = style.position
}
if (right <= left || bottom <= top) {
return null
}

return { x: left, y: top, width: right - left, height: bottom - top }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Prepare a scroll container for the screenshots of a full page screenshot, or put it back as it was (#125):
* - `snap`: turn off scroll snap, so that the container stops at the scroll position of each screenshot (an inline
* `!important` value wins over every value of the page),
* - `scrollbar`: hide its scrollbar, as `hideScrollBars` does for the page.
* The parts can be put back separately: the scrollbar before the scroll back (it can change the layout), scroll snap
* after it.
*/
export default function prepareScrollContainer(container: HTMLElement, prepare: boolean, parts: { scrollbar: boolean, snap: boolean }): void {
const scrollbarAttribute = 'data-wic-scroll-container'
const snapAttribute = 'data-wic-scroll-snap-type'
const styleId = 'wic-scroll-container-style'

if (!prepare) {
const saved = parts.snap ? container.getAttribute(snapAttribute) : null
if (saved !== null) {
const [value, priority] = JSON.parse(saved)
if (value) {
container.style.setProperty('scroll-snap-type', value, priority)
} else {
container.style.removeProperty('scroll-snap-type')
}
container.removeAttribute(snapAttribute)
}
if (parts.scrollbar) {
container.removeAttribute(scrollbarAttribute)
document.getElementById(styleId)?.remove()
}
return
}

if (parts.snap) {
container.setAttribute(snapAttribute, JSON.stringify([
container.style.getPropertyValue('scroll-snap-type'),
container.style.getPropertyPriority('scroll-snap-type'),
]))
container.style.setProperty('scroll-snap-type', 'none', 'important')
}

if (parts.scrollbar) {
container.setAttribute(scrollbarAttribute, '')
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `[${scrollbarAttribute}] { scrollbar-width: none !important; } [${scrollbarAttribute}]::-webkit-scrollbar { display: none !important; }`
document.head.appendChild(style)
}
}
}
Loading
Loading