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