@americano98/peye 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +426 -0
- package/dist/bin.js +3219 -0
- package/dist/bin.js.map +1 -0
- package/package.json +69 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,3219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/bin.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "@americano98/peye",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
description: "Standalone CLI for visual diffing UI screenshots against Figma references.",
|
|
11
|
+
type: "module",
|
|
12
|
+
packageManager: "pnpm@10.11.0",
|
|
13
|
+
bin: {
|
|
14
|
+
peye: "dist/bin.js"
|
|
15
|
+
},
|
|
16
|
+
publishConfig: {
|
|
17
|
+
access: "public"
|
|
18
|
+
},
|
|
19
|
+
files: [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
engines: {
|
|
23
|
+
node: ">=22"
|
|
24
|
+
},
|
|
25
|
+
scripts: {
|
|
26
|
+
build: "tsup",
|
|
27
|
+
clean: "rm -rf coverage dist test-output peye-output",
|
|
28
|
+
dev: "tsx src/cli/bin.ts",
|
|
29
|
+
format: "prettier . --write",
|
|
30
|
+
"format:check": "prettier . --check",
|
|
31
|
+
lint: "eslint .",
|
|
32
|
+
"lint:fix": "eslint . --fix",
|
|
33
|
+
prepack: "pnpm check",
|
|
34
|
+
test: "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
typecheck: "tsc --noEmit",
|
|
37
|
+
check: "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test && pnpm build"
|
|
38
|
+
},
|
|
39
|
+
keywords: [
|
|
40
|
+
"cli",
|
|
41
|
+
"visual-diff",
|
|
42
|
+
"figma",
|
|
43
|
+
"playwright",
|
|
44
|
+
"pixelmatch"
|
|
45
|
+
],
|
|
46
|
+
homepage: "https://github.com/americano98/peye#readme",
|
|
47
|
+
repository: {
|
|
48
|
+
type: "git",
|
|
49
|
+
url: "git+https://github.com/americano98/peye.git"
|
|
50
|
+
},
|
|
51
|
+
bugs: {
|
|
52
|
+
url: "https://github.com/americano98/peye/issues"
|
|
53
|
+
},
|
|
54
|
+
license: "MIT",
|
|
55
|
+
dependencies: {
|
|
56
|
+
"@modelcontextprotocol/sdk": "1.27.1",
|
|
57
|
+
commander: "^14.0.3",
|
|
58
|
+
pixelmatch: "7.1.0",
|
|
59
|
+
playwright: "1.58.2",
|
|
60
|
+
sharp: "0.34.5"
|
|
61
|
+
},
|
|
62
|
+
devDependencies: {
|
|
63
|
+
"@eslint/js": "10.0.1",
|
|
64
|
+
"@types/node": "^22.19.15",
|
|
65
|
+
eslint: "10.0.3",
|
|
66
|
+
"eslint-config-prettier": "10.1.8",
|
|
67
|
+
globals: "17.4.0",
|
|
68
|
+
prettier: "3.8.1",
|
|
69
|
+
tsup: "8.5.1",
|
|
70
|
+
tsx: "4.21.0",
|
|
71
|
+
typescript: "5.9.3",
|
|
72
|
+
"typescript-eslint": "8.57.1",
|
|
73
|
+
vitest: "^4.1.0"
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/cli/compare-command.ts
|
|
78
|
+
import path7 from "path";
|
|
79
|
+
|
|
80
|
+
// src/config/defaults.ts
|
|
81
|
+
var DEFAULT_MODE = "all";
|
|
82
|
+
var DEFAULT_THRESHOLDS = {
|
|
83
|
+
pass: 0.5,
|
|
84
|
+
tolerated: 1.5,
|
|
85
|
+
retry: 5
|
|
86
|
+
};
|
|
87
|
+
var DEFAULT_PIXELMATCH_THRESHOLD = 0.1;
|
|
88
|
+
var DEFAULT_EDGE_THRESHOLD = 96;
|
|
89
|
+
var DEFAULT_MIN_REGION_PIXELS = 9;
|
|
90
|
+
var DEFAULT_CAPTURE_DELAY_MS = 250;
|
|
91
|
+
var DEFAULT_VIEWPORT_HEIGHT = 900;
|
|
92
|
+
var DEFAULT_NAVIGATION_TIMEOUT_MS = 3e4;
|
|
93
|
+
var DEFAULT_RESOURCE_TIMEOUT_MS = 15e3;
|
|
94
|
+
var DEFAULT_FONT_READY_TIMEOUT_MS = 5e3;
|
|
95
|
+
var DEFAULT_REPORT_FINDINGS_LIMIT = 25;
|
|
96
|
+
var DEFAULT_MAX_TEXT_SNIPPET_LENGTH = 80;
|
|
97
|
+
var DEFAULT_MAX_SELECTOR_LENGTH = 120;
|
|
98
|
+
var DEFAULT_MAX_TAG_ROLLUPS = 10;
|
|
99
|
+
var DEFAULT_DOM_OVERLAP_THRESHOLD = 0.3;
|
|
100
|
+
var DEFAULT_CLUSTER_PADDING_PX = 8;
|
|
101
|
+
var DEFAULT_HOTSPOT_CLUSTER_PADDING_PX = 6;
|
|
102
|
+
var DEFAULT_HOTSPOT_LIMIT_PER_FINDING = 5;
|
|
103
|
+
var DEFAULT_FIGMA_SOURCE = "auto";
|
|
104
|
+
var DEFAULT_FIGMA_API_BASE_URL = "https://api.figma.com";
|
|
105
|
+
var DEFAULT_FIGMA_MCP_DESKTOP_URL = "http://127.0.0.1:3845/mcp";
|
|
106
|
+
var DEFAULT_FIGMA_MCP_REMOTE_URL = "https://mcp.figma.com/mcp";
|
|
107
|
+
var DEFAULT_FIGMA_OAUTH_TIMEOUT_MS = 18e4;
|
|
108
|
+
var LAYOUT_EDGE_RATIO_THRESHOLD = 0.35;
|
|
109
|
+
var COLOR_REGION_DELTA_THRESHOLD = 10;
|
|
110
|
+
var STRONG_DIMENSION_DELTA_PX = 40;
|
|
111
|
+
var STRONG_DIMENSION_ASPECT_DELTA = 0.1;
|
|
112
|
+
var HUMAN_REVIEW_MISMATCH_MULTIPLIER = 3;
|
|
113
|
+
var MAX_RGB_DISTANCE = 441.6729559300637;
|
|
114
|
+
|
|
115
|
+
// src/core/run-compare.ts
|
|
116
|
+
import path6 from "path";
|
|
117
|
+
|
|
118
|
+
// src/utils/errors.ts
|
|
119
|
+
var AppError = class extends Error {
|
|
120
|
+
code;
|
|
121
|
+
exitCode;
|
|
122
|
+
recommendation;
|
|
123
|
+
severity;
|
|
124
|
+
constructor(message, options) {
|
|
125
|
+
super(message, options?.cause ? { cause: options.cause } : void 0);
|
|
126
|
+
this.name = "AppError";
|
|
127
|
+
this.code = options?.code ?? "app_error";
|
|
128
|
+
this.exitCode = options?.exitCode ?? 1;
|
|
129
|
+
this.recommendation = options?.recommendation ?? null;
|
|
130
|
+
this.severity = options?.severity ?? null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
function isAppError(error) {
|
|
134
|
+
return error instanceof AppError;
|
|
135
|
+
}
|
|
136
|
+
function ensureError(error) {
|
|
137
|
+
if (error instanceof Error) {
|
|
138
|
+
return error;
|
|
139
|
+
}
|
|
140
|
+
return new Error(typeof error === "string" ? error : "Unknown error");
|
|
141
|
+
}
|
|
142
|
+
function createFailureReport(params) {
|
|
143
|
+
const emptyMetrics = {
|
|
144
|
+
mismatchPixels: 0,
|
|
145
|
+
mismatchPercent: 0,
|
|
146
|
+
meanColorDelta: null,
|
|
147
|
+
maxColorDelta: null,
|
|
148
|
+
structuralMismatchPercent: null,
|
|
149
|
+
dimensionMismatch: {
|
|
150
|
+
widthDelta: 0,
|
|
151
|
+
heightDelta: 0,
|
|
152
|
+
aspectRatioDelta: 0,
|
|
153
|
+
hasMismatch: false
|
|
154
|
+
},
|
|
155
|
+
findingsCount: 0,
|
|
156
|
+
affectedElementCount: 0
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
analysisMode: params.analysisMode,
|
|
160
|
+
summary: {
|
|
161
|
+
recommendation: params.recommendation,
|
|
162
|
+
severity: params.severity,
|
|
163
|
+
reason: params.reason
|
|
164
|
+
},
|
|
165
|
+
inputs: {
|
|
166
|
+
preview: params.preview,
|
|
167
|
+
reference: params.reference,
|
|
168
|
+
viewport: params.viewport,
|
|
169
|
+
mode: params.mode,
|
|
170
|
+
fullPage: params.fullPage
|
|
171
|
+
},
|
|
172
|
+
images: params.images,
|
|
173
|
+
metrics: emptyMetrics,
|
|
174
|
+
rollups: {
|
|
175
|
+
bySeverity: [],
|
|
176
|
+
byKind: [],
|
|
177
|
+
byTag: [],
|
|
178
|
+
rawRegionCount: 0,
|
|
179
|
+
findingsCount: 0,
|
|
180
|
+
affectedElementCount: 0,
|
|
181
|
+
omittedFindings: 0
|
|
182
|
+
},
|
|
183
|
+
findings: [],
|
|
184
|
+
artifacts: params.artifacts,
|
|
185
|
+
error: params.error
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/utils/severity.ts
|
|
190
|
+
var SEVERITY_RANK = {
|
|
191
|
+
low: 0,
|
|
192
|
+
medium: 1,
|
|
193
|
+
high: 2,
|
|
194
|
+
critical: 3
|
|
195
|
+
};
|
|
196
|
+
function maxSeverity(values) {
|
|
197
|
+
if (values.length === 0) {
|
|
198
|
+
return "low";
|
|
199
|
+
}
|
|
200
|
+
return values.reduce(
|
|
201
|
+
(current, candidate) => SEVERITY_RANK[candidate] > SEVERITY_RANK[current] ? candidate : current
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function compareSeverityDescending(left, right) {
|
|
205
|
+
return SEVERITY_RANK[right] - SEVERITY_RANK[left];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/analysis/findings.ts
|
|
209
|
+
var FINDING_KIND_ORDER = ["dimension", "mixed", "layout", "color", "pixel"];
|
|
210
|
+
function buildFindingsAnalysis(params) {
|
|
211
|
+
const totalPixels = params.width * params.height;
|
|
212
|
+
const draftFindingGroups = params.analysisMode === "dom-elements" ? buildDomFindings(
|
|
213
|
+
params.rawRegions,
|
|
214
|
+
params.domSnapshot,
|
|
215
|
+
params.width,
|
|
216
|
+
params.height,
|
|
217
|
+
totalPixels
|
|
218
|
+
) : buildVisualClusterFindings(params.rawRegions, totalPixels, params.width, params.height);
|
|
219
|
+
const sortedGroups = draftFindingGroups.sort(
|
|
220
|
+
(left, right) => compareDraftFindings(left.finding, right.finding)
|
|
221
|
+
);
|
|
222
|
+
const sortedFindings = sortedGroups.map((group) => group.finding);
|
|
223
|
+
const limitedGroups = sortedGroups.slice(0, DEFAULT_REPORT_FINDINGS_LIMIT);
|
|
224
|
+
const limitedFindings = limitedGroups.map((group) => group.finding);
|
|
225
|
+
const affectedElementCount = params.analysisMode === "dom-elements" ? new Set(
|
|
226
|
+
sortedFindings.map((finding) => finding.element?.selector ?? null).filter((selector) => selector !== null)
|
|
227
|
+
).size : 0;
|
|
228
|
+
return {
|
|
229
|
+
findings: limitedFindings.map((finding, index) => ({
|
|
230
|
+
id: `finding-${String(index + 1).padStart(3, "0")}`,
|
|
231
|
+
...finding
|
|
232
|
+
})),
|
|
233
|
+
rollups: {
|
|
234
|
+
bySeverity: buildSeverityRollups(sortedFindings),
|
|
235
|
+
byKind: buildKindRollups(sortedFindings),
|
|
236
|
+
byTag: params.analysisMode === "dom-elements" ? buildTagRollups(sortedFindings).slice(0, DEFAULT_MAX_TAG_ROLLUPS) : [],
|
|
237
|
+
rawRegionCount: params.rawRegions.length,
|
|
238
|
+
findingsCount: sortedFindings.length,
|
|
239
|
+
affectedElementCount,
|
|
240
|
+
omittedFindings: Math.max(0, sortedFindings.length - limitedFindings.length)
|
|
241
|
+
},
|
|
242
|
+
metrics: {
|
|
243
|
+
findingsCount: sortedFindings.length,
|
|
244
|
+
affectedElementCount
|
|
245
|
+
},
|
|
246
|
+
visuals: sortedGroups.map((group) => {
|
|
247
|
+
const primaryBox = group.finding.element?.bbox ?? group.finding.bbox;
|
|
248
|
+
return {
|
|
249
|
+
severity: group.finding.severity,
|
|
250
|
+
primaryBox,
|
|
251
|
+
hotspotBoxes: group.finding.hotspots
|
|
252
|
+
};
|
|
253
|
+
})
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function buildDomFindings(rawRegions, domSnapshot, width, height, totalPixels) {
|
|
257
|
+
if (!domSnapshot) {
|
|
258
|
+
throw new AppError("Preview URL capture did not produce a DOM snapshot.", {
|
|
259
|
+
exitCode: 3,
|
|
260
|
+
recommendation: "needs_human_review",
|
|
261
|
+
severity: "high",
|
|
262
|
+
code: "dom_snapshot_missing"
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (rawRegions.length === 0) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const candidates = buildDomCandidates(domSnapshot, width, height);
|
|
269
|
+
const regionsByElement = /* @__PURE__ */ new Map();
|
|
270
|
+
const elementsById = new Map(candidates.map((element) => [element.id, element]));
|
|
271
|
+
for (const region of rawRegions) {
|
|
272
|
+
const element = resolveDomElementForRegion(region, candidates);
|
|
273
|
+
const existing = regionsByElement.get(element.id);
|
|
274
|
+
if (existing) {
|
|
275
|
+
existing.push(region);
|
|
276
|
+
} else {
|
|
277
|
+
regionsByElement.set(element.id, [region]);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return Array.from(regionsByElement.entries(), ([elementId, regions]) => {
|
|
281
|
+
const element = elementsById.get(elementId);
|
|
282
|
+
if (!element) {
|
|
283
|
+
throw new AppError(`DOM element snapshot missing for finding group ${elementId}.`, {
|
|
284
|
+
exitCode: 3,
|
|
285
|
+
recommendation: "needs_human_review",
|
|
286
|
+
severity: "high",
|
|
287
|
+
code: "dom_snapshot_element_missing"
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
finding: buildDraftFinding({
|
|
292
|
+
source: "dom-element",
|
|
293
|
+
regions,
|
|
294
|
+
totalPixels,
|
|
295
|
+
element,
|
|
296
|
+
canvasWidth: width,
|
|
297
|
+
canvasHeight: height
|
|
298
|
+
}),
|
|
299
|
+
regions
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function buildVisualClusterFindings(rawRegions, totalPixels, width, height) {
|
|
304
|
+
if (rawRegions.length === 0) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const clusters = clusterRegions(rawRegions);
|
|
308
|
+
return clusters.map((cluster) => ({
|
|
309
|
+
finding: buildDraftFinding({
|
|
310
|
+
source: "visual-cluster",
|
|
311
|
+
regions: cluster,
|
|
312
|
+
totalPixels,
|
|
313
|
+
element: null,
|
|
314
|
+
canvasWidth: width,
|
|
315
|
+
canvasHeight: height
|
|
316
|
+
}),
|
|
317
|
+
regions: cluster
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
function buildDraftFinding(params) {
|
|
321
|
+
const bbox = unionRegionBoxes(params.regions);
|
|
322
|
+
const mismatchPixels = params.regions.reduce((sum, region) => sum + region.pixelCount, 0);
|
|
323
|
+
const kind = aggregateKind(params.regions);
|
|
324
|
+
const elementReport = params.element ? toElementReport(params.element) : null;
|
|
325
|
+
const primaryBox = elementReport?.bbox ?? bbox;
|
|
326
|
+
return {
|
|
327
|
+
source: params.source,
|
|
328
|
+
kind,
|
|
329
|
+
severity: maxSeverity(params.regions.map((region) => region.severity)),
|
|
330
|
+
summary: buildFindingSummary(kind, elementReport?.tag ?? null),
|
|
331
|
+
bbox,
|
|
332
|
+
regionCount: params.regions.length,
|
|
333
|
+
mismatchPixels,
|
|
334
|
+
mismatchPercentOfCanvas: params.totalPixels === 0 ? 0 : Number((mismatchPixels / params.totalPixels * 100).toFixed(4)),
|
|
335
|
+
issueTypes: issueTypesForKind(kind),
|
|
336
|
+
signals: buildFindingSignals({
|
|
337
|
+
kind,
|
|
338
|
+
bbox,
|
|
339
|
+
element: params.element,
|
|
340
|
+
canvasWidth: params.canvasWidth,
|
|
341
|
+
canvasHeight: params.canvasHeight
|
|
342
|
+
}),
|
|
343
|
+
hotspots: buildHotspotBoxes(params.regions, primaryBox),
|
|
344
|
+
element: elementReport
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function buildDomCandidates(domSnapshot, width, height) {
|
|
348
|
+
const root = {
|
|
349
|
+
...domSnapshot.root,
|
|
350
|
+
bbox: {
|
|
351
|
+
x: 0,
|
|
352
|
+
y: 0,
|
|
353
|
+
width,
|
|
354
|
+
height
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
return [root, ...domSnapshot.elements];
|
|
358
|
+
}
|
|
359
|
+
function resolveDomElementForRegion(region, candidates) {
|
|
360
|
+
const centerX = region.x + region.width / 2;
|
|
361
|
+
const centerY = region.y + region.height / 2;
|
|
362
|
+
const containing = candidates.filter(
|
|
363
|
+
(candidate) => containsPoint(candidate.bbox, centerX, centerY)
|
|
364
|
+
);
|
|
365
|
+
if (containing.length > 0) {
|
|
366
|
+
return containing.sort(compareDomCandidates)[0];
|
|
367
|
+
}
|
|
368
|
+
const regionBox = {
|
|
369
|
+
x: region.x,
|
|
370
|
+
y: region.y,
|
|
371
|
+
width: region.width,
|
|
372
|
+
height: region.height
|
|
373
|
+
};
|
|
374
|
+
let bestCandidate = null;
|
|
375
|
+
let bestOverlap = 0;
|
|
376
|
+
for (const candidate of candidates) {
|
|
377
|
+
const overlap = overlapRatio(regionBox, candidate.bbox);
|
|
378
|
+
if (overlap > bestOverlap) {
|
|
379
|
+
bestCandidate = candidate;
|
|
380
|
+
bestOverlap = overlap;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (overlap === bestOverlap && bestCandidate && compareDomCandidates(candidate, bestCandidate) < 0) {
|
|
384
|
+
bestCandidate = candidate;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (bestCandidate && bestOverlap >= DEFAULT_DOM_OVERLAP_THRESHOLD) {
|
|
388
|
+
return bestCandidate;
|
|
389
|
+
}
|
|
390
|
+
throw new AppError(
|
|
391
|
+
`Could not assign mismatch region at (${region.x}, ${region.y}, ${region.width}x${region.height}) to a DOM element.`,
|
|
392
|
+
{
|
|
393
|
+
exitCode: 3,
|
|
394
|
+
recommendation: "needs_human_review",
|
|
395
|
+
severity: "high",
|
|
396
|
+
code: "dom_region_assignment_failed"
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
function compareDomCandidates(left, right) {
|
|
401
|
+
if (left.depth !== right.depth) {
|
|
402
|
+
return right.depth - left.depth;
|
|
403
|
+
}
|
|
404
|
+
const leftArea = left.bbox.width * left.bbox.height;
|
|
405
|
+
const rightArea = right.bbox.width * right.bbox.height;
|
|
406
|
+
if (leftArea !== rightArea) {
|
|
407
|
+
return leftArea - rightArea;
|
|
408
|
+
}
|
|
409
|
+
return left.selector.localeCompare(right.selector);
|
|
410
|
+
}
|
|
411
|
+
function clusterRegions(rawRegions, padding = DEFAULT_CLUSTER_PADDING_PX) {
|
|
412
|
+
const visited = new Uint8Array(rawRegions.length);
|
|
413
|
+
const clusters = [];
|
|
414
|
+
for (let index = 0; index < rawRegions.length; index += 1) {
|
|
415
|
+
if (visited[index] === 1) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
visited[index] = 1;
|
|
419
|
+
const queue = [index];
|
|
420
|
+
const cluster = [];
|
|
421
|
+
while (queue.length > 0) {
|
|
422
|
+
const currentIndex = queue.shift();
|
|
423
|
+
if (currentIndex === void 0) {
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
const currentRegion = rawRegions[currentIndex];
|
|
427
|
+
cluster.push(currentRegion);
|
|
428
|
+
for (let candidateIndex = 0; candidateIndex < rawRegions.length; candidateIndex += 1) {
|
|
429
|
+
if (visited[candidateIndex] === 1) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (expandedBoxesIntersect(currentRegion, rawRegions[candidateIndex], padding)) {
|
|
433
|
+
visited[candidateIndex] = 1;
|
|
434
|
+
queue.push(candidateIndex);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
clusters.push(cluster);
|
|
439
|
+
}
|
|
440
|
+
return clusters;
|
|
441
|
+
}
|
|
442
|
+
function expandedBoxesIntersect(left, right, padding) {
|
|
443
|
+
return boxesIntersect(expandBox(left, padding), expandBox(right, padding));
|
|
444
|
+
}
|
|
445
|
+
function expandBox(box, padding) {
|
|
446
|
+
return {
|
|
447
|
+
x: box.x - padding,
|
|
448
|
+
y: box.y - padding,
|
|
449
|
+
width: box.width + padding * 2,
|
|
450
|
+
height: box.height + padding * 2
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function boxesIntersect(left, right) {
|
|
454
|
+
return !(left.x + left.width < right.x || right.x + right.width < left.x || left.y + left.height < right.y || right.y + right.height < left.y);
|
|
455
|
+
}
|
|
456
|
+
function containsPoint(box, x, y) {
|
|
457
|
+
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
|
|
458
|
+
}
|
|
459
|
+
function overlapRatio(left, right) {
|
|
460
|
+
const intersectionWidth = Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x);
|
|
461
|
+
const intersectionHeight = Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y);
|
|
462
|
+
if (intersectionWidth <= 0 || intersectionHeight <= 0) {
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
const regionArea = Math.max(1, left.width * left.height);
|
|
466
|
+
return intersectionWidth * intersectionHeight / regionArea;
|
|
467
|
+
}
|
|
468
|
+
function unionRegionBoxes(regions) {
|
|
469
|
+
const minX = Math.min(...regions.map((region) => region.x));
|
|
470
|
+
const minY = Math.min(...regions.map((region) => region.y));
|
|
471
|
+
const maxX = Math.max(...regions.map((region) => region.x + region.width));
|
|
472
|
+
const maxY = Math.max(...regions.map((region) => region.y + region.height));
|
|
473
|
+
return {
|
|
474
|
+
x: minX,
|
|
475
|
+
y: minY,
|
|
476
|
+
width: maxX - minX,
|
|
477
|
+
height: maxY - minY
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function buildHotspotBoxes(regions, primaryBox) {
|
|
481
|
+
if (regions.length <= 1) {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
return clusterRegions(regions, DEFAULT_HOTSPOT_CLUSTER_PADDING_PX).map((cluster) => ({
|
|
485
|
+
bbox: unionRegionBoxes(cluster),
|
|
486
|
+
mismatchPixels: cluster.reduce((sum, region) => sum + region.pixelCount, 0)
|
|
487
|
+
})).filter(({ bbox }) => !isNearlySameBox(bbox, primaryBox)).sort((left, right) => {
|
|
488
|
+
if (left.mismatchPixels !== right.mismatchPixels) {
|
|
489
|
+
return right.mismatchPixels - left.mismatchPixels;
|
|
490
|
+
}
|
|
491
|
+
if (left.bbox.y !== right.bbox.y) {
|
|
492
|
+
return left.bbox.y - right.bbox.y;
|
|
493
|
+
}
|
|
494
|
+
return left.bbox.x - right.bbox.x;
|
|
495
|
+
}).slice(0, DEFAULT_HOTSPOT_LIMIT_PER_FINDING).map(({ bbox }) => bbox);
|
|
496
|
+
}
|
|
497
|
+
function isNearlySameBox(left, right) {
|
|
498
|
+
const leftArea = Math.max(1, left.width * left.height);
|
|
499
|
+
const rightArea = Math.max(1, right.width * right.height);
|
|
500
|
+
const areaRatio = Math.min(leftArea, rightArea) / Math.max(leftArea, rightArea);
|
|
501
|
+
return areaRatio >= 0.9 && overlapRatio(left, right) >= 0.9;
|
|
502
|
+
}
|
|
503
|
+
function aggregateKind(regions) {
|
|
504
|
+
const kinds = new Set(regions.map((region) => region.kind));
|
|
505
|
+
if (kinds.has("dimension")) {
|
|
506
|
+
return "dimension";
|
|
507
|
+
}
|
|
508
|
+
if (kinds.has("mixed")) {
|
|
509
|
+
return "mixed";
|
|
510
|
+
}
|
|
511
|
+
if (kinds.has("layout") && (kinds.has("color") || kinds.has("pixel"))) {
|
|
512
|
+
return "mixed";
|
|
513
|
+
}
|
|
514
|
+
if (kinds.has("layout")) {
|
|
515
|
+
return "layout";
|
|
516
|
+
}
|
|
517
|
+
if (kinds.has("color")) {
|
|
518
|
+
return "color";
|
|
519
|
+
}
|
|
520
|
+
return "pixel";
|
|
521
|
+
}
|
|
522
|
+
function buildFindingSummary(kind, tag) {
|
|
523
|
+
const subject = tag ? `Element <${tag}>` : "Visual cluster";
|
|
524
|
+
switch (kind) {
|
|
525
|
+
case "dimension":
|
|
526
|
+
return `${subject} is missing content, has extra content, or was captured at the wrong canvas size.`;
|
|
527
|
+
case "layout":
|
|
528
|
+
return `${subject} differs in position, spacing, or alignment.`;
|
|
529
|
+
case "color":
|
|
530
|
+
return `${subject} differs in color or visual styling.`;
|
|
531
|
+
case "mixed":
|
|
532
|
+
return `${subject} differs in both layout and styling.`;
|
|
533
|
+
case "pixel":
|
|
534
|
+
default:
|
|
535
|
+
return `${subject} differs in rendering or fine-grained styling.`;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function issueTypesForKind(kind) {
|
|
539
|
+
switch (kind) {
|
|
540
|
+
case "dimension":
|
|
541
|
+
return ["missing_or_extra", "size"];
|
|
542
|
+
case "layout":
|
|
543
|
+
return ["position", "spacing"];
|
|
544
|
+
case "color":
|
|
545
|
+
return ["color", "style"];
|
|
546
|
+
case "mixed":
|
|
547
|
+
return ["position", "spacing", "style"];
|
|
548
|
+
case "pixel":
|
|
549
|
+
default:
|
|
550
|
+
return ["style"];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function buildFindingSignals(params) {
|
|
554
|
+
const signals = [];
|
|
555
|
+
const textClippingSignal = buildTextClippingSignal(params.element);
|
|
556
|
+
if (textClippingSignal) {
|
|
557
|
+
signals.push(textClippingSignal);
|
|
558
|
+
}
|
|
559
|
+
const captureCropSignal = buildCaptureCropSignal(params.element);
|
|
560
|
+
if (captureCropSignal) {
|
|
561
|
+
signals.push(captureCropSignal);
|
|
562
|
+
} else {
|
|
563
|
+
const viewportSignal = buildViewportMismatchSignal(
|
|
564
|
+
params.kind,
|
|
565
|
+
params.bbox,
|
|
566
|
+
params.canvasWidth,
|
|
567
|
+
params.canvasHeight
|
|
568
|
+
);
|
|
569
|
+
if (viewportSignal) {
|
|
570
|
+
signals.push(viewportSignal);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return signals;
|
|
574
|
+
}
|
|
575
|
+
function buildTextClippingSignal(element) {
|
|
576
|
+
if (!element?.textSnippet || !element.textMetrics) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const { textMetrics } = element;
|
|
580
|
+
const hasOverflowX = textMetrics.scrollWidth > textMetrics.clientWidth + 1;
|
|
581
|
+
const hasOverflowY = textMetrics.scrollHeight > textMetrics.clientHeight + 1;
|
|
582
|
+
const clipsX = hasOverflowX && (textMetrics.overflowX === "hidden" || textMetrics.overflowX === "clip" || textMetrics.textOverflow === "ellipsis");
|
|
583
|
+
const lineClampActive = textMetrics.lineClamp !== null && textMetrics.lineClamp !== "" && textMetrics.lineClamp !== "none" && textMetrics.lineClamp !== "0" && textMetrics.lineClamp !== "normal";
|
|
584
|
+
const clipsY = hasOverflowY && (textMetrics.overflowY === "hidden" || textMetrics.overflowY === "clip" || lineClampActive);
|
|
585
|
+
if (!clipsX && !clipsY) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
const axis = clipsX && clipsY ? "horizontal and vertical axes" : clipsX ? "horizontal axis" : "vertical axis";
|
|
589
|
+
return {
|
|
590
|
+
code: "probable_text_clipping",
|
|
591
|
+
confidence: lineClampActive || clipsX && clipsY ? "high" : "medium",
|
|
592
|
+
message: `Text content likely overflows the element bounds and is being clipped on the ${axis}.`
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function buildCaptureCropSignal(element) {
|
|
596
|
+
if (!element || element.captureClippedEdges.length === 0) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
code: "possible_capture_crop",
|
|
601
|
+
confidence: "high",
|
|
602
|
+
message: `Element bounds were clipped by the preview capture on the ${formatEdgeList(element.captureClippedEdges)} edge(s); check selector scope and capture framing.`
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function buildViewportMismatchSignal(kind, bbox, canvasWidth, canvasHeight) {
|
|
606
|
+
if (kind !== "dimension") {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const touchingEdges = boxEdgesAgainstCanvas(bbox, canvasWidth, canvasHeight);
|
|
610
|
+
if (touchingEdges.length === 0) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
code: "possible_viewport_mismatch",
|
|
615
|
+
confidence: "medium",
|
|
616
|
+
message: `Dimension mismatch reaches the ${formatEdgeList(touchingEdges)} edge(s) of the comparison canvas; verify viewport, selected frame, and capture target.`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function boxEdgesAgainstCanvas(box, canvasWidth, canvasHeight) {
|
|
620
|
+
const edges = [];
|
|
621
|
+
if (box.y <= 0) {
|
|
622
|
+
edges.push("top");
|
|
623
|
+
}
|
|
624
|
+
if (box.x + box.width >= canvasWidth) {
|
|
625
|
+
edges.push("right");
|
|
626
|
+
}
|
|
627
|
+
if (box.y + box.height >= canvasHeight) {
|
|
628
|
+
edges.push("bottom");
|
|
629
|
+
}
|
|
630
|
+
if (box.x <= 0) {
|
|
631
|
+
edges.push("left");
|
|
632
|
+
}
|
|
633
|
+
return edges;
|
|
634
|
+
}
|
|
635
|
+
function formatEdgeList(edges) {
|
|
636
|
+
if (edges.length === 1) {
|
|
637
|
+
return edges[0];
|
|
638
|
+
}
|
|
639
|
+
if (edges.length === 2) {
|
|
640
|
+
return `${edges[0]} and ${edges[1]}`;
|
|
641
|
+
}
|
|
642
|
+
return `${edges.slice(0, -1).join(", ")}, and ${edges[edges.length - 1]}`;
|
|
643
|
+
}
|
|
644
|
+
function toElementReport(element) {
|
|
645
|
+
return {
|
|
646
|
+
tag: element.tag,
|
|
647
|
+
selector: element.selector,
|
|
648
|
+
role: element.role,
|
|
649
|
+
textSnippet: element.textSnippet,
|
|
650
|
+
bbox: element.bbox
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function buildSeverityRollups(findings) {
|
|
654
|
+
const severityCounts = /* @__PURE__ */ new Map();
|
|
655
|
+
for (const finding of findings) {
|
|
656
|
+
severityCounts.set(finding.severity, (severityCounts.get(finding.severity) ?? 0) + 1);
|
|
657
|
+
}
|
|
658
|
+
return Array.from(severityCounts.entries()).map(([severity, count]) => ({ severity, count })).sort((left, right) => compareSeverityDescending(left.severity, right.severity));
|
|
659
|
+
}
|
|
660
|
+
function buildKindRollups(findings) {
|
|
661
|
+
const kindCounts = /* @__PURE__ */ new Map();
|
|
662
|
+
for (const finding of findings) {
|
|
663
|
+
kindCounts.set(finding.kind, (kindCounts.get(finding.kind) ?? 0) + 1);
|
|
664
|
+
}
|
|
665
|
+
return Array.from(kindCounts.entries()).map(([kind, count]) => ({ kind, count })).sort(
|
|
666
|
+
(left, right) => FINDING_KIND_ORDER.indexOf(left.kind) - FINDING_KIND_ORDER.indexOf(right.kind)
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
function buildTagRollups(findings) {
|
|
670
|
+
const tagCounts = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const finding of findings) {
|
|
672
|
+
const tag = finding.element?.tag;
|
|
673
|
+
if (!tag) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
|
677
|
+
}
|
|
678
|
+
return Array.from(tagCounts.entries()).map(([tag, count]) => ({ tag, count })).sort((left, right) => {
|
|
679
|
+
if (left.count !== right.count) {
|
|
680
|
+
return right.count - left.count;
|
|
681
|
+
}
|
|
682
|
+
return left.tag.localeCompare(right.tag);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
function compareDraftFindings(left, right) {
|
|
686
|
+
const severityOrder = compareSeverityDescending(left.severity, right.severity);
|
|
687
|
+
if (severityOrder !== 0) {
|
|
688
|
+
return severityOrder;
|
|
689
|
+
}
|
|
690
|
+
if (left.mismatchPixels !== right.mismatchPixels) {
|
|
691
|
+
return right.mismatchPixels - left.mismatchPixels;
|
|
692
|
+
}
|
|
693
|
+
if (left.bbox.y !== right.bbox.y) {
|
|
694
|
+
return left.bbox.y - right.bbox.y;
|
|
695
|
+
}
|
|
696
|
+
if (left.bbox.x !== right.bbox.x) {
|
|
697
|
+
return left.bbox.x - right.bbox.x;
|
|
698
|
+
}
|
|
699
|
+
return left.summary.localeCompare(right.summary);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/analysis/recommendation.ts
|
|
703
|
+
function decideRecommendation(params) {
|
|
704
|
+
const { metrics, thresholds, findings } = params;
|
|
705
|
+
const highestFindingSeverity = maxSeverity(findings.map((finding) => finding.severity));
|
|
706
|
+
const strongDimensionMismatch = hasStrongDimensionMismatch(metrics);
|
|
707
|
+
if (strongDimensionMismatch || metrics.mismatchPercent > thresholds.retry * HUMAN_REVIEW_MISMATCH_MULTIPLIER) {
|
|
708
|
+
return {
|
|
709
|
+
recommendation: "needs_human_review",
|
|
710
|
+
severity: maxSeverity([
|
|
711
|
+
highestFindingSeverity,
|
|
712
|
+
strongDimensionMismatch ? "critical" : "high"
|
|
713
|
+
]),
|
|
714
|
+
reason: strongDimensionMismatch ? "Reference and preview dimensions diverge too much for a reliable automated verdict." : `Mismatch percentage ${metrics.mismatchPercent.toFixed(2)}% is too large for an automated fix recommendation.`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
if (metrics.mismatchPercent <= thresholds.pass && highestFindingSeverity === "low") {
|
|
718
|
+
return {
|
|
719
|
+
recommendation: "pass",
|
|
720
|
+
severity: "low",
|
|
721
|
+
reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%, within the strict pass threshold.`
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (metrics.mismatchPercent <= thresholds.tolerated && highestFindingSeverity === "low") {
|
|
725
|
+
return {
|
|
726
|
+
recommendation: "pass_with_tolerated_differences",
|
|
727
|
+
severity: "low",
|
|
728
|
+
reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%, within the tolerated threshold.`
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (metrics.mismatchPercent <= thresholds.retry || highestFindingSeverity === "medium" || highestFindingSeverity === "high") {
|
|
732
|
+
return {
|
|
733
|
+
recommendation: "retry_fix",
|
|
734
|
+
severity: highestFindingSeverity === "critical" ? "high" : highestFindingSeverity,
|
|
735
|
+
reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%; localized issues were detected and should be fixed before retrying.`
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
recommendation: "needs_human_review",
|
|
740
|
+
severity: maxSeverity([highestFindingSeverity, "high"]),
|
|
741
|
+
reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}% and exceeds the retry threshold.`
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function hasStrongDimensionMismatch(metrics) {
|
|
745
|
+
return metrics.dimensionMismatch.hasMismatch && (Math.abs(metrics.dimensionMismatch.widthDelta) >= STRONG_DIMENSION_DELTA_PX || Math.abs(metrics.dimensionMismatch.heightDelta) >= STRONG_DIMENSION_DELTA_PX || metrics.dimensionMismatch.aspectRatioDelta >= STRONG_DIMENSION_ASPECT_DELTA);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/capture/playwright-capture.ts
|
|
749
|
+
import { unlink } from "fs/promises";
|
|
750
|
+
import path2 from "path";
|
|
751
|
+
import { chromium } from "playwright";
|
|
752
|
+
|
|
753
|
+
// src/io/image.ts
|
|
754
|
+
import { writeFile } from "fs/promises";
|
|
755
|
+
import path from "path";
|
|
756
|
+
import sharp from "sharp";
|
|
757
|
+
async function normalizeImageToPng(inputPath, outputPath) {
|
|
758
|
+
return normalizeSourceToPng(inputPath, outputPath, `Failed to normalize image: ${inputPath}`);
|
|
759
|
+
}
|
|
760
|
+
async function bufferToNormalizedPng(buffer, outputPath, options) {
|
|
761
|
+
return normalizeSourceToPng(
|
|
762
|
+
buffer,
|
|
763
|
+
outputPath,
|
|
764
|
+
"Failed to normalize in-memory image buffer.",
|
|
765
|
+
options
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
async function loadNormalizedImage(imagePath) {
|
|
769
|
+
try {
|
|
770
|
+
const { data, info } = await sharp(imagePath).rotate().ensureAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
|
|
771
|
+
return {
|
|
772
|
+
width: info.width,
|
|
773
|
+
height: info.height,
|
|
774
|
+
data: new Uint8ClampedArray(data)
|
|
775
|
+
};
|
|
776
|
+
} catch (error) {
|
|
777
|
+
throw new AppError(`Failed to load normalized image data: ${imagePath}`, {
|
|
778
|
+
code: "image_load_failed",
|
|
779
|
+
cause: error
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function padImageToCanvas(image, width, height) {
|
|
784
|
+
if (image.width === width && image.height === height) {
|
|
785
|
+
return image;
|
|
786
|
+
}
|
|
787
|
+
const target = new Uint8ClampedArray(width * height * 4);
|
|
788
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
789
|
+
const sourceOffset = y * image.width * 4;
|
|
790
|
+
const targetOffset = y * width * 4;
|
|
791
|
+
target.set(image.data.subarray(sourceOffset, sourceOffset + image.width * 4), targetOffset);
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
width,
|
|
795
|
+
height,
|
|
796
|
+
data: target
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
async function writeRawRgbaPng(outputPath, data, width, height) {
|
|
800
|
+
try {
|
|
801
|
+
await sharp(Buffer.from(data), {
|
|
802
|
+
raw: {
|
|
803
|
+
width,
|
|
804
|
+
height,
|
|
805
|
+
channels: 4
|
|
806
|
+
}
|
|
807
|
+
}).png().toFile(outputPath);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
throw new AppError(`Failed to write PNG artifact: ${outputPath}`, {
|
|
810
|
+
code: "artifact_write_failed",
|
|
811
|
+
cause: error
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async function normalizeSourceToPng(input, outputPath, errorMessage, options) {
|
|
816
|
+
const resolvedOutputPath = path.resolve(outputPath);
|
|
817
|
+
try {
|
|
818
|
+
let pipeline = sharp(input).rotate().ensureAlpha().toColorspace("srgb");
|
|
819
|
+
if (options?.resizeTo) {
|
|
820
|
+
pipeline = pipeline.resize(options.resizeTo.width, options.resizeTo.height, {
|
|
821
|
+
fit: "fill",
|
|
822
|
+
kernel: sharp.kernel.lanczos3
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
|
|
826
|
+
if (!info.width || !info.height) {
|
|
827
|
+
throw new AppError(`Could not read normalized image dimensions for ${resolvedOutputPath}`, {
|
|
828
|
+
code: "image_dimensions_missing"
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
await writeFile(resolvedOutputPath, data);
|
|
832
|
+
return {
|
|
833
|
+
path: resolvedOutputPath,
|
|
834
|
+
width: info.width,
|
|
835
|
+
height: info.height
|
|
836
|
+
};
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (error instanceof AppError) {
|
|
839
|
+
throw error;
|
|
840
|
+
}
|
|
841
|
+
throw new AppError(`${errorMessage}: ${ensureError(error).message}`, {
|
|
842
|
+
code: "image_normalization_failed",
|
|
843
|
+
cause: error
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/capture/playwright-capture.ts
|
|
849
|
+
async function materializePreviewImage(preview, outputPath, fullPage) {
|
|
850
|
+
if (preview.kind === "path") {
|
|
851
|
+
try {
|
|
852
|
+
const normalized = await normalizeImageToPng(preview.resolved, outputPath);
|
|
853
|
+
return {
|
|
854
|
+
...normalized,
|
|
855
|
+
analysisMode: "visual-clusters",
|
|
856
|
+
domSnapshot: null
|
|
857
|
+
};
|
|
858
|
+
} catch (error) {
|
|
859
|
+
throw new AppError(
|
|
860
|
+
`Failed to normalize preview image: ${preview.resolved}. ${ensureError(error).message}`,
|
|
861
|
+
{
|
|
862
|
+
code: "preview_image_normalization_failed",
|
|
863
|
+
cause: error
|
|
864
|
+
}
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const temporaryCapturePath = buildTemporaryCapturePath(outputPath);
|
|
869
|
+
const browser = await chromium.launch({ headless: true });
|
|
870
|
+
let domSnapshot;
|
|
871
|
+
try {
|
|
872
|
+
const page = await browser.newPage({
|
|
873
|
+
viewport: preview.viewport,
|
|
874
|
+
deviceScaleFactor: 1
|
|
875
|
+
});
|
|
876
|
+
await navigateForCapture(page, preview.resolved);
|
|
877
|
+
await waitForCaptureStability(page);
|
|
878
|
+
if (preview.selector !== null) {
|
|
879
|
+
domSnapshot = await captureSelectorScreenshot(page, preview.selector, temporaryCapturePath);
|
|
880
|
+
} else {
|
|
881
|
+
domSnapshot = await collectPageDomSnapshot(page, fullPage);
|
|
882
|
+
await page.screenshot({
|
|
883
|
+
path: temporaryCapturePath,
|
|
884
|
+
animations: "disabled",
|
|
885
|
+
caret: "hide",
|
|
886
|
+
fullPage,
|
|
887
|
+
scale: "css",
|
|
888
|
+
type: "png"
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
} finally {
|
|
892
|
+
await browser.close();
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
const normalized = await normalizeImageToPng(temporaryCapturePath, outputPath);
|
|
896
|
+
return {
|
|
897
|
+
...normalized,
|
|
898
|
+
analysisMode: "dom-elements",
|
|
899
|
+
domSnapshot
|
|
900
|
+
};
|
|
901
|
+
} catch (error) {
|
|
902
|
+
throw new AppError(
|
|
903
|
+
`Failed to normalize captured preview image: ${temporaryCapturePath}. ${ensureError(error).message}`,
|
|
904
|
+
{
|
|
905
|
+
code: "preview_capture_normalization_failed",
|
|
906
|
+
cause: error
|
|
907
|
+
}
|
|
908
|
+
);
|
|
909
|
+
} finally {
|
|
910
|
+
await unlink(temporaryCapturePath).catch(() => void 0);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function navigateForCapture(page, url) {
|
|
914
|
+
try {
|
|
915
|
+
await page.goto(url, {
|
|
916
|
+
waitUntil: "networkidle",
|
|
917
|
+
timeout: DEFAULT_NAVIGATION_TIMEOUT_MS
|
|
918
|
+
});
|
|
919
|
+
} catch {
|
|
920
|
+
await page.goto(url, {
|
|
921
|
+
waitUntil: "load",
|
|
922
|
+
timeout: DEFAULT_NAVIGATION_TIMEOUT_MS
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function waitForCaptureStability(page) {
|
|
927
|
+
await page.evaluate(
|
|
928
|
+
async ({ fontTimeoutMs }) => {
|
|
929
|
+
const fonts = document.fonts;
|
|
930
|
+
if (!fonts) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
await Promise.race([
|
|
934
|
+
fonts.ready,
|
|
935
|
+
new Promise((resolve) => {
|
|
936
|
+
window.setTimeout(resolve, fontTimeoutMs);
|
|
937
|
+
})
|
|
938
|
+
]);
|
|
939
|
+
},
|
|
940
|
+
{ fontTimeoutMs: DEFAULT_FONT_READY_TIMEOUT_MS }
|
|
941
|
+
).catch(() => void 0);
|
|
942
|
+
await page.waitForTimeout(DEFAULT_CAPTURE_DELAY_MS);
|
|
943
|
+
}
|
|
944
|
+
async function captureSelectorScreenshot(page, selector, outputPath) {
|
|
945
|
+
try {
|
|
946
|
+
const locator = page.locator(selector).first();
|
|
947
|
+
await locator.waitFor({ state: "visible", timeout: DEFAULT_NAVIGATION_TIMEOUT_MS });
|
|
948
|
+
await locator.scrollIntoViewIfNeeded();
|
|
949
|
+
const domSnapshot = await collectSelectorDomSnapshot(locator);
|
|
950
|
+
await locator.screenshot({
|
|
951
|
+
path: outputPath,
|
|
952
|
+
animations: "disabled",
|
|
953
|
+
caret: "hide",
|
|
954
|
+
scale: "css",
|
|
955
|
+
type: "png"
|
|
956
|
+
});
|
|
957
|
+
return domSnapshot;
|
|
958
|
+
} catch (error) {
|
|
959
|
+
throw new AppError(
|
|
960
|
+
`Preview selector could not be captured: ${selector}. ${ensureError(error).message}`,
|
|
961
|
+
{
|
|
962
|
+
exitCode: 3,
|
|
963
|
+
recommendation: "needs_human_review",
|
|
964
|
+
severity: "high",
|
|
965
|
+
code: "preview_selector_capture_failed",
|
|
966
|
+
cause: error
|
|
967
|
+
}
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function buildTemporaryCapturePath(outputPath) {
|
|
972
|
+
const parsedPath = path2.parse(outputPath);
|
|
973
|
+
return path2.join(
|
|
974
|
+
parsedPath.dir,
|
|
975
|
+
`${parsedPath.name}.capture-${process.pid}-${Date.now()}${parsedPath.ext || ".png"}`
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
async function collectSelectorDomSnapshot(locator) {
|
|
979
|
+
return locator.evaluate(
|
|
980
|
+
(root, { maxSelectorLength, maxTextLength }) => {
|
|
981
|
+
const excludedTags = /* @__PURE__ */ new Set(["script", "style", "noscript", "meta", "link", "head"]);
|
|
982
|
+
const inlineNoiseTags = /* @__PURE__ */ new Set(["span", "strong", "em", "b", "i", "u", "small"]);
|
|
983
|
+
const semanticTags = /* @__PURE__ */ new Set([
|
|
984
|
+
"h1",
|
|
985
|
+
"h2",
|
|
986
|
+
"h3",
|
|
987
|
+
"h4",
|
|
988
|
+
"h5",
|
|
989
|
+
"h6",
|
|
990
|
+
"p",
|
|
991
|
+
"button",
|
|
992
|
+
"a",
|
|
993
|
+
"img",
|
|
994
|
+
"svg",
|
|
995
|
+
"video",
|
|
996
|
+
"canvas",
|
|
997
|
+
"input",
|
|
998
|
+
"textarea",
|
|
999
|
+
"select",
|
|
1000
|
+
"label",
|
|
1001
|
+
"li",
|
|
1002
|
+
"section",
|
|
1003
|
+
"article",
|
|
1004
|
+
"nav",
|
|
1005
|
+
"main",
|
|
1006
|
+
"aside",
|
|
1007
|
+
"header",
|
|
1008
|
+
"footer"
|
|
1009
|
+
]);
|
|
1010
|
+
const rootRect = root.getBoundingClientRect();
|
|
1011
|
+
const captureBounds = {
|
|
1012
|
+
x: 0,
|
|
1013
|
+
y: 0,
|
|
1014
|
+
width: Math.max(1, Math.round(rootRect.width)),
|
|
1015
|
+
height: Math.max(1, Math.round(rootRect.height))
|
|
1016
|
+
};
|
|
1017
|
+
const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
1018
|
+
const clipValue = (value, limit) => value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
1019
|
+
const toTag = (element) => element.tagName.toLowerCase();
|
|
1020
|
+
const isTransparent = (value) => value === "transparent" || value === "rgba(0, 0, 0, 0)" || value === "rgba(0,0,0,0)";
|
|
1021
|
+
const hasVisibleBorder = (style) => Number.parseFloat(style.borderTopWidth) > 0 || Number.parseFloat(style.borderRightWidth) > 0 || Number.parseFloat(style.borderBottomWidth) > 0 || Number.parseFloat(style.borderLeftWidth) > 0;
|
|
1022
|
+
const hasPaintedBox = (style) => style.backgroundImage !== "none" || !isTransparent(style.backgroundColor) || hasVisibleBorder(style) || style.boxShadow !== "none";
|
|
1023
|
+
const escapeIdentifier = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value;
|
|
1024
|
+
const directTextSnippet = (element) => {
|
|
1025
|
+
const text = element instanceof HTMLElement ? element.innerText || element.textContent || "" : element.textContent || "";
|
|
1026
|
+
return clipValue(normalizeWhitespace(text), maxTextLength);
|
|
1027
|
+
};
|
|
1028
|
+
const depthFromRoot = (element) => {
|
|
1029
|
+
let depth = 0;
|
|
1030
|
+
let current = element;
|
|
1031
|
+
while (current && current !== root) {
|
|
1032
|
+
depth += 1;
|
|
1033
|
+
current = current.parentElement;
|
|
1034
|
+
}
|
|
1035
|
+
return depth;
|
|
1036
|
+
};
|
|
1037
|
+
const nthOfType = (element) => {
|
|
1038
|
+
let index = 1;
|
|
1039
|
+
let sibling = element.previousElementSibling;
|
|
1040
|
+
while (sibling) {
|
|
1041
|
+
if (sibling.tagName === element.tagName) {
|
|
1042
|
+
index += 1;
|
|
1043
|
+
}
|
|
1044
|
+
sibling = sibling.previousElementSibling;
|
|
1045
|
+
}
|
|
1046
|
+
return index;
|
|
1047
|
+
};
|
|
1048
|
+
const segmentFor = (element) => {
|
|
1049
|
+
const tag = toTag(element);
|
|
1050
|
+
if (element.id) {
|
|
1051
|
+
return `${tag}#${escapeIdentifier(element.id)}`;
|
|
1052
|
+
}
|
|
1053
|
+
const classNames = Array.from(element.classList).filter((className) => /^[A-Za-z_][\w-]*$/u.test(className)).slice(0, 2);
|
|
1054
|
+
if (classNames.length > 0) {
|
|
1055
|
+
return `${tag}.${classNames.map((className) => escapeIdentifier(className)).join(".")}`;
|
|
1056
|
+
}
|
|
1057
|
+
return `${tag}:nth-of-type(${nthOfType(element)})`;
|
|
1058
|
+
};
|
|
1059
|
+
const buildSelector = (element) => {
|
|
1060
|
+
if (element === root) {
|
|
1061
|
+
return clipValue(segmentFor(element), maxSelectorLength);
|
|
1062
|
+
}
|
|
1063
|
+
const parts = [];
|
|
1064
|
+
let current = element;
|
|
1065
|
+
while (current && parts.length < 3) {
|
|
1066
|
+
parts.unshift(segmentFor(current));
|
|
1067
|
+
if (current === root) {
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
current = current.parentElement;
|
|
1071
|
+
}
|
|
1072
|
+
return clipValue(parts.join(" > "), maxSelectorLength);
|
|
1073
|
+
};
|
|
1074
|
+
const clipBoxToBounds = (box, bounds) => {
|
|
1075
|
+
const left = Math.max(bounds.x, box.x);
|
|
1076
|
+
const top = Math.max(bounds.y, box.y);
|
|
1077
|
+
const right = Math.min(bounds.x + bounds.width, box.x + box.width);
|
|
1078
|
+
const bottom = Math.min(bounds.y + bounds.height, box.y + box.height);
|
|
1079
|
+
if (right <= left || bottom <= top) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
1083
|
+
x: Math.round(left),
|
|
1084
|
+
y: Math.round(top),
|
|
1085
|
+
width: Math.round(right - left),
|
|
1086
|
+
height: Math.round(bottom - top)
|
|
1087
|
+
};
|
|
1088
|
+
};
|
|
1089
|
+
const clippedEdgesForBox = (box, bounds) => {
|
|
1090
|
+
const edges = [];
|
|
1091
|
+
if (box.y < bounds.y) {
|
|
1092
|
+
edges.push("top");
|
|
1093
|
+
}
|
|
1094
|
+
if (box.x + box.width > bounds.x + bounds.width) {
|
|
1095
|
+
edges.push("right");
|
|
1096
|
+
}
|
|
1097
|
+
if (box.y + box.height > bounds.y + bounds.height) {
|
|
1098
|
+
edges.push("bottom");
|
|
1099
|
+
}
|
|
1100
|
+
if (box.x < bounds.x) {
|
|
1101
|
+
edges.push("left");
|
|
1102
|
+
}
|
|
1103
|
+
return edges;
|
|
1104
|
+
};
|
|
1105
|
+
const toRelativeBox = (element) => {
|
|
1106
|
+
const rect = element.getBoundingClientRect();
|
|
1107
|
+
const rawBox = {
|
|
1108
|
+
x: rect.left - rootRect.left,
|
|
1109
|
+
y: rect.top - rootRect.top,
|
|
1110
|
+
width: rect.width,
|
|
1111
|
+
height: rect.height
|
|
1112
|
+
};
|
|
1113
|
+
const bbox = clipBoxToBounds(rawBox, captureBounds);
|
|
1114
|
+
if (!bbox) {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
bbox,
|
|
1119
|
+
captureClippedEdges: clippedEdgesForBox(rawBox, captureBounds)
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
const isVisible = (element) => {
|
|
1123
|
+
const rect = element.getBoundingClientRect();
|
|
1124
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
const style = window.getComputedStyle(element);
|
|
1128
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && style.pointerEvents !== "none";
|
|
1129
|
+
};
|
|
1130
|
+
const isMeaningful = (element) => {
|
|
1131
|
+
if (element === root) {
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
const tag = toTag(element);
|
|
1135
|
+
if (excludedTags.has(tag) || inlineNoiseTags.has(tag)) {
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
if (!isVisible(element)) {
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
const style = window.getComputedStyle(element);
|
|
1142
|
+
const hasText = directTextSnippet(element).length > 0;
|
|
1143
|
+
const hasRole = Boolean(element.getAttribute("role"));
|
|
1144
|
+
return hasText || hasRole || semanticTags.has(tag) || hasPaintedBox(style) || element.children.length === 0;
|
|
1145
|
+
};
|
|
1146
|
+
const buildSnapshotElement = (element) => {
|
|
1147
|
+
const boxInfo = toRelativeBox(element);
|
|
1148
|
+
if (!boxInfo) {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
const style = window.getComputedStyle(element);
|
|
1152
|
+
const textMetrics = element instanceof HTMLElement ? {
|
|
1153
|
+
clientWidth: Math.round(element.clientWidth),
|
|
1154
|
+
clientHeight: Math.round(element.clientHeight),
|
|
1155
|
+
scrollWidth: Math.round(element.scrollWidth),
|
|
1156
|
+
scrollHeight: Math.round(element.scrollHeight),
|
|
1157
|
+
overflowX: style.overflowX,
|
|
1158
|
+
overflowY: style.overflowY,
|
|
1159
|
+
textOverflow: style.textOverflow,
|
|
1160
|
+
whiteSpace: style.whiteSpace,
|
|
1161
|
+
lineClamp: style.getPropertyValue("-webkit-line-clamp") || style.getPropertyValue("line-clamp") || null
|
|
1162
|
+
} : null;
|
|
1163
|
+
return {
|
|
1164
|
+
id: buildSelector(element),
|
|
1165
|
+
tag: toTag(element),
|
|
1166
|
+
selector: buildSelector(element),
|
|
1167
|
+
role: element.getAttribute("role"),
|
|
1168
|
+
textSnippet: directTextSnippet(element) || null,
|
|
1169
|
+
bbox: boxInfo.bbox,
|
|
1170
|
+
depth: depthFromRoot(element),
|
|
1171
|
+
captureClippedEdges: boxInfo.captureClippedEdges,
|
|
1172
|
+
textMetrics
|
|
1173
|
+
};
|
|
1174
|
+
};
|
|
1175
|
+
const allElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => isMeaningful(element)).map((element) => buildSnapshotElement(element)).filter((element) => element !== null);
|
|
1176
|
+
const [snapshotRoot, ...elements] = allElements;
|
|
1177
|
+
if (!snapshotRoot) {
|
|
1178
|
+
throw new Error("No meaningful DOM elements were found inside the selector capture.");
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
root: snapshotRoot,
|
|
1182
|
+
elements
|
|
1183
|
+
};
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
maxSelectorLength: DEFAULT_MAX_SELECTOR_LENGTH,
|
|
1187
|
+
maxTextLength: DEFAULT_MAX_TEXT_SNIPPET_LENGTH
|
|
1188
|
+
}
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
async function collectPageDomSnapshot(page, fullPage) {
|
|
1192
|
+
return page.evaluate(
|
|
1193
|
+
({ fullPageCapture, maxSelectorLength, maxTextLength }) => {
|
|
1194
|
+
const excludedTags = /* @__PURE__ */ new Set(["script", "style", "noscript", "meta", "link", "head"]);
|
|
1195
|
+
const inlineNoiseTags = /* @__PURE__ */ new Set(["span", "strong", "em", "b", "i", "u", "small"]);
|
|
1196
|
+
const semanticTags = /* @__PURE__ */ new Set([
|
|
1197
|
+
"h1",
|
|
1198
|
+
"h2",
|
|
1199
|
+
"h3",
|
|
1200
|
+
"h4",
|
|
1201
|
+
"h5",
|
|
1202
|
+
"h6",
|
|
1203
|
+
"p",
|
|
1204
|
+
"button",
|
|
1205
|
+
"a",
|
|
1206
|
+
"img",
|
|
1207
|
+
"svg",
|
|
1208
|
+
"video",
|
|
1209
|
+
"canvas",
|
|
1210
|
+
"input",
|
|
1211
|
+
"textarea",
|
|
1212
|
+
"select",
|
|
1213
|
+
"label",
|
|
1214
|
+
"li",
|
|
1215
|
+
"section",
|
|
1216
|
+
"article",
|
|
1217
|
+
"nav",
|
|
1218
|
+
"main",
|
|
1219
|
+
"aside",
|
|
1220
|
+
"header",
|
|
1221
|
+
"footer"
|
|
1222
|
+
]);
|
|
1223
|
+
const root = document.body ?? document.documentElement;
|
|
1224
|
+
const captureBounds = fullPageCapture ? {
|
|
1225
|
+
x: 0,
|
|
1226
|
+
y: 0,
|
|
1227
|
+
width: Math.max(
|
|
1228
|
+
document.documentElement.scrollWidth,
|
|
1229
|
+
document.body?.scrollWidth ?? 0,
|
|
1230
|
+
window.innerWidth
|
|
1231
|
+
),
|
|
1232
|
+
height: Math.max(
|
|
1233
|
+
document.documentElement.scrollHeight,
|
|
1234
|
+
document.body?.scrollHeight ?? 0,
|
|
1235
|
+
window.innerHeight
|
|
1236
|
+
)
|
|
1237
|
+
} : {
|
|
1238
|
+
x: 0,
|
|
1239
|
+
y: 0,
|
|
1240
|
+
width: window.innerWidth,
|
|
1241
|
+
height: window.innerHeight
|
|
1242
|
+
};
|
|
1243
|
+
const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
1244
|
+
const clipValue = (value, limit) => value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
1245
|
+
const toTag = (element) => element.tagName.toLowerCase();
|
|
1246
|
+
const isTransparent = (value) => value === "transparent" || value === "rgba(0, 0, 0, 0)" || value === "rgba(0,0,0,0)";
|
|
1247
|
+
const hasVisibleBorder = (style) => Number.parseFloat(style.borderTopWidth) > 0 || Number.parseFloat(style.borderRightWidth) > 0 || Number.parseFloat(style.borderBottomWidth) > 0 || Number.parseFloat(style.borderLeftWidth) > 0;
|
|
1248
|
+
const hasPaintedBox = (style) => style.backgroundImage !== "none" || !isTransparent(style.backgroundColor) || hasVisibleBorder(style) || style.boxShadow !== "none";
|
|
1249
|
+
const escapeIdentifier = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value;
|
|
1250
|
+
const directTextSnippet = (element) => {
|
|
1251
|
+
const text = element instanceof HTMLElement ? element.innerText || element.textContent || "" : element.textContent || "";
|
|
1252
|
+
return clipValue(normalizeWhitespace(text), maxTextLength);
|
|
1253
|
+
};
|
|
1254
|
+
const depthFromRoot = (element) => {
|
|
1255
|
+
let depth = 0;
|
|
1256
|
+
let current = element;
|
|
1257
|
+
while (current && current !== root) {
|
|
1258
|
+
depth += 1;
|
|
1259
|
+
current = current.parentElement;
|
|
1260
|
+
}
|
|
1261
|
+
return depth;
|
|
1262
|
+
};
|
|
1263
|
+
const nthOfType = (element) => {
|
|
1264
|
+
let index = 1;
|
|
1265
|
+
let sibling = element.previousElementSibling;
|
|
1266
|
+
while (sibling) {
|
|
1267
|
+
if (sibling.tagName === element.tagName) {
|
|
1268
|
+
index += 1;
|
|
1269
|
+
}
|
|
1270
|
+
sibling = sibling.previousElementSibling;
|
|
1271
|
+
}
|
|
1272
|
+
return index;
|
|
1273
|
+
};
|
|
1274
|
+
const segmentFor = (element) => {
|
|
1275
|
+
const tag = toTag(element);
|
|
1276
|
+
if (element.id) {
|
|
1277
|
+
return `${tag}#${escapeIdentifier(element.id)}`;
|
|
1278
|
+
}
|
|
1279
|
+
const classNames = Array.from(element.classList).filter((className) => /^[A-Za-z_][\w-]*$/u.test(className)).slice(0, 2);
|
|
1280
|
+
if (classNames.length > 0) {
|
|
1281
|
+
return `${tag}.${classNames.map((className) => escapeIdentifier(className)).join(".")}`;
|
|
1282
|
+
}
|
|
1283
|
+
return `${tag}:nth-of-type(${nthOfType(element)})`;
|
|
1284
|
+
};
|
|
1285
|
+
const buildSelector = (element) => {
|
|
1286
|
+
if (element === root) {
|
|
1287
|
+
return clipValue(segmentFor(element), maxSelectorLength);
|
|
1288
|
+
}
|
|
1289
|
+
const parts = [];
|
|
1290
|
+
let current = element;
|
|
1291
|
+
while (current && parts.length < 3) {
|
|
1292
|
+
parts.unshift(segmentFor(current));
|
|
1293
|
+
if (current === root) {
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
current = current.parentElement;
|
|
1297
|
+
}
|
|
1298
|
+
return clipValue(parts.join(" > "), maxSelectorLength);
|
|
1299
|
+
};
|
|
1300
|
+
const clipBoxToBounds = (box, bounds) => {
|
|
1301
|
+
const left = Math.max(bounds.x, box.x);
|
|
1302
|
+
const top = Math.max(bounds.y, box.y);
|
|
1303
|
+
const right = Math.min(bounds.x + bounds.width, box.x + box.width);
|
|
1304
|
+
const bottom = Math.min(bounds.y + bounds.height, box.y + box.height);
|
|
1305
|
+
if (right <= left || bottom <= top) {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
return {
|
|
1309
|
+
x: Math.round(left),
|
|
1310
|
+
y: Math.round(top),
|
|
1311
|
+
width: Math.round(right - left),
|
|
1312
|
+
height: Math.round(bottom - top)
|
|
1313
|
+
};
|
|
1314
|
+
};
|
|
1315
|
+
const clippedEdgesForBox = (box, bounds) => {
|
|
1316
|
+
const edges = [];
|
|
1317
|
+
if (box.y < bounds.y) {
|
|
1318
|
+
edges.push("top");
|
|
1319
|
+
}
|
|
1320
|
+
if (box.x + box.width > bounds.x + bounds.width) {
|
|
1321
|
+
edges.push("right");
|
|
1322
|
+
}
|
|
1323
|
+
if (box.y + box.height > bounds.y + bounds.height) {
|
|
1324
|
+
edges.push("bottom");
|
|
1325
|
+
}
|
|
1326
|
+
if (box.x < bounds.x) {
|
|
1327
|
+
edges.push("left");
|
|
1328
|
+
}
|
|
1329
|
+
return edges;
|
|
1330
|
+
};
|
|
1331
|
+
const toRelativeBox = (element) => {
|
|
1332
|
+
const rect = element.getBoundingClientRect();
|
|
1333
|
+
const x = fullPageCapture ? rect.left + window.scrollX : rect.left;
|
|
1334
|
+
const y = fullPageCapture ? rect.top + window.scrollY : rect.top;
|
|
1335
|
+
const rawBox = {
|
|
1336
|
+
x,
|
|
1337
|
+
y,
|
|
1338
|
+
width: rect.width,
|
|
1339
|
+
height: rect.height
|
|
1340
|
+
};
|
|
1341
|
+
const bbox = clipBoxToBounds(rawBox, captureBounds);
|
|
1342
|
+
if (!bbox) {
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
return {
|
|
1346
|
+
bbox,
|
|
1347
|
+
captureClippedEdges: clippedEdgesForBox(rawBox, captureBounds)
|
|
1348
|
+
};
|
|
1349
|
+
};
|
|
1350
|
+
const isVisible = (element) => {
|
|
1351
|
+
const rect = element.getBoundingClientRect();
|
|
1352
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
const style = window.getComputedStyle(element);
|
|
1356
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && style.pointerEvents !== "none";
|
|
1357
|
+
};
|
|
1358
|
+
const isMeaningful = (element) => {
|
|
1359
|
+
if (element === root) {
|
|
1360
|
+
return true;
|
|
1361
|
+
}
|
|
1362
|
+
const tag = toTag(element);
|
|
1363
|
+
if (excludedTags.has(tag) || inlineNoiseTags.has(tag)) {
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
if (!isVisible(element)) {
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
const style = window.getComputedStyle(element);
|
|
1370
|
+
const hasText = directTextSnippet(element).length > 0;
|
|
1371
|
+
const hasRole = Boolean(element.getAttribute("role"));
|
|
1372
|
+
return hasText || hasRole || semanticTags.has(tag) || hasPaintedBox(style) || element.children.length === 0;
|
|
1373
|
+
};
|
|
1374
|
+
const buildSnapshotElement = (element) => {
|
|
1375
|
+
const boxInfo = toRelativeBox(element);
|
|
1376
|
+
if (!boxInfo) {
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
const style = window.getComputedStyle(element);
|
|
1380
|
+
const textMetrics = element instanceof HTMLElement ? {
|
|
1381
|
+
clientWidth: Math.round(element.clientWidth),
|
|
1382
|
+
clientHeight: Math.round(element.clientHeight),
|
|
1383
|
+
scrollWidth: Math.round(element.scrollWidth),
|
|
1384
|
+
scrollHeight: Math.round(element.scrollHeight),
|
|
1385
|
+
overflowX: style.overflowX,
|
|
1386
|
+
overflowY: style.overflowY,
|
|
1387
|
+
textOverflow: style.textOverflow,
|
|
1388
|
+
whiteSpace: style.whiteSpace,
|
|
1389
|
+
lineClamp: style.getPropertyValue("-webkit-line-clamp") || style.getPropertyValue("line-clamp") || null
|
|
1390
|
+
} : null;
|
|
1391
|
+
return {
|
|
1392
|
+
id: buildSelector(element),
|
|
1393
|
+
tag: toTag(element),
|
|
1394
|
+
selector: buildSelector(element),
|
|
1395
|
+
role: element.getAttribute("role"),
|
|
1396
|
+
textSnippet: directTextSnippet(element) || null,
|
|
1397
|
+
bbox: boxInfo.bbox,
|
|
1398
|
+
depth: depthFromRoot(element),
|
|
1399
|
+
captureClippedEdges: boxInfo.captureClippedEdges,
|
|
1400
|
+
textMetrics
|
|
1401
|
+
};
|
|
1402
|
+
};
|
|
1403
|
+
const allElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => isMeaningful(element)).map((element) => buildSnapshotElement(element)).filter((element) => element !== null);
|
|
1404
|
+
const [snapshotRoot, ...elements] = allElements;
|
|
1405
|
+
if (!snapshotRoot) {
|
|
1406
|
+
throw new Error("No meaningful DOM elements were found in the page capture.");
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
root: snapshotRoot,
|
|
1410
|
+
elements
|
|
1411
|
+
};
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
fullPageCapture: fullPage,
|
|
1415
|
+
maxSelectorLength: DEFAULT_MAX_SELECTOR_LENGTH,
|
|
1416
|
+
maxTextLength: DEFAULT_MAX_TEXT_SNIPPET_LENGTH
|
|
1417
|
+
}
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/compare/engine.ts
|
|
1422
|
+
import pixelmatch from "pixelmatch";
|
|
1423
|
+
|
|
1424
|
+
// src/compare/regions.ts
|
|
1425
|
+
function extractRegions(mask, width, height, minPixels = DEFAULT_MIN_REGION_PIXELS) {
|
|
1426
|
+
const visited = new Uint8Array(mask.length);
|
|
1427
|
+
const regions = [];
|
|
1428
|
+
const queue = new Uint32Array(mask.length);
|
|
1429
|
+
for (let index = 0; index < mask.length; index += 1) {
|
|
1430
|
+
if (mask[index] === 0 || visited[index] === 1) {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
let head = 0;
|
|
1434
|
+
let tail = 0;
|
|
1435
|
+
queue[tail] = index;
|
|
1436
|
+
tail += 1;
|
|
1437
|
+
visited[index] = 1;
|
|
1438
|
+
let minX = index % width;
|
|
1439
|
+
let maxX = minX;
|
|
1440
|
+
let minY = Math.floor(index / width);
|
|
1441
|
+
let maxY = minY;
|
|
1442
|
+
let pixelCount = 0;
|
|
1443
|
+
while (head < tail) {
|
|
1444
|
+
const current = queue[head];
|
|
1445
|
+
head += 1;
|
|
1446
|
+
const x = current % width;
|
|
1447
|
+
const y = Math.floor(current / width);
|
|
1448
|
+
pixelCount += 1;
|
|
1449
|
+
minX = Math.min(minX, x);
|
|
1450
|
+
maxX = Math.max(maxX, x);
|
|
1451
|
+
minY = Math.min(minY, y);
|
|
1452
|
+
maxY = Math.max(maxY, y);
|
|
1453
|
+
if (x > 0) {
|
|
1454
|
+
const neighbor = current - 1;
|
|
1455
|
+
if (mask[neighbor] === 1 && visited[neighbor] === 0) {
|
|
1456
|
+
visited[neighbor] = 1;
|
|
1457
|
+
queue[tail] = neighbor;
|
|
1458
|
+
tail += 1;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
if (x < width - 1) {
|
|
1462
|
+
const neighbor = current + 1;
|
|
1463
|
+
if (mask[neighbor] === 1 && visited[neighbor] === 0) {
|
|
1464
|
+
visited[neighbor] = 1;
|
|
1465
|
+
queue[tail] = neighbor;
|
|
1466
|
+
tail += 1;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (y > 0) {
|
|
1470
|
+
const neighbor = current - width;
|
|
1471
|
+
if (mask[neighbor] === 1 && visited[neighbor] === 0) {
|
|
1472
|
+
visited[neighbor] = 1;
|
|
1473
|
+
queue[tail] = neighbor;
|
|
1474
|
+
tail += 1;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
if (y < height - 1) {
|
|
1478
|
+
const neighbor = current + width;
|
|
1479
|
+
if (mask[neighbor] === 1 && visited[neighbor] === 0) {
|
|
1480
|
+
visited[neighbor] = 1;
|
|
1481
|
+
queue[tail] = neighbor;
|
|
1482
|
+
tail += 1;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (pixelCount < minPixels) {
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
regions.push({
|
|
1490
|
+
x: minX,
|
|
1491
|
+
y: minY,
|
|
1492
|
+
width: maxX - minX + 1,
|
|
1493
|
+
height: maxY - minY + 1,
|
|
1494
|
+
pixelCount
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
return regions;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/compare/structure.ts
|
|
1501
|
+
function analyzeStructure(reference, preview, width, height) {
|
|
1502
|
+
const lumaReference = rgbaToLuma(reference, width, height);
|
|
1503
|
+
const lumaPreview = rgbaToLuma(preview, width, height);
|
|
1504
|
+
const edgeMaskReference = sobelEdgeMask(lumaReference, width, height, DEFAULT_EDGE_THRESHOLD);
|
|
1505
|
+
const edgeMaskPreview = sobelEdgeMask(lumaPreview, width, height, DEFAULT_EDGE_THRESHOLD);
|
|
1506
|
+
const edgeDiffMask = new Uint8Array(width * height);
|
|
1507
|
+
let unionCount = 0;
|
|
1508
|
+
let diffCount = 0;
|
|
1509
|
+
for (let index = 0; index < edgeDiffMask.length; index += 1) {
|
|
1510
|
+
const ref = edgeMaskReference[index];
|
|
1511
|
+
const prev = edgeMaskPreview[index];
|
|
1512
|
+
const union = ref === 1 || prev === 1;
|
|
1513
|
+
if (union) {
|
|
1514
|
+
unionCount += 1;
|
|
1515
|
+
}
|
|
1516
|
+
if (ref !== prev) {
|
|
1517
|
+
edgeDiffMask[index] = 1;
|
|
1518
|
+
diffCount += 1;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
edgeMaskReference,
|
|
1523
|
+
edgeMaskPreview,
|
|
1524
|
+
edgeDiffMask,
|
|
1525
|
+
structuralMismatchPercent: unionCount === 0 ? 0 : diffCount / unionCount * 100
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
function rgbaToLuma(data, width, height) {
|
|
1529
|
+
const luma = new Float32Array(width * height);
|
|
1530
|
+
for (let index = 0; index < width * height; index += 1) {
|
|
1531
|
+
const offset = index * 4;
|
|
1532
|
+
luma[index] = data[offset] * 0.2126 + data[offset + 1] * 0.7152 + data[offset + 2] * 0.0722;
|
|
1533
|
+
}
|
|
1534
|
+
return luma;
|
|
1535
|
+
}
|
|
1536
|
+
function sobelEdgeMask(luma, width, height, threshold) {
|
|
1537
|
+
const edgeMask = new Uint8Array(width * height);
|
|
1538
|
+
for (let y = 1; y < height - 1; y += 1) {
|
|
1539
|
+
for (let x = 1; x < width - 1; x += 1) {
|
|
1540
|
+
const topLeft = luma[(y - 1) * width + (x - 1)];
|
|
1541
|
+
const top = luma[(y - 1) * width + x];
|
|
1542
|
+
const topRight = luma[(y - 1) * width + (x + 1)];
|
|
1543
|
+
const left = luma[y * width + (x - 1)];
|
|
1544
|
+
const right = luma[y * width + (x + 1)];
|
|
1545
|
+
const bottomLeft = luma[(y + 1) * width + (x - 1)];
|
|
1546
|
+
const bottom = luma[(y + 1) * width + x];
|
|
1547
|
+
const bottomRight = luma[(y + 1) * width + (x + 1)];
|
|
1548
|
+
const gx = -topLeft + topRight - 2 * left + 2 * right - bottomLeft + bottomRight;
|
|
1549
|
+
const gy = topLeft + 2 * top + topRight - bottomLeft - 2 * bottom - bottomRight;
|
|
1550
|
+
const magnitude = Math.sqrt(gx * gx + gy * gy);
|
|
1551
|
+
if (magnitude >= threshold) {
|
|
1552
|
+
edgeMask[y * width + x] = 1;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return edgeMask;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/compare/engine.ts
|
|
1560
|
+
function runComparisonEngine(params) {
|
|
1561
|
+
const totalPixels = params.width * params.height;
|
|
1562
|
+
const diffBuffer = new Uint8ClampedArray(totalPixels * 4);
|
|
1563
|
+
const maskBuffer = new Uint8ClampedArray(totalPixels * 4);
|
|
1564
|
+
const colorDeltaMap = new Float32Array(totalPixels);
|
|
1565
|
+
const mismatchPixels = pixelmatch(
|
|
1566
|
+
params.reference,
|
|
1567
|
+
params.preview,
|
|
1568
|
+
diffBuffer,
|
|
1569
|
+
params.width,
|
|
1570
|
+
params.height,
|
|
1571
|
+
{
|
|
1572
|
+
threshold: DEFAULT_PIXELMATCH_THRESHOLD,
|
|
1573
|
+
alpha: 0.2,
|
|
1574
|
+
diffColor: [255, 0, 0],
|
|
1575
|
+
diffColorAlt: [0, 180, 255],
|
|
1576
|
+
includeAA: true
|
|
1577
|
+
}
|
|
1578
|
+
);
|
|
1579
|
+
pixelmatch(params.reference, params.preview, maskBuffer, params.width, params.height, {
|
|
1580
|
+
threshold: DEFAULT_PIXELMATCH_THRESHOLD,
|
|
1581
|
+
alpha: 1,
|
|
1582
|
+
diffColor: [255, 255, 255],
|
|
1583
|
+
diffColorAlt: [255, 255, 255],
|
|
1584
|
+
includeAA: true,
|
|
1585
|
+
diffMask: true
|
|
1586
|
+
});
|
|
1587
|
+
const mismatchMask = new Uint8Array(totalPixels);
|
|
1588
|
+
let colorDeltaSum = 0;
|
|
1589
|
+
let maxColorDelta = 0;
|
|
1590
|
+
let mismatchedColorSamples = 0;
|
|
1591
|
+
for (let index = 0; index < totalPixels; index += 1) {
|
|
1592
|
+
const offset = index * 4;
|
|
1593
|
+
const hasMismatch = maskBuffer[offset] > 0 || maskBuffer[offset + 1] > 0 || maskBuffer[offset + 2] > 0 || maskBuffer[offset + 3] > 0;
|
|
1594
|
+
if (!hasMismatch) {
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
mismatchMask[index] = 1;
|
|
1598
|
+
const redDelta = params.reference[offset] - params.preview[offset];
|
|
1599
|
+
const greenDelta = params.reference[offset + 1] - params.preview[offset + 1];
|
|
1600
|
+
const blueDelta = params.reference[offset + 2] - params.preview[offset + 2];
|
|
1601
|
+
const delta = Math.sqrt(redDelta ** 2 + greenDelta ** 2 + blueDelta ** 2) / MAX_RGB_DISTANCE * 100;
|
|
1602
|
+
colorDeltaMap[index] = delta;
|
|
1603
|
+
colorDeltaSum += delta;
|
|
1604
|
+
maxColorDelta = Math.max(maxColorDelta, delta);
|
|
1605
|
+
mismatchedColorSamples += 1;
|
|
1606
|
+
}
|
|
1607
|
+
const dimensionMismatch = buildDimensionMismatch(
|
|
1608
|
+
params.referenceOriginal,
|
|
1609
|
+
params.previewOriginal
|
|
1610
|
+
);
|
|
1611
|
+
const structural = params.mode === "all" || params.mode === "layout" ? analyzeStructure(params.reference, params.preview, params.width, params.height) : null;
|
|
1612
|
+
const rawRegions = buildRegions({
|
|
1613
|
+
mismatchMask,
|
|
1614
|
+
edgeDiffMask: structural?.edgeDiffMask ?? null,
|
|
1615
|
+
width: params.width,
|
|
1616
|
+
height: params.height,
|
|
1617
|
+
totalPixels,
|
|
1618
|
+
dimensionMismatch,
|
|
1619
|
+
overlapWidth: Math.min(params.referenceOriginal.width, params.previewOriginal.width),
|
|
1620
|
+
overlapHeight: Math.min(params.referenceOriginal.height, params.previewOriginal.height),
|
|
1621
|
+
colorDeltaMap,
|
|
1622
|
+
mode: params.mode
|
|
1623
|
+
});
|
|
1624
|
+
const overlay = createOverlay(params.reference, params.preview, mismatchMask);
|
|
1625
|
+
return {
|
|
1626
|
+
metrics: {
|
|
1627
|
+
mismatchPixels,
|
|
1628
|
+
mismatchPercent: totalPixels === 0 ? 0 : Number((mismatchPixels / totalPixels * 100).toFixed(4)),
|
|
1629
|
+
meanColorDelta: params.mode === "all" || params.mode === "color" ? mismatchedColorSamples > 0 ? Number((colorDeltaSum / mismatchedColorSamples).toFixed(4)) : 0 : null,
|
|
1630
|
+
maxColorDelta: params.mode === "all" || params.mode === "color" ? Number(maxColorDelta.toFixed(4)) : null,
|
|
1631
|
+
structuralMismatchPercent: structural === null ? null : Number(structural.structuralMismatchPercent.toFixed(4)),
|
|
1632
|
+
dimensionMismatch,
|
|
1633
|
+
findingsCount: 0,
|
|
1634
|
+
affectedElementCount: 0
|
|
1635
|
+
},
|
|
1636
|
+
rawRegions,
|
|
1637
|
+
mismatchMask,
|
|
1638
|
+
buffers: {
|
|
1639
|
+
overlay,
|
|
1640
|
+
diff: diffBuffer
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
function buildDimensionMismatch(reference, preview) {
|
|
1645
|
+
const widthDelta = preview.width - reference.width;
|
|
1646
|
+
const heightDelta = preview.height - reference.height;
|
|
1647
|
+
const referenceAspect = reference.width / reference.height;
|
|
1648
|
+
const previewAspect = preview.width / preview.height;
|
|
1649
|
+
return {
|
|
1650
|
+
widthDelta,
|
|
1651
|
+
heightDelta,
|
|
1652
|
+
aspectRatioDelta: Number(Math.abs(previewAspect - referenceAspect).toFixed(6)),
|
|
1653
|
+
hasMismatch: widthDelta !== 0 || heightDelta !== 0
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
function buildRegions(params) {
|
|
1657
|
+
const rawRegions = extractRegions(params.mismatchMask, params.width, params.height);
|
|
1658
|
+
return rawRegions.map((region) => {
|
|
1659
|
+
const bboxArea = region.width * region.height;
|
|
1660
|
+
const mismatchPercent = bboxArea === 0 ? 0 : region.pixelCount / bboxArea * 100;
|
|
1661
|
+
const kind = classifyRegionKind({
|
|
1662
|
+
region,
|
|
1663
|
+
edgeDiffMask: params.edgeDiffMask,
|
|
1664
|
+
width: params.width,
|
|
1665
|
+
overlapWidth: params.overlapWidth,
|
|
1666
|
+
overlapHeight: params.overlapHeight,
|
|
1667
|
+
colorDeltaMap: params.colorDeltaMap,
|
|
1668
|
+
mode: params.mode
|
|
1669
|
+
});
|
|
1670
|
+
const severity = classifyRegionSeverity(
|
|
1671
|
+
region.pixelCount / params.totalPixels * 100,
|
|
1672
|
+
kind,
|
|
1673
|
+
params.dimensionMismatch
|
|
1674
|
+
);
|
|
1675
|
+
return {
|
|
1676
|
+
...region,
|
|
1677
|
+
mismatchPercent: Number(mismatchPercent.toFixed(4)),
|
|
1678
|
+
kind,
|
|
1679
|
+
severity
|
|
1680
|
+
};
|
|
1681
|
+
}).sort(compareRegions);
|
|
1682
|
+
}
|
|
1683
|
+
function classifyRegionKind(params) {
|
|
1684
|
+
const { region, edgeDiffMask, width, overlapWidth, overlapHeight, colorDeltaMap, mode } = params;
|
|
1685
|
+
const outsideOverlap = region.x + region.width > overlapWidth || region.y + region.height > overlapHeight;
|
|
1686
|
+
if (outsideOverlap) {
|
|
1687
|
+
return "dimension";
|
|
1688
|
+
}
|
|
1689
|
+
const regionMeanColorDelta = meanColorDeltaForRegion(region, colorDeltaMap, width);
|
|
1690
|
+
if (mode === "pixel") {
|
|
1691
|
+
return "pixel";
|
|
1692
|
+
}
|
|
1693
|
+
if (mode === "color") {
|
|
1694
|
+
return regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD ? "color" : "pixel";
|
|
1695
|
+
}
|
|
1696
|
+
if (!edgeDiffMask) {
|
|
1697
|
+
return regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD ? "color" : "pixel";
|
|
1698
|
+
}
|
|
1699
|
+
let edgePixels = 0;
|
|
1700
|
+
for (let y = region.y; y < region.y + region.height; y += 1) {
|
|
1701
|
+
for (let x = region.x; x < region.x + region.width; x += 1) {
|
|
1702
|
+
if (edgeDiffMask[y * width + x] === 1) {
|
|
1703
|
+
edgePixels += 1;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const edgeRatio = edgePixels / Math.max(1, region.width * region.height);
|
|
1708
|
+
if (mode === "layout") {
|
|
1709
|
+
return edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD ? "layout" : "pixel";
|
|
1710
|
+
}
|
|
1711
|
+
if (edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD && regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD) {
|
|
1712
|
+
return "mixed";
|
|
1713
|
+
}
|
|
1714
|
+
if (edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD) {
|
|
1715
|
+
return "layout";
|
|
1716
|
+
}
|
|
1717
|
+
if (regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD) {
|
|
1718
|
+
return "color";
|
|
1719
|
+
}
|
|
1720
|
+
return "pixel";
|
|
1721
|
+
}
|
|
1722
|
+
function meanColorDeltaForRegion(region, colorDeltaMap, width) {
|
|
1723
|
+
let totalDelta = 0;
|
|
1724
|
+
let sampleCount = 0;
|
|
1725
|
+
for (let y = region.y; y < region.y + region.height; y += 1) {
|
|
1726
|
+
for (let x = region.x; x < region.x + region.width; x += 1) {
|
|
1727
|
+
const delta = colorDeltaMap[y * width + x];
|
|
1728
|
+
if (delta <= 0) {
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
totalDelta += delta;
|
|
1732
|
+
sampleCount += 1;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return sampleCount === 0 ? 0 : totalDelta / sampleCount;
|
|
1736
|
+
}
|
|
1737
|
+
function classifyRegionSeverity(areaPercent, kind, dimensionMismatch) {
|
|
1738
|
+
if (kind === "dimension") {
|
|
1739
|
+
const strongDimensionShift = Math.abs(dimensionMismatch.widthDelta) >= STRONG_DIMENSION_DELTA_PX || Math.abs(dimensionMismatch.heightDelta) >= STRONG_DIMENSION_DELTA_PX || dimensionMismatch.aspectRatioDelta >= STRONG_DIMENSION_ASPECT_DELTA;
|
|
1740
|
+
return strongDimensionShift ? "critical" : "high";
|
|
1741
|
+
}
|
|
1742
|
+
let severity;
|
|
1743
|
+
if (areaPercent >= 12) {
|
|
1744
|
+
severity = "critical";
|
|
1745
|
+
} else if (areaPercent >= 4) {
|
|
1746
|
+
severity = "high";
|
|
1747
|
+
} else if (areaPercent >= 1.5) {
|
|
1748
|
+
severity = "medium";
|
|
1749
|
+
} else {
|
|
1750
|
+
severity = "low";
|
|
1751
|
+
}
|
|
1752
|
+
if (kind === "layout" && severity === "low") {
|
|
1753
|
+
return "medium";
|
|
1754
|
+
}
|
|
1755
|
+
if (kind === "mixed" && severity === "low") {
|
|
1756
|
+
return "medium";
|
|
1757
|
+
}
|
|
1758
|
+
return severity;
|
|
1759
|
+
}
|
|
1760
|
+
function createOverlay(reference, preview, mismatchMask) {
|
|
1761
|
+
const overlay = new Uint8ClampedArray(reference.length);
|
|
1762
|
+
for (let index = 0; index < mismatchMask.length; index += 1) {
|
|
1763
|
+
const offset = index * 4;
|
|
1764
|
+
overlay[offset] = Math.round((reference[offset] + preview[offset]) / 2);
|
|
1765
|
+
overlay[offset + 1] = Math.round((reference[offset + 1] + preview[offset + 1]) / 2);
|
|
1766
|
+
overlay[offset + 2] = Math.round((reference[offset + 2] + preview[offset + 2]) / 2);
|
|
1767
|
+
overlay[offset + 3] = 255;
|
|
1768
|
+
if (mismatchMask[index] === 1) {
|
|
1769
|
+
overlay[offset] = Math.min(255, overlay[offset] + 50);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return overlay;
|
|
1773
|
+
}
|
|
1774
|
+
function compareRegions(left, right) {
|
|
1775
|
+
const severityOrder = compareSeverityDescending(left.severity, right.severity);
|
|
1776
|
+
if (severityOrder !== 0) {
|
|
1777
|
+
return severityOrder;
|
|
1778
|
+
}
|
|
1779
|
+
if (left.pixelCount !== right.pixelCount) {
|
|
1780
|
+
return right.pixelCount - left.pixelCount;
|
|
1781
|
+
}
|
|
1782
|
+
if (left.y !== right.y) {
|
|
1783
|
+
return left.y - right.y;
|
|
1784
|
+
}
|
|
1785
|
+
return left.x - right.x;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// src/compare/heatmap.ts
|
|
1789
|
+
function createHeatmapArtifact(params) {
|
|
1790
|
+
const heatmap = new Uint8ClampedArray(params.reference.length);
|
|
1791
|
+
for (let index = 0; index < params.mismatchMask.length; index += 1) {
|
|
1792
|
+
const offset = index * 4;
|
|
1793
|
+
const base = Math.round((params.reference[offset] + params.preview[offset]) / 2 * 0.25);
|
|
1794
|
+
heatmap[offset] = base;
|
|
1795
|
+
heatmap[offset + 1] = base;
|
|
1796
|
+
heatmap[offset + 2] = base;
|
|
1797
|
+
heatmap[offset + 3] = 255;
|
|
1798
|
+
if (params.mismatchMask[index] === 1) {
|
|
1799
|
+
heatmap[offset] = 255;
|
|
1800
|
+
heatmap[offset + 1] = 120;
|
|
1801
|
+
heatmap[offset + 2] = 0;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
for (const visual of params.visuals) {
|
|
1805
|
+
drawBoundingBox(
|
|
1806
|
+
heatmap,
|
|
1807
|
+
params.width,
|
|
1808
|
+
params.height,
|
|
1809
|
+
visual.primaryBox,
|
|
1810
|
+
boundingBoxColor(visual.severity),
|
|
1811
|
+
2
|
|
1812
|
+
);
|
|
1813
|
+
for (const hotspotBox of visual.hotspotBoxes) {
|
|
1814
|
+
drawBoundingBox(
|
|
1815
|
+
heatmap,
|
|
1816
|
+
params.width,
|
|
1817
|
+
params.height,
|
|
1818
|
+
hotspotBox,
|
|
1819
|
+
hotspotColor(visual.severity),
|
|
1820
|
+
1
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return heatmap;
|
|
1825
|
+
}
|
|
1826
|
+
function drawBoundingBox(image, width, height, box, color, thickness) {
|
|
1827
|
+
for (let inset = 0; inset < thickness; inset += 1) {
|
|
1828
|
+
const xStart = Math.max(0, box.x + inset);
|
|
1829
|
+
const yStart = Math.max(0, box.y + inset);
|
|
1830
|
+
const xEnd = Math.min(width - 1, box.x + box.width - 1 - inset);
|
|
1831
|
+
const yEnd = Math.min(height - 1, box.y + box.height - 1 - inset);
|
|
1832
|
+
if (xEnd < xStart || yEnd < yStart) {
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
for (let x = xStart; x <= xEnd; x += 1) {
|
|
1836
|
+
paintPixel(image, width, x, yStart, color);
|
|
1837
|
+
paintPixel(image, width, x, yEnd, color);
|
|
1838
|
+
}
|
|
1839
|
+
for (let y = yStart; y <= yEnd; y += 1) {
|
|
1840
|
+
paintPixel(image, width, xStart, y, color);
|
|
1841
|
+
paintPixel(image, width, xEnd, y, color);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
function paintPixel(image, width, x, y, color) {
|
|
1846
|
+
const offset = (y * width + x) * 4;
|
|
1847
|
+
image[offset] = color[0];
|
|
1848
|
+
image[offset + 1] = color[1];
|
|
1849
|
+
image[offset + 2] = color[2];
|
|
1850
|
+
image[offset + 3] = color[3];
|
|
1851
|
+
}
|
|
1852
|
+
function boundingBoxColor(severity) {
|
|
1853
|
+
switch (severity) {
|
|
1854
|
+
case "critical":
|
|
1855
|
+
return [255, 255, 255, 255];
|
|
1856
|
+
case "high":
|
|
1857
|
+
return [255, 0, 255, 255];
|
|
1858
|
+
case "medium":
|
|
1859
|
+
return [0, 255, 255, 255];
|
|
1860
|
+
case "low":
|
|
1861
|
+
default:
|
|
1862
|
+
return [0, 255, 0, 255];
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
function hotspotColor(severity) {
|
|
1866
|
+
switch (severity) {
|
|
1867
|
+
case "critical":
|
|
1868
|
+
return [255, 220, 120, 255];
|
|
1869
|
+
case "high":
|
|
1870
|
+
return [255, 140, 120, 255];
|
|
1871
|
+
case "medium":
|
|
1872
|
+
return [120, 220, 255, 255];
|
|
1873
|
+
case "low":
|
|
1874
|
+
default:
|
|
1875
|
+
return [120, 255, 180, 255];
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/io/fs.ts
|
|
1880
|
+
import { access, mkdir, stat, writeFile as writeFile2 } from "fs/promises";
|
|
1881
|
+
import path3 from "path";
|
|
1882
|
+
async function ensureDirectory(dirPath) {
|
|
1883
|
+
const resolved = path3.resolve(dirPath);
|
|
1884
|
+
await mkdir(resolved, { recursive: true });
|
|
1885
|
+
return resolved;
|
|
1886
|
+
}
|
|
1887
|
+
async function ensureFileExists(filePath) {
|
|
1888
|
+
const resolved = path3.resolve(filePath);
|
|
1889
|
+
try {
|
|
1890
|
+
const fileStat = await stat(resolved);
|
|
1891
|
+
if (!fileStat.isFile()) {
|
|
1892
|
+
throw new AppError(`Expected a file but received a different path: ${resolved}`);
|
|
1893
|
+
}
|
|
1894
|
+
return resolved;
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
if (error instanceof AppError) {
|
|
1897
|
+
throw error;
|
|
1898
|
+
}
|
|
1899
|
+
const code = error.code;
|
|
1900
|
+
if (code === "ENOENT") {
|
|
1901
|
+
throw new AppError(`Input file does not exist: ${resolved}`, {
|
|
1902
|
+
code: "input_file_missing",
|
|
1903
|
+
cause: error
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
if (code === "EACCES") {
|
|
1907
|
+
throw new AppError(`Input file is not readable: ${resolved}`, {
|
|
1908
|
+
code: "input_file_unreadable",
|
|
1909
|
+
cause: error
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
throw new AppError(`Failed to access input file: ${resolved}`, {
|
|
1913
|
+
code: "input_file_access_failed",
|
|
1914
|
+
cause: error
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
async function writeJsonFile(filePath, value) {
|
|
1919
|
+
await writeFile2(filePath, `${JSON.stringify(value, null, 2)}
|
|
1920
|
+
`, "utf8");
|
|
1921
|
+
}
|
|
1922
|
+
async function pathExists(filePath) {
|
|
1923
|
+
try {
|
|
1924
|
+
await access(filePath);
|
|
1925
|
+
return true;
|
|
1926
|
+
} catch {
|
|
1927
|
+
return false;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// src/io/inputs.ts
|
|
1932
|
+
import path4 from "path";
|
|
1933
|
+
|
|
1934
|
+
// src/utils/url.ts
|
|
1935
|
+
function parseUrl(value) {
|
|
1936
|
+
try {
|
|
1937
|
+
return new URL(value);
|
|
1938
|
+
} catch {
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
function isHttpUrl(value) {
|
|
1943
|
+
const url = parseUrl(value);
|
|
1944
|
+
return url?.protocol === "http:" || url?.protocol === "https:";
|
|
1945
|
+
}
|
|
1946
|
+
function isFigmaUrl(value) {
|
|
1947
|
+
const url = parseUrl(value);
|
|
1948
|
+
if (!url) {
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
return url.hostname === "figma.com" || url.hostname.endsWith(".figma.com");
|
|
1952
|
+
}
|
|
1953
|
+
function hashToSelector(input, explicitSelector) {
|
|
1954
|
+
if (explicitSelector) {
|
|
1955
|
+
return explicitSelector;
|
|
1956
|
+
}
|
|
1957
|
+
if (!isHttpUrl(input)) {
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
const url = new URL(input);
|
|
1961
|
+
const hash = url.hash.replace(/^#/, "");
|
|
1962
|
+
if (!hash) {
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
return `#${decodeURIComponent(hash)}`;
|
|
1966
|
+
}
|
|
1967
|
+
function normalizeNodeId(value) {
|
|
1968
|
+
return decodeURIComponent(value).replace(/-/g, ":");
|
|
1969
|
+
}
|
|
1970
|
+
function parseFigmaUrl(input) {
|
|
1971
|
+
const url = parseUrl(input);
|
|
1972
|
+
if (!url) {
|
|
1973
|
+
throw new AppError(`Invalid Figma URL: ${input}`, {
|
|
1974
|
+
code: "figma_url_invalid"
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
if (!isFigmaUrl(input)) {
|
|
1978
|
+
throw new AppError(`Reference URL must be a Figma URL or local image path: ${input}`, {
|
|
1979
|
+
code: "figma_url_expected"
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
1983
|
+
if (parts.length < 2) {
|
|
1984
|
+
throw new AppError(`Could not extract file key from Figma URL: ${input}`, {
|
|
1985
|
+
code: "figma_file_key_missing"
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
let fileKey;
|
|
1989
|
+
if (parts[0] === "design" && parts[2] === "branch") {
|
|
1990
|
+
fileKey = parts[3];
|
|
1991
|
+
} else if (parts[0] === "design" || parts[0] === "file") {
|
|
1992
|
+
fileKey = parts[1];
|
|
1993
|
+
} else if (parts[1] === "branch" && parts[2]) {
|
|
1994
|
+
fileKey = parts[2];
|
|
1995
|
+
}
|
|
1996
|
+
if (!fileKey) {
|
|
1997
|
+
fileKey = parts[1];
|
|
1998
|
+
}
|
|
1999
|
+
const nodeId = url.searchParams.get("node-id");
|
|
2000
|
+
if (!nodeId) {
|
|
2001
|
+
throw new AppError(
|
|
2002
|
+
`Figma URL must include a node-id query parameter so the CLI can export the correct frame: ${input}`,
|
|
2003
|
+
{
|
|
2004
|
+
exitCode: 3,
|
|
2005
|
+
recommendation: "needs_human_review",
|
|
2006
|
+
severity: "high",
|
|
2007
|
+
code: "figma_node_id_missing"
|
|
2008
|
+
}
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
return {
|
|
2012
|
+
fileKey,
|
|
2013
|
+
nodeId: normalizeNodeId(nodeId),
|
|
2014
|
+
resolved: url.toString()
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// src/utils/viewport.ts
|
|
2019
|
+
function parseViewport(value) {
|
|
2020
|
+
const trimmedValue = value.trim();
|
|
2021
|
+
const explicitMatch = /^(?<width>\d+)x(?<height>\d+)$/i.exec(trimmedValue);
|
|
2022
|
+
if (explicitMatch?.groups) {
|
|
2023
|
+
const width = Number(explicitMatch.groups.width);
|
|
2024
|
+
const height = Number(explicitMatch.groups.height);
|
|
2025
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
2026
|
+
throw new AppError(
|
|
2027
|
+
`Invalid viewport "${value}". Width and height must be positive integers.`,
|
|
2028
|
+
{
|
|
2029
|
+
code: "viewport_invalid_dimensions"
|
|
2030
|
+
}
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
return { width, height };
|
|
2034
|
+
}
|
|
2035
|
+
const widthOnlyMatch = /^(?<width>\d+)$/i.exec(trimmedValue);
|
|
2036
|
+
if (widthOnlyMatch?.groups) {
|
|
2037
|
+
const width = Number(widthOnlyMatch.groups.width);
|
|
2038
|
+
if (!Number.isFinite(width) || width <= 0) {
|
|
2039
|
+
throw new AppError(`Invalid viewport "${value}". Width must be a positive integer.`, {
|
|
2040
|
+
code: "viewport_invalid_width"
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
return {
|
|
2044
|
+
width,
|
|
2045
|
+
height: DEFAULT_VIEWPORT_HEIGHT
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
throw new AppError(
|
|
2049
|
+
`Invalid viewport "${value}". Expected WIDTH or WIDTHxHEIGHT, for example 1920 or 1920x900.`,
|
|
2050
|
+
{
|
|
2051
|
+
code: "viewport_invalid_format"
|
|
2052
|
+
}
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// src/io/inputs.ts
|
|
2057
|
+
async function parsePreviewInput(options) {
|
|
2058
|
+
const selector = hashToSelector(options.preview, options.selector);
|
|
2059
|
+
if (isHttpUrl(options.preview)) {
|
|
2060
|
+
if (!options.viewport) {
|
|
2061
|
+
throw new AppError(
|
|
2062
|
+
"Preview URL requires --viewport so the browser screenshot is deterministic.",
|
|
2063
|
+
{
|
|
2064
|
+
code: "preview_viewport_required"
|
|
2065
|
+
}
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
const viewport2 = parseViewport(options.viewport);
|
|
2069
|
+
if (options.fullPage && selector) {
|
|
2070
|
+
throw new AppError(
|
|
2071
|
+
"--full-page cannot be used together with --selector or a preview URL hash fragment.",
|
|
2072
|
+
{
|
|
2073
|
+
code: "preview_full_page_selector_conflict"
|
|
2074
|
+
}
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
return {
|
|
2078
|
+
kind: "url",
|
|
2079
|
+
input: options.preview,
|
|
2080
|
+
resolved: new URL(options.preview).toString(),
|
|
2081
|
+
selector,
|
|
2082
|
+
viewport: viewport2
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
if (options.selector) {
|
|
2086
|
+
throw new AppError("--selector can only be used when --preview is a URL.", {
|
|
2087
|
+
code: "preview_selector_requires_url"
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
if (options.fullPage) {
|
|
2091
|
+
throw new AppError("--full-page can only be used when --preview is a URL.", {
|
|
2092
|
+
code: "preview_full_page_requires_url"
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
const resolvedPath = await ensureFileExists(options.preview);
|
|
2096
|
+
const viewport = options.viewport ? parseViewport(options.viewport) : null;
|
|
2097
|
+
return {
|
|
2098
|
+
kind: "path",
|
|
2099
|
+
input: options.preview,
|
|
2100
|
+
resolved: resolvedPath,
|
|
2101
|
+
selector: null,
|
|
2102
|
+
viewport
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
async function parseReferenceInput(reference) {
|
|
2106
|
+
if (isHttpUrl(reference)) {
|
|
2107
|
+
const parsedFigmaUrl = parseFigmaUrl(reference);
|
|
2108
|
+
return {
|
|
2109
|
+
kind: "figma-url",
|
|
2110
|
+
input: reference,
|
|
2111
|
+
resolved: parsedFigmaUrl.resolved,
|
|
2112
|
+
fileKey: parsedFigmaUrl.fileKey,
|
|
2113
|
+
nodeId: parsedFigmaUrl.nodeId
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
const resolvedPath = await ensureFileExists(reference);
|
|
2117
|
+
return {
|
|
2118
|
+
kind: "path",
|
|
2119
|
+
input: reference,
|
|
2120
|
+
resolved: resolvedPath
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
function resolveViewportForReport(previewViewport, imageDimensions) {
|
|
2124
|
+
return previewViewport ?? { width: imageDimensions.width, height: imageDimensions.height };
|
|
2125
|
+
}
|
|
2126
|
+
function inferPreviewInputSource(options) {
|
|
2127
|
+
if (isHttpUrl(options.preview)) {
|
|
2128
|
+
return {
|
|
2129
|
+
input: options.preview,
|
|
2130
|
+
kind: "url",
|
|
2131
|
+
resolved: new URL(options.preview).toString(),
|
|
2132
|
+
selector: hashToSelector(options.preview, options.selector)
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
return {
|
|
2136
|
+
input: options.preview,
|
|
2137
|
+
kind: "path",
|
|
2138
|
+
resolved: path4.resolve(options.preview),
|
|
2139
|
+
selector: null
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
function inferReferenceInputSource(reference) {
|
|
2143
|
+
if (isHttpUrl(reference)) {
|
|
2144
|
+
return {
|
|
2145
|
+
input: reference,
|
|
2146
|
+
kind: isFigmaUrl(reference) ? "figma-url" : "url",
|
|
2147
|
+
resolved: new URL(reference).toString(),
|
|
2148
|
+
selector: null,
|
|
2149
|
+
transport: null
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
return {
|
|
2153
|
+
input: reference,
|
|
2154
|
+
kind: "path",
|
|
2155
|
+
resolved: path4.resolve(reference),
|
|
2156
|
+
selector: null,
|
|
2157
|
+
transport: "path"
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// src/reference/figma-mcp-reference-provider.ts
|
|
2162
|
+
import { spawn } from "child_process";
|
|
2163
|
+
import { createHash } from "crypto";
|
|
2164
|
+
import { createServer } from "http";
|
|
2165
|
+
import { readFile, rm, writeFile as writeFile3 } from "fs/promises";
|
|
2166
|
+
import os from "os";
|
|
2167
|
+
import path5 from "path";
|
|
2168
|
+
import sharp2 from "sharp";
|
|
2169
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
2170
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2171
|
+
import {
|
|
2172
|
+
StreamableHTTPClientTransport,
|
|
2173
|
+
StreamableHTTPError
|
|
2174
|
+
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2175
|
+
var PEYE_CLIENT_NAME = "peye";
|
|
2176
|
+
var PEYE_CLIENT_VERSION = "0.1.0";
|
|
2177
|
+
var SCREENSHOT_TOOL_NAME = "get_screenshot";
|
|
2178
|
+
var METADATA_TOOL_NAME = "get_metadata";
|
|
2179
|
+
var MAX_ACCEPTABLE_ASPECT_RATIO_DELTA = 0.02;
|
|
2180
|
+
var FigmaMcpReferenceProvider = class {
|
|
2181
|
+
kind = "figma-url";
|
|
2182
|
+
serverUrl;
|
|
2183
|
+
transport;
|
|
2184
|
+
constructor(options) {
|
|
2185
|
+
this.serverUrl = options.serverUrl;
|
|
2186
|
+
this.transport = options.transport;
|
|
2187
|
+
}
|
|
2188
|
+
async prepare(reference, outputPath) {
|
|
2189
|
+
if (reference.kind !== "figma-url") {
|
|
2190
|
+
throw new AppError(
|
|
2191
|
+
`Unsupported reference input kind for Figma MCP provider: ${reference.kind}`,
|
|
2192
|
+
{
|
|
2193
|
+
code: "reference_provider_kind_mismatch"
|
|
2194
|
+
}
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
const callbackServer = this.transport === "figma-mcp-remote" && isInteractiveTerminal() ? await createOAuthCallbackServer() : null;
|
|
2198
|
+
const oauthProvider = this.transport === "figma-mcp-remote" && callbackServer ? await PersistentOAuthClientProvider.create(this.serverUrl, callbackServer) : null;
|
|
2199
|
+
const transportOptions = oauthProvider === null ? { fetch: fetchWithTimeout } : { authProvider: oauthProvider, fetch: fetchWithTimeout };
|
|
2200
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.serverUrl), transportOptions);
|
|
2201
|
+
const client = new Client(
|
|
2202
|
+
{
|
|
2203
|
+
name: PEYE_CLIENT_NAME,
|
|
2204
|
+
version: PEYE_CLIENT_VERSION
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
capabilities: {}
|
|
2208
|
+
}
|
|
2209
|
+
);
|
|
2210
|
+
try {
|
|
2211
|
+
await runWithOAuthRetry(
|
|
2212
|
+
transport,
|
|
2213
|
+
oauthProvider,
|
|
2214
|
+
callbackServer,
|
|
2215
|
+
() => client.connect(transport)
|
|
2216
|
+
);
|
|
2217
|
+
const tools = await runWithOAuthRetry(
|
|
2218
|
+
transport,
|
|
2219
|
+
oauthProvider,
|
|
2220
|
+
callbackServer,
|
|
2221
|
+
() => client.listTools()
|
|
2222
|
+
);
|
|
2223
|
+
const hasScreenshotTool = tools.tools.some((tool) => tool.name === SCREENSHOT_TOOL_NAME);
|
|
2224
|
+
if (!hasScreenshotTool) {
|
|
2225
|
+
throw new AppError(
|
|
2226
|
+
`Figma MCP server at ${this.serverUrl} does not expose ${SCREENSHOT_TOOL_NAME}.`,
|
|
2227
|
+
{
|
|
2228
|
+
code: "figma_mcp_tool_missing"
|
|
2229
|
+
}
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
const result = await runWithOAuthRetry(
|
|
2233
|
+
transport,
|
|
2234
|
+
oauthProvider,
|
|
2235
|
+
callbackServer,
|
|
2236
|
+
() => client.callTool({
|
|
2237
|
+
name: SCREENSHOT_TOOL_NAME,
|
|
2238
|
+
arguments: {
|
|
2239
|
+
fileKey: reference.fileKey,
|
|
2240
|
+
nodeId: reference.nodeId
|
|
2241
|
+
}
|
|
2242
|
+
})
|
|
2243
|
+
);
|
|
2244
|
+
const buffer = extractImageBuffer(result, this.serverUrl);
|
|
2245
|
+
const resizeTarget = tools.tools.some((tool) => tool.name === METADATA_TOOL_NAME) ? await resolveMetadataResizeTarget(
|
|
2246
|
+
client,
|
|
2247
|
+
transport,
|
|
2248
|
+
oauthProvider,
|
|
2249
|
+
callbackServer,
|
|
2250
|
+
reference,
|
|
2251
|
+
buffer
|
|
2252
|
+
) : null;
|
|
2253
|
+
const prepared = resizeTarget === null ? await bufferToNormalizedPng(buffer, outputPath) : await bufferToNormalizedPng(buffer, outputPath, {
|
|
2254
|
+
resizeTo: resizeTarget
|
|
2255
|
+
});
|
|
2256
|
+
return {
|
|
2257
|
+
...prepared,
|
|
2258
|
+
transport: this.transport
|
|
2259
|
+
};
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
throw mapMcpError(error, this.transport, this.serverUrl);
|
|
2262
|
+
} finally {
|
|
2263
|
+
await client.close().catch(() => void 0);
|
|
2264
|
+
await transport.close().catch(() => void 0);
|
|
2265
|
+
await callbackServer?.close().catch(() => void 0);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
async function resolveMetadataResizeTarget(client, transport, oauthProvider, callbackServer, reference, screenshotBuffer) {
|
|
2270
|
+
const metadataResult = await runWithOAuthRetry(
|
|
2271
|
+
transport,
|
|
2272
|
+
oauthProvider,
|
|
2273
|
+
callbackServer,
|
|
2274
|
+
() => client.callTool({
|
|
2275
|
+
name: METADATA_TOOL_NAME,
|
|
2276
|
+
arguments: {
|
|
2277
|
+
fileKey: reference.fileKey,
|
|
2278
|
+
nodeId: reference.nodeId
|
|
2279
|
+
}
|
|
2280
|
+
})
|
|
2281
|
+
);
|
|
2282
|
+
const targetDimensions = extractNodeDimensions(metadataResult);
|
|
2283
|
+
if (targetDimensions === null) {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
const currentDimensions = await readBufferDimensions(screenshotBuffer);
|
|
2287
|
+
if (currentDimensions.width >= targetDimensions.width || currentDimensions.height >= targetDimensions.height) {
|
|
2288
|
+
return null;
|
|
2289
|
+
}
|
|
2290
|
+
const currentAspectRatio = currentDimensions.width / currentDimensions.height;
|
|
2291
|
+
const targetAspectRatio = targetDimensions.width / targetDimensions.height;
|
|
2292
|
+
if (Math.abs(currentAspectRatio - targetAspectRatio) > MAX_ACCEPTABLE_ASPECT_RATIO_DELTA) {
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
return targetDimensions;
|
|
2296
|
+
}
|
|
2297
|
+
async function runWithOAuthRetry(transport, provider, callbackServer, operation) {
|
|
2298
|
+
let hasRetried = false;
|
|
2299
|
+
while (true) {
|
|
2300
|
+
try {
|
|
2301
|
+
return await operation();
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
if (!(error instanceof UnauthorizedError) || provider === null || callbackServer === null) {
|
|
2304
|
+
throw error;
|
|
2305
|
+
}
|
|
2306
|
+
if (hasRetried) {
|
|
2307
|
+
throw error;
|
|
2308
|
+
}
|
|
2309
|
+
hasRetried = true;
|
|
2310
|
+
const authorizationCode = await callbackServer.waitForAuthorizationCode();
|
|
2311
|
+
await transport.finishAuth(authorizationCode);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
function extractImageBuffer(result, serverUrl) {
|
|
2316
|
+
if ("toolResult" in result) {
|
|
2317
|
+
throw new AppError(`Figma MCP server at ${serverUrl} returned an unsupported tool result.`, {
|
|
2318
|
+
exitCode: 3,
|
|
2319
|
+
recommendation: "needs_human_review",
|
|
2320
|
+
severity: "high",
|
|
2321
|
+
code: "figma_mcp_invalid_response"
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
if (result.isError) {
|
|
2325
|
+
const message = extractTextContent(result.content) ?? "Figma MCP tool returned an error.";
|
|
2326
|
+
throw new AppError(message, {
|
|
2327
|
+
exitCode: 3,
|
|
2328
|
+
recommendation: "needs_human_review",
|
|
2329
|
+
severity: "high",
|
|
2330
|
+
code: "figma_mcp_invalid_response"
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
for (const block of result.content) {
|
|
2334
|
+
if (block.type === "image" && block.mimeType.startsWith("image/")) {
|
|
2335
|
+
return Buffer.from(block.data, "base64");
|
|
2336
|
+
}
|
|
2337
|
+
if (block.type === "resource" && "blob" in block.resource) {
|
|
2338
|
+
const mimeType = block.resource.mimeType ?? "";
|
|
2339
|
+
if (mimeType.startsWith("image/")) {
|
|
2340
|
+
return Buffer.from(block.resource.blob, "base64");
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
throw new AppError(
|
|
2345
|
+
`Figma MCP server at ${serverUrl} returned no image content for get_screenshot.`,
|
|
2346
|
+
{
|
|
2347
|
+
exitCode: 3,
|
|
2348
|
+
recommendation: "needs_human_review",
|
|
2349
|
+
severity: "high",
|
|
2350
|
+
code: "figma_mcp_invalid_response"
|
|
2351
|
+
}
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
function extractNodeDimensions(result) {
|
|
2355
|
+
if ("toolResult" in result || result.isError) {
|
|
2356
|
+
return null;
|
|
2357
|
+
}
|
|
2358
|
+
const metadataText = extractTextContent(result.content);
|
|
2359
|
+
if (!metadataText) {
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
const rootTagMatch = metadataText.match(/<[^>]+>/);
|
|
2363
|
+
if (!rootTagMatch) {
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
const widthMatch = rootTagMatch[0].match(/\bwidth="([^"]+)"/);
|
|
2367
|
+
const heightMatch = rootTagMatch[0].match(/\bheight="([^"]+)"/);
|
|
2368
|
+
const width = Math.round(Number.parseFloat(widthMatch?.[1] ?? ""));
|
|
2369
|
+
const height = Math.round(Number.parseFloat(heightMatch?.[1] ?? ""));
|
|
2370
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
return { width, height };
|
|
2374
|
+
}
|
|
2375
|
+
function extractTextContent(content) {
|
|
2376
|
+
for (const block of content) {
|
|
2377
|
+
if (block.type === "text") {
|
|
2378
|
+
return block.text ?? null;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return null;
|
|
2382
|
+
}
|
|
2383
|
+
async function readBufferDimensions(buffer) {
|
|
2384
|
+
const metadata = await sharp2(buffer).metadata();
|
|
2385
|
+
if (!metadata.width || !metadata.height) {
|
|
2386
|
+
throw new AppError("Could not read dimensions from the Figma MCP screenshot buffer.", {
|
|
2387
|
+
code: "figma_mcp_invalid_response"
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
return {
|
|
2391
|
+
width: metadata.width,
|
|
2392
|
+
height: metadata.height
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
function mapMcpError(error, transport, serverUrl) {
|
|
2396
|
+
if (error instanceof AppError) {
|
|
2397
|
+
return error;
|
|
2398
|
+
}
|
|
2399
|
+
if (error instanceof UnauthorizedError) {
|
|
2400
|
+
return new AppError(
|
|
2401
|
+
transport === "figma-mcp-remote" ? "Remote Figma MCP requires OAuth authorization." : `Figma MCP at ${serverUrl} rejected the request as unauthorized.`,
|
|
2402
|
+
{
|
|
2403
|
+
code: "figma_mcp_auth_required",
|
|
2404
|
+
cause: error
|
|
2405
|
+
}
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
if (error instanceof StreamableHTTPError && error.code === 401) {
|
|
2409
|
+
return new AppError(
|
|
2410
|
+
transport === "figma-mcp-remote" ? "Remote Figma MCP requires OAuth authorization." : `Figma MCP at ${serverUrl} rejected the request as unauthorized.`,
|
|
2411
|
+
{
|
|
2412
|
+
code: "figma_mcp_auth_required",
|
|
2413
|
+
cause: error
|
|
2414
|
+
}
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
return new AppError(
|
|
2418
|
+
`Failed to read Figma reference through ${transport} at ${serverUrl}. ${ensureError(error).message}`,
|
|
2419
|
+
{
|
|
2420
|
+
code: "figma_mcp_request_failed",
|
|
2421
|
+
cause: error
|
|
2422
|
+
}
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
function isInteractiveTerminal() {
|
|
2426
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
2427
|
+
}
|
|
2428
|
+
function fetchWithTimeout(input, init) {
|
|
2429
|
+
const timeoutSignal = AbortSignal.timeout(DEFAULT_RESOURCE_TIMEOUT_MS);
|
|
2430
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
2431
|
+
return fetch(input, {
|
|
2432
|
+
...init,
|
|
2433
|
+
signal
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
var PersistentOAuthClientProvider = class _PersistentOAuthClientProvider {
|
|
2437
|
+
callbackServer;
|
|
2438
|
+
storagePath;
|
|
2439
|
+
oauthState;
|
|
2440
|
+
constructor(callbackServer, storagePath, state) {
|
|
2441
|
+
this.callbackServer = callbackServer;
|
|
2442
|
+
this.storagePath = storagePath;
|
|
2443
|
+
this.oauthState = state;
|
|
2444
|
+
}
|
|
2445
|
+
static async create(serverUrl, callbackServer) {
|
|
2446
|
+
const storagePath = await oauthStatePath(serverUrl);
|
|
2447
|
+
const state = await loadOAuthState(storagePath);
|
|
2448
|
+
return new _PersistentOAuthClientProvider(callbackServer, storagePath, state);
|
|
2449
|
+
}
|
|
2450
|
+
get redirectUrl() {
|
|
2451
|
+
return this.callbackServer.redirectUrl;
|
|
2452
|
+
}
|
|
2453
|
+
get clientMetadata() {
|
|
2454
|
+
return {
|
|
2455
|
+
client_name: "peye",
|
|
2456
|
+
redirect_uris: [this.callbackServer.redirectUrl],
|
|
2457
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
2458
|
+
response_types: ["code"],
|
|
2459
|
+
token_endpoint_auth_method: "none"
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
clientInformation() {
|
|
2463
|
+
return this.oauthState.clientInformation;
|
|
2464
|
+
}
|
|
2465
|
+
async saveClientInformation(clientInformation) {
|
|
2466
|
+
this.oauthState.clientInformation = clientInformation;
|
|
2467
|
+
await this.persist();
|
|
2468
|
+
}
|
|
2469
|
+
tokens() {
|
|
2470
|
+
return this.oauthState.tokens;
|
|
2471
|
+
}
|
|
2472
|
+
async saveTokens(tokens) {
|
|
2473
|
+
this.oauthState.tokens = tokens;
|
|
2474
|
+
await this.persist();
|
|
2475
|
+
}
|
|
2476
|
+
redirectToAuthorization(authorizationUrl) {
|
|
2477
|
+
process.stderr.write(
|
|
2478
|
+
[
|
|
2479
|
+
"Remote Figma MCP requires authorization.",
|
|
2480
|
+
`Open this URL to continue: ${authorizationUrl.toString()}`
|
|
2481
|
+
].join("\n") + "\n"
|
|
2482
|
+
);
|
|
2483
|
+
openExternalUrl(authorizationUrl.toString());
|
|
2484
|
+
}
|
|
2485
|
+
async saveCodeVerifier(codeVerifier) {
|
|
2486
|
+
this.oauthState.codeVerifier = codeVerifier;
|
|
2487
|
+
await this.persist();
|
|
2488
|
+
}
|
|
2489
|
+
codeVerifier() {
|
|
2490
|
+
if (!this.oauthState.codeVerifier) {
|
|
2491
|
+
throw new Error("No OAuth code verifier available.");
|
|
2492
|
+
}
|
|
2493
|
+
return this.oauthState.codeVerifier;
|
|
2494
|
+
}
|
|
2495
|
+
async invalidateCredentials(scope) {
|
|
2496
|
+
if (scope === "all" || scope === "client") {
|
|
2497
|
+
delete this.oauthState.clientInformation;
|
|
2498
|
+
}
|
|
2499
|
+
if (scope === "all" || scope === "tokens") {
|
|
2500
|
+
delete this.oauthState.tokens;
|
|
2501
|
+
}
|
|
2502
|
+
if (scope === "all" || scope === "verifier") {
|
|
2503
|
+
delete this.oauthState.codeVerifier;
|
|
2504
|
+
}
|
|
2505
|
+
if (scope === "all" || scope === "discovery") {
|
|
2506
|
+
delete this.oauthState.discoveryState;
|
|
2507
|
+
}
|
|
2508
|
+
const hasState = Object.values(this.oauthState).some((value) => value !== void 0);
|
|
2509
|
+
if (!hasState) {
|
|
2510
|
+
await rm(this.storagePath, { force: true }).catch(() => void 0);
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
await this.persist();
|
|
2514
|
+
}
|
|
2515
|
+
discoveryState() {
|
|
2516
|
+
return this.oauthState.discoveryState;
|
|
2517
|
+
}
|
|
2518
|
+
async saveDiscoveryState(state) {
|
|
2519
|
+
this.oauthState.discoveryState = state;
|
|
2520
|
+
await this.persist();
|
|
2521
|
+
}
|
|
2522
|
+
async persist() {
|
|
2523
|
+
await ensureDirectory(path5.dirname(this.storagePath));
|
|
2524
|
+
await writeFile3(this.storagePath, `${JSON.stringify(this.oauthState, null, 2)}
|
|
2525
|
+
`, "utf8");
|
|
2526
|
+
}
|
|
2527
|
+
};
|
|
2528
|
+
async function loadOAuthState(storagePath) {
|
|
2529
|
+
try {
|
|
2530
|
+
const raw = await readFile(storagePath, "utf8");
|
|
2531
|
+
return JSON.parse(raw);
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
const message = ensureError(error).message;
|
|
2534
|
+
if (error.code === "ENOENT") {
|
|
2535
|
+
return {};
|
|
2536
|
+
}
|
|
2537
|
+
throw new AppError(`Failed to load persisted Figma MCP OAuth state. ${message}`, {
|
|
2538
|
+
code: "figma_mcp_oauth_state_invalid",
|
|
2539
|
+
cause: error
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
async function oauthStatePath(serverUrl) {
|
|
2544
|
+
const digest = createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
|
|
2545
|
+
const dir = path5.join(resolveConfigDir(), "oauth");
|
|
2546
|
+
await ensureDirectory(dir);
|
|
2547
|
+
return path5.join(dir, `figma-${digest}.json`);
|
|
2548
|
+
}
|
|
2549
|
+
function resolveConfigDir() {
|
|
2550
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
2551
|
+
return path5.join(process.env.APPDATA, "peye");
|
|
2552
|
+
}
|
|
2553
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
2554
|
+
return path5.join(process.env.XDG_CONFIG_HOME, "peye");
|
|
2555
|
+
}
|
|
2556
|
+
return path5.join(os.homedir(), ".config", "peye");
|
|
2557
|
+
}
|
|
2558
|
+
async function createOAuthCallbackServer() {
|
|
2559
|
+
const deferred = createDeferred();
|
|
2560
|
+
const server = createServer((request, response) => {
|
|
2561
|
+
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
2562
|
+
const authorizationCode = requestUrl.searchParams.get("code");
|
|
2563
|
+
const error = requestUrl.searchParams.get("error");
|
|
2564
|
+
if (authorizationCode) {
|
|
2565
|
+
response.statusCode = 200;
|
|
2566
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
2567
|
+
response.end(
|
|
2568
|
+
"<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
|
|
2569
|
+
);
|
|
2570
|
+
deferred.resolve(authorizationCode);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (error) {
|
|
2574
|
+
response.statusCode = 400;
|
|
2575
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
2576
|
+
response.end(`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`);
|
|
2577
|
+
deferred.reject(new Error(`OAuth authorization failed: ${error}`));
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
response.statusCode = 400;
|
|
2581
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
2582
|
+
response.end("<html><body><h1>Invalid callback</h1></body></html>");
|
|
2583
|
+
});
|
|
2584
|
+
await new Promise((resolve, reject) => {
|
|
2585
|
+
server.once("error", reject);
|
|
2586
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2587
|
+
server.off("error", reject);
|
|
2588
|
+
resolve();
|
|
2589
|
+
});
|
|
2590
|
+
});
|
|
2591
|
+
const address = server.address();
|
|
2592
|
+
if (!address || typeof address === "string") {
|
|
2593
|
+
throw new AppError("Failed to start local OAuth callback server.", {
|
|
2594
|
+
code: "figma_mcp_oauth_callback_failed"
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
return {
|
|
2598
|
+
redirectUrl: `http://127.0.0.1:${address.port}/callback`,
|
|
2599
|
+
waitForAuthorizationCode: async () => {
|
|
2600
|
+
return new Promise((resolve, reject) => {
|
|
2601
|
+
const timeout = setTimeout(() => {
|
|
2602
|
+
reject(
|
|
2603
|
+
new AppError("Timed out waiting for the Figma MCP OAuth callback.", {
|
|
2604
|
+
code: "figma_mcp_oauth_timeout"
|
|
2605
|
+
})
|
|
2606
|
+
);
|
|
2607
|
+
}, DEFAULT_FIGMA_OAUTH_TIMEOUT_MS);
|
|
2608
|
+
timeout.unref?.();
|
|
2609
|
+
deferred.promise.then(
|
|
2610
|
+
(code) => {
|
|
2611
|
+
clearTimeout(timeout);
|
|
2612
|
+
resolve(code);
|
|
2613
|
+
},
|
|
2614
|
+
(error) => {
|
|
2615
|
+
clearTimeout(timeout);
|
|
2616
|
+
reject(ensureError(error));
|
|
2617
|
+
}
|
|
2618
|
+
);
|
|
2619
|
+
});
|
|
2620
|
+
},
|
|
2621
|
+
close: async () => {
|
|
2622
|
+
await new Promise((resolve, reject) => {
|
|
2623
|
+
server.close((error) => {
|
|
2624
|
+
if (error) {
|
|
2625
|
+
reject(error);
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
resolve();
|
|
2629
|
+
});
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
function createDeferred() {
|
|
2635
|
+
let resolve;
|
|
2636
|
+
let reject;
|
|
2637
|
+
const promise = new Promise((innerResolve, innerReject) => {
|
|
2638
|
+
resolve = innerResolve;
|
|
2639
|
+
reject = innerReject;
|
|
2640
|
+
});
|
|
2641
|
+
return {
|
|
2642
|
+
promise,
|
|
2643
|
+
reject,
|
|
2644
|
+
resolve
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
function openExternalUrl(url) {
|
|
2648
|
+
const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
2649
|
+
const [file, ...args] = command;
|
|
2650
|
+
try {
|
|
2651
|
+
const child = spawn(file, args, {
|
|
2652
|
+
detached: true,
|
|
2653
|
+
stdio: "ignore"
|
|
2654
|
+
});
|
|
2655
|
+
child.once("error", () => {
|
|
2656
|
+
process.stderr.write("Open the authorization URL manually if the browser did not start.\n");
|
|
2657
|
+
});
|
|
2658
|
+
child.unref();
|
|
2659
|
+
} catch {
|
|
2660
|
+
process.stderr.write(`Open the authorization URL manually if the browser did not start.
|
|
2661
|
+
`);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// src/reference/figma-rest-reference-provider.ts
|
|
2666
|
+
var FigmaRestReferenceProvider = class {
|
|
2667
|
+
kind = "figma-url";
|
|
2668
|
+
async prepare(reference, outputPath) {
|
|
2669
|
+
if (reference.kind !== "figma-url") {
|
|
2670
|
+
throw new AppError(`Unsupported reference input kind for Figma provider: ${reference.kind}`, {
|
|
2671
|
+
code: "reference_provider_kind_mismatch"
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
const token = process.env.FIGMA_TOKEN;
|
|
2675
|
+
if (!token) {
|
|
2676
|
+
throw new AppError("FIGMA_TOKEN is required when --reference points to a Figma URL.", {
|
|
2677
|
+
code: "figma_token_missing"
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
const endpoint = new URL(`/v1/images/${reference.fileKey}`, figmaApiBaseUrl());
|
|
2681
|
+
endpoint.searchParams.set("ids", reference.nodeId);
|
|
2682
|
+
endpoint.searchParams.set("format", "png");
|
|
2683
|
+
endpoint.searchParams.set("scale", "1");
|
|
2684
|
+
const payload = await fetchFigmaJson(
|
|
2685
|
+
endpoint,
|
|
2686
|
+
token,
|
|
2687
|
+
`Failed to export Figma node image for ${reference.nodeId}.`
|
|
2688
|
+
);
|
|
2689
|
+
const imageUrl = payload.images?.[reference.nodeId];
|
|
2690
|
+
if (!imageUrl) {
|
|
2691
|
+
throw new AppError(`Figma did not return an image URL for node ${reference.nodeId}.`, {
|
|
2692
|
+
exitCode: 3,
|
|
2693
|
+
recommendation: "needs_human_review",
|
|
2694
|
+
severity: "high",
|
|
2695
|
+
code: "figma_image_missing"
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
const buffer = await fetchBinary(
|
|
2699
|
+
imageUrl,
|
|
2700
|
+
`Failed to download exported Figma image for ${reference.nodeId}.`
|
|
2701
|
+
);
|
|
2702
|
+
const prepared = await bufferToNormalizedPng(buffer, outputPath);
|
|
2703
|
+
return {
|
|
2704
|
+
...prepared,
|
|
2705
|
+
transport: "figma-rest"
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
};
|
|
2709
|
+
function figmaApiBaseUrl() {
|
|
2710
|
+
return process.env.FIGMA_API_BASE_URL ?? DEFAULT_FIGMA_API_BASE_URL;
|
|
2711
|
+
}
|
|
2712
|
+
async function fetchFigmaJson(url, token, failureMessage) {
|
|
2713
|
+
const response = await fetchWithTimeout2(
|
|
2714
|
+
url,
|
|
2715
|
+
{
|
|
2716
|
+
headers: {
|
|
2717
|
+
"X-Figma-Token": token
|
|
2718
|
+
}
|
|
2719
|
+
},
|
|
2720
|
+
failureMessage
|
|
2721
|
+
);
|
|
2722
|
+
try {
|
|
2723
|
+
return await response.json();
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
throw new AppError(`${failureMessage} Invalid JSON response from Figma API.`, {
|
|
2726
|
+
code: "figma_response_invalid_json",
|
|
2727
|
+
cause: error
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
async function fetchBinary(url, failureMessage) {
|
|
2732
|
+
const response = await fetchWithTimeout2(url, void 0, failureMessage);
|
|
2733
|
+
return Buffer.from(await response.arrayBuffer());
|
|
2734
|
+
}
|
|
2735
|
+
async function fetchWithTimeout2(input, init, failureMessage) {
|
|
2736
|
+
try {
|
|
2737
|
+
const response = await fetch(input, {
|
|
2738
|
+
...init,
|
|
2739
|
+
signal: AbortSignal.timeout(DEFAULT_RESOURCE_TIMEOUT_MS)
|
|
2740
|
+
});
|
|
2741
|
+
if (!response.ok) {
|
|
2742
|
+
throw new AppError(`${failureMessage} Received ${response.status} ${response.statusText}.`, {
|
|
2743
|
+
code: "remote_request_failed"
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
return response;
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
if (error instanceof AppError) {
|
|
2749
|
+
throw error;
|
|
2750
|
+
}
|
|
2751
|
+
throw new AppError(`${failureMessage} ${ensureError(error).message}`, {
|
|
2752
|
+
code: "remote_request_failed",
|
|
2753
|
+
cause: error
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// src/reference/figma-reference-provider.ts
|
|
2759
|
+
var FigmaReferenceProvider = class {
|
|
2760
|
+
kind = "figma-url";
|
|
2761
|
+
async prepare(reference, outputPath) {
|
|
2762
|
+
const providers2 = buildProviders();
|
|
2763
|
+
const failures = [];
|
|
2764
|
+
for (const provider of providers2) {
|
|
2765
|
+
try {
|
|
2766
|
+
return await provider.instance.prepare(reference, outputPath);
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
const appError = error instanceof AppError ? error : new AppError(ensureError(error).message, {
|
|
2769
|
+
cause: error,
|
|
2770
|
+
code: "figma_reference_attempt_failed"
|
|
2771
|
+
});
|
|
2772
|
+
failures.push({
|
|
2773
|
+
label: provider.label,
|
|
2774
|
+
error: appError
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
throw buildFailureFromAttempts(failures);
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
function buildProviders() {
|
|
2782
|
+
const mode = resolveFigmaSourceMode();
|
|
2783
|
+
const desktopProvider = new FigmaMcpReferenceProvider({
|
|
2784
|
+
serverUrl: process.env.PEYE_FIGMA_MCP_DESKTOP_URL ?? DEFAULT_FIGMA_MCP_DESKTOP_URL,
|
|
2785
|
+
transport: "figma-mcp-desktop"
|
|
2786
|
+
});
|
|
2787
|
+
const remoteProvider = new FigmaMcpReferenceProvider({
|
|
2788
|
+
serverUrl: process.env.PEYE_FIGMA_MCP_REMOTE_URL ?? DEFAULT_FIGMA_MCP_REMOTE_URL,
|
|
2789
|
+
transport: "figma-mcp-remote"
|
|
2790
|
+
});
|
|
2791
|
+
const restProvider = new FigmaRestReferenceProvider();
|
|
2792
|
+
if (mode === "rest") {
|
|
2793
|
+
return [{ instance: restProvider, label: "Figma REST" }];
|
|
2794
|
+
}
|
|
2795
|
+
if (mode === "mcp") {
|
|
2796
|
+
return [
|
|
2797
|
+
{ instance: desktopProvider, label: "Figma MCP desktop" },
|
|
2798
|
+
{ instance: remoteProvider, label: "Figma MCP remote" }
|
|
2799
|
+
];
|
|
2800
|
+
}
|
|
2801
|
+
return [
|
|
2802
|
+
{ instance: desktopProvider, label: "Figma MCP desktop" },
|
|
2803
|
+
{ instance: remoteProvider, label: "Figma MCP remote" },
|
|
2804
|
+
{ instance: restProvider, label: "Figma REST" }
|
|
2805
|
+
];
|
|
2806
|
+
}
|
|
2807
|
+
function resolveFigmaSourceMode() {
|
|
2808
|
+
const mode = process.env.PEYE_FIGMA_SOURCE ?? DEFAULT_FIGMA_SOURCE;
|
|
2809
|
+
if (mode === "auto" || mode === "mcp" || mode === "rest") {
|
|
2810
|
+
return mode;
|
|
2811
|
+
}
|
|
2812
|
+
throw new AppError(
|
|
2813
|
+
`Invalid PEYE_FIGMA_SOURCE value "${mode}". Expected one of: auto, mcp, rest.`,
|
|
2814
|
+
{
|
|
2815
|
+
code: "figma_source_invalid"
|
|
2816
|
+
}
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
function buildFailureFromAttempts(failures) {
|
|
2820
|
+
if (failures.length === 1) {
|
|
2821
|
+
return failures[0].error;
|
|
2822
|
+
}
|
|
2823
|
+
const preferredError = failures.find(
|
|
2824
|
+
(failure) => failure.error.code === "figma_mcp_invalid_response" || failure.error.code === "figma_image_missing"
|
|
2825
|
+
)?.error ?? failures.at(-1)?.error;
|
|
2826
|
+
const nextSteps = [
|
|
2827
|
+
"Start the Figma desktop app MCP server.",
|
|
2828
|
+
"Run peye in an interactive terminal to authorize remote Figma MCP.",
|
|
2829
|
+
"Set FIGMA_TOKEN to enable REST fallback."
|
|
2830
|
+
];
|
|
2831
|
+
const attemptLines = failures.map((failure) => `- ${failure.label}: ${failure.error.message}`);
|
|
2832
|
+
return new AppError(
|
|
2833
|
+
[
|
|
2834
|
+
preferredError?.message ?? "Failed to resolve the Figma reference.",
|
|
2835
|
+
"Reference lookup attempts:",
|
|
2836
|
+
...attemptLines,
|
|
2837
|
+
"Next steps:",
|
|
2838
|
+
...nextSteps.map((step) => `- ${step}`)
|
|
2839
|
+
].join("\n"),
|
|
2840
|
+
{
|
|
2841
|
+
exitCode: preferredError?.exitCode ?? 3,
|
|
2842
|
+
recommendation: preferredError?.recommendation ?? "needs_human_review",
|
|
2843
|
+
severity: preferredError?.severity ?? "high",
|
|
2844
|
+
code: preferredError?.code ?? "figma_reference_unavailable",
|
|
2845
|
+
cause: preferredError
|
|
2846
|
+
}
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// src/reference/local-reference-provider.ts
|
|
2851
|
+
var LocalReferenceProvider = class {
|
|
2852
|
+
kind = "path";
|
|
2853
|
+
async prepare(reference, outputPath) {
|
|
2854
|
+
try {
|
|
2855
|
+
const prepared = await normalizeImageToPng(reference.resolved, outputPath);
|
|
2856
|
+
return {
|
|
2857
|
+
...prepared,
|
|
2858
|
+
transport: "path"
|
|
2859
|
+
};
|
|
2860
|
+
} catch (error) {
|
|
2861
|
+
throw new AppError(
|
|
2862
|
+
`Failed to normalize reference image: ${reference.resolved}. ${ensureError(error).message}`,
|
|
2863
|
+
{
|
|
2864
|
+
code: "reference_image_normalization_failed",
|
|
2865
|
+
cause: error
|
|
2866
|
+
}
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
// src/reference/index.ts
|
|
2873
|
+
var providers = /* @__PURE__ */ new Map([
|
|
2874
|
+
["figma-url", new FigmaReferenceProvider()],
|
|
2875
|
+
["path", new LocalReferenceProvider()]
|
|
2876
|
+
]);
|
|
2877
|
+
async function materializeReferenceImage(reference, outputPath) {
|
|
2878
|
+
const provider = providers.get(reference.kind);
|
|
2879
|
+
if (!provider) {
|
|
2880
|
+
throw new AppError(`No reference provider registered for ${reference.kind}.`, {
|
|
2881
|
+
code: "reference_provider_missing"
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
return provider.prepare(reference, outputPath);
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// src/core/run-compare.ts
|
|
2888
|
+
async function runCompare(options) {
|
|
2889
|
+
validateThresholdOrdering(options);
|
|
2890
|
+
const outputDir = await ensureDirectory(options.output);
|
|
2891
|
+
const artifactPaths = createArtifactPaths(outputDir);
|
|
2892
|
+
let previewInput = null;
|
|
2893
|
+
let referenceInput = null;
|
|
2894
|
+
let preparedPreview = null;
|
|
2895
|
+
let preparedReference = null;
|
|
2896
|
+
try {
|
|
2897
|
+
previewInput = await parsePreviewInput(options);
|
|
2898
|
+
referenceInput = await parseReferenceInput(options.reference);
|
|
2899
|
+
preparedPreview = await materializePreviewImage(
|
|
2900
|
+
previewInput,
|
|
2901
|
+
artifactPaths.preview,
|
|
2902
|
+
options.fullPage
|
|
2903
|
+
);
|
|
2904
|
+
preparedReference = await materializeReferenceImage(referenceInput, artifactPaths.reference);
|
|
2905
|
+
const previewImage = await loadNormalizedImage(preparedPreview.path);
|
|
2906
|
+
const referenceImage = await loadNormalizedImage(preparedReference.path);
|
|
2907
|
+
const width = Math.max(previewImage.width, referenceImage.width);
|
|
2908
|
+
const height = Math.max(previewImage.height, referenceImage.height);
|
|
2909
|
+
const paddedPreview = padImageToCanvas(previewImage, width, height);
|
|
2910
|
+
const paddedReference = padImageToCanvas(referenceImage, width, height);
|
|
2911
|
+
const comparison = runComparisonEngine({
|
|
2912
|
+
reference: paddedReference.data,
|
|
2913
|
+
preview: paddedPreview.data,
|
|
2914
|
+
width,
|
|
2915
|
+
height,
|
|
2916
|
+
referenceOriginal: {
|
|
2917
|
+
width: referenceImage.width,
|
|
2918
|
+
height: referenceImage.height
|
|
2919
|
+
},
|
|
2920
|
+
previewOriginal: {
|
|
2921
|
+
width: previewImage.width,
|
|
2922
|
+
height: previewImage.height
|
|
2923
|
+
},
|
|
2924
|
+
mode: options.mode ?? DEFAULT_MODE
|
|
2925
|
+
});
|
|
2926
|
+
const findingsAnalysis = buildFindingsAnalysis({
|
|
2927
|
+
analysisMode: preparedPreview.analysisMode,
|
|
2928
|
+
rawRegions: comparison.rawRegions,
|
|
2929
|
+
domSnapshot: preparedPreview.domSnapshot,
|
|
2930
|
+
width,
|
|
2931
|
+
height
|
|
2932
|
+
});
|
|
2933
|
+
const heatmap = createHeatmapArtifact({
|
|
2934
|
+
reference: paddedReference.data,
|
|
2935
|
+
preview: paddedPreview.data,
|
|
2936
|
+
mismatchMask: comparison.mismatchMask,
|
|
2937
|
+
visuals: findingsAnalysis.visuals,
|
|
2938
|
+
width,
|
|
2939
|
+
height
|
|
2940
|
+
});
|
|
2941
|
+
await Promise.all([
|
|
2942
|
+
writeRawRgbaPng(artifactPaths.overlay, comparison.buffers.overlay, width, height),
|
|
2943
|
+
writeRawRgbaPng(artifactPaths.diff, comparison.buffers.diff, width, height),
|
|
2944
|
+
writeRawRgbaPng(artifactPaths.heatmap, heatmap, width, height)
|
|
2945
|
+
]);
|
|
2946
|
+
const metrics = {
|
|
2947
|
+
...comparison.metrics,
|
|
2948
|
+
findingsCount: findingsAnalysis.metrics.findingsCount,
|
|
2949
|
+
affectedElementCount: findingsAnalysis.metrics.affectedElementCount
|
|
2950
|
+
};
|
|
2951
|
+
const decision = decideRecommendation({
|
|
2952
|
+
metrics,
|
|
2953
|
+
thresholds: {
|
|
2954
|
+
pass: options.thresholdPass,
|
|
2955
|
+
tolerated: options.thresholdTolerated,
|
|
2956
|
+
retry: options.thresholdRetry
|
|
2957
|
+
},
|
|
2958
|
+
findings: findingsAnalysis.findings
|
|
2959
|
+
});
|
|
2960
|
+
const report = {
|
|
2961
|
+
analysisMode: preparedPreview.analysisMode,
|
|
2962
|
+
summary: decision,
|
|
2963
|
+
inputs: {
|
|
2964
|
+
preview: {
|
|
2965
|
+
input: previewInput.input,
|
|
2966
|
+
kind: previewInput.kind,
|
|
2967
|
+
resolved: previewInput.resolved,
|
|
2968
|
+
selector: previewInput.selector
|
|
2969
|
+
},
|
|
2970
|
+
reference: {
|
|
2971
|
+
input: referenceInput.input,
|
|
2972
|
+
kind: referenceInput.kind,
|
|
2973
|
+
resolved: referenceInput.resolved,
|
|
2974
|
+
selector: null,
|
|
2975
|
+
transport: preparedReference.transport
|
|
2976
|
+
},
|
|
2977
|
+
viewport: resolveViewportForReport(previewInput.viewport, {
|
|
2978
|
+
width: previewImage.width,
|
|
2979
|
+
height: previewImage.height
|
|
2980
|
+
}),
|
|
2981
|
+
mode: options.mode,
|
|
2982
|
+
fullPage: options.fullPage
|
|
2983
|
+
},
|
|
2984
|
+
images: {
|
|
2985
|
+
preview: { width: previewImage.width, height: previewImage.height },
|
|
2986
|
+
reference: { width: referenceImage.width, height: referenceImage.height },
|
|
2987
|
+
canvas: { width, height }
|
|
2988
|
+
},
|
|
2989
|
+
metrics,
|
|
2990
|
+
rollups: findingsAnalysis.rollups,
|
|
2991
|
+
findings: findingsAnalysis.findings,
|
|
2992
|
+
artifacts: artifactPaths,
|
|
2993
|
+
error: null
|
|
2994
|
+
};
|
|
2995
|
+
await writeJsonFile(artifactPaths.report, report);
|
|
2996
|
+
return {
|
|
2997
|
+
report,
|
|
2998
|
+
exitCode: exitCodeForRecommendation(report.summary.recommendation)
|
|
2999
|
+
};
|
|
3000
|
+
} catch (error) {
|
|
3001
|
+
if (isAppError(error)) {
|
|
3002
|
+
const report = createFailureReport({
|
|
3003
|
+
reason: error.message,
|
|
3004
|
+
recommendation: error.recommendation ?? "needs_human_review",
|
|
3005
|
+
severity: error.severity ?? (error.exitCode === 1 ? "medium" : "high"),
|
|
3006
|
+
preview: toPreviewSourceReport(previewInput, options),
|
|
3007
|
+
reference: toReferenceSourceReport(referenceInput, options.reference, preparedReference),
|
|
3008
|
+
viewport: previewInput?.viewport ?? null,
|
|
3009
|
+
analysisMode: preparedPreview?.analysisMode ?? inferAnalysisMode(previewInput, options),
|
|
3010
|
+
mode: options.mode,
|
|
3011
|
+
fullPage: options.fullPage,
|
|
3012
|
+
images: buildImagesReport(preparedPreview, preparedReference),
|
|
3013
|
+
artifacts: {
|
|
3014
|
+
...artifactPaths,
|
|
3015
|
+
preview: await pathExists(artifactPaths.preview) ? artifactPaths.preview : null,
|
|
3016
|
+
reference: await pathExists(artifactPaths.reference) ? artifactPaths.reference : null,
|
|
3017
|
+
overlay: null,
|
|
3018
|
+
diff: null,
|
|
3019
|
+
heatmap: null
|
|
3020
|
+
},
|
|
3021
|
+
error: {
|
|
3022
|
+
code: error.code,
|
|
3023
|
+
message: error.message,
|
|
3024
|
+
exitCode: error.exitCode
|
|
3025
|
+
}
|
|
3026
|
+
});
|
|
3027
|
+
await writeJsonFile(artifactPaths.report, report);
|
|
3028
|
+
return {
|
|
3029
|
+
report,
|
|
3030
|
+
exitCode: error.exitCode
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
throw error;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
function buildImagesReport(preparedPreview, preparedReference) {
|
|
3037
|
+
const preview = preparedPreview === null ? null : {
|
|
3038
|
+
width: preparedPreview.width,
|
|
3039
|
+
height: preparedPreview.height
|
|
3040
|
+
};
|
|
3041
|
+
const reference = preparedReference === null ? null : {
|
|
3042
|
+
width: preparedReference.width,
|
|
3043
|
+
height: preparedReference.height
|
|
3044
|
+
};
|
|
3045
|
+
return {
|
|
3046
|
+
preview,
|
|
3047
|
+
reference,
|
|
3048
|
+
canvas: preview === null || reference === null ? null : {
|
|
3049
|
+
width: Math.max(preview.width, reference.width),
|
|
3050
|
+
height: Math.max(preview.height, reference.height)
|
|
3051
|
+
}
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
function inferAnalysisMode(previewInput, options) {
|
|
3055
|
+
if (previewInput) {
|
|
3056
|
+
return previewInput.kind === "url" ? "dom-elements" : "visual-clusters";
|
|
3057
|
+
}
|
|
3058
|
+
return options.preview.startsWith("http://") || options.preview.startsWith("https://") ? "dom-elements" : "visual-clusters";
|
|
3059
|
+
}
|
|
3060
|
+
function createArtifactPaths(outputDir) {
|
|
3061
|
+
return {
|
|
3062
|
+
preview: path6.join(outputDir, "preview.png"),
|
|
3063
|
+
reference: path6.join(outputDir, "reference.png"),
|
|
3064
|
+
overlay: path6.join(outputDir, "overlay.png"),
|
|
3065
|
+
diff: path6.join(outputDir, "diff.png"),
|
|
3066
|
+
heatmap: path6.join(outputDir, "heatmap.png"),
|
|
3067
|
+
report: path6.join(outputDir, "report.json")
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
function toPreviewSourceReport(previewInput, options) {
|
|
3071
|
+
if (!previewInput) {
|
|
3072
|
+
return inferPreviewInputSource(options);
|
|
3073
|
+
}
|
|
3074
|
+
return {
|
|
3075
|
+
input: previewInput.input,
|
|
3076
|
+
kind: previewInput.kind,
|
|
3077
|
+
resolved: previewInput.resolved,
|
|
3078
|
+
selector: previewInput.selector
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
function toReferenceSourceReport(referenceInput, reference, preparedReference) {
|
|
3082
|
+
if (preparedReference) {
|
|
3083
|
+
const inferred = inferReferenceInputSource(reference);
|
|
3084
|
+
return {
|
|
3085
|
+
input: referenceInput?.input ?? reference,
|
|
3086
|
+
kind: referenceInput?.kind ?? inferred.kind,
|
|
3087
|
+
resolved: referenceInput?.resolved ?? inferred.resolved,
|
|
3088
|
+
selector: null,
|
|
3089
|
+
transport: preparedReference.transport
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
if (!referenceInput) {
|
|
3093
|
+
return inferReferenceInputSource(reference);
|
|
3094
|
+
}
|
|
3095
|
+
return {
|
|
3096
|
+
input: referenceInput.input,
|
|
3097
|
+
kind: referenceInput.kind,
|
|
3098
|
+
resolved: referenceInput.resolved,
|
|
3099
|
+
selector: null,
|
|
3100
|
+
transport: referenceInput.kind === "path" ? "path" : null
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
function validateThresholdOrdering(options) {
|
|
3104
|
+
if (options.thresholdPass > options.thresholdTolerated) {
|
|
3105
|
+
throw new AppError("--threshold-pass must be less than or equal to --threshold-tolerated.");
|
|
3106
|
+
}
|
|
3107
|
+
if (options.thresholdTolerated > options.thresholdRetry) {
|
|
3108
|
+
throw new AppError("--threshold-tolerated must be less than or equal to --threshold-retry.");
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
function exitCodeForRecommendation(recommendation) {
|
|
3112
|
+
switch (recommendation) {
|
|
3113
|
+
case "pass":
|
|
3114
|
+
case "pass_with_tolerated_differences":
|
|
3115
|
+
return 0;
|
|
3116
|
+
case "retry_fix":
|
|
3117
|
+
return 2;
|
|
3118
|
+
case "needs_human_review":
|
|
3119
|
+
return 3;
|
|
3120
|
+
default:
|
|
3121
|
+
return 1;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// src/types/report.ts
|
|
3126
|
+
var COMPARE_MODES = ["all", "pixel", "layout", "color"];
|
|
3127
|
+
|
|
3128
|
+
// src/cli/compare-command.ts
|
|
3129
|
+
function registerCompareCommand(program2) {
|
|
3130
|
+
program2.command("compare").description("Compare a preview screenshot or URL against a Figma reference or local image.").requiredOption("--preview <url|path>", "Preview URL or screenshot path").requiredOption("--reference <figma-url|path>", "Figma URL or local reference screenshot path").requiredOption("--output <dir>", "Output directory for report and generated artifacts").option(
|
|
3131
|
+
"--viewport <width|widthxheight>",
|
|
3132
|
+
"Viewport width or widthxheight used for preview URL capture, for example 1920 or 1920x900"
|
|
3133
|
+
).option("--mode <mode>", "Analysis mode: all, pixel, layout, color", DEFAULT_MODE).option("--selector <css>", "CSS selector for preview element capture").option("--full-page", "Capture the full preview page when preview is a URL", false).option("--quiet", "Suppress the human-readable terminal summary", false).option(
|
|
3134
|
+
"--report-stdout",
|
|
3135
|
+
"Write compact report JSON to stdout and suppress the human-readable summary",
|
|
3136
|
+
false
|
|
3137
|
+
).option(
|
|
3138
|
+
"--threshold-pass <number>",
|
|
3139
|
+
"Pass threshold in percent",
|
|
3140
|
+
parseFloat,
|
|
3141
|
+
DEFAULT_THRESHOLDS.pass
|
|
3142
|
+
).option(
|
|
3143
|
+
"--threshold-tolerated <number>",
|
|
3144
|
+
"Tolerated threshold in percent",
|
|
3145
|
+
parseFloat,
|
|
3146
|
+
DEFAULT_THRESHOLDS.tolerated
|
|
3147
|
+
).option(
|
|
3148
|
+
"--threshold-retry <number>",
|
|
3149
|
+
"Retry threshold in percent",
|
|
3150
|
+
parseFloat,
|
|
3151
|
+
DEFAULT_THRESHOLDS.retry
|
|
3152
|
+
).action(async (rawOptions) => {
|
|
3153
|
+
const { quiet, reportStdout, ...options } = validateOptions(rawOptions);
|
|
3154
|
+
const result = await runCompare(options);
|
|
3155
|
+
writeCliOutput(result.report, { quiet, reportStdout });
|
|
3156
|
+
process.exitCode = result.exitCode;
|
|
3157
|
+
});
|
|
3158
|
+
}
|
|
3159
|
+
function validateOptions(options) {
|
|
3160
|
+
if (!COMPARE_MODES.includes(options.mode)) {
|
|
3161
|
+
throw new AppError(
|
|
3162
|
+
`Invalid --mode value "${options.mode}". Expected one of: ${COMPARE_MODES.join(", ")}.`
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
for (const [label, value] of [
|
|
3166
|
+
["threshold-pass", options.thresholdPass],
|
|
3167
|
+
["threshold-tolerated", options.thresholdTolerated],
|
|
3168
|
+
["threshold-retry", options.thresholdRetry]
|
|
3169
|
+
]) {
|
|
3170
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
3171
|
+
throw new AppError(`--${label} must be a non-negative number.`);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
return options;
|
|
3175
|
+
}
|
|
3176
|
+
function writeCliOutput(report, options) {
|
|
3177
|
+
if (options.reportStdout) {
|
|
3178
|
+
process.stdout.write(`${JSON.stringify(report)}
|
|
3179
|
+
`);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (options.quiet) {
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
3185
|
+
printSummary(report);
|
|
3186
|
+
}
|
|
3187
|
+
function printSummary(report) {
|
|
3188
|
+
console.log(`recommendation: ${report.summary.recommendation}`);
|
|
3189
|
+
console.log(`severity: ${report.summary.severity}`);
|
|
3190
|
+
console.log(`reason: ${report.summary.reason}`);
|
|
3191
|
+
console.log(`mismatchPercent: ${report.metrics.mismatchPercent.toFixed(4)}%`);
|
|
3192
|
+
console.log(`findings: ${report.findings.length}/${report.metrics.findingsCount}`);
|
|
3193
|
+
console.log(`output: ${path7.dirname(report.artifacts.report)}`);
|
|
3194
|
+
}
|
|
3195
|
+
function handleCliError(error) {
|
|
3196
|
+
if (isAppError(error)) {
|
|
3197
|
+
console.error(error.message);
|
|
3198
|
+
process.exitCode = error.exitCode;
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
if (error instanceof Error) {
|
|
3202
|
+
console.error(error.message);
|
|
3203
|
+
} else {
|
|
3204
|
+
console.error("Unknown error");
|
|
3205
|
+
}
|
|
3206
|
+
process.exitCode = 1;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// src/cli/bin.ts
|
|
3210
|
+
var program = new Command();
|
|
3211
|
+
program.name("peye").version(package_default.version).description(
|
|
3212
|
+
"Standalone visual diff CLI for comparing preview screenshots against Figma references."
|
|
3213
|
+
).configureHelp({
|
|
3214
|
+
sortOptions: true,
|
|
3215
|
+
sortSubcommands: true
|
|
3216
|
+
}).showHelpAfterError();
|
|
3217
|
+
registerCompareCommand(program);
|
|
3218
|
+
program.parseAsync(process.argv).catch(handleCliError);
|
|
3219
|
+
//# sourceMappingURL=bin.js.map
|