@odavl/guardian 0.1.0-rc1
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/guardian.js +690 -0
- package/flows/example-login-flow.json +36 -0
- package/flows/example-signup-flow.json +44 -0
- package/guardian-contract-v1.md +149 -0
- package/guardian.config.json +54 -0
- package/guardian.policy.json +12 -0
- package/guardian.profile.docs.yaml +18 -0
- package/guardian.profile.ecommerce.yaml +17 -0
- package/guardian.profile.marketing.yaml +18 -0
- package/guardian.profile.saas.yaml +21 -0
- package/package.json +69 -0
- package/policies/enterprise.json +12 -0
- package/policies/saas.json +12 -0
- package/policies/startup.json +12 -0
- package/src/guardian/attempt-engine.js +454 -0
- package/src/guardian/attempt-registry.js +227 -0
- package/src/guardian/attempt-reporter.js +507 -0
- package/src/guardian/attempt.js +227 -0
- package/src/guardian/auto-attempt-builder.js +283 -0
- package/src/guardian/baseline-reporter.js +143 -0
- package/src/guardian/baseline-storage.js +285 -0
- package/src/guardian/baseline.js +492 -0
- package/src/guardian/behavioral-signals.js +261 -0
- package/src/guardian/breakage-intelligence.js +223 -0
- package/src/guardian/browser.js +92 -0
- package/src/guardian/cli-summary.js +141 -0
- package/src/guardian/crawler.js +142 -0
- package/src/guardian/discovery-engine.js +661 -0
- package/src/guardian/enhanced-html-reporter.js +305 -0
- package/src/guardian/failure-taxonomy.js +169 -0
- package/src/guardian/flow-executor.js +374 -0
- package/src/guardian/flow-registry.js +67 -0
- package/src/guardian/html-reporter.js +414 -0
- package/src/guardian/index.js +218 -0
- package/src/guardian/init-command.js +139 -0
- package/src/guardian/junit-reporter.js +264 -0
- package/src/guardian/market-criticality.js +335 -0
- package/src/guardian/market-reporter.js +305 -0
- package/src/guardian/network-trace.js +178 -0
- package/src/guardian/policy.js +357 -0
- package/src/guardian/preset-loader.js +148 -0
- package/src/guardian/reality.js +547 -0
- package/src/guardian/reporter.js +181 -0
- package/src/guardian/root-cause-analysis.js +171 -0
- package/src/guardian/safety.js +248 -0
- package/src/guardian/scan-presets.js +60 -0
- package/src/guardian/screenshot.js +152 -0
- package/src/guardian/sitemap.js +225 -0
- package/src/guardian/snapshot-schema.js +266 -0
- package/src/guardian/snapshot.js +327 -0
- package/src/guardian/validators.js +323 -0
- package/src/guardian/visual-diff.js +247 -0
- package/src/guardian/webhook.js +206 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { executeReality } = require('./reality');
|
|
4
|
+
const { BaselineCheckReporter } = require('./baseline-reporter');
|
|
5
|
+
const { getDefaultAttemptIds, getAttemptDefinition } = require('./attempt-registry');
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
function safeNumber(n) {
|
|
10
|
+
return typeof n === 'number' && !Number.isNaN(n) ? n : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toStepSummary(step) {
|
|
14
|
+
return {
|
|
15
|
+
stepId: step.id,
|
|
16
|
+
type: step.type,
|
|
17
|
+
durationMs: safeNumber(step.durationMs) || 0,
|
|
18
|
+
retries: safeNumber(step.retries) || 0
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toFrictionSignalSummary(signal) {
|
|
23
|
+
return {
|
|
24
|
+
id: signal.id,
|
|
25
|
+
metric: signal.metric,
|
|
26
|
+
threshold: safeNumber(signal.threshold),
|
|
27
|
+
observedValue: safeNumber(signal.observedValue),
|
|
28
|
+
affectedStepId: signal.affectedStepId || null,
|
|
29
|
+
severity: signal.severity || 'medium'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildBaselineSnapshot({ name, baseUrl, attempts, guardianVersion }, realityReport) {
|
|
34
|
+
// Include all executed attempts (manual + auto-generated)
|
|
35
|
+
const allAttemptIds = realityReport.results.map(r => r.attemptId);
|
|
36
|
+
const resultsByAttempt = new Map(realityReport.results.map(r => [r.attemptId, r]));
|
|
37
|
+
|
|
38
|
+
const perAttempt = allAttemptIds.map((attemptId) => {
|
|
39
|
+
const r = resultsByAttempt.get(attemptId);
|
|
40
|
+
const steps = (r.steps || []).map(toStepSummary);
|
|
41
|
+
const retriesTotal = steps.reduce((sum, s) => sum + (s.retries || 0), 0);
|
|
42
|
+
const frictionSignals = (r.friction && r.friction.signals ? r.friction.signals : []).map(toFrictionSignalSummary);
|
|
43
|
+
return {
|
|
44
|
+
attemptId,
|
|
45
|
+
attemptName: r.attemptName || (getAttemptDefinition(attemptId)?.name || attemptId),
|
|
46
|
+
outcome: r.outcome,
|
|
47
|
+
totalDurationMs: safeNumber(r.totalDurationMs) || 0,
|
|
48
|
+
totalRetries: retriesTotal,
|
|
49
|
+
frictionSignals,
|
|
50
|
+
steps
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const flows = (realityReport.flows || []).map((flow) => ({
|
|
55
|
+
flowId: flow.flowId,
|
|
56
|
+
flowName: flow.flowName,
|
|
57
|
+
outcome: flow.outcome,
|
|
58
|
+
riskCategory: flow.riskCategory,
|
|
59
|
+
stepsExecuted: flow.stepsExecuted || 0,
|
|
60
|
+
stepsTotal: flow.stepsTotal || 0,
|
|
61
|
+
durationMs: safeNumber(flow.durationMs) || 0,
|
|
62
|
+
error: flow.error || null
|
|
63
|
+
}));
|
|
64
|
+
const flowIds = flows.map(f => f.flowId);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: SCHEMA_VERSION,
|
|
68
|
+
guardianVersion: guardianVersion || 'unknown',
|
|
69
|
+
baselineName: name,
|
|
70
|
+
createdAt: new Date().toISOString(),
|
|
71
|
+
baseUrl,
|
|
72
|
+
attempts: allAttemptIds, // Include all executed attempts
|
|
73
|
+
flows: flowIds,
|
|
74
|
+
overallVerdict: realityReport.summary.overallVerdict,
|
|
75
|
+
perAttempt,
|
|
76
|
+
perFlow: flows
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureDir(p) {
|
|
81
|
+
fs.mkdirSync(p, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function saveBaseline(options) {
|
|
85
|
+
const {
|
|
86
|
+
baseUrl,
|
|
87
|
+
attempts = getDefaultAttemptIds(),
|
|
88
|
+
name = 'baseline',
|
|
89
|
+
artifactsDir = './artifacts',
|
|
90
|
+
baselineDir,
|
|
91
|
+
headful = false,
|
|
92
|
+
enableTrace = true,
|
|
93
|
+
enableScreenshots = true,
|
|
94
|
+
enableDiscovery = false,
|
|
95
|
+
enableAutoAttempts = false,
|
|
96
|
+
maxPages,
|
|
97
|
+
autoAttemptOptions,
|
|
98
|
+
guardianVersion,
|
|
99
|
+
enableFlows = true,
|
|
100
|
+
flowOptions = {}
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
const reality = await executeReality({
|
|
104
|
+
baseUrl,
|
|
105
|
+
attempts,
|
|
106
|
+
artifactsDir,
|
|
107
|
+
headful,
|
|
108
|
+
enableTrace,
|
|
109
|
+
enableScreenshots,
|
|
110
|
+
enableDiscovery,
|
|
111
|
+
enableAutoAttempts,
|
|
112
|
+
maxPages,
|
|
113
|
+
autoAttemptOptions,
|
|
114
|
+
enableFlows,
|
|
115
|
+
flowOptions
|
|
116
|
+
});
|
|
117
|
+
const snapshot = buildBaselineSnapshot({ name, baseUrl, attempts, guardianVersion }, reality.report);
|
|
118
|
+
|
|
119
|
+
const targetBaselineDir = baselineDir ? baselineDir : path.join(artifactsDir, 'baselines');
|
|
120
|
+
ensureDir(targetBaselineDir);
|
|
121
|
+
const baselinePath = path.join(targetBaselineDir, `${name}.json`);
|
|
122
|
+
fs.writeFileSync(baselinePath, JSON.stringify(snapshot, null, 2));
|
|
123
|
+
|
|
124
|
+
console.log(`\n💾 Baseline saved: ${baselinePath}`);
|
|
125
|
+
console.log(`Captured verdict: ${snapshot.overallVerdict}`);
|
|
126
|
+
|
|
127
|
+
// Always exit 0 for save; return structured result
|
|
128
|
+
return {
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
baselinePath,
|
|
131
|
+
runDir: reality.runDir,
|
|
132
|
+
marketJsonPath: reality.marketJsonPath,
|
|
133
|
+
marketHtmlPath: reality.marketHtmlPath,
|
|
134
|
+
snapshot
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadBaselineOrThrow(baselinePath) {
|
|
139
|
+
if (!fs.existsSync(baselinePath)) {
|
|
140
|
+
const err = new Error(`Baseline not found at ${baselinePath}`);
|
|
141
|
+
err.code = 'BASELINE_MISSING';
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
const raw = fs.readFileSync(baselinePath, 'utf8');
|
|
145
|
+
const data = JSON.parse(raw);
|
|
146
|
+
if (!data || data.schemaVersion !== SCHEMA_VERSION) {
|
|
147
|
+
const err = new Error(`Baseline schema mismatch. Expected schemaVersion=${SCHEMA_VERSION}.`);
|
|
148
|
+
err.code = 'SCHEMA_MISMATCH';
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function percentChange(baseline, current) {
|
|
155
|
+
if (baseline === null || current === null) return null;
|
|
156
|
+
if (baseline === 0) return current > 0 ? Infinity : 0;
|
|
157
|
+
return ((current - baseline) / baseline) * 100;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function indexSignals(signals) {
|
|
161
|
+
const map = new Map();
|
|
162
|
+
for (const s of signals || []) {
|
|
163
|
+
if (s && s.id) map.set(s.id, s);
|
|
164
|
+
}
|
|
165
|
+
return map;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compareAttempt(b, c) {
|
|
169
|
+
// Handle missing attempts
|
|
170
|
+
if (!b && !c) {
|
|
171
|
+
return {
|
|
172
|
+
regressionType: 'NO_REGRESSION',
|
|
173
|
+
improvements: [],
|
|
174
|
+
regressionReasons: [],
|
|
175
|
+
frictionDelta: { added: [], removed: [], changed: [] },
|
|
176
|
+
keyMetricsDelta: { durationMs: null, durationPct: null, retriesDelta: null }
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (!b) {
|
|
180
|
+
// New attempt not in baseline (auto-generated)
|
|
181
|
+
return {
|
|
182
|
+
regressionType: 'NO_REGRESSION',
|
|
183
|
+
improvements: ['New attempt added'],
|
|
184
|
+
regressionReasons: [],
|
|
185
|
+
frictionDelta: { added: [], removed: [], changed: [] },
|
|
186
|
+
keyMetricsDelta: { durationMs: null, durationPct: null, retriesDelta: null }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!c) {
|
|
190
|
+
// Attempt disappeared
|
|
191
|
+
return {
|
|
192
|
+
regressionType: 'REGRESSION_MISSING',
|
|
193
|
+
improvements: [],
|
|
194
|
+
regressionReasons: ['Attempt no longer exists in current run'],
|
|
195
|
+
frictionDelta: { added: [], removed: [], changed: [] },
|
|
196
|
+
keyMetricsDelta: { durationMs: null, durationPct: null, retriesDelta: null }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const baselineOutcome = b.outcome;
|
|
201
|
+
const currentOutcome = c.outcome;
|
|
202
|
+
|
|
203
|
+
const improvements = [];
|
|
204
|
+
const regressionReasons = [];
|
|
205
|
+
const frictionDelta = { added: [], removed: [], changed: [] };
|
|
206
|
+
const keyMetricsDelta = { durationMs: null, durationPct: null, retriesDelta: null };
|
|
207
|
+
|
|
208
|
+
// duration and retries deltas
|
|
209
|
+
keyMetricsDelta.durationMs = (safeNumber(c.totalDurationMs) || 0) - (safeNumber(b.totalDurationMs) || 0);
|
|
210
|
+
keyMetricsDelta.durationPct = percentChange(safeNumber(b.totalDurationMs) || 0, safeNumber(c.totalDurationMs) || 0);
|
|
211
|
+
keyMetricsDelta.retriesDelta = (safeNumber(c.totalRetries) || 0) - (safeNumber(b.totalRetries) || 0);
|
|
212
|
+
|
|
213
|
+
// friction signals diff
|
|
214
|
+
const bIdx = indexSignals(b.frictionSignals || []);
|
|
215
|
+
const cIdx = indexSignals(c.frictionSignals || []);
|
|
216
|
+
for (const [id, cs] of cIdx.entries()) {
|
|
217
|
+
if (!bIdx.has(id)) {
|
|
218
|
+
frictionDelta.added.push(id);
|
|
219
|
+
} else {
|
|
220
|
+
const bs = bIdx.get(id);
|
|
221
|
+
if (typeof bs.observedValue === 'number' && typeof cs.observedValue === 'number') {
|
|
222
|
+
const pct = percentChange(bs.observedValue, cs.observedValue);
|
|
223
|
+
if (pct !== null && pct !== Infinity && pct >= 20) {
|
|
224
|
+
frictionDelta.changed.push({ id, observedPctIncrease: pct });
|
|
225
|
+
}
|
|
226
|
+
if (pct < 0) {
|
|
227
|
+
improvements.push(`Friction signal ${id} observed value decreased by ${Math.abs(Math.round(pct))}%`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const [id] of bIdx.entries()) {
|
|
233
|
+
if (!cIdx.has(id)) {
|
|
234
|
+
frictionDelta.removed.push(id);
|
|
235
|
+
improvements.push(`Friction signal ${id} removed`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// classify regression
|
|
240
|
+
let regressionType = 'NO_REGRESSION';
|
|
241
|
+
|
|
242
|
+
if ((baselineOutcome === 'SUCCESS' || baselineOutcome === 'FRICTION') && currentOutcome === 'FAILURE') {
|
|
243
|
+
regressionType = 'REGRESSION_FAILURE';
|
|
244
|
+
regressionReasons.push('Baseline was non-failure; current attempt failed.');
|
|
245
|
+
} else if (baselineOutcome === 'SUCCESS' && currentOutcome === 'FRICTION') {
|
|
246
|
+
regressionType = 'REGRESSION_FRICTION_NEW';
|
|
247
|
+
regressionReasons.push('Baseline had no friction; current attempt shows friction.');
|
|
248
|
+
} else if (baselineOutcome === 'FRICTION' && currentOutcome === 'FRICTION') {
|
|
249
|
+
let worse = false;
|
|
250
|
+
if (frictionDelta.added.length > 0) {
|
|
251
|
+
worse = true;
|
|
252
|
+
regressionReasons.push(`New friction signals: ${frictionDelta.added.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
if (frictionDelta.changed.length > 0) {
|
|
255
|
+
worse = true;
|
|
256
|
+
regressionReasons.push('Friction observed values increased by >=20%.');
|
|
257
|
+
}
|
|
258
|
+
if (keyMetricsDelta.durationPct !== null && keyMetricsDelta.durationPct >= 20) {
|
|
259
|
+
worse = true;
|
|
260
|
+
regressionReasons.push('Total duration increased by >=20%.');
|
|
261
|
+
}
|
|
262
|
+
if (keyMetricsDelta.retriesDelta !== null && keyMetricsDelta.retriesDelta > 0) {
|
|
263
|
+
worse = true;
|
|
264
|
+
regressionReasons.push('Total retries increased.');
|
|
265
|
+
}
|
|
266
|
+
if (worse) {
|
|
267
|
+
regressionType = 'REGRESSION_FRICTION_WORSE';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// improvements not changing status
|
|
272
|
+
if (baselineOutcome === 'FAILURE' && (currentOutcome === 'SUCCESS' || currentOutcome === 'FRICTION')) {
|
|
273
|
+
improvements.push('Outcome improved from FAILURE.');
|
|
274
|
+
}
|
|
275
|
+
if (baselineOutcome === 'FRICTION' && currentOutcome === 'SUCCESS') {
|
|
276
|
+
improvements.push('Outcome improved from FRICTION to SUCCESS.');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
baselineOutcome,
|
|
281
|
+
currentOutcome,
|
|
282
|
+
regressionType,
|
|
283
|
+
regressionReasons,
|
|
284
|
+
improvements,
|
|
285
|
+
frictionDelta,
|
|
286
|
+
keyMetricsDelta
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function aggregateVerdict(comparisons) {
|
|
291
|
+
const hasFailure = comparisons.some(c => c.regressionType === 'REGRESSION_FAILURE' || c.regressionType === 'REGRESSION_MISSING');
|
|
292
|
+
if (hasFailure) return 'REGRESSION_FAILURE';
|
|
293
|
+
const hasFriction = comparisons.some(c => c.regressionType === 'REGRESSION_FRICTION_NEW' || c.regressionType === 'REGRESSION_FRICTION_WORSE');
|
|
294
|
+
if (hasFriction) return 'REGRESSION_FRICTION';
|
|
295
|
+
return 'NO_REGRESSION';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function checkBaseline(options) {
|
|
299
|
+
const {
|
|
300
|
+
baseUrl,
|
|
301
|
+
name,
|
|
302
|
+
attempts = getDefaultAttemptIds(),
|
|
303
|
+
artifactsDir = './artifacts',
|
|
304
|
+
baselineDir,
|
|
305
|
+
junit,
|
|
306
|
+
headful = false,
|
|
307
|
+
enableTrace = true,
|
|
308
|
+
enableScreenshots = true,
|
|
309
|
+
enableDiscovery = false,
|
|
310
|
+
enableAutoAttempts = false,
|
|
311
|
+
maxPages,
|
|
312
|
+
autoAttemptOptions,
|
|
313
|
+
enableFlows = true,
|
|
314
|
+
flowOptions = {}
|
|
315
|
+
} = options;
|
|
316
|
+
|
|
317
|
+
const baselinePath = path.join(baselineDir ? baselineDir : path.join(artifactsDir, 'baselines'), `${name}.json`);
|
|
318
|
+
let baseline;
|
|
319
|
+
try {
|
|
320
|
+
baseline = loadBaselineOrThrow(baselinePath);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error(`\n❌ Baseline error: ${err.message}`);
|
|
323
|
+
return { exitCode: 1, error: err.message };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const current = await executeReality({
|
|
327
|
+
baseUrl,
|
|
328
|
+
attempts,
|
|
329
|
+
artifactsDir,
|
|
330
|
+
headful,
|
|
331
|
+
enableTrace,
|
|
332
|
+
enableScreenshots,
|
|
333
|
+
enableDiscovery,
|
|
334
|
+
enableAutoAttempts,
|
|
335
|
+
maxPages,
|
|
336
|
+
autoAttemptOptions,
|
|
337
|
+
enableFlows,
|
|
338
|
+
flowOptions
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Use baseline attempts (includes auto-generated) for comparison
|
|
342
|
+
const comparisonAttempts = baseline.attempts || attempts;
|
|
343
|
+
|
|
344
|
+
// Map baseline and current per attempt
|
|
345
|
+
const bMap = new Map((baseline.perAttempt || []).map(a => [a.attemptId, a]));
|
|
346
|
+
const cMap = new Map((current.report.results || []).map(r => [r.attemptId, {
|
|
347
|
+
attemptId: r.attemptId,
|
|
348
|
+
outcome: r.outcome,
|
|
349
|
+
totalDurationMs: safeNumber(r.totalDurationMs) || 0,
|
|
350
|
+
totalRetries: (r.steps || []).reduce((sum, s) => sum + (s.retries || 0), 0),
|
|
351
|
+
frictionSignals: (r.friction && r.friction.signals ? r.friction.signals : []).map(toFrictionSignalSummary),
|
|
352
|
+
reportHtmlPath: r.reportHtmlPath,
|
|
353
|
+
reportJsonPath: r.reportJsonPath
|
|
354
|
+
}]));
|
|
355
|
+
|
|
356
|
+
const bFlowMap = new Map((baseline.perFlow || []).map(f => [f.flowId, {
|
|
357
|
+
...f,
|
|
358
|
+
totalDurationMs: safeNumber(f.durationMs) || 0,
|
|
359
|
+
totalRetries: 0,
|
|
360
|
+
frictionSignals: []
|
|
361
|
+
}]));
|
|
362
|
+
const cFlowMap = new Map(((current.flowResults || current.report.flows || [])).map(f => [f.flowId, {
|
|
363
|
+
...f,
|
|
364
|
+
totalDurationMs: safeNumber(f.durationMs) || 0,
|
|
365
|
+
totalRetries: 0,
|
|
366
|
+
frictionSignals: []
|
|
367
|
+
}]));
|
|
368
|
+
|
|
369
|
+
const comparisons = comparisonAttempts.map((attemptId) => {
|
|
370
|
+
const b = bMap.get(attemptId);
|
|
371
|
+
const c = cMap.get(attemptId);
|
|
372
|
+
const comp = compareAttempt(b, c);
|
|
373
|
+
return {
|
|
374
|
+
attemptId,
|
|
375
|
+
...comp,
|
|
376
|
+
links: {
|
|
377
|
+
reportHtml: (c && c.reportHtmlPath) || null,
|
|
378
|
+
reportJson: (c && c.reportJsonPath) || null
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const comparisonFlows = (baseline.flows || []).map((flowId) => {
|
|
384
|
+
const b = bFlowMap.get(flowId);
|
|
385
|
+
const c = cFlowMap.get(flowId);
|
|
386
|
+
const comp = compareAttempt(b, c);
|
|
387
|
+
return {
|
|
388
|
+
flowId,
|
|
389
|
+
...comp
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const overallRegressionVerdict = aggregateVerdict([...comparisons, ...comparisonFlows]);
|
|
394
|
+
|
|
395
|
+
const jsonReport = {
|
|
396
|
+
meta: {
|
|
397
|
+
runId: current.report.runId,
|
|
398
|
+
timestamp: current.report.timestamp,
|
|
399
|
+
baselineName: baseline.baselineName,
|
|
400
|
+
baseUrl
|
|
401
|
+
},
|
|
402
|
+
baselineSummary: {
|
|
403
|
+
overallVerdict: baseline.overallVerdict,
|
|
404
|
+
createdAt: baseline.createdAt
|
|
405
|
+
},
|
|
406
|
+
currentSummary: {
|
|
407
|
+
overallVerdict: current.report.summary.overallVerdict
|
|
408
|
+
},
|
|
409
|
+
comparisons,
|
|
410
|
+
flowComparisons: comparisonFlows,
|
|
411
|
+
overallRegressionVerdict
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const reporter = new BaselineCheckReporter();
|
|
415
|
+
const jsonPath = reporter.saveJsonReport(jsonReport, current.runDir);
|
|
416
|
+
const html = reporter.generateHtmlReport(jsonReport);
|
|
417
|
+
const htmlPath = reporter.saveHtmlReport(html, current.runDir);
|
|
418
|
+
|
|
419
|
+
// Optional JUnit XML output
|
|
420
|
+
if (junit) {
|
|
421
|
+
try {
|
|
422
|
+
const xml = generateJunitXml(jsonReport);
|
|
423
|
+
const dir = path.dirname(junit);
|
|
424
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
425
|
+
fs.writeFileSync(junit, xml, 'utf8');
|
|
426
|
+
} catch (e) {
|
|
427
|
+
console.error(`Failed to write JUnit XML: ${e.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Console UX
|
|
432
|
+
console.log(`\n🧮 Baseline Check: ${overallRegressionVerdict}`);
|
|
433
|
+
const regList = [
|
|
434
|
+
...comparisons.filter(c => c.regressionType !== 'NO_REGRESSION'),
|
|
435
|
+
...comparisonFlows.filter(c => c.regressionType !== 'NO_REGRESSION')
|
|
436
|
+
];
|
|
437
|
+
if (regList.length > 0) {
|
|
438
|
+
for (const r of regList) {
|
|
439
|
+
const reasons = r.regressionReasons.slice(0, 2).join('; ');
|
|
440
|
+
const label = r.attemptId || r.flowId;
|
|
441
|
+
console.log(` - ${label}: ${r.regressionType} (${reasons})`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
console.log(`Report: ${htmlPath}`);
|
|
445
|
+
|
|
446
|
+
let exitCode = 0;
|
|
447
|
+
if (overallRegressionVerdict === 'REGRESSION_FAILURE') exitCode = 4;
|
|
448
|
+
else if (overallRegressionVerdict === 'REGRESSION_FRICTION') exitCode = 3;
|
|
449
|
+
else exitCode = 0;
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
exitCode,
|
|
453
|
+
runDir: current.runDir,
|
|
454
|
+
reportJsonPath: jsonPath,
|
|
455
|
+
reportHtmlPath: htmlPath,
|
|
456
|
+
junitPath: junit || null,
|
|
457
|
+
overallRegressionVerdict,
|
|
458
|
+
comparisons,
|
|
459
|
+
flowComparisons: comparisonFlows
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = {
|
|
464
|
+
saveBaseline,
|
|
465
|
+
checkBaseline,
|
|
466
|
+
buildBaselineSnapshot,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Helpers: JUnit XML generation
|
|
470
|
+
function xmlEscape(s) {
|
|
471
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function generateJunitXml(report) {
|
|
475
|
+
const flowCases = report.flowComparisons || [];
|
|
476
|
+
const attemptCases = report.comparisons || [];
|
|
477
|
+
const allCases = [...attemptCases, ...flowCases];
|
|
478
|
+
const tests = allCases.length;
|
|
479
|
+
const failures = allCases.filter(c => c.regressionType !== 'NO_REGRESSION').length;
|
|
480
|
+
const props = `\n <property name="overallRegressionVerdict" value="${xmlEscape(report.overallRegressionVerdict)}"/>\n <property name="baselineVerdict" value="${xmlEscape(report.baselineSummary.overallVerdict)}"/>\n <property name="currentVerdict" value="${xmlEscape(report.currentSummary.overallVerdict)}"/>`;
|
|
481
|
+
const header = `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="odavl-guardian-regression" tests="${tests}" failures="${failures}">\n <properties>${props}\n </properties>`;
|
|
482
|
+
const cases = allCases.map(c => {
|
|
483
|
+
const name = xmlEscape(c.attemptId || c.flowId);
|
|
484
|
+
if (c.regressionType === 'NO_REGRESSION') {
|
|
485
|
+
return `\n <testcase name="${name}"/>`;
|
|
486
|
+
}
|
|
487
|
+
const msg = `${c.regressionType}: ${c.regressionReasons.join('; ')}`;
|
|
488
|
+
return `\n <testcase name="${name}">\n <failure message="${xmlEscape(msg)}"/>\n </testcase>`;
|
|
489
|
+
}).join('');
|
|
490
|
+
const footer = `\n</testsuite>\n`;
|
|
491
|
+
return header + cases + footer;
|
|
492
|
+
}
|