Skip to content

Commit 24cf1fe

Browse files
committed
fix: fix remaining issues in new assertView algorithm and implement cropMargins options (#1277)
* fix: handle a case when setTimeout is stubbed during waiting for selectors to stabilize * fix: fix safe area computation for fixed blocks * fix: disable animations and hovers in page screenshots by default * fix: do not move pointer on mobile devices, because it causes some browsers to freeze * fix: handle a case when capture area needs to be expanded, but capture elements are out of viewport * fix: fix handling of sticky/fixed positioned elements inside capture elements * fix: when computing average shift, only take non-zero shifts into account * fix: print helpful message if element to capture is hidden/disappeared mid-capture * fix: ignore transparent blocks via css filter opacity when computing safe area * fix: capture fixed-positioned elements and fix various inconsistencies with fixed/sticky/absolute elements participating in capture * fix: handle missing timeout value in safari simulators during capture area settle wait * fix: rollback only on needed amount of px if safe area shrinks instead of on the whole safe area size to prevent infinite rollback-scroll trap * fix: use correct computation to determine instersection percentage between safe area and capture area * fix: revert introducing synthetic capture specs for fixed-positioned descendants * feat: implement cropMargins options * chore: enable screenshot verbose logging only with TESTPLANE_DEBUG_SCREENSHOTS * fix: fix zero maxDelta non-renderable capture spec case during composite and unit tests * test: fix unit tests * fix: fix review issues * fix!: set default tolerance value to 3 and ignoreDiffPixelCount to 4 * docs: actualize screenshots dev readme
1 parent 345a527 commit 24cf1fe

52 files changed

Lines changed: 2407 additions & 232 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/browser/camera/index.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import makeDebug from "debug";
44

55
import { Image } from "../../image";
66
import * as utils from "./utils";
7+
import type { CropMargins } from "./utils";
78
import * as logger from "../../utils/logger";
89
import {
910
getIntersection,
@@ -20,12 +21,15 @@ import { NEW_ISSUE_LINK } from "../../constants/help";
2021
const debug = makeDebug("testplane:screenshots:camera");
2122

2223
export type ScreenshotMode = "fullpage" | "viewport" | "auto";
24+
export type { CropMargins } from "./utils";
2325

2426
export interface CaptureViewportImageOpts {
2527
viewportOffset: Point<"page", "device">;
2628
viewportSize: Size<"device">;
2729
/** Delay before taking the screenshot, in milliseconds. */
2830
screenshotDelay?: number;
31+
/** Additional raw screenshot margins to crop, in physical pixels. */
32+
cropMargins?: CropMargins;
2933
}
3034

3135
export class Camera {
@@ -82,15 +86,23 @@ export class Camera {
8286
this._calibrationScreenshotSize.height === height;
8387
const calibrationArea = shouldApplyCalibration ? this._calibratedArea : null;
8488

85-
const calibratedImageArea = this._cropAreaToCalibratedArea(imageArea, calibrationArea);
89+
const calibratedImageArea = this._cropAreaToIntersection(imageArea, calibrationArea);
90+
const cropMarginsArea = utils.cropMarginsToRect(imageArea, opts?.cropMargins);
91+
const croppedImageArea = getIntersection(calibratedImageArea, cropMarginsArea);
92+
if (croppedImageArea === null) {
93+
throw new Error(
94+
`Invalid cropMargins option: resulting screenshot crop area is empty. ` +
95+
`imageSize: ${prettySize(imageArea)}, cropMargins: ${JSON.stringify(opts?.cropMargins)}`,
96+
);
97+
}
8698

8799
const viewportCroppedArea = this._cropAreaToViewport(
88-
calibratedImageArea,
100+
croppedImageArea,
89101
{ width, height },
90-
calibrationArea,
102+
croppedImageArea,
91103
opts,
92104
);
93-
await utils.saveViewportImageForDebugIfNeeded(image, calibratedImageArea, this._debugTmpDir);
105+
await utils.saveViewportImageForDebugIfNeeded(image, croppedImageArea, this._debugTmpDir);
94106

95107
if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) {
96108
await image.crop(viewportCroppedArea);
@@ -99,19 +111,19 @@ export class Camera {
99111
return image;
100112
}
101113

102-
private _cropAreaToCalibratedArea(
114+
private _cropAreaToIntersection(
103115
imageArea: Rect<"image", "device">,
104-
calibrationArea: Rect<"image", "device"> | null,
116+
cropArea: Rect<"image", "device"> | null,
105117
): Rect<"image", "device"> {
106-
if (!calibrationArea) {
118+
if (!cropArea) {
107119
return imageArea;
108120
}
109121

110-
const intersection = getIntersection(imageArea, calibrationArea);
122+
const intersection = getIntersection(imageArea, cropArea);
111123
if (intersection === null) {
112124
logger.warn(
113-
`No intersection found between image area and calibrated viewport area, falling back to original image area.\n` +
114-
`imageArea: ${prettyRect(imageArea)}, calibratedViewportArea: ${prettyRect(calibrationArea)}\n` +
125+
`No intersection found between image area and crop area, falling back to original image area.\n` +
126+
`imageArea: ${prettyRect(imageArea)}, cropArea: ${prettyRect(cropArea)}\n` +
115127
`This likely means Testplane incorrectly determined area free of system UI elements. You can let us know at ${NEW_ISSUE_LINK}, providing this log and browser used.`,
116128
);
117129

src/browser/camera/utils.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import path from "path";
22
import fs from "fs";
3-
import { ScreenshotMode } from ".";
3+
import type { ScreenshotMode } from ".";
44
import { Image } from "../../image";
55
import { Rect, Size, getBottom } from "../isomorphic/geometry";
66
import { saveViewportImageWithDebugRects } from "../screen-shooter/composite-image/debug-utils";
77

8+
export interface CropMargins {
9+
top?: number;
10+
right?: number;
11+
bottom?: number;
12+
left?: number;
13+
}
14+
15+
type NormalizedCropMargins = Required<CropMargins>;
16+
817
export const isFullPage = (
918
imageSize: Rect<"image", "device">,
1019
viewportSize: Size<"device">,
@@ -24,6 +33,40 @@ export const isFullPage = (
2433
}
2534
};
2635

36+
export const normalizeCropMargins = (cropMargins?: CropMargins): NormalizedCropMargins => {
37+
const result = {
38+
top: cropMargins?.top ?? 0,
39+
right: cropMargins?.right ?? 0,
40+
bottom: cropMargins?.bottom ?? 0,
41+
left: cropMargins?.left ?? 0,
42+
};
43+
44+
for (const side of ["top", "right", "bottom", "left"] as const) {
45+
const value = result[side];
46+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
47+
throw new Error(
48+
`Invalid cropMargins.${side} option: expected a non-negative integer, got ${String(value)}`,
49+
);
50+
}
51+
}
52+
53+
return result;
54+
};
55+
56+
export const cropMarginsToRect = (
57+
imageArea: Rect<"image", "device">,
58+
cropMargins?: CropMargins,
59+
): Rect<"image", "device"> => {
60+
const margins = normalizeCropMargins(cropMargins);
61+
62+
return {
63+
top: margins.top,
64+
left: margins.left,
65+
width: imageArea.width - margins.left - margins.right,
66+
height: imageArea.height - margins.top - margins.bottom,
67+
} as Rect<"image", "device">;
68+
};
69+
2770
export async function saveViewportImageForDebugIfNeeded(
2871
viewportImage: Image,
2972
viewportCroppedArea: Rect<"image", "device">,

src/browser/client-scripts/screen-shooter/implementation.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
ScrollFullPageResult,
2525
ScrollResult,
2626
GetCaptureStateResult,
27-
TrackedElementData
27+
TrackedElementData,
28+
ElementPositionsProbe
2829
} from "./types";
2930
import { createDebugLogger } from "../shared/logger";
3031
import {
@@ -196,9 +197,7 @@ export function scrollBy(
196197
: `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
197198
: `auto-detected ${readableAutoScrollElementDescr}`;
198199

199-
// Subtracting 1px to avoid a case when element boundary gets rounded up and it appears during screenshots stitching
200-
const scrollHeightCss = (fromDeviceToCssNumber(scrollDelta as Coord<"page", "device", "y">, pixelRatio) -
201-
1) as Coord<"page", "css", "y">;
200+
const scrollHeightCss = fromDeviceToCssNumber(scrollDelta as Coord<"page", "device", "y">, pixelRatio);
202201
scrollElementBy(scrollElement, scrollHeightCss);
203202

204203
return {
@@ -271,6 +270,7 @@ export function getCaptureState(
271270
const captureSpecsAfterCss = computeCaptureSpecs(selectorsToCapture, logger);
272271
const captureSpecs = captureSpecsAfterCss.map(spec => ({
273272
full: fromCssToDevice(roundCoords(spec.full), pixelRatio),
273+
clip: fromCssToDevice(roundCoords(spec.clip), pixelRatio),
274274
visible: fromCssToDevice(roundCoords(spec.visible), pixelRatio)
275275
}));
276276
const scrollOffset = computeScrollOffset(scrollElement);
@@ -326,8 +326,9 @@ export function prepareFullPageScreenshot(
326326
}
327327
}
328328

329-
const elementPositionsProbe = computeElementPositionsProbe().map(rect =>
330-
rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null
329+
const elementPositionsProbe: ElementPositionsProbe<"device">[] = computeElementPositionsProbe().map(
330+
(rect: ElementPositionsProbe<"css">): ElementPositionsProbe<"device"> =>
331+
rect ? { ...fromCssToDevice(roundCoords(rect), pixelRatio), elementDescr: rect.elementDescr } : null
331332
);
332333

333334
return {
@@ -354,8 +355,9 @@ export function scrollFullPage(
354355
scrollElementBy(document.documentElement, scrollHeightCss);
355356

356357
const viewportOffset = computeViewportOffset();
357-
const elementPositionsProbe = computeElementPositionsProbe().map(rect =>
358-
rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null
358+
const elementPositionsProbe: ElementPositionsProbe<"device">[] = computeElementPositionsProbe().map(
359+
(rect: ElementPositionsProbe<"css">): ElementPositionsProbe<"device"> =>
360+
rect ? { ...fromCssToDevice(roundCoords(rect), pixelRatio), elementDescr: rect.elementDescr } : null
359361
);
360362

361363
return {
@@ -526,6 +528,7 @@ function prepareElementsScreenshotUnsafe(
526528
ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)),
527529
captureSpecs: captureSpecs.map(s => ({
528530
full: fromCssToDevice(roundCoords(s.full), pixelRatio),
531+
clip: fromCssToDevice(roundCoords(s.clip), pixelRatio),
529532
visible: fromCssToDevice(roundCoords(s.visible), pixelRatio)
530533
})),
531534
viewportSize: fromCssToDevice(viewportSize, pixelRatio),

0 commit comments

Comments
 (0)