argusqa-os 9.6.6 → 9.7.3
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 +24 -14
- package/glama.json +1 -1
- package/package.json +77 -71
- package/src/adapters/browser.js +2 -1
- package/src/cli/chrome-launcher.js +175 -0
- package/src/cli/doctor.js +133 -0
- package/src/cli/pr-validate.js +20 -6
- package/src/mcp-server.js +1 -1
- package/src/orchestration/orchestrator.js +9 -7
- package/src/orchestration/report-processor.js +33 -1
- package/src/utils/a11y-deep-analyzer.js +1 -1
- package/src/utils/design-fidelity-analyzer.js +1 -1
- package/src/utils/flow-runner.js +16 -2
- package/src/utils/font-analyzer.js +1 -1
- package/src/utils/form-analyzer.js +1 -1
- package/src/utils/har-recorder.js +1 -1
- package/src/utils/issues-analyzer.js +12 -19
- package/src/utils/motion-analyzer.js +1 -1
- package/src/utils/noise-filter.js +159 -0
- package/src/utils/pdf-exporter.js +146 -0
- package/src/utils/pr-diff-analyzer.js +2 -1
- package/src/utils/root-cause-linker.js +169 -0
- package/src/utils/screen-recorder.js +250 -0
- package/src/utils/security-analyzer.js +132 -1
- package/src/utils/theme-analyzer.js +1 -1
- package/src/utils/visual-diff-analyzer.js +1 -1
- package/src/utils/web-vitals-analyzer.js +1 -1
|
@@ -20,10 +20,11 @@ import { SECURITY_ANALYSIS_SCRIPT, parseSecurityAnalysisResult, analyzeSecurityC
|
|
|
20
20
|
import { CONTENT_ANALYSIS_SCRIPT, parseContentAnalysisResult } from '../utils/content-analyzer.js';
|
|
21
21
|
import { runLoginFlow, saveSession, restoreSession, hasSession, refreshSession } from '../utils/session-manager.js';
|
|
22
22
|
import { mergeRunResults } from '../utils/flakiness-detector.js';
|
|
23
|
-
import { runAllFlows,
|
|
23
|
+
import { runAllFlows, waitForSelector } from '../utils/flow-runner.js';
|
|
24
24
|
import { analyzeApiFrequency } from '../utils/api-frequency.js';
|
|
25
25
|
import { slugify } from '../utils/slug.js';
|
|
26
26
|
import { unwrapEval, createMcpClient } from '../utils/mcp-client.js';
|
|
27
|
+
import { parseConsoleMsgResponse } from '../utils/mcp-parsers.js';
|
|
27
28
|
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
28
29
|
import { getFigmaFrame } from '../adapters/figma.js';
|
|
29
30
|
import { chunkArray } from '../utils/parallel-crawler.js';
|
|
@@ -435,9 +436,9 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
435
436
|
const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
|
|
436
437
|
const baselineNetList = await browser.listNetwork().catch(() => []);
|
|
437
438
|
const networkMaxReqId = baselineNetList.reduce((max, r) => Math.max(max, r._reqid ?? 0), 0);
|
|
438
|
-
// listConsoleRaw returns
|
|
439
|
+
// listConsoleRaw returns markdown text ("msgid=N [issue] text") — parse like console messages
|
|
439
440
|
const issuesBaselineRaw = await browser.listConsoleRaw({ types: ['issue'] }).catch(() => null);
|
|
440
|
-
const issuesBaseline =
|
|
441
|
+
const issuesBaseline = parseConsoleMsgResponse(issuesBaselineRaw).length;
|
|
441
442
|
|
|
442
443
|
// 1. Navigate
|
|
443
444
|
await browser.navigate(url);
|
|
@@ -710,11 +711,12 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
710
711
|
logger.warn(`[ARGUS] Content analysis skipped for ${url}: ${err.message}`);
|
|
711
712
|
}
|
|
712
713
|
|
|
713
|
-
// 9e. Chrome DevTools Issues panel
|
|
714
|
+
// 9e. Chrome DevTools Issues panel — same reset-per-navigation guard as console (D5)
|
|
714
715
|
try {
|
|
715
|
-
const issueRaw
|
|
716
|
-
const
|
|
717
|
-
|
|
716
|
+
const issueRaw = await browser.listConsoleRaw({ types: ['issue'] });
|
|
717
|
+
const allIssues = parseConsoleMsgResponse(issueRaw);
|
|
718
|
+
const issuesSliceAt = allIssues.length > issuesBaseline ? issuesBaseline : 0;
|
|
719
|
+
result.errors.push(...parseIssues(allIssues.slice(issuesSliceAt), url, route.critical));
|
|
718
720
|
} catch (err) {
|
|
719
721
|
logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
|
|
720
722
|
}
|
|
@@ -13,6 +13,8 @@ import path from 'path';
|
|
|
13
13
|
import { childLogger } from '../utils/logger.js';
|
|
14
14
|
import { applyOverrides } from '../utils/severity-overrides.js';
|
|
15
15
|
import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
|
|
16
|
+
import { loadRunHistory, recordRunHistory, applyNoiseFilter } from '../utils/noise-filter.js';
|
|
17
|
+
import { getRecentChanges, linkRootCauses } from '../utils/root-cause-linker.js';
|
|
16
18
|
|
|
17
19
|
const logger = childLogger('report-processor');
|
|
18
20
|
|
|
@@ -104,6 +106,29 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
104
106
|
logger.info('[ARGUS] First run — no baseline to compare; all findings treated as new');
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
// 3a. Intelligent baseline filtering — downgrade cross-run flip-flopping findings
|
|
110
|
+
// to info. Best-effort; disable with ARGUS_NOISE_FILTER=0.
|
|
111
|
+
const historyPath = path.join(outputDir, 'baselines', `${safeBranch}-history.json`);
|
|
112
|
+
if (process.env.ARGUS_NOISE_FILTER !== '0') {
|
|
113
|
+
try {
|
|
114
|
+
const history = loadRunHistory(historyPath);
|
|
115
|
+
const { noisyCount } = applyNoiseFilter(report, history);
|
|
116
|
+
if (noisyCount > 0) rebuildSummary(report); // downgrades change severity counts
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.warn(`[ARGUS] Noise filter skipped: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3b. Root cause linking — annotate new findings with recent git changes that
|
|
123
|
+
// map to their route. Best-effort; disable with ARGUS_ROOT_CAUSE=0.
|
|
124
|
+
if (process.env.ARGUS_ROOT_CAUSE !== '0') {
|
|
125
|
+
try {
|
|
126
|
+
linkRootCauses(report, getRecentChanges());
|
|
127
|
+
} catch (err) {
|
|
128
|
+
logger.warn(`[ARGUS] Root cause linking skipped: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
107
132
|
// 4. Write JSON report
|
|
108
133
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
109
134
|
const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
|
|
@@ -115,8 +140,15 @@ export async function processReport(report, { outputDir, severityOverrides }) {
|
|
|
115
140
|
}
|
|
116
141
|
logger.info(`[ARGUS] Report written: ${reportPath}`);
|
|
117
142
|
|
|
118
|
-
// 5. Persist baseline + append trend entry
|
|
143
|
+
// 5. Persist baseline + run history + append trend entry
|
|
119
144
|
saveBaseline(baselinePath, report);
|
|
145
|
+
if (process.env.ARGUS_NOISE_FILTER !== '0') {
|
|
146
|
+
try {
|
|
147
|
+
recordRunHistory(historyPath, report);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.warn(`[ARGUS] Run history write skipped: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
120
152
|
appendTrend(trendsPath, {
|
|
121
153
|
runAt: report.generatedAt,
|
|
122
154
|
baseUrl: report.baseUrl,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ARGUS Design Fidelity Analyzer (
|
|
2
|
+
* ARGUS Design Fidelity Analyzer (D9: Design Fidelity)
|
|
3
3
|
*
|
|
4
4
|
* Compares a live page's computed CSS against every property extracted by
|
|
5
5
|
* src/adapters/figma.js. Requires pre-fetched figmaData — analysis is skipped
|
package/src/utils/flow-runner.js
CHANGED
|
@@ -102,12 +102,26 @@ export async function resolveUidForSelector(browser, selector) {
|
|
|
102
102
|
const fence = text.match(/```(?:json|text)?\s*([\s\S]*?)\s*```/);
|
|
103
103
|
if (fence) text = fence[1];
|
|
104
104
|
|
|
105
|
+
// Pass 1 — exact accessible-name match across ALL identifiers before any
|
|
106
|
+
// substring matching. Substring matches can hit unrelated nodes whose text
|
|
107
|
+
// merely mentions the identifier (e.g. a paragraph documenting "#drag-source"
|
|
108
|
+
// matches the id "drag-source" and wins over the real element's text node).
|
|
105
109
|
for (const identifier of identifiers) {
|
|
106
110
|
const esc = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
107
|
-
// Current snapshot format: "uid=N_M role "accessible name" [attrs]"
|
|
108
|
-
// uid precedes the role and accessible name; MCP tools expect just the N_M part (no "uid=" prefix).
|
|
109
111
|
// Prefer interactive element lines (combobox, button, etc.) over StaticText label
|
|
110
112
|
// nodes — both may share the same accessible name (e.g. a <label> and its <select>).
|
|
113
|
+
const e1 = text.match(new RegExp(`uid=([^\\s]+)\\s+(?!StaticText)[^\\n]*"${esc}"`, 'm'));
|
|
114
|
+
if (e1) return e1[1];
|
|
115
|
+
const e1b = text.match(new RegExp(`uid=([^\\s]+)[^\\n]*"${esc}"`, 'm'));
|
|
116
|
+
if (e1b) return e1b[1];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Pass 2 — substring fallback (accessible names that embed the identifier,
|
|
120
|
+
// e.g. truncated textContent or label text with surrounding punctuation).
|
|
121
|
+
for (const identifier of identifiers) {
|
|
122
|
+
const esc = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
123
|
+
// Current snapshot format: "uid=N_M role "accessible name" [attrs]"
|
|
124
|
+
// uid precedes the role and accessible name; MCP tools expect just the N_M part (no "uid=" prefix).
|
|
111
125
|
const m1 = text.match(new RegExp(`uid=([^\\s]+)\\s+(?!StaticText)[^\\n]*"[^"]*${esc}`, 'm'));
|
|
112
126
|
if (m1) return m1[1];
|
|
113
127
|
// Fallback: accept StaticText nodes (e.g. draggable divs whose only a11y node is text)
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* analyzeIssues(browser, url, isCritical) — standalone navigator for direct harness use.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { parseConsoleMsgResponse } from './mcp-parsers.js';
|
|
27
27
|
|
|
28
28
|
// ── Issue classifiers ─────────────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -112,7 +112,8 @@ function classifyIssue(issue, url, isCritical) {
|
|
|
112
112
|
* Parse a pre-fetched, already-baseline-sliced issues array into findings.
|
|
113
113
|
* Pure function — used by crawlRouteCheap after the D5 baseline-slice.
|
|
114
114
|
*
|
|
115
|
-
* @param {object[]} issues -
|
|
115
|
+
* @param {object[]} issues - Parsed issue objects ({ level, text }) from
|
|
116
|
+
* parseConsoleMsgResponse(list_console_messages({ types: ['issue'] }))
|
|
116
117
|
* @param {string} url - Page URL (used as finding context)
|
|
117
118
|
* @param {boolean} isCritical
|
|
118
119
|
* @returns {object[]}
|
|
@@ -127,11 +128,12 @@ export function parseIssues(issues, url, isCritical = false) {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
|
-
* Standalone issues analyzer — navigates to a URL,
|
|
131
|
-
*
|
|
131
|
+
* Standalone issues analyzer — navigates to a URL, queries the Issues
|
|
132
|
+
* panel after load, and returns findings.
|
|
132
133
|
*
|
|
133
|
-
* Used by the test harness and any standalone caller.
|
|
134
|
-
*
|
|
134
|
+
* Used by the test harness and any standalone caller. No baseline slice is
|
|
135
|
+
* needed: list_console_messages resets per navigation, so the post-navigation
|
|
136
|
+
* response contains only the current page's issues.
|
|
135
137
|
*
|
|
136
138
|
* @param {object} browser
|
|
137
139
|
* @param {string} url
|
|
@@ -141,14 +143,6 @@ export function parseIssues(issues, url, isCritical = false) {
|
|
|
141
143
|
export async function analyzeIssues(browser, url, isCritical = false) {
|
|
142
144
|
const findings = [];
|
|
143
145
|
|
|
144
|
-
let baseline = 0;
|
|
145
|
-
try {
|
|
146
|
-
const priorRaw = await browser.listConsoleRaw({ types: ['issue'], includePreservedMessages: true });
|
|
147
|
-
baseline = normalizeArray(priorRaw).length;
|
|
148
|
-
} catch {
|
|
149
|
-
// Issues API may not be available — baseline stays 0
|
|
150
|
-
}
|
|
151
|
-
|
|
152
146
|
try {
|
|
153
147
|
await browser.navigate(url);
|
|
154
148
|
await new Promise(r => setTimeout(r, 1000));
|
|
@@ -157,11 +151,10 @@ export async function analyzeIssues(browser, url, isCritical = false) {
|
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const issues = normalizeArray(raw).slice(baseline);
|
|
154
|
+
// Response is markdown text ("msgid=N [issue] text") — same format as
|
|
155
|
+
// console messages. parseConsoleMsgResponse extracts { level, text }.
|
|
156
|
+
const raw = await browser.listConsoleRaw({ types: ['issue'] });
|
|
157
|
+
const issues = parseConsoleMsgResponse(raw);
|
|
165
158
|
findings.push(...parseIssues(issues, url, isCritical));
|
|
166
159
|
} catch {
|
|
167
160
|
// Issues API not available in this chrome-devtools-mcp build — silent skip
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ARGUS Motion & Animation Accessibility Analyzer (
|
|
2
|
+
* ARGUS Motion & Animation Accessibility Analyzer (A9)
|
|
3
3
|
*
|
|
4
4
|
* Detects pages that trigger motion/animation without respecting the user's
|
|
5
5
|
* `prefers-reduced-motion` OS preference — a WCAG 2.1 SC 2.3.3 (AAA) violation
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intelligent Baseline Filtering — cross-run noise classifier.
|
|
3
|
+
*
|
|
4
|
+
* Pure algorithmic false-positive filter: no external API, no per-run cost.
|
|
5
|
+
* Tracks which finding keys appeared on which routes across the last N runs
|
|
6
|
+
* (reports/baselines/<branch>-history.json) and flags findings that flip-flop
|
|
7
|
+
* between present and absent as "noisy". Noisy findings are downgraded to
|
|
8
|
+
* severity "info" (never suppressed — visibility is kept) and annotated with
|
|
9
|
+
* `noisy: true`, `noiseScore`, and `originalSeverity`.
|
|
10
|
+
*
|
|
11
|
+
* Distinct from flakiness-detector.js (B4), which compares two crawls WITHIN
|
|
12
|
+
* one run. This module classifies across run HISTORY, catching findings that
|
|
13
|
+
* are stable within a run but unstable between runs (timing-dependent ads,
|
|
14
|
+
* third-party scripts, A/B-tested content).
|
|
15
|
+
*
|
|
16
|
+
* Disable with ARGUS_NOISE_FILTER=0.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { findingKey } from './flakiness-detector.js';
|
|
22
|
+
import { childLogger } from './logger.js';
|
|
23
|
+
|
|
24
|
+
const logger = childLogger('noise-filter');
|
|
25
|
+
|
|
26
|
+
/** Minimum recorded runs for a route before its findings can be classified noisy. */
|
|
27
|
+
export const NOISE_MIN_RUNS = 4;
|
|
28
|
+
/** Presence-flip ratio (transitions / (runs - 1)) at or above which a finding is noisy. */
|
|
29
|
+
export const NOISE_FLIP_THRESHOLD = 0.4;
|
|
30
|
+
/** Maximum run entries kept in the history file. */
|
|
31
|
+
export const MAX_HISTORY_RUNS = 20;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load run history from disk. Returns [] when the file is absent or corrupt.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} historyFile
|
|
37
|
+
* @returns {Array<{ runAt: string, routes: Record<string, string[]> }>}
|
|
38
|
+
*/
|
|
39
|
+
export function loadRunHistory(historyFile) {
|
|
40
|
+
if (!fs.existsSync(historyFile)) return [];
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
|
|
43
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Append the current report's finding keys as one run entry, capped at maxRuns.
|
|
51
|
+
* Atomic write (tmp + rename) — same pattern as baseline-manager.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} historyFile
|
|
54
|
+
* @param {object} report - { generatedAt, routes: [{ url, errors }] }
|
|
55
|
+
* @param {number} [maxRuns]
|
|
56
|
+
*/
|
|
57
|
+
export function recordRunHistory(historyFile, report, maxRuns = MAX_HISTORY_RUNS) {
|
|
58
|
+
const dir = path.dirname(historyFile);
|
|
59
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const entry = { runAt: report.generatedAt ?? new Date().toISOString(), routes: {} };
|
|
62
|
+
for (const routeResult of (report.routes ?? [])) {
|
|
63
|
+
entry.routes[routeResult.url] = (routeResult.errors ?? []).map(findingKey);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let history = loadRunHistory(historyFile);
|
|
67
|
+
history.push(entry);
|
|
68
|
+
if (history.length > maxRuns) history = history.slice(-maxRuns);
|
|
69
|
+
|
|
70
|
+
const tmp = `${historyFile}.${process.pid}.${Date.now()}.tmp`;
|
|
71
|
+
fs.writeFileSync(tmp, JSON.stringify(history, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl history to a local baseline file by design
|
|
72
|
+
fs.renameSync(tmp, historyFile);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute per-finding noise scores from run history.
|
|
77
|
+
*
|
|
78
|
+
* For every route, builds a presence series per finding key across the runs in
|
|
79
|
+
* which that route was crawled, then scores `transitions / (runs - 1)` — 0 for
|
|
80
|
+
* a finding that is always present (or always absent), 1 for one that flips on
|
|
81
|
+
* every consecutive run pair.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<{ routes: Record<string, string[]> }>} history
|
|
84
|
+
* @returns {Map<string, { score: number, runs: number, transitions: number }>}
|
|
85
|
+
* keyed by `${url}::${findingKey}`
|
|
86
|
+
*/
|
|
87
|
+
export function computeNoiseScores(history) {
|
|
88
|
+
const scores = new Map();
|
|
89
|
+
if (!Array.isArray(history) || history.length < 2) return scores;
|
|
90
|
+
|
|
91
|
+
// url → array of Set(keys), one per run that crawled the url (run order preserved)
|
|
92
|
+
const routeSeries = new Map();
|
|
93
|
+
for (const run of history) {
|
|
94
|
+
for (const [url, keys] of Object.entries(run.routes ?? {})) {
|
|
95
|
+
if (!routeSeries.has(url)) routeSeries.set(url, []);
|
|
96
|
+
routeSeries.get(url).push(new Set(keys));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [url, series] of routeSeries) {
|
|
101
|
+
if (series.length < 2) continue;
|
|
102
|
+
const allKeys = new Set();
|
|
103
|
+
for (const runKeys of series) for (const k of runKeys) allKeys.add(k);
|
|
104
|
+
|
|
105
|
+
for (const key of allKeys) {
|
|
106
|
+
let transitions = 0;
|
|
107
|
+
for (let i = 1; i < series.length; i++) {
|
|
108
|
+
if (series[i].has(key) !== series[i - 1].has(key)) transitions++;
|
|
109
|
+
}
|
|
110
|
+
scores.set(`${url}::${key}`, {
|
|
111
|
+
score: transitions / (series.length - 1),
|
|
112
|
+
runs: series.length,
|
|
113
|
+
transitions,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return scores;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Annotate and downgrade noisy findings in the report (mutates in place).
|
|
122
|
+
*
|
|
123
|
+
* A finding is noisy when its route has ≥ minRuns of history AND its presence
|
|
124
|
+
* flip ratio ≥ flipThreshold. Noisy findings get `noisy: true`, `noiseScore`,
|
|
125
|
+
* `originalSeverity`, and severity downgraded to "info". Caller is responsible
|
|
126
|
+
* for rebuilding report.summary afterwards.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} report
|
|
129
|
+
* @param {Array} history - From loadRunHistory()
|
|
130
|
+
* @param {object} [opts]
|
|
131
|
+
* @param {number} [opts.minRuns]
|
|
132
|
+
* @param {number} [opts.flipThreshold]
|
|
133
|
+
* @returns {{ noisyCount: number }}
|
|
134
|
+
*/
|
|
135
|
+
export function applyNoiseFilter(report, history, { minRuns = NOISE_MIN_RUNS, flipThreshold = NOISE_FLIP_THRESHOLD } = {}) {
|
|
136
|
+
const scores = computeNoiseScores(history);
|
|
137
|
+
let noisyCount = 0;
|
|
138
|
+
if (scores.size === 0) return { noisyCount };
|
|
139
|
+
|
|
140
|
+
for (const routeResult of (report.routes ?? [])) {
|
|
141
|
+
for (const finding of (routeResult.errors ?? [])) {
|
|
142
|
+
const entry = scores.get(`${routeResult.url}::${findingKey(finding)}`);
|
|
143
|
+
if (!entry || entry.runs < minRuns || entry.score < flipThreshold) continue;
|
|
144
|
+
|
|
145
|
+
finding.noisy = true;
|
|
146
|
+
finding.noiseScore = Math.round(entry.score * 100) / 100;
|
|
147
|
+
if (finding.severity !== 'info') {
|
|
148
|
+
finding.originalSeverity = finding.severity;
|
|
149
|
+
finding.severity = 'info';
|
|
150
|
+
}
|
|
151
|
+
noisyCount++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (noisyCount > 0) {
|
|
156
|
+
logger.info(`[ARGUS] Noise filter: ${noisyCount} flip-flopping finding(s) downgraded to info`);
|
|
157
|
+
}
|
|
158
|
+
return { noisyCount };
|
|
159
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus PDF Exporter
|
|
3
|
+
*
|
|
4
|
+
* Exports the Argus HTML report as a branded A4 PDF using puppeteer.
|
|
5
|
+
* puppeteer is an optional peer dependency — install when needed:
|
|
6
|
+
* npm install puppeteer
|
|
7
|
+
*
|
|
8
|
+
* Usage (programmatic):
|
|
9
|
+
* import { exportReportToPdf } from './pdf-exporter.js';
|
|
10
|
+
* const pdfPath = await exportReportToPdf('./reports/report.html', './reports/report.pdf');
|
|
11
|
+
*
|
|
12
|
+
* Usage (CLI):
|
|
13
|
+
* node src/utils/pdf-exporter.js ./reports/report.html ./reports/report.pdf
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
19
|
+
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load puppeteer dynamically so the import failure is a clear runtime error,
|
|
24
|
+
* not a module load error on startup.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<object>} puppeteer default export
|
|
27
|
+
* @throws {Error} with install instructions if not installed
|
|
28
|
+
*/
|
|
29
|
+
async function loadPuppeteer() {
|
|
30
|
+
try {
|
|
31
|
+
return (await import('puppeteer')).default;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'PDF export requires puppeteer:\n' +
|
|
35
|
+
' npm install puppeteer\n' +
|
|
36
|
+
'Then retry.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Export an Argus HTML report to a branded A4 PDF.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} htmlPath - Absolute or relative path to the source HTML report
|
|
45
|
+
* @param {string} outputPath - Destination PDF file path (written to disk)
|
|
46
|
+
* @param {{ format?: string, landscape?: boolean, scale?: number }} [options]
|
|
47
|
+
* @returns {Promise<string>} Resolved outputPath
|
|
48
|
+
*/
|
|
49
|
+
export async function exportReportToPdf(htmlPath, outputPath, options = {}) {
|
|
50
|
+
const {
|
|
51
|
+
format = 'A4',
|
|
52
|
+
landscape = false,
|
|
53
|
+
scale = 1,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
const resolvedHtml = path.resolve(htmlPath);
|
|
57
|
+
if (!fs.existsSync(resolvedHtml)) {
|
|
58
|
+
throw new Error(`HTML report not found: ${resolvedHtml}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resolvedOut = path.resolve(outputPath);
|
|
62
|
+
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
63
|
+
|
|
64
|
+
const puppeteer = await loadPuppeteer();
|
|
65
|
+
const browser = await puppeteer.launch({ headless: 'new' });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const page = await browser.newPage();
|
|
69
|
+
await page.setViewport({ width: 1280, height: 900 });
|
|
70
|
+
await page.goto(pathToFileURL(resolvedHtml).href, { waitUntil: 'networkidle0', timeout: 30_000 });
|
|
71
|
+
|
|
72
|
+
await page.pdf({
|
|
73
|
+
path: resolvedOut,
|
|
74
|
+
format,
|
|
75
|
+
landscape,
|
|
76
|
+
scale,
|
|
77
|
+
printBackground: true,
|
|
78
|
+
margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
|
|
79
|
+
});
|
|
80
|
+
} finally {
|
|
81
|
+
await browser.close();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return resolvedOut;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Navigate to a live URL and export to PDF.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} pageUrl - URL to navigate to before printing
|
|
91
|
+
* @param {string} outputPath - Destination PDF file path
|
|
92
|
+
* @param {{ format?: string, landscape?: boolean, scale?: number, waitUntil?: string }} [options]
|
|
93
|
+
* @returns {Promise<string>}
|
|
94
|
+
*/
|
|
95
|
+
export async function exportPageToPdf(pageUrl, outputPath, options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
format = 'A4',
|
|
98
|
+
landscape = false,
|
|
99
|
+
scale = 1,
|
|
100
|
+
waitUntil = 'networkidle0',
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
const resolvedOut = path.resolve(outputPath);
|
|
104
|
+
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
105
|
+
|
|
106
|
+
const puppeteer = await loadPuppeteer();
|
|
107
|
+
const browser = await puppeteer.launch({ headless: 'new' });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const page = await browser.newPage();
|
|
111
|
+
await page.setViewport({ width: 1280, height: 900 });
|
|
112
|
+
await page.goto(pageUrl, { waitUntil, timeout: 30_000 });
|
|
113
|
+
|
|
114
|
+
await page.pdf({
|
|
115
|
+
path: resolvedOut,
|
|
116
|
+
format,
|
|
117
|
+
landscape,
|
|
118
|
+
scale,
|
|
119
|
+
printBackground: true,
|
|
120
|
+
margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
await browser.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return resolvedOut;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── CLI entry ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
if (process.argv[1] === __filename) {
|
|
132
|
+
const [,, htmlArg, outArg] = process.argv;
|
|
133
|
+
|
|
134
|
+
if (!htmlArg || !outArg) {
|
|
135
|
+
process.stderr.write('Usage: node src/utils/pdf-exporter.js <report.html> <output.pdf>\n');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const out = await exportReportToPdf(htmlArg, outArg);
|
|
141
|
+
process.stdout.write(`✓ PDF written: ${out}\n`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(`✗ ${err.message}\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -81,8 +81,9 @@ const EXCLUDED_PATTERNS = [
|
|
|
81
81
|
/**
|
|
82
82
|
* Patterns that indicate an infrastructure-level file whose change can affect
|
|
83
83
|
* every route — framework configs, root layouts, global stylesheets, package.json.
|
|
84
|
+
* Exported for reuse by root-cause-linker.js (MIT).
|
|
84
85
|
*/
|
|
85
|
-
const INFRA_PATTERNS = [
|
|
86
|
+
export const INFRA_PATTERNS = [
|
|
86
87
|
/next\.config\./i,
|
|
87
88
|
/vite\.config\./i,
|
|
88
89
|
/tailwind\.config\./i,
|