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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
-- Nullable with no default, so this is a catalogue-only change on Postgres 11+:
-- no table rewrite and no long lock, which matters on a TestRun table holding
-- a hundred builds' worth of runs. Existing rows keep NULL and fall back to
-- computing the signature on demand.
ALTER TABLE "TestRun" ADD COLUMN "changeSignature" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
-- Nullable with no default, so this is a catalogue-only change on Postgres 11+:
-- no table rewrite and no long lock. Existing rows keep NULL and the UI falls
-- back to the full-size image for them.
ALTER TABLE "TestRun" ADD COLUMN "imageThumbnailName" TEXT,
ADD COLUMN "diffThumbnailName" TEXT;
16 changes: 15 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
}

Expand Down Expand Up @@ -72,6 +72,20 @@ model TestRun {
ignoreAreas String @default("[]")
tempIgnoreAreas String @default("[]")
vlmDescription String?
// Position-independent colour signature of this run's change, as JSON, so the
// variations dialog can group a screen's locales without fetching and
// decoding every sibling's screenshots at review time. Written once, next to
// the diff that already decoded them. Null on runs that predate this, that
// have no diff, or that were compared by something other than pixelmatch —
// those fall back to computing it on demand.
changeSignature String?
// Small copies of the checkpoint and of the diff, made at ingest from the
// pixels the comparison had already decoded. The card grids draw pictures a
// hundred-odd pixels wide, and pulling the full-size files for that cost
// megabytes per screen. Null on runs ingested before this, which fall back to
// the full-size file.
imageThumbnailName String?
diffThumbnailName String?
baseline Baseline?
build Build @relation(fields: [buildId], references: [id])
project Project? @relation(fields: [projectId], references: [id])
Expand Down
3 changes: 3 additions & 0 deletions src/_data_/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export const generateTestRun = (testRun?: Partial<TestRun>): TestRun => {
branchName: 'develop',
merge: false,
vlmDescription: null,
changeSignature: null,
imageThumbnailName: null,
diffThumbnailName: null,
...testRun,
};
};
Expand Down
136 changes: 136 additions & 0 deletions src/compare/libs/pixelmatch/pixelmatch.core.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { PNG } from 'pngjs';
import { computePixelmatchDiff } from './pixelmatch.core';
import { computeChangeSignature } from './signature.core';
import { THUMBNAIL_MAX_DIMENSION } from './thumbnail.core';

const WIDTH = 60;
const HEIGHT = 60;

const png = (paint: (set: (x: number, y: number, rgb: [number, number, number]) => void) => void): Buffer => {
const image = new PNG({ width: WIDTH, height: HEIGHT });
for (let i = 0; i < WIDTH * HEIGHT; i++) {
image.data[i * 4] = 255;
image.data[i * 4 + 1] = 255;
image.data[i * 4 + 2] = 255;
image.data[i * 4 + 3] = 255;
}
paint((x, y, [r, g, b]) => {
const index = (y * WIDTH + x) * 4;
image.data[index] = r;
image.data[index + 1] = g;
image.data[index + 2] = b;
});
return PNG.sync.write(image);
};

const block = (left: number, top: number, rgb: [number, number, number]): Buffer =>
png((set) => {
for (let y = top; y < top + 12; y++) {
for (let x = left; x < left + 12; x++) {
set(x, y, rgb);
}
}
});

const blank = png(() => undefined);
const RED: [number, number, number] = [255, 0, 0];

const diffJob = (
baseline: Buffer,
image: Buffer,
withSignature: boolean,
withThumbnails = false,
{ saveDiff = true, diffTolerancePercent = 0 } = {}
) =>
computePixelmatchDiff({
kind: 'diff',
baseline,
image,
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
allowDiffDimensions: false,
diffTolerancePercent,
saveDiff,
withSignature,
withThumbnails,
});

describe('computePixelmatchDiff with a signature', () => {
// The whole point of computing it here is to reuse the decode the diff
// already paid for. If the fused version answered differently from the
// standalone one, stored signatures would stop matching computed ones and
// variations would silently stop grouping.
it('gives the same signature the standalone job would', () => {
const image = block(10, 10, RED);

const fused = diffJob(blank, image, true).signature;
const standalone = computeChangeSignature({
kind: 'signature',
baseline: blank,
image,
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
}).signature;

expect(fused).toEqual(standalone);
expect(fused).not.toBeNull();
});

it('leaves the signature out when it was not asked for', () => {
expect(diffJob(blank, block(10, 10, RED), false).signature).toBeUndefined();
});

it('has no signature when the screenshots are identical', () => {
expect(diffJob(blank, blank, true).signature).toBeUndefined();
});

it('still reports the diff it was asked for', () => {
const result = diffJob(blank, block(10, 10, RED), true);

expect(result.equal).toBe(false);
expect(result.pixelMisMatchCount).toBe(144);
});
});

describe('computePixelmatchDiff with thumbnails', () => {
// Made here from the pixels the diff has already decoded and already drawn,
// so a thumbnail costs a resize rather than a second read of the screenshot.
it('returns a small picture of both the checkpoint and the diff', () => {
const result = diffJob(blank, block(10, 10, RED), false, true);

const image = PNG.sync.read(Buffer.from(result.imageThumbnail));
const diff = PNG.sync.read(Buffer.from(result.diffThumbnail));
expect(Math.max(image.width, image.height)).toBeLessThanOrEqual(THUMBNAIL_MAX_DIMENSION);
expect(Math.max(diff.width, diff.height)).toBeLessThanOrEqual(THUMBNAIL_MAX_DIMENSION);
});

it('makes none when they were not asked for', () => {
const result = diffJob(blank, block(10, 10, RED), false, false);

expect(result.imageThumbnail).toBeUndefined();
expect(result.diffThumbnail).toBeUndefined();
});

// A build is mostly runs that pass. Resizing two images for each of those,
// when the diff they belong to is thrown away seconds later, is work nobody
// ever sees the result of.
it('makes none when the change is under tolerance', () => {
const result = diffJob(blank, block(10, 10, RED), false, true, { diffTolerancePercent: 90 });

expect(result.imageThumbnail).toBeUndefined();
expect(result.diffThumbnail).toBeUndefined();
});

it('makes none when the caller is not keeping the diff', () => {
const result = diffJob(blank, block(10, 10, RED), false, true, { saveDiff: false });

expect(result.imageThumbnail).toBeUndefined();
});

// nothing to show and nothing to shrink
it('makes none when the screenshots are identical', () => {
expect(diffJob(blank, blank, false, true).imageThumbnail).toBeUndefined();
});
});
54 changes: 52 additions & 2 deletions src/compare/libs/pixelmatch/pixelmatch.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { PNG } from 'pngjs';
import Pixelmatch from 'pixelmatch';
import { IgnoreAreaDto } from '../../../test-runs/dto/ignore-area.dto';
import { applyIgnoreAreas, scaleImageToSize } from '../../utils';
import { signatureOfDecoded } from './signature.core';
import { encodeThumbnail } from './thumbnail.core';

/**
* CPU-bound part of the pixelmatch comparison, extracted so it can run inside
Expand All @@ -20,6 +22,19 @@ export interface PixelmatchJobInput {
allowDiffDimensions: boolean;
diffTolerancePercent: number;
saveDiff: boolean;
/**
* Also produce the change signature the variations dialog matches on. Free
* here next to the diff — the images are decoded already — and computed once
* at ingest so reviewing never has to fetch and decode a build's screenshots
* again just to group them.
*/
withSignature?: boolean;
/**
* Also return small PNGs of the checkpoint and of the diff. The grids draw
* these a hundred-odd pixels wide; sending the full-size files and letting
* CSS shrink them is what made opening the variations dialog cost megabytes.
*/
withThumbnails?: boolean;
}

export interface PixelmatchJobOutput {
Expand All @@ -28,6 +43,13 @@ export interface PixelmatchJobOutput {
pixelMisMatchCount?: number;
diffPercent?: number;
diffBuffer?: Buffer | Uint8Array;
// absent when not asked for, when the screenshots are identical, or when
// their dimensions differ and there is nothing meaningful to sign
signature?: number[];
// absent when not asked for, or when the screenshots matched and there is
// nothing to show
imageThumbnail?: Buffer | Uint8Array;
diffThumbnail?: Buffer | Uint8Array;
}

// postMessage turns Buffers into Uint8Array views over their own ArrayBuffer.
Expand Down Expand Up @@ -69,10 +91,38 @@ export function computePixelmatchDiff(input: PixelmatchJobInput): PixelmatchJobO
});
const diffPercent = (pixelMisMatchCount * 100) / (scaledImage.width * scaledImage.height);

// A build is mostly runs that pass, so anything produced for a diff that is
// about to be thrown away is work nobody ever sees the result of.
const keepingTheDiff = diffPercent > input.diffTolerancePercent && input.saveDiff;

let diffBuffer: Buffer;
if (diffPercent > input.diffTolerancePercent && input.saveDiff) {
if (keepingTheDiff) {
diffBuffer = PNG.sync.write(diff);
}

return { equal: false, isSameDimension, pixelMisMatchCount, diffPercent, diffBuffer };
// Same source images, same ignore areas, same threshold as the standalone
// signature job — sharing signatureOfDecoded is what keeps the two answers
// identical, so a stored signature still matches one computed on the fly.
const signature =
input.withSignature && isSameDimension
? signatureOfDecoded(baselineIgnored, imageIgnored, {
threshold: input.threshold,
includeAA: input.includeAA,
})
: null;

const thumbnails =
input.withThumbnails && keepingTheDiff
? { imageThumbnail: encodeThumbnail(imageIgnored), diffThumbnail: encodeThumbnail(diff) }
: {};

return {
equal: false,
isSameDimension,
pixelMisMatchCount,
diffPercent,
diffBuffer,
...(signature ? { signature } : {}),
...thumbnails,
};
}
Loading
Loading