Skip to content
Merged
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
34 changes: 20 additions & 14 deletions src/browser/calibrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from "path";
import looksSame from "looks-same";
import { CoreError } from "./core-error";
import { ExistingBrowser } from "./existing-browser";
import type { Image } from "../image";
import type { Image, RGB } from "../image";
import { Coord, Length, Rect, Size, XBand, getHeight, getIntersection, getWidth } from "./isomorphic";
import * as logger from "../utils/logger";
import os from "node:os";
Expand Down Expand Up @@ -46,8 +46,11 @@ export class Calibrator {

const { innerWidth, pixelRatio } = features;
const hasPixelRatio = Boolean(pixelRatio && pixelRatio > 1.0);
const screenshotSize = (await image.getSize()) as Size<"device">;
const imageFeatures = await this._findMarkerAreaInImage(image);
const screenshotSize = image.getSize() as Size<"device">;
const searchColor: RGB = image.hasICCPChunk
? await image.getRGB(Math.floor(screenshotSize.width / 2), Math.floor(screenshotSize.height / 2))
: { R: 148, G: 250, B: 0 };
const imageFeatures = await this._findMarkerAreaInImage(image, searchColor);

if (!imageFeatures) {
const screenshotPath = path.join(os.tmpdir(), "testplane-calibration-page.png");
Expand All @@ -73,13 +76,13 @@ export class Calibrator {
return calibratedFeatures;
}

private async _findMarkerAreaInImage(image: Image): Promise<Rect<"image", "device"> | null> {
const imageHeight = (await image.getSize()).height;
private async _findMarkerAreaInImage(image: Image, searchColor: RGB): Promise<Rect<"image", "device"> | null> {
const imageHeight = image.getSize().height;

let topPart: Rect<"image", "device"> | null = null;

for (let y = 0 as Coord<"image", "device", "y">; y < imageHeight; y++) {
const result = await findMarkerXBandInRow(y, image);
const result = await findMarkerXBandInRow(y, image, searchColor);
if (result) {
topPart = {
top: y,
Expand All @@ -96,7 +99,7 @@ export class Calibrator {
}

for (let y = (imageHeight - 1) as Coord<"image", "device", "y">; y >= 0; y--) {
const result = await findMarkerXBandInRow(y, image);
const result = await findMarkerXBandInRow(y, image, searchColor);
if (result) {
const bottomPart = {
top: 0,
Expand All @@ -116,14 +119,15 @@ export class Calibrator {
async function findMarkerXBandInRow(
row: Coord<"image", "device", "y">,
image: Image,
searchColor: RGB,
): Promise<XBand<"image", "device"> | null> {
const markerStart = await findMarkerStartInRow(row, image);
const markerStart = await findMarkerStartInRow(row, image, searchColor);

if (markerStart === null) {
return null;
}

const markerEnd = await findMarkerEndInRow(row, image);
const markerEnd = await findMarkerEndInRow(row, image, searchColor);

if (markerEnd === null) {
return null;
Expand All @@ -139,8 +143,8 @@ async function isMarkerColorAtPoint(
image: Image,
x: Coord<"image", "device", "x">,
y: Coord<"image", "device", "y">,
searchColor: RGB,
): Promise<boolean> {
const searchColor = { R: 148, G: 250, B: 0 };
const color = await image.getRGB(x, y);

return looksSame.colors(color, searchColor);
Expand All @@ -149,11 +153,12 @@ async function isMarkerColorAtPoint(
async function findMarkerStartInRow(
row: Coord<"image", "device", "y">,
image: Image,
searchColor: RGB,
): Promise<Coord<"image", "device", "x"> | null> {
const imageWidth = (await image.getSize()).width;
const imageWidth = image.getSize().width;

for (let x = 0 as Coord<"image", "device", "x">; x < imageWidth; x++) {
if (await isMarkerColorAtPoint(image, x, row)) {
if (await isMarkerColorAtPoint(image, x, row, searchColor)) {
return x;
}
}
Expand All @@ -164,11 +169,12 @@ async function findMarkerStartInRow(
async function findMarkerEndInRow(
row: Coord<"image", "device", "y">,
image: Image,
searchColor: RGB,
): Promise<Coord<"image", "device", "x"> | null> {
const imageWidth = (await image.getSize()).width;
const imageWidth = image.getSize().width;

for (let x = (imageWidth - 1) as Coord<"image", "device", "x">; x >= 0; x--) {
if (await isMarkerColorAtPoint(image, x, row)) {
if (await isMarkerColorAtPoint(image, x, row, searchColor)) {
return x;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/browser/camera/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class Camera {
const base64 = await this._takeScreenshot();
const image = Image.fromBase64(base64);

const { width, height } = (await image.getSize()) as Size<"device">;
const { width, height } = image.getSize() as Size<"device">;
const imageArea: Rect<"image", "device"> = {
left: 0 as Coord<"image", "device", "x">,
top: 0 as Coord<"image", "device", "y">,
Expand Down
2 changes: 1 addition & 1 deletion src/browser/commands/assert-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ module.exports.default = browser => {
const { tempOpts } = RuntimeConfig.getInstance();
temp.attach(tempOpts);

const currSize = await currImgInst.getSize();
const currSize = currImgInst.getSize();
const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize };

const refImgAbsolutePath = config.getScreenshotPath(test, state);
Expand Down
2 changes: 1 addition & 1 deletion src/browser/screen-shooter/composite-image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class CompositeImage {
throw new Error("Capture area size cannot be zero or negative. Got: " + prettySize(this._captureAreaSize));
}

const imageSize = (await viewportImage.getSize()) as Size<"device">;
const imageSize = viewportImage.getSize() as Size<"device">;

debug(
"Captured the next chunk.\n captureSpecs: %O\n visibleCoveringRect: %O\n ignoreBoundingRects: %O\n viewportImageSize: %O",
Expand Down
36 changes: 34 additions & 2 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import looksSame from "looks-same";
import { loadEsm } from "./utils/preload-utils";
import { DiffOptions, ImageSize } from "./types";
import { convertRgbaToPng } from "./utils/eight-bit-rgba-to-png";
import { BITS_IN_BYTE, PNG_HEIGHT_OFFSET, PNG_WIDTH_OFFSET, RGBA_CHANNELS } from "./constants/png";
import { BITS_IN_BYTE, PNG_HEIGHT_OFFSET, PNG_SIGNATURE, PNG_WIDTH_OFFSET, RGBA_CHANNELS } from "./constants/png";

interface PngImageData {
data: Buffer;
Expand Down Expand Up @@ -61,6 +61,32 @@ const jsquashDecode = (buffer: ArrayBuffer): Promise<ImageData> => {
]).then(([mod]) => mod.decode(buffer, { bitDepth: BITS_IN_BYTE }));
};

const hasICCPChunk = (buffer: Buffer): boolean => {
const auxChunkSizeBytes = 12; // 4 bytes for length, 4 bytes for type, 4 bytes for crc
const iCCPChunkType = Buffer.from("iCCP", "ascii").readUInt32BE(0);
const IDATChunkType = Buffer.from("IDAT", "ascii").readUInt32BE(0);
const PLTEChunkType = Buffer.from("PLTE", "ascii").readUInt32BE(0);

for (let nextChunkPointer = PNG_SIGNATURE.byteLength; nextChunkPointer <= buffer.length - auxChunkSizeBytes; ) {
const chunkLength = buffer.readUInt32BE(nextChunkPointer);
const chunkType = buffer.readUInt32BE(nextChunkPointer + 4);

if (chunkType === iCCPChunkType) {
return true;
}

// If the iCCP chunk appears, it must precede the first IDAT chunk, and it must also precede the PLTE chunk if present
// https://libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.iCCP
if (chunkType === IDATChunkType || chunkType === PLTEChunkType) {
return false;
}

nextChunkPointer += chunkLength + auxChunkSizeBytes;
}

return false;
};

export class Image {
private _imgDataPromise: Promise<Buffer | null>;
private _imgData: Buffer | null = null;
Expand All @@ -69,6 +95,7 @@ export class Image {
private _composeImages: this[] = [];
private _clearAreas: Rect[] = [];
private _decodeError: Error | null = null;
private _hasICCPChunk: boolean = false;

static create(buffer: Buffer): Image {
return new this(buffer);
Expand All @@ -80,6 +107,7 @@ export class Image {
if (Buffer.isBuffer(bufferOrSize)) {
this._width = bufferOrSize.readUInt32BE(PNG_WIDTH_OFFSET);
this._height = bufferOrSize.readUInt32BE(PNG_HEIGHT_OFFSET);
this._hasICCPChunk = hasICCPChunk(bufferOrSize);
this._imgDataPromise = jsquashDecode(bufferOrSize)
.then(({ data }) => {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
Expand All @@ -100,6 +128,10 @@ export class Image {
this._imgDataPromise = Promise.resolve(this._imgData);
}

public get hasICCPChunk(): boolean {
return this._hasICCPChunk;
}

async _getImgData(): Promise<Buffer> {
if (this._imgData) {
return this._imgData;
Expand All @@ -125,7 +157,7 @@ export class Image {
}
}

async getSize(): Promise<ImageSize> {
getSize(): ImageSize {
this._ensureImagesHaveSameWidth();

const height = this._composeImages.reduce((acc, img) => acc + img._height, this._height);
Expand Down
4 changes: 2 additions & 2 deletions src/worker/runner/test-runner/capture-fail-screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ async function captureScreenshot(browser: ExistingBrowser, config: ExistingBrows
},
});
const image = await screenshooter.capture();
const size = await image.getSize();
const size = image.getSize();
const buffer = await image.toPngBuffer({ resolveWithObject: false });

return { base64: buffer.toString("base64"), size };
}

const base64 = await browser.publicAPI.takeScreenshot();
const image = Image.fromBase64(base64);
const size = await image.getSize();
const size = image.getSize();

return { base64, size };
}
58 changes: 57 additions & 1 deletion test/src/browser/calibrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ describe("calibrator", () => {
const setScreenshot = imageName => {
const imgPath = path.join(__dirname, "..", "..", "fixtures", imageName);
const imgData = fs.readFileSync(imgPath);
const image = new Image(imgData);

browser.captureViewportImage.returns(Promise.resolve(new Image(imgData)));
browser.captureViewportImage.returns(Promise.resolve(image));

return image;
};

beforeEach(() => {
Expand Down Expand Up @@ -88,4 +91,57 @@ describe("calibrator", () => {

return assert.isRejected(calibrator.calibrate(browser), CoreError);
});

describe("when image has iCCP chunk", () => {
it("should use the color at the center of the image as the marker search color", async () => {
const image = setScreenshot("calibrate.png");
// Color profile shifts the rendered marker color away from the hardcoded green
const markerColor = { R: 50, G: 100, B: 150 };
const markerLeft = 4;
const markerTop = 4;
const markerRight = 6;
const markerBottom = 6;

image._hasICCPChunk = true;

sinon.stub(image, "getSize").returns({ width: 10, height: 10 });
sinon.stub(image, "getRGB").callsFake(async (x, y) => {
const insideMarker = x >= markerLeft && x <= markerRight && y >= markerTop && y <= markerBottom;

return insideMarker ? markerColor : { R: 0, G: 0, B: 0 };
});

const result = await calibrator.calibrate(browser);

// The center pixel (5, 5) lies inside the marker, so its (shifted) color is used to find the marker
assert.calledWith(image.getRGB, 5, 5);
assert.equal(result.viewportArea.top, markerTop);
assert.equal(result.viewportArea.left, markerLeft);
});
});

describe("when image does not have iCCP chunk", () => {
it("should use the hardcoded green color as the marker search color", async () => {
const image = setScreenshot("calibrate.png");
const greenColor = { R: 148, G: 250, B: 0 };
const markerLeft = 3;
const markerTop = 5;
const markerRight = 6;
const markerBottom = 6;

image._hasICCPChunk = false;

sinon.stub(image, "getSize").returns({ width: 10, height: 10 });
sinon.stub(image, "getRGB").callsFake(async (x, y) => {
const insideMarker = x >= markerLeft && x <= markerRight && y >= markerTop && y <= markerBottom;

return insideMarker ? greenColor : { R: 0, G: 0, B: 0 };
});

const result = await calibrator.calibrate(browser);

assert.equal(result.viewportArea.top, markerTop);
assert.equal(result.viewportArea.left, markerLeft);
});
});
});
6 changes: 3 additions & 3 deletions test/src/browser/camera/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("browser/camera", () => {
}).Camera;

image = sinon.createStubInstance(Image);
image.getSize.resolves({ width: 100500, height: 500100 });
image.getSize.returns({ width: 100500, height: 500100 });
image.crop.resolves();

sandbox.stub(Image, "fromBase64").returns(image);
Expand All @@ -43,7 +43,7 @@ describe("browser/camera", () => {
describe("calibration", () => {
it("should apply calibration on taken screenshot", async () => {
const camera = Camera.create(null, sinon.stub().resolves());
image.getSize.resolves({ width: 10, height: 10 });
image.getSize.returns({ width: 10, height: 10 });

camera.calibrate({ top: 6, left: 4, width: 10, height: 10 }, { width: 10, height: 10 });
await camera.captureViewportImage();
Expand All @@ -58,7 +58,7 @@ describe("browser/camera", () => {

it("should not apply calibration when screenshot size differs from calibration screenshot size", async () => {
const camera = Camera.create(null, sinon.stub().resolves());
image.getSize.resolves({ width: 20, height: 20 });
image.getSize.returns({ width: 20, height: 20 });

camera.calibrate({ top: 6, left: 4, width: 10, height: 10 }, { width: 10, height: 10 });
await camera.captureViewportImage();
Expand Down
12 changes: 6 additions & 6 deletions test/src/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,33 +151,33 @@ describe("Image", () => {
});

describe("getSize", () => {
it("should return size of single image", async () => {
it("should return size of single image", () => {
const buffer = createMockPngBuffer(100, 50);
const image = new Image(buffer);

const size = await image.getSize();
const size = image.getSize();

assert.deepEqual(size, { width: 100, height: 50 });
});

it("should return combined height for composed images", async () => {
it("should return combined height for composed images", () => {
const buffer = createMockPngBuffer(100, 50);
const image = new Image(buffer);
const attachedImage1 = new Image(createMockPngBuffer(100, 30));
const attachedImage2 = new Image(createMockPngBuffer(100, 20));
image._composeImages = [attachedImage1, attachedImage2];

const size = await image.getSize();
const size = image.getSize();

assert.deepEqual(size, { width: 100, height: 100 }); // 50 + 30 + 20
});

it("should ensure images have same width before calculating size", async () => {
it("should ensure images have same width before calculating size", () => {
const buffer = createMockPngBuffer(100, 50);
const image = new Image(buffer);
sandbox.spy(image, "_ensureImagesHaveSameWidth");

await image.getSize();
image.getSize();

assert.calledOnce(image._ensureImagesHaveSameWidth);
});
Expand Down
Loading