diff --git a/package.json b/package.json
index 4ab705a26..bff383280 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"test-e2e-testplane": "npm run test-e2e-testplane:generate-fixtures && npm run test-e2e-testplane:run-tests",
"test-e2e-testplane:run-tests": "node bin/testplane --config test/e2e/testplane.config.ts",
"test-e2e-testplane:generate-fixtures": "node bin/testplane --config test/e2e/fixtures/basic-report/testplane.config.ts || true",
- "test-e2e:gui": "node bin/testplane --config test/e2e/testplane.config.ts gui",
+ "test-e2e-testplane:gui": "node bin/testplane --config test/e2e/testplane.config.ts gui",
"test-browser-env": "TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane -r tsconfig-paths/register --config test/browser-env/testplane.config.ts",
"test-browser-env:gui": "TS_NODE_PROJECT=./test/browser-env/tsconfig.json node bin/testplane gui -r tsconfig-paths/register --config test/browser-env/testplane.config.ts",
"toc": "doctoc docs --title '### Contents'",
diff --git a/src/browser/client-scripts/screen-shooter/operations.ts b/src/browser/client-scripts/screen-shooter/operations.ts
index 4f1486969..a7431c618 100644
--- a/src/browser/client-scripts/screen-shooter/operations.ts
+++ b/src/browser/client-scripts/screen-shooter/operations.ts
@@ -8,7 +8,6 @@ import {
fromBcrToRect,
getBottom,
getCoveringRect,
- getHeight,
getIntersection
} from "@isomorphic";
import { OutsideOfViewportError } from "./errors/outside-of-viewport";
@@ -48,6 +47,16 @@ export function computeScrollOffset(element: Element): Coord<"page", "css", "y">
}
export function computeViewportSize(): Size<"css"> {
+ const visualViewport = window.visualViewport;
+
+ // Visual viewport occasionally returns more correct values than innerWidth/Height, but may not be available in older browsers
+ if (visualViewport && visualViewport.width > 0 && visualViewport.height > 0) {
+ return {
+ width: visualViewport.width as Length<"css", "x">,
+ height: visualViewport.height as Length<"css", "y">
+ };
+ }
+
return {
width: window.innerWidth as Length<"css", "x">,
height: window.innerHeight as Length<"css", "y">
@@ -333,7 +342,8 @@ export function computeSafeArea(
const scrollParentBcr = scrollParent.getBoundingClientRect();
topValue += isRootLikeElement(scrollParent) ? 0 : scrollParentBcr.top;
shouldSkipZIndexCheck =
- scrollParent === scrollEl || (isRootLikeElement(scrollParent) && isRootLikeElement(scrollEl));
+ captureElements.some(capEl => capEl === el || capEl.contains(el)) &&
+ (scrollParent === scrollEl || (isRootLikeElement(scrollParent) && isRootLikeElement(scrollEl)));
if (!isNaN(topValue)) {
adjustedRect = {
@@ -396,7 +406,7 @@ export function computeSafeArea(
if (shrinkTop < shrinkBottom) {
resultingTop = Math.max(brBottom, safeTop) as Coord<"viewport", "css", "y">;
- resultingHeight = getHeight(safeBottom, resultingTop);
+ resultingHeight = Math.max(0, safeBottom - resultingTop) as Length<"css", "y">;
logger?.("decided to shrink top");
} else if (shrinkBottom) {
resultingHeight = Math.min(safeHeight, br.top - safeTop) as Length<"css", "y">;
diff --git a/src/browser/isomorphic/types.ts b/src/browser/isomorphic/types.ts
index 18a7a1fd8..a35fd0c24 100644
--- a/src/browser/isomorphic/types.ts
+++ b/src/browser/isomorphic/types.ts
@@ -1,8 +1,10 @@
-export enum DisableHoverMode {
- Always = "always",
- WhenScrollingNeeded = "when-scrolling-needed",
- Never = "never",
-}
+export const DisableHoverMode = {
+ Always: "always",
+ WhenScrollingNeeded: "when-scrolling-needed",
+ Never: "never",
+} as const;
+
+export type DisableHoverMode = (typeof DisableHoverMode)[keyof typeof DisableHoverMode];
export enum BrowserSideErrorCode {
JS = "JS",
diff --git a/src/browser/screen-shooter/composite-image/index.ts b/src/browser/screen-shooter/composite-image/index.ts
index 1bc0af684..59c8d9a2f 100644
--- a/src/browser/screen-shooter/composite-image/index.ts
+++ b/src/browser/screen-shooter/composite-image/index.ts
@@ -106,7 +106,11 @@ export class CompositeImage {
}
if (this._captureAreaSize.width <= 0 || this._captureAreaSize.height <= 0) {
- throw new Error("Capture area size cannot be zero or negative. Got: " + prettySize(this._captureAreaSize));
+ throw new Error(
+ "Capture area size cannot be zero or negative. Got: " +
+ prettySize(this._captureAreaSize) +
+ "\nMost likely this means that you are trying to capture an area that is completely clipped by parent block (e.g. with overflow: hidden), or the element is zero-sized on its own.",
+ );
}
const imageSize = viewportImage.getSize() as Size<"device">;
@@ -166,8 +170,8 @@ export class CompositeImage {
debug("Candidates: %O", candidates);
- this._chooseBestCandidates(candidates);
this._expandCandidatesToFullArea(candidates);
+ this._chooseBestCandidates(candidates);
const commonHorizontalArea = this._computeCommonHorizontalAreaIfNeeded(candidates, this._captureAreaSize.width);
const captureWidth = commonHorizontalArea?.width ?? this._captureAreaSize.width;
diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts
index 2d327d75d..52818be8f 100644
--- a/src/browser/screen-shooter/elements-screen-shooter.ts
+++ b/src/browser/screen-shooter/elements-screen-shooter.ts
@@ -631,6 +631,16 @@ export class ElementsScreenShooter {
opts,
async currentState => {
if (currentState.captureSpecs.length === 0) {
+ if (iterations > 0) {
+ debug(
+ "Capture area disappeared after %d chunk(s), rendering already captured data for selectors: %s",
+ iterations,
+ selectorsToCapture.join("; "),
+ );
+
+ return;
+ }
+
throw new Error(getEmptyCaptureSpecsErrorMessage(selectorsToCapture));
}
diff --git a/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png b/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png
new file mode 100644
index 000000000..e43299c71
Binary files /dev/null and b/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png differ
diff --git a/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
index 19432971b..bc875d537 100644
--- a/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
+++ b/test/browser-env/tests/desktop/screenshooter/computeSafeArea.testplane.ts
@@ -336,6 +336,19 @@ describe("computeSafeArea", () => {
await browser.assertView("compute-safe-area-stacking-context-filter-in-front");
});
+ it("should not shrink when root sticky element is behind absolute popup target", async ({ browser }) => {
+ const { default: html } = await import("./fixtures/safe-areas/root-sticky-behind-absolute-popup.html?raw");
+ document.body.innerHTML = html;
+
+ const selectors = [".popup"];
+ const safeArea = computeSafeArea(selectors);
+ const captureSpecs = computeCaptureSpecs(selectors);
+
+ visualizeCaptureSpecs(captureSpecs);
+ visualizeSafeArea(safeArea.top, safeArea.height);
+ await browser.assertView("compute-safe-area-root-sticky-behind-absolute-popup");
+ });
+
it("should shrink for fixed header that creates stacking context via filter and is in front", async ({
browser,
}) => {
diff --git a/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/root-sticky-behind-absolute-popup.html b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/root-sticky-behind-absolute-popup.html
new file mode 100644
index 000000000..29d9885b2
--- /dev/null
+++ b/test/browser-env/tests/desktop/screenshooter/fixtures/safe-areas/root-sticky-behind-absolute-popup.html
@@ -0,0 +1,71 @@
+
+
+
+
Sticky filter behind popup
+
+
+
Listing card
+
The sticky filter belongs to page content and is below the popup in stacking order.
+
+
+
+
diff --git a/test/e2e/screens/2f3696c/chrome/fixed-layer-in-transformed-page.png b/test/e2e/screens/2f3696c/chrome/fixed-layer-in-transformed-page.png
new file mode 100644
index 000000000..a4f896320
Binary files /dev/null and b/test/e2e/screens/2f3696c/chrome/fixed-layer-in-transformed-page.png differ
diff --git a/test/e2e/screens/8b8b177/chrome/scroll-sensitive-popup.png b/test/e2e/screens/8b8b177/chrome/scroll-sensitive-popup.png
new file mode 100644
index 000000000..f694d2589
Binary files /dev/null and b/test/e2e/screens/8b8b177/chrome/scroll-sensitive-popup.png differ
diff --git a/test/e2e/static/capture-area-disappears-after-scroll.html b/test/e2e/static/capture-area-disappears-after-scroll.html
new file mode 100644
index 000000000..297fbb4f2
--- /dev/null
+++ b/test/e2e/static/capture-area-disappears-after-scroll.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Capture Area Disappears After Scroll
+
+
+
+ Safe area interference
+
+
+ Popup top
+ Popup middle
+ Popup bottom
+
+
+
+
+
diff --git a/test/e2e/static/fixed-layer-in-transformed-page.html b/test/e2e/static/fixed-layer-in-transformed-page.html
new file mode 100644
index 000000000..a45ee02a8
--- /dev/null
+++ b/test/e2e/static/fixed-layer-in-transformed-page.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ Fixed Layer in Transformed Page
+
+
+
+
+
+
+
+ Target top
+ Target upper middle
+ Target lower middle
+ Target bottom must be captured
+
+
+
+
diff --git a/test/e2e/tests/assert-view.testplane.js b/test/e2e/tests/assert-view.testplane.js
index 68ccd469b..57fad9ce9 100644
--- a/test/e2e/tests/assert-view.testplane.js
+++ b/test/e2e/tests/assert-view.testplane.js
@@ -61,6 +61,22 @@ describe("assertView", () => {
});
});
+ it("should keep capturing when a fixed layer scrolls inside a transformed page", async ({ browser }) => {
+ await browser.url("fixed-layer-in-transformed-page.html");
+
+ await browser.assertView("fixed-layer-in-transformed-page", "[data-testid=capture-target]", {
+ captureElementFromTop: true,
+ });
+ });
+
+ it("should use captured chunk when capture area disappears after scrolling", async ({ browser }) => {
+ await browser.url("capture-area-disappears-after-scroll.html");
+
+ await browser.assertView("scroll-sensitive-popup", "[data-testid=scroll-sensitive-popup]", {
+ captureElementFromTop: true,
+ });
+ });
+
it("should treat sticky content inside capture target as interference", async ({ browser }) => {
await browser.url("sticky-interference-behind-capture-target.html");
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/data.json b/test/src/browser/screen-shooter/composite-image/fixtures/data.json
index e51554cb8..0c629aaf5 100644
--- a/test/src/browser/screen-shooter/composite-image/fixtures/data.json
+++ b/test/src/browser/screen-shooter/composite-image/fixtures/data.json
@@ -1222,6 +1222,113 @@
}
]
},
+ {
+ "id": "prefer-safe-lower-chunk-over-unsafe-upper-relaxation",
+ "fullPage": "prefer-safe-lower-chunk-over-unsafe-upper-relaxation/full-page.png",
+ "expected": "prefer-safe-lower-chunk-over-unsafe-upper-relaxation/expected.png",
+ "chunks": [
+ {
+ "file": "prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/0.png",
+ "safeArea": {
+ "top": 30,
+ "height": 190
+ },
+ "captureSpecs": [
+ {
+ "full": {
+ "left": 10,
+ "top": 60,
+ "width": 100,
+ "height": 100
+ },
+ "clip": {
+ "left": 0,
+ "top": 0,
+ "width": 120,
+ "height": 300
+ },
+ "visible": {
+ "left": 10,
+ "top": 60,
+ "width": 100,
+ "height": 100
+ }
+ },
+ {
+ "full": {
+ "left": 10,
+ "top": 250,
+ "width": 100,
+ "height": 160
+ },
+ "clip": {
+ "left": 0,
+ "top": 0,
+ "width": 120,
+ "height": 300
+ },
+ "visible": {
+ "left": 10,
+ "top": 250,
+ "width": 100,
+ "height": 10
+ }
+ }
+ ],
+ "ignoreBoundingRects": []
+ },
+ {
+ "file": "prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/1.png",
+ "safeArea": {
+ "top": 30,
+ "height": 190
+ },
+ "captureSpecs": [
+ {
+ "full": {
+ "left": 10,
+ "top": -130,
+ "width": 100,
+ "height": 100
+ },
+ "clip": {
+ "left": 0,
+ "top": 0,
+ "width": 120,
+ "height": 300
+ },
+ "visible": {
+ "left": 10,
+ "top": -130,
+ "width": 0,
+ "height": 0
+ }
+ },
+ {
+ "full": {
+ "left": 10,
+ "top": 60,
+ "width": 100,
+ "height": 160
+ },
+ "clip": {
+ "left": 0,
+ "top": 0,
+ "width": 120,
+ "height": 300
+ },
+ "visible": {
+ "left": 10,
+ "top": 60,
+ "width": 100,
+ "height": 160
+ }
+ }
+ ],
+ "ignoreBoundingRects": []
+ }
+ ]
+ },
{
"id": "distant-selectors-without-common-visible-spec",
"fullPage": "distant-selectors-without-common-visible-spec/full-page.png",
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts b/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts
index 927fdea4b..530412fb1 100644
--- a/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts
+++ b/test/src/browser/screen-shooter/composite-image/fixtures/generate.ts
@@ -1064,6 +1064,187 @@ if (process.argv.includes("generate")) {
],
};
})(),
+ (async (): Promise => {
+ const id = "prefer-safe-lower-chunk-over-unsafe-upper-relaxation";
+ const scenarioDir = path.join(__dirname, id);
+ const chunksDir = path.join(scenarioDir, "chunks");
+ await fs.promises.mkdir(chunksDir, { recursive: true });
+
+ const pageWidth = 120 as Length<"device", "x">;
+ const pageHeight = 420 as Length<"device", "y">;
+ const viewportHeight = 300 as Length<"device", "y">;
+ const safeArea = toViewportYBand(30, 190);
+ const clip = toViewportRect({
+ left: 0,
+ top: 0,
+ width: pageWidth as number,
+ height: viewportHeight as number,
+ });
+ const firstBox = {
+ left: toPageX(10),
+ top: toPageY(60),
+ width: 100 as Length<"device", "x">,
+ height: 100 as Length<"device", "y">,
+ };
+ const secondBox = {
+ left: toPageX(10),
+ top: toPageY(250),
+ width: 100 as Length<"device", "x">,
+ height: 160 as Length<"device", "y">,
+ };
+
+ const page = Buffer.alloc((pageWidth as number) * (pageHeight as number) * RGBA_CHANNELS);
+ fillRect(
+ page,
+ pageWidth,
+ pageHeight,
+ {
+ left: toPageX(0),
+ top: toPageY(0),
+ width: pageWidth,
+ height: pageHeight,
+ },
+ WHITE,
+ );
+
+ for (let y = 0; y < (pageHeight as number); y += 20) {
+ drawLine(
+ page,
+ pageWidth,
+ pageHeight,
+ toPageX(0),
+ toPageY(y),
+ toPageX((pageWidth as number) - 1),
+ toPageY(y),
+ ORANGE,
+ );
+ }
+
+ fillRect(page, pageWidth, pageHeight, firstBox, GRAY);
+ drawRectangle(page, pageWidth, pageHeight, firstBox, BLUE);
+ fillRect(page, pageWidth, pageHeight, secondBox, GRAY);
+ drawRectangle(page, pageWidth, pageHeight, secondBox, BLUE);
+
+ const chunkResults: ScenarioGenerationResult["chunks"] = [];
+ const chunkDefinitions = [
+ {
+ offsetTop: 0,
+ captureSpecs: [
+ {
+ full: toViewportRect({
+ left: firstBox.left as number,
+ top: firstBox.top as number,
+ width: firstBox.width as number,
+ height: firstBox.height as number,
+ }),
+ clip,
+ visible: toViewportRect({
+ left: firstBox.left as number,
+ top: firstBox.top as number,
+ width: firstBox.width as number,
+ height: firstBox.height as number,
+ }),
+ },
+ {
+ full: toViewportRect({
+ left: secondBox.left as number,
+ top: secondBox.top as number,
+ width: secondBox.width as number,
+ height: secondBox.height as number,
+ }),
+ clip,
+ visible: toViewportRect({
+ left: secondBox.left as number,
+ top: secondBox.top as number,
+ width: secondBox.width as number,
+ height: 10,
+ }),
+ },
+ ],
+ },
+ {
+ offsetTop: 190,
+ captureSpecs: [
+ {
+ full: toViewportRect({
+ left: firstBox.left as number,
+ top: (firstBox.top as number) - 190,
+ width: firstBox.width as number,
+ height: firstBox.height as number,
+ }),
+ clip,
+ visible: toViewportRect({
+ left: firstBox.left as number,
+ top: (firstBox.top as number) - 190,
+ width: 0,
+ height: 0,
+ }),
+ },
+ {
+ full: toViewportRect({
+ left: secondBox.left as number,
+ top: (secondBox.top as number) - 190,
+ width: secondBox.width as number,
+ height: secondBox.height as number,
+ }),
+ clip,
+ visible: toViewportRect({
+ left: secondBox.left as number,
+ top: (secondBox.top as number) - 190,
+ width: secondBox.width as number,
+ height: secondBox.height as number,
+ }),
+ },
+ ],
+ },
+ ];
+
+ for (let chunkIndex = 0; chunkIndex < chunkDefinitions.length; chunkIndex++) {
+ const chunkDefinition = chunkDefinitions[chunkIndex];
+ const chunkRgba = crop(
+ page,
+ pageWidth,
+ pageHeight,
+ toPageX(0),
+ toPageY(chunkDefinition.offsetTop),
+ pageWidth,
+ viewportHeight,
+ );
+ applyUnsafeAreasToChunk(chunkRgba, pageWidth, viewportHeight, [{ bottom: 0, height: 80 }]);
+
+ const chunkPath = path.posix.join(chunksDir, `${chunkIndex}.png`);
+ await saveRgbaAsPng(chunkPath, chunkRgba, pageWidth, viewportHeight);
+
+ chunkResults.push({
+ file: path.relative(__dirname, chunkPath),
+ safeArea,
+ captureSpecs: chunkDefinition.captureSpecs,
+ ignoreBoundingRects: [],
+ });
+ }
+
+ const fullPagePath = path.posix.join(scenarioDir, "full-page.png");
+ await saveRgbaAsPng(fullPagePath, page, pageWidth, pageHeight);
+
+ const expectedRgba = crop(
+ page,
+ pageWidth,
+ pageHeight,
+ firstBox.left,
+ firstBox.top,
+ firstBox.width,
+ 350 as Length<"device", "y">,
+ );
+ const expectedPath = path.posix.join(scenarioDir, "expected.png");
+ await saveRgbaAsPng(expectedPath, expectedRgba, firstBox.width, 350 as Length<"device", "y">);
+
+ return {
+ id,
+ fullPage: path.relative(__dirname, fullPagePath),
+ expected: path.relative(__dirname, expectedPath),
+ chunks: chunkResults,
+ };
+ })(),
(async (): Promise => {
const id = "distant-selectors-without-common-visible-spec";
const scenarioDir = path.join(__dirname, id);
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/0.png b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/0.png
new file mode 100644
index 000000000..63e785c0d
Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/0.png differ
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/1.png b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/1.png
new file mode 100644
index 000000000..d2f3136d8
Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/chunks/1.png differ
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/expected.png b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/expected.png
new file mode 100644
index 000000000..19d38da07
Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/expected.png differ
diff --git a/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/full-page.png b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/full-page.png
new file mode 100644
index 000000000..423367941
Binary files /dev/null and b/test/src/browser/screen-shooter/composite-image/fixtures/prefer-safe-lower-chunk-over-unsafe-upper-relaxation/full-page.png differ