475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import { crc32, deflateSync } from "node:zlib";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
|
import {
|
|
AnalysisResourceConstraintError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import {
|
|
compareWebScreenshotsInputSchema,
|
|
createWebScreenshotArtifact,
|
|
webScreenshotDiffSchema,
|
|
} from "../domain/webScreenshot.js";
|
|
|
|
describe("PNG visual diff", () => {
|
|
it("reports exact changed-pixel and channel metrics", () => {
|
|
const before = artifact(1, 1, [0, 0, 0, 255]);
|
|
const after = artifact(1, 1, [10, 0, 0, 255]);
|
|
const result = comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before, after }),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "different",
|
|
compared_pixels: 1,
|
|
changed_pixels: 1,
|
|
changed_ratio: 1,
|
|
maximum_channel_delta: 10,
|
|
mean_absolute_channel_delta: 2.5,
|
|
});
|
|
});
|
|
|
|
it("applies a channel threshold and reports dimension mismatch", () => {
|
|
const one = artifact(1, 1, [0, 0, 0, 255]);
|
|
const near = artifact(1, 1, [2, 0, 0, 255]);
|
|
expect(
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: one,
|
|
after: near,
|
|
channel_threshold: 2,
|
|
}),
|
|
).status,
|
|
).toBe("identical");
|
|
expect(
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: one,
|
|
after: artifact(2, 1, [0, 0, 0, 255, 0, 0, 0, 255]),
|
|
}),
|
|
),
|
|
).toMatchObject({ status: "dimension_mismatch", compared_pixels: 0 });
|
|
});
|
|
|
|
it("preserves RGB transparent-color samples when comparing RGBA pixels", () => {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(2, 0);
|
|
header.writeUInt32BE(1, 4);
|
|
header[8] = 8;
|
|
header[9] = 2;
|
|
const transparent = Buffer.alloc(6);
|
|
transparent.writeUInt16BE(10, 0);
|
|
transparent.writeUInt16BE(20, 2);
|
|
transparent.writeUInt16BE(30, 4);
|
|
const rgb = createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", header),
|
|
chunk("tRNS", transparent),
|
|
chunk("IDAT", deflateSync(Buffer.from([0, 10, 20, 30, 10, 20, 31]))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]),
|
|
);
|
|
const rgba = artifact(2, 1, [10, 20, 30, 0, 10, 20, 31, 255]);
|
|
expect(
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before: rgb, after: rgba }),
|
|
),
|
|
).toMatchObject({
|
|
status: "identical",
|
|
changed_pixels: 0,
|
|
maximum_channel_delta: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps 8-bit samples opaque when a 16-bit transparency key exceeds 255", () => {
|
|
const key = Buffer.alloc(6);
|
|
key.writeUInt16BE(266, 0);
|
|
key.writeUInt16BE(20, 2);
|
|
key.writeUInt16BE(30, 4);
|
|
const rgb = transparencyArtifact(2, key, [10, 20, 30]);
|
|
const result = comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: rgb,
|
|
after: artifact(1, 1, [10, 20, 30, 255]),
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({ status: "identical", changed_pixels: 0 });
|
|
expect(result.limitations).toContain(
|
|
"PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.",
|
|
);
|
|
});
|
|
|
|
it.each([0, 5, 7])("rejects an RGB tRNS chunk of length %i", (length) => {
|
|
const image = transparencyArtifact(2, Buffer.alloc(length), [10, 20, 30]);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
|
|
),
|
|
).toThrow("Unsupported PNG transparency");
|
|
});
|
|
|
|
it("rejects tRNS on an RGBA image instead of dropping its transparency data", () => {
|
|
const image = transparencyArtifact(6, Buffer.alloc(6), [10, 20, 30, 255]);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
|
|
),
|
|
).toThrow("Unsupported PNG transparency");
|
|
});
|
|
|
|
it("rejects malformed PNG dimensions after validating image data", () => {
|
|
const image = artifact(2, 1, [0, 0, 0, 255]);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: image,
|
|
after: image,
|
|
}),
|
|
),
|
|
).toThrow("Unexpected PNG size");
|
|
});
|
|
|
|
it("rejects metrics that cannot represent their comparison status", () => {
|
|
const image = artifact(1, 1, [0, 0, 0, 255]);
|
|
const result = comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
|
|
);
|
|
|
|
expect(
|
|
webScreenshotDiffSchema.safeParse({
|
|
...result,
|
|
status: "dimension_mismatch",
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
webScreenshotDiffSchema.safeParse({
|
|
...result,
|
|
compared_pixels: 2,
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PNG comparison memory budget", () => {
|
|
it("classifies legal huge dimensions as a memory constraint before inflate", () => {
|
|
const image = createWebScreenshotArtifact(headerOnlyPng(0x7fffffff, 1));
|
|
const input = compareWebScreenshotsInputSchema.parse({
|
|
before: image,
|
|
after: image,
|
|
});
|
|
|
|
try {
|
|
comparePngScreenshots(input);
|
|
throw new Error("expected PNG working-memory rejection");
|
|
} catch (cause: unknown) {
|
|
expect(cause).toBeInstanceOf(AnalysisResourceConstraintError);
|
|
expect(cause).toMatchObject({
|
|
resource: "memory",
|
|
reportedLimits: {
|
|
maximum_working_memory_bytes: 256 * 1024 * 1024,
|
|
estimated_working_memory_bytes: "34359738499",
|
|
before_dimensions: { width: 0x7fffffff, height: 1 },
|
|
after_dimensions: { width: 0x7fffffff, height: 1 },
|
|
},
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PNG chunk integrity", () => {
|
|
it.each(["IHDR", "IDAT", "IEND"])(
|
|
"rejects a corrupt %s chunk checksum",
|
|
(type) => {
|
|
const bytes = png(1, 1, [0, 0, 0, 255]);
|
|
let offset = 8;
|
|
while (offset < bytes.length) {
|
|
const length = bytes.readUInt32BE(offset);
|
|
if (bytes.subarray(offset + 4, offset + 8).toString("ascii") === type) {
|
|
bytes[offset + 8 + length] = (bytes[offset + 8 + length] ?? 0) ^ 1;
|
|
break;
|
|
}
|
|
offset += length + 12;
|
|
}
|
|
const image = createWebScreenshotArtifact(bytes);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: image,
|
|
after: image,
|
|
}),
|
|
),
|
|
).toThrow("Invalid PNG chunk checksum");
|
|
},
|
|
);
|
|
|
|
it("reports a CRC-valid but corrupt compressed stream as invalid PNG data", () => {
|
|
const header = Buffer.from(png(1, 1, [0, 0, 0, 255]).subarray(16, 29));
|
|
const image = createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", header),
|
|
chunk("IDAT", Buffer.from("bad")),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]),
|
|
);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({ before: image, after: image }),
|
|
),
|
|
).toThrow("Invalid PNG image data");
|
|
});
|
|
|
|
it("rejects high-bit bytes in a CRC-valid PNG chunk name", () => {
|
|
const bytes = png(1, 1, [0, 0, 0, 255]);
|
|
const headerChunk = Buffer.from(bytes.subarray(8, 33));
|
|
headerChunk[4] = 0xc9;
|
|
headerChunk.writeUInt32BE(crc32(headerChunk.subarray(4, 21)), 21);
|
|
const malformed = createWebScreenshotArtifact(
|
|
Buffer.concat([bytes.subarray(0, 8), headerChunk, bytes.subarray(33)]),
|
|
);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: malformed,
|
|
after: malformed,
|
|
}),
|
|
),
|
|
).toThrow("Invalid PNG chunk type");
|
|
});
|
|
|
|
it("rejects duplicate image headers and unknown critical chunks while preserving ancillary chunks", () => {
|
|
const bytes = png(1, 1, [0, 0, 0, 255]);
|
|
const duplicate = Buffer.concat([
|
|
bytes.subarray(0, 33),
|
|
bytes.subarray(8, 33),
|
|
bytes.subarray(33),
|
|
]);
|
|
const unknown = Buffer.concat([
|
|
bytes.subarray(0, 33),
|
|
chunk("ABCD", Buffer.alloc(0)),
|
|
bytes.subarray(33),
|
|
]);
|
|
for (const malformed of [duplicate, unknown]) {
|
|
const image = createWebScreenshotArtifact(malformed);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: image,
|
|
after: image,
|
|
}),
|
|
),
|
|
).toThrow(/Invalid PNG header order|Unsupported critical PNG chunk/u);
|
|
}
|
|
const ancillary = createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
bytes.subarray(0, 33),
|
|
chunk("tEXt", Buffer.from("Comment\0original capture")),
|
|
bytes.subarray(33),
|
|
]),
|
|
);
|
|
expect(
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: ancillary,
|
|
after: createWebScreenshotArtifact(bytes),
|
|
}),
|
|
).status,
|
|
).toBe("identical");
|
|
});
|
|
|
|
it("rejects bytes after IEND and reports valid unsupported PNG encodings separately", () => {
|
|
const bytes = png(1, 1, [0, 0, 0, 255]);
|
|
const trailing = createWebScreenshotArtifact(
|
|
Buffer.concat([bytes, Buffer.from([0])]),
|
|
);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: trailing,
|
|
after: trailing,
|
|
}),
|
|
),
|
|
).toThrow("Invalid PNG end chunk");
|
|
|
|
const grayscaleHeader = Buffer.from(bytes.subarray(16, 29));
|
|
grayscaleHeader[9] = 0;
|
|
const grayscale = createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", grayscaleHeader),
|
|
chunk("IDAT", deflateSync(Buffer.from([0, 30]))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]),
|
|
);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: grayscale,
|
|
after: grayscale,
|
|
}),
|
|
),
|
|
).toThrow(AnalysisUnsupportedTargetError);
|
|
});
|
|
});
|
|
|
|
describe("PNG chunk ordering", () => {
|
|
it("rejects malformed palettes, interrupted data and misplaced transparency while retaining valid chunks", () => {
|
|
const header = Buffer.from(png(1, 1, [10, 20, 30, 255]).subarray(16, 29));
|
|
header[9] = 2;
|
|
const ihdr = chunk("IHDR", header);
|
|
const compressed = deflateSync(Buffer.from([0, 10, 20, 30]));
|
|
const idat = chunk("IDAT", compressed);
|
|
const iend = chunk("IEND", Buffer.alloc(0));
|
|
const palette = chunk("PLTE", Buffer.from([10, 20, 30]));
|
|
const transparency = chunk("tRNS", Buffer.from([0, 10, 0, 20, 0, 30]));
|
|
const ancillary = chunk("tEXt", Buffer.from("Comment\0retained"));
|
|
const image = (...chunks: readonly Buffer[]) =>
|
|
createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
...chunks,
|
|
]),
|
|
);
|
|
const original = image(ihdr, idat, iend);
|
|
const cases = [
|
|
[ihdr, chunk("PLTE", Buffer.alloc(1)), idat, iend],
|
|
[ihdr, palette, palette, idat, iend],
|
|
[ihdr, idat, palette, iend],
|
|
[ihdr, transparency, palette, idat, iend],
|
|
[ihdr, transparency, transparency, idat, iend],
|
|
[ihdr, idat, transparency, iend],
|
|
[
|
|
ihdr,
|
|
chunk("IDAT", compressed.subarray(0, 6)),
|
|
ancillary,
|
|
chunk("IDAT", compressed.subarray(6)),
|
|
iend,
|
|
],
|
|
[ihdr, idat, chunk("IEND", Buffer.from([1]))],
|
|
];
|
|
for (const chunks of cases) {
|
|
const malformed = image(...chunks);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: malformed,
|
|
after: original,
|
|
}),
|
|
),
|
|
).toThrow(
|
|
/Invalid PNG palette|Invalid PNG transparency order|Nonconsecutive PNG image data|Invalid PNG end chunk/u,
|
|
);
|
|
}
|
|
const indexedHeader = Buffer.from(header);
|
|
indexedHeader[8] = 1;
|
|
indexedHeader[9] = 3;
|
|
const emptyTransparency = image(
|
|
chunk("IHDR", indexedHeader),
|
|
chunk("PLTE", Buffer.from([10, 20, 30])),
|
|
chunk("tRNS", Buffer.alloc(0)),
|
|
chunk("IDAT", deflateSync(Buffer.from([0, 0]))),
|
|
iend,
|
|
);
|
|
expect(() =>
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: emptyTransparency,
|
|
after: original,
|
|
}),
|
|
),
|
|
).toThrow("Unsupported PNG transparency");
|
|
for (const valid of [
|
|
image(ihdr, palette, idat, iend),
|
|
image(ihdr, ancillary, idat, iend),
|
|
image(
|
|
ihdr,
|
|
chunk("IDAT", compressed.subarray(0, 6)),
|
|
chunk("IDAT", compressed.subarray(6)),
|
|
iend,
|
|
),
|
|
]) {
|
|
expect(
|
|
comparePngScreenshots(
|
|
compareWebScreenshotsInputSchema.parse({
|
|
before: valid,
|
|
after: original,
|
|
}),
|
|
).status,
|
|
).toBe("identical");
|
|
}
|
|
});
|
|
});
|
|
|
|
const artifact = (width: number, height: number, pixels: readonly number[]) =>
|
|
createWebScreenshotArtifact(png(width, height, pixels));
|
|
|
|
const png = (
|
|
width: number,
|
|
height: number,
|
|
pixels: readonly number[],
|
|
): Buffer => {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width, 0);
|
|
header.writeUInt32BE(height, 4);
|
|
header[8] = 8;
|
|
header[9] = 6;
|
|
const rows: number[] = [];
|
|
for (let row = 0; row < height; row += 1)
|
|
rows.push(0, ...pixels.slice(row * width * 4, (row + 1) * width * 4));
|
|
return Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", header),
|
|
chunk("IDAT", deflateSync(Buffer.from(rows))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
};
|
|
|
|
const headerOnlyPng = (width: number, height: number): Buffer => {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width, 0);
|
|
header.writeUInt32BE(height, 4);
|
|
header.set([8, 6, 0, 0, 0], 8);
|
|
return Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", header),
|
|
chunk("IDAT", deflateSync(Buffer.from([0]))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
};
|
|
|
|
const chunk = (type: string, data: Buffer): Buffer => {
|
|
const result = Buffer.alloc(12 + data.byteLength);
|
|
result.writeUInt32BE(data.byteLength, 0);
|
|
result.write(type, 4, 4, "ascii");
|
|
data.copy(result, 8);
|
|
result.writeUInt32BE(
|
|
crc32(result.subarray(4, 8 + data.byteLength)),
|
|
8 + data.byteLength,
|
|
);
|
|
return result;
|
|
};
|
|
|
|
const transparencyArtifact = (
|
|
colorType: number,
|
|
transparency: Buffer,
|
|
pixels: readonly number[],
|
|
) => {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(1, 0);
|
|
header.writeUInt32BE(1, 4);
|
|
header[8] = 8;
|
|
header[9] = colorType;
|
|
return createWebScreenshotArtifact(
|
|
Buffer.concat([
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
chunk("IHDR", header),
|
|
chunk("tRNS", transparency),
|
|
chunk("IDAT", deflateSync(Buffer.from([0, ...pixels]))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]),
|
|
);
|
|
};
|