@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,227 @@
1
+ /**
2
+ * Guardian Attempt Mode
3
+ * Single user attempt execution orchestration
4
+ * Phase 2: Soft failure detection via validators
5
+ */
6
+
7
+ const { GuardianBrowser } = require('./browser');
8
+ const { AttemptEngine } = require('./attempt-engine');
9
+ const { AttemptReporter } = require('./attempt-reporter');
10
+ const { getAttemptDefinition } = require('./attempt-registry');
11
+ const GuardianNetworkTrace = require('./network-trace');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Programmatic API for executing attempts
17
+ * Returns result object instead of calling process.exit
18
+ * @param {Object} config - Configuration
19
+ * @returns {Promise<Object>} Result with outcome, exitCode, paths, etc.
20
+ */
21
+ async function executeAttempt(config) {
22
+ const {
23
+ baseUrl,
24
+ attemptId = 'contact_form',
25
+ artifactsDir = './artifacts',
26
+ enableTrace = true,
27
+ enableScreenshots = true,
28
+ headful = false
29
+ } = config;
30
+
31
+ // Validate baseUrl
32
+ try {
33
+ new URL(baseUrl);
34
+ } catch (e) {
35
+ throw new Error(`Invalid URL: ${baseUrl}`);
36
+ }
37
+
38
+ const browser = new GuardianBrowser();
39
+ let attemptResult = null;
40
+ let runDir = null;
41
+
42
+ try {
43
+ // Prepare artifacts directory
44
+ const now = new Date();
45
+ const dateStr = now.toISOString()
46
+ .replace(/[:\-]/g, '')
47
+ .substring(0, 15)
48
+ .replace('T', '-');
49
+ const runId = `attempt-${dateStr}`;
50
+ runDir = path.join(artifactsDir, runId);
51
+
52
+ if (!fs.existsSync(runDir)) {
53
+ fs.mkdirSync(runDir, { recursive: true });
54
+ }
55
+
56
+ console.log(`\n📁 Artifacts: ${runDir}`);
57
+
58
+ // Launch browser
59
+ console.log(`\n🚀 Launching browser...`);
60
+ const browserOptions = {
61
+ headless: !headful,
62
+ args: !headful ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
63
+ };
64
+ await browser.launch(30000, browserOptions);
65
+ console.log(`✅ Browser launched`);
66
+
67
+ // Start trace if enabled
68
+ let tracePath = null;
69
+ if (enableTrace && browser.context) {
70
+ const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
71
+ tracePath = await networkTrace.startTrace(browser.context, runDir);
72
+ if (tracePath) {
73
+ console.log(`📹 Trace recording started`);
74
+ }
75
+ }
76
+
77
+ // Execute attempt
78
+ console.log(`\n🎬 Executing attempt...`);
79
+ const engine = new AttemptEngine({
80
+ attemptId,
81
+ timeout: config.timeout || 30000,
82
+ frictionThresholds: config.frictionThresholds || {
83
+ totalDurationMs: 2500,
84
+ stepDurationMs: 1500,
85
+ retryCount: 1
86
+ }
87
+ });
88
+
89
+ // Get validators from attempt definition (Phase 2)
90
+ const attemptDef = getAttemptDefinition(attemptId);
91
+ const validators = attemptDef?.validators || [];
92
+
93
+ attemptResult = await engine.executeAttempt(browser.page, attemptId, baseUrl, runDir, validators);
94
+
95
+ console.log(`\n✅ Attempt completed: ${attemptResult.outcome}`);
96
+
97
+ // Stop trace if enabled
98
+ if (enableTrace && browser.context && tracePath) {
99
+ const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
100
+ await networkTrace.stopTrace(browser.context, tracePath);
101
+ console.log(`✅ Trace saved: trace.zip`);
102
+ }
103
+
104
+ // Generate reports
105
+ console.log(`\n📊 Generating reports...`);
106
+ const reporter = new AttemptReporter();
107
+ const report = reporter.createReport(attemptResult, baseUrl, attemptId);
108
+
109
+ // Save JSON report
110
+ const jsonPath = reporter.saveJsonReport(report, runDir);
111
+ console.log(`✅ JSON report: ${path.basename(jsonPath)}`);
112
+
113
+ // Save HTML report
114
+ const htmlContent = reporter.generateHtmlReport(report);
115
+ const htmlPath = reporter.saveHtmlReport(htmlContent, runDir);
116
+ console.log(`✅ HTML report: ${path.basename(htmlPath)}`);
117
+
118
+ // Display summary
119
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
120
+
121
+ const outcomeEmoji = attemptResult.outcome === 'SUCCESS' ? '🟢' :
122
+ attemptResult.outcome === 'FRICTION' ? '🟡' : '🔴';
123
+
124
+ console.log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
125
+
126
+ if (attemptResult.outcome === 'SUCCESS') {
127
+ console.log(`\n✅ User successfully completed the attempt!`);
128
+ console.log(` ${attemptResult.successReason}`);
129
+ } else if (attemptResult.outcome === 'FRICTION') {
130
+ console.log(`\n⚠️ Attempt succeeded but with friction:`);
131
+ attemptResult.friction.reasons.forEach(reason => {
132
+ console.log(` • ${reason}`);
133
+ });
134
+ } else {
135
+ console.log(`\n❌ Attempt failed:`);
136
+ console.log(` ${attemptResult.error}`);
137
+ }
138
+
139
+ console.log(`\n⏱️ Duration: ${attemptResult.totalDurationMs}ms`);
140
+ console.log(`📋 Steps: ${attemptResult.steps.length}`);
141
+
142
+ if (attemptResult.steps.length > 0) {
143
+ const failedSteps = attemptResult.steps.filter(s => s.status === 'failed');
144
+ if (failedSteps.length > 0) {
145
+ console.log(`❌ Failed steps: ${failedSteps.length}`);
146
+ failedSteps.forEach(step => {
147
+ console.log(` • ${step.id}: ${step.error}`);
148
+ });
149
+ }
150
+
151
+ const retriedSteps = attemptResult.steps.filter(s => s.retries > 0);
152
+ if (retriedSteps.length > 0) {
153
+ console.log(`🔄 Steps with retries: ${retriedSteps.length}`);
154
+ retriedSteps.forEach(step => {
155
+ console.log(` • ${step.id}: ${step.retries} retries`);
156
+ });
157
+ }
158
+ }
159
+
160
+ console.log(`\n💾 Full report: ${runDir}`);
161
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
162
+
163
+ // Close browser before returning
164
+ try {
165
+ await browser.close();
166
+ } catch (closeErr) {
167
+ // Ignore browser close errors
168
+ }
169
+
170
+ // Determine exit code
171
+ let exitCode = 0;
172
+ if (attemptResult.outcome === 'SUCCESS') {
173
+ exitCode = 0;
174
+ } else if (attemptResult.outcome === 'FRICTION') {
175
+ exitCode = 2;
176
+ } else {
177
+ exitCode = 1;
178
+ }
179
+
180
+ // Return structured result
181
+ return {
182
+ outcome: attemptResult.outcome,
183
+ exitCode,
184
+ attemptResult,
185
+ artifactsDir: runDir,
186
+ reportJsonPath: path.join(runDir, 'attempt-report.json'),
187
+ reportHtmlPath: path.join(runDir, 'attempt-report.html'),
188
+ tracePath: enableTrace ? path.join(runDir, 'trace.zip') : null,
189
+ steps: attemptResult.steps,
190
+ friction: attemptResult.friction,
191
+ error: attemptResult.error,
192
+ successReason: attemptResult.successReason
193
+ };
194
+
195
+ } catch (err) {
196
+ await browser.close().catch(() => {});
197
+ throw err;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * CLI wrapper for executeAttempt that prints output and calls process.exit
203
+ */
204
+ async function runAttemptCLI(config) {
205
+ const {
206
+ baseUrl,
207
+ attemptId = 'contact_form',
208
+ headful = false
209
+ } = config;
210
+
211
+ console.log(`\n🛡️ ODAVL Guardian — Single User Attempt (Phase 1)`);
212
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
213
+ console.log(`📍 Target: ${baseUrl}`);
214
+ console.log(`🎯 Attempt: ${attemptId}`);
215
+ console.log(`⚙️ Mode: ${headful ? 'headed' : 'headless'}`);
216
+
217
+ try {
218
+ const result = await executeAttempt(config);
219
+ process.exit(result.exitCode);
220
+ } catch (err) {
221
+ console.error(`\n❌ Error: ${err.message}`);
222
+ if (err.stack) console.error(err.stack);
223
+ process.exit(1);
224
+ }
225
+ }
226
+
227
+ module.exports = { executeAttempt, runAttemptCLI };
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Auto-Attempt Builder (Phase 2)
3
+ *
4
+ * Converts discovered interactions into safe, executable attempt definitions.
5
+ * - Filters by safety/confidence
6
+ * - Generates deterministic steps (navigate → interact → validate)
7
+ * - Deduplicates attempts
8
+ * - Applies minimal validators
9
+ *
10
+ * NO AI. Pure deterministic logic.
11
+ */
12
+
13
+ const crypto = require('crypto');
14
+
15
+ /**
16
+ * @typedef {Object} AutoAttemptDefinition
17
+ * @property {string} attemptId - unique ID (e.g., "auto-navigate-about")
18
+ * @property {string} name - human-readable name
19
+ * @property {string} goal - what the attempt is testing
20
+ * @property {Array<Object>} steps - execution steps
21
+ * @property {Array<Object>} validators - success validators
22
+ * @property {string} riskCategory - TRUST, REVENUE, LEAD, UNKNOWN
23
+ * @property {string} source - 'auto-generated'
24
+ * @property {Object} metadata - discovery metadata (interaction, confidence, etc.)
25
+ */
26
+
27
+ /**
28
+ * Generate stable hash for deduplication
29
+ */
30
+ function generateAttemptHash(interaction) {
31
+ const key = `${interaction.pageUrl}|${interaction.type}|${interaction.selector}|${interaction.text || ''}`;
32
+ return crypto.createHash('md5').update(key).digest('hex').substring(0, 8);
33
+ }
34
+
35
+ /**
36
+ * Generate attemptId from interaction
37
+ */
38
+ function generateAttemptId(interaction, hash) {
39
+ const prefix = 'auto';
40
+ const interactionClass = (interaction.interactionClass || 'action').toLowerCase();
41
+ const sanitizedText = (interaction.text || interaction.ariaLabel || 'unknown')
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, '-')
44
+ .substring(0, 20);
45
+
46
+ return `${prefix}-${interactionClass}-${sanitizedText}-${hash}`;
47
+ }
48
+
49
+ /**
50
+ * Generate human-readable name
51
+ */
52
+ function generateAttemptName(interaction) {
53
+ const className = interaction.interactionClass || 'Interaction';
54
+ const text = interaction.text || interaction.ariaLabel || interaction.selector;
55
+ return `Auto ${className}: ${text.substring(0, 50)}`;
56
+ }
57
+
58
+ /**
59
+ * Generate goal statement
60
+ */
61
+ function generateGoal(interaction) {
62
+ const action = interaction.type === 'NAVIGATE' ? 'Navigate to' :
63
+ interaction.type === 'CLICK' ? 'Click' :
64
+ interaction.type === 'FORM_FILL' ? 'Fill form' : 'Interact with';
65
+ const target = interaction.text || interaction.ariaLabel || interaction.selector;
66
+ return `${action} "${target}" and verify page remains responsive`;
67
+ }
68
+
69
+ /**
70
+ * Generate execution steps for an interaction
71
+ */
72
+ function generateSteps(interaction) {
73
+ const steps = [];
74
+
75
+ // Step 1: Navigate to page where interaction was found
76
+ steps.push({
77
+ id: 'navigate_to_page',
78
+ type: 'navigate',
79
+ target: interaction.pageUrl,
80
+ description: `Navigate to ${interaction.pageUrl}`
81
+ });
82
+
83
+ // Step 2: Wait for element to be visible
84
+ steps.push({
85
+ id: 'wait_for_element',
86
+ type: 'waitFor',
87
+ target: interaction.selector,
88
+ timeout: 10000,
89
+ description: `Wait for element: ${interaction.selector}`
90
+ });
91
+
92
+ // Step 3: Execute interaction
93
+ if (interaction.type === 'NAVIGATE') {
94
+ steps.push({
95
+ id: 'click_link',
96
+ type: 'click',
97
+ target: interaction.selector,
98
+ description: `Click link: ${interaction.text || interaction.selector}`
99
+ });
100
+ } else if (interaction.type === 'CLICK') {
101
+ steps.push({
102
+ id: 'click_button',
103
+ type: 'click',
104
+ target: interaction.selector,
105
+ description: `Click button: ${interaction.text || interaction.selector}`
106
+ });
107
+ } else if (interaction.type === 'FORM_FILL') {
108
+ // Fill form fields with safe test data
109
+ steps.push({
110
+ id: 'fill_form',
111
+ type: 'fillForm',
112
+ target: interaction.selector,
113
+ formData: generateFormData(interaction.formFields || []),
114
+ description: `Fill form: ${interaction.formId || interaction.selector}`
115
+ });
116
+ }
117
+
118
+ // Step 4: Wait briefly for DOM updates
119
+ steps.push({
120
+ id: 'wait_for_update',
121
+ type: 'wait',
122
+ duration: 500,
123
+ description: 'Wait for DOM update'
124
+ });
125
+
126
+ return steps;
127
+ }
128
+
129
+ /**
130
+ * Generate safe form data
131
+ */
132
+ function generateFormData(fieldTypes) {
133
+ const data = {};
134
+ fieldTypes.forEach((type, idx) => {
135
+ if (type === 'email') {
136
+ data[`email_${idx}`] = `autotest${idx}@example.com`;
137
+ } else if (type === 'text') {
138
+ data[`text_${idx}`] = `Auto test ${idx}`;
139
+ } else if (type === 'password') {
140
+ data[`password_${idx}`] = 'AutoTest123!';
141
+ } else if (type === 'tel') {
142
+ data[`tel_${idx}`] = '5551234567';
143
+ } else if (type === 'url') {
144
+ data[`url_${idx}`] = 'https://example.com';
145
+ }
146
+ });
147
+ return data;
148
+ }
149
+
150
+ /**
151
+ * Generate minimal validators for auto-attempts
152
+ * Focus on: page responsive, no critical errors, state changed
153
+ */
154
+ function generateValidators(interaction) {
155
+ const validators = [];
156
+
157
+ // Validator 1: Page is still responsive (no crash)
158
+ validators.push({
159
+ type: 'elementVisible',
160
+ selector: 'body'
161
+ });
162
+
163
+ // Validator 2: No console errors above 'error' level
164
+ validators.push({
165
+ type: 'noConsoleErrorsAbove',
166
+ minSeverity: 'error'
167
+ });
168
+
169
+ return validators;
170
+ }
171
+
172
+ /**
173
+ * Determine risk category for auto-attempt
174
+ * Auto-attempts are lower priority than manual intent flows
175
+ */
176
+ function determineRiskCategory(interaction) {
177
+ const { interactionClass, text = '', ariaLabel = '' } = interaction;
178
+ const combinedText = `${text} ${ariaLabel}`.toLowerCase();
179
+
180
+ // Navigation to key pages: TRUST
181
+ if (interactionClass === 'NAVIGATION') {
182
+ if (combinedText.includes('about') || combinedText.includes('contact') ||
183
+ combinedText.includes('help') || combinedText.includes('support')) {
184
+ return 'TRUST';
185
+ }
186
+ }
187
+
188
+ // Forms: LEAD
189
+ if (interactionClass === 'SUBMISSION') {
190
+ return 'LEAD';
191
+ }
192
+
193
+ // Default: lower priority
194
+ return 'DISCOVERY';
195
+ }
196
+
197
+ /**
198
+ * Build auto-attempt from discovered interaction
199
+ */
200
+ function buildAutoAttempt(interaction) {
201
+ const hash = generateAttemptHash(interaction);
202
+ const attemptId = generateAttemptId(interaction, hash);
203
+ const name = generateAttemptName(interaction);
204
+ const goal = generateGoal(interaction);
205
+ const steps = generateSteps(interaction);
206
+ const validators = generateValidators(interaction);
207
+ const riskCategory = determineRiskCategory(interaction);
208
+
209
+ return {
210
+ id: attemptId, // Registry expects 'id'
211
+ attemptId, // Keep for backwards compatibility
212
+ name,
213
+ goal,
214
+ baseSteps: steps, // Engine expects 'baseSteps'
215
+ successConditions: [
216
+ { type: 'selector', target: 'body', description: 'Page loaded and responsive' }
217
+ ],
218
+ validators,
219
+ riskCategory,
220
+ source: 'auto-generated',
221
+ metadata: {
222
+ discoveryInteractionId: interaction.interactionId,
223
+ discoveredUrl: interaction.pageUrl,
224
+ interactionType: interaction.type,
225
+ interactionClass: interaction.interactionClass,
226
+ confidenceScore: interaction.confidenceScore,
227
+ selector: interaction.selector,
228
+ text: interaction.text,
229
+ ariaLabel: interaction.ariaLabel
230
+ }
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Main function: Convert discovered interactions to auto-attempts
236
+ * @param {Array<Object>} interactions - discovered interactions from DiscoveryEngine
237
+ * @param {Object} options - filtering options
238
+ * @returns {Array<AutoAttemptDefinition>} auto-attempt definitions
239
+ */
240
+ function buildAutoAttempts(interactions, options = {}) {
241
+ const {
242
+ minConfidence = 60,
243
+ maxAttempts = 10,
244
+ excludeRisky = true,
245
+ includeClasses = ['NAVIGATION', 'ACTION', 'SUBMISSION', 'TOGGLE']
246
+ } = options;
247
+
248
+ // Filter safe, high-confidence interactions
249
+ let filtered = interactions.filter(interaction => {
250
+ if (excludeRisky && interaction.isRisky) return false;
251
+ if (interaction.confidenceScore < minConfidence) return false;
252
+ if (!includeClasses.includes(interaction.interactionClass)) return false;
253
+ return true;
254
+ });
255
+
256
+ // Sort by confidence (highest first)
257
+ filtered.sort((a, b) => b.confidenceScore - a.confidenceScore);
258
+
259
+ // Deduplicate by hash
260
+ const seen = new Set();
261
+ const unique = [];
262
+ for (const interaction of filtered) {
263
+ const hash = generateAttemptHash(interaction);
264
+ if (!seen.has(hash)) {
265
+ seen.add(hash);
266
+ unique.push(interaction);
267
+ }
268
+ }
269
+
270
+ // Limit to maxAttempts
271
+ const limited = unique.slice(0, maxAttempts);
272
+
273
+ // Build attempts
274
+ return limited.map(interaction => buildAutoAttempt(interaction));
275
+ }
276
+
277
+ module.exports = {
278
+ buildAutoAttempts,
279
+ buildAutoAttempt,
280
+ generateAttemptId,
281
+ generateSteps,
282
+ generateValidators
283
+ };
@@ -0,0 +1,143 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ class BaselineCheckReporter {
5
+ saveJsonReport(report, artifactsDir) {
6
+ const p = path.join(artifactsDir, 'baseline-check-report.json');
7
+ fs.writeFileSync(p, JSON.stringify(report, null, 2));
8
+ return p;
9
+ }
10
+
11
+ generateHtmlReport(report) {
12
+ const verdict = report.overallRegressionVerdict;
13
+ const color = verdict === 'NO_REGRESSION' ? '#10b981' : verdict === 'REGRESSION_FRICTION' ? '#f59e0b' : '#ef4444';
14
+ const emoji = verdict === 'NO_REGRESSION' ? '🟢' : verdict === 'REGRESSION_FRICTION' ? '🟡' : '🔴';
15
+
16
+ const rows = report.comparisons.map((c, i) => {
17
+ const badgeColor = c.regressionType === 'NO_REGRESSION' ? '#10b981' : (c.regressionType.startsWith('REGRESSION_FRICTION') ? '#f59e0b' : '#ef4444');
18
+ const badgeText = c.regressionType.replace(/_/g, ' ');
19
+ return `
20
+ <tr>
21
+ <td>${i + 1}</td>
22
+ <td>${c.attemptId}</td>
23
+ <td>${c.baselineOutcome}</td>
24
+ <td>${c.currentOutcome}</td>
25
+ <td><span class="badge" style="background:${badgeColor}">${badgeText}</span></td>
26
+ <td>${(c.keyMetricsDelta.durationPct ?? 0).toFixed(2)}%</td>
27
+ <td>${c.keyMetricsDelta.retriesDelta ?? 0}</td>
28
+ <td>
29
+ ${c.links.reportHtml ? `<a href="${path.basename(c.links.reportHtml)}" target="_blank">Attempt HTML</a>` : ''}
30
+ ${c.links.reportJson ? ` | <a href="${path.basename(c.links.reportJson)}" target="_blank">Attempt JSON</a>` : ''}
31
+ </td>
32
+ </tr>
33
+ <tr class="details">
34
+ <td colspan="8">
35
+ <details>
36
+ <summary>Details</summary>
37
+ <div class="detail-content">
38
+ <p><strong>Regression Reasons:</strong> ${c.regressionReasons.length ? c.regressionReasons.join('; ') : 'None'}</p>
39
+ <p><strong>Improvements:</strong> ${c.improvements.length ? c.improvements.join('; ') : 'None'}</p>
40
+ <p><strong>Friction Delta:</strong> added=[${(c.frictionDelta.added||[]).join(', ')}], removed=[${(c.frictionDelta.removed||[]).join(', ')}], changed=[${(c.frictionDelta.changed||[]).map(x=>x.id).join(', ')}]</p>
41
+ </div>
42
+ </details>
43
+ </td>
44
+ </tr>
45
+ `;
46
+ }).join('');
47
+
48
+ const flowRows = (report.flowComparisons || []).map((c, i) => {
49
+ const badgeColor = c.regressionType === 'NO_REGRESSION' ? '#10b981' : '#ef4444';
50
+ const badgeText = c.regressionType.replace(/_/g, ' ');
51
+ return `
52
+ <tr>
53
+ <td>${i + 1}</td>
54
+ <td>${c.flowId}</td>
55
+ <td>${c.baselineOutcome}</td>
56
+ <td>${c.currentOutcome}</td>
57
+ <td><span class="badge" style="background:${badgeColor}">${badgeText}</span></td>
58
+ <td colspan="3">${c.regressionReasons.slice(0,2).join('; ') || '—'}</td>
59
+ </tr>
60
+ `;
61
+ }).join('');
62
+
63
+ return `<!doctype html>
64
+ <html>
65
+ <head>
66
+ <meta charset="utf-8" />
67
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
68
+ <title>Baseline Check Report</title>
69
+ <style>
70
+ body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial; background:#f6f7fb; color:#1f2937; padding:20px; }
71
+ .container { max-width: 1100px; margin: 0 auto; }
72
+ .header { background: #111827; color:#fff; padding:20px; border-radius:12px; }
73
+ .badge { color:#fff; padding:6px 10px; border-radius:999px; font-size:0.85em; }
74
+ .verdict { display:inline-flex; align-items:center; gap:8px; background:${color}; color:#fff; padding:10px 14px; border-radius:999px; font-weight:bold; }
75
+ table { width:100%; border-collapse:collapse; background:#fff; border-radius:10px; overflow:hidden; margin-top:16px; }
76
+ th, td { padding:10px 12px; border-bottom:1px solid #e5e7eb; text-align:left; }
77
+ th { background:#f3f4f6; }
78
+ tr.details td { background:#fafafa; }
79
+ details { margin: 6px 0; }
80
+ .meta { display:flex; gap:16px; color:#e5e7eb; margin-top:8px; font-size:0.95em; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="container">
85
+ <div class="header">
86
+ <h1>Baseline Check</h1>
87
+ <div class="verdict">${emoji} ${verdict}</div>
88
+ <div class="meta">
89
+ <div><strong>Baseline:</strong> ${report.meta.baselineName}</div>
90
+ <div><strong>URL:</strong> ${report.meta.baseUrl}</div>
91
+ <div><strong>Run ID:</strong> ${report.meta.runId}</div>
92
+ <div><strong>Time:</strong> ${report.meta.timestamp}</div>
93
+ </div>
94
+ </div>
95
+
96
+ <table>
97
+ <thead>
98
+ <tr>
99
+ <th>#</th>
100
+ <th>Attempt</th>
101
+ <th>Baseline</th>
102
+ <th>Current</th>
103
+ <th>Regression</th>
104
+ <th>Δ Duration %</th>
105
+ <th>Δ Retries</th>
106
+ <th>Links</th>
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ ${rows}
111
+ </tbody>
112
+ </table>
113
+
114
+ ${flowRows ? `
115
+ <h3 style="margin-top:20px;">Intent Flows</h3>
116
+ <table>
117
+ <thead>
118
+ <tr>
119
+ <th>#</th>
120
+ <th>Flow</th>
121
+ <th>Baseline</th>
122
+ <th>Current</th>
123
+ <th>Regression</th>
124
+ <th colspan="3">Notes</th>
125
+ </tr>
126
+ </thead>
127
+ <tbody>
128
+ ${flowRows}
129
+ </tbody>
130
+ </table>` : ''}
131
+ </div>
132
+ </body>
133
+ </html>`;
134
+ }
135
+
136
+ saveHtmlReport(html, artifactsDir) {
137
+ const p = path.join(artifactsDir, 'baseline-check-report.html');
138
+ fs.writeFileSync(p, html, 'utf8');
139
+ return p;
140
+ }
141
+ }
142
+
143
+ module.exports = { BaselineCheckReporter };