@odavl/guardian 0.2.0 → 1.0.0

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 (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Journey Scanner - MVP Human Journey Execution Engine
3
+ *
4
+ * Executes deterministic, human-like journeys through a website
5
+ * with clear evidence capture and failure classification.
6
+ */
7
+
8
+ const { GuardianBrowser } = require('./browser');
9
+ const { buildStabilityReport, classifyErrorType } = require('./stability-scorer');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ class JourneyScanner {
14
+ constructor(options = {}) {
15
+ this.options = {
16
+ timeout: options.timeout || 20000,
17
+ headless: options.headless !== false,
18
+ maxRetries: options.maxRetries || 2,
19
+ screenshotDir: options.screenshotDir,
20
+ ...options
21
+ };
22
+ this.browser = null;
23
+ this.evidence = [];
24
+ this.executedSteps = [];
25
+ this.failedSteps = [];
26
+ }
27
+
28
+ /**
29
+ * Execute a journey against a URL
30
+ */
31
+ async scan(baseUrl, journeyDefinition) {
32
+ try {
33
+ this.browser = new GuardianBrowser();
34
+ await this.browser.launch(this.options.timeout, {
35
+ headless: this.options.headless
36
+ });
37
+
38
+ // Safety timeout for entire scan (non-throwing)
39
+ let scanCompleted = false;
40
+ this._scanTimeoutTriggered = false;
41
+ const scanTimeout = setTimeout(() => {
42
+ if (!scanCompleted) {
43
+ this._scanTimeoutTriggered = true;
44
+ }
45
+ }, this.options.timeout * 5); // Allow multiple steps
46
+
47
+ try {
48
+ const result = {
49
+ url: baseUrl,
50
+ journey: journeyDefinition.name,
51
+ startedAt: new Date().toISOString(),
52
+ executedSteps: [],
53
+ failedSteps: [],
54
+ evidence: [],
55
+ finalDecision: null,
56
+ errorClassification: null,
57
+ goal: { goalReached: false, goalDescription: '' }
58
+ };
59
+
60
+ // Execute journey steps
61
+ for (const step of journeyDefinition.steps) {
62
+ const stepResult = await this._executeStep(step, baseUrl);
63
+ result.executedSteps.push(stepResult);
64
+
65
+ if (!stepResult.success) {
66
+ result.failedSteps.push(stepResult);
67
+ }
68
+ }
69
+
70
+ // Evaluate human goal based on journey preset
71
+ const goalEval = await this._evaluateHumanGoal(journeyDefinition?.preset || 'saas');
72
+ result.goal = goalEval;
73
+
74
+ // Mark timeout classification if triggered
75
+ if (this._scanTimeoutTriggered) {
76
+ result.errorClassification = { type: 'SITE_UNREACHABLE', reason: 'Scan timeout exceeded' };
77
+ }
78
+
79
+ result.endedAt = new Date().toISOString();
80
+ result.evidence = this.evidence;
81
+
82
+ // Classify and decide
83
+ const classification = this._classifyErrors(result);
84
+ result.errorClassification = classification;
85
+ result.finalDecision = this._decideOutcome(result);
86
+
87
+ // Add stability scoring
88
+ const stabilityReport = buildStabilityReport(result);
89
+ result.stability = stabilityReport;
90
+
91
+ scanCompleted = true;
92
+ clearTimeout(scanTimeout);
93
+ return result;
94
+ } catch (err) {
95
+ scanCompleted = true;
96
+ clearTimeout(scanTimeout);
97
+ // Return structured failure instead of throwing
98
+ return {
99
+ url: baseUrl,
100
+ journey: journeyDefinition.name,
101
+ startedAt: new Date().toISOString(),
102
+ endedAt: new Date().toISOString(),
103
+ executedSteps: this.executedSteps,
104
+ failedSteps: this.failedSteps,
105
+ evidence: this.evidence,
106
+ finalDecision: 'DO_NOT_LAUNCH',
107
+ errorClassification: {
108
+ type: 'SITE_UNREACHABLE',
109
+ reason: err.message
110
+ },
111
+ fatalError: err.message
112
+ };
113
+ }
114
+ } catch (err) {
115
+ // Site unreachable or fatal error
116
+ return {
117
+ url: baseUrl,
118
+ journey: journeyDefinition.name,
119
+ startedAt: new Date().toISOString(),
120
+ endedAt: new Date().toISOString(),
121
+ executedSteps: this.executedSteps,
122
+ failedSteps: this.failedSteps,
123
+ evidence: this.evidence,
124
+ finalDecision: 'DO_NOT_LAUNCH',
125
+ errorClassification: {
126
+ type: 'SITE_UNREACHABLE',
127
+ reason: err.message
128
+ },
129
+ fatalError: err.message
130
+ };
131
+ } finally {
132
+ if (this.browser) {
133
+ await this._cleanup();
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Execute a single journey step with smarter retry policy
140
+ */
141
+ async _executeStep(step, baseUrl) {
142
+ let lastError = null;
143
+ let isTransient = true;
144
+ let failureCount = 0;
145
+
146
+ for (let attempt = 0; attempt <= this.options.maxRetries; attempt++) {
147
+ try {
148
+ const stepResult = await this._performAction(step, baseUrl);
149
+
150
+ if (stepResult.success) {
151
+ this.executedSteps.push(step.id);
152
+ this._captureEvidence(stepResult);
153
+ return {
154
+ id: step.id,
155
+ name: step.name,
156
+ action: step.action,
157
+ success: true,
158
+ url: stepResult.url,
159
+ pageTitle: stepResult.pageTitle,
160
+ finalUrl: stepResult.finalUrl,
161
+ evidence: stepResult.evidence,
162
+ attemptNumber: attempt + 1,
163
+ failureCount
164
+ };
165
+ }
166
+ lastError = stepResult.error;
167
+ failureCount++;
168
+ } catch (err) {
169
+ lastError = err.message;
170
+ failureCount++;
171
+ }
172
+
173
+ // Check if error is transient before retrying
174
+ if (attempt < this.options.maxRetries) {
175
+ const errorClassification = classifyErrorType(lastError);
176
+ isTransient = errorClassification.isTransient;
177
+
178
+ // Only retry on transient errors
179
+ if (!isTransient && attempt > 0) {
180
+ break; // Stop retrying deterministic failures
181
+ }
182
+
183
+ // Wait before retry
184
+ await new Promise(r => setTimeout(r, 500 + (attempt * 200)));
185
+ }
186
+ }
187
+
188
+ // All retries exhausted
189
+ this.failedSteps.push(step.id);
190
+ return {
191
+ id: step.id,
192
+ name: step.name,
193
+ action: step.action,
194
+ success: false,
195
+ error: lastError,
196
+ attemptNumber: this.options.maxRetries + 1,
197
+ failureCount,
198
+ isTransientFailure: isTransient
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Perform a single action (navigate, click, etc)
204
+ */
205
+ async _performAction(step, baseUrl) {
206
+ const { action, target, expectedIndicator } = step;
207
+
208
+ if (action === 'navigate') {
209
+ const url = target.startsWith('http') ? target : new URL(target, baseUrl).href;
210
+ try {
211
+ // Wait for page ready: DOMContentLoaded + conservative network idle heuristic
212
+ const response = await this.browser.page.goto(url, {
213
+ waitUntil: 'domcontentloaded',
214
+ timeout: this.options.timeout
215
+ });
216
+
217
+ if (!response) {
218
+ return {
219
+ success: false,
220
+ error: `Navigation to ${url} failed: no response`
221
+ };
222
+ }
223
+
224
+ // Wait for page to settle (network idle heuristic)
225
+ await this._waitForPageReady();
226
+
227
+ // Verify we landed on expected page
228
+ const currentUrl = this.browser.page.url();
229
+ const pageTitle = await this.browser.page.title();
230
+ const heading = await this._getMainHeading();
231
+
232
+ // Take screenshot
233
+ const screenshot = await this._takeScreenshot(`navigate-${step.id}`);
234
+
235
+ return {
236
+ success: true,
237
+ url: currentUrl,
238
+ finalUrl: currentUrl,
239
+ pageTitle,
240
+ mainHeadingText: heading,
241
+ evidence: { screenshot }
242
+ };
243
+ } catch (err) {
244
+ const screenshot = await this._takeScreenshot(`navigate-failed-${step.id}`);
245
+ return {
246
+ success: false,
247
+ error: `Navigation to ${url} failed: ${err.message}`,
248
+ evidence: { screenshot }
249
+ };
250
+ }
251
+ }
252
+
253
+ if (action === 'find_cta') {
254
+ // Find a primary CTA element
255
+ const cta = await this._findCTA();
256
+
257
+ if (!cta) {
258
+ return {
259
+ success: false,
260
+ error: 'No CTA found matching heuristics'
261
+ };
262
+ }
263
+
264
+ return {
265
+ success: true,
266
+ url: this.browser.page.url(),
267
+ cta: cta.text,
268
+ evidence: { ctaFound: cta.text }
269
+ };
270
+ }
271
+
272
+ if (action === 'click') {
273
+ const selector = target;
274
+ try {
275
+ // Check if element is visible and clickable
276
+ const element = this.browser.page.locator(selector);
277
+ const count = await element.count();
278
+
279
+ if (count === 0) {
280
+ const screenshot = await this._takeScreenshot(`click-failed-${step.id}`);
281
+ return {
282
+ success: false,
283
+ error: `Element not found: ${selector}`,
284
+ evidence: { screenshot }
285
+ };
286
+ }
287
+
288
+ const isVisible = await element.first().isVisible();
289
+ if (!isVisible) {
290
+ const screenshot = await this._takeScreenshot(`click-failed-${step.id}`);
291
+ return {
292
+ success: false,
293
+ error: `Element not visible: ${selector}`,
294
+ evidence: { screenshot }
295
+ };
296
+ }
297
+
298
+ // Click and wait for navigation or content change
299
+ const initialUrl = this.browser.page.url();
300
+ await Promise.race([
301
+ element.first().click(),
302
+ new Promise(r => setTimeout(r, 1000))
303
+ ]);
304
+
305
+ // Wait for page to settle
306
+ await this._waitForPageReady();
307
+
308
+ const finalUrl = this.browser.page.url();
309
+ const navigationOccurred = initialUrl !== finalUrl;
310
+ const pageTitle = await this.browser.page.title();
311
+ const heading = await this._getMainHeading();
312
+
313
+ const screenshot = await this._takeScreenshot(`click-${step.id}`);
314
+
315
+ return {
316
+ success: true,
317
+ url: finalUrl,
318
+ finalUrl,
319
+ pageTitle,
320
+ mainHeadingText: heading,
321
+ navigationOccurred,
322
+ evidence: { screenshot, clicked: selector }
323
+ };
324
+ } catch (err) {
325
+ const screenshot = await this._takeScreenshot(`click-failed-${step.id}`);
326
+ return {
327
+ success: false,
328
+ error: `Click failed: ${err.message}`,
329
+ evidence: { screenshot }
330
+ };
331
+ }
332
+ }
333
+
334
+ return {
335
+ success: false,
336
+ error: `Unknown action: ${action}`
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Find a primary CTA using heuristics
342
+ */
343
+ async _findCTA() {
344
+ try {
345
+ const ctas = await this.browser.page.evaluate(() => {
346
+ const keywords = [
347
+ 'sign up', 'signup', 'get started', 'start', 'register',
348
+ 'pricing', 'try', 'buy', 'demo', 'contact us', 'contact'
349
+ ];
350
+
351
+ const elements = Array.from(document.querySelectorAll('a, button'));
352
+
353
+ for (const el of elements) {
354
+ const text = el.innerText?.trim().toLowerCase() || '';
355
+ if (!text) continue;
356
+
357
+ if (keywords.some(kw => text.includes(kw))) {
358
+ return {
359
+ text: el.innerText.trim(),
360
+ href: el.href || el.getAttribute('onclick') || null,
361
+ isButton: el.tagName === 'BUTTON',
362
+ isLink: el.tagName === 'A'
363
+ };
364
+ }
365
+ }
366
+ return null;
367
+ });
368
+
369
+ return ctas;
370
+ } catch (err) {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Wait for page to be ready (DOMContentLoaded + network idle heuristic)
377
+ */
378
+ async _waitForPageReady() {
379
+ try {
380
+ // Wait for a conservative network idle: no new requests for 300ms
381
+ await this.browser.page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {
382
+ // If networkidle times out, that's okay; just continue
383
+ });
384
+ } catch (err) {
385
+ // Ignore timeout, just move on
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Extract main heading text from page
391
+ */
392
+ async _getMainHeading() {
393
+ try {
394
+ const heading = await this.browser.page.evaluate(() => {
395
+ const h1 = document.querySelector('h1');
396
+ if (h1) return h1.innerText.trim();
397
+
398
+ const h2 = document.querySelector('h2');
399
+ if (h2) return h2.innerText.trim();
400
+
401
+ return null;
402
+ });
403
+ return heading || null;
404
+ } catch (err) {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Classify errors into buckets
411
+ */
412
+ _classifyErrors(result) {
413
+ if (result.executedSteps.length === 0) {
414
+ return {
415
+ type: 'SITE_UNREACHABLE',
416
+ reason: 'Could not load initial page'
417
+ };
418
+ }
419
+
420
+ if (result.failedSteps.length === 0) {
421
+ return {
422
+ type: 'NO_ERRORS',
423
+ reason: 'All steps completed successfully'
424
+ };
425
+ }
426
+
427
+ // Analyze failures
428
+ const hasNavigationFailure = result.failedSteps.some(fs => {
429
+ const step = result.executedSteps.find(s => s.id === fs);
430
+ return step && step.action === 'navigate';
431
+ });
432
+
433
+ const hasCTAFailure = result.failedSteps.some(fs => {
434
+ const step = result.executedSteps.find(s => s.id === fs);
435
+ return step && step.action === 'find_cta';
436
+ });
437
+
438
+ if (hasNavigationFailure) {
439
+ return {
440
+ type: 'NAVIGATION_BLOCKED',
441
+ reason: 'User cannot navigate to critical pages'
442
+ };
443
+ }
444
+
445
+ if (hasCTAFailure) {
446
+ return {
447
+ type: 'CTA_NOT_FOUND',
448
+ reason: 'Cannot find key conversion elements'
449
+ };
450
+ }
451
+
452
+ return {
453
+ type: 'ELEMENT_NOT_FOUND',
454
+ reason: 'Some interactive elements are broken'
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Decide SAFE/RISK/DO_NOT_LAUNCH
460
+ */
461
+ _decideOutcome(result) {
462
+ const executedCount = result.executedSteps.length;
463
+ const failedCount = result.failedSteps.length;
464
+ const goalKnown = result.goal && typeof result.goal.goalReached === 'boolean';
465
+ const goalReached = goalKnown ? !!result.goal.goalReached : true;
466
+
467
+ // Total failure
468
+ if (executedCount > 0 && failedCount === executedCount) {
469
+ return 'DO_NOT_LAUNCH';
470
+ }
471
+
472
+ // No errors at all → require goal reached for SAFE
473
+ if (failedCount === 0 && executedCount > 0) {
474
+ return goalReached ? 'SAFE' : 'RISK';
475
+ }
476
+
477
+ // Partial failure = RISK
478
+ if (failedCount > 0 && executedCount > failedCount) {
479
+ return 'RISK';
480
+ }
481
+
482
+ // Unclear state
483
+ return 'DO_NOT_LAUNCH';
484
+ }
485
+
486
+ /**
487
+ * Capture evidence from a step
488
+ */
489
+ _captureEvidence(stepResult) {
490
+ this.evidence.push({
491
+ timestamp: new Date().toISOString(),
492
+ step: stepResult,
493
+ screenshot: stepResult.evidence?.screenshot
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Take a screenshot if directory configured
499
+ */
500
+ async _takeScreenshot(name) {
501
+ if (!this.options.screenshotDir) return null;
502
+
503
+ try {
504
+ const screenshotPath = path.join(
505
+ this.options.screenshotDir,
506
+ `${name}-${Date.now()}.png`
507
+ );
508
+
509
+ if (!fs.existsSync(this.options.screenshotDir)) {
510
+ fs.mkdirSync(this.options.screenshotDir, { recursive: true });
511
+ }
512
+
513
+ await this.browser.page.screenshot({ path: screenshotPath });
514
+ return screenshotPath;
515
+ } catch (err) {
516
+ // Screenshot failed but don't fail the whole journey
517
+ return null;
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Cleanup
523
+ */
524
+ async _cleanup() {
525
+ try {
526
+ if (this.browser?.context) {
527
+ await this.browser.context.close();
528
+ }
529
+ if (this.browser?.browser) {
530
+ await this.browser.browser.close();
531
+ }
532
+ } catch (err) {
533
+ // Ignore cleanup errors
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Human goal validation
539
+ */
540
+ async _evaluateHumanGoal(preset) {
541
+ try {
542
+ const page = this.browser.page;
543
+ const url = page.url().toLowerCase();
544
+ const ctx = await page.evaluate(() => {
545
+ const text = (document.body.innerText || '').toLowerCase();
546
+ const hasEmail = !!document.querySelector('input[type="email"], input[name*="email" i]');
547
+ const hasForm = !!document.querySelector('form');
548
+ const hasContact = text.includes('contact');
549
+ const hasCheckoutKW = /checkout|cart|add to cart|purchase|order/.test(text);
550
+ const hasSignupKW = /sign up|signup|subscribe|get started|register/.test(text);
551
+ return { text, hasEmail, hasForm, hasContact, hasCheckoutKW, hasSignupKW };
552
+ });
553
+
554
+ if (preset === 'saas') {
555
+ const reached = url.includes('/signup') || url.includes('/account/signup') || url.includes('/pricing')
556
+ || (ctx.hasForm && ctx.hasEmail) || ctx.hasSignupKW;
557
+ return {
558
+ goalReached: !!reached,
559
+ goalDescription: 'Signup or pricing accessible with visible form or CTA'
560
+ };
561
+ }
562
+
563
+ if (preset === 'shop') {
564
+ const reached = url.includes('/cart') || url.includes('/checkout') || ctx.hasCheckoutKW;
565
+ return {
566
+ goalReached: !!reached,
567
+ goalDescription: 'Cart or checkout reachable'
568
+ };
569
+ }
570
+
571
+ // landing
572
+ const reached = (ctx.hasForm && (ctx.hasEmail || /name|message/.test(ctx.text))) || ctx.hasContact;
573
+ return {
574
+ goalReached: !!reached,
575
+ goalDescription: 'Contact form or section visible'
576
+ };
577
+ } catch (e) {
578
+ return { goalReached: false, goalDescription: 'Goal evaluation unavailable' };
579
+ }
580
+ }
581
+ }
582
+
583
+ module.exports = { JourneyScanner };
@@ -11,6 +11,7 @@
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
+ const { formatVerdictStatus, formatConfidence, formatVerdictWhy } = require('./text-formatters');
14
15
 
15
16
  /**
16
17
  * Escape XML special characters
@@ -173,6 +174,16 @@ function generateJunitXml(snapshot, baseUrl = '') {
173
174
  xml += ` <property name="runId" value="${escapeXml(runId)}" />\n`;
174
175
  xml += ` <property name="url" value="${escapeXml(url)}" />\n`;
175
176
  xml += ` <property name="createdAt" value="${escapeXml(createdAt)}" />\n`;
177
+ // Verdict properties
178
+ const v = snapshot.verdict || snapshot.meta?.verdict || null;
179
+ if (v) {
180
+ const cf = v.confidence || {};
181
+ const whyShort = (v.why || '').slice(0, 200);
182
+ xml += ` <property name="verdict" value="${escapeXml(v.verdict)}" />\n`;
183
+ if (typeof cf.score === 'number') xml += ` <property name="confidenceScore" value="${escapeXml(String(cf.score))}" />\n`;
184
+ if (cf.level) xml += ` <property name="confidenceLevel" value="${escapeXml(cf.level)}" />\n`;
185
+ if (whyShort) xml += ` <property name="verdictWhy" value="${escapeXml(whyShort)}" />\n`;
186
+ }
176
187
  xml += ` </properties>\n`;
177
188
 
178
189
  // Testcases
@@ -184,11 +195,17 @@ function generateJunitXml(snapshot, baseUrl = '') {
184
195
  xml += `URL: ${escapeXml(url)}\n`;
185
196
  xml += `Run ID: ${escapeXml(runId)}\n`;
186
197
  xml += `Created: ${escapeXml(createdAt)}\n\n`;
198
+ if (v) {
199
+ xml += `Verdict: ${escapeXml(formatVerdictStatus(v))}\n`;
200
+ xml += `Confidence: ${escapeXml(formatConfidence(v))}\n`;
201
+ const why = formatVerdictWhy(v);
202
+ if (why) xml += `Why: ${escapeXml(why)}\n\n`;
203
+ }
187
204
 
188
205
  xml += `Summary:\n`;
189
206
  xml += ` Total Tests: ${totalTests}\n`;
190
207
  xml += ` Failures: ${totalFailures}\n`;
191
- xml += ` Skipped: ${totalSkipped}\n`;
208
+ xml += ` Not Executed (JUnit skipped): ${totalSkipped}\n`;
192
209
 
193
210
  if (marketImpact.countsBySeverity) {
194
211
  xml += `\nMarket Impact:\n`;