@bridge_gpt/mcp-server 0.2.18 → 0.2.19
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/CONDUCTOR.md +75 -0
- package/README.md +2 -2
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +1 -0
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +97 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +11222 -8544
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/public/css/main.min.css +39 -0
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visual-diff.ts — orchestration for the deterministic `visual_diff` MCP tool
|
|
3
|
+
* (BAPI-563).
|
|
4
|
+
*
|
|
5
|
+
* `index.ts` stays the thin registration surface; the browser render, image
|
|
6
|
+
* sniffing/dimension logic, comp resolution, and worker dispatch live here so
|
|
7
|
+
* they are unit-testable without launching a real browser or spawning worker
|
|
8
|
+
* threads. `runVisualDiff` renders `target_url` with Playwright, screenshots it,
|
|
9
|
+
* diffs it pixel-for-pixel against a design comp, and returns an MCP content
|
|
10
|
+
* array: a JSON text block plus a native `type: "image"` heatmap. Every internal
|
|
11
|
+
* failure is caught and returned as a structured JSON envelope — it never throws
|
|
12
|
+
* to the MCP transport.
|
|
13
|
+
*
|
|
14
|
+
* Determinism discipline is baked in: viewport auto-matches the comp's intrinsic
|
|
15
|
+
* pixel dimensions, the browser runs at deviceScaleFactor 1, animations /
|
|
16
|
+
* transitions / caret are disabled, fonts settle, and mask selectors black out
|
|
17
|
+
* dynamic content in BOTH images before the diff. The pass gate uses a non-zero
|
|
18
|
+
* mismatch budget (never 0%, which AA/font rendering makes unreachable).
|
|
19
|
+
*/
|
|
20
|
+
import path from "path";
|
|
21
|
+
import { Worker } from "worker_threads";
|
|
22
|
+
import { computePngVisualDiff, DEFAULT_PASS_MISMATCH_PCT, PIXELMATCH_COLOR_THRESHOLD, } from "./visual-diff-worker.js";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants (bounded resource / timeout guards)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/** Navigation timeout (ms) for page.goto. */
|
|
27
|
+
export const NAV_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** Network-idle settle timeout (ms). */
|
|
29
|
+
export const NETWORK_IDLE_TIMEOUT_MS = 15_000;
|
|
30
|
+
/** document.fonts.ready settle timeout (ms). Best-effort — proceeds on timeout. */
|
|
31
|
+
export const FONTS_READY_TIMEOUT_MS = 5_000;
|
|
32
|
+
/** Screenshot capture timeout (ms). */
|
|
33
|
+
export const SCREENSHOT_TIMEOUT_MS = 20_000;
|
|
34
|
+
/** Conservative per-axis pixel guard to avoid unbounded local CPU/memory use. */
|
|
35
|
+
export const MAX_VIEWPORT_DIMENSION = 16_384;
|
|
36
|
+
/** Conservative total-pixel guard. */
|
|
37
|
+
export const MAX_VIEWPORT_PIXELS = 32_000_000;
|
|
38
|
+
/** Conservative comp byte-size guard (~25 MB). */
|
|
39
|
+
export const MAX_COMP_BYTES = 25 * 1024 * 1024;
|
|
40
|
+
/** Deterministic CSS injected before capture to stop flaky pixels. */
|
|
41
|
+
export const DETERMINISTIC_CSS = "* { animation: none !important; transition: none !important; caret-color: transparent !important; }";
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// MCP envelope helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export function textJson(value) {
|
|
46
|
+
return { type: "text", text: JSON.stringify(value, null, 2) };
|
|
47
|
+
}
|
|
48
|
+
export function errorContent(error, status, message, extra) {
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: JSON.stringify({ error, status, message, ...(extra ?? {}) }),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Image format detection + intrinsic dimensions
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
62
|
+
export function sniffImageFormat(bytes) {
|
|
63
|
+
if (bytes.length >= 8 && PNG_MAGIC.every((b, i) => bytes[i] === b)) {
|
|
64
|
+
return "png";
|
|
65
|
+
}
|
|
66
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
67
|
+
return "jpeg";
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function readPngDimensions(bytes) {
|
|
72
|
+
// 8-byte signature, then IHDR chunk: 4 length + 4 "IHDR" + 4 width + 4 height.
|
|
73
|
+
if (bytes.length < 24 || sniffImageFormat(bytes) !== "png") {
|
|
74
|
+
return { ok: false, message: "Not a valid PNG header." };
|
|
75
|
+
}
|
|
76
|
+
if (bytes[12] !== 0x49 || // 'I'
|
|
77
|
+
bytes[13] !== 0x48 || // 'H'
|
|
78
|
+
bytes[14] !== 0x44 || // 'D'
|
|
79
|
+
bytes[15] !== 0x52 // 'R'
|
|
80
|
+
) {
|
|
81
|
+
return { ok: false, message: "PNG IHDR chunk not found." };
|
|
82
|
+
}
|
|
83
|
+
const width = readUInt32BE(bytes, 16);
|
|
84
|
+
const height = readUInt32BE(bytes, 20);
|
|
85
|
+
if (width <= 0 || height <= 0) {
|
|
86
|
+
return { ok: false, message: "PNG reports non-positive dimensions." };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, width, height };
|
|
89
|
+
}
|
|
90
|
+
export function readJpegDimensions(bytes) {
|
|
91
|
+
if (sniffImageFormat(bytes) !== "jpeg") {
|
|
92
|
+
return { ok: false, message: "Not a valid JPEG header." };
|
|
93
|
+
}
|
|
94
|
+
let offset = 2; // skip SOI (0xFFD8)
|
|
95
|
+
const len = bytes.length;
|
|
96
|
+
while (offset + 1 < len) {
|
|
97
|
+
if (bytes[offset] !== 0xff) {
|
|
98
|
+
offset++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let marker = bytes[offset + 1];
|
|
102
|
+
// Skip fill bytes (0xFF padding).
|
|
103
|
+
while (marker === 0xff && offset + 1 < len) {
|
|
104
|
+
offset++;
|
|
105
|
+
marker = bytes[offset + 1];
|
|
106
|
+
}
|
|
107
|
+
offset += 2;
|
|
108
|
+
// Standalone markers (no length): RSTn, SOI, EOI, TEM.
|
|
109
|
+
if ((marker >= 0xd0 && marker <= 0xd9) ||
|
|
110
|
+
marker === 0x01) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (offset + 1 >= len)
|
|
114
|
+
break;
|
|
115
|
+
const segLen = readUInt16BE(bytes, offset);
|
|
116
|
+
// SOF markers carry intrinsic dimensions (exclude DHT/DAC/DQT/JPGn variants).
|
|
117
|
+
const isSof = marker >= 0xc0 &&
|
|
118
|
+
marker <= 0xcf &&
|
|
119
|
+
marker !== 0xc4 &&
|
|
120
|
+
marker !== 0xc8 &&
|
|
121
|
+
marker !== 0xcc;
|
|
122
|
+
if (isSof) {
|
|
123
|
+
if (offset + 5 >= len)
|
|
124
|
+
break;
|
|
125
|
+
const height = readUInt16BE(bytes, offset + 3);
|
|
126
|
+
const width = readUInt16BE(bytes, offset + 5);
|
|
127
|
+
if (width <= 0 || height <= 0) {
|
|
128
|
+
return { ok: false, message: "JPEG SOF reports non-positive dimensions." };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, width, height };
|
|
131
|
+
}
|
|
132
|
+
offset += segLen;
|
|
133
|
+
}
|
|
134
|
+
return { ok: false, message: "No supported JPEG SOF marker found." };
|
|
135
|
+
}
|
|
136
|
+
function readUInt32BE(b, o) {
|
|
137
|
+
return (b[o] * 0x1000000) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3];
|
|
138
|
+
}
|
|
139
|
+
function readUInt16BE(b, o) {
|
|
140
|
+
return (b[o] << 8) + b[o + 1];
|
|
141
|
+
}
|
|
142
|
+
function isPositiveInt(n) {
|
|
143
|
+
return Number.isInteger(n) && n > 0;
|
|
144
|
+
}
|
|
145
|
+
export function resolveViewport(input, compDimensions) {
|
|
146
|
+
const vp = input.viewport ?? compDimensions;
|
|
147
|
+
if (!isPositiveInt(vp.width) || !isPositiveInt(vp.height)) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: "INVALID_VIEWPORT",
|
|
151
|
+
status: 400,
|
|
152
|
+
message: `Viewport must be positive integers, got ${vp.width}x${vp.height}.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (vp.width > MAX_VIEWPORT_DIMENSION ||
|
|
156
|
+
vp.height > MAX_VIEWPORT_DIMENSION ||
|
|
157
|
+
vp.width * vp.height > MAX_VIEWPORT_PIXELS) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: "IMAGE_TOO_LARGE",
|
|
161
|
+
status: 413,
|
|
162
|
+
message: `Requested render area ${vp.width}x${vp.height} exceeds the local pixel guard.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, viewport: { width: vp.width, height: vp.height } };
|
|
166
|
+
}
|
|
167
|
+
function toUint8(bytes) {
|
|
168
|
+
return bytes instanceof Uint8Array ? bytes : Buffer.from(bytes);
|
|
169
|
+
}
|
|
170
|
+
export async function resolveCompRef(compRef, deps) {
|
|
171
|
+
// 1. Local file resolution first (absolute as-is; relative against project
|
|
172
|
+
// root, then cwd as a secondary candidate).
|
|
173
|
+
const candidates = [];
|
|
174
|
+
if (path.isAbsolute(compRef)) {
|
|
175
|
+
candidates.push(compRef);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const root = await deps.getProjectRoot();
|
|
179
|
+
candidates.push(path.resolve(root, compRef));
|
|
180
|
+
const cwdCandidate = path.resolve(process.cwd(), compRef);
|
|
181
|
+
if (!candidates.includes(cwdCandidate))
|
|
182
|
+
candidates.push(cwdCandidate);
|
|
183
|
+
}
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
try {
|
|
186
|
+
const st = await deps.stat(candidate);
|
|
187
|
+
if (st && st.isFile()) {
|
|
188
|
+
const bytes = toUint8(await deps.readFile(candidate));
|
|
189
|
+
return { ok: true, bytes, source: "local", sourcePath: candidate, warnings: [] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Not present / not accessible — try the next candidate.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 2. Attachment resolution. Numeric-looking refs are attachment ids; anything
|
|
197
|
+
// else is treated as a filename.
|
|
198
|
+
const trimmed = compRef.trim();
|
|
199
|
+
const lookup = /^\d+$/.test(trimmed)
|
|
200
|
+
? { kind: "attachment_id", attachment_id: trimmed }
|
|
201
|
+
: { kind: "filename", filename: compRef };
|
|
202
|
+
const fetched = await deps.fetchAttachmentBytes(lookup);
|
|
203
|
+
if (!fetched.ok) {
|
|
204
|
+
return { ok: false, error: fetched.error, status: fetched.status, message: fetched.message };
|
|
205
|
+
}
|
|
206
|
+
const bytes = toUint8(fetched.bytes);
|
|
207
|
+
// Persist a local copy under visual-diffs (warn-only: never fail the diff once
|
|
208
|
+
// the bytes are already in hand).
|
|
209
|
+
const warnings = [];
|
|
210
|
+
try {
|
|
211
|
+
const dir = await deps.getDocsPath("visual-diffs");
|
|
212
|
+
const rawName = fetched.filename ||
|
|
213
|
+
(lookup.kind === "attachment_id" ? `attachment-${lookup.attachment_id}` : lookup.filename);
|
|
214
|
+
const base = path.basename(rawName); // strips any path traversal in the name
|
|
215
|
+
const target = path.resolve(dir, `comp-${deps.safeTimestampForFilename()}-${base}`);
|
|
216
|
+
if (!target.startsWith(path.resolve(dir) + path.sep)) {
|
|
217
|
+
warnings.push("Skipped saving attachment comp copy: resolved path escaped the visual-diffs directory.");
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
await deps.mkdir(dir, { recursive: true });
|
|
221
|
+
await deps.writeFile(target, Buffer.from(bytes));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
warnings.push(`Attachment comp copy could not be saved locally: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
226
|
+
}
|
|
227
|
+
return { ok: true, bytes, source: "attachment", warnings };
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Browser loading + deterministic capture
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
export async function loadPlaywright() {
|
|
233
|
+
try {
|
|
234
|
+
// A non-literal specifier keeps tsc from requiring the optional package at
|
|
235
|
+
// compile time and keeps esbuild from inlining it — mirroring the
|
|
236
|
+
// better-sqlite3 optional-native gating precedent.
|
|
237
|
+
const specifier = "playwright";
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
239
|
+
const mod = await import(specifier);
|
|
240
|
+
const chromium = mod?.chromium ?? mod?.default?.chromium;
|
|
241
|
+
if (!chromium || typeof chromium.launch !== "function") {
|
|
242
|
+
return { ok: false };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, playwright: { chromium } };
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return { ok: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export async function launchBrowser(playwright) {
|
|
251
|
+
try {
|
|
252
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
253
|
+
return { ok: true, browser };
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
// Missing browser binary and launch failures both degrade to BROWSER_UNAVAILABLE.
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
error: "BROWSER_UNAVAILABLE",
|
|
260
|
+
status: 503,
|
|
261
|
+
message: `Chromium could not be launched: ${err instanceof Error ? err.message : "unknown error"}. Run "npx playwright install chromium".`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Clip, integer-round, and deterministically sort raw mask boxes. */
|
|
266
|
+
export function normalizeMaskBoxes(raw, viewport) {
|
|
267
|
+
const boxes = [];
|
|
268
|
+
for (const r of raw) {
|
|
269
|
+
const x0 = Math.max(0, Math.floor(r.x));
|
|
270
|
+
const y0 = Math.max(0, Math.floor(r.y));
|
|
271
|
+
const x1 = Math.min(viewport.width, Math.ceil(r.x + r.width));
|
|
272
|
+
const y1 = Math.min(viewport.height, Math.ceil(r.y + r.height));
|
|
273
|
+
const width = x1 - x0;
|
|
274
|
+
const height = y1 - y0;
|
|
275
|
+
if (width > 0 && height > 0) {
|
|
276
|
+
boxes.push({ x: x0, y: y0, width, height });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
boxes.sort((a, b) => {
|
|
280
|
+
if (a.y !== b.y)
|
|
281
|
+
return a.y - b.y;
|
|
282
|
+
if (a.x !== b.x)
|
|
283
|
+
return a.x - b.x;
|
|
284
|
+
if (a.width !== b.width)
|
|
285
|
+
return a.width - b.width;
|
|
286
|
+
return a.height - b.height;
|
|
287
|
+
});
|
|
288
|
+
return boxes;
|
|
289
|
+
}
|
|
290
|
+
export async function collectMaskBoxes(page, selectors, viewport) {
|
|
291
|
+
if (!selectors || selectors.length === 0)
|
|
292
|
+
return [];
|
|
293
|
+
const raw = (await page.evaluate((sels) => {
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const sel of sels) {
|
|
296
|
+
// eslint-disable-next-line no-undef
|
|
297
|
+
document.querySelectorAll(sel).forEach((el) => {
|
|
298
|
+
const rect = el.getBoundingClientRect();
|
|
299
|
+
out.push({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}, selectors));
|
|
304
|
+
return normalizeMaskBoxes(Array.isArray(raw) ? raw : [], viewport);
|
|
305
|
+
}
|
|
306
|
+
export async function captureRenderPng(browser, targetUrl, viewport, maskSelectors) {
|
|
307
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
308
|
+
let page;
|
|
309
|
+
try {
|
|
310
|
+
page = await context.newPage();
|
|
311
|
+
try {
|
|
312
|
+
await page.goto(targetUrl, { timeout: NAV_TIMEOUT_MS, waitUntil: "load" });
|
|
313
|
+
await page.waitForLoadState("networkidle", { timeout: NETWORK_IDLE_TIMEOUT_MS });
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: isTimeoutError(err) ? "PAGE_TIMEOUT" : "PAGE_LOAD_FAILED",
|
|
319
|
+
status: isTimeoutError(err) ? 504 : 502,
|
|
320
|
+
message: `Failed to load ${targetUrl}: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Deterministic settling: kill animations/transitions/caret, then wait fonts.
|
|
324
|
+
await page.addStyleTag({ content: DETERMINISTIC_CSS });
|
|
325
|
+
await settleFonts(page);
|
|
326
|
+
const maskBoxes = await collectMaskBoxes(page, maskSelectors, viewport);
|
|
327
|
+
let png;
|
|
328
|
+
try {
|
|
329
|
+
png = await page.screenshot({
|
|
330
|
+
clip: { x: 0, y: 0, width: viewport.width, height: viewport.height },
|
|
331
|
+
timeout: SCREENSHOT_TIMEOUT_MS,
|
|
332
|
+
animations: "disabled",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
error: isTimeoutError(err) ? "PAGE_TIMEOUT" : "PAGE_LOAD_FAILED",
|
|
339
|
+
status: isTimeoutError(err) ? 504 : 502,
|
|
340
|
+
message: `Screenshot capture failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return { ok: true, png: toUint8(png), maskBoxes, dimensions: viewport };
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
try {
|
|
347
|
+
if (page)
|
|
348
|
+
await page.close();
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* ignore */
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
await context.close();
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
/* ignore */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function settleFonts(page) {
|
|
362
|
+
try {
|
|
363
|
+
await Promise.race([
|
|
364
|
+
page.evaluate(() => {
|
|
365
|
+
// eslint-disable-next-line no-undef
|
|
366
|
+
const d = document;
|
|
367
|
+
return d.fonts && d.fonts.ready ? d.fonts.ready.then(() => true) : true;
|
|
368
|
+
}),
|
|
369
|
+
new Promise((resolve) => setTimeout(resolve, FONTS_READY_TIMEOUT_MS)),
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Font readiness is best-effort; never fail capture on it.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function isTimeoutError(err) {
|
|
377
|
+
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
378
|
+
return /timeout|timed out|TimeoutError/i.test(msg);
|
|
379
|
+
}
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// JPEG -> PNG normalization (through the already-launched browser)
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
export async function normalizeCompToPng(browser, compBytes, format, dims) {
|
|
384
|
+
if (format === "png") {
|
|
385
|
+
return { ok: true, png: Buffer.from(compBytes) };
|
|
386
|
+
}
|
|
387
|
+
// JPEG: decode + re-encode as PNG in the browser at intrinsic dimensions so
|
|
388
|
+
// both diff inputs are PNG without a native image dependency.
|
|
389
|
+
const context = await browser.newContext({ viewport: dims, deviceScaleFactor: 1 });
|
|
390
|
+
let page;
|
|
391
|
+
try {
|
|
392
|
+
page = await context.newPage();
|
|
393
|
+
if (page.setContent) {
|
|
394
|
+
await page.setContent("<!doctype html><html><body></body></html>");
|
|
395
|
+
}
|
|
396
|
+
const dataUrl = `data:image/jpeg;base64,${Buffer.from(compBytes).toString("base64")}`;
|
|
397
|
+
const pngDataUrl = (await page.evaluate(async (arg) => {
|
|
398
|
+
// eslint-disable-next-line no-undef
|
|
399
|
+
const img = new Image();
|
|
400
|
+
await new Promise((resolve, reject) => {
|
|
401
|
+
img.onload = () => resolve();
|
|
402
|
+
img.onerror = () => reject(new Error("image load failed"));
|
|
403
|
+
img.src = arg.url;
|
|
404
|
+
});
|
|
405
|
+
// eslint-disable-next-line no-undef
|
|
406
|
+
const canvas = document.createElement("canvas");
|
|
407
|
+
canvas.width = arg.w;
|
|
408
|
+
canvas.height = arg.h;
|
|
409
|
+
const ctx = canvas.getContext("2d");
|
|
410
|
+
if (!ctx)
|
|
411
|
+
throw new Error("no 2d context");
|
|
412
|
+
ctx.drawImage(img, 0, 0, arg.w, arg.h);
|
|
413
|
+
return canvas.toDataURL("image/png");
|
|
414
|
+
}, { url: dataUrl, w: dims.width, h: dims.height }));
|
|
415
|
+
const base64 = pngDataUrl.split(",")[1] ?? "";
|
|
416
|
+
if (!base64) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
error: "IMAGE_DECODE_FAILED",
|
|
420
|
+
status: 422,
|
|
421
|
+
message: "Browser-side JPEG normalization produced no PNG data.",
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, png: Buffer.from(base64, "base64") };
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
error: "UNSUPPORTED_COMP_IMAGE",
|
|
430
|
+
status: 400,
|
|
431
|
+
message: `Failed to normalize JPEG comp to PNG: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
try {
|
|
436
|
+
if (page)
|
|
437
|
+
await page.close();
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* ignore */
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
await context.close();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
/* ignore */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
export async function computeVisualDiffInWorker(args, deps) {
|
|
451
|
+
try {
|
|
452
|
+
return await runInWorker(args);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// Worker spawning can fail in bundled/edge runtimes; the deterministic
|
|
456
|
+
// engine is pure, so fall back to computing inline rather than failing.
|
|
457
|
+
deps?.logger?.(`visual_diff: worker unavailable, computing inline (${err instanceof Error ? err.message : "unknown error"}).`);
|
|
458
|
+
try {
|
|
459
|
+
return computePngVisualDiff(args);
|
|
460
|
+
}
|
|
461
|
+
catch (inlineErr) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
error: "DIFF_FAILED",
|
|
465
|
+
status: 500,
|
|
466
|
+
message: `Diff computation failed: ${inlineErr instanceof Error ? inlineErr.message : "unknown error"}`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function runInWorker(args) {
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
// Variable specifier so esbuild does not treat the worker as a bundled
|
|
474
|
+
// asset; it resolves to the tsc-emitted build/visual-diff-worker.js sibling.
|
|
475
|
+
const workerRelative = "./visual-diff-worker.js";
|
|
476
|
+
const workerUrl = new URL(workerRelative, import.meta.url);
|
|
477
|
+
let settled = false;
|
|
478
|
+
let worker;
|
|
479
|
+
try {
|
|
480
|
+
worker = new Worker(workerUrl, { workerData: args });
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
reject(err);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
worker.once("message", (msg) => {
|
|
487
|
+
settled = true;
|
|
488
|
+
resolve(msg);
|
|
489
|
+
void worker.terminate();
|
|
490
|
+
});
|
|
491
|
+
worker.once("error", (err) => {
|
|
492
|
+
if (!settled)
|
|
493
|
+
reject(err);
|
|
494
|
+
});
|
|
495
|
+
worker.once("exit", (code) => {
|
|
496
|
+
if (!settled && code !== 0) {
|
|
497
|
+
reject(new Error(`diff worker exited with code ${code}`));
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// Heatmap persistence (warn-only)
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
export async function saveHeatmap(heatmapBase64, deps) {
|
|
506
|
+
try {
|
|
507
|
+
const dir = await deps.getDocsPath("visual-diffs");
|
|
508
|
+
const target = path.resolve(dir, `visual-diff-${deps.safeTimestampForFilename()}.png`);
|
|
509
|
+
if (!target.startsWith(path.resolve(dir) + path.sep)) {
|
|
510
|
+
return { ok: false, warning: "Heatmap not saved: resolved path escaped the visual-diffs directory." };
|
|
511
|
+
}
|
|
512
|
+
await deps.mkdir(dir, { recursive: true });
|
|
513
|
+
await deps.writeFile(target, Buffer.from(heatmapBase64, "base64"));
|
|
514
|
+
return { ok: true, path: target };
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
warning: `Heatmap could not be saved locally: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// High-level orchestration
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
export async function runVisualDiff(input, deps) {
|
|
527
|
+
try {
|
|
528
|
+
const warnings = [];
|
|
529
|
+
// 1. Resolve comp bytes (local path first, then Jira attachment).
|
|
530
|
+
const resolved = await resolveCompRef(input.comp_ref, deps);
|
|
531
|
+
if (!resolved.ok) {
|
|
532
|
+
return errorContent(resolved.error, resolved.status, resolved.message);
|
|
533
|
+
}
|
|
534
|
+
warnings.push(...resolved.warnings);
|
|
535
|
+
const compBytes = resolved.bytes;
|
|
536
|
+
if (compBytes.length > MAX_COMP_BYTES) {
|
|
537
|
+
return errorContent("IMAGE_TOO_LARGE", 413, `Comp image is ${compBytes.length} bytes, exceeding the ${MAX_COMP_BYTES}-byte guard.`);
|
|
538
|
+
}
|
|
539
|
+
// 2. Detect format + intrinsic comp dimensions.
|
|
540
|
+
const format = sniffImageFormat(compBytes);
|
|
541
|
+
if (!format) {
|
|
542
|
+
return errorContent("UNSUPPORTED_COMP_IMAGE", 400, "comp_ref resolved but is not a decodable PNG or JPEG image.");
|
|
543
|
+
}
|
|
544
|
+
const dims = format === "png" ? readPngDimensions(compBytes) : readJpegDimensions(compBytes);
|
|
545
|
+
if (!dims.ok) {
|
|
546
|
+
return errorContent("UNSUPPORTED_COMP_IMAGE", 400, dims.message);
|
|
547
|
+
}
|
|
548
|
+
const compDimensions = { width: dims.width, height: dims.height };
|
|
549
|
+
// 3. Resolve viewport (auto-match comp unless caller overrode it).
|
|
550
|
+
const vp = resolveViewport(input, compDimensions);
|
|
551
|
+
if (!vp.ok) {
|
|
552
|
+
return errorContent(vp.error, vp.status, vp.message);
|
|
553
|
+
}
|
|
554
|
+
// 4. Load Playwright (degrade cleanly when the optional runtime is absent).
|
|
555
|
+
const loader = deps.loadPlaywright ?? loadPlaywright;
|
|
556
|
+
const loaded = await loader();
|
|
557
|
+
if (!loaded.ok) {
|
|
558
|
+
return errorContent("BROWSER_UNAVAILABLE", 503, 'The optional Playwright browser runtime is not available. Install it with "npm i playwright && npx playwright install chromium".');
|
|
559
|
+
}
|
|
560
|
+
// 5. Launch browser.
|
|
561
|
+
const launch = await launchBrowser(loaded.playwright);
|
|
562
|
+
if (!launch.ok) {
|
|
563
|
+
return errorContent(launch.error, launch.status, launch.message);
|
|
564
|
+
}
|
|
565
|
+
const browser = launch.browser;
|
|
566
|
+
let capture;
|
|
567
|
+
let normalized;
|
|
568
|
+
try {
|
|
569
|
+
capture = await captureRenderPng(browser, input.target_url, vp.viewport, input.mask_selectors);
|
|
570
|
+
if (!capture.ok) {
|
|
571
|
+
return errorContent(capture.error, capture.status, capture.message);
|
|
572
|
+
}
|
|
573
|
+
normalized = await normalizeCompToPng(browser, compBytes, format, compDimensions);
|
|
574
|
+
if (!normalized.ok) {
|
|
575
|
+
return errorContent(normalized.error, normalized.status, normalized.message);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
try {
|
|
580
|
+
await browser.close();
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* ignore */
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// 6. Diff (worker thread, inline fallback).
|
|
587
|
+
const passMismatchPct = typeof input.threshold === "number" ? input.threshold : DEFAULT_PASS_MISMATCH_PCT;
|
|
588
|
+
const diff = await computeVisualDiffInWorker({
|
|
589
|
+
compPngBuffer: normalized.png,
|
|
590
|
+
renderPngBuffer: capture.png,
|
|
591
|
+
maskBoxes: capture.maskBoxes,
|
|
592
|
+
passMismatchPct,
|
|
593
|
+
pixelmatchColorThreshold: PIXELMATCH_COLOR_THRESHOLD,
|
|
594
|
+
}, deps);
|
|
595
|
+
if (!diff.ok) {
|
|
596
|
+
return errorContent(diff.error, diff.status, diff.message);
|
|
597
|
+
}
|
|
598
|
+
// 7. Persist heatmap (warn-only).
|
|
599
|
+
const saved = await saveHeatmap(diff.heatmap_base64, deps);
|
|
600
|
+
const heatmapPath = saved.ok ? saved.path : null;
|
|
601
|
+
if (!saved.ok)
|
|
602
|
+
warnings.push(saved.warning);
|
|
603
|
+
// 8. Assemble the MCP content: JSON text block first, image second.
|
|
604
|
+
const result = {
|
|
605
|
+
mismatch_pct: diff.mismatch_pct,
|
|
606
|
+
dimension_match: diff.dimension_match,
|
|
607
|
+
diff_regions: diff.diff_regions,
|
|
608
|
+
heatmap_path: heatmapPath,
|
|
609
|
+
comp_dimensions: diff.comp_dimensions,
|
|
610
|
+
render_dimensions: diff.render_dimensions,
|
|
611
|
+
threshold_used: passMismatchPct,
|
|
612
|
+
passed: diff.passed,
|
|
613
|
+
};
|
|
614
|
+
if (!diff.dimension_match) {
|
|
615
|
+
result.message =
|
|
616
|
+
`Render dimensions ${diff.render_dimensions.width}x${diff.render_dimensions.height} differ from ` +
|
|
617
|
+
`comp dimensions ${diff.comp_dimensions.width}x${diff.comp_dimensions.height}. Images were NOT rescaled, ` +
|
|
618
|
+
"so this result is automatically not passed; the diff covers the union region.";
|
|
619
|
+
}
|
|
620
|
+
if (warnings.length > 0) {
|
|
621
|
+
result.warnings = warnings;
|
|
622
|
+
}
|
|
623
|
+
const content = [
|
|
624
|
+
textJson(result),
|
|
625
|
+
{ type: "image", data: diff.heatmap_base64, mimeType: "image/png" },
|
|
626
|
+
];
|
|
627
|
+
return { content };
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
return errorContent("VISUAL_DIFF_FAILED", 500, `visual_diff failed: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
631
|
+
}
|
|
632
|
+
}
|