Skip to content

feat: full page screenshots of apps where a container scrolls (scrollContainer) - #1322

Draft
plum117 wants to merge 7 commits into
webdriverio:mainfrom
plum117:feat/full-page-scroll-container
Draft

plum117 wants to merge 7 commits into
webdriverio:mainfrom
plum117:feat/full-page-scroll-container

Conversation

@plum117

@plum117 plum117 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #125

Why

In many apps the page itself does not scroll, a container does (for example a main element under a fixed header, with html, body { height: 100%; overflow: hidden }). A full page screenshot of such an app was:

What

A new method option scrollContainer for checkFullPageScreen(), saveFullPageScreen() and toMatchFullPageSnapshot():

await expect(browser).toMatchFullPageSnapshot('app', { scrollContainer: await $('main') })

The image is the viewport with that container expanded: the rows above the container (a header), the full content of the container, then the rows below it (a footer). Without the option nothing changes, so no baseline changes.

  • getScrollContainerFullPageScreenshotsData (methods/screenshots.ts): the first tile has all columns (header and the first part of the container), the next tiles only the columns of the container (new optional canvasXPosition of a tile, so a sidebar is not repeated), and the last screenshot gives the rows below the container. Lazy content that makes the container longer is followed. The container scrollbar is hidden with hideScrollBars, hideAfterFirstScroll works, and the container is scrolled back to its start position in finally.
  • It always scrolls and stitches, also in a BiDi session (a document screenshot can not show the content of a container).
  • The ignore elements are measured right after each screenshot (with the scrollbar of the container still hidden) and mapped through the crop of each tile: an element gets a region at each place where it is in the image, for example a sticky title in each screenshot of the container. Each box is clipped to the ancestors that clip its overflow, along its chain of containing blocks (an absolute or fixed element escapes static wrappers; transform, translate, rotate, scale, filter, contain, container-type, ... make a wrapper its containing block), so a hidden part of an element does not ignore pixels outside the container.
  • Each crop uses the scroll position of the container when the screenshot was taken. The scroll target is a device pixel, and the position after the wait is compared with the position that the browser reports right after the scroll, so a container that starts at a fractional row (for example under a header of 60.5 px) works.
  • Scroll snap (and smooth scrolling) of the container is turned off while the screenshots are taken. Afterwards the scrollbar of the container comes back first (a scrollbar that takes space changes the layout), then the container is scrolled back, then scroll snap comes back.
  • When content that loads above the visible rows moves the position during the wait, the earlier screenshots are out of date: the screenshots start again from the top (at most 3 times, then a clear error). Content that loads at the end does not start again.
  • The content box of the container comes from its bounding box and computed borders (the client values are rounded to whole pixels; at DPR 2.625 a 6px border is 5.714px). The tiles are made from rounded device-pixel edges, and the crop row comes from the canvas row minus the scroll position in device pixels, so the tiles touch without a gap or a shifted row at any DPR.
  • The container must be fully in the viewport: a container that goes above or below the viewport gives a clear error, because its first or last rows can not be in a screenshot.
  • Mobile web: the viewport is the measured viewport of a native screenshot (Android native web, iOS), or not higher than innerHeight (Android ChromeDriver screenshots are 56 px higher than the viewport). The service does not add its shadow padding to the body in this mode: in an app shell the body has the height of the viewport, so the padding cut the end of the container.
  • Columns next to the container (a sidebar) are only in the image for the first viewport. The page itself is not scrolled.
  • The tabbable commands do not support the option (their canvas uses page coordinates); they ignore it with a warning.
  • Unit tests for the stitching, the routing, the image composition, the commands, the ignore region mapping, and the client scripts; e2e tests in tests/specs/v10.browsingContexts.spec.ts with a local app-shell page (it fails on main: 70.9%), also with scroll snap and a header of 60.5 px (it fails with the first version of this PR); a changeset (minor). The feature ships after 11.0.0, so it is not in the v11 migration guide; the option needs a line in the WebdriverIO docs (method options).

Test

Matrix: 12 test pages (header, header + footer, sidebar, a sticky title in the container with hideAfterFirstScroll, scroll-behavior: smooth, a container with border and padding, a container without overflow, scroll snap y mandatory, a header of 60.5 px, content that loads above the visible rows, content that loads at the end, dir="rtl") × start positions of the container (top, middle, end), a not-awaited $() element, and a visible container scrollbar, with ignore regions in and below the container. The expected image is the same page with the container expanded by CSS, made with the existing full page command:

Setup Result
Chrome BiDi DPR 1 60 cells: 0% difference, container scrolled back, the ignore regions cover each ignore element and nothing else
Chrome BiDi DPR 1.5 and 2, Edge BiDi, Firefox BiDi DPR 1 and 2, Firefox Classic 24 cells each (top and middle start): 0%
Chrome BiDi DPR 2.625 24 cells: at most 0.07% (text)
Chrome Classic 24 cells: at most 0.1%, all in the expected image (the existing page command shows the page overlay scrollbar there)
Safari desktop (DPR 2) 24 cells at most 0.12% (Safari noise), on the first review commit; not run again on the last commit
Android 16 emulator (DPR 3), ChromeDriver and native web screenshots 8 pages each by the structure of the image (every stripe once, at a fixed step), exact height, scroll back, ignore region
iOS 26.3 simulator (DPR 3) the same 8 pages and checks

Not counted as failures, checked by hand: with a header of 60.5 px the expected page is 1 to 2 device pixels higher, because it rounds its height up to whole CSS pixels (the common rows are equal); for content that loads above, and for scroll snap on Android, the harness measures the start position before the page moves it (the image is equal). With hideScrollBars: false and an overlay scrollbar, the scrollbar is in each screenshot of the container, as the page scrollbar is in a normal full page screenshot.

The review rounds of Greptile found 5 more cases, each reproduced in Chrome and fixed with unit tests that fail without the fix: an ignored element that goes out of the container (its region covered a sidebar), the scroll back with a scrollbar of 300 px that takes space (4537 instead of 4699 px), an absolute element positioned by an ancestor above a static overflow: hidden wrapper, and wrappers with translate (and the other properties that make a containing block).

Before this review, a header of 60.5 px and scroll snap always gave an error, content that loaded above gave a repeated band (8.42%), and at DPR 1.5 and 2.625 some tiles were 1 or 2 device rows off (up to 0.28%).

iOS 26.3 simulator (iPhone 16 Pro) on the last commit: 8 pages by the structure of the image, exact height, scroll back, and the ignore region of an element in the container (also across a tile seam). All core and visual-service unit tests, lint and types pass. The local Chrome suites (test.local.chrome.v10, .jasmine, .emulation, test.local.desktop.multi) pass; the new e2e test with scroll snap and a header of 60.5 px fails with the first version of this PR.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e898382

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@wdio/image-comparison-core Minor
@wdio/visual-service Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@plum117
plum117 marked this pull request as ready for review October 10, 2026 14:29
@plum117
plum117 marked this pull request as draft October 10, 2026 14:29
@plum117

plum117 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Keeping draft until @wswebcreation approval

@greptile-apps

greptile-apps Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the numbered finding is fixed and no new actionable issue was found.

Summary

Adds scrollContainer for full-page screenshots of apps whose content scrolls inside an element.

  • Full-page screenshots now expand the scrolling container without repeating nearby columns.
  • Ignored elements stay covered wherever they appear in the expanded image.
  • Tabbable-page commands warn when given scrollContainer.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Full-page screenshot request] --> B{scrollContainer supplied?}
    B -->|No| C[Existing screenshot path]
    B -->|Yes| D[Save position and prepare container]
    D --> E[Scroll and capture screenshots]
    E --> F[Measure ignored elements for each screenshot]
    F --> G[Restore scrollbar and saved position]
    G --> H[Stitch container with header and footer]
    H --> I[Map ignored regions into final image]
Loading

Reviews (5) · Last reviewed commit: "fix: the separate translate, rotate and ..." · Reviewed by Greptile

Comment thread packages/image-comparison-core/src/methods/screenshots.ts Outdated
Comment thread packages/image-comparison-core/src/methods/screenshots.ts Outdated
Comment thread packages/image-comparison-core/src/methods/rectangles.ts Outdated
Comment thread packages/image-comparison-core/src/methods/screenshots.ts Outdated
plum117 added a commit to plum117/visual-testing that referenced this pull request Oct 10, 2026
…and keep the rows of the container

Greptile review of webdriverio#1322:

- the crop of each screenshot uses the scroll position of the container
  when the screenshot was taken, not the one before the lazy-loading wait.
  When content that loads above the visible rows moves the position, the
  container is scrolled again (at most 3 times, then a clear error)
- a container that goes above or below the viewport gives a clear error:
  its first or last rows can not be in a screenshot (before, they were
  silently missing)
- the ignore elements are measured right after each screenshot (with the
  scrollbar of the container still hidden) and mapped through the crop of
  each tile, so a sticky element is ignored at each place in the image and
  the layout is the layout of the screenshot. The mapping after the
  screenshot (rawExpandedContainerBcr) is removed

Matrix (21 pages x Chrome BiDi, Classic, DPR 2, Edge, Firefox): 21/21 each,
the ignore regions cover each element and nothing else. Mobile structure
check: iOS simulator, Android ChromeDriver and native screenshots OK. New
e2e test: a sticky title in the container, ignored, with another color
(fails with the old code: 4.032 %).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plum117
plum117 force-pushed the feat/full-page-scroll-container branch from 5f6603e to 33e69af Compare October 10, 2026 18:11
plum117 and others added 4 commits October 10, 2026 16:56
…Container)

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 was
then only the viewport (BiDi), or the top of the page again and again
(scroll and stitch, webdriverio#125).

New method option `scrollContainer` for `checkFullPageScreen()`,
`saveFullPageScreen()` and `toMatchFullPageSnapshot()`. The image is the
viewport with that container expanded: the rows above the container, its
full content, then the rows below it. Columns next to the container (a
sidebar) are only in the image for the first viewport.

- New stitching `getScrollContainerFullPageScreenshotsData`: the first
  tile has all columns, the next tiles only the container columns (new
  `canvasXPosition` of a tile), the last screenshot gives the rows below.
  Always stitched, also in a BiDi session (a document screenshot does not
  show the content of a container). The container scrollbar is hidden
  with `hideScrollBars`, and the container is scrolled back in `finally`.
- Ignore regions are mapped to that image (the scroll position of the
  container for its content, the hidden content for elements below it).
- Mobile web: the viewport comes from the device rectangles (native
  screenshots), or is not higher than `innerHeight` (Android ChromeDriver
  screenshots can be higher). The service does not add its shadow
  padding to the body, which would cut the end of the container.
- The tabbable commands do not support it and ignore it with a warning.

Checked against the same page with the container expanded by CSS: 0%
difference in Chrome (BiDi, Classic, DPR 2), Edge and Firefox; Safari,
Android (ChromeDriver and native screenshots) and iOS checked by the
structure of the image (every stripe once, at a fixed step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and keep the rows of the container

Greptile review of webdriverio#1322:

- the crop of each screenshot uses the scroll position of the container
  when the screenshot was taken, not the one before the lazy-loading wait.
  When content that loads above the visible rows moves the position, the
  container is scrolled again (at most 3 times, then a clear error)
- a container that goes above or below the viewport gives a clear error:
  its first or last rows can not be in a screenshot (before, they were
  silently missing)
- the ignore elements are measured right after each screenshot (with the
  scrollbar of the container still hidden) and mapped through the crop of
  each tile, so a sticky element is ignored at each place in the image and
  the layout is the layout of the screenshot. The mapping after the
  screenshot (rawExpandedContainerBcr) is removed

Matrix (21 pages x Chrome BiDi, Classic, DPR 2, Edge, Firefox): 21/21 each,
the ignore regions cover each element and nothing else. Mobile structure
check: iOS simulator, Android ChromeDriver and native screenshots OK. New
e2e test: a sticky title in the container, ignored, with another color
(fails with the old code: 4.032 %).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on, and start again when content above changes

- A container that starts at a fractional row (for example under a header of 60.5 px) always failed: the browser
  keeps the scroll position in device pixels, so the container did not stop at the fractional position of the next
  screenshot, and this was taken as a change of the position. The scroll target is now a device pixel, the position
  after the wait is compared with the position that the browser reports right after the scroll (a difference of up
  to 1 px is rounding), and the tiles are made from rounded edges, so they touch without a gap.
- A container with `scroll-snap-type: mandatory` always failed for the same reason: scroll snap is now turned off
  (inline `!important`) while the screenshots are taken, and put back after the scroll back.
- When content loads above the visible rows during the screenshots (the browser keeps the visible rows in place), the
  earlier screenshots are out of date: the screenshots now start again from the top (at most 3 times), so the image
  has no repeated rows. Content that loads at the end after the container stopped there does not start again.
- The feature is in 11.1.0, not in 11.0.0: the section in the v11 migration guide is removed, the changeset has the
  details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At DPR 1.5 and 2.625 some tiles were 1 or 2 device rows off (0.1 to 0.3 % of the image):
- Chrome reports the scroll position as a 32-bit float (596.6666870117188 for 895 device pixels), so the crop row and
  the canvas row were rounded in different directions. A canvas row and the screenshot row of the same content differ
  by the scroll position in device pixels (the top of the container cancels out), so the crop row now comes from the
  canvas row minus the rounded scroll position.
- `clientTop` and `clientHeight` are rounded to whole pixels, but at DPR 2.625 Chrome draws a 6px border as 5.714px, so
  each tile also took 1 or 2 rows of the border. The content box now comes from the bounding box and the computed
  border widths, without a scrollbar that takes space.

The matrix gives 0 % in Chrome (DPR 1, 1.5, 2), Edge and Firefox (DPR 1, 2) for all 12 pages, and at most 0.07 % (text)
at DPR 2.625.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plum117
plum117 marked this pull request as ready for review October 11, 2026 01:15
@plum117
plum117 force-pushed the feat/full-page-scroll-container branch from 33e69af to dd6b23c Compare October 11, 2026 01:16
Comment thread packages/image-comparison-core/src/methods/screenshots.ts Outdated
…re its layout before the scroll back

- An ignored element in the container was measured with its whole box, also the part that the container hides, so
  the region could cover pixels outside the container (for example a sidebar). The box is now clipped to each
  ancestor that clips its overflow (not for a fixed element).
- The container was scrolled back while its scrollbar was still hidden. A scrollbar that takes space changes the
  layout, so near the end the container stopped at another position (4537 instead of 4699 px with a 300 px scrollbar).
  The scrollbar now comes back first, then the scroll back with scroll snap still off, then scroll snap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntaining block chain

An absolute element escapes the static ancestors below its positioned ancestor, and a fixed element escapes all
ancestors below one with a transform (or a filter, contain, ...). The clip of the ignore region now follows that chain,
so a visible absolute child of a static `overflow: hidden` wrapper keeps its whole region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…containing block for ignore regions

A wrapper with `translate`, `rotate`, `scale`, `backdrop-filter`, `container-type`, `content-visibility: auto|hidden`
or `will-change` of these is the containing block of its absolute and fixed descendants, so its overflow clips them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@plum117
plum117 marked this pull request as draft October 11, 2026 02:07

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using checkFullPageScreen method, capturing images with duplicate viewport and is not scrolling to the bottom of the page

1 participant