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 @@ -17,6 +17,11 @@ export interface PageMeta {
documentWidth: number;
}

export interface ViewportImage extends Image {
/** Calibrated screenshot dimensions before cropping to the viewport. */
readonly uncroppedSize: Pick<ImageArea, "width" | "height">;
}

interface Calibration {
left: number;
top: number;
Expand All @@ -41,7 +46,7 @@ export class Camera {
this._calibration = calibration;
}

async captureViewportImage(page?: PageMeta): Promise<Image> {
async captureViewportImage(page?: PageMeta): Promise<ViewportImage> {
const base64 = await this._takeScreenshot();
const image = Image.fromBase64(base64);

Expand All @@ -55,7 +60,9 @@ export class Camera {
await image.crop(viewportCroppedArea);
}

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

private _calibrateArea(imageArea: ImageArea): ImageArea {
Expand Down
1 change: 1 addition & 0 deletions src/browser/client-scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function prepareScreenshotUnsafe(areas, opts) {
})
.scale(pixelRatio)
.serialize(),
viewportSizeInCss: { width: viewportWidth, height: viewportHeight },
documentHeight: Math.ceil(documentHeight * pixelRatio),
documentWidth: Math.ceil(documentWidth * pixelRatio),
canHaveCaret: isEditable(document.activeElement),
Expand Down
37 changes: 27 additions & 10 deletions src/browser/screen-shooter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,15 @@ module.exports = class ScreenShooter {
}

async capture(page, opts = {}) {
const {
allowViewportOverflow,
compositeImage,
screenshotDelay,
selectorToScroll,
preferredPixelRatio,
reprepareScreenshot,
} = opts;
const { allowViewportOverflow, compositeImage, screenshotDelay, selectorToScroll, reprepareScreenshot } = opts;
const viewportOpts = { allowViewportOverflow, compositeImage };
const cropImageOpts = { screenshotDelay, compositeImage, selectorToScroll };

const capturedImage = await this._browser.captureViewportImage(page, screenshotDelay);
if (reprepareScreenshot) {
const currentPixelRatio = await this._browser.evalScript("window.devicePixelRatio");
const currentPixelRatio = getPixelRatioFromImage(capturedImage.uncroppedSize, page);

if (currentPixelRatio !== (preferredPixelRatio ?? page.pixelRatio)) {
if (currentPixelRatio !== undefined) {
Object.assign(page, await reprepareScreenshot(currentPixelRatio));
delete opts.preferredPixelRatio;
delete opts.reprepareScreenshot;
Expand Down Expand Up @@ -67,3 +60,27 @@ module.exports = class ScreenShooter {
await viewport.extendBy(physicalScrollHeight, newImage);
}
};

function getPixelRatioFromImage(imageSize, page) {
// Allow rounding differences between CSS geometry and the captured bitmap.
if (
Math.abs(imageSize.width - page.viewport.width) <= 1 &&
Math.abs(imageSize.height - page.viewport.height) <= 1
) {
return;
}

const pixelRatio = imageSize.width / page.viewportSizeInCss.width;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match full-page images against document dimensions

When pixel-ratio validation runs with screenshotMode: "fullpage" (or auto detects a full-page bitmap), capturedImage.uncroppedSize contains the entire document because Camera.captureViewportImage records it before cropping. Dividing that width by the CSS viewport width therefore interprets document extent as DPR; depending on the document aspect ratio, this either throws at the following check or reparses the page with a grossly inflated ratio, breaking assertView for this supported configuration. The calculation must use dimensions corresponding to the captured image or retain whether the source bitmap was full-page.

Useful? React with 👍 / 👎.

if (Math.abs(imageSize.height - page.viewportSizeInCss.height * pixelRatio) > 1) {
throw new Error("Screenshot dimensions do not match the viewport at a consistent pixel ratio");
Comment on lines +73 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for independent fractional-DPR rounding

When the actual screenshot DPR is fractional and differs from the prepared estimate, bitmap width and height are rounded independently, so deriving an exact ratio from width and allowing only one pixel of height error rejects valid images. For example, the repository's Rect.scale rounding maps a 101×201 CSS viewport at DPR 2.625 to 266×528; this code infers 266 / 101 and predicts a height of about 529.37, causing the valid screenshot to throw instead of triggering re-preparation. Compare compatible DPR intervals from both axes or otherwise include the maximum rounding error induced by the viewport aspect ratio.

Useful? React with 👍 / 👎.

}

const roundedPixelRatio = Math.round(pixelRatio);
const epsilon = 0.001;

return roundedPixelRatio > 0 &&
pixelRatio >= roundedPixelRatio - epsilon &&
pixelRatio <= roundedPixelRatio + epsilon
? roundedPixelRatio
: pixelRatio;
}
13 changes: 13 additions & 0 deletions test/src/browser/camera/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ describe("browser/camera", () => {
return assert.becomes(camera.captureViewportImage(), image);
});

it("should expose calibrated dimensions before cropping to the viewport", async () => {
const camera = Camera.create("viewport", sinon.stub().resolves());
image.getSize.returns({ width: 1204, height: 2406 });
camera.calibrate({ left: 4, top: 6 });

const result = await camera.captureViewportImage({
viewport: { left: 0, top: 0, width: 400, height: 800 },
});

assert.deepEqual(result.uncroppedSize, { width: 1200, height: 2400 });
assert.calledOnceWith(image.crop, { left: 4, top: 6, width: 400, height: 800 });
});

describe("crop", () => {
describe("calibration", () => {
it("should apply calibration on taken screenshot", async () => {
Expand Down
18 changes: 18 additions & 0 deletions test/src/browser/commands/assert-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,24 @@ describe("assertView command", () => {
);
});

[{ args: ["--headless=new"], mobileEmulation: { deviceMetrics: { pixelRatio: 3 } } }, { args: [] }].forEach(
chromeOptions => {
it(`should not validate pixel ratio without headful mobile emulation: ${JSON.stringify(
chromeOptions,
)}`, async () => {
const session = mkSessionStub_();
session.requestedCapabilities = { "goog:chromeOptions": chromeOptions };
const browser = await initBrowser_({ session });

await browser.publicAPI.assertView("plain", ".selector");

const screenShooterOpts = ScreenShooter.prototype.capture.lastCall.args[1];
assert.notProperty(screenShooterOpts, "preferredPixelRatio");
assert.notProperty(screenShooterOpts, "reprepareScreenshot");
});
},
);

it("should screenshot the viewport if selector is not provided", async () => {
const browser = await initBrowser_();

Expand Down
114 changes: 101 additions & 13 deletions test/src/browser/screen-shooter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("screen-shooter", () => {
const capture = (page, opts) => ScreenShooter.create(browser).capture(stubPage(page), opts);

beforeEach(() => {
imageStub.uncroppedSize = { width: 300, height: 600 };
browser = {
config: {},
captureViewportImage: sandbox.stub().resolves(imageStub),
Expand Down Expand Up @@ -95,33 +96,34 @@ describe("screen-shooter", () => {
assert.calledWithMatch(browser.captureViewportImage, sinon.match.any, 2000);
});

it("should retry capture using pixel ratio from browser if it differs from prepared", async () => {
it("should retry using the screenshot pixel ratio even if the browser reports another value", async () => {
const preparedPage = {
captureArea: { left: 1, top: 2, width: 10, height: 20 },
viewport: { left: 0, top: 0, width: 100, height: 200 },
ignoreAreas: [{ left: 3, top: 4, width: 5, height: 6 }],
documentHeight: 300,
documentWidth: 200,
captureArea: { left: 3, top: 6, width: 30, height: 60 },
viewport: { left: 0, top: 0, width: 300, height: 600 },
viewportSizeInCss: { width: 100, height: 200 },
ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }],
documentHeight: 900,
documentWidth: 600,
pixelRatio: 3,
};
const reprepareScreenshot = sandbox.stub().resolves(preparedPage);
const opts = { reprepareScreenshot };
browser.evalScript.resolves(3);

await capture(
{
captureArea: { left: 3, top: 6, width: 30, height: 60 },
viewport: { left: 0, top: 0, width: 300, height: 600 },
ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }],
documentHeight: 900,
documentWidth: 600,
captureArea: { left: 1, top: 2, width: 10, height: 20 },
viewport: { left: 0, top: 0, width: 100, height: 200 },
viewportSizeInCss: { width: 100, height: 200 },
ignoreAreas: [{ left: 3, top: 4, width: 5, height: 6 }],
documentHeight: 300,
documentWidth: 200,
pixelRatio: 1,
},
opts,
);

assert.calledTwice(browser.captureViewportImage);
assert.calledOnceWith(browser.evalScript, "window.devicePixelRatio");
assert.notCalled(browser.evalScript);
assert.calledOnceWith(reprepareScreenshot, 3);
assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any);
assert.notProperty(opts, "preferredPixelRatio");
Expand All @@ -132,18 +134,21 @@ describe("screen-shooter", () => {
const preparedPage = {
captureArea: { left: 1, top: 2, width: 10, height: 20 },
viewport: { left: 50, top: 0, width: 101, height: 201 },
viewportSizeInCss: { width: 101, height: 201 },
ignoreAreas: [{ left: 3, top: 4, width: 6, height: 7 }],
documentHeight: 201,
documentWidth: 101,
pixelRatio: 1,
};
const reprepareScreenshot = sandbox.stub().resolves(preparedPage);
const opts = { preferredPixelRatio: 2.625, reprepareScreenshot };
imageStub.uncroppedSize = { width: 101, height: 201 };

await capture(
{
captureArea: { left: 3, top: 6, width: 28, height: 54 },
viewport: { left: 131, top: 0, width: 266, height: 528 },
viewportSizeInCss: { width: 101, height: 201 },
ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }],
documentHeight: 528,
documentWidth: 266,
Expand All @@ -156,6 +161,89 @@ describe("screen-shooter", () => {
assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any);
});

it("should retain the capabilities estimate without a browser round trip when the image matches", async () => {
const reprepareScreenshot = sandbox.stub();

await capture(
{ viewport: { width: 300, height: 600 }, pixelRatio: 3 },
{ preferredPixelRatio: 3, reprepareScreenshot },
);

assert.calledOnce(browser.captureViewportImage);
assert.notCalled(browser.evalScript);
assert.notCalled(reprepareScreenshot);
});

it("should allow a one-pixel rounding difference in screenshot dimensions", async () => {
const reprepareScreenshot = sandbox.stub();

await capture({ viewport: { width: 301, height: 599 }, pixelRatio: 3 }, { reprepareScreenshot });

assert.notCalled(reprepareScreenshot);
});

it("should finish the retry in best-effort mode if the screenshot scale changes again", async () => {
const reprepareScreenshot = sandbox.stub().resolves({
viewport: { width: 300, height: 600 },
pixelRatio: 3,
});
const secondImage = sinon.createStubInstance(Image);
secondImage.uncroppedSize = { width: 200, height: 400 };
browser.captureViewportImage.onSecondCall().resolves(secondImage);

await capture(
{ viewport: { width: 100, height: 200 }, viewportSizeInCss: { width: 100, height: 200 } },
{ reprepareScreenshot },
);

assert.calledOnceWith(reprepareScreenshot, 3);
assert.calledTwice(browser.captureViewportImage);
assert.calledOnceWith(Viewport.prototype.handleImage, secondImage);
});

it("should reject screenshot dimensions that do not indicate a uniform scale", async () => {
imageStub.uncroppedSize = { width: 300, height: 800 };
const reprepareScreenshot = sandbox.stub();

await assert.isRejected(
capture(
{ viewport: { width: 100, height: 200 }, viewportSizeInCss: { width: 100, height: 200 } },
{ reprepareScreenshot },
),
"Screenshot dimensions do not match the viewport at a consistent pixel ratio",
);

assert.notCalled(reprepareScreenshot);
});

[
[1.999999, 2],
[2.000001, 2],
[1.999, 2],
[2.001, 2],
[4.999, 5],
[5.001, 5],
[1.9989, 1.9989],
[2.0011, 2.0011],
[1.75, 1.75],
[0.0005, 0.0005],
].forEach(([actual, expected]) => {
it(`should infer screenshot pixel ratio ${actual} as ${expected}`, async () => {
imageStub.uncroppedSize = { width: actual * 1000000, height: actual * 2000000 };
const reprepareScreenshot = sandbox.stub().resolves({ pixelRatio: expected });

await capture(
{
viewport: { width: 1000000, height: 2000000 },
viewportSizeInCss: { width: 1000000, height: 2000000 },
},
{ reprepareScreenshot },
);

assert.calledOnceWith(reprepareScreenshot, expected);
});
});

it("should extract image of passed size", async () => {
await capture({ captureArea: { foo: "bar" } });

Expand Down