mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-16 05:11:14 +01:00
528 lines
17 KiB
TypeScript
528 lines
17 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
// Diffs a locally rendered screenshot manifest against a base-commit manifest
|
|
// and prints Markdown to stdout. Empty output (exit 0) means no reportable
|
|
// changes. The base manifest is supplied as a file path — the caller is
|
|
// responsible for fetching it (e.g. via curl from the screenshot service's
|
|
// public /commits/<owner>/<repo>/<sha> endpoint).
|
|
//
|
|
// Usage:
|
|
// node build/lib/screenshotDiffReport.ts \
|
|
// <service-url> <base-sha> <current-sha> <base-manifest> <local-manifest>
|
|
//
|
|
// Both before- and after-images are referenced via the public /images/<hash>
|
|
// endpoint of the screenshot service. After-images become available there
|
|
// once the workflow's upload step has pushed them. Note: GitHub markdown
|
|
// does not render `data:` URIs, so we cannot inline images as base64; for
|
|
// runs that skip the upload (e.g. fork PRs) the after-image URL will 404
|
|
// until the service has the hash, which is acceptable since fork PRs do
|
|
// not post a PR comment.
|
|
|
|
import * as fs from 'fs';
|
|
import * as https from 'https';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import * as zlib from 'zlib';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const COMMENT_MARKER = '<!-- screenshot-diff-report -->';
|
|
const EXPAND_FIRST_N = 5;
|
|
const EXCLUDED_LABELS = new Set(['animated', 'flaky']);
|
|
const MAX_BODY_BYTES = 300 * 1024;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pixel-level image comparison
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_INSIGNIFICANT_PIXELS = 20;
|
|
const MAX_INSIGNIFICANT_CHANNEL_DELTA = 2;
|
|
|
|
interface RawImage {
|
|
readonly width: number;
|
|
readonly height: number;
|
|
readonly data: Buffer;
|
|
}
|
|
|
|
function readPngPixels(buf: Buffer): RawImage {
|
|
if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4E || buf[3] !== 0x47) {
|
|
throw new Error('Not a PNG file');
|
|
}
|
|
let offset = 8;
|
|
let width = 0, height = 0, colorType = 0;
|
|
const idatChunks: Buffer[] = [];
|
|
while (offset < buf.length) {
|
|
const length = buf.readUInt32BE(offset);
|
|
const type = buf.toString('ascii', offset + 4, offset + 8);
|
|
const data = buf.subarray(offset + 8, offset + 8 + length);
|
|
if (type === 'IHDR') {
|
|
width = data.readUInt32BE(0);
|
|
height = data.readUInt32BE(4);
|
|
if (data[8] !== 8) { throw new Error(`Unsupported bit depth: ${data[8]}`); }
|
|
colorType = data[9];
|
|
if (colorType !== 2 && colorType !== 6) { throw new Error(`Unsupported color type: ${colorType}`); }
|
|
} else if (type === 'IDAT') {
|
|
idatChunks.push(data);
|
|
} else if (type === 'IEND') {
|
|
break;
|
|
}
|
|
offset += 12 + length;
|
|
}
|
|
const bpp = colorType === 6 ? 4 : 3;
|
|
const raw = zlib.inflateSync(Buffer.concat(idatChunks));
|
|
const stride = width * bpp + 1;
|
|
const pixels = Buffer.alloc(width * height * 4);
|
|
const prevRow = Buffer.alloc(width * bpp);
|
|
const currRow = Buffer.alloc(width * bpp);
|
|
for (let y = 0; y < height; y++) {
|
|
const filterType = raw[y * stride];
|
|
const rowData = raw.subarray(y * stride + 1, y * stride + 1 + width * bpp);
|
|
for (let i = 0; i < width * bpp; i++) {
|
|
const a = i >= bpp ? currRow[i - bpp] : 0;
|
|
const b = prevRow[i];
|
|
const c = i >= bpp ? prevRow[i - bpp] : 0;
|
|
let val = rowData[i];
|
|
switch (filterType) {
|
|
case 0: break;
|
|
case 1: val = (val + a) & 0xff; break;
|
|
case 2: val = (val + b) & 0xff; break;
|
|
case 3: val = (val + ((a + b) >> 1)) & 0xff; break;
|
|
case 4: {
|
|
const p = a + b - c;
|
|
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
val = (val + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
|
|
break;
|
|
}
|
|
}
|
|
currRow[i] = val;
|
|
}
|
|
for (let x = 0; x < width; x++) {
|
|
const pi = (y * width + x) * 4;
|
|
pixels[pi] = currRow[x * bpp];
|
|
pixels[pi + 1] = currRow[x * bpp + 1];
|
|
pixels[pi + 2] = currRow[x * bpp + 2];
|
|
pixels[pi + 3] = bpp === 4 ? currRow[x * bpp + 3] : 255;
|
|
}
|
|
prevRow.set(currRow);
|
|
}
|
|
return { width, height, data: pixels };
|
|
}
|
|
|
|
class ImageDiffResult {
|
|
readonly changedPixelCount: number;
|
|
readonly maxChannelDelta: number;
|
|
|
|
constructor(changedPixelCount: number, maxChannelDelta: number) {
|
|
this.changedPixelCount = changedPixelCount;
|
|
this.maxChannelDelta = maxChannelDelta;
|
|
}
|
|
|
|
get isSignificant(): boolean {
|
|
return this.changedPixelCount > MAX_INSIGNIFICANT_PIXELS
|
|
|| this.maxChannelDelta > MAX_INSIGNIFICANT_CHANNEL_DELTA;
|
|
}
|
|
|
|
toString(): string {
|
|
if (this.changedPixelCount === 0) {
|
|
return 'identical';
|
|
}
|
|
return `${this.changedPixelCount} px changed, max \u0394${this.maxChannelDelta}${this.isSignificant ? '' : ' (insignificant)'}`;
|
|
}
|
|
}
|
|
|
|
function diffImages(before: Buffer, after: Buffer): ImageDiffResult {
|
|
const imgA = readPngPixels(before);
|
|
const imgB = readPngPixels(after);
|
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
return new ImageDiffResult(imgA.width * imgA.height, 255);
|
|
}
|
|
let changedPixels = 0;
|
|
let maxDelta = 0;
|
|
for (let i = 0; i < imgA.data.length; i += 4) {
|
|
const dr = Math.abs(imgA.data[i] - imgB.data[i]);
|
|
const dg = Math.abs(imgA.data[i + 1] - imgB.data[i + 1]);
|
|
const db = Math.abs(imgA.data[i + 2] - imgB.data[i + 2]);
|
|
const da = Math.abs(imgA.data[i + 3] - imgB.data[i + 3]);
|
|
if (dr || dg || db || da) {
|
|
changedPixels++;
|
|
maxDelta = Math.max(maxDelta, dr, dg, db, da);
|
|
}
|
|
}
|
|
return new ImageDiffResult(changedPixels, maxDelta);
|
|
}
|
|
|
|
function fetchBuffer(url: string): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
https.get(url, (res) => {
|
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
fetchBuffer(res.headers.location).then(resolve, reject);
|
|
return;
|
|
}
|
|
if (!res.statusCode || res.statusCode !== 200) {
|
|
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
res.on('error', reject);
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function computePixelDiffs(
|
|
changed: readonly ChangedDiffEntry[],
|
|
serviceUrl: string,
|
|
localManifestDir: string,
|
|
): Promise<Map<string, ImageDiffResult>> {
|
|
const results = new Map<string, ImageDiffResult>();
|
|
await Promise.all(changed.map(async (entry) => {
|
|
try {
|
|
const beforeUrl = loadImageUrl(serviceUrl, entry.beforeHash);
|
|
const [beforeBuf, afterBuf] = await Promise.all([
|
|
fetchBuffer(beforeUrl),
|
|
fs.promises.readFile(path.resolve(localManifestDir, entry.afterPath)),
|
|
]);
|
|
results.set(entry.fixtureId, diffImages(beforeBuf, afterBuf));
|
|
} catch (err) {
|
|
console.error(` Warning: pixel diff failed for ${entry.fixtureId}: ${err}`);
|
|
}
|
|
}));
|
|
return results;
|
|
}
|
|
|
|
interface ManifestEvent {
|
|
readonly type?: string;
|
|
readonly message?: string;
|
|
readonly stack?: string;
|
|
readonly phase?: string;
|
|
readonly isError?: boolean;
|
|
}
|
|
|
|
interface LocalManifestFixture {
|
|
readonly fixtureId: string;
|
|
readonly imageHash?: string;
|
|
readonly imagePath?: string;
|
|
readonly labels?: readonly string[];
|
|
readonly hasError?: boolean;
|
|
readonly events?: readonly ManifestEvent[];
|
|
}
|
|
|
|
interface LocalManifest {
|
|
readonly fixtures: readonly LocalManifestFixture[];
|
|
}
|
|
|
|
interface BaseFixture {
|
|
readonly fixtureId: string;
|
|
readonly imageHash: string;
|
|
readonly labels?: readonly string[];
|
|
}
|
|
|
|
interface BaseCommitResponse {
|
|
readonly commitSha: string;
|
|
readonly fixtures: readonly BaseFixture[];
|
|
}
|
|
|
|
interface DiffEntry {
|
|
readonly fixtureId: string;
|
|
readonly labels?: readonly string[];
|
|
}
|
|
|
|
interface ChangedDiffEntry extends DiffEntry {
|
|
readonly beforeHash: string;
|
|
readonly afterHash: string;
|
|
readonly afterPath: string;
|
|
}
|
|
|
|
interface AddedDiffEntry extends DiffEntry {
|
|
readonly afterHash: string;
|
|
readonly afterPath: string;
|
|
}
|
|
|
|
interface RemovedDiffEntry extends DiffEntry {
|
|
readonly beforeHash: string;
|
|
}
|
|
|
|
interface ErroredDiffEntry extends DiffEntry {
|
|
readonly errorMessage: string;
|
|
readonly errorStack?: string;
|
|
}
|
|
|
|
interface DiffResult {
|
|
readonly changed: readonly ChangedDiffEntry[];
|
|
readonly added: readonly AddedDiffEntry[];
|
|
readonly removed: readonly RemovedDiffEntry[];
|
|
readonly errored: readonly ErroredDiffEntry[];
|
|
}
|
|
|
|
function shouldIncludeInReport(labels: readonly string[] | undefined): boolean {
|
|
return !labels?.some(l => EXCLUDED_LABELS.has(l));
|
|
}
|
|
|
|
function diffManifests(local: LocalManifest, base: BaseCommitResponse): DiffResult {
|
|
const baseByFixture = new Map<string, BaseFixture>();
|
|
for (const f of base.fixtures) {
|
|
baseByFixture.set(f.fixtureId, f);
|
|
}
|
|
const localByFixture = new Map<string, LocalManifestFixture>();
|
|
for (const f of local.fixtures) {
|
|
localByFixture.set(f.fixtureId, f);
|
|
}
|
|
|
|
const changed: ChangedDiffEntry[] = [];
|
|
const added: AddedDiffEntry[] = [];
|
|
const removed: RemovedDiffEntry[] = [];
|
|
const errored: ErroredDiffEntry[] = [];
|
|
|
|
for (const cur of local.fixtures) {
|
|
if (cur.hasError || !cur.imageHash || !cur.imagePath) {
|
|
const errorEvents = (cur.events ?? []).filter(e => e.isError);
|
|
const errorMessage = errorEvents.map(e => e.message).filter(Boolean).join('; ')
|
|
|| 'unknown error (no image hash produced)';
|
|
const errorStack = errorEvents.map(e => e.stack).find(s => s !== undefined);
|
|
errored.push({
|
|
fixtureId: cur.fixtureId,
|
|
labels: cur.labels,
|
|
errorMessage,
|
|
errorStack,
|
|
});
|
|
continue;
|
|
}
|
|
const baseEntry = baseByFixture.get(cur.fixtureId);
|
|
if (!baseEntry) {
|
|
added.push({
|
|
fixtureId: cur.fixtureId,
|
|
labels: cur.labels,
|
|
afterHash: cur.imageHash,
|
|
afterPath: cur.imagePath,
|
|
});
|
|
continue;
|
|
}
|
|
if (baseEntry.imageHash !== cur.imageHash) {
|
|
changed.push({
|
|
fixtureId: cur.fixtureId,
|
|
labels: cur.labels,
|
|
beforeHash: baseEntry.imageHash,
|
|
afterHash: cur.imageHash,
|
|
afterPath: cur.imagePath,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const baseEntry of base.fixtures) {
|
|
if (!localByFixture.has(baseEntry.fixtureId)) {
|
|
removed.push({
|
|
fixtureId: baseEntry.fixtureId,
|
|
labels: baseEntry.labels,
|
|
beforeHash: baseEntry.imageHash,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { changed, added, removed, errored };
|
|
}
|
|
|
|
function loadImageUrl(serviceUrl: string, hash: string): string {
|
|
return `${serviceUrl.replace(/\/$/, '')}/images/${hash}`;
|
|
}
|
|
|
|
function generateMarkdown(
|
|
diff: DiffResult,
|
|
serviceUrl: string,
|
|
baseSha: string,
|
|
currentSha: string,
|
|
pixelDiffs: ReadonlyMap<string, ImageDiffResult>,
|
|
): string {
|
|
const changed = diff.changed.filter(e => shouldIncludeInReport(e.labels));
|
|
const added = diff.added.filter(e => shouldIncludeInReport(e.labels));
|
|
const removed = diff.removed.filter(e => shouldIncludeInReport(e.labels));
|
|
const errored = diff.errored.filter(e => shouldIncludeInReport(e.labels));
|
|
|
|
const significantChanged = changed.filter(e => {
|
|
const pd = pixelDiffs.get(e.fixtureId);
|
|
return !pd || pd.isSignificant;
|
|
});
|
|
const insignificantChangedCount = changed.length - significantChanged.length;
|
|
|
|
if (significantChanged.length === 0 && added.length === 0 && removed.length === 0 && errored.length === 0) {
|
|
if (insignificantChangedCount > 0) {
|
|
console.error(`All ${insignificantChangedCount} changed screenshot(s) are insignificant — suppressing PR comment.`);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push('## Screenshot Changes');
|
|
lines.push('');
|
|
lines.push(`**Base:** \`${baseSha.slice(0, 8)}\` **Current:** \`${currentSha.slice(0, 8)}\``);
|
|
lines.push('');
|
|
|
|
if (significantChanged.length > 0 || insignificantChangedCount > 0) {
|
|
if (significantChanged.length > 0) {
|
|
lines.push(`### Changed (${significantChanged.length})`);
|
|
lines.push('');
|
|
for (let i = 0; i < significantChanged.length; i++) {
|
|
const entry = significantChanged[i];
|
|
const open = i < EXPAND_FIRST_N ? ' open' : '';
|
|
lines.push(`<details${open}><summary><code>${entry.fixtureId}</code></summary>`);
|
|
lines.push('');
|
|
lines.push('| Before | After |');
|
|
lines.push('|--------|-------|');
|
|
lines.push(`| }) | }) |`);
|
|
lines.push('');
|
|
lines.push('</details>');
|
|
lines.push('');
|
|
}
|
|
}
|
|
if (insignificantChangedCount > 0) {
|
|
lines.push(`_${insignificantChangedCount} insignificant change(s) omitted (\u2264${MAX_INSIGNIFICANT_PIXELS} px, \u0394\u2264${MAX_INSIGNIFICANT_CHANNEL_DELTA}). See CI logs for details._`);
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
if (added.length > 0) {
|
|
lines.push(`### Added (${added.length})`);
|
|
lines.push('');
|
|
for (let i = 0; i < added.length; i++) {
|
|
const entry = added[i];
|
|
const open = i < EXPAND_FIRST_N ? ' open' : '';
|
|
lines.push(`<details${open}><summary><code>${entry.fixtureId}</code></summary>`);
|
|
lines.push('');
|
|
lines.push(`})`);
|
|
lines.push('');
|
|
lines.push('</details>');
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
if (removed.length > 0) {
|
|
lines.push(`### Removed (${removed.length})`);
|
|
lines.push('');
|
|
for (let i = 0; i < removed.length; i++) {
|
|
const entry = removed[i];
|
|
const open = i < EXPAND_FIRST_N ? ' open' : '';
|
|
lines.push(`<details${open}><summary><code>${entry.fixtureId}</code></summary>`);
|
|
lines.push('');
|
|
lines.push(`})`);
|
|
lines.push('');
|
|
lines.push('</details>');
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
if (errored.length > 0) {
|
|
lines.push(`### Errored (${errored.length})`);
|
|
lines.push('');
|
|
lines.push('Fixtures that failed to render — no screenshot was produced.');
|
|
lines.push('');
|
|
const reservedForFooter = 200;
|
|
const usedBytes = () => Buffer.byteLength(lines.join('\n'), 'utf8');
|
|
let truncatedAt = -1;
|
|
for (let i = 0; i < errored.length; i++) {
|
|
const entry = errored[i];
|
|
const open = i < EXPAND_FIRST_N ? ' open' : '';
|
|
const headerMessage = entry.errorMessage.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? entry.errorMessage;
|
|
const header = `<details${open}><summary><code>${entry.fixtureId}</code> — ${escapeMarkdown(headerMessage)}</summary>`;
|
|
const fullStack = entry.errorStack ?? entry.errorMessage;
|
|
const fullBlock = `${header}\n\n\`\`\`\n${fullStack}\n\`\`\`\n\n</details>\n`;
|
|
|
|
const remainingBudget = MAX_BODY_BYTES - usedBytes() - reservedForFooter;
|
|
if (Buffer.byteLength(fullBlock, 'utf8') <= remainingBudget) {
|
|
lines.push(header);
|
|
lines.push('');
|
|
lines.push('```');
|
|
lines.push(fullStack);
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('</details>');
|
|
lines.push('');
|
|
continue;
|
|
}
|
|
|
|
const minimalBlock = `${header}\n\n\`\`\`\n…\n\`\`\`\n\n</details>\n`;
|
|
const minimalBudget = remainingBudget - Buffer.byteLength(minimalBlock, 'utf8');
|
|
if (minimalBudget < 0) {
|
|
truncatedAt = i;
|
|
break;
|
|
}
|
|
const truncationMarker = '\n…(truncated)';
|
|
const stackBudget = minimalBudget + Buffer.byteLength('…', 'utf8') - Buffer.byteLength(truncationMarker, 'utf8');
|
|
const truncatedStack = stackBudget > 0
|
|
? fullStack.slice(0, stackBudget) + truncationMarker
|
|
: '…(truncated)';
|
|
lines.push(header);
|
|
lines.push('');
|
|
lines.push('```');
|
|
lines.push(truncatedStack);
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('</details>');
|
|
lines.push('');
|
|
}
|
|
if (truncatedAt !== -1) {
|
|
lines.push(`_…and ${errored.length - truncatedAt} more errored fixture(s) omitted to stay under the ${Math.round(MAX_BODY_BYTES / 1024)}KB body limit. See workflow logs for the full list._`);
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function escapeMarkdown(text: string): string {
|
|
return text.replace(/[\r\n]+/g, ' ').replace(/[<>]/g, c => c === '<' ? '<' : '>');
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const [serviceUrl, baseSha, currentSha, baseManifestPath, localManifestPath] = process.argv.slice(2);
|
|
if (!serviceUrl || !baseSha || !currentSha || !baseManifestPath || !localManifestPath) {
|
|
console.error('Usage: node build/lib/screenshotDiffReport.ts <service-url> <base-sha> <current-sha> <base-manifest> <local-manifest>');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!fs.existsSync(localManifestPath)) {
|
|
console.error(`Local manifest not found: ${localManifestPath}`);
|
|
process.exit(1);
|
|
}
|
|
if (!fs.existsSync(baseManifestPath)) {
|
|
console.error(`Base manifest not found: ${baseManifestPath}. Skipping diff.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const local = JSON.parse(fs.readFileSync(localManifestPath, 'utf8')) as LocalManifest;
|
|
const base = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8')) as BaseCommitResponse;
|
|
|
|
const diff = diffManifests(local, base);
|
|
console.error(`Compare result: ${diff.changed.length} changed, ${diff.added.length} added, ${diff.removed.length} removed, ${diff.errored.length} errored.`);
|
|
|
|
const localManifestDir = path.dirname(path.resolve(localManifestPath));
|
|
const pixelDiffs = await computePixelDiffs(diff.changed, serviceUrl, localManifestDir);
|
|
for (const entry of diff.changed) {
|
|
const pd = pixelDiffs.get(entry.fixtureId);
|
|
console.error(` ${entry.fixtureId}: ${pd?.toString() ?? 'pixel diff unavailable'}`);
|
|
}
|
|
|
|
const tmpDir = path.join(__dirname, '../../.tmp');
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'screenshotDiffReport.json'),
|
|
JSON.stringify(diff, null, 2),
|
|
);
|
|
|
|
const markdown = generateMarkdown(diff, serviceUrl, baseSha, currentSha, pixelDiffs);
|
|
|
|
if (!markdown) {
|
|
console.error('No reportable changes.');
|
|
process.exit(0);
|
|
}
|
|
|
|
process.stdout.write(`${COMMENT_MARKER}\n${markdown}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|