@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/bin/guardian.js +690 -0
  5. package/flows/example-login-flow.json +36 -0
  6. package/flows/example-signup-flow.json +44 -0
  7. package/guardian-contract-v1.md +149 -0
  8. package/guardian.config.json +54 -0
  9. package/guardian.policy.json +12 -0
  10. package/guardian.profile.docs.yaml +18 -0
  11. package/guardian.profile.ecommerce.yaml +17 -0
  12. package/guardian.profile.marketing.yaml +18 -0
  13. package/guardian.profile.saas.yaml +21 -0
  14. package/package.json +69 -0
  15. package/policies/enterprise.json +12 -0
  16. package/policies/saas.json +12 -0
  17. package/policies/startup.json +12 -0
  18. package/src/guardian/attempt-engine.js +454 -0
  19. package/src/guardian/attempt-registry.js +227 -0
  20. package/src/guardian/attempt-reporter.js +507 -0
  21. package/src/guardian/attempt.js +227 -0
  22. package/src/guardian/auto-attempt-builder.js +283 -0
  23. package/src/guardian/baseline-reporter.js +143 -0
  24. package/src/guardian/baseline-storage.js +285 -0
  25. package/src/guardian/baseline.js +492 -0
  26. package/src/guardian/behavioral-signals.js +261 -0
  27. package/src/guardian/breakage-intelligence.js +223 -0
  28. package/src/guardian/browser.js +92 -0
  29. package/src/guardian/cli-summary.js +141 -0
  30. package/src/guardian/crawler.js +142 -0
  31. package/src/guardian/discovery-engine.js +661 -0
  32. package/src/guardian/enhanced-html-reporter.js +305 -0
  33. package/src/guardian/failure-taxonomy.js +169 -0
  34. package/src/guardian/flow-executor.js +374 -0
  35. package/src/guardian/flow-registry.js +67 -0
  36. package/src/guardian/html-reporter.js +414 -0
  37. package/src/guardian/index.js +218 -0
  38. package/src/guardian/init-command.js +139 -0
  39. package/src/guardian/junit-reporter.js +264 -0
  40. package/src/guardian/market-criticality.js +335 -0
  41. package/src/guardian/market-reporter.js +305 -0
  42. package/src/guardian/network-trace.js +178 -0
  43. package/src/guardian/policy.js +357 -0
  44. package/src/guardian/preset-loader.js +148 -0
  45. package/src/guardian/reality.js +547 -0
  46. package/src/guardian/reporter.js +181 -0
  47. package/src/guardian/root-cause-analysis.js +171 -0
  48. package/src/guardian/safety.js +248 -0
  49. package/src/guardian/scan-presets.js +60 -0
  50. package/src/guardian/screenshot.js +152 -0
  51. package/src/guardian/sitemap.js +225 -0
  52. package/src/guardian/snapshot-schema.js +266 -0
  53. package/src/guardian/snapshot.js +327 -0
  54. package/src/guardian/validators.js +323 -0
  55. package/src/guardian/visual-diff.js +247 -0
  56. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }