Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7929e01
fix: handle a case when setTimeout is stubbed during waiting for sele…
shadowusr May 30, 2026
d24c8d0
fix: fix safe area computation for fixed blocks
shadowusr Jun 10, 2026
a52e26a
fix: disable animations and hovers in page screenshots by default
shadowusr Jun 10, 2026
65c907d
fix: do not move pointer on mobile devices, because it causes some br…
shadowusr Jun 10, 2026
a098cc7
fix: handle a case when capture area needs to be expanded, but captur…
shadowusr Jun 12, 2026
2137d40
fix: fix handling of sticky/fixed positioned elements inside capture …
shadowusr Jun 13, 2026
27ed383
fix: when computing average shift, only take non-zero shifts into acc…
shadowusr Jun 14, 2026
2207f29
fix: print helpful message if element to capture is hidden/disappeare…
shadowusr Jun 14, 2026
54ea496
fix: ignore transparent blocks via css filter opacity when computing …
shadowusr Jun 14, 2026
94b1e8f
fix: capture fixed-positioned elements and fix various inconsistencie…
shadowusr Jun 14, 2026
bc75487
fix: handle missing timeout value in safari simulators during capture…
shadowusr Jun 14, 2026
f2e0c7d
fix: rollback only on needed amount of px if safe area shrinks instea…
shadowusr Jun 14, 2026
9a82ec0
fix: use correct computation to determine instersection percentage be…
shadowusr Jun 14, 2026
636304b
fix: revert introducing synthetic capture specs for fixed-positioned …
shadowusr Jun 14, 2026
6ac39b9
feat: implement cropMargins options
shadowusr Jun 15, 2026
b3a9c31
chore: enable screenshot verbose logging only with TESTPLANE_DEBUG_SC…
shadowusr Jun 15, 2026
2b88b5b
fix: fix zero maxDelta non-renderable capture spec case during compos…
shadowusr Jun 15, 2026
0a4c880
test: fix unit tests
shadowusr Jun 15, 2026
02f97bc
fix: fix review issues
shadowusr Jun 15, 2026
db39348
fix!: set default tolerance value to 3 and ignoreDiffPixelCount to 4
shadowusr Jun 15, 2026
d1ef73e
docs: actualize screenshots dev readme
shadowusr Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions src/browser/camera/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import makeDebug from "debug";

import { Image } from "../../image";
import * as utils from "./utils";
import type { CropMargins } from "./utils";
import * as logger from "../../utils/logger";
import {
getIntersection,
Expand All @@ -20,12 +21,15 @@ import { NEW_ISSUE_LINK } from "../../constants/help";
const debug = makeDebug("testplane:screenshots:camera");

export type ScreenshotMode = "fullpage" | "viewport" | "auto";
export type { CropMargins } from "./utils";
Comment thread
shadowusr marked this conversation as resolved.

export interface CaptureViewportImageOpts {
viewportOffset: Point<"page", "device">;
viewportSize: Size<"device">;
/** Delay before taking the screenshot, in milliseconds. */
screenshotDelay?: number;
/** Additional raw screenshot margins to crop, in physical pixels. */
cropMargins?: CropMargins;
}

export class Camera {
Expand Down Expand Up @@ -82,15 +86,23 @@ export class Camera {
this._calibrationScreenshotSize.height === height;
const calibrationArea = shouldApplyCalibration ? this._calibratedArea : null;

const calibratedImageArea = this._cropAreaToCalibratedArea(imageArea, calibrationArea);
const calibratedImageArea = this._cropAreaToIntersection(imageArea, calibrationArea);
const cropMarginsArea = utils.cropMarginsToRect(imageArea, opts?.cropMargins);
const croppedImageArea = getIntersection(calibratedImageArea, cropMarginsArea);
if (croppedImageArea === null) {
throw new Error(
`Invalid cropMargins option: resulting screenshot crop area is empty. ` +
`imageSize: ${prettySize(imageArea)}, cropMargins: ${JSON.stringify(opts?.cropMargins)}`,
);
}

const viewportCroppedArea = this._cropAreaToViewport(
calibratedImageArea,
croppedImageArea,
{ width, height },
calibrationArea,
croppedImageArea,
opts,
);
await utils.saveViewportImageForDebugIfNeeded(image, calibratedImageArea, this._debugTmpDir);
await utils.saveViewportImageForDebugIfNeeded(image, croppedImageArea, this._debugTmpDir);

if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) {
await image.crop(viewportCroppedArea);
Expand All @@ -99,19 +111,19 @@ export class Camera {
return image;
}

private _cropAreaToCalibratedArea(
private _cropAreaToIntersection(
imageArea: Rect<"image", "device">,
calibrationArea: Rect<"image", "device"> | null,
cropArea: Rect<"image", "device"> | null,
): Rect<"image", "device"> {
if (!calibrationArea) {
if (!cropArea) {
return imageArea;
}

const intersection = getIntersection(imageArea, calibrationArea);
const intersection = getIntersection(imageArea, cropArea);
if (intersection === null) {
logger.warn(
`No intersection found between image area and calibrated viewport area, falling back to original image area.\n` +
`imageArea: ${prettyRect(imageArea)}, calibratedViewportArea: ${prettyRect(calibrationArea)}\n` +
`No intersection found between image area and crop area, falling back to original image area.\n` +
`imageArea: ${prettyRect(imageArea)}, cropArea: ${prettyRect(cropArea)}\n` +
`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.`,
);

Expand Down
45 changes: 44 additions & 1 deletion src/browser/camera/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import path from "path";
import fs from "fs";
import { ScreenshotMode } from ".";
import type { ScreenshotMode } from ".";
import { Image } from "../../image";
import { Rect, Size, getBottom } from "../isomorphic/geometry";
import { saveViewportImageWithDebugRects } from "../screen-shooter/composite-image/debug-utils";

export interface CropMargins {
top?: number;
right?: number;
bottom?: number;
left?: number;
}

type NormalizedCropMargins = Required<CropMargins>;

export const isFullPage = (
imageSize: Rect<"image", "device">,
viewportSize: Size<"device">,
Expand All @@ -24,6 +33,40 @@ export const isFullPage = (
}
};

export const normalizeCropMargins = (cropMargins?: CropMargins): NormalizedCropMargins => {
const result = {
top: cropMargins?.top ?? 0,
right: cropMargins?.right ?? 0,
bottom: cropMargins?.bottom ?? 0,
left: cropMargins?.left ?? 0,
};

for (const side of ["top", "right", "bottom", "left"] as const) {
const value = result[side];
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
throw new Error(
`Invalid cropMargins.${side} option: expected a non-negative integer, got ${String(value)}`,
);
}
}

return result;
};

export const cropMarginsToRect = (
imageArea: Rect<"image", "device">,
cropMargins?: CropMargins,
): Rect<"image", "device"> => {
const margins = normalizeCropMargins(cropMargins);

return {
top: margins.top,
left: margins.left,
width: imageArea.width - margins.left - margins.right,
height: imageArea.height - margins.top - margins.bottom,
} as Rect<"image", "device">;
};

export async function saveViewportImageForDebugIfNeeded(
viewportImage: Image,
viewportCroppedArea: Rect<"image", "device">,
Expand Down
19 changes: 11 additions & 8 deletions src/browser/client-scripts/screen-shooter/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
ScrollFullPageResult,
ScrollResult,
GetCaptureStateResult,
TrackedElementData
TrackedElementData,
ElementPositionsProbe
} from "./types";
import { createDebugLogger } from "../shared/logger";
import {
Expand Down Expand Up @@ -196,9 +197,7 @@ export function scrollBy(
: `${selectorToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})`
: `auto-detected ${readableAutoScrollElementDescr}`;

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

return {
Expand Down Expand Up @@ -271,6 +270,7 @@ export function getCaptureState(
const captureSpecsAfterCss = computeCaptureSpecs(selectorsToCapture, logger);
const captureSpecs = captureSpecsAfterCss.map(spec => ({
full: fromCssToDevice(roundCoords(spec.full), pixelRatio),
clip: fromCssToDevice(roundCoords(spec.clip), pixelRatio),
visible: fromCssToDevice(roundCoords(spec.visible), pixelRatio)
}));
const scrollOffset = computeScrollOffset(scrollElement);
Expand Down Expand Up @@ -326,8 +326,9 @@ export function prepareFullPageScreenshot(
}
}

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

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

const viewportOffset = computeViewportOffset();
const elementPositionsProbe = computeElementPositionsProbe().map(rect =>
rect ? fromCssToDevice(roundCoords(rect), pixelRatio) : null
const elementPositionsProbe: ElementPositionsProbe<"device">[] = computeElementPositionsProbe().map(
(rect: ElementPositionsProbe<"css">): ElementPositionsProbe<"device"> =>
rect ? { ...fromCssToDevice(roundCoords(rect), pixelRatio), elementDescr: rect.elementDescr } : null
);

return {
Expand Down Expand Up @@ -526,6 +528,7 @@ function prepareElementsScreenshotUnsafe(
ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)),
captureSpecs: captureSpecs.map(s => ({
full: fromCssToDevice(roundCoords(s.full), pixelRatio),
clip: fromCssToDevice(roundCoords(s.clip), pixelRatio),
visible: fromCssToDevice(roundCoords(s.visible), pixelRatio)
})),
viewportSize: fromCssToDevice(viewportSize, pixelRatio),
Expand Down
Loading
Loading