@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,335 @@
1
+ /**
2
+ * Market Criticality Engine - Phase 3
3
+ *
4
+ * Deterministic scoring of market risk:
5
+ * - NO AI, NO GUESSING
6
+ * - Pure heuristics based on:
7
+ * - Attempt type
8
+ * - Page context (URL patterns)
9
+ * - Validator outcomes
10
+ * - Frequency
11
+ */
12
+
13
+ /**
14
+ * Severity levels and ranges
15
+ */
16
+ const SEVERITY_LEVELS = {
17
+ INFO: { range: [0, 30], label: 'INFO' },
18
+ WARNING: { range: [31, 70], label: 'WARNING' },
19
+ CRITICAL: { range: [71, 100], label: 'CRITICAL' }
20
+ };
21
+
22
+ /**
23
+ * Risk categories
24
+ */
25
+ const RISK_CATEGORIES = {
26
+ REVENUE: 'REVENUE',
27
+ LEAD: 'LEAD',
28
+ TRUST: 'TRUST/UX',
29
+ UX: 'UX'
30
+ };
31
+
32
+ /**
33
+ * Attempt type to category mapping
34
+ */
35
+ const ATTEMPT_CATEGORIES = {
36
+ contact_form: RISK_CATEGORIES.LEAD,
37
+ newsletter_signup: RISK_CATEGORIES.LEAD,
38
+ language_switch: RISK_CATEGORIES.TRUST,
39
+ signup: RISK_CATEGORIES.LEAD,
40
+ login: RISK_CATEGORIES.TRUST,
41
+ checkout: RISK_CATEGORIES.REVENUE,
42
+ payment: RISK_CATEGORIES.REVENUE,
43
+ auth: RISK_CATEGORIES.TRUST,
44
+ search: RISK_CATEGORIES.UX
45
+ };
46
+
47
+ /**
48
+ * URL context matchers
49
+ */
50
+ const URL_CONTEXT_PATTERNS = {
51
+ pricing: /pricing|price|plans|payment-method/i,
52
+ checkout: /checkout|cart|order|purchase/i,
53
+ signup: /signup|register|join|subscribe/i,
54
+ auth: /login|signin|logout|password/i,
55
+ account: /account|profile|settings|dashboard/i
56
+ };
57
+
58
+ /**
59
+ * Calculate impact score (0-100) for a single failure
60
+ *
61
+ * @param {Object} input
62
+ * @param {string} input.attemptId - attempt identifier
63
+ * @param {string} input.category - REVENUE|LEAD|TRUST|UX
64
+ * @param {string} input.validatorStatus - FAIL|WARN|PASS
65
+ * @param {string} input.pageUrl - current page URL
66
+ * @param {number} input.frequency - how many runs this appeared (default 1)
67
+ * @returns {number} score 0-100
68
+ */
69
+ function calculateImpactScore(input) {
70
+ const {
71
+ attemptId = '',
72
+ category = RISK_CATEGORIES.UX,
73
+ validatorStatus = 'WARN',
74
+ pageUrl = '',
75
+ frequency = 1
76
+ } = input;
77
+
78
+ let score = 0;
79
+
80
+ // Base score from category importance
81
+ const categoryScores = {
82
+ [RISK_CATEGORIES.REVENUE]: 80, // Highest: money
83
+ [RISK_CATEGORIES.LEAD]: 60, // Medium-high: customer acquisition
84
+ [RISK_CATEGORIES.TRUST]: 50, // Medium: user trust
85
+ [RISK_CATEGORIES.UX]: 30 // Lower: convenience
86
+ };
87
+ score += categoryScores[category] || 30;
88
+
89
+ // Validator outcome multiplier
90
+ if (validatorStatus === 'FAIL') {
91
+ score += 15; // Explicit failure
92
+ } else if (validatorStatus === 'WARN') {
93
+ score += 8; // Warning flag
94
+ }
95
+
96
+ // URL context boost: if on critical pages, increase weight
97
+ const urlContext = detectUrlContext(pageUrl);
98
+ if (urlContext === 'checkout' && category === RISK_CATEGORIES.REVENUE) {
99
+ score += 10;
100
+ } else if (urlContext === 'signup' && category === RISK_CATEGORIES.LEAD) {
101
+ score += 8;
102
+ } else if (urlContext === 'auth' && category === RISK_CATEGORIES.TRUST) {
103
+ score += 8;
104
+ }
105
+
106
+ // Frequency multiplier: if this appears multiple times, escalate
107
+ // But cap at reasonable level
108
+ const frequencyMultiplier = Math.min(frequency, 3);
109
+ score = Math.round(score * (1 + (frequencyMultiplier - 1) * 0.15));
110
+
111
+ // Cap at 100
112
+ return Math.min(100, Math.max(0, score));
113
+ }
114
+
115
+ /**
116
+ * Determine severity level from impact score
117
+ * @param {number} impactScore - 0-100
118
+ * @returns {string} INFO|WARNING|CRITICAL
119
+ */
120
+ function getSeverityFromScore(impactScore) {
121
+ if (impactScore >= SEVERITY_LEVELS.CRITICAL.range[0]) {
122
+ return SEVERITY_LEVELS.CRITICAL.label;
123
+ } else if (impactScore >= SEVERITY_LEVELS.WARNING.range[0]) {
124
+ return SEVERITY_LEVELS.WARNING.label;
125
+ }
126
+ return SEVERITY_LEVELS.INFO.label;
127
+ }
128
+
129
+ /**
130
+ * Detect URL context pattern
131
+ * @param {string} url
132
+ * @returns {string|null} context type or null
133
+ */
134
+ function detectUrlContext(url) {
135
+ for (const [context, pattern] of Object.entries(URL_CONTEXT_PATTERNS)) {
136
+ if (pattern.test(url)) {
137
+ return context;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Analyze all attempts and extract market impact risks
145
+ *
146
+ * @param {Array} attempts - array of attempt execution results
147
+ * @param {string} baseUrl - base URL being tested
148
+ * @param {Object} frequencyMap - map of attemptId -> count (optional)
149
+ * @returns {Object} marketImpactSummary with risks and counts
150
+ */
151
+ function analyzeMarketImpact(attempts, baseUrl, frequencyMap = {}) {
152
+ const risks = [];
153
+ const countsBySeverity = {
154
+ CRITICAL: 0,
155
+ WARNING: 0,
156
+ INFO: 0
157
+ };
158
+
159
+ for (const attempt of attempts) {
160
+ // Skip successful attempts
161
+ if (attempt.outcome === 'SUCCESS') {
162
+ continue;
163
+ }
164
+
165
+ const attemptId = attempt.attemptId;
166
+ const attemptCategory = ATTEMPT_CATEGORIES[attemptId] || RISK_CATEGORIES.UX;
167
+ const frequency = frequencyMap[attemptId] || 1;
168
+
169
+ // Check if has validator failures
170
+ if (attempt.validators && Array.isArray(attempt.validators)) {
171
+ for (const validator of attempt.validators) {
172
+ if (validator.status === 'FAIL' || validator.status === 'WARN') {
173
+ const impactScore = calculateImpactScore({
174
+ attemptId,
175
+ category: attemptCategory,
176
+ validatorStatus: validator.status,
177
+ pageUrl: attempt.pageUrl || baseUrl,
178
+ frequency
179
+ });
180
+
181
+ const severity = getSeverityFromScore(impactScore);
182
+ countsBySeverity[severity]++;
183
+
184
+ risks.push({
185
+ attemptId,
186
+ validatorId: validator.id,
187
+ validatorType: validator.type,
188
+ category: attemptCategory,
189
+ severity,
190
+ impactScore,
191
+ humanReadableReason: generateRiskDescription(
192
+ attemptId,
193
+ validator.id,
194
+ validator.message,
195
+ attemptCategory,
196
+ severity
197
+ )
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ // Check friction signals
204
+ if (attempt.friction && attempt.friction.signals && Array.isArray(attempt.friction.signals)) {
205
+ for (const signal of attempt.friction.signals) {
206
+ const impactScore = calculateImpactScore({
207
+ attemptId,
208
+ category: attemptCategory,
209
+ validatorStatus: 'WARN',
210
+ pageUrl: attempt.pageUrl || baseUrl,
211
+ frequency
212
+ });
213
+
214
+ const severity = getSeverityFromScore(impactScore);
215
+ countsBySeverity[severity]++;
216
+
217
+ risks.push({
218
+ attemptId,
219
+ validatorId: signal.id,
220
+ validatorType: 'friction',
221
+ category: attemptCategory,
222
+ severity,
223
+ impactScore,
224
+ humanReadableReason: generateRiskDescription(
225
+ attemptId,
226
+ signal.id,
227
+ signal.description,
228
+ attemptCategory,
229
+ severity
230
+ )
231
+ });
232
+ }
233
+ }
234
+
235
+ // Check outcome-based risk
236
+ if (attempt.outcome === 'FAILURE') {
237
+ const impactScore = calculateImpactScore({
238
+ attemptId,
239
+ category: attemptCategory,
240
+ validatorStatus: 'FAIL',
241
+ pageUrl: attempt.pageUrl || baseUrl,
242
+ frequency
243
+ });
244
+
245
+ const severity = getSeverityFromScore(impactScore);
246
+ countsBySeverity[severity]++;
247
+
248
+ risks.push({
249
+ attemptId,
250
+ validatorId: `outcome_${attemptId}`,
251
+ validatorType: 'outcome',
252
+ category: attemptCategory,
253
+ severity,
254
+ impactScore,
255
+ humanReadableReason: `${attemptId} attempt FAILED - user could not complete goal`
256
+ });
257
+ }
258
+ }
259
+
260
+ // Sort by impact score (highest first)
261
+ risks.sort((a, b) => b.impactScore - a.impactScore);
262
+
263
+ // Determine highest severity
264
+ let highestSeverity = 'INFO';
265
+ if (countsBySeverity.CRITICAL > 0) {
266
+ highestSeverity = 'CRITICAL';
267
+ } else if (countsBySeverity.WARNING > 0) {
268
+ highestSeverity = 'WARNING';
269
+ }
270
+
271
+ return {
272
+ highestSeverity,
273
+ topRisks: risks.slice(0, 10), // Top 10 risks
274
+ countsBySeverity,
275
+ totalRiskCount: risks.length,
276
+ allRisks: risks
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Generate human-readable description of a risk
282
+ * @param {string} attemptId
283
+ * @param {string} validatorId
284
+ * @param {string} validatorMessage
285
+ * @param {string} category
286
+ * @param {string} severity
287
+ * @returns {string}
288
+ */
289
+ function generateRiskDescription(attemptId, validatorId, validatorMessage, category, severity) {
290
+ const categoryLabel = category === RISK_CATEGORIES.REVENUE ? '💰 Revenue' :
291
+ category === RISK_CATEGORIES.LEAD ? '👥 Lead Gen' :
292
+ category === RISK_CATEGORIES.TRUST ? '🔒 Trust' :
293
+ '⚙️ UX';
294
+
295
+ return `${categoryLabel}: ${attemptId} - ${validatorMessage}`;
296
+ }
297
+
298
+ /**
299
+ * Determine if severity has escalated between runs
300
+ * Used to decide exit codes
301
+ *
302
+ * @param {string} previousSeverity - INFO|WARNING|CRITICAL
303
+ * @param {string} currentSeverity - INFO|WARNING|CRITICAL
304
+ * @returns {Object} { escalated: boolean, severity: 0|1|2 }
305
+ */
306
+ function determineExitCodeFromEscalation(previousSeverity, currentSeverity) {
307
+ const severityRank = {
308
+ INFO: 0,
309
+ WARNING: 1,
310
+ CRITICAL: 2
311
+ };
312
+
313
+ const prevRank = severityRank[previousSeverity] || 0;
314
+ const currRank = severityRank[currentSeverity] || 0;
315
+
316
+ return {
317
+ escalated: currRank > prevRank,
318
+ severity: currRank,
319
+ previousSeverity,
320
+ currentSeverity
321
+ };
322
+ }
323
+
324
+ module.exports = {
325
+ SEVERITY_LEVELS,
326
+ RISK_CATEGORIES,
327
+ ATTEMPT_CATEGORIES,
328
+ URL_CONTEXT_PATTERNS,
329
+ calculateImpactScore,
330
+ getSeverityFromScore,
331
+ detectUrlContext,
332
+ analyzeMarketImpact,
333
+ generateRiskDescription,
334
+ determineExitCodeFromEscalation
335
+ };
@@ -0,0 +1,305 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { aggregateIntelligence } = require('./breakage-intelligence');
4
+
5
+ class MarketReporter {
6
+ createReport(runMeta) {
7
+ const { runId, baseUrl, attemptsRun, results, flows = [] } = runMeta;
8
+ const timestamp = new Date().toISOString();
9
+
10
+ const summary = this._buildSummary(results);
11
+
12
+ const flowSummary = {
13
+ total: flows.length,
14
+ success: flows.filter(f => f.outcome === 'SUCCESS').length,
15
+ failure: flows.filter(f => f.outcome === 'FAILURE').length
16
+ };
17
+
18
+ // Phase 2: Separate manual and auto-generated attempts
19
+ const manualResults = results.filter(r => r.source !== 'auto-generated');
20
+ const autoResults = results.filter(r => r.source === 'auto-generated');
21
+
22
+ // Phase 4: Breakage intelligence
23
+ const intelligence = aggregateIntelligence(results, flows);
24
+
25
+ return {
26
+ version: '1.0.0',
27
+ runId,
28
+ timestamp,
29
+ baseUrl,
30
+ attemptsRun,
31
+ summary,
32
+ flows,
33
+ flowSummary,
34
+ results,
35
+ manualResults,
36
+ autoResults,
37
+ intelligence
38
+ };
39
+ }
40
+
41
+ saveJsonReport(report, artifactsDir) {
42
+ const reportPath = path.join(artifactsDir, 'market-report.json');
43
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
44
+ return reportPath;
45
+ }
46
+
47
+ generateHtmlReport(report) {
48
+ const { summary, results, runId, baseUrl, attemptsRun, timestamp, manualResults = [], autoResults = [], flows = [], flowSummary = { total: 0, success: 0, failure: 0 }, intelligence = {} } = report;
49
+ const verdictColor = summary.overallVerdict === 'SUCCESS' ? '#10b981'
50
+ : summary.overallVerdict === 'FRICTION' ? '#f59e0b'
51
+ : '#ef4444';
52
+ const verdictEmoji = summary.overallVerdict === 'SUCCESS' ? '🟢' : summary.overallVerdict === 'FRICTION' ? '🟡' : '🔴';
53
+
54
+ const attemptsRows = results.map((result, idx) => {
55
+ const color = result.outcome === 'SUCCESS' ? '#10b981' : result.outcome === 'FRICTION' ? '#f59e0b' : '#ef4444';
56
+ const badge = result.outcome === 'SUCCESS' ? '✅ SUCCESS' : result.outcome === 'FRICTION' ? '⚠️ FRICTION' : '❌ FAILURE';
57
+ const frictionSignals = result.friction && result.friction.signals ? result.friction.signals : [];
58
+ const sourceLabel = result.source === 'auto-generated' ? ' 🤖' : '';
59
+ return `
60
+ <tr>
61
+ <td>${idx + 1}</td>
62
+ <td>${result.attemptId}${sourceLabel}</td>
63
+ <td>${result.attemptName || ''}</td>
64
+ <td><span class="badge" style="background:${color}">${badge}</span></td>
65
+ <td>${result.totalDurationMs || 0} ms</td>
66
+ <td>${frictionSignals.length}</td>
67
+ <td>
68
+ ${result.reportHtmlPath ? `<a href="${path.basename(result.reportHtmlPath)}" target="_blank">HTML</a>` : ''}
69
+ ${result.reportJsonPath ? ` | <a href="${path.basename(result.reportJsonPath)}" target="_blank">JSON</a>` : ''}
70
+ </td>
71
+ </tr>
72
+ `;
73
+ }).join('');
74
+
75
+ const attemptDetails = results.map((result, idx) => {
76
+ const frictionSignals = result.friction && result.friction.signals ? result.friction.signals : [];
77
+ const frictionHtml = frictionSignals.length > 0 ? `
78
+ <div class="friction-block">
79
+ <h4>Friction Signals (${frictionSignals.length})</h4>
80
+ ${frictionSignals.map(signal => {
81
+ const severity = signal.severity || 'medium';
82
+ const severityLabel = severity.toUpperCase();
83
+ return `
84
+ <div class="signal-card severity-${severity}">
85
+ <div class="signal-header">
86
+ <span class="signal-id">${signal.id}</span>
87
+ <span class="signal-severity severity-${severity}">${severityLabel}</span>
88
+ </div>
89
+ <p class="signal-description">${signal.description}</p>
90
+ <div class="signal-metrics">
91
+ <div class="signal-metric"><strong>Metric:</strong> ${signal.metric}</div>
92
+ <div class="signal-metric"><strong>Threshold:</strong> ${signal.threshold}</div>
93
+ <div class="signal-metric"><strong>Observed:</strong> ${signal.observedValue}</div>
94
+ ${signal.affectedStepId ? `<div class="signal-metric"><strong>Affected Step:</strong> ${signal.affectedStepId}</div>` : ''}
95
+ </div>
96
+ </div>
97
+ `;
98
+ }).join('')}
99
+ </div>
100
+ ` : '<p class="no-friction">No friction signals</p>';
101
+
102
+ const stepsList = (result.steps || []).map(step => `<li>${step.id} — ${step.status} (${step.durationMs || 0}ms)</li>`).join('');
103
+
104
+ return `
105
+ <details open>
106
+ <summary>Attempt ${idx + 1}: ${result.attemptId} — ${result.outcome}</summary>
107
+ <div class="attempt-detail">
108
+ <p><strong>Outcome:</strong> ${result.outcome}</p>
109
+ <p><strong>Duration:</strong> ${result.totalDurationMs || 0}ms</p>
110
+ <p><strong>Friction Summary:</strong> ${result.friction && result.friction.summary ? result.friction.summary : 'None'}</p>
111
+ ${frictionHtml}
112
+ <div class="steps-block">
113
+ <h4>Steps</h4>
114
+ <ol>${stepsList}</ol>
115
+ </div>
116
+ </div>
117
+ </details>
118
+ `;
119
+ }).join('');
120
+
121
+ const flowRows = flows.map((flow, idx) => {
122
+ const color = flow.outcome === 'SUCCESS' ? '#10b981' : '#ef4444';
123
+ const badge = flow.outcome === 'SUCCESS' ? '✅ SUCCESS' : '❌ FAILURE';
124
+ return `
125
+ <tr>
126
+ <td>${idx + 1}</td>
127
+ <td>${flow.flowId}</td>
128
+ <td>${flow.flowName || ''}</td>
129
+ <td><span class="badge" style="background:${color}">${badge}</span></td>
130
+ <td>${flow.stepsExecuted || 0}/${flow.stepsTotal || 0}</td>
131
+ <td>${flow.error || ''}</td>
132
+ </tr>
133
+ `;
134
+ }).join('');
135
+
136
+ return `<!DOCTYPE html>
137
+ <html lang="en">
138
+ <head>
139
+ <meta charset="UTF-8">
140
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
141
+ <title>Market Reality Report</title>
142
+ <style>
143
+ * { box-sizing: border-box; }
144
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f6f7fb; color: #1f2937; padding: 20px; }
145
+ .container { max-width: 1200px; margin: 0 auto; }
146
+ .header { background: linear-gradient(135deg, #111827, #1f2937); color: white; padding: 24px; border-radius: 12px; margin-bottom: 24px; }
147
+ .header h1 { margin: 0 0 8px 0; }
148
+ .badge { color: white; padding: 6px 10px; border-radius: 999px; font-size: 0.9em; }
149
+ .verdict { display: inline-flex; align-items: center; gap: 8px; background: ${verdictColor}; color: white; padding: 10px 14px; border-radius: 999px; font-weight: bold; }
150
+ table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
151
+ th, td { padding: 12px 14px; border-bottom: 1px solid #e5e7eb; text-align: left; }
152
+ th { background: #f3f4f6; font-weight: 600; }
153
+ tr:last-child td { border-bottom: none; }
154
+ details { background: white; padding: 14px; border-radius: 10px; margin-top: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.06); }
155
+ summary { cursor: pointer; font-weight: 600; }
156
+ .attempt-detail { margin-top: 10px; }
157
+ .friction-block { margin: 10px 0; }
158
+ .signal-card { background: #fefce8; border: 1px solid #fde047; border-left: 4px solid #f59e0b; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
159
+ .signal-card.severity-low { background: #f0f9ff; border-color: #7dd3fc; border-left-color: #0ea5e9; }
160
+ .signal-card.severity-medium { background: #fefce8; border-color: #fde047; border-left-color: #f59e0b; }
161
+ .signal-card.severity-high { background: #fef2f2; border-color: #fca5a5; border-left-color: #ef4444; }
162
+ .signal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
163
+ .signal-id { font-family: monospace; font-weight: 700; }
164
+ .signal-severity { padding: 3px 8px; border-radius: 4px; color: white; font-size: 0.8em; }
165
+ .signal-severity.severity-low { background: #0ea5e9; }
166
+ .signal-severity.severity-medium { background: #f59e0b; }
167
+ .signal-severity.severity-high { background: #ef4444; }
168
+ .signal-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; font-size: 0.9em; }
169
+ .signal-metric { background: #fff; border: 1px solid #e5e7eb; border-radius: 4px; padding: 6px; }
170
+ .steps-block ol { padding-left: 20px; }
171
+ .no-friction { color: #16a34a; font-weight: 600; }
172
+ .meta { display: flex; gap: 18px; margin-top: 10px; color: #e5e7eb; font-size: 0.95em; }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div class="container">
177
+ <div class="header">
178
+ <h1>Market Reality Report</h1>
179
+ <div class="verdict">${verdictEmoji} ${summary.overallVerdict}</div>
180
+ <div class="meta">
181
+ <div><strong>Run ID:</strong> ${runId}</div>
182
+ <div><strong>Base URL:</strong> ${baseUrl}</div>
183
+ <div><strong>Attempts:</strong> ${attemptsRun.join(', ')}</div>
184
+ <div><strong>Manual:</strong> ${manualResults.length} | <strong>Auto:</strong> ${autoResults.length} 🤖</div>
185
+ <div><strong>Flows:</strong> ${flowSummary.total} (✅ ${flowSummary.success} / ❌ ${flowSummary.failure})</div>
186
+ <div><strong>Timestamp:</strong> ${timestamp}</div>
187
+ </div>
188
+ </div>
189
+
190
+ <table>
191
+ <thead>
192
+ <tr>
193
+ <th>#</th>
194
+ <th>Attempt ID</th>
195
+ <th>Name</th>
196
+ <th>Outcome</th>
197
+ <th>Duration</th>
198
+ <th>Friction Signals</th>
199
+ <th>Reports</th>
200
+ </tr>
201
+ </thead>
202
+ <tbody>
203
+ ${attemptsRows}
204
+ </tbody>
205
+ </table>
206
+
207
+ <div class="details">${attemptDetails}</div>
208
+
209
+ ${flows.length ? `
210
+ <h3 style="margin-top:24px;">Intent Flows</h3>
211
+ <table>
212
+ <thead>
213
+ <tr>
214
+ <th>#</th>
215
+ <th>Flow ID</th>
216
+ <th>Name</th>
217
+ <th>Outcome</th>
218
+ <th>Steps</th>
219
+ <th>Error</th>
220
+ </tr>
221
+ </thead>
222
+ <tbody>
223
+ ${flowRows}
224
+ </tbody>
225
+ </table>
226
+ ` : ''}
227
+
228
+ ${intelligence && intelligence.totalFailures > 0 ? `
229
+ <h3 style="margin-top:24px;">🔍 Breakage Intelligence</h3>
230
+ <div style="background:#fff; padding:16px; border-radius:10px; margin-top:12px;">
231
+ <p><strong>Critical Failures:</strong> ${intelligence.criticalCount} | <strong>Warnings:</strong> ${intelligence.warningCount} | <strong>Info:</strong> ${intelligence.infoCount}</p>
232
+ ${intelligence.escalationSignals.length ? `
233
+ <div style="background:#fef2f2; border:1px solid #fca5a5; padding:10px; border-radius:6px; margin-top:8px;">
234
+ <strong>⚠️ Escalation Signals:</strong>
235
+ <ul>
236
+ ${intelligence.escalationSignals.map(s => `<li>${s}</li>`).join('')}
237
+ </ul>
238
+ </div>
239
+ ` : ''}
240
+ ${intelligence.failures && intelligence.failures.slice(0, 5).map(f => `
241
+ <details style="margin-top:10px;">
242
+ <summary><strong>${f.name}</strong> — ${f.breakType} (${f.severity})</summary>
243
+ <div style="margin-top:8px; padding:8px; background:#f9fafb;">
244
+ <p><strong>Primary Hint:</strong> ${f.primaryHint}</p>
245
+ <p><strong>Why It Matters:</strong></p>
246
+ <ul>${f.whyItMatters.map(w => `<li>${w}</li>`).join('')}</ul>
247
+ <p><strong>Top Actions:</strong></p>
248
+ <ol>${f.topActions.map(a => `<li>${a}</li>`).join('')}</ol>
249
+ ${f.breakType === 'VISUAL' && f.visualDiff ? `
250
+ <div style="margin-top:12px; padding:8px; background:#fef3c7; border:1px solid #fbbf24; border-radius:6px;">
251
+ <p><strong>📊 Visual Regression Details:</strong></p>
252
+ <ul>
253
+ <li><strong>Change Detected:</strong> ${f.visualDiff.hasDiff ? 'YES ⚠️' : 'NO ✅'}</li>
254
+ <li><strong>Diff Magnitude:</strong> ${(f.visualDiff.percentChange || 0).toFixed(1)}%</li>
255
+ ${f.visualDiff.reason ? `<li><strong>Reason:</strong> ${f.visualDiff.reason}</li>` : ''}
256
+ ${f.visualDiff.diffRegions && f.visualDiff.diffRegions.length > 0 ? `<li><strong>Changed Regions:</strong> ${f.visualDiff.diffRegions.join(', ')}</li>` : ''}
257
+ </ul>
258
+ </div>
259
+ ` : ''}
260
+ ${f.behavioralSignals ? `
261
+ <div style="margin-top:12px; padding:8px; background:#e0f2fe; border:1px solid #06b6d4; border-radius:6px;">
262
+ <p><strong>🎯 Behavioral Signals:</strong></p>
263
+ <ul>
264
+ ${f.behavioralSignals.map(sig => `<li><strong>${sig.type}:</strong> ${sig.status === 'VISIBLE' ? '✅' : '❌'} ${sig.description}</li>`).join('')}
265
+ </ul>
266
+ </div>
267
+ ` : ''}
268
+ </div>
269
+ </details>
270
+ `).join('')}
271
+ </div>
272
+ ` : ''}
273
+ </div>
274
+ </body>
275
+ </html>`;
276
+ }
277
+
278
+ saveHtmlReport(html, artifactsDir) {
279
+ const htmlPath = path.join(artifactsDir, 'market-report.html');
280
+ fs.writeFileSync(htmlPath, html, 'utf8');
281
+ return htmlPath;
282
+ }
283
+
284
+ _buildSummary(results) {
285
+ const successCount = results.filter(r => r.outcome === 'SUCCESS').length;
286
+ const frictionCount = results.filter(r => r.outcome === 'FRICTION').length;
287
+ const failureCount = results.filter(r => r.outcome === 'FAILURE').length;
288
+
289
+ let overallVerdict = 'SUCCESS';
290
+ if (failureCount > 0) {
291
+ overallVerdict = 'FAILURE';
292
+ } else if (frictionCount > 0) {
293
+ overallVerdict = 'FRICTION';
294
+ }
295
+
296
+ return {
297
+ successCount,
298
+ frictionCount,
299
+ failureCount,
300
+ overallVerdict
301
+ };
302
+ }
303
+ }
304
+
305
+ module.exports = { MarketReporter };