@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
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Guardian Attempt Engine - PHASE 1 + PHASE 2
|
|
3
|
-
* Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION)
|
|
3
|
+
* Executes a single user attempt and tracks outcome (SUCCESS, FAILURE, FRICTION, NOT_APPLICABLE, DISCOVERY_FAILED)
|
|
4
4
|
* Phase 2: Soft failure detection via validators
|
|
5
|
+
* Phase 3: Robust selector discovery with fallbacks
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
const fs = require('fs');
|
|
8
9
|
const path = require('path');
|
|
9
10
|
const { getAttemptDefinition } = require('./attempt-registry');
|
|
10
11
|
const { runValidators, analyzeSoftFailures } = require('./validators');
|
|
12
|
+
const { buildSelectorChain, findElement, detectFeature } = require('./selector-fallbacks');
|
|
11
13
|
|
|
12
14
|
class AttemptEngine {
|
|
13
15
|
constructor(options = {}) {
|
|
@@ -18,6 +20,9 @@ class AttemptEngine {
|
|
|
18
20
|
stepDurationMs: 1500, // Any single step > 1.5s
|
|
19
21
|
retryCount: 1 // More than 1 retry = friction
|
|
20
22
|
};
|
|
23
|
+
this.maxStepRetries = typeof options.maxStepRetries === 'number'
|
|
24
|
+
? Math.max(1, options.maxStepRetries)
|
|
25
|
+
: 2;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -41,6 +46,8 @@ class AttemptEngine {
|
|
|
41
46
|
const steps = [];
|
|
42
47
|
const frictionSignals = [];
|
|
43
48
|
const consoleMessages = []; // Capture console messages for validators
|
|
49
|
+
const consoleErrors = [];
|
|
50
|
+
const pageErrors = [];
|
|
44
51
|
let currentStep = null;
|
|
45
52
|
let lastError = null;
|
|
46
53
|
let frictionReasons = [];
|
|
@@ -53,11 +60,29 @@ class AttemptEngine {
|
|
|
53
60
|
text: msg.text(),
|
|
54
61
|
location: msg.location()
|
|
55
62
|
});
|
|
63
|
+
if (msg.type() === 'error') {
|
|
64
|
+
consoleErrors.push(msg.text());
|
|
65
|
+
}
|
|
56
66
|
};
|
|
57
67
|
|
|
58
68
|
page.on('console', consoleHandler);
|
|
69
|
+
const pageErrorHandler = (err) => {
|
|
70
|
+
pageErrors.push(err.message || 'page error');
|
|
71
|
+
};
|
|
72
|
+
page.on('pageerror', pageErrorHandler);
|
|
59
73
|
|
|
60
74
|
try {
|
|
75
|
+
// Custom universal attempts bypass base step execution and implement purpose-built logic
|
|
76
|
+
if (attemptId === 'site_smoke') {
|
|
77
|
+
return await this._runSiteSmokeAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
|
|
78
|
+
}
|
|
79
|
+
if (attemptId === 'primary_ctas') {
|
|
80
|
+
return await this._runPrimaryCtasAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
|
|
81
|
+
}
|
|
82
|
+
if (attemptId === 'contact_discovery_v2') {
|
|
83
|
+
return await this._runContactDiscoveryAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors);
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
// Replace $BASEURL placeholder in all steps
|
|
62
87
|
const processedSteps = attemptDef.baseSteps.map(step => {
|
|
63
88
|
if (step.target && step.target === '$BASEURL') {
|
|
@@ -85,7 +110,7 @@ class AttemptEngine {
|
|
|
85
110
|
try {
|
|
86
111
|
// Execute with retry logic (up to 2 attempts)
|
|
87
112
|
let success = false;
|
|
88
|
-
for (let attempt = 0; attempt <
|
|
113
|
+
for (let attempt = 0; attempt < this.maxStepRetries; attempt++) {
|
|
89
114
|
try {
|
|
90
115
|
if (attempt > 0) {
|
|
91
116
|
currentStep.retries++;
|
|
@@ -97,7 +122,7 @@ class AttemptEngine {
|
|
|
97
122
|
success = true;
|
|
98
123
|
break;
|
|
99
124
|
} catch (err) {
|
|
100
|
-
if (attempt === 1) {
|
|
125
|
+
if (attempt === this.maxStepRetries - 1) {
|
|
101
126
|
throw err; // Final attempt failed
|
|
102
127
|
}
|
|
103
128
|
// Retry on first failure
|
|
@@ -153,18 +178,42 @@ class AttemptEngine {
|
|
|
153
178
|
} catch (err) {
|
|
154
179
|
currentStep.endedAt = new Date().toISOString();
|
|
155
180
|
currentStep.durationMs = Date.now() - stepStartTime;
|
|
156
|
-
currentStep.status = stepDef.optional ? '
|
|
181
|
+
currentStep.status = stepDef.optional ? 'optional_failed' : 'failed';
|
|
157
182
|
currentStep.error = err.message;
|
|
158
183
|
|
|
159
184
|
if (stepDef.optional) {
|
|
160
|
-
// Optional steps should not fail the attempt; record
|
|
185
|
+
// Optional steps should not fail the attempt; record soft failure
|
|
186
|
+
frictionSignals.push({
|
|
187
|
+
id: 'optional_step_failed',
|
|
188
|
+
description: `Optional step failed: ${stepDef.id}`,
|
|
189
|
+
metric: 'optionalStep',
|
|
190
|
+
threshold: 0,
|
|
191
|
+
observedValue: 1,
|
|
192
|
+
affectedStepId: stepDef.id,
|
|
193
|
+
severity: 'low'
|
|
194
|
+
});
|
|
195
|
+
frictionReasons.push(`Optional step failed and was skipped: ${stepDef.id}`);
|
|
196
|
+
if (artifactsDir) {
|
|
197
|
+
const screenshotPath = await this._captureScreenshot(
|
|
198
|
+
page,
|
|
199
|
+
artifactsDir,
|
|
200
|
+
`${stepDef.id}_optional_failure`
|
|
201
|
+
);
|
|
202
|
+
if (screenshotPath) {
|
|
203
|
+
currentStep.screenshots.push(screenshotPath);
|
|
204
|
+
}
|
|
205
|
+
const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_optional_failure`);
|
|
206
|
+
if (domPath) {
|
|
207
|
+
currentStep.domPath = domPath;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
161
210
|
steps.push(currentStep);
|
|
162
211
|
continue;
|
|
163
212
|
}
|
|
164
213
|
|
|
165
214
|
lastError = err;
|
|
166
215
|
|
|
167
|
-
// Capture screenshot on failure
|
|
216
|
+
// Capture screenshot and DOM on failure
|
|
168
217
|
if (artifactsDir) {
|
|
169
218
|
const screenshotPath = await this._captureScreenshot(
|
|
170
219
|
page,
|
|
@@ -174,6 +223,10 @@ class AttemptEngine {
|
|
|
174
223
|
if (screenshotPath) {
|
|
175
224
|
currentStep.screenshots.push(screenshotPath);
|
|
176
225
|
}
|
|
226
|
+
const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_failure`);
|
|
227
|
+
if (domPath) {
|
|
228
|
+
currentStep.domPath = domPath;
|
|
229
|
+
}
|
|
177
230
|
}
|
|
178
231
|
|
|
179
232
|
throw err; // Stop attempt on step failure
|
|
@@ -217,6 +270,7 @@ class AttemptEngine {
|
|
|
217
270
|
|
|
218
271
|
if (!successMet) {
|
|
219
272
|
page.removeListener('console', consoleHandler);
|
|
273
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
220
274
|
return {
|
|
221
275
|
outcome: 'FAILURE',
|
|
222
276
|
steps,
|
|
@@ -234,7 +288,11 @@ class AttemptEngine {
|
|
|
234
288
|
error: 'Success conditions not met after all steps completed',
|
|
235
289
|
successReason: null,
|
|
236
290
|
validators: [],
|
|
237
|
-
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
|
|
291
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
292
|
+
discoverySignals: {
|
|
293
|
+
consoleErrorCount: consoleErrors.length,
|
|
294
|
+
pageErrorCount: pageErrors.length
|
|
295
|
+
}
|
|
238
296
|
};
|
|
239
297
|
}
|
|
240
298
|
|
|
@@ -306,12 +364,17 @@ class AttemptEngine {
|
|
|
306
364
|
error: null,
|
|
307
365
|
successReason,
|
|
308
366
|
validators: validatorResults,
|
|
309
|
-
softFailures: softFailureAnalysis
|
|
367
|
+
softFailures: softFailureAnalysis,
|
|
368
|
+
discoverySignals: {
|
|
369
|
+
consoleErrorCount: consoleErrors.length,
|
|
370
|
+
pageErrorCount: pageErrors.length
|
|
371
|
+
}
|
|
310
372
|
};
|
|
311
373
|
|
|
312
374
|
} catch (err) {
|
|
313
375
|
const endedAt = new Date();
|
|
314
376
|
page.removeListener('console', consoleHandler);
|
|
377
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
315
378
|
return {
|
|
316
379
|
outcome: 'FAILURE',
|
|
317
380
|
steps,
|
|
@@ -327,10 +390,15 @@ class AttemptEngine {
|
|
|
327
390
|
error: `Step "${currentStep?.id}" failed: ${err.message}`,
|
|
328
391
|
successReason: null,
|
|
329
392
|
validators: [],
|
|
330
|
-
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 }
|
|
393
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
394
|
+
discoverySignals: {
|
|
395
|
+
consoleErrorCount: consoleErrors.length,
|
|
396
|
+
pageErrorCount: pageErrors.length
|
|
397
|
+
}
|
|
331
398
|
};
|
|
332
399
|
} finally {
|
|
333
400
|
page.removeListener('console', consoleHandler);
|
|
401
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
334
402
|
}
|
|
335
403
|
}
|
|
336
404
|
|
|
@@ -396,22 +464,33 @@ class AttemptEngine {
|
|
|
396
464
|
case 'waitFor':
|
|
397
465
|
const waitSelectors = stepDef.target.split(',').map(s => s.trim());
|
|
398
466
|
let found = false;
|
|
467
|
+
let earlyExitReason = null;
|
|
399
468
|
|
|
400
469
|
for (const selector of waitSelectors) {
|
|
401
470
|
try {
|
|
471
|
+
// Phase 7.4: Adaptive timeout
|
|
472
|
+
const adaptiveTimeout = stepDef.timeout || 5000;
|
|
473
|
+
|
|
402
474
|
await page.waitForSelector(selector, {
|
|
403
|
-
timeout:
|
|
475
|
+
timeout: adaptiveTimeout,
|
|
404
476
|
state: stepDef.state || 'visible'
|
|
405
477
|
});
|
|
406
478
|
found = true;
|
|
407
479
|
break;
|
|
408
480
|
} catch (err) {
|
|
409
|
-
//
|
|
481
|
+
// Phase 7.4: Detect early exit signals
|
|
482
|
+
if (err.message && err.message.includes('Timeout')) {
|
|
483
|
+
earlyExitReason = 'Target never appeared (DOM settled)';
|
|
484
|
+
}
|
|
410
485
|
}
|
|
411
486
|
}
|
|
412
487
|
|
|
413
488
|
if (!found) {
|
|
414
|
-
|
|
489
|
+
// Phase 7.4: Include early exit reason
|
|
490
|
+
const errorMsg = earlyExitReason
|
|
491
|
+
? `${earlyExitReason}: ${stepDef.target}`
|
|
492
|
+
: `Element not found: ${stepDef.target}`;
|
|
493
|
+
throw new Error(errorMsg);
|
|
415
494
|
}
|
|
416
495
|
break;
|
|
417
496
|
|
|
@@ -449,6 +528,502 @@ class AttemptEngine {
|
|
|
449
528
|
return null;
|
|
450
529
|
}
|
|
451
530
|
}
|
|
531
|
+
|
|
532
|
+
async _savePageContent(page, artifactsDir, stepId) {
|
|
533
|
+
try {
|
|
534
|
+
const domDir = path.join(artifactsDir, 'attempt-dom');
|
|
535
|
+
if (!fs.existsSync(domDir)) {
|
|
536
|
+
fs.mkdirSync(domDir, { recursive: true });
|
|
537
|
+
}
|
|
538
|
+
const filename = `${stepId}.html`;
|
|
539
|
+
const fullPath = path.join(domDir, filename);
|
|
540
|
+
const content = await page.content();
|
|
541
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
542
|
+
return path.relative(artifactsDir, fullPath);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Check if an attempt is applicable to this site
|
|
550
|
+
* Returns: { applicable: boolean, confidence: number, reason: string, discoverySignals: {} }
|
|
551
|
+
*/
|
|
552
|
+
async checkAttemptApplicability(page, attemptId) {
|
|
553
|
+
const attemptDef = this.loadAttemptDefinition(attemptId);
|
|
554
|
+
if (!attemptDef) {
|
|
555
|
+
return {
|
|
556
|
+
applicable: false,
|
|
557
|
+
confidence: 0,
|
|
558
|
+
reason: 'Attempt not found in registry',
|
|
559
|
+
discoverySignals: {}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Map attempt IDs to feature types
|
|
564
|
+
const featureTypeMap = {
|
|
565
|
+
'login': 'login',
|
|
566
|
+
'signup': 'signup',
|
|
567
|
+
'checkout': 'checkout',
|
|
568
|
+
'contact_form': 'contact_form',
|
|
569
|
+
'newsletter_signup': 'newsletter',
|
|
570
|
+
'language_switch': 'language_switch'
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const featureType = featureTypeMap[attemptId] || null;
|
|
574
|
+
|
|
575
|
+
if (!featureType) {
|
|
576
|
+
// Attempt with no feature detection (e.g., custom attempts) - always applicable
|
|
577
|
+
return {
|
|
578
|
+
applicable: true,
|
|
579
|
+
confidence: 0.5,
|
|
580
|
+
reason: 'Custom attempt, assuming applicable',
|
|
581
|
+
discoverySignals: {}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const detection = await detectFeature(page, featureType);
|
|
587
|
+
return {
|
|
588
|
+
applicable: detection.present,
|
|
589
|
+
confidence: detection.confidence,
|
|
590
|
+
reason: detection.present
|
|
591
|
+
? `Feature detected with signals: ${detection.evidence.join(', ')}`
|
|
592
|
+
: `Feature not detected; no signals found`,
|
|
593
|
+
discoverySignals: {
|
|
594
|
+
featureType,
|
|
595
|
+
detectionSignals: detection.evidence,
|
|
596
|
+
detected: detection.present,
|
|
597
|
+
confidence: detection.confidence
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
} catch (err) {
|
|
601
|
+
return {
|
|
602
|
+
applicable: false,
|
|
603
|
+
confidence: 0,
|
|
604
|
+
reason: `Detection error: ${err.message}`,
|
|
605
|
+
discoverySignals: { error: err.message }
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Attempt to find an element using fallback selectors
|
|
612
|
+
* Used by _executeStep when element not found with primary selector
|
|
613
|
+
* Returns: { element, discoverySignals }
|
|
614
|
+
*/
|
|
615
|
+
async findElementWithFallbacks(page, goalType) {
|
|
616
|
+
try {
|
|
617
|
+
const selectorChain = buildSelectorChain(goalType);
|
|
618
|
+
if (!selectorChain || selectorChain.length === 0) {
|
|
619
|
+
return {
|
|
620
|
+
element: null,
|
|
621
|
+
discoverySignals: { error: `No selector chain for goal: ${goalType}` }
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const result = await findElement(page, selectorChain, { timeout: 5000, requireVisible: true });
|
|
626
|
+
return {
|
|
627
|
+
element: result.element,
|
|
628
|
+
discoverySignals: {
|
|
629
|
+
goalType,
|
|
630
|
+
selectorChainLength: selectorChain.length,
|
|
631
|
+
strategy: result.strategy,
|
|
632
|
+
confidence: result.confidence,
|
|
633
|
+
found: result.element ? true : false,
|
|
634
|
+
...result.discoverySignals
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return {
|
|
639
|
+
element: null,
|
|
640
|
+
discoverySignals: { error: err.message }
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async _runSiteSmokeAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
|
|
646
|
+
const startedAt = new Date();
|
|
647
|
+
const steps = [];
|
|
648
|
+
const discoverySignals = {
|
|
649
|
+
discoveredLinks: [],
|
|
650
|
+
chosenTargets: [],
|
|
651
|
+
navigationResults: [],
|
|
652
|
+
consoleErrorCount: consoleErrors.length,
|
|
653
|
+
pageErrorCount: pageErrors.length
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const recordStep = (step) => {
|
|
657
|
+
steps.push(step);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// Step: navigate home
|
|
661
|
+
let homepageStatus = null;
|
|
662
|
+
try {
|
|
663
|
+
const resp = await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
664
|
+
homepageStatus = resp ? resp.status() : null;
|
|
665
|
+
recordStep({
|
|
666
|
+
id: 'navigate_home',
|
|
667
|
+
type: 'navigate',
|
|
668
|
+
target: baseUrl,
|
|
669
|
+
status: 'success',
|
|
670
|
+
startedAt: startedAt.toISOString(),
|
|
671
|
+
endedAt: new Date().toISOString(),
|
|
672
|
+
durationMs: null,
|
|
673
|
+
retries: 0,
|
|
674
|
+
screenshots: []
|
|
675
|
+
});
|
|
676
|
+
if (artifactsDir) {
|
|
677
|
+
await this._captureScreenshot(page, artifactsDir, 'site_smoke_home');
|
|
678
|
+
}
|
|
679
|
+
} catch (err) {
|
|
680
|
+
recordStep({
|
|
681
|
+
id: 'navigate_home',
|
|
682
|
+
type: 'navigate',
|
|
683
|
+
target: baseUrl,
|
|
684
|
+
status: 'failed',
|
|
685
|
+
error: err.message,
|
|
686
|
+
startedAt: startedAt.toISOString(),
|
|
687
|
+
endedAt: new Date().toISOString(),
|
|
688
|
+
durationMs: null,
|
|
689
|
+
retries: 0,
|
|
690
|
+
screenshots: []
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
outcome: 'FAILURE',
|
|
694
|
+
steps,
|
|
695
|
+
startedAt: startedAt.toISOString(),
|
|
696
|
+
endedAt: new Date().toISOString(),
|
|
697
|
+
totalDurationMs: new Date() - startedAt,
|
|
698
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
699
|
+
error: `Failed to load homepage: ${err.message}`,
|
|
700
|
+
successReason: null,
|
|
701
|
+
validators: [],
|
|
702
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
703
|
+
discoverySignals
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Discover internal links from header/nav/footer
|
|
708
|
+
const prioritized = ['docs', 'pricing', 'features', 'about', 'contact', 'login', 'signup', 'privacy', 'terms'];
|
|
709
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
710
|
+
const { discoveredLinks, chosenLinks } = await page.evaluate(({ origin, prioritizedList }) => {
|
|
711
|
+
const anchors = Array.from(document.querySelectorAll('header a[href], nav a[href], footer a[href], a[href]'));
|
|
712
|
+
const cleaned = anchors
|
|
713
|
+
.map(a => ({ href: a.getAttribute('href') || '', text: (a.textContent || '').trim() }))
|
|
714
|
+
.filter(a => a.href && !a.href.startsWith('mailto:') && !a.href.startsWith('tel:') && !a.href.startsWith('javascript:'))
|
|
715
|
+
.map(a => {
|
|
716
|
+
let abs = a.href;
|
|
717
|
+
try {
|
|
718
|
+
abs = new URL(a.href, origin).href;
|
|
719
|
+
} catch (_) {}
|
|
720
|
+
return { ...a, abs };
|
|
721
|
+
})
|
|
722
|
+
.filter(a => a.abs.startsWith(origin));
|
|
723
|
+
|
|
724
|
+
const seen = new Set();
|
|
725
|
+
const unique = [];
|
|
726
|
+
for (const link of cleaned) {
|
|
727
|
+
if (seen.has(link.abs)) continue;
|
|
728
|
+
seen.add(link.abs);
|
|
729
|
+
unique.push(link);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const prioritizedMatches = [];
|
|
733
|
+
for (const link of unique) {
|
|
734
|
+
const lower = (link.abs + ' ' + link.text).toLowerCase();
|
|
735
|
+
const match = prioritizedList.find(p => lower.includes(`/${p}`) || lower.includes(p));
|
|
736
|
+
if (match) {
|
|
737
|
+
prioritizedMatches.push({ ...link, priority: prioritizedList.indexOf(match) });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
prioritizedMatches.sort((a, b) => a.priority - b.priority);
|
|
742
|
+
const topPrioritized = prioritizedMatches.slice(0, 3);
|
|
743
|
+
const fallback = unique.filter(l => !topPrioritized.find(t => t.abs === l.abs)).slice(0, 3 - topPrioritized.length);
|
|
744
|
+
const chosen = [...topPrioritized, ...fallback];
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
discoveredLinks: unique,
|
|
748
|
+
chosenLinks: chosen
|
|
749
|
+
};
|
|
750
|
+
}, { origin: baseOrigin, prioritizedList: prioritized });
|
|
751
|
+
|
|
752
|
+
discoverySignals.discoveredLinks = discoveredLinks;
|
|
753
|
+
discoverySignals.chosenTargets = chosenLinks;
|
|
754
|
+
|
|
755
|
+
// Attempt navigation to chosen links (up to 3)
|
|
756
|
+
for (const link of chosenLinks) {
|
|
757
|
+
const start = Date.now();
|
|
758
|
+
let navResult = { target: link.abs, text: link.text, ok: false, status: null, finalUrl: null };
|
|
759
|
+
try {
|
|
760
|
+
const resp = await page.goto(link.abs, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
761
|
+
navResult.status = resp ? resp.status() : null;
|
|
762
|
+
navResult.finalUrl = page.url();
|
|
763
|
+
navResult.ok = (navResult.status && navResult.status < 400) || navResult.finalUrl.startsWith(link.abs);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
navResult.error = err.message;
|
|
766
|
+
}
|
|
767
|
+
navResult.durationMs = Date.now() - start;
|
|
768
|
+
discoverySignals.navigationResults.push(navResult);
|
|
769
|
+
recordStep({
|
|
770
|
+
id: `nav_${link.text || link.abs}`,
|
|
771
|
+
type: 'navigate',
|
|
772
|
+
target: link.abs,
|
|
773
|
+
status: navResult.ok ? 'success' : 'failed',
|
|
774
|
+
error: navResult.ok ? null : navResult.error || 'Navigation failed',
|
|
775
|
+
startedAt: new Date(start).toISOString(),
|
|
776
|
+
endedAt: new Date().toISOString(),
|
|
777
|
+
durationMs: navResult.durationMs,
|
|
778
|
+
retries: 0,
|
|
779
|
+
screenshots: []
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const executedOk = discoverySignals.navigationResults.some(r => r.ok) || homepageStatus !== null;
|
|
784
|
+
const endedAt = new Date();
|
|
785
|
+
const totalDurationMs = endedAt - startedAt;
|
|
786
|
+
const outcome = executedOk ? 'SUCCESS' : 'FAILURE';
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
outcome,
|
|
790
|
+
steps,
|
|
791
|
+
startedAt: startedAt.toISOString(),
|
|
792
|
+
endedAt: endedAt.toISOString(),
|
|
793
|
+
totalDurationMs,
|
|
794
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
795
|
+
error: executedOk ? null : 'No internal navigation succeeded',
|
|
796
|
+
successReason: executedOk ? 'At least one navigation completed' : null,
|
|
797
|
+
validators: [],
|
|
798
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
799
|
+
discoverySignals: {
|
|
800
|
+
...discoverySignals,
|
|
801
|
+
consoleErrorCount: consoleErrors.length,
|
|
802
|
+
pageErrorCount: pageErrors.length,
|
|
803
|
+
homepageStatus
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async _runPrimaryCtasAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
|
|
809
|
+
const startedAt = new Date();
|
|
810
|
+
const steps = [];
|
|
811
|
+
const selectorChainTried = ['text:Docs', 'text:Pricing', 'text:GitHub', 'text:Contact', 'text:Sign in', 'text:Sign up', 'text:Get started', 'text:Try', 'text:Demo'];
|
|
812
|
+
const discoverySignals = {
|
|
813
|
+
ctaCandidates: [],
|
|
814
|
+
navigationResults: [],
|
|
815
|
+
githubValidated: false,
|
|
816
|
+
selectorChainTried,
|
|
817
|
+
consoleErrorCount: consoleErrors.length,
|
|
818
|
+
pageErrorCount: pageErrors.length
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
822
|
+
steps.push({ id: 'navigate_home', type: 'navigate', target: baseUrl, status: 'success', startedAt: startedAt.toISOString(), endedAt: new Date().toISOString(), retries: 0, screenshots: [] });
|
|
823
|
+
if (artifactsDir) {
|
|
824
|
+
await this._captureScreenshot(page, artifactsDir, 'primary_ctas_home');
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
828
|
+
const ctaCandidates = await page.evaluate(({ origin }) => {
|
|
829
|
+
const keywords = ['docs','pricing','github','contact','sign in','sign up','get started','try','demo','start'];
|
|
830
|
+
const elements = Array.from(document.querySelectorAll('a[href], button'));
|
|
831
|
+
const candidates = [];
|
|
832
|
+
for (const el of elements) {
|
|
833
|
+
const text = (el.textContent || '').trim();
|
|
834
|
+
if (!text) continue;
|
|
835
|
+
const lower = text.toLowerCase();
|
|
836
|
+
if (!keywords.some(k => lower.includes(k))) continue;
|
|
837
|
+
let href = el.getAttribute('href') || '';
|
|
838
|
+
let abs = href;
|
|
839
|
+
if (href) {
|
|
840
|
+
try {
|
|
841
|
+
abs = new URL(href, origin).href;
|
|
842
|
+
} catch (_) {}
|
|
843
|
+
}
|
|
844
|
+
candidates.push({ text, href, abs, tag: el.tagName, target: el.getAttribute('target') || null });
|
|
845
|
+
}
|
|
846
|
+
const seen = new Set();
|
|
847
|
+
const unique = [];
|
|
848
|
+
for (const c of candidates) {
|
|
849
|
+
const key = c.abs || c.text;
|
|
850
|
+
if (seen.has(key)) continue;
|
|
851
|
+
seen.add(key);
|
|
852
|
+
unique.push(c);
|
|
853
|
+
}
|
|
854
|
+
return unique;
|
|
855
|
+
}, { origin: baseOrigin });
|
|
856
|
+
|
|
857
|
+
discoverySignals.ctaCandidates = ctaCandidates;
|
|
858
|
+
|
|
859
|
+
if (ctaCandidates.length === 0) {
|
|
860
|
+
return {
|
|
861
|
+
outcome: 'NOT_APPLICABLE',
|
|
862
|
+
skipReason: 'No CTA elements detected',
|
|
863
|
+
steps,
|
|
864
|
+
startedAt: startedAt.toISOString(),
|
|
865
|
+
endedAt: new Date().toISOString(),
|
|
866
|
+
totalDurationMs: new Date() - startedAt,
|
|
867
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
868
|
+
error: null,
|
|
869
|
+
successReason: null,
|
|
870
|
+
validators: [],
|
|
871
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
872
|
+
discoverySignals
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const targets = ctaCandidates.slice(0, 2);
|
|
877
|
+
for (const target of targets) {
|
|
878
|
+
const start = Date.now();
|
|
879
|
+
let navResult = { target: target.abs || target.href, text: target.text, ok: false, status: null, finalUrl: null };
|
|
880
|
+
try {
|
|
881
|
+
const resp = await page.goto(target.abs || target.href || baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
882
|
+
navResult.status = resp ? resp.status() : null;
|
|
883
|
+
navResult.finalUrl = page.url();
|
|
884
|
+
navResult.ok = (navResult.status && navResult.status < 400) || (navResult.finalUrl && navResult.finalUrl !== baseUrl);
|
|
885
|
+
if ((target.abs || '').includes('github.com') && navResult.ok) {
|
|
886
|
+
discoverySignals.githubValidated = true;
|
|
887
|
+
}
|
|
888
|
+
} catch (err) {
|
|
889
|
+
navResult.error = err.message;
|
|
890
|
+
}
|
|
891
|
+
navResult.durationMs = Date.now() - start;
|
|
892
|
+
discoverySignals.navigationResults.push(navResult);
|
|
893
|
+
steps.push({
|
|
894
|
+
id: `cta_${target.text.toLowerCase().replace(/\s+/g, '_')}`,
|
|
895
|
+
type: 'navigate',
|
|
896
|
+
target: target.abs || target.href,
|
|
897
|
+
status: navResult.ok ? 'success' : 'failed',
|
|
898
|
+
error: navResult.ok ? null : navResult.error || 'Navigation failed',
|
|
899
|
+
startedAt: new Date(start).toISOString(),
|
|
900
|
+
endedAt: new Date().toISOString(),
|
|
901
|
+
retries: 0,
|
|
902
|
+
screenshots: []
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const executedOk = discoverySignals.navigationResults.some(r => r.ok);
|
|
907
|
+
const endedAt = new Date();
|
|
908
|
+
const totalDurationMs = endedAt - startedAt;
|
|
909
|
+
return {
|
|
910
|
+
outcome: executedOk ? 'SUCCESS' : 'FAILURE',
|
|
911
|
+
steps,
|
|
912
|
+
startedAt: startedAt.toISOString(),
|
|
913
|
+
endedAt: endedAt.toISOString(),
|
|
914
|
+
totalDurationMs,
|
|
915
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
916
|
+
error: executedOk ? null : 'CTA navigation did not succeed',
|
|
917
|
+
successReason: executedOk ? 'CTA navigation completed' : null,
|
|
918
|
+
validators: [],
|
|
919
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
920
|
+
discoverySignals
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async _runContactDiscoveryAttempt(page, baseUrl, artifactsDir, consoleErrors, pageErrors) {
|
|
925
|
+
const startedAt = new Date();
|
|
926
|
+
const steps = [];
|
|
927
|
+
const discoverySignals = {
|
|
928
|
+
mailto: null,
|
|
929
|
+
contactLinks: [],
|
|
930
|
+
navigationResults: [],
|
|
931
|
+
consoleErrorCount: consoleErrors.length,
|
|
932
|
+
pageErrorCount: pageErrors.length
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
936
|
+
steps.push({ id: 'navigate_home', type: 'navigate', target: baseUrl, status: 'success', startedAt: startedAt.toISOString(), endedAt: new Date().toISOString(), retries: 0, screenshots: [] });
|
|
937
|
+
|
|
938
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
939
|
+
const contactInfo = await page.evaluate(({ origin }) => {
|
|
940
|
+
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
941
|
+
const mailto = anchors.find(a => (a.getAttribute('href') || '').startsWith('mailto:'));
|
|
942
|
+
if (mailto) {
|
|
943
|
+
return { mailto: mailto.getAttribute('href'), contactLinks: [] };
|
|
944
|
+
}
|
|
945
|
+
const contactLinks = anchors
|
|
946
|
+
.filter(a => {
|
|
947
|
+
const href = a.getAttribute('href') || '';
|
|
948
|
+
const text = (a.textContent || '').toLowerCase();
|
|
949
|
+
return href.toLowerCase().includes('contact') || text.includes('contact');
|
|
950
|
+
})
|
|
951
|
+
.map(a => {
|
|
952
|
+
const href = a.getAttribute('href') || '';
|
|
953
|
+
let abs = href;
|
|
954
|
+
try { abs = new URL(href, origin).href; } catch (_) {}
|
|
955
|
+
return { href, abs, text: (a.textContent || '').trim() };
|
|
956
|
+
});
|
|
957
|
+
return { mailto: null, contactLinks };
|
|
958
|
+
}, { origin: baseOrigin });
|
|
959
|
+
|
|
960
|
+
discoverySignals.mailto = contactInfo.mailto;
|
|
961
|
+
discoverySignals.contactLinks = contactInfo.contactLinks;
|
|
962
|
+
|
|
963
|
+
if (contactInfo.mailto) {
|
|
964
|
+
return {
|
|
965
|
+
outcome: 'SUCCESS',
|
|
966
|
+
steps,
|
|
967
|
+
startedAt: startedAt.toISOString(),
|
|
968
|
+
endedAt: new Date().toISOString(),
|
|
969
|
+
totalDurationMs: new Date() - startedAt,
|
|
970
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
971
|
+
error: null,
|
|
972
|
+
successReason: `Found mailto: ${contactInfo.mailto}`,
|
|
973
|
+
validators: [],
|
|
974
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
975
|
+
discoverySignals
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (contactInfo.contactLinks.length === 0) {
|
|
980
|
+
return {
|
|
981
|
+
outcome: 'NOT_APPLICABLE',
|
|
982
|
+
skipReason: 'No contact link or mailto detected',
|
|
983
|
+
steps,
|
|
984
|
+
startedAt: startedAt.toISOString(),
|
|
985
|
+
endedAt: new Date().toISOString(),
|
|
986
|
+
totalDurationMs: new Date() - startedAt,
|
|
987
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
988
|
+
error: null,
|
|
989
|
+
successReason: null,
|
|
990
|
+
validators: [],
|
|
991
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
992
|
+
discoverySignals
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const target = contactInfo.contactLinks[0];
|
|
997
|
+
const startNav = Date.now();
|
|
998
|
+
let navResult = { target: target.abs || target.href, text: target.text, ok: false, status: null, finalUrl: null };
|
|
999
|
+
try {
|
|
1000
|
+
const resp = await page.goto(target.abs || target.href, { waitUntil: 'domcontentloaded', timeout: this.timeout });
|
|
1001
|
+
navResult.status = resp ? resp.status() : null;
|
|
1002
|
+
navResult.finalUrl = page.url();
|
|
1003
|
+
navResult.ok = (navResult.status && navResult.status < 400) || (navResult.finalUrl && navResult.finalUrl.includes('contact'));
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
navResult.error = err.message;
|
|
1006
|
+
}
|
|
1007
|
+
navResult.durationMs = Date.now() - startNav;
|
|
1008
|
+
discoverySignals.navigationResults.push(navResult);
|
|
1009
|
+
steps.push({ id: 'visit_contact', type: 'navigate', target: target.abs || target.href, status: navResult.ok ? 'success' : 'failed', error: navResult.error || null, startedAt: new Date(startNav).toISOString(), endedAt: new Date().toISOString(), retries: 0, screenshots: [] });
|
|
1010
|
+
|
|
1011
|
+
const endedAt = new Date();
|
|
1012
|
+
const totalDurationMs = endedAt - startedAt;
|
|
1013
|
+
return {
|
|
1014
|
+
outcome: navResult.ok ? 'SUCCESS' : 'FAILURE',
|
|
1015
|
+
steps,
|
|
1016
|
+
startedAt: startedAt.toISOString(),
|
|
1017
|
+
endedAt: endedAt.toISOString(),
|
|
1018
|
+
totalDurationMs,
|
|
1019
|
+
friction: { isFriction: false, signals: [], summary: null, reasons: [], thresholds: this.frictionThresholds, metrics: {} },
|
|
1020
|
+
error: navResult.ok ? null : 'Contact link navigation failed',
|
|
1021
|
+
successReason: navResult.ok ? 'Contact link reachable' : null,
|
|
1022
|
+
validators: [],
|
|
1023
|
+
softFailures: { hasSoftFailure: false, failureCount: 0, warnCount: 0 },
|
|
1024
|
+
discoverySignals
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
452
1027
|
}
|
|
453
1028
|
|
|
454
1029
|
module.exports = { AttemptEngine };
|