mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix: run fork visual reports from trusted code * fix: auto-approve strict web visual capture * fix: address visual report review feedback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: propagate visual report storage failures Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: validate PR screenshots before upload Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: validate visual PR identity before comment * fix: harden fork visual report validation Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: address remaining fork visual report review feedback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: handle stale fork visual report lookup Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix: allow stale fork visual report fallback Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
|
import { describe, expect, test } from 'vitest';
|
|
|
|
import { PNG } from 'pngjs';
|
|
|
|
import {
|
|
assertPngPixels,
|
|
compareCase,
|
|
diffBoxesFromMask,
|
|
drawBox,
|
|
mergeDiffBoxes,
|
|
padBox,
|
|
type DiffBox,
|
|
} from '../scripts/visual-report.js';
|
|
|
|
describe('visual report PNG sizing', () => {
|
|
test('rejects normalized diff canvases that exceed the pixel ceiling', () => {
|
|
expect(() => assertPngPixels(4_000, 900, 'main.png')).not.toThrow();
|
|
expect(() => assertPngPixels(900, 4_000, 'pr.png')).not.toThrow();
|
|
expect(() => assertPngPixels(4_000, 4_000, 'main.png vs pr.png normalized diff canvas')).toThrow(
|
|
/maximum allowed is 4000000 pixels/,
|
|
);
|
|
});
|
|
|
|
test('malformed screenshots fail one case without preventing later valid cases', async () => {
|
|
const workDir = await mkdtemp(path.join(tmpdir(), 'visual-report-'));
|
|
try {
|
|
const outputDir = path.join(workDir, 'output');
|
|
const goodPath = path.join(workDir, 'visual-good.png');
|
|
const badPath = path.join(workDir, 'visual-bad.png');
|
|
const pngBuffer = PNG.sync.write(createFilledPng(2, 2));
|
|
await writeFile(goodPath, pngBuffer);
|
|
await writeFile(badPath, Buffer.from('not-a-png'));
|
|
|
|
const r2 = {
|
|
bucket: 'visual-bucket',
|
|
publicOrigin: 'https://example.invalid',
|
|
client: {} as never,
|
|
};
|
|
const ops = {
|
|
putFile: async () => {},
|
|
findBaseline: async () => ({ sha: 'a'.repeat(40), key: 'baseline/visual-good.png', behindBy: 0 }),
|
|
downloadObject: async (_r2: unknown, _key: string, outputPath: string) => {
|
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
await writeFile(outputPath, pngBuffer);
|
|
},
|
|
writeDiffPng: async (_mainPath: string, prPath: string, diffPath: string) => {
|
|
await mkdir(path.dirname(diffPath), { recursive: true });
|
|
await writeFile(diffPath, await readFile(prPath));
|
|
return 0;
|
|
},
|
|
};
|
|
|
|
const malformed = await compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-bad', path: badPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
ops,
|
|
);
|
|
const valid = await compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-good', path: goodPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
ops,
|
|
);
|
|
|
|
expect(malformed.status).toBe('failed');
|
|
expect(malformed.name).toBe('visual-bad');
|
|
expect(valid).toMatchObject({
|
|
name: 'visual-good',
|
|
status: 'unchanged',
|
|
prUrl: 'https://example.invalid/visual-regression/pr-12/34/pr/visual-good.png',
|
|
});
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('required R2 failures propagate instead of returning a failed case', async () => {
|
|
const workDir = await mkdtemp(path.join(tmpdir(), 'visual-report-'));
|
|
try {
|
|
const outputDir = path.join(workDir, 'output');
|
|
const goodPath = path.join(workDir, 'visual-good.png');
|
|
const pngBuffer = PNG.sync.write(createFilledPng(2, 2));
|
|
await writeFile(goodPath, pngBuffer);
|
|
|
|
const r2 = {
|
|
bucket: 'visual-bucket',
|
|
publicOrigin: 'https://example.invalid',
|
|
client: {} as never,
|
|
};
|
|
|
|
await expect(compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-good', path: goodPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
{
|
|
putFile: async () => {},
|
|
findBaseline: async () => ({ sha: 'a'.repeat(40), key: 'baseline/visual-good.png', behindBy: 0 }),
|
|
downloadObject: async () => {
|
|
throw new Error('download failed');
|
|
},
|
|
writeDiffPng: async () => 0,
|
|
},
|
|
)).rejects.toThrow('download failed');
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('missing screenshot files propagate instead of returning a failed case', async () => {
|
|
const workDir = await mkdtemp(path.join(tmpdir(), 'visual-report-'));
|
|
try {
|
|
const outputDir = path.join(workDir, 'output');
|
|
const missingPath = path.join(workDir, 'visual-missing.png');
|
|
|
|
const r2 = {
|
|
bucket: 'visual-bucket',
|
|
publicOrigin: 'https://example.invalid',
|
|
client: {} as never,
|
|
};
|
|
|
|
await expect(compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-missing', path: missingPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
{
|
|
putFile: async () => {},
|
|
findBaseline: async () => null,
|
|
downloadObject: async () => {
|
|
throw new Error('download should not run');
|
|
},
|
|
writeDiffPng: async () => {
|
|
throw new Error('diff should not run');
|
|
},
|
|
},
|
|
)).rejects.toThrow(/ENOENT|no such file/i);
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('malformed screenshots without a baseline fail before any PR upload', async () => {
|
|
const workDir = await mkdtemp(path.join(tmpdir(), 'visual-report-'));
|
|
try {
|
|
const outputDir = path.join(workDir, 'output');
|
|
const badPath = path.join(workDir, 'visual-bad.png');
|
|
await writeFile(badPath, Buffer.from('not-a-png'));
|
|
|
|
const uploadedKeys: string[] = [];
|
|
const r2 = {
|
|
bucket: 'visual-bucket',
|
|
publicOrigin: 'https://example.invalid',
|
|
client: {} as never,
|
|
};
|
|
|
|
const result = await compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-bad', path: badPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
{
|
|
putFile: async (_r2: unknown, key: string) => {
|
|
uploadedKeys.push(key);
|
|
},
|
|
findBaseline: async () => null,
|
|
downloadObject: async () => {
|
|
throw new Error('download should not run');
|
|
},
|
|
writeDiffPng: async () => {
|
|
throw new Error('diff should not run');
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
name: 'visual-bad',
|
|
status: 'failed',
|
|
});
|
|
expect(uploadedKeys).toEqual([]);
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('huge-dimension PNG headers fail before decode and upload', async () => {
|
|
const workDir = await mkdtemp(path.join(tmpdir(), 'visual-report-'));
|
|
try {
|
|
const outputDir = path.join(workDir, 'output');
|
|
const badPath = path.join(workDir, 'visual-huge.png');
|
|
await writeFile(badPath, createHugeHeaderPng(100_000, 100_000));
|
|
|
|
const uploadedKeys: string[] = [];
|
|
const r2 = {
|
|
bucket: 'visual-bucket',
|
|
publicOrigin: 'https://example.invalid',
|
|
client: {} as never,
|
|
};
|
|
|
|
const result = await compareCase(
|
|
{
|
|
r2,
|
|
prNumber: '12',
|
|
runId: '34',
|
|
headSha: 'b'.repeat(40),
|
|
visualCase: { name: 'visual-huge', path: badPath },
|
|
candidateShas: ['c'.repeat(40)],
|
|
outputDir,
|
|
},
|
|
{
|
|
putFile: async (_r2: unknown, key: string) => {
|
|
uploadedKeys.push(key);
|
|
},
|
|
findBaseline: async () => null,
|
|
downloadObject: async () => {
|
|
throw new Error('download should not run');
|
|
},
|
|
writeDiffPng: async () => {
|
|
throw new Error('diff should not run');
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
name: 'visual-huge',
|
|
status: 'failed',
|
|
error: expect.stringMatching(/maximum allowed is 4000000 pixels/),
|
|
});
|
|
expect(uploadedKeys).toEqual([]);
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('visual diff box extraction', () => {
|
|
test('returns one box for a single contiguous region', () => {
|
|
const mask = createMask(6, 5, [[1, 1], [2, 1], [2, 2], [3, 2]]);
|
|
|
|
expect(diffBoxesFromMask(mask)).toEqual([{ minX: 1, minY: 1, maxX: 3, maxY: 2 }]);
|
|
});
|
|
|
|
test('returns distinct boxes for disjoint regions', () => {
|
|
const mask = createMask(7, 5, [[0, 1], [1, 1], [5, 3], [5, 4]]);
|
|
|
|
expect(diffBoxesFromMask(mask)).toEqual([
|
|
{ minX: 0, minY: 1, maxX: 1, maxY: 1 },
|
|
{ minX: 5, minY: 3, maxX: 5, maxY: 4 },
|
|
]);
|
|
});
|
|
|
|
test('returns no boxes for an empty mask', () => {
|
|
expect(diffBoxesFromMask(createMask(3, 3, []))).toEqual([]);
|
|
});
|
|
|
|
test('collapses to the overall bounding box when region count exceeds the cap', () => {
|
|
const pixels = Array.from({ length: 2_001 }, (_, index) => [index * 2, 0] as const);
|
|
|
|
expect(diffBoxesFromMask(createMask(4_001, 1, pixels))).toEqual([{ minX: 0, minY: 0, maxX: 4_000, maxY: 0 }]);
|
|
});
|
|
});
|
|
|
|
describe('visual diff box merging and drawing', () => {
|
|
test('merges nearby regions into one box', () => {
|
|
const boxes: DiffBox[] = [
|
|
{ minX: 1, minY: 1, maxX: 2, maxY: 2 },
|
|
{ minX: 12, minY: 1, maxX: 13, maxY: 2 },
|
|
];
|
|
|
|
expect(mergeDiffBoxes(boxes, 10)).toEqual([{ minX: 1, minY: 1, maxX: 13, maxY: 2 }]);
|
|
});
|
|
|
|
test('keeps boxes separate when they are just beyond the merge distance', () => {
|
|
const boxes: DiffBox[] = [
|
|
{ minX: 1, minY: 1, maxX: 2, maxY: 2 },
|
|
{ minX: 15, minY: 1, maxX: 16, maxY: 2 },
|
|
];
|
|
|
|
expect(mergeDiffBoxes(boxes, 12)).toEqual(boxes);
|
|
});
|
|
|
|
test('pads boxes within image bounds', () => {
|
|
expect(padBox({ minX: 2, minY: 3, maxX: 4, maxY: 5 }, 6, 8, 10)).toEqual({
|
|
minX: 0,
|
|
minY: 0,
|
|
maxX: 7,
|
|
maxY: 9,
|
|
});
|
|
});
|
|
|
|
test('draws a clamped stroke around the box', () => {
|
|
const png = new PNG({ width: 4, height: 4 });
|
|
|
|
drawBox(png, { minX: 1, minY: 1, maxX: 2, maxY: 2 }, 3);
|
|
|
|
expect(redPixels(png).sort()).toEqual([
|
|
'1,1',
|
|
'1,2',
|
|
'2,1',
|
|
'2,2',
|
|
]);
|
|
});
|
|
});
|
|
|
|
function createMask(width: number, height: number, pixels: ReadonlyArray<readonly [number, number]>): PNG {
|
|
const png = new PNG({ width, height });
|
|
for (const [x, y] of pixels) {
|
|
const index = (png.width * y + x) << 2;
|
|
png.data[index] = 255;
|
|
png.data[index + 1] = 0;
|
|
png.data[index + 2] = 0;
|
|
png.data[index + 3] = 255;
|
|
}
|
|
return png;
|
|
}
|
|
|
|
function createFilledPng(width: number, height: number): PNG {
|
|
const png = new PNG({ width, height });
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const index = (png.width * y + x) << 2;
|
|
png.data[index] = 12;
|
|
png.data[index + 1] = 34;
|
|
png.data[index + 2] = 56;
|
|
png.data[index + 3] = 255;
|
|
}
|
|
}
|
|
return png;
|
|
}
|
|
|
|
function createHugeHeaderPng(width: number, height: number): Buffer {
|
|
const buffer = Buffer.alloc(33);
|
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer, 0);
|
|
buffer.writeUInt32BE(13, 8);
|
|
buffer.write('IHDR', 12, 'ascii');
|
|
buffer.writeUInt32BE(width, 16);
|
|
buffer.writeUInt32BE(height, 20);
|
|
buffer[24] = 8;
|
|
buffer[25] = 6;
|
|
buffer[26] = 0;
|
|
buffer[27] = 0;
|
|
buffer[28] = 0;
|
|
return buffer;
|
|
}
|
|
|
|
function redPixels(png: PNG): string[] {
|
|
const pixels: string[] = [];
|
|
for (let y = 0; y < png.height; y += 1) {
|
|
for (let x = 0; x < png.width; x += 1) {
|
|
const index = (png.width * y + x) << 2;
|
|
if (
|
|
png.data[index] === 255
|
|
&& png.data[index + 1] === 0
|
|
&& png.data[index + 2] === 0
|
|
&& png.data[index + 3] === 255
|
|
) {
|
|
pixels.push(`${x},${y}`);
|
|
}
|
|
}
|
|
}
|
|
return pixels;
|
|
}
|