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