@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,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 };
|