@odavl/guardian 0.2.0 → 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 +86 -2
- package/README.md +155 -97
- package/bin/guardian.js +1345 -60
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +21 -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 +568 -7
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +24 -0
- package/src/guardian/baseline.js +12 -4
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-output.js +4 -3
- package/src/guardian/cli-summary.js +79 -92
- package/src/guardian/config-loader.js +162 -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 +6 -1
- package/src/guardian/flag-validator.js +17 -3
- 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/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 +341 -81
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/preset-loader.js +9 -6
- package/src/guardian/reality.js +1278 -117
- 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/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-finder.js +2 -1
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +2 -2
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +46 -2
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/watch-runner.js +3 -7
- 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 = {}) {
|
|
@@ -44,6 +46,8 @@ class AttemptEngine {
|
|
|
44
46
|
const steps = [];
|
|
45
47
|
const frictionSignals = [];
|
|
46
48
|
const consoleMessages = []; // Capture console messages for validators
|
|
49
|
+
const consoleErrors = [];
|
|
50
|
+
const pageErrors = [];
|
|
47
51
|
let currentStep = null;
|
|
48
52
|
let lastError = null;
|
|
49
53
|
let frictionReasons = [];
|
|
@@ -56,11 +60,29 @@ class AttemptEngine {
|
|
|
56
60
|
text: msg.text(),
|
|
57
61
|
location: msg.location()
|
|
58
62
|
});
|
|
63
|
+
if (msg.type() === 'error') {
|
|
64
|
+
consoleErrors.push(msg.text());
|
|
65
|
+
}
|
|
59
66
|
};
|
|
60
67
|
|
|
61
68
|
page.on('console', consoleHandler);
|
|
69
|
+
const pageErrorHandler = (err) => {
|
|
70
|
+
pageErrors.push(err.message || 'page error');
|
|
71
|
+
};
|
|
72
|
+
page.on('pageerror', pageErrorHandler);
|
|
62
73
|
|
|
63
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
|
+
|
|
64
86
|
// Replace $BASEURL placeholder in all steps
|
|
65
87
|
const processedSteps = attemptDef.baseSteps.map(step => {
|
|
66
88
|
if (step.target && step.target === '$BASEURL') {
|
|
@@ -156,18 +178,42 @@ class AttemptEngine {
|
|
|
156
178
|
} catch (err) {
|
|
157
179
|
currentStep.endedAt = new Date().toISOString();
|
|
158
180
|
currentStep.durationMs = Date.now() - stepStartTime;
|
|
159
|
-
currentStep.status = stepDef.optional ? '
|
|
181
|
+
currentStep.status = stepDef.optional ? 'optional_failed' : 'failed';
|
|
160
182
|
currentStep.error = err.message;
|
|
161
183
|
|
|
162
184
|
if (stepDef.optional) {
|
|
163
|
-
// 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
|
+
}
|
|
164
210
|
steps.push(currentStep);
|
|
165
211
|
continue;
|
|
166
212
|
}
|
|
167
213
|
|
|
168
214
|
lastError = err;
|
|
169
215
|
|
|
170
|
-
// Capture screenshot on failure
|
|
216
|
+
// Capture screenshot and DOM on failure
|
|
171
217
|
if (artifactsDir) {
|
|
172
218
|
const screenshotPath = await this._captureScreenshot(
|
|
173
219
|
page,
|
|
@@ -177,6 +223,10 @@ class AttemptEngine {
|
|
|
177
223
|
if (screenshotPath) {
|
|
178
224
|
currentStep.screenshots.push(screenshotPath);
|
|
179
225
|
}
|
|
226
|
+
const domPath = await this._savePageContent(page, artifactsDir, `${stepDef.id}_failure`);
|
|
227
|
+
if (domPath) {
|
|
228
|
+
currentStep.domPath = domPath;
|
|
229
|
+
}
|
|
180
230
|
}
|
|
181
231
|
|
|
182
232
|
throw err; // Stop attempt on step failure
|
|
@@ -220,6 +270,7 @@ class AttemptEngine {
|
|
|
220
270
|
|
|
221
271
|
if (!successMet) {
|
|
222
272
|
page.removeListener('console', consoleHandler);
|
|
273
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
223
274
|
return {
|
|
224
275
|
outcome: 'FAILURE',
|
|
225
276
|
steps,
|
|
@@ -237,7 +288,11 @@ class AttemptEngine {
|
|
|
237
288
|
error: 'Success conditions not met after all steps completed',
|
|
238
289
|
successReason: null,
|
|
239
290
|
validators: [],
|
|
240
|
-
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
|
+
}
|
|
241
296
|
};
|
|
242
297
|
}
|
|
243
298
|
|
|
@@ -309,12 +364,17 @@ class AttemptEngine {
|
|
|
309
364
|
error: null,
|
|
310
365
|
successReason,
|
|
311
366
|
validators: validatorResults,
|
|
312
|
-
softFailures: softFailureAnalysis
|
|
367
|
+
softFailures: softFailureAnalysis,
|
|
368
|
+
discoverySignals: {
|
|
369
|
+
consoleErrorCount: consoleErrors.length,
|
|
370
|
+
pageErrorCount: pageErrors.length
|
|
371
|
+
}
|
|
313
372
|
};
|
|
314
373
|
|
|
315
374
|
} catch (err) {
|
|
316
375
|
const endedAt = new Date();
|
|
317
376
|
page.removeListener('console', consoleHandler);
|
|
377
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
318
378
|
return {
|
|
319
379
|
outcome: 'FAILURE',
|
|
320
380
|
steps,
|
|
@@ -330,10 +390,15 @@ class AttemptEngine {
|
|
|
330
390
|
error: `Step "${currentStep?.id}" failed: ${err.message}`,
|
|
331
391
|
successReason: null,
|
|
332
392
|
validators: [],
|
|
333
|
-
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
|
+
}
|
|
334
398
|
};
|
|
335
399
|
} finally {
|
|
336
400
|
page.removeListener('console', consoleHandler);
|
|
401
|
+
page.removeListener('pageerror', pageErrorHandler);
|
|
337
402
|
}
|
|
338
403
|
}
|
|
339
404
|
|
|
@@ -463,6 +528,502 @@ class AttemptEngine {
|
|
|
463
528
|
return null;
|
|
464
529
|
}
|
|
465
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
|
+
}
|
|
466
1027
|
}
|
|
467
1028
|
|
|
468
1029
|
module.exports = { AttemptEngine };
|