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
47 changes: 47 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{

Check warning on line 1 in package.json

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #582.

Check notice on line 1 in package.json

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #582.

Check notice on line 1 in package.json

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in package.json

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in package.json

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
"name": "gittensory",
"version": "0.1.0",
"private": true,
Expand Down Expand Up @@ -72,13 +72,17 @@
"@cloudflare/vitest-pool-workers": "^0.16.10",
"@tktco/node-actionlint": "^1.6.0",
"@types/node": "^24.10.1",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"@vitest/coverage-v8": "^4.1.7",
"drizzle-kit": "^0.31.7",
"git-cliff": "^2.13.1",
"github-actionlint": "^1.7.12",
"node-addon-api": "^8.5.0",
"node-gyp": "^12.1.0",
"playwright": "^1.56.1",
"pixelmatch": "^7.2.0",
"pngjs": "^7.0.0",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"vitest": "^4.1.7",
Expand Down
177 changes: 177 additions & 0 deletions src/visual-agent/visual-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**

Check warning on line 1 in src/visual-agent/visual-diff.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #582.

Check notice on line 1 in src/visual-agent/visual-diff.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #582.

Check notice on line 1 in src/visual-agent/visual-diff.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/visual-agent/visual-diff.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/visual-agent/visual-diff.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
* Agent-path visual diff utilities (Node `Buffer` + PNG decode).
* Must not be imported from the Worker entry (`src/index.ts`) or MCP bin bundle.
*/
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";

export type VisualRouteStatus = "changed" | "unchanged" | "new" | "removed";

export type VisualDiffOptions = {
/** Pixelmatch anti-alias tolerance (0–1). Default 0.1. */
threshold?: number;
/** Routes below this changed-pixel % are treated as unchanged noise. Default 0.05. */
changeThresholdPercent?: number;
/** Include diff PNG bytes for changed routes. Default true. */
includeDiffImage?: boolean;
};

export type VisualRouteComparison = {
route: string;
status: VisualRouteStatus;
changedPixelPercent: number | null;
width: number | null;
height: number | null;
diffImagePng: Buffer | null;
};

export type VisualDiffSummary = {
generatedAt: string;
routes: VisualRouteComparison[];
changedCount: number;
unchangedCount: number;
newCount: number;
removedCount: number;
overallChangedPixelPercent: number;
summary: string;
};

const DEFAULT_THRESHOLD = 0.1;
const DEFAULT_CHANGE_THRESHOLD_PERCENT = 0.05;

function decodePng(buffer: Buffer): PNG {
return PNG.sync.read(buffer);
}

function changedPercent(diffPixels: number, width: number, height: number): number {
const total = width * height;
return roundPercent((diffPixels / Math.max(total, 1)) * 100);
}

function roundPercent(value: number): number {
return Math.round(value * 10_000) / 10_000;
}

function resolveOptions(options: VisualDiffOptions | undefined) {
return {
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
changeThresholdPercent: options?.changeThresholdPercent ?? DEFAULT_CHANGE_THRESHOLD_PERCENT,
includeDiffImage: options?.includeDiffImage ?? true,
};
}

function comparePair(route: string, before: Buffer, after: Buffer, options: VisualDiffOptions | undefined): VisualRouteComparison {
const resolved = resolveOptions(options);
const beforeImage = decodePng(before);
const afterImage = decodePng(after);
if (beforeImage.width !== afterImage.width || beforeImage.height !== afterImage.height) {
return {
route,
status: "changed",
changedPixelPercent: 100,
width: Math.max(beforeImage.width, afterImage.width),
height: Math.max(beforeImage.height, afterImage.height),
diffImagePng: null,
};
}

const { width, height } = beforeImage;
const diff = new PNG({ width, height });
const diffPixels = pixelmatch(beforeImage.data, afterImage.data, diff.data, width, height, {
threshold: resolved.threshold,
includeAA: true,
});
const changedPixelPercent = changedPercent(diffPixels, width, height);
const status = changedPixelPercent >= resolved.changeThresholdPercent ? "changed" : "unchanged";
return {
route,
status,
changedPixelPercent,
width,
height,
diffImagePng: status === "changed" && resolved.includeDiffImage ? PNG.sync.write(diff) : null,
};
}

export function compareRouteScreenshots(args: {
route: string;
before?: Buffer | null | undefined;
after?: Buffer | null | undefined;
options?: VisualDiffOptions;
}): VisualRouteComparison {
const { route, before, after, options } = args;
if (!before && !after) {
return { route, status: "unchanged", changedPixelPercent: 0, width: null, height: null, diffImagePng: null };
}
if (!before && after) {
const afterImage = decodePng(after);
return {
route,
status: "new",
changedPixelPercent: null,
width: afterImage.width,
height: afterImage.height,
diffImagePng: null,
};
}
if (before && !after) {
const beforeImage = decodePng(before);
return {
route,
status: "removed",
changedPixelPercent: null,
width: beforeImage.width,
height: beforeImage.height,
diffImagePng: null,
};
}
return comparePair(route, before!, after!, options);
}

export function compareVisualCaptureSets(args: {
before: Record<string, Buffer>;
after: Record<string, Buffer>;
options?: VisualDiffOptions;
}): VisualDiffSummary {
const routes = [...new Set([...Object.keys(args.before), ...Object.keys(args.after)])].sort((left, right) => left.localeCompare(right));
const comparisons = routes.map((route) => {
const input: {
route: string;
before?: Buffer;
after?: Buffer;
options?: VisualDiffOptions;
} = { route };
if (args.before[route]) input.before = args.before[route];
if (args.after[route]) input.after = args.after[route];
if (args.options) input.options = args.options;
return compareRouteScreenshots(input);
});

const changed = comparisons.filter((entry) => entry.status === "changed");
const unchanged = comparisons.filter((entry) => entry.status === "unchanged");
const added = comparisons.filter((entry) => entry.status === "new");
const removed = comparisons.filter((entry) => entry.status === "removed");
const measurable = comparisons.filter(
(entry): entry is VisualRouteComparison & { changedPixelPercent: number } => entry.changedPixelPercent !== null,
);
const overallChangedPixelPercent =
measurable.length > 0
? roundPercent(measurable.reduce((sum, entry) => sum + entry.changedPixelPercent, 0) / measurable.length)
: 0;

const summary =
changed.length > 0
? `${changed.length} route(s) changed (${overallChangedPixelPercent}% avg changed pixels); ${unchanged.length} unchanged, ${added.length} new, ${removed.length} removed.`
: `${unchanged.length} route(s) unchanged; ${added.length} new, ${removed.length} removed.`;

return {
generatedAt: new Date().toISOString(),
routes: comparisons,
changedCount: changed.length,
unchangedCount: unchanged.length,
newCount: added.length,
removedCount: removed.length,
overallChangedPixelPercent,
summary,
};
}
Loading
Loading