Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions src/browser/camera/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const debug = makeDebug("testplane:screenshots:camera");
export type ScreenshotMode = "fullpage" | "viewport" | "auto";
export type { CropMargins } from "./utils";

export interface ViewportImage extends Image {
/** Calibrated screenshot dimensions before applying user margins or cropping to the viewport. */
readonly uncroppedSize: Size<"device">;
}

export interface CaptureViewportImageOpts {
viewportOffset: Point<"page", "device">;
viewportSize: Size<"device">;
Expand Down Expand Up @@ -64,7 +69,7 @@ export class Camera {
this._calibrationScreenshotSize = screenshotSize;
}

async captureViewportImage(opts?: CaptureViewportImageOpts): Promise<Image> {
async captureViewportImage(opts?: CaptureViewportImageOpts): Promise<ViewportImage> {
if (opts?.screenshotDelay) {
await new Promise(resolve => setTimeout(resolve, opts.screenshotDelay));
}
Expand Down Expand Up @@ -108,7 +113,9 @@ export class Camera {
await image.crop(viewportCroppedArea);
}

return image;
return Object.assign(image, {
uncroppedSize: { width: calibratedImageArea.width, height: calibratedImageArea.height },
});
}

private _cropAreaToIntersection(
Expand Down
9 changes: 4 additions & 5 deletions src/browser/client-scripts/screen-shooter/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,6 @@ export function prepareElementsScreenshot(
return safeCall(prepareElementsScreenshotUnsafe, targetsToCapture, opts);
}

export function getCurrentPixelRatio(): number {
return computePixelRatio();
}

export function scrollBy(
targetsToCapture: ElementTarget[],
scrollDelta: Length<"device", "y"> | Coord<"page", "device", "y">,
Expand Down Expand Up @@ -262,9 +258,11 @@ export function scrollTo(

function getViewportState(usePixelRatio?: boolean, pixelRatioOverride?: number): ViewportState {
const pixelRatio = computePixelRatio(usePixelRatio, pixelRatioOverride);
const viewportSizeInCss = computeViewportSize();

return {
viewportSize: fromCssToDevice(computeViewportSize(), pixelRatio),
viewportSize: fromCssToDevice(viewportSizeInCss, pixelRatio),
viewportSizeInCss,
viewportOffset: fromCssToDevice(floorCoords(computeViewportOffset()), pixelRatio),
documentSize: ceilCoords(fromCssToDevice(computeDocumentSize(), pixelRatio)),
pixelRatio
Expand Down Expand Up @@ -378,6 +376,7 @@ export function prepareFullPageScreenshot(
return {
documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)),
viewportSize: fromCssToDevice(viewportSize, pixelRatio),
viewportSizeInCss: viewportSize,
viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio),
safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio),
elementPositionsProbe,
Expand Down
1 change: 1 addition & 0 deletions src/browser/client-scripts/screen-shooter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface TrackedElementData {

export interface ViewportState {
viewportSize: Size<"device">;
viewportSizeInCss: Size<"css">;
viewportOffset: Point<"page", "device">;
documentSize: Size<"device">;
pixelRatio: number;
Expand Down
48 changes: 18 additions & 30 deletions src/browser/screen-shooter/elements-screen-shooter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import makeDebug from "debug";
import { CompositeImage } from "./composite-image";
import { Image } from "../../image";
import { assertCorrectCaptureAreaBounds } from "./validation";
import { assertCorrectCaptureAreaBounds, assertPixelRatio } from "./validation";
import { PixelRatioChangeError } from "./errors/pixel-ratio-change-error";
import type { AssertViewOpts } from "../../config/types";
import { runWithoutHistory } from "../history";
import {
Expand Down Expand Up @@ -34,13 +35,6 @@ class CaptureAreaSizeChangeError extends Error {
}
}

class PixelRatioChangeError extends Error {
constructor() {
super("Estimated pixel ratio did not match actual pixel ratio during capture");
this.name = "PixelRatioChangeError";
}
}

const debug = makeVerboseScreenshotsDebug("testplane:screenshots:elements-screen-shooter");
const SCROLL_OVERLAP_PX = 1;
const formatDuration = (duration: number): string => `${duration.toFixed(1)}ms`;
Expand All @@ -64,7 +58,7 @@ interface CaptureAttemptParams {
page: PrepareScreenshotSuccess;
opts: ScreenShooterOpts;
isStrictAttempt: boolean;
shouldCheckPixelRatio: boolean;
shouldValidatePixelRatio: boolean;
}

interface ScreenShooterBrowserProperties {
Expand Down Expand Up @@ -250,7 +244,7 @@ export class ElementsScreenShooter {
throw new Error("No targets to capture passed to ElementsScreenShooter.capture");
}

// Important to fix a bug with DPR, see test in dpr-off.testplane.js, which reproduces the bug with DPR
// OOPIFs can corrupt window.devicePixelRatio; validate against the screenshot instead.
const shouldValidatePixelRatio =
this._browserProperties.shouldUsePixelRatio &&
!this._browserProperties.isHeadless &&
Expand Down Expand Up @@ -300,7 +294,7 @@ export class ElementsScreenShooter {
page,
opts,
isStrictAttempt: true,
shouldCheckPixelRatio: shouldValidatePixelRatio,
shouldValidatePixelRatio,
});
} catch (error) {
if (!(error instanceof CaptureAreaSizeChangeError) && !(error instanceof PixelRatioChangeError)) {
Expand All @@ -310,7 +304,9 @@ export class ElementsScreenShooter {
perfDebug(`capture: retrying in best-effort mode (${error.message})`);

if (opts.pixelRatioOverride !== undefined || error instanceof PixelRatioChangeError) {
delete opts.pixelRatioOverride;
if (error instanceof PixelRatioChangeError) {
opts.pixelRatioOverride = error.pixelRatio;
}
Object.assign(page, await this._getCaptureState(targetsToCapture, targetsToIgnore, opts));
}

Expand All @@ -324,7 +320,7 @@ export class ElementsScreenShooter {
page,
opts,
isStrictAttempt: false,
shouldCheckPixelRatio: false,
shouldValidatePixelRatio: false,
});
}

Expand Down Expand Up @@ -655,7 +651,7 @@ export class ElementsScreenShooter {
page,
opts,
isStrictAttempt,
shouldCheckPixelRatio,
shouldValidatePixelRatio,
}: CaptureAttemptParams): Promise<CompositeImage> {
const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId);
const attemptMode = isStrictAttempt ? "strict" : "best-effort";
Expand Down Expand Up @@ -702,7 +698,7 @@ export class ElementsScreenShooter {
spec.full.height !== currentState.captureSpecs[index]?.full.height,
);

if (hasCaptureAreaSizeChanged && isStrictAttempt && !shouldCheckPixelRatio) {
if (hasCaptureAreaSizeChanged && isStrictAttempt && !shouldValidatePixelRatio) {
debug(
"capture area size changed, will retry capture attempt. Last state: %O, current state: %O",
lastState.captureSpecs,
Expand All @@ -721,28 +717,20 @@ export class ElementsScreenShooter {
const captureStartTime = performance.now();

const viewportImage = await this._camera.captureViewportImage({
viewportSize: page.viewportSize,
viewportSize: currentState.viewportSize,
viewportOffset: currentState.viewportOffset,
screenshotDelay: opts.screenshotDelay,
cropMargins: opts.cropMargins,
});

timeSpentOnCapture += performance.now() - captureStartTime;

if (shouldCheckPixelRatio) {
const currentPixelRatio = await this._browserSideScreenshooter.call("getCurrentPixelRatio", []);

if (currentPixelRatio !== currentState.pixelRatio) {
debug(
"expected pixel ratio %d did not match actual %d, retrying capture attempt",
currentState.pixelRatio,
currentPixelRatio,
);

throw new PixelRatioChangeError();
} else {
debug("pixel ratio %d matched expected %d", currentPixelRatio, currentState.pixelRatio);
}
if (shouldValidatePixelRatio) {
assertPixelRatio(
viewportImage.uncroppedSize,
currentState.viewportSize,
currentState.viewportSizeInCss,
);
}

if (hasCaptureAreaSizeChanged && isStrictAttempt) {
Expand Down
6 changes: 6 additions & 0 deletions src/browser/screen-shooter/errors/pixel-ratio-change-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class PixelRatioChangeError extends Error {
constructor(readonly pixelRatio: number) {
super("Estimated pixel ratio did not match actual pixel ratio during capture");
this.name = "PixelRatioChangeError";
}
}
33 changes: 32 additions & 1 deletion src/browser/screen-shooter/validation/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import { AssertViewOpts } from "../../../config/types";
import { getVerticalOverflowErrorMessage } from "../errors/vertical-overflow-error";
import { getHorizontalOverflowErrorMessage } from "../errors/horizontal-overflow-error";
import { PixelRatioChangeError } from "../errors/pixel-ratio-change-error";
import * as logger from "../../../utils/logger";
import { Point, Rect, Size, getCoveringRect } from "../../isomorphic";
import { Point, Rect, Size, getCoveringRect, prettySize } from "../../isomorphic";

export const assertPixelRatio = (
imageSize: Size<"device">,
viewportSize: Size<"device">,
viewportSizeInCss: Size<"css">,
): void => {
// Allow rounding differences between CSS geometry and the captured bitmap.
if (Math.abs(imageSize.width - viewportSize.width) <= 1 && Math.abs(imageSize.height - viewportSize.height) <= 1) {
return;
}

const pixelRatio = imageSize.width / viewportSizeInCss.width;
if (Math.abs(imageSize.height - viewportSizeInCss.height * pixelRatio) > 1) {
throw new Error(
`Screenshot dimensions do not match the viewport at a consistent pixel ratio. ` +
`imageSize: ${prettySize(imageSize)}, viewportSize: ${prettySize(viewportSize)}`,
);
}

const roundedPixelRatio = Math.round(pixelRatio);
const PIXEL_RATIO_EPSILON = 0.001;
// Compare bounds directly to avoid subtraction rounding at the tolerance boundary.
const shouldSnap =
roundedPixelRatio > 0 &&
pixelRatio >= roundedPixelRatio - PIXEL_RATIO_EPSILON &&
pixelRatio <= roundedPixelRatio + PIXEL_RATIO_EPSILON;
const finalPixelRatio = shouldSnap ? roundedPixelRatio : pixelRatio;

throw new PixelRatioChangeError(finalPixelRatio);
};

const isOutsideOfViewportHorizontally = (viewport: Size<"device">, cropArea: Rect<"viewport", "device">): boolean =>
cropArea.left < 0 || cropArea.left + cropArea.width > viewport.width;
Expand Down
34 changes: 20 additions & 14 deletions src/browser/screen-shooter/viewport-screen-shooter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Image } from "../../image";
import type { DisableHoverMode } from "../isomorphic/types";
import type { WdioBrowser } from "../../types";
import { Camera, type CropMargins } from "../camera";
import { Camera, type CropMargins, type ViewportImage } from "../camera";
import { assertPixelRatio } from "./validation";
import { PixelRatioChangeError } from "./errors/pixel-ratio-change-error";
import type * as browserSideScreenshooterImplementation from "../client-scripts/screen-shooter/implementation";
import { ClientBridge } from "../client-bridge";
import { isBrowserSideError } from "../isomorphic/types";
Expand Down Expand Up @@ -142,27 +144,31 @@ export class ViewportScreenShooter {

debug("Capturing viewport screenshot.\n viewportSize: %O\n viewportOffset: %O", viewportSize, viewportOffset);

const image = await this._camera.captureViewportImage({
viewportSize,
viewportOffset,
screenshotDelay: opts.screenshotDelay,
cropMargins: opts.cropMargins,
});

const shouldCheckPixelRatio =
!isRetry &&
this._browserProperties.shouldUsePixelRatio &&
!this._browserProperties.isHeadless &&
this._browserProperties.isPixelRatioEmulated;

if (shouldCheckPixelRatio) {
const currentPixelRatio = await this._browserSideScreenshooter.call("getCurrentPixelRatio", []);

if (currentPixelRatio !== prepareResult.pixelRatio) {
delete opts.pixelRatioOverride;
let image: ViewportImage;
try {
image = await this._camera.captureViewportImage({
viewportSize,
viewportOffset,
screenshotDelay: opts.screenshotDelay,
cropMargins: opts.cropMargins,
});

if (shouldCheckPixelRatio) {
assertPixelRatio(image.uncroppedSize, viewportSize, prepareResult.viewportSizeInCss);
}
} catch (error) {
if (error instanceof PixelRatioChangeError && !isRetry) {
opts.pixelRatioOverride = error.pixelRatio;

return this._captureImpl(opts, true);
}

throw error;
}

if (prepareResult.ignoreAreas.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("prepareViewportScreenshot in high pixel ratio mode", () => {
const result = getPrepareResult({ usePixelRatio: true });

expect(result.pixelRatio).toBe(3);
expect(result.viewportSizeInCss).toEqual(cssViewportSize);
expect(result.viewportSize.width).toBe((cssViewportSize.width as number) * 3);
expect(result.viewportSize.height).toBe((cssViewportSize.height as number) * 3);
expect(result.viewportOffset.left).toBe(Math.floor(cssViewportOffset.left as number) * 3);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 35 additions & 1 deletion test/e2e/tests/dpr-oopif.testplane.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global window */
/* global document, window */

describe("assertView with DPR and OOPIFs", () => {
it("should capture the whole bordered element after nested cross-site iframes attach", async ({ browser }) => {
Expand All @@ -15,4 +15,38 @@ describe("assertView with DPR and OOPIFs", () => {

await browser.assertView("bordered-block", "[data-testid=capture-target]");
});

it("should capture the whole bordered element when DPR changes after taking the screenshot", async ({
browser,
}) => {
await browser.url("http://localhost:3000/dpr-oopif.html");

expect(await browser.execute(() => window.devicePixelRatio)).toBe(3);

const originalTakeScreenshot = browser.takeScreenshot.bind(browser);
let shouldAttachIframes = true;

browser.overwriteCommand("takeScreenshot", async () => {
const screenshot = await originalTakeScreenshot();

if (shouldAttachIframes) {
shouldAttachIframes = false;
// Reproduce the race between taking the screenshot and validating its pixel ratio.
await browser.execute(() => document.querySelector("#attach").click());
await browser.waitUntil(async () => (await browser.execute(() => window.devicePixelRatio)) === 1, {
timeout: 10000,
interval: 50,
timeoutMsg: "Nested cross-site iframes did not change devicePixelRatio to 1",
});
}

return screenshot;
});

try {
await browser.assertView("bordered-block", "[data-testid=capture-target]");
} finally {
browser.overwriteCommand("takeScreenshot", async () => originalTakeScreenshot());
}
});
});
22 changes: 21 additions & 1 deletion test/src/browser/camera/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,27 @@ describe("browser/camera", () => {

Image.fromBase64.withArgs({ foo: "bar" }).returns(image);

return assert.becomes(camera.captureViewportImage(), image);
return camera.captureViewportImage().then(result => {
assert.strictEqual(result, image);
assert.deepEqual(result.uncroppedSize, { width: 100500, height: 500100 });
});
});

it("should expose calibrated dimensions before user margins and viewport cropping", async () => {
image.getSize.returns({ width: 390, height: 884 });
const camera = Camera.create("fullpage", sinon.stub().resolves());
camera.calibrate({ left: 0, top: 20, width: 390, height: 844 }, { width: 390, height: 884 });
isFullPageStub.returns(true);

const result = await camera.captureViewportImage({
viewportOffset: { left: 0, top: 20 },
viewportSize: { width: 130, height: 200 },
cropMargins: { left: 10 },
});

assert.strictEqual(result, image);
assert.deepEqual(result.uncroppedSize, { width: 390, height: 844 });
assert.calledOnceWithExactly(image.crop, { left: 10, top: 20, width: 120, height: 200 });
});

describe("crop", () => {
Expand Down
Loading
Loading