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