argusqa-os 9.5.1 → 9.5.5
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/README.md +1100 -1089
- package/glama.json +36 -32
- package/package.json +1 -1
- package/src/adapters/browser.js +5 -4
- package/src/adapters/figma.js +336 -0
- package/src/config/targets.js +4 -0
- package/src/domain/finding.js +16 -1
- package/src/mcp-server.js +54 -3
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/orchestrator.js +36 -24
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +2 -1
- package/src/registry.js +1 -1
- package/src/utils/css-analyzer.js +7 -0
- package/src/utils/design-fidelity-analyzer.js +685 -0
- package/src/utils/flow-runner.js +2 -0
- package/src/utils/mcp-client.js +2 -17
- package/src/utils/retry.js +1 -1
- package/src/utils/session-persistence.js +16 -4
- package/src/utils/theme-analyzer.js +173 -0
- package/src/utils/visual-diff-analyzer.js +207 -0
- package/src/utils/web-vitals-analyzer.js +284 -0
package/src/utils/mcp-client.js
CHANGED
|
@@ -17,15 +17,8 @@ import { childLogger } from './logger.js';
|
|
|
17
17
|
|
|
18
18
|
const logger = childLogger('mcp-client');
|
|
19
19
|
|
|
20
|
-
// Validate MCP_BROWSER_URL
|
|
21
|
-
//
|
|
22
|
-
// 1. new URL() rejects malformed/non-http(s) values.
|
|
23
|
-
// 2. Shell-metacharacter check rejects valid URLs whose query strings contain
|
|
24
|
-
// &, |, ;, backtick, $() etc. — new URL().toString() preserves & in query
|
|
25
|
-
// strings (valid URL syntax), but & is a shell background-operator that
|
|
26
|
-
// would split the spawn command on both bash and cmd.exe.
|
|
27
|
-
// A legitimate Chrome remote-debug URL is always http(s)://host:port with
|
|
28
|
-
// no path or query string, so this check never fires in practice.
|
|
20
|
+
// Validate MCP_BROWSER_URL — new URL() rejects malformed/non-http(s) values.
|
|
21
|
+
// A legitimate Chrome remote-debug URL is always http(s)://host:port.
|
|
29
22
|
const _rawBrowserUrl = process.env.MCP_BROWSER_URL ?? 'http://127.0.0.1:9222';
|
|
30
23
|
let BROWSER_URL;
|
|
31
24
|
try {
|
|
@@ -37,14 +30,6 @@ try {
|
|
|
37
30
|
} catch (e) {
|
|
38
31
|
throw new Error(`[ARGUS] Invalid MCP_BROWSER_URL "${_rawBrowserUrl}": ${e.message}`);
|
|
39
32
|
}
|
|
40
|
-
// Shell-metacharacter guard — must run AFTER URL re-serialization.
|
|
41
|
-
const _SHELL_META = /[&|;<>`${}()\n\r!"]/;
|
|
42
|
-
if (_SHELL_META.test(BROWSER_URL)) {
|
|
43
|
-
throw new Error(
|
|
44
|
-
`[ARGUS] MCP_BROWSER_URL contains shell-unsafe characters — ` +
|
|
45
|
-
`use a plain http(s)://host:port URL (got: "${BROWSER_URL}")`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
33
|
|
|
49
34
|
/**
|
|
50
35
|
* Unwrap an evaluate_script result to its plain value.
|
package/src/utils/retry.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function withRetry(fn, { attempts, delayMs = 400, label = '' } = {}
|
|
|
29
29
|
} catch (err) {
|
|
30
30
|
if (i === maxAttempts - 1) throw err;
|
|
31
31
|
const wait = delayMs * Math.pow(2, i);
|
|
32
|
-
logger.debug(`[ARGUS] ${label ? label + ': ' : ''}retry ${i + 1}/${maxAttempts - 1} after ${wait}ms — ${err.message}`);
|
|
32
|
+
logger.debug(`[ARGUS] ${label ? label + ': ' : ''}retry ${i + 1}/${maxAttempts - 1} after ${wait}ms — ${err.constructor?.name ?? 'Error'}: ${err.message}`);
|
|
33
33
|
await new Promise(r => setTimeout(r, wait));
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -104,11 +104,19 @@ export async function saveSession(browser, sessionFile) {
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const dir = path.dirname(sessionFile);
|
|
107
|
-
|
|
107
|
+
try {
|
|
108
|
+
if (dir) fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`[ARGUS] saveSession: failed to create directory "${dir}": ${err.message}`);
|
|
111
|
+
}
|
|
108
112
|
|
|
109
113
|
const tmpFile = `${sessionFile}.tmp`;
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
|
|
116
|
+
fs.renameSync(tmpFile, sessionFile);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(`[ARGUS] saveSession: failed to write session file "${sessionFile}": ${err.message}`);
|
|
119
|
+
}
|
|
112
120
|
|
|
113
121
|
const lsCount = Object.keys(state.localStorage).length;
|
|
114
122
|
const ssCount = Object.keys(state.sessionStorage).length;
|
|
@@ -162,7 +170,11 @@ export async function restoreSession(browser, baseUrl, sessionFile) {
|
|
|
162
170
|
} catch { /* URL parse failure — proceed and let Chrome handle it */ }
|
|
163
171
|
}
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
const NAV_TIMEOUT_MS = 10000;
|
|
174
|
+
await Promise.race([
|
|
175
|
+
browser.navigate(baseUrl),
|
|
176
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`restoreSession: navigate to "${baseUrl}" timed out after ${NAV_TIMEOUT_MS}ms`)), NAV_TIMEOUT_MS)),
|
|
177
|
+
]);
|
|
166
178
|
await new Promise(r => setTimeout(r, 400));
|
|
167
179
|
|
|
168
180
|
const restoreScript = buildRestoreScript(state);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Theme Analyzer (Sprint 1 — A7: Theme & Dark Mode)
|
|
3
|
+
*
|
|
4
|
+
* Detects dark mode support gaps and theme consistency issues by:
|
|
5
|
+
* 1. Scanning all stylesheets for @media (prefers-color-scheme: dark) rules
|
|
6
|
+
* 2. Collecting :root CSS custom properties in light mode
|
|
7
|
+
* 3. Emulating dark mode via CDP, re-collecting custom properties
|
|
8
|
+
* 4. Flagging properties whose value does not change between modes
|
|
9
|
+
*
|
|
10
|
+
* Detections:
|
|
11
|
+
* theme_no_dark_mode — info — no @media (prefers-color-scheme: dark) rule anywhere
|
|
12
|
+
* theme_static_var — warning — CSS custom property identical in light + dark mode
|
|
13
|
+
* theme_summary — info — summary: dark mode supported/not, var count, screenshot taken
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { registerExpensive } from '../registry.js';
|
|
17
|
+
import { unwrapEval } from './mcp-client.js';
|
|
18
|
+
import { childLogger } from './logger.js';
|
|
19
|
+
|
|
20
|
+
const logger = childLogger('theme-analyzer');
|
|
21
|
+
|
|
22
|
+
// ── Page script ────────────────────────────────────────────────────────────────
|
|
23
|
+
// Injected via evaluate_script. Scans stylesheets and :root custom properties.
|
|
24
|
+
// Returns JSON: { hasDarkModeQuery, rootVars }
|
|
25
|
+
const THEME_SCAN_SCRIPT = `() => {
|
|
26
|
+
var result = { hasDarkModeQuery: false, rootVars: {} };
|
|
27
|
+
|
|
28
|
+
// Scan all stylesheets for @media (prefers-color-scheme: dark) rules
|
|
29
|
+
var sheets = Array.from(document.styleSheets);
|
|
30
|
+
for (var s = 0; s < sheets.length; s++) {
|
|
31
|
+
try {
|
|
32
|
+
var rules = Array.from(sheets[s].cssRules || []);
|
|
33
|
+
for (var r = 0; r < rules.length; r++) {
|
|
34
|
+
var rule = rules[r];
|
|
35
|
+
if (rule.type === 4 /* MEDIA_RULE */) {
|
|
36
|
+
var cond = rule.conditionText || (rule.media && rule.media.mediaText) || '';
|
|
37
|
+
if (cond.indexOf('prefers-color-scheme') !== -1 && cond.indexOf('dark') !== -1) {
|
|
38
|
+
result.hasDarkModeQuery = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (e) { /* cross-origin stylesheet — skip */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Collect all CSS custom properties declared on :root
|
|
46
|
+
var rootStyle = getComputedStyle(document.documentElement);
|
|
47
|
+
for (var i = 0; i < rootStyle.length; i++) {
|
|
48
|
+
var prop = rootStyle.item(i);
|
|
49
|
+
if (prop.charAt(0) === '-' && prop.charAt(1) === '-') {
|
|
50
|
+
result.rootVars[prop] = rootStyle.getPropertyValue(prop).trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return JSON.stringify(result);
|
|
55
|
+
}`;
|
|
56
|
+
|
|
57
|
+
// Names suggesting a color/theme token — only these are flagged as static vars
|
|
58
|
+
const COLOR_VAR_RE = /color|bg|background|text|foreground|surface|fill|stroke|border|shadow|ring|accent|primary|secondary|muted|card|popover|input|destructive/i;
|
|
59
|
+
|
|
60
|
+
// ── JSON parse helper ──────────────────────────────────────────────────────────
|
|
61
|
+
function parseJson(raw) {
|
|
62
|
+
try {
|
|
63
|
+
const str = unwrapEval(raw);
|
|
64
|
+
if (typeof str === 'object' && str !== null) return str;
|
|
65
|
+
return JSON.parse(str);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Analyse theme and dark mode support for a single page.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
77
|
+
* @param {string} url - Fully-qualified URL to analyse
|
|
78
|
+
* @returns {Promise<object[]>} Array of theme finding objects
|
|
79
|
+
*/
|
|
80
|
+
export async function analyzeTheme(browser, url) {
|
|
81
|
+
const findings = [];
|
|
82
|
+
|
|
83
|
+
// Navigate and settle
|
|
84
|
+
try {
|
|
85
|
+
await browser.navigate(url);
|
|
86
|
+
await browser.waitFor({ state: 'networkidle' }).catch(() => {});
|
|
87
|
+
await new Promise(r => setTimeout(r, 400));
|
|
88
|
+
} catch {
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Light mode scan ──────────────────────────────────────────────────────────
|
|
93
|
+
let lightData;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await browser.evaluate(THEME_SCAN_SCRIPT);
|
|
96
|
+
lightData = parseJson(raw);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.warn(`[ARGUS] theme-analyzer: light scan failed for ${url}: ${err.message}`);
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
if (!lightData) return findings;
|
|
102
|
+
|
|
103
|
+
const lightVars = lightData.rootVars ?? {};
|
|
104
|
+
const varCount = Object.keys(lightVars).length;
|
|
105
|
+
|
|
106
|
+
// ── Detection 1: no dark mode media query ────────────────────────────────────
|
|
107
|
+
if (!lightData.hasDarkModeQuery) {
|
|
108
|
+
findings.push({
|
|
109
|
+
type: 'theme_no_dark_mode',
|
|
110
|
+
message: 'No @media (prefers-color-scheme: dark) rule detected — page has no dark mode support',
|
|
111
|
+
severity: 'info',
|
|
112
|
+
url,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Dark mode emulation + comparison ────────────────────────────────────────
|
|
117
|
+
let darkData = null;
|
|
118
|
+
try {
|
|
119
|
+
await browser.emulateColorScheme('dark');
|
|
120
|
+
await new Promise(r => setTimeout(r, 300));
|
|
121
|
+
const raw = await browser.evaluate(THEME_SCAN_SCRIPT);
|
|
122
|
+
darkData = parseJson(raw);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.debug(`[ARGUS] theme-analyzer: dark mode emulation skipped for ${url}: ${err.message}`);
|
|
125
|
+
} finally {
|
|
126
|
+
try { await browser.emulateColorScheme('light'); } catch { /* restore best-effort */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Detection 2: CSS custom properties that don't adapt to dark mode ─────────
|
|
130
|
+
if (darkData && lightData.hasDarkModeQuery) {
|
|
131
|
+
const darkVars = darkData.rootVars ?? {};
|
|
132
|
+
const staticVars = [];
|
|
133
|
+
|
|
134
|
+
for (const [name, lightVal] of Object.entries(lightVars)) {
|
|
135
|
+
const darkVal = darkVars[name];
|
|
136
|
+
if (darkVal !== undefined && darkVal === lightVal && COLOR_VAR_RE.test(name)) {
|
|
137
|
+
staticVars.push(name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (staticVars.length > 0) {
|
|
142
|
+
const preview = staticVars.slice(0, 3).join(', ');
|
|
143
|
+
const extra = staticVars.length > 3 ? ` (+${staticVars.length - 3} more)` : '';
|
|
144
|
+
findings.push({
|
|
145
|
+
type: 'theme_static_var',
|
|
146
|
+
vars: staticVars.slice(0, 10),
|
|
147
|
+
count: staticVars.length,
|
|
148
|
+
message: `${staticVars.length} color custom propert${staticVars.length === 1 ? 'y does' : 'ies do'} not change between light and dark mode: ${preview}${extra}`,
|
|
149
|
+
severity: 'warning',
|
|
150
|
+
url,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Summary finding ──────────────────────────────────────────────────────────
|
|
156
|
+
findings.push({
|
|
157
|
+
type: 'theme_summary',
|
|
158
|
+
hasDarkMode: lightData.hasDarkModeQuery,
|
|
159
|
+
rootVarCount: varCount,
|
|
160
|
+
darkEmulated: darkData !== null,
|
|
161
|
+
message: `Theme: ${lightData.hasDarkModeQuery ? 'dark mode supported' : 'no dark mode'}, ${varCount} CSS custom propert${varCount === 1 ? 'y' : 'ies'} on :root`,
|
|
162
|
+
severity: 'info',
|
|
163
|
+
url,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return findings;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Self-registration ─────────────────────────────────────────────────────────
|
|
170
|
+
registerExpensive({
|
|
171
|
+
name: 'theme',
|
|
172
|
+
analyze: (browser, url) => analyzeTheme(browser, url),
|
|
173
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Visual Regression Analyzer (Sprint 3 — A8)
|
|
3
|
+
*
|
|
4
|
+
* Per-route visual regression detection via screenshot baseline comparison.
|
|
5
|
+
* Takes a PNG screenshot, compares it pixel-by-pixel against a stored baseline,
|
|
6
|
+
* and emits a finding when the diff exceeds the configured threshold.
|
|
7
|
+
*
|
|
8
|
+
* Works in headless Chrome — uses the Performance API screenshot path, not Lighthouse.
|
|
9
|
+
*
|
|
10
|
+
* Findings emitted:
|
|
11
|
+
* visual_baseline_created — info, first run for a URL (baseline saved, no prior exists)
|
|
12
|
+
* visual_regression — warning ≥0.1%, critical ≥5% pixels changed
|
|
13
|
+
* visual_diff_summary — info, always emitted with full diff metrics
|
|
14
|
+
*
|
|
15
|
+
* Baseline storage: {config.outputDir}/baselines/screenshots/{slug}.png
|
|
16
|
+
* Override via opts.baselineDir for testing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import os from 'os';
|
|
22
|
+
import { PNG } from 'pngjs';
|
|
23
|
+
import pixelmatch from 'pixelmatch';
|
|
24
|
+
import { registerExpensive } from '../registry.js';
|
|
25
|
+
import { childLogger } from './logger.js';
|
|
26
|
+
import { slugify } from './slug.js';
|
|
27
|
+
import { config, thresholds } from '../config/targets.js';
|
|
28
|
+
|
|
29
|
+
const logger = childLogger('visual-diff');
|
|
30
|
+
|
|
31
|
+
// ── Thresholds ─────────────────────────────────────────────────────────────────
|
|
32
|
+
const WARN_PERCENT = thresholds.visual?.warnPercent ?? 0.1; // %
|
|
33
|
+
const CRIT_PERCENT = thresholds.visual?.critPercent ?? 5.0; // %
|
|
34
|
+
|
|
35
|
+
// ── PNG helpers ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function cropPng(img, width, height) {
|
|
38
|
+
if (img.width === width && img.height === height) return img;
|
|
39
|
+
const out = new PNG({ width, height });
|
|
40
|
+
for (let y = 0; y < height; y++) {
|
|
41
|
+
for (let x = 0; x < width; x++) {
|
|
42
|
+
const src = (y * img.width + x) * 4;
|
|
43
|
+
const dst = (y * width + x) * 4;
|
|
44
|
+
out.data[dst] = img.data[src];
|
|
45
|
+
out.data[dst + 1] = img.data[src + 1];
|
|
46
|
+
out.data[dst + 2] = img.data[src + 2];
|
|
47
|
+
out.data[dst + 3] = img.data[src + 3];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two PNG Buffers pixel-by-pixel using pixelmatch.
|
|
55
|
+
*
|
|
56
|
+
* @param {Buffer} bufA
|
|
57
|
+
* @param {Buffer} bufB
|
|
58
|
+
* @returns {{ diffPixels: number, totalPixels: number, diffPercent: number }}
|
|
59
|
+
*/
|
|
60
|
+
function comparePngBuffers(bufA, bufB) {
|
|
61
|
+
const imgA = PNG.sync.read(bufA);
|
|
62
|
+
const imgB = PNG.sync.read(bufB);
|
|
63
|
+
|
|
64
|
+
const width = Math.min(imgA.width, imgB.width);
|
|
65
|
+
const height = Math.min(imgA.height, imgB.height);
|
|
66
|
+
|
|
67
|
+
if (width === 0 || height === 0) {
|
|
68
|
+
throw new Error(`visual-diff: zero-dimension PNG (${imgA.width}×${imgA.height} vs ${imgB.width}×${imgB.height})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const croppedA = cropPng(imgA, width, height);
|
|
72
|
+
const croppedB = cropPng(imgB, width, height);
|
|
73
|
+
const diff = new PNG({ width, height });
|
|
74
|
+
|
|
75
|
+
const diffPixels = pixelmatch(croppedA.data, croppedB.data, diff.data, width, height, { threshold: 0.1 });
|
|
76
|
+
const totalPixels = width * height;
|
|
77
|
+
const diffPercent = (diffPixels / totalPixels) * 100;
|
|
78
|
+
|
|
79
|
+
return { diffPixels, totalPixels, diffPercent };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Capture a screenshot of `url` and compare against the stored baseline.
|
|
86
|
+
*
|
|
87
|
+
* First run (no baseline): saves the screenshot as the new baseline and returns
|
|
88
|
+
* a `visual_baseline_created` info finding.
|
|
89
|
+
*
|
|
90
|
+
* Subsequent runs: compares pixel-by-pixel and emits `visual_regression` when
|
|
91
|
+
* the diff exceeds the threshold, plus always emits `visual_diff_summary`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
94
|
+
* @param {string} url - Page URL (already loaded)
|
|
95
|
+
* @param {object} [opts]
|
|
96
|
+
* @param {string} [opts.baselineDir] - Override baseline storage directory
|
|
97
|
+
* @returns {Promise<object[]>}
|
|
98
|
+
*/
|
|
99
|
+
export async function analyzeVisualRegression(browser, url, opts = {}) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
|
|
102
|
+
// ── 1. Take screenshot ──────────────────────────────────────────────────────
|
|
103
|
+
// Use filePath so the MCP server writes the PNG to disk — take_screenshot
|
|
104
|
+
// returns an image content block, not { data: base64 }, so the filePath
|
|
105
|
+
// approach is the only reliable way to get raw PNG bytes in headless mode.
|
|
106
|
+
const tmpPath = path.join(os.tmpdir(), `argus-visual-${Date.now()}-${slugify(url)}.png`);
|
|
107
|
+
try {
|
|
108
|
+
await browser.screenshot({ format: 'png', filePath: tmpPath });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.warn(`[ARGUS] visual-diff: screenshot failed for ${url}: ${err.message}`);
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let currentBuf;
|
|
115
|
+
try {
|
|
116
|
+
currentBuf = fs.readFileSync(tmpPath);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.warn(`[ARGUS] visual-diff: could not read screenshot from ${tmpPath}: ${err.message}`);
|
|
119
|
+
return findings;
|
|
120
|
+
} finally {
|
|
121
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!currentBuf || currentBuf.length === 0) {
|
|
125
|
+
logger.warn(`[ARGUS] visual-diff: empty screenshot for ${url}`);
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 2. Resolve baseline path ────────────────────────────────────────────────
|
|
130
|
+
const baselineDir = opts.baselineDir ??
|
|
131
|
+
path.join(config.outputDir, 'baselines', 'screenshots');
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(baselineDir, { recursive: true });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.warn(`[ARGUS] visual-diff: could not create baseline dir ${baselineDir}: ${err.message}`);
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const slug = slugify(url);
|
|
141
|
+
const baselinePath = path.join(baselineDir, `${slug}.png`);
|
|
142
|
+
|
|
143
|
+
// ── 3. First run: save baseline ─────────────────────────────────────────────
|
|
144
|
+
if (!fs.existsSync(baselinePath)) {
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(baselinePath, currentBuf);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
|
|
149
|
+
return findings;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
findings.push({
|
|
153
|
+
type: 'visual_baseline_created',
|
|
154
|
+
message: `Visual baseline saved for ${url} — next run will compare against this snapshot`,
|
|
155
|
+
severity: 'info',
|
|
156
|
+
url,
|
|
157
|
+
baselinePath,
|
|
158
|
+
});
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── 4. Compare against existing baseline ────────────────────────────────────
|
|
163
|
+
let result;
|
|
164
|
+
try {
|
|
165
|
+
const baselineBuf = fs.readFileSync(baselinePath);
|
|
166
|
+
result = comparePngBuffers(baselineBuf, currentBuf);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logger.warn(`[ARGUS] visual-diff: comparison failed for ${url}: ${err.message}`);
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { diffPixels, totalPixels, diffPercent } = result;
|
|
173
|
+
|
|
174
|
+
// ── 5. Emit regression finding if threshold exceeded ────────────────────────
|
|
175
|
+
if (diffPercent >= WARN_PERCENT) {
|
|
176
|
+
const sev = diffPercent >= CRIT_PERCENT ? 'critical' : 'warning';
|
|
177
|
+
findings.push({
|
|
178
|
+
type: 'visual_regression',
|
|
179
|
+
diffPercent: parseFloat(diffPercent.toFixed(3)),
|
|
180
|
+
diffPixels,
|
|
181
|
+
totalPixels,
|
|
182
|
+
threshold: WARN_PERCENT,
|
|
183
|
+
message: `Visual regression: ${diffPercent.toFixed(2)}% pixels changed — threshold ${WARN_PERCENT}% (warning) / ${CRIT_PERCENT}% (critical)`,
|
|
184
|
+
severity: sev,
|
|
185
|
+
url,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── 6. Summary — always emitted ─────────────────────────────────────────────
|
|
190
|
+
findings.push({
|
|
191
|
+
type: 'visual_diff_summary',
|
|
192
|
+
diffPercent: parseFloat(diffPercent.toFixed(3)),
|
|
193
|
+
diffPixels,
|
|
194
|
+
totalPixels,
|
|
195
|
+
message: `Visual diff: ${diffPercent.toFixed(3)}% (${diffPixels}/${totalPixels} pixels changed)`,
|
|
196
|
+
severity: 'info',
|
|
197
|
+
url,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return findings;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Self-registration ──────────────────────────────────────────────────────────
|
|
204
|
+
registerExpensive({
|
|
205
|
+
name: 'visual',
|
|
206
|
+
analyze: (browser, url) => analyzeVisualRegression(browser, url),
|
|
207
|
+
});
|